mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-19 02:34:37 -05:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
248b513fa4 | ||
|
|
ab48787422 | ||
|
|
91aa1f9a52 | ||
|
|
2979269ac3 | ||
|
|
cf28822a1c | ||
|
|
86ca984926 | ||
|
|
e3964624ac | ||
|
|
7c7c0fd955 | ||
|
|
e37b4a926d | ||
|
|
11f3a14c02 |
@@ -5532,3 +5532,18 @@ export function OnePasswordIcon(props: SVGProps<SVGSVGElement>) {
|
|||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function VercelIcon(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
{...props}
|
||||||
|
viewBox='0 0 256 222'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
preserveAspectRatio='xMidYMid'
|
||||||
|
>
|
||||||
|
<g transform='translate(19.2 16.63) scale(0.85)'>
|
||||||
|
<polygon fill='#fafafa' points='128 0 256 221.705007 0 221.705007' />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ import {
|
|||||||
TTSIcon,
|
TTSIcon,
|
||||||
TwilioIcon,
|
TwilioIcon,
|
||||||
TypeformIcon,
|
TypeformIcon,
|
||||||
|
VercelIcon,
|
||||||
VideoIcon,
|
VideoIcon,
|
||||||
WealthboxIcon,
|
WealthboxIcon,
|
||||||
WebflowIcon,
|
WebflowIcon,
|
||||||
@@ -262,6 +263,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
|||||||
twilio_sms: TwilioIcon,
|
twilio_sms: TwilioIcon,
|
||||||
twilio_voice: TwilioIcon,
|
twilio_voice: TwilioIcon,
|
||||||
typeform: TypeformIcon,
|
typeform: TypeformIcon,
|
||||||
|
vercel: VercelIcon,
|
||||||
video_generator_v2: VideoIcon,
|
video_generator_v2: VideoIcon,
|
||||||
vision_v2: EyeIcon,
|
vision_v2: EyeIcon,
|
||||||
wealthbox: WealthboxIcon,
|
wealthbox: WealthboxIcon,
|
||||||
|
|||||||
@@ -122,6 +122,7 @@
|
|||||||
"twilio_sms",
|
"twilio_sms",
|
||||||
"twilio_voice",
|
"twilio_voice",
|
||||||
"typeform",
|
"typeform",
|
||||||
|
"vercel",
|
||||||
"video_generator",
|
"video_generator",
|
||||||
"vision",
|
"vision",
|
||||||
"wealthbox",
|
"wealthbox",
|
||||||
|
|||||||
1391
apps/docs/content/docs/en/tools/vercel.mdx
Normal file
1391
apps/docs/content/docs/en/tools/vercel.mdx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,7 @@
|
|||||||
"fumadocs-mdx": "14.1.0",
|
"fumadocs-mdx": "14.1.0",
|
||||||
"fumadocs-ui": "16.2.3",
|
"fumadocs-ui": "16.2.3",
|
||||||
"lucide-react": "^0.511.0",
|
"lucide-react": "^0.511.0",
|
||||||
"next": "16.1.0-canary.21",
|
"next": "16.1.6",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"postgres": "^3.4.5",
|
"postgres": "^3.4.5",
|
||||||
"react": "19.2.1",
|
"react": "19.2.1",
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ vi.mock('@/lib/core/utils/urls', () => ({
|
|||||||
function setupAuthApiMocks(
|
function setupAuthApiMocks(
|
||||||
options: {
|
options: {
|
||||||
operations?: {
|
operations?: {
|
||||||
forgetPassword?: { success?: boolean; error?: string }
|
requestPasswordReset?: { success?: boolean; error?: string }
|
||||||
resetPassword?: { success?: boolean; error?: string }
|
resetPassword?: { success?: boolean; error?: string }
|
||||||
}
|
}
|
||||||
} = {}
|
} = {}
|
||||||
@@ -34,7 +34,11 @@ function setupAuthApiMocks(
|
|||||||
|
|
||||||
const { operations = {} } = options
|
const { operations = {} } = options
|
||||||
const defaultOperations = {
|
const defaultOperations = {
|
||||||
forgetPassword: { success: true, error: 'Forget password error', ...operations.forgetPassword },
|
requestPasswordReset: {
|
||||||
|
success: true,
|
||||||
|
error: 'Forget password error',
|
||||||
|
...operations.requestPasswordReset,
|
||||||
|
},
|
||||||
resetPassword: { success: true, error: 'Reset password error', ...operations.resetPassword },
|
resetPassword: { success: true, error: 'Reset password error', ...operations.resetPassword },
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +54,7 @@ function setupAuthApiMocks(
|
|||||||
vi.doMock('@/lib/auth', () => ({
|
vi.doMock('@/lib/auth', () => ({
|
||||||
auth: {
|
auth: {
|
||||||
api: {
|
api: {
|
||||||
forgetPassword: createAuthMethod(defaultOperations.forgetPassword),
|
requestPasswordReset: createAuthMethod(defaultOperations.requestPasswordReset),
|
||||||
resetPassword: createAuthMethod(defaultOperations.resetPassword),
|
resetPassword: createAuthMethod(defaultOperations.resetPassword),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -69,7 +73,7 @@ describe('Forget Password API Route', () => {
|
|||||||
it('should send password reset email successfully with same-origin redirectTo', async () => {
|
it('should send password reset email successfully with same-origin redirectTo', async () => {
|
||||||
setupAuthApiMocks({
|
setupAuthApiMocks({
|
||||||
operations: {
|
operations: {
|
||||||
forgetPassword: { success: true },
|
requestPasswordReset: { success: true },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -87,7 +91,7 @@ describe('Forget Password API Route', () => {
|
|||||||
expect(data.success).toBe(true)
|
expect(data.success).toBe(true)
|
||||||
|
|
||||||
const auth = await import('@/lib/auth')
|
const auth = await import('@/lib/auth')
|
||||||
expect(auth.auth.api.forgetPassword).toHaveBeenCalledWith({
|
expect(auth.auth.api.requestPasswordReset).toHaveBeenCalledWith({
|
||||||
body: {
|
body: {
|
||||||
email: 'test@example.com',
|
email: 'test@example.com',
|
||||||
redirectTo: 'https://app.example.com/reset',
|
redirectTo: 'https://app.example.com/reset',
|
||||||
@@ -99,7 +103,7 @@ describe('Forget Password API Route', () => {
|
|||||||
it('should reject external redirectTo URL', async () => {
|
it('should reject external redirectTo URL', async () => {
|
||||||
setupAuthApiMocks({
|
setupAuthApiMocks({
|
||||||
operations: {
|
operations: {
|
||||||
forgetPassword: { success: true },
|
requestPasswordReset: { success: true },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -117,13 +121,13 @@ describe('Forget Password API Route', () => {
|
|||||||
expect(data.message).toBe('Redirect URL must be a valid same-origin URL')
|
expect(data.message).toBe('Redirect URL must be a valid same-origin URL')
|
||||||
|
|
||||||
const auth = await import('@/lib/auth')
|
const auth = await import('@/lib/auth')
|
||||||
expect(auth.auth.api.forgetPassword).not.toHaveBeenCalled()
|
expect(auth.auth.api.requestPasswordReset).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should send password reset email without redirectTo', async () => {
|
it('should send password reset email without redirectTo', async () => {
|
||||||
setupAuthApiMocks({
|
setupAuthApiMocks({
|
||||||
operations: {
|
operations: {
|
||||||
forgetPassword: { success: true },
|
requestPasswordReset: { success: true },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -140,7 +144,7 @@ describe('Forget Password API Route', () => {
|
|||||||
expect(data.success).toBe(true)
|
expect(data.success).toBe(true)
|
||||||
|
|
||||||
const auth = await import('@/lib/auth')
|
const auth = await import('@/lib/auth')
|
||||||
expect(auth.auth.api.forgetPassword).toHaveBeenCalledWith({
|
expect(auth.auth.api.requestPasswordReset).toHaveBeenCalledWith({
|
||||||
body: {
|
body: {
|
||||||
email: 'test@example.com',
|
email: 'test@example.com',
|
||||||
redirectTo: undefined,
|
redirectTo: undefined,
|
||||||
@@ -163,7 +167,7 @@ describe('Forget Password API Route', () => {
|
|||||||
expect(data.message).toBe('Email is required')
|
expect(data.message).toBe('Email is required')
|
||||||
|
|
||||||
const auth = await import('@/lib/auth')
|
const auth = await import('@/lib/auth')
|
||||||
expect(auth.auth.api.forgetPassword).not.toHaveBeenCalled()
|
expect(auth.auth.api.requestPasswordReset).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle empty email', async () => {
|
it('should handle empty email', async () => {
|
||||||
@@ -182,7 +186,7 @@ describe('Forget Password API Route', () => {
|
|||||||
expect(data.message).toBe('Please provide a valid email address')
|
expect(data.message).toBe('Please provide a valid email address')
|
||||||
|
|
||||||
const auth = await import('@/lib/auth')
|
const auth = await import('@/lib/auth')
|
||||||
expect(auth.auth.api.forgetPassword).not.toHaveBeenCalled()
|
expect(auth.auth.api.requestPasswordReset).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle auth service error with message', async () => {
|
it('should handle auth service error with message', async () => {
|
||||||
@@ -190,7 +194,7 @@ describe('Forget Password API Route', () => {
|
|||||||
|
|
||||||
setupAuthApiMocks({
|
setupAuthApiMocks({
|
||||||
operations: {
|
operations: {
|
||||||
forgetPassword: {
|
requestPasswordReset: {
|
||||||
success: false,
|
success: false,
|
||||||
error: errorMessage,
|
error: errorMessage,
|
||||||
},
|
},
|
||||||
@@ -222,7 +226,7 @@ describe('Forget Password API Route', () => {
|
|||||||
vi.doMock('@/lib/auth', () => ({
|
vi.doMock('@/lib/auth', () => ({
|
||||||
auth: {
|
auth: {
|
||||||
api: {
|
api: {
|
||||||
forgetPassword: vi.fn().mockRejectedValue('Unknown error'),
|
requestPasswordReset: vi.fn().mockRejectedValue('Unknown error'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const { email, redirectTo } = validationResult.data
|
const { email, redirectTo } = validationResult.data
|
||||||
|
|
||||||
await auth.api.forgetPassword({
|
await auth.api.requestPasswordReset({
|
||||||
body: {
|
body: {
|
||||||
email,
|
email,
|
||||||
redirectTo,
|
redirectTo,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*
|
*
|
||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
import { createMockLogger, createMockRequest } from '@sim/testing'
|
import { auditMock, createMockLogger, createMockRequest } from '@sim/testing'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
describe('OAuth Disconnect API Route', () => {
|
describe('OAuth Disconnect API Route', () => {
|
||||||
@@ -67,6 +67,8 @@ describe('OAuth Disconnect API Route', () => {
|
|||||||
vi.doMock('@/lib/webhooks/utils.server', () => ({
|
vi.doMock('@/lib/webhooks/utils.server', () => ({
|
||||||
syncAllWebhooksForCredentialSet: mockSyncAllWebhooksForCredentialSet,
|
syncAllWebhooksForCredentialSet: mockSyncAllWebhooksForCredentialSet,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.doMock('@/lib/audit/log', () => auditMock)
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { and, eq, like, or } from 'drizzle-orm'
|
import { and, eq, like, or } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
|
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
|
||||||
@@ -118,6 +119,20 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: null,
|
||||||
|
actorId: session.user.id,
|
||||||
|
action: AuditAction.OAUTH_DISCONNECTED,
|
||||||
|
resourceType: AuditResourceType.OAUTH,
|
||||||
|
resourceId: providerId ?? provider,
|
||||||
|
actorName: session.user.name ?? undefined,
|
||||||
|
actorEmail: session.user.email ?? undefined,
|
||||||
|
resourceName: provider,
|
||||||
|
description: `Disconnected OAuth provider: ${provider}`,
|
||||||
|
metadata: { provider, providerId },
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({ success: true }, { status: 200 })
|
return NextResponse.json({ success: true }, { status: 200 })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`[${requestId}] Error disconnecting OAuth provider`, error)
|
logger.error(`[${requestId}] Error disconnecting OAuth provider`, error)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
function setupAuthApiMocks(
|
function setupAuthApiMocks(
|
||||||
options: {
|
options: {
|
||||||
operations?: {
|
operations?: {
|
||||||
forgetPassword?: { success?: boolean; error?: string }
|
requestPasswordReset?: { success?: boolean; error?: string }
|
||||||
resetPassword?: { success?: boolean; error?: string }
|
resetPassword?: { success?: boolean; error?: string }
|
||||||
}
|
}
|
||||||
} = {}
|
} = {}
|
||||||
@@ -30,7 +30,11 @@ function setupAuthApiMocks(
|
|||||||
|
|
||||||
const { operations = {} } = options
|
const { operations = {} } = options
|
||||||
const defaultOperations = {
|
const defaultOperations = {
|
||||||
forgetPassword: { success: true, error: 'Forget password error', ...operations.forgetPassword },
|
requestPasswordReset: {
|
||||||
|
success: true,
|
||||||
|
error: 'Forget password error',
|
||||||
|
...operations.requestPasswordReset,
|
||||||
|
},
|
||||||
resetPassword: { success: true, error: 'Reset password error', ...operations.resetPassword },
|
resetPassword: { success: true, error: 'Reset password error', ...operations.resetPassword },
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +50,7 @@ function setupAuthApiMocks(
|
|||||||
vi.doMock('@/lib/auth', () => ({
|
vi.doMock('@/lib/auth', () => ({
|
||||||
auth: {
|
auth: {
|
||||||
api: {
|
api: {
|
||||||
forgetPassword: createAuthMethod(defaultOperations.forgetPassword),
|
requestPasswordReset: createAuthMethod(defaultOperations.requestPasswordReset),
|
||||||
resetPassword: createAuthMethod(defaultOperations.resetPassword),
|
resetPassword: createAuthMethod(defaultOperations.resetPassword),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { getCreditBalance } from '@/lib/billing/credits/balance'
|
import { getCreditBalance } from '@/lib/billing/credits/balance'
|
||||||
import { purchaseCredits } from '@/lib/billing/credits/purchase'
|
import { purchaseCredits } from '@/lib/billing/credits/purchase'
|
||||||
@@ -57,6 +58,17 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: result.error }, { status: 400 })
|
return NextResponse.json({ error: result.error }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
actorId: session.user.id,
|
||||||
|
actorName: session.user.name,
|
||||||
|
actorEmail: session.user.email,
|
||||||
|
action: AuditAction.CREDIT_PURCHASED,
|
||||||
|
resourceType: AuditResourceType.BILLING,
|
||||||
|
description: `Purchased $${validation.data.amount} in credits`,
|
||||||
|
metadata: { amount: validation.data.amount, requestId: validation.data.requestId },
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to purchase credits', { error, userId: session.user.id })
|
logger.error('Failed to purchase credits', { error, userId: session.user.id })
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
*
|
*
|
||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
import { loggerMock } from '@sim/testing'
|
import { auditMock, loggerMock } from '@sim/testing'
|
||||||
import { NextRequest } from 'next/server'
|
import { NextRequest } from 'next/server'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('@/lib/audit/log', () => auditMock)
|
||||||
|
|
||||||
vi.mock('@/lib/core/config/feature-flags', () => ({
|
vi.mock('@/lib/core/config/feature-flags', () => ({
|
||||||
isDev: true,
|
isDev: true,
|
||||||
isHosted: false,
|
isHosted: false,
|
||||||
@@ -216,8 +218,11 @@ describe('Chat Edit API Route', () => {
|
|||||||
workflowId: 'workflow-123',
|
workflowId: 'workflow-123',
|
||||||
}
|
}
|
||||||
|
|
||||||
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
|
mockCheckChatAccess.mockResolvedValue({
|
||||||
mockLimit.mockResolvedValueOnce([]) // No identifier conflict
|
hasAccess: true,
|
||||||
|
chat: mockChat,
|
||||||
|
workspaceId: 'workspace-123',
|
||||||
|
})
|
||||||
|
|
||||||
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
|
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@@ -311,8 +316,11 @@ describe('Chat Edit API Route', () => {
|
|||||||
workflowId: 'workflow-123',
|
workflowId: 'workflow-123',
|
||||||
}
|
}
|
||||||
|
|
||||||
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
|
mockCheckChatAccess.mockResolvedValue({
|
||||||
mockLimit.mockResolvedValueOnce([])
|
hasAccess: true,
|
||||||
|
chat: mockChat,
|
||||||
|
workspaceId: 'workspace-123',
|
||||||
|
})
|
||||||
|
|
||||||
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
|
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@@ -371,8 +379,11 @@ describe('Chat Edit API Route', () => {
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mockCheckChatAccess.mockResolvedValue({ hasAccess: true })
|
mockCheckChatAccess.mockResolvedValue({
|
||||||
mockWhere.mockResolvedValue(undefined)
|
hasAccess: true,
|
||||||
|
chat: { title: 'Test Chat', workflowId: 'workflow-123' },
|
||||||
|
workspaceId: 'workspace-123',
|
||||||
|
})
|
||||||
|
|
||||||
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
|
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
@@ -393,8 +404,11 @@ describe('Chat Edit API Route', () => {
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mockCheckChatAccess.mockResolvedValue({ hasAccess: true })
|
mockCheckChatAccess.mockResolvedValue({
|
||||||
mockWhere.mockResolvedValue(undefined)
|
hasAccess: true,
|
||||||
|
chat: { title: 'Test Chat', workflowId: 'workflow-123' },
|
||||||
|
workspaceId: 'workspace-123',
|
||||||
|
})
|
||||||
|
|
||||||
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
|
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { eq } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm'
|
||||||
import type { NextRequest } from 'next/server'
|
import type { NextRequest } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { isDev } from '@/lib/core/config/feature-flags'
|
import { isDev } from '@/lib/core/config/feature-flags'
|
||||||
import { encryptSecret } from '@/lib/core/security/encryption'
|
import { encryptSecret } from '@/lib/core/security/encryption'
|
||||||
@@ -103,7 +104,11 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
|||||||
try {
|
try {
|
||||||
const validatedData = chatUpdateSchema.parse(body)
|
const validatedData = chatUpdateSchema.parse(body)
|
||||||
|
|
||||||
const { hasAccess, chat: existingChatRecord } = await checkChatAccess(chatId, session.user.id)
|
const {
|
||||||
|
hasAccess,
|
||||||
|
chat: existingChatRecord,
|
||||||
|
workspaceId: chatWorkspaceId,
|
||||||
|
} = await checkChatAccess(chatId, session.user.id)
|
||||||
|
|
||||||
if (!hasAccess || !existingChatRecord) {
|
if (!hasAccess || !existingChatRecord) {
|
||||||
return createErrorResponse('Chat not found or access denied', 404)
|
return createErrorResponse('Chat not found or access denied', 404)
|
||||||
@@ -217,6 +222,19 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
|||||||
|
|
||||||
logger.info(`Chat "${chatId}" updated successfully`)
|
logger.info(`Chat "${chatId}" updated successfully`)
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: chatWorkspaceId || null,
|
||||||
|
actorId: session.user.id,
|
||||||
|
actorName: session.user.name,
|
||||||
|
actorEmail: session.user.email,
|
||||||
|
action: AuditAction.CHAT_UPDATED,
|
||||||
|
resourceType: AuditResourceType.CHAT,
|
||||||
|
resourceId: chatId,
|
||||||
|
resourceName: title || existingChatRecord.title,
|
||||||
|
description: `Updated chat deployment "${title || existingChatRecord.title}"`,
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
return createSuccessResponse({
|
return createSuccessResponse({
|
||||||
id: chatId,
|
id: chatId,
|
||||||
chatUrl,
|
chatUrl,
|
||||||
@@ -252,7 +270,11 @@ export async function DELETE(
|
|||||||
return createErrorResponse('Unauthorized', 401)
|
return createErrorResponse('Unauthorized', 401)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { hasAccess } = await checkChatAccess(chatId, session.user.id)
|
const {
|
||||||
|
hasAccess,
|
||||||
|
chat: chatRecord,
|
||||||
|
workspaceId: chatWorkspaceId,
|
||||||
|
} = await checkChatAccess(chatId, session.user.id)
|
||||||
|
|
||||||
if (!hasAccess) {
|
if (!hasAccess) {
|
||||||
return createErrorResponse('Chat not found or access denied', 404)
|
return createErrorResponse('Chat not found or access denied', 404)
|
||||||
@@ -262,6 +284,19 @@ export async function DELETE(
|
|||||||
|
|
||||||
logger.info(`Chat "${chatId}" deleted successfully`)
|
logger.info(`Chat "${chatId}" deleted successfully`)
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: chatWorkspaceId || null,
|
||||||
|
actorId: session.user.id,
|
||||||
|
actorName: session.user.name,
|
||||||
|
actorEmail: session.user.email,
|
||||||
|
action: AuditAction.CHAT_DELETED,
|
||||||
|
resourceType: AuditResourceType.CHAT,
|
||||||
|
resourceId: chatId,
|
||||||
|
resourceName: chatRecord?.title || chatId,
|
||||||
|
description: `Deleted chat deployment "${chatRecord?.title || chatId}"`,
|
||||||
|
request: _request,
|
||||||
|
})
|
||||||
|
|
||||||
return createSuccessResponse({
|
return createSuccessResponse({
|
||||||
message: 'Chat deployment deleted successfully',
|
message: 'Chat deployment deleted successfully',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { NextRequest } from 'next/server'
|
|
||||||
/**
|
/**
|
||||||
* Tests for chat API route
|
* Tests for chat API route
|
||||||
*
|
*
|
||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
|
import { auditMock } from '@sim/testing'
|
||||||
|
import { NextRequest } from 'next/server'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
describe('Chat API Route', () => {
|
describe('Chat API Route', () => {
|
||||||
@@ -30,6 +31,8 @@ describe('Chat API Route', () => {
|
|||||||
mockInsert.mockReturnValue({ values: mockValues })
|
mockInsert.mockReturnValue({ values: mockValues })
|
||||||
mockValues.mockReturnValue({ returning: mockReturning })
|
mockValues.mockReturnValue({ returning: mockReturning })
|
||||||
|
|
||||||
|
vi.doMock('@/lib/audit/log', () => auditMock)
|
||||||
|
|
||||||
vi.doMock('@sim/db', () => ({
|
vi.doMock('@sim/db', () => ({
|
||||||
db: {
|
db: {
|
||||||
select: mockSelect,
|
select: mockSelect,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { eq } from 'drizzle-orm'
|
|||||||
import type { NextRequest } from 'next/server'
|
import type { NextRequest } from 'next/server'
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { isDev } from '@/lib/core/config/feature-flags'
|
import { isDev } from '@/lib/core/config/feature-flags'
|
||||||
import { encryptSecret } from '@/lib/core/security/encryption'
|
import { encryptSecret } from '@/lib/core/security/encryption'
|
||||||
@@ -42,7 +43,7 @@ const chatSchema = z.object({
|
|||||||
.default([]),
|
.default([]),
|
||||||
})
|
})
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(_request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const session = await getSession()
|
const session = await getSession()
|
||||||
|
|
||||||
@@ -174,7 +175,7 @@ export async function POST(request: NextRequest) {
|
|||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
identifier,
|
identifier,
|
||||||
title,
|
title,
|
||||||
description: description || '',
|
description: description || null,
|
||||||
customizations: mergedCustomizations,
|
customizations: mergedCustomizations,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
authType,
|
authType,
|
||||||
@@ -224,6 +225,20 @@ export async function POST(request: NextRequest) {
|
|||||||
// Silently fail
|
// Silently fail
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: workflowRecord.workspaceId || null,
|
||||||
|
actorId: session.user.id,
|
||||||
|
actorName: session.user.name,
|
||||||
|
actorEmail: session.user.email,
|
||||||
|
action: AuditAction.CHAT_DEPLOYED,
|
||||||
|
resourceType: AuditResourceType.CHAT,
|
||||||
|
resourceId: id,
|
||||||
|
resourceName: title,
|
||||||
|
description: `Deployed chat "${title}"`,
|
||||||
|
metadata: { workflowId, identifier, authType },
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
return createSuccessResponse({
|
return createSuccessResponse({
|
||||||
id,
|
id,
|
||||||
chatUrl,
|
chatUrl,
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export async function checkWorkflowAccessForChatCreation(
|
|||||||
export async function checkChatAccess(
|
export async function checkChatAccess(
|
||||||
chatId: string,
|
chatId: string,
|
||||||
userId: string
|
userId: string
|
||||||
): Promise<{ hasAccess: boolean; chat?: any }> {
|
): Promise<{ hasAccess: boolean; chat?: any; workspaceId?: string }> {
|
||||||
const chatData = await db
|
const chatData = await db
|
||||||
.select({
|
.select({
|
||||||
chat: chat,
|
chat: chat,
|
||||||
@@ -78,7 +78,9 @@ export async function checkChatAccess(
|
|||||||
action: 'admin',
|
action: 'admin',
|
||||||
})
|
})
|
||||||
|
|
||||||
return authorization.allowed ? { hasAccess: true, chat: chatRecord } : { hasAccess: false }
|
return authorization.allowed
|
||||||
|
? { hasAccess: true, chat: chatRecord, workspaceId: workflowWorkspaceId }
|
||||||
|
: { hasAccess: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function validateChatAuth(
|
export async function validateChatAuth(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { and, eq } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { getEmailSubject, renderPollingGroupInvitationEmail } from '@/components/emails'
|
import { getEmailSubject, renderPollingGroupInvitationEmail } from '@/components/emails'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { hasCredentialSetsAccess } from '@/lib/billing'
|
import { hasCredentialSetsAccess } from '@/lib/billing'
|
||||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||||
@@ -148,6 +149,19 @@ export async function POST(
|
|||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
actorId: session.user.id,
|
||||||
|
actorName: session.user.name,
|
||||||
|
actorEmail: session.user.email,
|
||||||
|
action: AuditAction.CREDENTIAL_SET_INVITATION_RESENT,
|
||||||
|
resourceType: AuditResourceType.CREDENTIAL_SET,
|
||||||
|
resourceId: id,
|
||||||
|
resourceName: result.set.name,
|
||||||
|
description: `Resent credential set invitation to ${invitation.email}`,
|
||||||
|
metadata: { invitationId, email: invitation.email },
|
||||||
|
request: req,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error resending invitation', error)
|
logger.error('Error resending invitation', error)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm'
|
|||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { getEmailSubject, renderPollingGroupInvitationEmail } from '@/components/emails'
|
import { getEmailSubject, renderPollingGroupInvitationEmail } from '@/components/emails'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { hasCredentialSetsAccess } from '@/lib/billing'
|
import { hasCredentialSetsAccess } from '@/lib/billing'
|
||||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||||
@@ -175,6 +176,19 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
emailSent: !!email,
|
emailSent: !!email,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: null,
|
||||||
|
actorId: session.user.id,
|
||||||
|
action: AuditAction.CREDENTIAL_SET_INVITATION_CREATED,
|
||||||
|
resourceType: AuditResourceType.CREDENTIAL_SET,
|
||||||
|
resourceId: id,
|
||||||
|
actorName: session.user.name ?? undefined,
|
||||||
|
actorEmail: session.user.email ?? undefined,
|
||||||
|
resourceName: result.set.name,
|
||||||
|
description: `Created invitation for credential set "${result.set.name}"${email ? ` to ${email}` : ''}`,
|
||||||
|
request: req,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
invitation: {
|
invitation: {
|
||||||
...invitation,
|
...invitation,
|
||||||
@@ -235,6 +249,19 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: null,
|
||||||
|
actorId: session.user.id,
|
||||||
|
action: AuditAction.CREDENTIAL_SET_INVITATION_REVOKED,
|
||||||
|
resourceType: AuditResourceType.CREDENTIAL_SET,
|
||||||
|
resourceId: id,
|
||||||
|
actorName: session.user.name ?? undefined,
|
||||||
|
actorEmail: session.user.email ?? undefined,
|
||||||
|
resourceName: result.set.name,
|
||||||
|
description: `Revoked invitation "${invitationId}" for credential set "${result.set.name}"`,
|
||||||
|
request: req,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error cancelling invitation', error)
|
logger.error('Error cancelling invitation', error)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { account, credentialSet, credentialSetMember, member, user } from '@sim/
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { and, eq, inArray } from 'drizzle-orm'
|
import { and, eq, inArray } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { hasCredentialSetsAccess } from '@/lib/billing'
|
import { hasCredentialSetsAccess } from '@/lib/billing'
|
||||||
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
|
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
|
||||||
@@ -13,6 +14,7 @@ async function getCredentialSetWithAccess(credentialSetId: string, userId: strin
|
|||||||
const [set] = await db
|
const [set] = await db
|
||||||
.select({
|
.select({
|
||||||
id: credentialSet.id,
|
id: credentialSet.id,
|
||||||
|
name: credentialSet.name,
|
||||||
organizationId: credentialSet.organizationId,
|
organizationId: credentialSet.organizationId,
|
||||||
providerId: credentialSet.providerId,
|
providerId: credentialSet.providerId,
|
||||||
})
|
})
|
||||||
@@ -177,6 +179,19 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
|
|||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: null,
|
||||||
|
actorId: session.user.id,
|
||||||
|
action: AuditAction.CREDENTIAL_SET_MEMBER_REMOVED,
|
||||||
|
resourceType: AuditResourceType.CREDENTIAL_SET,
|
||||||
|
resourceId: id,
|
||||||
|
actorName: session.user.name ?? undefined,
|
||||||
|
actorEmail: session.user.email ?? undefined,
|
||||||
|
resourceName: result.set.name,
|
||||||
|
description: `Removed member from credential set "${result.set.name}"`,
|
||||||
|
request: req,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error removing member from credential set', error)
|
logger.error('Error removing member from credential set', error)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { and, eq } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { hasCredentialSetsAccess } from '@/lib/billing'
|
import { hasCredentialSetsAccess } from '@/lib/billing'
|
||||||
|
|
||||||
@@ -131,6 +132,19 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
|
|
||||||
const [updated] = await db.select().from(credentialSet).where(eq(credentialSet.id, id)).limit(1)
|
const [updated] = await db.select().from(credentialSet).where(eq(credentialSet.id, id)).limit(1)
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: null,
|
||||||
|
actorId: session.user.id,
|
||||||
|
action: AuditAction.CREDENTIAL_SET_UPDATED,
|
||||||
|
resourceType: AuditResourceType.CREDENTIAL_SET,
|
||||||
|
resourceId: id,
|
||||||
|
actorName: session.user.name ?? undefined,
|
||||||
|
actorEmail: session.user.email ?? undefined,
|
||||||
|
resourceName: updated?.name ?? result.set.name,
|
||||||
|
description: `Updated credential set "${updated?.name ?? result.set.name}"`,
|
||||||
|
request: req,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({ credentialSet: updated })
|
return NextResponse.json({ credentialSet: updated })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
@@ -175,6 +189,19 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
|
|||||||
|
|
||||||
logger.info('Deleted credential set', { credentialSetId: id, userId: session.user.id })
|
logger.info('Deleted credential set', { credentialSetId: id, userId: session.user.id })
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: null,
|
||||||
|
actorId: session.user.id,
|
||||||
|
action: AuditAction.CREDENTIAL_SET_DELETED,
|
||||||
|
resourceType: AuditResourceType.CREDENTIAL_SET,
|
||||||
|
resourceId: id,
|
||||||
|
actorName: session.user.name ?? undefined,
|
||||||
|
actorEmail: session.user.email ?? undefined,
|
||||||
|
resourceName: result.set.name,
|
||||||
|
description: `Deleted credential set "${result.set.name}"`,
|
||||||
|
request: req,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error deleting credential set', error)
|
logger.error('Error deleting credential set', error)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { and, eq } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
|
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
|
||||||
|
|
||||||
@@ -78,6 +79,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ tok
|
|||||||
status: credentialSetInvitation.status,
|
status: credentialSetInvitation.status,
|
||||||
expiresAt: credentialSetInvitation.expiresAt,
|
expiresAt: credentialSetInvitation.expiresAt,
|
||||||
invitedBy: credentialSetInvitation.invitedBy,
|
invitedBy: credentialSetInvitation.invitedBy,
|
||||||
|
credentialSetName: credentialSet.name,
|
||||||
providerId: credentialSet.providerId,
|
providerId: credentialSet.providerId,
|
||||||
})
|
})
|
||||||
.from(credentialSetInvitation)
|
.from(credentialSetInvitation)
|
||||||
@@ -125,7 +127,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ tok
|
|||||||
const now = new Date()
|
const now = new Date()
|
||||||
const requestId = crypto.randomUUID().slice(0, 8)
|
const requestId = crypto.randomUUID().slice(0, 8)
|
||||||
|
|
||||||
// Use transaction to ensure membership + invitation update + webhook sync are atomic
|
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
await tx.insert(credentialSetMember).values({
|
await tx.insert(credentialSetMember).values({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
@@ -147,8 +148,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ tok
|
|||||||
})
|
})
|
||||||
.where(eq(credentialSetInvitation.id, invitation.id))
|
.where(eq(credentialSetInvitation.id, invitation.id))
|
||||||
|
|
||||||
// Clean up all other pending invitations for the same credential set and email
|
|
||||||
// This prevents duplicate invites from showing up after accepting one
|
|
||||||
if (invitation.email) {
|
if (invitation.email) {
|
||||||
await tx
|
await tx
|
||||||
.update(credentialSetInvitation)
|
.update(credentialSetInvitation)
|
||||||
@@ -166,7 +165,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ tok
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync webhooks within the transaction
|
|
||||||
const syncResult = await syncAllWebhooksForCredentialSet(
|
const syncResult = await syncAllWebhooksForCredentialSet(
|
||||||
invitation.credentialSetId,
|
invitation.credentialSetId,
|
||||||
requestId,
|
requestId,
|
||||||
@@ -184,6 +182,19 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ tok
|
|||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
actorId: session.user.id,
|
||||||
|
actorName: session.user.name,
|
||||||
|
actorEmail: session.user.email,
|
||||||
|
action: AuditAction.CREDENTIAL_SET_INVITATION_ACCEPTED,
|
||||||
|
resourceType: AuditResourceType.CREDENTIAL_SET,
|
||||||
|
resourceId: invitation.credentialSetId,
|
||||||
|
resourceName: invitation.credentialSetName,
|
||||||
|
description: `Accepted credential set invitation`,
|
||||||
|
metadata: { invitationId: invitation.id },
|
||||||
|
request: req,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
credentialSetId: invitation.credentialSetId,
|
credentialSetId: invitation.credentialSetId,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { credentialSet, credentialSetMember, organization } from '@sim/db/schema
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { and, eq } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
|
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
|
||||||
|
|
||||||
@@ -106,6 +107,17 @@ export async function DELETE(req: NextRequest) {
|
|||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
actorId: session.user.id,
|
||||||
|
actorName: session.user.name,
|
||||||
|
actorEmail: session.user.email,
|
||||||
|
action: AuditAction.CREDENTIAL_SET_MEMBER_LEFT,
|
||||||
|
resourceType: AuditResourceType.CREDENTIAL_SET,
|
||||||
|
resourceId: credentialSetId,
|
||||||
|
description: `Left credential set`,
|
||||||
|
request: req,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Failed to leave credential set'
|
const message = error instanceof Error ? error.message : 'Failed to leave credential set'
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { and, count, desc, eq } from 'drizzle-orm'
|
import { and, count, desc, eq } from 'drizzle-orm'
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { hasCredentialSetsAccess } from '@/lib/billing'
|
import { hasCredentialSetsAccess } from '@/lib/billing'
|
||||||
|
|
||||||
@@ -165,6 +166,19 @@ export async function POST(req: Request) {
|
|||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: null,
|
||||||
|
actorId: session.user.id,
|
||||||
|
action: AuditAction.CREDENTIAL_SET_CREATED,
|
||||||
|
resourceType: AuditResourceType.CREDENTIAL_SET,
|
||||||
|
resourceId: newCredentialSet.id,
|
||||||
|
actorName: session.user.name ?? undefined,
|
||||||
|
actorEmail: session.user.email ?? undefined,
|
||||||
|
resourceName: name,
|
||||||
|
description: `Created credential set "${name}"`,
|
||||||
|
request: req,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({ credentialSet: newCredentialSet }, { status: 201 })
|
return NextResponse.json({ credentialSet: newCredentialSet }, { status: 201 })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { eq } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
|
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
@@ -53,6 +54,17 @@ export async function POST(req: NextRequest) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
actorId: session.user.id,
|
||||||
|
actorName: session.user.name,
|
||||||
|
actorEmail: session.user.email,
|
||||||
|
action: AuditAction.ENVIRONMENT_UPDATED,
|
||||||
|
resourceType: AuditResourceType.ENVIRONMENT,
|
||||||
|
description: 'Updated global environment variables',
|
||||||
|
metadata: { variableCount: Object.keys(variables).length },
|
||||||
|
request: req,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (validationError) {
|
} catch (validationError) {
|
||||||
if (validationError instanceof z.ZodError) {
|
if (validationError instanceof z.ZodError) {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { db } from '@sim/db'
|
import { db } from '@sim/db'
|
||||||
import { workflow, workflowFolder } from '@sim/db/schema'
|
import { workflow, workflowFolder } from '@sim/db/schema'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { and, eq } from 'drizzle-orm'
|
import { and, eq, isNull, min } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate'
|
import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate'
|
||||||
@@ -36,7 +37,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
|
|
||||||
logger.info(`[${requestId}] Duplicating folder ${sourceFolderId} for user ${session.user.id}`)
|
logger.info(`[${requestId}] Duplicating folder ${sourceFolderId} for user ${session.user.id}`)
|
||||||
|
|
||||||
// Verify the source folder exists
|
|
||||||
const sourceFolder = await db
|
const sourceFolder = await db
|
||||||
.select()
|
.select()
|
||||||
.from(workflowFolder)
|
.from(workflowFolder)
|
||||||
@@ -47,7 +47,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
throw new Error('Source folder not found')
|
throw new Error('Source folder not found')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user has permission to access the source folder
|
|
||||||
const userPermission = await getUserEntityPermissions(
|
const userPermission = await getUserEntityPermissions(
|
||||||
session.user.id,
|
session.user.id,
|
||||||
'workspace',
|
'workspace',
|
||||||
@@ -60,26 +59,51 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
|
|
||||||
const targetWorkspaceId = workspaceId || sourceFolder.workspaceId
|
const targetWorkspaceId = workspaceId || sourceFolder.workspaceId
|
||||||
|
|
||||||
// Step 1: Duplicate folder structure
|
|
||||||
const { newFolderId, folderMapping } = await db.transaction(async (tx) => {
|
const { newFolderId, folderMapping } = await db.transaction(async (tx) => {
|
||||||
const newFolderId = crypto.randomUUID()
|
const newFolderId = crypto.randomUUID()
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
const targetParentId = parentId ?? sourceFolder.parentId
|
||||||
|
|
||||||
|
const folderParentCondition = targetParentId
|
||||||
|
? eq(workflowFolder.parentId, targetParentId)
|
||||||
|
: isNull(workflowFolder.parentId)
|
||||||
|
const workflowParentCondition = targetParentId
|
||||||
|
? eq(workflow.folderId, targetParentId)
|
||||||
|
: isNull(workflow.folderId)
|
||||||
|
|
||||||
|
const [[folderResult], [workflowResult]] = await Promise.all([
|
||||||
|
tx
|
||||||
|
.select({ minSortOrder: min(workflowFolder.sortOrder) })
|
||||||
|
.from(workflowFolder)
|
||||||
|
.where(and(eq(workflowFolder.workspaceId, targetWorkspaceId), folderParentCondition)),
|
||||||
|
tx
|
||||||
|
.select({ minSortOrder: min(workflow.sortOrder) })
|
||||||
|
.from(workflow)
|
||||||
|
.where(and(eq(workflow.workspaceId, targetWorkspaceId), workflowParentCondition)),
|
||||||
|
])
|
||||||
|
|
||||||
|
const minSortOrder = [folderResult?.minSortOrder, workflowResult?.minSortOrder].reduce<
|
||||||
|
number | null
|
||||||
|
>((currentMin, candidate) => {
|
||||||
|
if (candidate == null) return currentMin
|
||||||
|
if (currentMin == null) return candidate
|
||||||
|
return Math.min(currentMin, candidate)
|
||||||
|
}, null)
|
||||||
|
const sortOrder = minSortOrder != null ? minSortOrder - 1 : 0
|
||||||
|
|
||||||
// Create the new root folder
|
|
||||||
await tx.insert(workflowFolder).values({
|
await tx.insert(workflowFolder).values({
|
||||||
id: newFolderId,
|
id: newFolderId,
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
workspaceId: targetWorkspaceId,
|
workspaceId: targetWorkspaceId,
|
||||||
name,
|
name,
|
||||||
color: color || sourceFolder.color,
|
color: color || sourceFolder.color,
|
||||||
parentId: parentId || sourceFolder.parentId,
|
parentId: targetParentId,
|
||||||
sortOrder: sourceFolder.sortOrder,
|
sortOrder,
|
||||||
isExpanded: false,
|
isExpanded: false,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Recursively duplicate child folders
|
|
||||||
const folderMapping = new Map<string, string>([[sourceFolderId, newFolderId]])
|
const folderMapping = new Map<string, string>([[sourceFolderId, newFolderId]])
|
||||||
await duplicateFolderStructure(
|
await duplicateFolderStructure(
|
||||||
tx,
|
tx,
|
||||||
@@ -95,7 +119,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
return { newFolderId, folderMapping }
|
return { newFolderId, folderMapping }
|
||||||
})
|
})
|
||||||
|
|
||||||
// Step 2: Duplicate workflows
|
|
||||||
const workflowStats = await duplicateWorkflowsInFolderTree(
|
const workflowStats = await duplicateWorkflowsInFolderTree(
|
||||||
sourceFolder.workspaceId,
|
sourceFolder.workspaceId,
|
||||||
targetWorkspaceId,
|
targetWorkspaceId,
|
||||||
@@ -115,6 +138,19 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: targetWorkspaceId,
|
||||||
|
actorId: session.user.id,
|
||||||
|
action: AuditAction.FOLDER_DUPLICATED,
|
||||||
|
resourceType: AuditResourceType.FOLDER,
|
||||||
|
resourceId: newFolderId,
|
||||||
|
actorName: session.user.name ?? undefined,
|
||||||
|
actorEmail: session.user.email ?? undefined,
|
||||||
|
resourceName: name,
|
||||||
|
description: `Duplicated folder "${sourceFolder.name}" as "${name}"`,
|
||||||
|
request: req,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
id: newFolderId,
|
id: newFolderId,
|
||||||
@@ -159,7 +195,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to recursively duplicate folder structure
|
|
||||||
async function duplicateFolderStructure(
|
async function duplicateFolderStructure(
|
||||||
tx: any,
|
tx: any,
|
||||||
sourceFolderId: string,
|
sourceFolderId: string,
|
||||||
@@ -170,7 +205,6 @@ async function duplicateFolderStructure(
|
|||||||
timestamp: Date,
|
timestamp: Date,
|
||||||
folderMapping: Map<string, string>
|
folderMapping: Map<string, string>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Get all child folders
|
|
||||||
const childFolders = await tx
|
const childFolders = await tx
|
||||||
.select()
|
.select()
|
||||||
.from(workflowFolder)
|
.from(workflowFolder)
|
||||||
@@ -181,7 +215,6 @@ async function duplicateFolderStructure(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Create each child folder and recurse
|
|
||||||
for (const childFolder of childFolders) {
|
for (const childFolder of childFolders) {
|
||||||
const newChildFolderId = crypto.randomUUID()
|
const newChildFolderId = crypto.randomUUID()
|
||||||
folderMapping.set(childFolder.id, newChildFolderId)
|
folderMapping.set(childFolder.id, newChildFolderId)
|
||||||
@@ -199,7 +232,6 @@ async function duplicateFolderStructure(
|
|||||||
updatedAt: timestamp,
|
updatedAt: timestamp,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Recurse for this child's children
|
|
||||||
await duplicateFolderStructure(
|
await duplicateFolderStructure(
|
||||||
tx,
|
tx,
|
||||||
childFolder.id,
|
childFolder.id,
|
||||||
@@ -213,7 +245,6 @@ async function duplicateFolderStructure(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to duplicate all workflows in a folder tree
|
|
||||||
async function duplicateWorkflowsInFolderTree(
|
async function duplicateWorkflowsInFolderTree(
|
||||||
sourceWorkspaceId: string,
|
sourceWorkspaceId: string,
|
||||||
targetWorkspaceId: string,
|
targetWorkspaceId: string,
|
||||||
@@ -223,9 +254,7 @@ async function duplicateWorkflowsInFolderTree(
|
|||||||
): Promise<{ total: number; succeeded: number; failed: number }> {
|
): Promise<{ total: number; succeeded: number; failed: number }> {
|
||||||
const stats = { total: 0, succeeded: 0, failed: 0 }
|
const stats = { total: 0, succeeded: 0, failed: 0 }
|
||||||
|
|
||||||
// Process each folder in the mapping
|
|
||||||
for (const [oldFolderId, newFolderId] of folderMapping.entries()) {
|
for (const [oldFolderId, newFolderId] of folderMapping.entries()) {
|
||||||
// Get workflows in this folder
|
|
||||||
const workflowsInFolder = await db
|
const workflowsInFolder = await db
|
||||||
.select()
|
.select()
|
||||||
.from(workflow)
|
.from(workflow)
|
||||||
@@ -233,7 +262,6 @@ async function duplicateWorkflowsInFolderTree(
|
|||||||
|
|
||||||
stats.total += workflowsInFolder.length
|
stats.total += workflowsInFolder.length
|
||||||
|
|
||||||
// Duplicate each workflow
|
|
||||||
for (const sourceWorkflow of workflowsInFolder) {
|
for (const sourceWorkflow of workflowsInFolder) {
|
||||||
try {
|
try {
|
||||||
await duplicateWorkflow({
|
await duplicateWorkflow({
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
|
auditMock,
|
||||||
createMockRequest,
|
createMockRequest,
|
||||||
type MockUser,
|
type MockUser,
|
||||||
mockAuth,
|
mockAuth,
|
||||||
@@ -12,6 +13,8 @@ import {
|
|||||||
} from '@sim/testing'
|
} from '@sim/testing'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('@/lib/audit/log', () => auditMock)
|
||||||
|
|
||||||
/** Type for captured folder values in tests */
|
/** Type for captured folder values in tests */
|
||||||
interface CapturedFolderValues {
|
interface CapturedFolderValues {
|
||||||
name?: string
|
name?: string
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { and, eq } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||||
|
|
||||||
@@ -167,6 +168,19 @@ export async function DELETE(
|
|||||||
deletionStats,
|
deletionStats,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: existingFolder.workspaceId,
|
||||||
|
actorId: session.user.id,
|
||||||
|
actorName: session.user.name,
|
||||||
|
actorEmail: session.user.email,
|
||||||
|
action: AuditAction.FOLDER_DELETED,
|
||||||
|
resourceType: AuditResourceType.FOLDER,
|
||||||
|
resourceId: id,
|
||||||
|
resourceName: existingFolder.name,
|
||||||
|
description: `Deleted folder "${existingFolder.name}"`,
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
deletedItems: deletionStats,
|
deletedItems: deletionStats,
|
||||||
|
|||||||
@@ -3,9 +3,22 @@
|
|||||||
*
|
*
|
||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
import { createMockRequest, mockAuth, mockConsoleLogger, setupCommonApiMocks } from '@sim/testing'
|
import {
|
||||||
|
auditMock,
|
||||||
|
createMockRequest,
|
||||||
|
mockAuth,
|
||||||
|
mockConsoleLogger,
|
||||||
|
setupCommonApiMocks,
|
||||||
|
} from '@sim/testing'
|
||||||
|
import { drizzleOrmMock } from '@sim/testing/mocks'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('@/lib/audit/log', () => auditMock)
|
||||||
|
vi.mock('drizzle-orm', () => ({
|
||||||
|
...drizzleOrmMock,
|
||||||
|
min: vi.fn((field) => ({ type: 'min', field })),
|
||||||
|
}))
|
||||||
|
|
||||||
interface CapturedFolderValues {
|
interface CapturedFolderValues {
|
||||||
name?: string
|
name?: string
|
||||||
color?: string
|
color?: string
|
||||||
@@ -16,29 +29,35 @@ interface CapturedFolderValues {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createMockTransaction(mockData: {
|
function createMockTransaction(mockData: {
|
||||||
selectData?: Array<{ id: string; [key: string]: unknown }>
|
selectResults?: Array<Array<{ [key: string]: unknown }>>
|
||||||
insertResult?: Array<{ id: string; [key: string]: unknown }>
|
insertResult?: Array<{ id: string; [key: string]: unknown }>
|
||||||
|
onInsertValues?: (values: CapturedFolderValues) => void
|
||||||
}) {
|
}) {
|
||||||
const { selectData = [], insertResult = [] } = mockData
|
const { selectResults = [[], []], insertResult = [], onInsertValues } = mockData
|
||||||
return vi.fn().mockImplementation(async (callback: (tx: unknown) => Promise<unknown>) => {
|
return async (callback: (tx: unknown) => Promise<unknown>) => {
|
||||||
|
const where = vi.fn()
|
||||||
|
for (const result of selectResults) {
|
||||||
|
where.mockReturnValueOnce(result)
|
||||||
|
}
|
||||||
|
where.mockReturnValue([])
|
||||||
|
|
||||||
const tx = {
|
const tx = {
|
||||||
select: vi.fn().mockReturnValue({
|
select: vi.fn().mockReturnValue({
|
||||||
from: vi.fn().mockReturnValue({
|
from: vi.fn().mockReturnValue({
|
||||||
where: vi.fn().mockReturnValue({
|
where,
|
||||||
orderBy: vi.fn().mockReturnValue({
|
|
||||||
limit: vi.fn().mockReturnValue(selectData),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
insert: vi.fn().mockReturnValue({
|
insert: vi.fn().mockReturnValue({
|
||||||
values: vi.fn().mockReturnValue({
|
values: vi.fn().mockImplementation((values: CapturedFolderValues) => {
|
||||||
returning: vi.fn().mockReturnValue(insertResult),
|
onInsertValues?.(values)
|
||||||
|
return {
|
||||||
|
returning: vi.fn().mockReturnValue(insertResult),
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
return await callback(tx)
|
return await callback(tx)
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('Folders API Route', () => {
|
describe('Folders API Route', () => {
|
||||||
@@ -249,25 +268,12 @@ describe('Folders API Route', () => {
|
|||||||
it('should create a new folder successfully', async () => {
|
it('should create a new folder successfully', async () => {
|
||||||
mockAuthenticatedUser()
|
mockAuthenticatedUser()
|
||||||
|
|
||||||
mockTransaction.mockImplementationOnce(async (callback: any) => {
|
mockTransaction.mockImplementationOnce(
|
||||||
const tx = {
|
createMockTransaction({
|
||||||
select: vi.fn().mockReturnValue({
|
selectResults: [[], []],
|
||||||
from: vi.fn().mockReturnValue({
|
insertResult: [mockFolders[0]],
|
||||||
where: vi.fn().mockReturnValue({
|
})
|
||||||
orderBy: vi.fn().mockReturnValue({
|
)
|
||||||
limit: vi.fn().mockReturnValue([]), // No existing folders
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
insert: vi.fn().mockReturnValue({
|
|
||||||
values: vi.fn().mockReturnValue({
|
|
||||||
returning: vi.fn().mockReturnValue([mockFolders[0]]),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
return await callback(tx)
|
|
||||||
})
|
|
||||||
|
|
||||||
const req = createMockRequest('POST', {
|
const req = createMockRequest('POST', {
|
||||||
name: 'New Test Folder',
|
name: 'New Test Folder',
|
||||||
@@ -277,12 +283,11 @@ describe('Folders API Route', () => {
|
|||||||
|
|
||||||
const { POST } = await import('@/app/api/folders/route')
|
const { POST } = await import('@/app/api/folders/route')
|
||||||
const response = await POST(req)
|
const response = await POST(req)
|
||||||
|
const responseBody = await response.json()
|
||||||
|
|
||||||
expect(response.status).toBe(200)
|
expect(response.status).toBe(200)
|
||||||
|
expect(responseBody).toHaveProperty('folder')
|
||||||
const data = await response.json()
|
expect(responseBody.folder).toMatchObject({
|
||||||
expect(data).toHaveProperty('folder')
|
|
||||||
expect(data.folder).toMatchObject({
|
|
||||||
id: 'folder-1',
|
id: 'folder-1',
|
||||||
name: 'Test Folder 1',
|
name: 'Test Folder 1',
|
||||||
workspaceId: 'workspace-123',
|
workspaceId: 'workspace-123',
|
||||||
@@ -291,26 +296,17 @@ describe('Folders API Route', () => {
|
|||||||
|
|
||||||
it('should create folder with correct sort order', async () => {
|
it('should create folder with correct sort order', async () => {
|
||||||
mockAuthenticatedUser()
|
mockAuthenticatedUser()
|
||||||
|
let capturedValues: CapturedFolderValues | null = null
|
||||||
|
|
||||||
mockTransaction.mockImplementationOnce(async (callback: any) => {
|
mockTransaction.mockImplementationOnce(
|
||||||
const tx = {
|
createMockTransaction({
|
||||||
select: vi.fn().mockReturnValue({
|
selectResults: [[{ minSortOrder: 5 }], [{ minSortOrder: 2 }]],
|
||||||
from: vi.fn().mockReturnValue({
|
insertResult: [{ ...mockFolders[0], sortOrder: 1 }],
|
||||||
where: vi.fn().mockReturnValue({
|
onInsertValues: (values) => {
|
||||||
orderBy: vi.fn().mockReturnValue({
|
capturedValues = values
|
||||||
limit: vi.fn().mockReturnValue([{ sortOrder: 5 }]), // Existing folder with sort order 5
|
},
|
||||||
}),
|
})
|
||||||
}),
|
)
|
||||||
}),
|
|
||||||
}),
|
|
||||||
insert: vi.fn().mockReturnValue({
|
|
||||||
values: vi.fn().mockReturnValue({
|
|
||||||
returning: vi.fn().mockReturnValue([{ ...mockFolders[0], sortOrder: 6 }]),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
return await callback(tx)
|
|
||||||
})
|
|
||||||
|
|
||||||
const req = createMockRequest('POST', {
|
const req = createMockRequest('POST', {
|
||||||
name: 'New Test Folder',
|
name: 'New Test Folder',
|
||||||
@@ -324,8 +320,10 @@ describe('Folders API Route', () => {
|
|||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
expect(data.folder).toMatchObject({
|
expect(data.folder).toMatchObject({
|
||||||
sortOrder: 6,
|
sortOrder: 1,
|
||||||
})
|
})
|
||||||
|
expect(capturedValues).not.toBeNull()
|
||||||
|
expect(capturedValues!.sortOrder).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should create subfolder with parent reference', async () => {
|
it('should create subfolder with parent reference', async () => {
|
||||||
@@ -333,7 +331,7 @@ describe('Folders API Route', () => {
|
|||||||
|
|
||||||
mockTransaction.mockImplementationOnce(
|
mockTransaction.mockImplementationOnce(
|
||||||
createMockTransaction({
|
createMockTransaction({
|
||||||
selectData: [], // No existing folders
|
selectResults: [[], []],
|
||||||
insertResult: [{ ...mockFolders[1] }],
|
insertResult: [{ ...mockFolders[1] }],
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -394,25 +392,12 @@ describe('Folders API Route', () => {
|
|||||||
mockAuthenticatedUser()
|
mockAuthenticatedUser()
|
||||||
mockGetUserEntityPermissions.mockResolvedValue('write') // Write permissions
|
mockGetUserEntityPermissions.mockResolvedValue('write') // Write permissions
|
||||||
|
|
||||||
mockTransaction.mockImplementationOnce(async (callback: any) => {
|
mockTransaction.mockImplementationOnce(
|
||||||
const tx = {
|
createMockTransaction({
|
||||||
select: vi.fn().mockReturnValue({
|
selectResults: [[], []],
|
||||||
from: vi.fn().mockReturnValue({
|
insertResult: [mockFolders[0]],
|
||||||
where: vi.fn().mockReturnValue({
|
})
|
||||||
orderBy: vi.fn().mockReturnValue({
|
)
|
||||||
limit: vi.fn().mockReturnValue([]), // No existing folders
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
insert: vi.fn().mockReturnValue({
|
|
||||||
values: vi.fn().mockReturnValue({
|
|
||||||
returning: vi.fn().mockReturnValue([mockFolders[0]]),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
return await callback(tx)
|
|
||||||
})
|
|
||||||
|
|
||||||
const req = createMockRequest('POST', {
|
const req = createMockRequest('POST', {
|
||||||
name: 'Test Folder',
|
name: 'Test Folder',
|
||||||
@@ -432,25 +417,12 @@ describe('Folders API Route', () => {
|
|||||||
mockAuthenticatedUser()
|
mockAuthenticatedUser()
|
||||||
mockGetUserEntityPermissions.mockResolvedValue('admin') // Admin permissions
|
mockGetUserEntityPermissions.mockResolvedValue('admin') // Admin permissions
|
||||||
|
|
||||||
mockTransaction.mockImplementationOnce(async (callback: any) => {
|
mockTransaction.mockImplementationOnce(
|
||||||
const tx = {
|
createMockTransaction({
|
||||||
select: vi.fn().mockReturnValue({
|
selectResults: [[], []],
|
||||||
from: vi.fn().mockReturnValue({
|
insertResult: [mockFolders[0]],
|
||||||
where: vi.fn().mockReturnValue({
|
})
|
||||||
orderBy: vi.fn().mockReturnValue({
|
)
|
||||||
limit: vi.fn().mockReturnValue([]), // No existing folders
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
insert: vi.fn().mockReturnValue({
|
|
||||||
values: vi.fn().mockReturnValue({
|
|
||||||
returning: vi.fn().mockReturnValue([mockFolders[0]]),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
return await callback(tx)
|
|
||||||
})
|
|
||||||
|
|
||||||
const req = createMockRequest('POST', {
|
const req = createMockRequest('POST', {
|
||||||
name: 'Test Folder',
|
name: 'Test Folder',
|
||||||
@@ -519,28 +491,15 @@ describe('Folders API Route', () => {
|
|||||||
|
|
||||||
let capturedValues: CapturedFolderValues | null = null
|
let capturedValues: CapturedFolderValues | null = null
|
||||||
|
|
||||||
mockTransaction.mockImplementationOnce(async (callback: any) => {
|
mockTransaction.mockImplementationOnce(
|
||||||
const tx = {
|
createMockTransaction({
|
||||||
select: vi.fn().mockReturnValue({
|
selectResults: [[], []],
|
||||||
from: vi.fn().mockReturnValue({
|
insertResult: [mockFolders[0]],
|
||||||
where: vi.fn().mockReturnValue({
|
onInsertValues: (values) => {
|
||||||
orderBy: vi.fn().mockReturnValue({
|
capturedValues = values
|
||||||
limit: vi.fn().mockReturnValue([]),
|
},
|
||||||
}),
|
})
|
||||||
}),
|
)
|
||||||
}),
|
|
||||||
}),
|
|
||||||
insert: vi.fn().mockReturnValue({
|
|
||||||
values: vi.fn().mockImplementation((values) => {
|
|
||||||
capturedValues = values
|
|
||||||
return {
|
|
||||||
returning: vi.fn().mockReturnValue([mockFolders[0]]),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
return await callback(tx)
|
|
||||||
})
|
|
||||||
|
|
||||||
const req = createMockRequest('POST', {
|
const req = createMockRequest('POST', {
|
||||||
name: ' Test Folder With Spaces ',
|
name: ' Test Folder With Spaces ',
|
||||||
@@ -559,28 +518,15 @@ describe('Folders API Route', () => {
|
|||||||
|
|
||||||
let capturedValues: CapturedFolderValues | null = null
|
let capturedValues: CapturedFolderValues | null = null
|
||||||
|
|
||||||
mockTransaction.mockImplementationOnce(async (callback: any) => {
|
mockTransaction.mockImplementationOnce(
|
||||||
const tx = {
|
createMockTransaction({
|
||||||
select: vi.fn().mockReturnValue({
|
selectResults: [[], []],
|
||||||
from: vi.fn().mockReturnValue({
|
insertResult: [mockFolders[0]],
|
||||||
where: vi.fn().mockReturnValue({
|
onInsertValues: (values) => {
|
||||||
orderBy: vi.fn().mockReturnValue({
|
capturedValues = values
|
||||||
limit: vi.fn().mockReturnValue([]),
|
},
|
||||||
}),
|
})
|
||||||
}),
|
)
|
||||||
}),
|
|
||||||
}),
|
|
||||||
insert: vi.fn().mockReturnValue({
|
|
||||||
values: vi.fn().mockImplementation((values) => {
|
|
||||||
capturedValues = values
|
|
||||||
return {
|
|
||||||
returning: vi.fn().mockReturnValue([mockFolders[0]]),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
return await callback(tx)
|
|
||||||
})
|
|
||||||
|
|
||||||
const req = createMockRequest('POST', {
|
const req = createMockRequest('POST', {
|
||||||
name: 'Test Folder',
|
name: 'Test Folder',
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { db } from '@sim/db'
|
import { db } from '@sim/db'
|
||||||
import { workflowFolder } from '@sim/db/schema'
|
import { workflow, workflowFolder } from '@sim/db/schema'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { and, asc, desc, eq, isNull } from 'drizzle-orm'
|
import { and, asc, eq, isNull, min } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||||
|
|
||||||
@@ -86,19 +87,33 @@ export async function POST(request: NextRequest) {
|
|||||||
if (providedSortOrder !== undefined) {
|
if (providedSortOrder !== undefined) {
|
||||||
sortOrder = providedSortOrder
|
sortOrder = providedSortOrder
|
||||||
} else {
|
} else {
|
||||||
const existingFolders = await tx
|
const folderParentCondition = parentId
|
||||||
.select({ sortOrder: workflowFolder.sortOrder })
|
? eq(workflowFolder.parentId, parentId)
|
||||||
.from(workflowFolder)
|
: isNull(workflowFolder.parentId)
|
||||||
.where(
|
const workflowParentCondition = parentId
|
||||||
and(
|
? eq(workflow.folderId, parentId)
|
||||||
eq(workflowFolder.workspaceId, workspaceId),
|
: isNull(workflow.folderId)
|
||||||
parentId ? eq(workflowFolder.parentId, parentId) : isNull(workflowFolder.parentId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.orderBy(desc(workflowFolder.sortOrder))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
sortOrder = existingFolders.length > 0 ? existingFolders[0].sortOrder + 1 : 0
|
const [[folderResult], [workflowResult]] = await Promise.all([
|
||||||
|
tx
|
||||||
|
.select({ minSortOrder: min(workflowFolder.sortOrder) })
|
||||||
|
.from(workflowFolder)
|
||||||
|
.where(and(eq(workflowFolder.workspaceId, workspaceId), folderParentCondition)),
|
||||||
|
tx
|
||||||
|
.select({ minSortOrder: min(workflow.sortOrder) })
|
||||||
|
.from(workflow)
|
||||||
|
.where(and(eq(workflow.workspaceId, workspaceId), workflowParentCondition)),
|
||||||
|
])
|
||||||
|
|
||||||
|
const minSortOrder = [folderResult?.minSortOrder, workflowResult?.minSortOrder].reduce<
|
||||||
|
number | null
|
||||||
|
>((currentMin, candidate) => {
|
||||||
|
if (candidate == null) return currentMin
|
||||||
|
if (currentMin == null) return candidate
|
||||||
|
return Math.min(currentMin, candidate)
|
||||||
|
}, null)
|
||||||
|
|
||||||
|
sortOrder = minSortOrder != null ? minSortOrder - 1 : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
const [folder] = await tx
|
const [folder] = await tx
|
||||||
@@ -119,6 +134,20 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
logger.info('Created new folder:', { id, name, workspaceId, parentId })
|
logger.info('Created new folder:', { id, name, workspaceId, parentId })
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId,
|
||||||
|
actorId: session.user.id,
|
||||||
|
actorName: session.user.name,
|
||||||
|
actorEmail: session.user.email,
|
||||||
|
action: AuditAction.FOLDER_CREATED,
|
||||||
|
resourceType: AuditResourceType.FOLDER,
|
||||||
|
resourceId: id,
|
||||||
|
resourceName: name.trim(),
|
||||||
|
description: `Created folder "${name.trim()}"`,
|
||||||
|
metadata: { name: name.trim() },
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({ folder: newFolder })
|
return NextResponse.json({ folder: newFolder })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error creating folder:', { error })
|
logger.error('Error creating folder:', { error })
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { eq } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm'
|
||||||
import type { NextRequest } from 'next/server'
|
import type { NextRequest } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { encryptSecret } from '@/lib/core/security/encryption'
|
import { encryptSecret } from '@/lib/core/security/encryption'
|
||||||
import { checkFormAccess, DEFAULT_FORM_CUSTOMIZATIONS } from '@/app/api/form/utils'
|
import { checkFormAccess, DEFAULT_FORM_CUSTOMIZATIONS } from '@/app/api/form/utils'
|
||||||
@@ -102,7 +103,11 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
|||||||
|
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
|
|
||||||
const { hasAccess, form: formRecord } = await checkFormAccess(id, session.user.id)
|
const {
|
||||||
|
hasAccess,
|
||||||
|
form: formRecord,
|
||||||
|
workspaceId: formWorkspaceId,
|
||||||
|
} = await checkFormAccess(id, session.user.id)
|
||||||
|
|
||||||
if (!hasAccess || !formRecord) {
|
if (!hasAccess || !formRecord) {
|
||||||
return createErrorResponse('Form not found or access denied', 404)
|
return createErrorResponse('Form not found or access denied', 404)
|
||||||
@@ -184,6 +189,19 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
|||||||
|
|
||||||
logger.info(`Form ${id} updated successfully`)
|
logger.info(`Form ${id} updated successfully`)
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: formWorkspaceId ?? null,
|
||||||
|
actorId: session.user.id,
|
||||||
|
action: AuditAction.FORM_UPDATED,
|
||||||
|
resourceType: AuditResourceType.FORM,
|
||||||
|
resourceId: id,
|
||||||
|
actorName: session.user.name ?? undefined,
|
||||||
|
actorEmail: session.user.email ?? undefined,
|
||||||
|
resourceName: formRecord.title ?? undefined,
|
||||||
|
description: `Updated form "${formRecord.title}"`,
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
return createSuccessResponse({
|
return createSuccessResponse({
|
||||||
message: 'Form updated successfully',
|
message: 'Form updated successfully',
|
||||||
})
|
})
|
||||||
@@ -213,7 +231,11 @@ export async function DELETE(
|
|||||||
|
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
|
|
||||||
const { hasAccess, form: formRecord } = await checkFormAccess(id, session.user.id)
|
const {
|
||||||
|
hasAccess,
|
||||||
|
form: formRecord,
|
||||||
|
workspaceId: formWorkspaceId,
|
||||||
|
} = await checkFormAccess(id, session.user.id)
|
||||||
|
|
||||||
if (!hasAccess || !formRecord) {
|
if (!hasAccess || !formRecord) {
|
||||||
return createErrorResponse('Form not found or access denied', 404)
|
return createErrorResponse('Form not found or access denied', 404)
|
||||||
@@ -223,6 +245,19 @@ export async function DELETE(
|
|||||||
|
|
||||||
logger.info(`Form ${id} deleted (soft delete)`)
|
logger.info(`Form ${id} deleted (soft delete)`)
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: formWorkspaceId ?? null,
|
||||||
|
actorId: session.user.id,
|
||||||
|
action: AuditAction.FORM_DELETED,
|
||||||
|
resourceType: AuditResourceType.FORM,
|
||||||
|
resourceId: id,
|
||||||
|
actorName: session.user.name ?? undefined,
|
||||||
|
actorEmail: session.user.email ?? undefined,
|
||||||
|
resourceName: formRecord.title ?? undefined,
|
||||||
|
description: `Deleted form "${formRecord.title}"`,
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
return createSuccessResponse({
|
return createSuccessResponse({
|
||||||
message: 'Form deleted successfully',
|
message: 'Form deleted successfully',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { eq } from 'drizzle-orm'
|
|||||||
import type { NextRequest } from 'next/server'
|
import type { NextRequest } from 'next/server'
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { isDev } from '@/lib/core/config/feature-flags'
|
import { isDev } from '@/lib/core/config/feature-flags'
|
||||||
import { encryptSecret } from '@/lib/core/security/encryption'
|
import { encryptSecret } from '@/lib/core/security/encryption'
|
||||||
@@ -178,7 +179,7 @@ export async function POST(request: NextRequest) {
|
|||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
identifier,
|
identifier,
|
||||||
title,
|
title,
|
||||||
description: description || '',
|
description: description || null,
|
||||||
customizations: mergedCustomizations,
|
customizations: mergedCustomizations,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
authType,
|
authType,
|
||||||
@@ -195,6 +196,19 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
logger.info(`Form "${title}" deployed successfully at ${formUrl}`)
|
logger.info(`Form "${title}" deployed successfully at ${formUrl}`)
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: workflowRecord.workspaceId ?? null,
|
||||||
|
actorId: session.user.id,
|
||||||
|
action: AuditAction.FORM_CREATED,
|
||||||
|
resourceType: AuditResourceType.FORM,
|
||||||
|
resourceId: id,
|
||||||
|
actorName: session.user.name ?? undefined,
|
||||||
|
actorEmail: session.user.email ?? undefined,
|
||||||
|
resourceName: title,
|
||||||
|
description: `Created form "${title}" for workflow ${workflowId}`,
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
return createSuccessResponse({
|
return createSuccessResponse({
|
||||||
id,
|
id,
|
||||||
formUrl,
|
formUrl,
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export async function checkWorkflowAccessForFormCreation(
|
|||||||
export async function checkFormAccess(
|
export async function checkFormAccess(
|
||||||
formId: string,
|
formId: string,
|
||||||
userId: string
|
userId: string
|
||||||
): Promise<{ hasAccess: boolean; form?: any }> {
|
): Promise<{ hasAccess: boolean; form?: any; workspaceId?: string }> {
|
||||||
const formData = await db
|
const formData = await db
|
||||||
.select({ form: form, workflowWorkspaceId: workflow.workspaceId })
|
.select({ form: form, workflowWorkspaceId: workflow.workspaceId })
|
||||||
.from(form)
|
.from(form)
|
||||||
@@ -75,7 +75,9 @@ export async function checkFormAccess(
|
|||||||
action: 'admin',
|
action: 'admin',
|
||||||
})
|
})
|
||||||
|
|
||||||
return authorization.allowed ? { hasAccess: true, form: formRecord } : { hasAccess: false }
|
return authorization.allowed
|
||||||
|
? { hasAccess: true, form: formRecord, workspaceId: workflowWorkspaceId }
|
||||||
|
: { hasAccess: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function validateFormAuth(
|
export async function validateFormAuth(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
|
auditMock,
|
||||||
createMockRequest,
|
createMockRequest,
|
||||||
mockAuth,
|
mockAuth,
|
||||||
mockConsoleLogger,
|
mockConsoleLogger,
|
||||||
@@ -35,6 +36,8 @@ vi.mock('@/lib/knowledge/documents/service', () => ({
|
|||||||
mockDrizzleOrm()
|
mockDrizzleOrm()
|
||||||
mockConsoleLogger()
|
mockConsoleLogger()
|
||||||
|
|
||||||
|
vi.mock('@/lib/audit/log', () => auditMock)
|
||||||
|
|
||||||
describe('Document By ID API Route', () => {
|
describe('Document By ID API Route', () => {
|
||||||
const mockAuth$ = mockAuth()
|
const mockAuth$ = mockAuth()
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import {
|
import {
|
||||||
@@ -197,6 +198,19 @@ export async function PUT(
|
|||||||
`[${requestId}] Document updated: ${documentId} in knowledge base ${knowledgeBaseId}`
|
`[${requestId}] Document updated: ${documentId} in knowledge base ${knowledgeBaseId}`
|
||||||
)
|
)
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null,
|
||||||
|
actorId: userId,
|
||||||
|
actorName: auth.userName,
|
||||||
|
actorEmail: auth.userEmail,
|
||||||
|
action: AuditAction.DOCUMENT_UPDATED,
|
||||||
|
resourceType: AuditResourceType.DOCUMENT,
|
||||||
|
resourceId: documentId,
|
||||||
|
resourceName: validatedData.filename ?? accessCheck.document?.filename,
|
||||||
|
description: `Updated document "${documentId}" in knowledge base "${knowledgeBaseId}"`,
|
||||||
|
request: req,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: updatedDocument,
|
data: updatedDocument,
|
||||||
@@ -257,6 +271,19 @@ export async function DELETE(
|
|||||||
`[${requestId}] Document deleted: ${documentId} from knowledge base ${knowledgeBaseId}`
|
`[${requestId}] Document deleted: ${documentId} from knowledge base ${knowledgeBaseId}`
|
||||||
)
|
)
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null,
|
||||||
|
actorId: userId,
|
||||||
|
actorName: auth.userName,
|
||||||
|
actorEmail: auth.userEmail,
|
||||||
|
action: AuditAction.DOCUMENT_DELETED,
|
||||||
|
resourceType: AuditResourceType.DOCUMENT,
|
||||||
|
resourceId: documentId,
|
||||||
|
resourceName: accessCheck.document?.filename,
|
||||||
|
description: `Deleted document "${documentId}" from knowledge base "${knowledgeBaseId}"`,
|
||||||
|
request: req,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: result,
|
data: result,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
|
auditMock,
|
||||||
createMockRequest,
|
createMockRequest,
|
||||||
mockAuth,
|
mockAuth,
|
||||||
mockConsoleLogger,
|
mockConsoleLogger,
|
||||||
@@ -40,6 +41,8 @@ vi.mock('@/lib/knowledge/documents/service', () => ({
|
|||||||
mockDrizzleOrm()
|
mockDrizzleOrm()
|
||||||
mockConsoleLogger()
|
mockConsoleLogger()
|
||||||
|
|
||||||
|
vi.mock('@/lib/audit/log', () => auditMock)
|
||||||
|
|
||||||
describe('Knowledge Base Documents API Route', () => {
|
describe('Knowledge Base Documents API Route', () => {
|
||||||
const mockAuth$ = mockAuth()
|
const mockAuth$ = mockAuth()
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||||
import {
|
import {
|
||||||
@@ -244,6 +245,19 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
logger.error(`[${requestId}] Critical error in document processing pipeline:`, error)
|
logger.error(`[${requestId}] Critical error in document processing pipeline:`, error)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null,
|
||||||
|
actorId: userId,
|
||||||
|
actorName: auth.userName,
|
||||||
|
actorEmail: auth.userEmail,
|
||||||
|
action: AuditAction.DOCUMENT_UPLOADED,
|
||||||
|
resourceType: AuditResourceType.DOCUMENT,
|
||||||
|
resourceId: knowledgeBaseId,
|
||||||
|
resourceName: `${createdDocuments.length} document(s)`,
|
||||||
|
description: `Uploaded ${createdDocuments.length} document(s) to knowledge base "${knowledgeBaseId}"`,
|
||||||
|
request: req,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
@@ -292,6 +306,19 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
// Silently fail
|
// Silently fail
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null,
|
||||||
|
actorId: userId,
|
||||||
|
actorName: auth.userName,
|
||||||
|
actorEmail: auth.userEmail,
|
||||||
|
action: AuditAction.DOCUMENT_UPLOADED,
|
||||||
|
resourceType: AuditResourceType.DOCUMENT,
|
||||||
|
resourceId: knowledgeBaseId,
|
||||||
|
resourceName: validatedData.filename,
|
||||||
|
description: `Uploaded document "${validatedData.filename}" to knowledge base "${knowledgeBaseId}"`,
|
||||||
|
request: req,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: newDocument,
|
data: newDocument,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
|
auditMock,
|
||||||
createMockRequest,
|
createMockRequest,
|
||||||
mockAuth,
|
mockAuth,
|
||||||
mockConsoleLogger,
|
mockConsoleLogger,
|
||||||
@@ -16,6 +17,8 @@ mockKnowledgeSchemas()
|
|||||||
mockDrizzleOrm()
|
mockDrizzleOrm()
|
||||||
mockConsoleLogger()
|
mockConsoleLogger()
|
||||||
|
|
||||||
|
vi.mock('@/lib/audit/log', () => auditMock)
|
||||||
|
|
||||||
vi.mock('@/lib/knowledge/service', () => ({
|
vi.mock('@/lib/knowledge/service', () => ({
|
||||||
getKnowledgeBaseById: vi.fn(),
|
getKnowledgeBaseById: vi.fn(),
|
||||||
updateKnowledgeBase: vi.fn(),
|
updateKnowledgeBase: vi.fn(),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||||
import { PlatformEvents } from '@/lib/core/telemetry'
|
import { PlatformEvents } from '@/lib/core/telemetry'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
@@ -135,6 +136,19 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
|
|
||||||
logger.info(`[${requestId}] Knowledge base updated: ${id} for user ${userId}`)
|
logger.info(`[${requestId}] Knowledge base updated: ${id} for user ${userId}`)
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: accessCheck.knowledgeBase.workspaceId ?? null,
|
||||||
|
actorId: userId,
|
||||||
|
actorName: auth.userName,
|
||||||
|
actorEmail: auth.userEmail,
|
||||||
|
action: AuditAction.KNOWLEDGE_BASE_UPDATED,
|
||||||
|
resourceType: AuditResourceType.KNOWLEDGE_BASE,
|
||||||
|
resourceId: id,
|
||||||
|
resourceName: validatedData.name ?? updatedKnowledgeBase.name,
|
||||||
|
description: `Updated knowledge base "${validatedData.name ?? updatedKnowledgeBase.name}"`,
|
||||||
|
request: req,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: updatedKnowledgeBase,
|
data: updatedKnowledgeBase,
|
||||||
@@ -197,6 +211,19 @@ export async function DELETE(
|
|||||||
|
|
||||||
logger.info(`[${requestId}] Knowledge base deleted: ${id} for user ${userId}`)
|
logger.info(`[${requestId}] Knowledge base deleted: ${id} for user ${userId}`)
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: accessCheck.knowledgeBase.workspaceId ?? null,
|
||||||
|
actorId: userId,
|
||||||
|
actorName: auth.userName,
|
||||||
|
actorEmail: auth.userEmail,
|
||||||
|
action: AuditAction.KNOWLEDGE_BASE_DELETED,
|
||||||
|
resourceType: AuditResourceType.KNOWLEDGE_BASE,
|
||||||
|
resourceId: id,
|
||||||
|
resourceName: accessCheck.knowledgeBase.name,
|
||||||
|
description: `Deleted knowledge base "${accessCheck.knowledgeBase.name || id}"`,
|
||||||
|
request: _request,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: { message: 'Knowledge base deleted successfully' },
|
data: { message: 'Knowledge base deleted successfully' },
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
|
auditMock,
|
||||||
createMockRequest,
|
createMockRequest,
|
||||||
mockAuth,
|
mockAuth,
|
||||||
mockConsoleLogger,
|
mockConsoleLogger,
|
||||||
@@ -16,6 +17,8 @@ mockKnowledgeSchemas()
|
|||||||
mockDrizzleOrm()
|
mockDrizzleOrm()
|
||||||
mockConsoleLogger()
|
mockConsoleLogger()
|
||||||
|
|
||||||
|
vi.mock('@/lib/audit/log', () => auditMock)
|
||||||
|
|
||||||
vi.mock('@/lib/workspaces/permissions/utils', () => ({
|
vi.mock('@/lib/workspaces/permissions/utils', () => ({
|
||||||
getUserEntityPermissions: vi.fn().mockResolvedValue('admin'),
|
getUserEntityPermissions: vi.fn().mockResolvedValue('admin'),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { PlatformEvents } from '@/lib/core/telemetry'
|
import { PlatformEvents } from '@/lib/core/telemetry'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
@@ -109,6 +110,20 @@ export async function POST(req: NextRequest) {
|
|||||||
`[${requestId}] Knowledge base created: ${newKnowledgeBase.id} for user ${session.user.id}`
|
`[${requestId}] Knowledge base created: ${newKnowledgeBase.id} for user ${session.user.id}`
|
||||||
)
|
)
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: validatedData.workspaceId,
|
||||||
|
actorId: session.user.id,
|
||||||
|
actorName: session.user.name,
|
||||||
|
actorEmail: session.user.email,
|
||||||
|
action: AuditAction.KNOWLEDGE_BASE_CREATED,
|
||||||
|
resourceType: AuditResourceType.KNOWLEDGE_BASE,
|
||||||
|
resourceId: newKnowledgeBase.id,
|
||||||
|
resourceName: validatedData.name,
|
||||||
|
description: `Created knowledge base "${validatedData.name}"`,
|
||||||
|
metadata: { name: validatedData.name },
|
||||||
|
request: req,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: newKnowledgeBase,
|
data: newKnowledgeBase,
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export interface EmbeddingData {
|
|||||||
|
|
||||||
export interface KnowledgeBaseAccessResult {
|
export interface KnowledgeBaseAccessResult {
|
||||||
hasAccess: true
|
hasAccess: true
|
||||||
knowledgeBase: Pick<KnowledgeBaseData, 'id' | 'userId' | 'workspaceId'>
|
knowledgeBase: Pick<KnowledgeBaseData, 'id' | 'userId' | 'workspaceId' | 'name'>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KnowledgeBaseAccessDenied {
|
export interface KnowledgeBaseAccessDenied {
|
||||||
@@ -113,7 +113,7 @@ export type KnowledgeBaseAccessCheck = KnowledgeBaseAccessResult | KnowledgeBase
|
|||||||
export interface DocumentAccessResult {
|
export interface DocumentAccessResult {
|
||||||
hasAccess: true
|
hasAccess: true
|
||||||
document: DocumentData
|
document: DocumentData
|
||||||
knowledgeBase: Pick<KnowledgeBaseData, 'id' | 'userId' | 'workspaceId'>
|
knowledgeBase: Pick<KnowledgeBaseData, 'id' | 'userId' | 'workspaceId' | 'name'>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DocumentAccessDenied {
|
export interface DocumentAccessDenied {
|
||||||
@@ -128,7 +128,7 @@ export interface ChunkAccessResult {
|
|||||||
hasAccess: true
|
hasAccess: true
|
||||||
chunk: EmbeddingData
|
chunk: EmbeddingData
|
||||||
document: DocumentData
|
document: DocumentData
|
||||||
knowledgeBase: Pick<KnowledgeBaseData, 'id' | 'userId' | 'workspaceId'>
|
knowledgeBase: Pick<KnowledgeBaseData, 'id' | 'userId' | 'workspaceId' | 'name'>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChunkAccessDenied {
|
export interface ChunkAccessDenied {
|
||||||
@@ -151,6 +151,7 @@ export async function checkKnowledgeBaseAccess(
|
|||||||
id: knowledgeBase.id,
|
id: knowledgeBase.id,
|
||||||
userId: knowledgeBase.userId,
|
userId: knowledgeBase.userId,
|
||||||
workspaceId: knowledgeBase.workspaceId,
|
workspaceId: knowledgeBase.workspaceId,
|
||||||
|
name: knowledgeBase.name,
|
||||||
})
|
})
|
||||||
.from(knowledgeBase)
|
.from(knowledgeBase)
|
||||||
.where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt)))
|
.where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt)))
|
||||||
@@ -193,6 +194,7 @@ export async function checkKnowledgeBaseWriteAccess(
|
|||||||
id: knowledgeBase.id,
|
id: knowledgeBase.id,
|
||||||
userId: knowledgeBase.userId,
|
userId: knowledgeBase.userId,
|
||||||
workspaceId: knowledgeBase.workspaceId,
|
workspaceId: knowledgeBase.workspaceId,
|
||||||
|
name: knowledgeBase.name,
|
||||||
})
|
})
|
||||||
.from(knowledgeBase)
|
.from(knowledgeBase)
|
||||||
.where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt)))
|
.where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt)))
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { mcpServers } from '@sim/db/schema'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { and, eq, isNull } from 'drizzle-orm'
|
import { and, eq, isNull } from 'drizzle-orm'
|
||||||
import type { NextRequest } from 'next/server'
|
import type { NextRequest } from 'next/server'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { McpDomainNotAllowedError, validateMcpDomain } from '@/lib/mcp/domain-check'
|
import { McpDomainNotAllowedError, validateMcpDomain } from '@/lib/mcp/domain-check'
|
||||||
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
||||||
import { mcpService } from '@/lib/mcp/service'
|
import { mcpService } from '@/lib/mcp/service'
|
||||||
@@ -16,7 +17,11 @@ export const dynamic = 'force-dynamic'
|
|||||||
* PATCH - Update an MCP server in the workspace (requires write or admin permission)
|
* PATCH - Update an MCP server in the workspace (requires write or admin permission)
|
||||||
*/
|
*/
|
||||||
export const PATCH = withMcpAuth<{ id: string }>('write')(
|
export const PATCH = withMcpAuth<{ id: string }>('write')(
|
||||||
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
async (
|
||||||
|
request: NextRequest,
|
||||||
|
{ userId, userName, userEmail, workspaceId, requestId },
|
||||||
|
{ params }
|
||||||
|
) => {
|
||||||
const { id: serverId } = await params
|
const { id: serverId } = await params
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -85,6 +90,20 @@ export const PATCH = withMcpAuth<{ id: string }>('write')(
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`[${requestId}] Successfully updated MCP server: ${serverId}`)
|
logger.info(`[${requestId}] Successfully updated MCP server: ${serverId}`)
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId,
|
||||||
|
actorId: userId,
|
||||||
|
actorName: userName,
|
||||||
|
actorEmail: userEmail,
|
||||||
|
action: AuditAction.MCP_SERVER_UPDATED,
|
||||||
|
resourceType: AuditResourceType.MCP_SERVER,
|
||||||
|
resourceId: serverId,
|
||||||
|
resourceName: updatedServer.name || serverId,
|
||||||
|
description: `Updated MCP server "${updatedServer.name || serverId}"`,
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
return createMcpSuccessResponse({ server: updatedServer })
|
return createMcpSuccessResponse({ server: updatedServer })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`[${requestId}] Error updating MCP server:`, error)
|
logger.error(`[${requestId}] Error updating MCP server:`, error)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { mcpServers } from '@sim/db/schema'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { and, eq, isNull } from 'drizzle-orm'
|
import { and, eq, isNull } from 'drizzle-orm'
|
||||||
import type { NextRequest } from 'next/server'
|
import type { NextRequest } from 'next/server'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { McpDomainNotAllowedError, validateMcpDomain } from '@/lib/mcp/domain-check'
|
import { McpDomainNotAllowedError, validateMcpDomain } from '@/lib/mcp/domain-check'
|
||||||
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
||||||
import { mcpService } from '@/lib/mcp/service'
|
import { mcpService } from '@/lib/mcp/service'
|
||||||
@@ -55,7 +56,7 @@ export const GET = withMcpAuth('read')(
|
|||||||
* it will be updated instead of creating a duplicate.
|
* it will be updated instead of creating a duplicate.
|
||||||
*/
|
*/
|
||||||
export const POST = withMcpAuth('write')(
|
export const POST = withMcpAuth('write')(
|
||||||
async (request: NextRequest, { userId, workspaceId, requestId }) => {
|
async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => {
|
||||||
try {
|
try {
|
||||||
const body = getParsedBody(request) || (await request.json())
|
const body = getParsedBody(request) || (await request.json())
|
||||||
|
|
||||||
@@ -161,6 +162,20 @@ export const POST = withMcpAuth('write')(
|
|||||||
// Silently fail
|
// Silently fail
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId,
|
||||||
|
actorId: userId,
|
||||||
|
actorName: userName,
|
||||||
|
actorEmail: userEmail,
|
||||||
|
action: AuditAction.MCP_SERVER_ADDED,
|
||||||
|
resourceType: AuditResourceType.MCP_SERVER,
|
||||||
|
resourceId: serverId,
|
||||||
|
resourceName: body.name,
|
||||||
|
description: `Added MCP server "${body.name}"`,
|
||||||
|
metadata: { serverName: body.name, transport: body.transport },
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
return createMcpSuccessResponse({ serverId }, 201)
|
return createMcpSuccessResponse({ serverId }, 201)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`[${requestId}] Error registering MCP server:`, error)
|
logger.error(`[${requestId}] Error registering MCP server:`, error)
|
||||||
@@ -177,7 +192,7 @@ export const POST = withMcpAuth('write')(
|
|||||||
* DELETE - Delete an MCP server from the workspace (requires admin permission)
|
* DELETE - Delete an MCP server from the workspace (requires admin permission)
|
||||||
*/
|
*/
|
||||||
export const DELETE = withMcpAuth('admin')(
|
export const DELETE = withMcpAuth('admin')(
|
||||||
async (request: NextRequest, { userId, workspaceId, requestId }) => {
|
async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => {
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const serverId = searchParams.get('serverId')
|
const serverId = searchParams.get('serverId')
|
||||||
@@ -208,6 +223,20 @@ export const DELETE = withMcpAuth('admin')(
|
|||||||
await mcpService.clearCache(workspaceId)
|
await mcpService.clearCache(workspaceId)
|
||||||
|
|
||||||
logger.info(`[${requestId}] Successfully deleted MCP server: ${serverId}`)
|
logger.info(`[${requestId}] Successfully deleted MCP server: ${serverId}`)
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId,
|
||||||
|
actorId: userId,
|
||||||
|
actorName: userName,
|
||||||
|
actorEmail: userEmail,
|
||||||
|
action: AuditAction.MCP_SERVER_REMOVED,
|
||||||
|
resourceType: AuditResourceType.MCP_SERVER,
|
||||||
|
resourceId: serverId!,
|
||||||
|
resourceName: deletedServer.name,
|
||||||
|
description: `Removed MCP server "${deletedServer.name}"`,
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` })
|
return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`[${requestId}] Error deleting MCP server:`, error)
|
logger.error(`[${requestId}] Error deleting MCP server:`, error)
|
||||||
|
|||||||
@@ -105,6 +105,16 @@ export const POST = withMcpAuth('write')(
|
|||||||
logger.warn(`[${requestId}] Some environment variables not found:`, { missingVars })
|
logger.warn(`[${requestId}] Some environment variables not found:`, { missingVars })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-validate domain after env var resolution
|
||||||
|
try {
|
||||||
|
validateMcpDomain(testConfig.url)
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof McpDomainNotAllowedError) {
|
||||||
|
return createMcpErrorResponse(e, e.message, 403)
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
|
||||||
const testSecurityPolicy = {
|
const testSecurityPolicy = {
|
||||||
requireConsent: false,
|
requireConsent: false,
|
||||||
auditLevel: 'none' as const,
|
auditLevel: 'none' as const,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { and, eq } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
import type { NextRequest } from 'next/server'
|
import type { NextRequest } from 'next/server'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
||||||
import { mcpPubSub } from '@/lib/mcp/pubsub'
|
import { mcpPubSub } from '@/lib/mcp/pubsub'
|
||||||
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
||||||
@@ -71,7 +72,11 @@ export const GET = withMcpAuth<RouteParams>('read')(
|
|||||||
* PATCH - Update a workflow MCP server
|
* PATCH - Update a workflow MCP server
|
||||||
*/
|
*/
|
||||||
export const PATCH = withMcpAuth<RouteParams>('write')(
|
export const PATCH = withMcpAuth<RouteParams>('write')(
|
||||||
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
async (
|
||||||
|
request: NextRequest,
|
||||||
|
{ userId, userName, userEmail, workspaceId, requestId },
|
||||||
|
{ params }
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const { id: serverId } = await params
|
const { id: serverId } = await params
|
||||||
const body = getParsedBody(request) || (await request.json())
|
const body = getParsedBody(request) || (await request.json())
|
||||||
@@ -112,6 +117,19 @@ export const PATCH = withMcpAuth<RouteParams>('write')(
|
|||||||
|
|
||||||
logger.info(`[${requestId}] Successfully updated workflow MCP server: ${serverId}`)
|
logger.info(`[${requestId}] Successfully updated workflow MCP server: ${serverId}`)
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId,
|
||||||
|
actorId: userId,
|
||||||
|
actorName: userName,
|
||||||
|
actorEmail: userEmail,
|
||||||
|
action: AuditAction.MCP_SERVER_UPDATED,
|
||||||
|
resourceType: AuditResourceType.MCP_SERVER,
|
||||||
|
resourceId: serverId,
|
||||||
|
resourceName: updatedServer.name,
|
||||||
|
description: `Updated workflow MCP server "${updatedServer.name}"`,
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
return createMcpSuccessResponse({ server: updatedServer })
|
return createMcpSuccessResponse({ server: updatedServer })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`[${requestId}] Error updating workflow MCP server:`, error)
|
logger.error(`[${requestId}] Error updating workflow MCP server:`, error)
|
||||||
@@ -128,7 +146,11 @@ export const PATCH = withMcpAuth<RouteParams>('write')(
|
|||||||
* DELETE - Delete a workflow MCP server and all its tools
|
* DELETE - Delete a workflow MCP server and all its tools
|
||||||
*/
|
*/
|
||||||
export const DELETE = withMcpAuth<RouteParams>('admin')(
|
export const DELETE = withMcpAuth<RouteParams>('admin')(
|
||||||
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
async (
|
||||||
|
request: NextRequest,
|
||||||
|
{ userId, userName, userEmail, workspaceId, requestId },
|
||||||
|
{ params }
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const { id: serverId } = await params
|
const { id: serverId } = await params
|
||||||
|
|
||||||
@@ -149,6 +171,19 @@ export const DELETE = withMcpAuth<RouteParams>('admin')(
|
|||||||
|
|
||||||
mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId })
|
mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId })
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId,
|
||||||
|
actorId: userId,
|
||||||
|
actorName: userName,
|
||||||
|
actorEmail: userEmail,
|
||||||
|
action: AuditAction.MCP_SERVER_REMOVED,
|
||||||
|
resourceType: AuditResourceType.MCP_SERVER,
|
||||||
|
resourceId: serverId,
|
||||||
|
resourceName: deletedServer.name,
|
||||||
|
description: `Unpublished workflow MCP server "${deletedServer.name}"`,
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` })
|
return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`[${requestId}] Error deleting workflow MCP server:`, error)
|
logger.error(`[${requestId}] Error deleting workflow MCP server:`, error)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { and, eq } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
import type { NextRequest } from 'next/server'
|
import type { NextRequest } from 'next/server'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
||||||
import { mcpPubSub } from '@/lib/mcp/pubsub'
|
import { mcpPubSub } from '@/lib/mcp/pubsub'
|
||||||
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
||||||
@@ -65,7 +66,11 @@ export const GET = withMcpAuth<RouteParams>('read')(
|
|||||||
* PATCH - Update a tool's configuration
|
* PATCH - Update a tool's configuration
|
||||||
*/
|
*/
|
||||||
export const PATCH = withMcpAuth<RouteParams>('write')(
|
export const PATCH = withMcpAuth<RouteParams>('write')(
|
||||||
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
async (
|
||||||
|
request: NextRequest,
|
||||||
|
{ userId, userName, userEmail, workspaceId, requestId },
|
||||||
|
{ params }
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const { id: serverId, toolId } = await params
|
const { id: serverId, toolId } = await params
|
||||||
const body = getParsedBody(request) || (await request.json())
|
const body = getParsedBody(request) || (await request.json())
|
||||||
@@ -118,6 +123,19 @@ export const PATCH = withMcpAuth<RouteParams>('write')(
|
|||||||
|
|
||||||
mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId })
|
mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId })
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId,
|
||||||
|
actorId: userId,
|
||||||
|
actorName: userName,
|
||||||
|
actorEmail: userEmail,
|
||||||
|
action: AuditAction.MCP_SERVER_UPDATED,
|
||||||
|
resourceType: AuditResourceType.MCP_SERVER,
|
||||||
|
resourceId: serverId,
|
||||||
|
description: `Updated tool "${updatedTool.toolName}" in MCP server`,
|
||||||
|
metadata: { toolId, toolName: updatedTool.toolName },
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
return createMcpSuccessResponse({ tool: updatedTool })
|
return createMcpSuccessResponse({ tool: updatedTool })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`[${requestId}] Error updating tool:`, error)
|
logger.error(`[${requestId}] Error updating tool:`, error)
|
||||||
@@ -134,7 +152,11 @@ export const PATCH = withMcpAuth<RouteParams>('write')(
|
|||||||
* DELETE - Remove a tool from an MCP server
|
* DELETE - Remove a tool from an MCP server
|
||||||
*/
|
*/
|
||||||
export const DELETE = withMcpAuth<RouteParams>('write')(
|
export const DELETE = withMcpAuth<RouteParams>('write')(
|
||||||
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
async (
|
||||||
|
request: NextRequest,
|
||||||
|
{ userId, userName, userEmail, workspaceId, requestId },
|
||||||
|
{ params }
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const { id: serverId, toolId } = await params
|
const { id: serverId, toolId } = await params
|
||||||
|
|
||||||
@@ -165,6 +187,19 @@ export const DELETE = withMcpAuth<RouteParams>('write')(
|
|||||||
|
|
||||||
mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId })
|
mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId })
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId,
|
||||||
|
actorId: userId,
|
||||||
|
actorName: userName,
|
||||||
|
actorEmail: userEmail,
|
||||||
|
action: AuditAction.MCP_SERVER_UPDATED,
|
||||||
|
resourceType: AuditResourceType.MCP_SERVER,
|
||||||
|
resourceId: serverId,
|
||||||
|
description: `Removed tool "${deletedTool.toolName}" from MCP server`,
|
||||||
|
metadata: { toolId, toolName: deletedTool.toolName },
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
return createMcpSuccessResponse({ message: `Tool ${toolId} deleted successfully` })
|
return createMcpSuccessResponse({ message: `Tool ${toolId} deleted successfully` })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`[${requestId}] Error deleting tool:`, error)
|
logger.error(`[${requestId}] Error deleting tool:`, error)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { and, eq } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
import type { NextRequest } from 'next/server'
|
import type { NextRequest } from 'next/server'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
||||||
import { mcpPubSub } from '@/lib/mcp/pubsub'
|
import { mcpPubSub } from '@/lib/mcp/pubsub'
|
||||||
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
||||||
@@ -76,7 +77,11 @@ export const GET = withMcpAuth<RouteParams>('read')(
|
|||||||
* POST - Add a workflow as a tool to an MCP server
|
* POST - Add a workflow as a tool to an MCP server
|
||||||
*/
|
*/
|
||||||
export const POST = withMcpAuth<RouteParams>('write')(
|
export const POST = withMcpAuth<RouteParams>('write')(
|
||||||
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
async (
|
||||||
|
request: NextRequest,
|
||||||
|
{ userId, userName, userEmail, workspaceId, requestId },
|
||||||
|
{ params }
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const { id: serverId } = await params
|
const { id: serverId } = await params
|
||||||
const body = getParsedBody(request) || (await request.json())
|
const body = getParsedBody(request) || (await request.json())
|
||||||
@@ -197,6 +202,19 @@ export const POST = withMcpAuth<RouteParams>('write')(
|
|||||||
|
|
||||||
mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId })
|
mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId })
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId,
|
||||||
|
actorId: userId,
|
||||||
|
actorName: userName,
|
||||||
|
actorEmail: userEmail,
|
||||||
|
action: AuditAction.MCP_SERVER_UPDATED,
|
||||||
|
resourceType: AuditResourceType.MCP_SERVER,
|
||||||
|
resourceId: serverId,
|
||||||
|
description: `Added tool "${toolName}" to MCP server`,
|
||||||
|
metadata: { toolId, toolName, workflowId: body.workflowId },
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
return createMcpSuccessResponse({ tool }, 201)
|
return createMcpSuccessResponse({ tool }, 201)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`[${requestId}] Error adding tool:`, error)
|
logger.error(`[${requestId}] Error adding tool:`, error)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { eq, inArray, sql } from 'drizzle-orm'
|
import { eq, inArray, sql } from 'drizzle-orm'
|
||||||
import type { NextRequest } from 'next/server'
|
import type { NextRequest } from 'next/server'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
||||||
import { mcpPubSub } from '@/lib/mcp/pubsub'
|
import { mcpPubSub } from '@/lib/mcp/pubsub'
|
||||||
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
||||||
@@ -85,7 +86,7 @@ export const GET = withMcpAuth('read')(
|
|||||||
* POST - Create a new workflow MCP server
|
* POST - Create a new workflow MCP server
|
||||||
*/
|
*/
|
||||||
export const POST = withMcpAuth('write')(
|
export const POST = withMcpAuth('write')(
|
||||||
async (request: NextRequest, { userId, workspaceId, requestId }) => {
|
async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => {
|
||||||
try {
|
try {
|
||||||
const body = getParsedBody(request) || (await request.json())
|
const body = getParsedBody(request) || (await request.json())
|
||||||
|
|
||||||
@@ -188,6 +189,19 @@ export const POST = withMcpAuth('write')(
|
|||||||
`[${requestId}] Successfully created workflow MCP server: ${body.name} (ID: ${serverId})`
|
`[${requestId}] Successfully created workflow MCP server: ${body.name} (ID: ${serverId})`
|
||||||
)
|
)
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId,
|
||||||
|
actorId: userId,
|
||||||
|
actorName: userName,
|
||||||
|
actorEmail: userEmail,
|
||||||
|
action: AuditAction.MCP_SERVER_ADDED,
|
||||||
|
resourceType: AuditResourceType.MCP_SERVER,
|
||||||
|
resourceId: serverId,
|
||||||
|
resourceName: body.name.trim(),
|
||||||
|
description: `Published workflow MCP server "${body.name.trim()}" with ${addedTools.length} tool(s)`,
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
return createMcpSuccessResponse({ server, addedTools }, 201)
|
return createMcpSuccessResponse({ server, addedTools }, 201)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`[${requestId}] Error creating workflow MCP server:`, error)
|
logger.error(`[${requestId}] Error creating workflow MCP server:`, error)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { and, eq } from 'drizzle-orm'
|
|||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { getEmailSubject, renderInvitationEmail } from '@/components/emails'
|
import { getEmailSubject, renderInvitationEmail } from '@/components/emails'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { hasAccessControlAccess } from '@/lib/billing'
|
import { hasAccessControlAccess } from '@/lib/billing'
|
||||||
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
|
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
|
||||||
@@ -552,6 +553,25 @@ export async function PUT(
|
|||||||
email: orgInvitation.email,
|
email: orgInvitation.email,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const auditActionMap = {
|
||||||
|
accepted: AuditAction.ORG_INVITATION_ACCEPTED,
|
||||||
|
rejected: AuditAction.ORG_INVITATION_REJECTED,
|
||||||
|
cancelled: AuditAction.ORG_INVITATION_CANCELLED,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: null,
|
||||||
|
actorId: session.user.id,
|
||||||
|
action: auditActionMap[status],
|
||||||
|
resourceType: AuditResourceType.ORGANIZATION,
|
||||||
|
resourceId: organizationId,
|
||||||
|
actorName: session.user.name ?? undefined,
|
||||||
|
actorEmail: session.user.email ?? undefined,
|
||||||
|
description: `Organization invitation ${status} for ${orgInvitation.email}`,
|
||||||
|
metadata: { invitationId, email: orgInvitation.email, status },
|
||||||
|
request: req,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: `Invitation ${status} successfully`,
|
message: `Invitation ${status} successfully`,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
renderBatchInvitationEmail,
|
renderBatchInvitationEmail,
|
||||||
renderInvitationEmail,
|
renderInvitationEmail,
|
||||||
} from '@/components/emails'
|
} from '@/components/emails'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import {
|
import {
|
||||||
validateBulkInvitations,
|
validateBulkInvitations,
|
||||||
@@ -411,6 +412,22 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
workspaceInvitationCount: workspaceInvitationIds.length,
|
workspaceInvitationCount: workspaceInvitationIds.length,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
for (const inv of invitationsToCreate) {
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: null,
|
||||||
|
actorId: session.user.id,
|
||||||
|
action: AuditAction.ORG_INVITATION_CREATED,
|
||||||
|
resourceType: AuditResourceType.ORGANIZATION,
|
||||||
|
resourceId: organizationId,
|
||||||
|
actorName: session.user.name ?? undefined,
|
||||||
|
actorEmail: session.user.email ?? undefined,
|
||||||
|
resourceName: organizationEntry[0]?.name,
|
||||||
|
description: `Invited ${inv.email} to organization as ${role}`,
|
||||||
|
metadata: { invitationId: inv.id, email: inv.email, role },
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: `${invitationsToCreate.length} invitation(s) sent successfully`,
|
message: `${invitationsToCreate.length} invitation(s) sent successfully`,
|
||||||
@@ -532,6 +549,19 @@ export async function DELETE(
|
|||||||
email: result[0].email,
|
email: result[0].email,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: null,
|
||||||
|
actorId: session.user.id,
|
||||||
|
action: AuditAction.ORG_INVITATION_REVOKED,
|
||||||
|
resourceType: AuditResourceType.ORGANIZATION,
|
||||||
|
resourceId: organizationId,
|
||||||
|
actorName: session.user.name ?? undefined,
|
||||||
|
actorEmail: session.user.email ?? undefined,
|
||||||
|
description: `Revoked organization invitation for ${result[0].email}`,
|
||||||
|
metadata: { invitationId, email: result[0].email },
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Invitation cancelled successfully',
|
message: 'Invitation cancelled successfully',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { and, eq } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { getUserUsageData } from '@/lib/billing/core/usage'
|
import { getUserUsageData } from '@/lib/billing/core/usage'
|
||||||
import { removeUserFromOrganization } from '@/lib/billing/organizations/membership'
|
import { removeUserFromOrganization } from '@/lib/billing/organizations/membership'
|
||||||
@@ -213,6 +214,19 @@ export async function PUT(
|
|||||||
updatedBy: session.user.id,
|
updatedBy: session.user.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: null,
|
||||||
|
actorId: session.user.id,
|
||||||
|
action: AuditAction.ORG_MEMBER_ROLE_CHANGED,
|
||||||
|
resourceType: AuditResourceType.ORGANIZATION,
|
||||||
|
resourceId: organizationId,
|
||||||
|
actorName: session.user.name ?? undefined,
|
||||||
|
actorEmail: session.user.email ?? undefined,
|
||||||
|
description: `Changed role for member ${memberId} to ${role}`,
|
||||||
|
metadata: { targetUserId: memberId, newRole: role },
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Member role updated successfully',
|
message: 'Member role updated successfully',
|
||||||
@@ -305,6 +319,22 @@ export async function DELETE(
|
|||||||
billingActions: result.billingActions,
|
billingActions: result.billingActions,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: null,
|
||||||
|
actorId: session.user.id,
|
||||||
|
action: AuditAction.ORG_MEMBER_REMOVED,
|
||||||
|
resourceType: AuditResourceType.ORGANIZATION,
|
||||||
|
resourceId: organizationId,
|
||||||
|
actorName: session.user.name ?? undefined,
|
||||||
|
actorEmail: session.user.email ?? undefined,
|
||||||
|
description:
|
||||||
|
session.user.id === targetUserId
|
||||||
|
? 'Left the organization'
|
||||||
|
: `Removed member ${targetUserId} from organization`,
|
||||||
|
metadata: { targetUserId, wasSelfRemoval: session.user.id === targetUserId },
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message:
|
message:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { and, eq } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { getEmailSubject, renderInvitationEmail } from '@/components/emails'
|
import { getEmailSubject, renderInvitationEmail } from '@/components/emails'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { getUserUsageData } from '@/lib/billing/core/usage'
|
import { getUserUsageData } from '@/lib/billing/core/usage'
|
||||||
import { validateSeatAvailability } from '@/lib/billing/validation/seat-management'
|
import { validateSeatAvailability } from '@/lib/billing/validation/seat-management'
|
||||||
@@ -285,6 +286,19 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
// Don't fail the request if email fails
|
// Don't fail the request if email fails
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: null,
|
||||||
|
actorId: session.user.id,
|
||||||
|
action: AuditAction.ORG_INVITATION_CREATED,
|
||||||
|
resourceType: AuditResourceType.ORGANIZATION,
|
||||||
|
resourceId: organizationId,
|
||||||
|
actorName: session.user.name ?? undefined,
|
||||||
|
actorEmail: session.user.email ?? undefined,
|
||||||
|
description: `Invited ${normalizedEmail} to organization as ${role}`,
|
||||||
|
metadata: { invitationId, email: normalizedEmail, role },
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: `Invitation sent to ${normalizedEmail}`,
|
message: `Invitation sent to ${normalizedEmail}`,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { and, eq, ne } from 'drizzle-orm'
|
import { and, eq, ne } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import {
|
import {
|
||||||
getOrganizationSeatAnalytics,
|
getOrganizationSeatAnalytics,
|
||||||
@@ -192,6 +193,20 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
|||||||
changes: { name, slug, logo },
|
changes: { name, slug, logo },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: null,
|
||||||
|
actorId: session.user.id,
|
||||||
|
action: AuditAction.ORGANIZATION_UPDATED,
|
||||||
|
resourceType: AuditResourceType.ORGANIZATION,
|
||||||
|
resourceId: organizationId,
|
||||||
|
actorName: session.user.name ?? undefined,
|
||||||
|
actorEmail: session.user.email ?? undefined,
|
||||||
|
resourceName: updatedOrg[0].name,
|
||||||
|
description: `Updated organization settings`,
|
||||||
|
metadata: { changes: { name, slug, logo } },
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Organization updated successfully',
|
message: 'Organization updated successfully',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { member, organization } from '@sim/db/schema'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { and, eq, or } from 'drizzle-orm'
|
import { and, eq, or } from 'drizzle-orm'
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { createOrganizationForTeamPlan } from '@/lib/billing/organization'
|
import { createOrganizationForTeamPlan } from '@/lib/billing/organization'
|
||||||
|
|
||||||
@@ -115,6 +116,19 @@ export async function POST(request: Request) {
|
|||||||
organizationId,
|
organizationId,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: null,
|
||||||
|
actorId: user.id,
|
||||||
|
action: AuditAction.ORGANIZATION_CREATED,
|
||||||
|
resourceType: AuditResourceType.ORGANIZATION,
|
||||||
|
resourceId: organizationId,
|
||||||
|
actorName: user.name ?? undefined,
|
||||||
|
actorEmail: user.email ?? undefined,
|
||||||
|
resourceName: organizationName ?? undefined,
|
||||||
|
description: `Created organization "${organizationName}"`,
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
organizationId,
|
organizationId,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { and, eq } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { hasAccessControlAccess } from '@/lib/billing'
|
import { hasAccessControlAccess } from '@/lib/billing'
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ async function getPermissionGroupWithAccess(groupId: string, userId: string) {
|
|||||||
const [group] = await db
|
const [group] = await db
|
||||||
.select({
|
.select({
|
||||||
id: permissionGroup.id,
|
id: permissionGroup.id,
|
||||||
|
name: permissionGroup.name,
|
||||||
organizationId: permissionGroup.organizationId,
|
organizationId: permissionGroup.organizationId,
|
||||||
})
|
})
|
||||||
.from(permissionGroup)
|
.from(permissionGroup)
|
||||||
@@ -151,6 +153,20 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
assignedBy: session.user.id,
|
assignedBy: session.user.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: null,
|
||||||
|
actorId: session.user.id,
|
||||||
|
action: AuditAction.PERMISSION_GROUP_MEMBER_ADDED,
|
||||||
|
resourceType: AuditResourceType.PERMISSION_GROUP,
|
||||||
|
resourceId: id,
|
||||||
|
resourceName: result.group.name,
|
||||||
|
actorName: session.user.name ?? undefined,
|
||||||
|
actorEmail: session.user.email ?? undefined,
|
||||||
|
description: `Added member ${userId} to permission group "${result.group.name}"`,
|
||||||
|
metadata: { targetUserId: userId, permissionGroupId: id },
|
||||||
|
request: req,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({ member: newMember }, { status: 201 })
|
return NextResponse.json({ member: newMember }, { status: 201 })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
@@ -221,6 +237,20 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
|
|||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: null,
|
||||||
|
actorId: session.user.id,
|
||||||
|
action: AuditAction.PERMISSION_GROUP_MEMBER_REMOVED,
|
||||||
|
resourceType: AuditResourceType.PERMISSION_GROUP,
|
||||||
|
resourceId: id,
|
||||||
|
resourceName: result.group.name,
|
||||||
|
actorName: session.user.name ?? undefined,
|
||||||
|
actorEmail: session.user.email ?? undefined,
|
||||||
|
description: `Removed member ${memberToRemove.userId} from permission group "${result.group.name}"`,
|
||||||
|
metadata: { targetUserId: memberToRemove.userId, memberId, permissionGroupId: id },
|
||||||
|
request: req,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error removing member from permission group', error)
|
logger.error('Error removing member from permission group', error)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { and, eq } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { hasAccessControlAccess } from '@/lib/billing'
|
import { hasAccessControlAccess } from '@/lib/billing'
|
||||||
import {
|
import {
|
||||||
@@ -181,6 +182,19 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
.where(eq(permissionGroup.id, id))
|
.where(eq(permissionGroup.id, id))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: null,
|
||||||
|
actorId: session.user.id,
|
||||||
|
action: AuditAction.PERMISSION_GROUP_UPDATED,
|
||||||
|
resourceType: AuditResourceType.PERMISSION_GROUP,
|
||||||
|
resourceId: id,
|
||||||
|
actorName: session.user.name ?? undefined,
|
||||||
|
actorEmail: session.user.email ?? undefined,
|
||||||
|
resourceName: updated.name,
|
||||||
|
description: `Updated permission group "${updated.name}"`,
|
||||||
|
request: req,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
permissionGroup: {
|
permissionGroup: {
|
||||||
...updated,
|
...updated,
|
||||||
@@ -229,6 +243,19 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
|
|||||||
|
|
||||||
logger.info('Deleted permission group', { permissionGroupId: id, userId: session.user.id })
|
logger.info('Deleted permission group', { permissionGroupId: id, userId: session.user.id })
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: null,
|
||||||
|
actorId: session.user.id,
|
||||||
|
action: AuditAction.PERMISSION_GROUP_DELETED,
|
||||||
|
resourceType: AuditResourceType.PERMISSION_GROUP,
|
||||||
|
resourceId: id,
|
||||||
|
actorName: session.user.name ?? undefined,
|
||||||
|
actorEmail: session.user.email ?? undefined,
|
||||||
|
resourceName: result.group.name,
|
||||||
|
description: `Deleted permission group "${result.group.name}"`,
|
||||||
|
request: req,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error deleting permission group', error)
|
logger.error('Error deleting permission group', error)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { and, count, desc, eq } from 'drizzle-orm'
|
import { and, count, desc, eq } from 'drizzle-orm'
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { hasAccessControlAccess } from '@/lib/billing'
|
import { hasAccessControlAccess } from '@/lib/billing'
|
||||||
import {
|
import {
|
||||||
@@ -198,6 +199,19 @@ export async function POST(req: Request) {
|
|||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: null,
|
||||||
|
actorId: session.user.id,
|
||||||
|
action: AuditAction.PERMISSION_GROUP_CREATED,
|
||||||
|
resourceType: AuditResourceType.PERMISSION_GROUP,
|
||||||
|
resourceId: newGroup.id,
|
||||||
|
actorName: session.user.name ?? undefined,
|
||||||
|
actorEmail: session.user.email ?? undefined,
|
||||||
|
resourceName: name,
|
||||||
|
description: `Created permission group "${name}"`,
|
||||||
|
request: req,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({ permissionGroup: newGroup }, { status: 201 })
|
return NextResponse.json({ permissionGroup: newGroup }, { status: 201 })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*
|
*
|
||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
import { databaseMock, loggerMock } from '@sim/testing'
|
import { auditMock, databaseMock, loggerMock } from '@sim/testing'
|
||||||
import { NextRequest } from 'next/server'
|
import { NextRequest } from 'next/server'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
@@ -37,6 +37,8 @@ vi.mock('@/lib/core/utils/request', () => ({
|
|||||||
|
|
||||||
vi.mock('@sim/logger', () => loggerMock)
|
vi.mock('@sim/logger', () => loggerMock)
|
||||||
|
|
||||||
|
vi.mock('@/lib/audit/log', () => auditMock)
|
||||||
|
|
||||||
import { PUT } from './route'
|
import { PUT } from './route'
|
||||||
|
|
||||||
function createRequest(body: Record<string, unknown>): NextRequest {
|
function createRequest(body: Record<string, unknown>): NextRequest {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { eq } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { validateCronExpression } from '@/lib/workflows/schedules/utils'
|
import { validateCronExpression } from '@/lib/workflows/schedules/utils'
|
||||||
@@ -106,6 +107,18 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
|||||||
|
|
||||||
logger.info(`[${requestId}] Reactivated schedule: ${scheduleId}`)
|
logger.info(`[${requestId}] Reactivated schedule: ${scheduleId}`)
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: authorization.workflow.workspaceId ?? null,
|
||||||
|
actorId: session.user.id,
|
||||||
|
action: AuditAction.SCHEDULE_UPDATED,
|
||||||
|
resourceType: AuditResourceType.SCHEDULE,
|
||||||
|
resourceId: scheduleId,
|
||||||
|
actorName: session.user.name ?? undefined,
|
||||||
|
actorEmail: session.user.email ?? undefined,
|
||||||
|
description: `Reactivated schedule for workflow ${schedule.workflowId}`,
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
message: 'Schedule activated successfully',
|
message: 'Schedule activated successfully',
|
||||||
nextRunAt,
|
nextRunAt,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { eq, sql } from 'drizzle-orm'
|
import { eq, sql } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import {
|
import {
|
||||||
@@ -247,6 +248,18 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
|||||||
|
|
||||||
logger.info(`[${requestId}] Successfully updated template: ${id}`)
|
logger.info(`[${requestId}] Successfully updated template: ${id}`)
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
actorId: session.user.id,
|
||||||
|
actorName: session.user.name,
|
||||||
|
actorEmail: session.user.email,
|
||||||
|
action: AuditAction.TEMPLATE_UPDATED,
|
||||||
|
resourceType: AuditResourceType.TEMPLATE,
|
||||||
|
resourceId: id,
|
||||||
|
resourceName: name ?? template.name,
|
||||||
|
description: `Updated template "${name ?? template.name}"`,
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
data: updatedTemplate[0],
|
data: updatedTemplate[0],
|
||||||
message: 'Template updated successfully',
|
message: 'Template updated successfully',
|
||||||
@@ -300,6 +313,19 @@ export async function DELETE(
|
|||||||
await db.delete(templates).where(eq(templates.id, id))
|
await db.delete(templates).where(eq(templates.id, id))
|
||||||
|
|
||||||
logger.info(`[${requestId}] Deleted template: ${id}`)
|
logger.info(`[${requestId}] Deleted template: ${id}`)
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
actorId: session.user.id,
|
||||||
|
actorName: session.user.name,
|
||||||
|
actorEmail: session.user.email,
|
||||||
|
action: AuditAction.TEMPLATE_DELETED,
|
||||||
|
resourceType: AuditResourceType.TEMPLATE,
|
||||||
|
resourceId: id,
|
||||||
|
resourceName: template.name,
|
||||||
|
description: `Deleted template "${template.name}"`,
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error(`[${requestId}] Error deleting template: ${id}`, error)
|
logger.error(`[${requestId}] Error deleting template: ${id}`, error)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { and, desc, eq, ilike, or, sql } from 'drizzle-orm'
|
|||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { verifyEffectiveSuperUser } from '@/lib/templates/permissions'
|
import { verifyEffectiveSuperUser } from '@/lib/templates/permissions'
|
||||||
@@ -285,6 +286,18 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
logger.info(`[${requestId}] Successfully created template: ${templateId}`)
|
logger.info(`[${requestId}] Successfully created template: ${templateId}`)
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
actorId: session.user.id,
|
||||||
|
actorName: session.user.name,
|
||||||
|
actorEmail: session.user.email,
|
||||||
|
action: AuditAction.TEMPLATE_CREATED,
|
||||||
|
resourceType: AuditResourceType.TEMPLATE,
|
||||||
|
resourceId: templateId,
|
||||||
|
resourceName: data.name,
|
||||||
|
description: `Created template "${data.name}"`,
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
id: templateId,
|
id: templateId,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { apiKey } from '@sim/db/schema'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { and, eq } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
|
|
||||||
@@ -34,12 +35,27 @@ export async function DELETE(
|
|||||||
const result = await db
|
const result = await db
|
||||||
.delete(apiKey)
|
.delete(apiKey)
|
||||||
.where(and(eq(apiKey.id, keyId), eq(apiKey.userId, userId)))
|
.where(and(eq(apiKey.id, keyId), eq(apiKey.userId, userId)))
|
||||||
.returning({ id: apiKey.id })
|
.returning({ id: apiKey.id, name: apiKey.name })
|
||||||
|
|
||||||
if (!result.length) {
|
if (!result.length) {
|
||||||
return NextResponse.json({ error: 'API key not found' }, { status: 404 })
|
return NextResponse.json({ error: 'API key not found' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deletedKey = result[0]
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: null,
|
||||||
|
actorId: userId,
|
||||||
|
action: AuditAction.PERSONAL_API_KEY_REVOKED,
|
||||||
|
resourceType: AuditResourceType.API_KEY,
|
||||||
|
resourceId: keyId,
|
||||||
|
actorName: session.user.name ?? undefined,
|
||||||
|
actorEmail: session.user.email ?? undefined,
|
||||||
|
resourceName: deletedKey.name,
|
||||||
|
description: `Revoked personal API key: ${deletedKey.name}`,
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to delete API key', { error })
|
logger.error('Failed to delete API key', { error })
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm'
|
|||||||
import { nanoid } from 'nanoid'
|
import { nanoid } from 'nanoid'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/auth'
|
import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/auth'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
|
|
||||||
const logger = createLogger('ApiKeysAPI')
|
const logger = createLogger('ApiKeysAPI')
|
||||||
@@ -110,6 +111,19 @@ export async function POST(request: NextRequest) {
|
|||||||
createdAt: apiKey.createdAt,
|
createdAt: apiKey.createdAt,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: null,
|
||||||
|
actorId: userId,
|
||||||
|
action: AuditAction.PERSONAL_API_KEY_CREATED,
|
||||||
|
resourceType: AuditResourceType.API_KEY,
|
||||||
|
resourceId: newKey.id,
|
||||||
|
actorName: session.user.name ?? undefined,
|
||||||
|
actorEmail: session.user.email ?? undefined,
|
||||||
|
resourceName: name,
|
||||||
|
description: `Created personal API key: ${name}`,
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
key: {
|
key: {
|
||||||
...newKey,
|
...newKey,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { webhook, workflow } from '@sim/db/schema'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { and, eq } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||||
import { validateInteger } from '@/lib/core/security/input-validation'
|
import { validateInteger } from '@/lib/core/security/input-validation'
|
||||||
import { PlatformEvents } from '@/lib/core/telemetry'
|
import { PlatformEvents } from '@/lib/core/telemetry'
|
||||||
@@ -261,6 +262,20 @@ export async function DELETE(
|
|||||||
logger.info(`[${requestId}] Successfully deleted webhook: ${id}`)
|
logger.info(`[${requestId}] Successfully deleted webhook: ${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: webhookData.workflow.workspaceId || null,
|
||||||
|
actorId: userId,
|
||||||
|
actorName: auth.userName,
|
||||||
|
actorEmail: auth.userEmail,
|
||||||
|
action: AuditAction.WEBHOOK_DELETED,
|
||||||
|
resourceType: AuditResourceType.WEBHOOK,
|
||||||
|
resourceId: id,
|
||||||
|
resourceName: foundWebhook.provider || 'generic',
|
||||||
|
description: 'Deleted webhook',
|
||||||
|
metadata: { workflowId: webhookData.workflow.id },
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({ success: true }, { status: 200 })
|
return NextResponse.json({ success: true }, { status: 200 })
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error(`[${requestId}] Error deleting webhook`, {
|
logger.error(`[${requestId}] Error deleting webhook`, {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { and, desc, eq, inArray, isNull, or } from 'drizzle-orm'
|
import { and, desc, eq, inArray, isNull, or } from 'drizzle-orm'
|
||||||
import { nanoid } from 'nanoid'
|
import { nanoid } from 'nanoid'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { PlatformEvents } from '@/lib/core/telemetry'
|
import { PlatformEvents } from '@/lib/core/telemetry'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
@@ -145,7 +146,8 @@ export async function GET(request: NextRequest) {
|
|||||||
// Create or Update a webhook
|
// Create or Update a webhook
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const requestId = generateRequestId()
|
const requestId = generateRequestId()
|
||||||
const userId = (await getSession())?.user?.id
|
const session = await getSession()
|
||||||
|
const userId = session?.user?.id
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
logger.warn(`[${requestId}] Unauthorized webhook creation attempt`)
|
logger.warn(`[${requestId}] Unauthorized webhook creation attempt`)
|
||||||
@@ -678,6 +680,20 @@ export async function POST(request: NextRequest) {
|
|||||||
} catch {
|
} catch {
|
||||||
// Telemetry should not fail the operation
|
// Telemetry should not fail the operation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: workflowRecord.workspaceId || null,
|
||||||
|
actorId: userId,
|
||||||
|
actorName: session?.user?.name ?? undefined,
|
||||||
|
actorEmail: session?.user?.email ?? undefined,
|
||||||
|
action: AuditAction.WEBHOOK_CREATED,
|
||||||
|
resourceType: AuditResourceType.WEBHOOK,
|
||||||
|
resourceId: savedWebhook.id,
|
||||||
|
resourceName: provider || 'generic',
|
||||||
|
description: `Created ${provider || 'generic'} webhook`,
|
||||||
|
metadata: { provider, workflowId },
|
||||||
|
request,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = targetWebhookId ? 200 : 201
|
const status = targetWebhookId ? 200 : 201
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { db, workflow, workflowDeploymentVersion } from '@sim/db'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { and, desc, eq } from 'drizzle-orm'
|
import { and, desc, eq } from 'drizzle-orm'
|
||||||
import type { NextRequest } from 'next/server'
|
import type { NextRequest } from 'next/server'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { removeMcpToolsForWorkflow, syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
|
import { removeMcpToolsForWorkflow, syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
|
||||||
import {
|
import {
|
||||||
@@ -258,6 +259,19 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
// Sync MCP tools with the latest parameter schema
|
// Sync MCP tools with the latest parameter schema
|
||||||
await syncMcpToolsForWorkflow({ workflowId: id, requestId, context: 'deploy' })
|
await syncMcpToolsForWorkflow({ workflowId: id, requestId, context: 'deploy' })
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: workflowData?.workspaceId || null,
|
||||||
|
actorId: actorUserId,
|
||||||
|
actorName: session?.user?.name,
|
||||||
|
actorEmail: session?.user?.email,
|
||||||
|
action: AuditAction.WORKFLOW_DEPLOYED,
|
||||||
|
resourceType: AuditResourceType.WORKFLOW,
|
||||||
|
resourceId: id,
|
||||||
|
resourceName: workflowData?.name,
|
||||||
|
description: `Deployed workflow "${workflowData?.name || id}"`,
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
const responseApiKeyInfo = workflowData!.workspaceId
|
const responseApiKeyInfo = workflowData!.workspaceId
|
||||||
? 'Workspace API keys'
|
? 'Workspace API keys'
|
||||||
: 'Personal API keys'
|
: 'Personal API keys'
|
||||||
@@ -297,11 +311,11 @@ export async function DELETE(
|
|||||||
try {
|
try {
|
||||||
logger.debug(`[${requestId}] Undeploying workflow: ${id}`)
|
logger.debug(`[${requestId}] Undeploying workflow: ${id}`)
|
||||||
|
|
||||||
const { error, workflow: workflowData } = await validateWorkflowPermissions(
|
const {
|
||||||
id,
|
error,
|
||||||
requestId,
|
session,
|
||||||
'admin'
|
workflow: workflowData,
|
||||||
)
|
} = await validateWorkflowPermissions(id, requestId, 'admin')
|
||||||
if (error) {
|
if (error) {
|
||||||
return createErrorResponse(error.message, error.status)
|
return createErrorResponse(error.message, error.status)
|
||||||
}
|
}
|
||||||
@@ -325,6 +339,19 @@ export async function DELETE(
|
|||||||
// Silently fail
|
// Silently fail
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: workflowData?.workspaceId || null,
|
||||||
|
actorId: session!.user.id,
|
||||||
|
actorName: session?.user?.name,
|
||||||
|
actorEmail: session?.user?.email,
|
||||||
|
action: AuditAction.WORKFLOW_UNDEPLOYED,
|
||||||
|
resourceType: AuditResourceType.WORKFLOW,
|
||||||
|
resourceId: id,
|
||||||
|
resourceName: workflowData?.name,
|
||||||
|
description: `Undeployed workflow "${workflowData?.name || id}"`,
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
return createSuccessResponse({
|
return createSuccessResponse({
|
||||||
isDeployed: false,
|
isDeployed: false,
|
||||||
deployedAt: null,
|
deployedAt: null,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { db, workflow, workflowDeploymentVersion } from '@sim/db'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { and, eq } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
import type { NextRequest } from 'next/server'
|
import type { NextRequest } from 'next/server'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { env } from '@/lib/core/config/env'
|
import { env } from '@/lib/core/config/env'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
|
import { syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
|
||||||
@@ -22,7 +23,11 @@ export async function POST(
|
|||||||
const { id, version } = await params
|
const { id, version } = await params
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { error } = await validateWorkflowPermissions(id, requestId, 'admin')
|
const {
|
||||||
|
error,
|
||||||
|
session,
|
||||||
|
workflow: workflowRecord,
|
||||||
|
} = await validateWorkflowPermissions(id, requestId, 'admin')
|
||||||
if (error) {
|
if (error) {
|
||||||
return createErrorResponse(error.message, error.status)
|
return createErrorResponse(error.message, error.status)
|
||||||
}
|
}
|
||||||
@@ -107,6 +112,19 @@ export async function POST(
|
|||||||
logger.error('Error sending workflow reverted event to socket server', e)
|
logger.error('Error sending workflow reverted event to socket server', e)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: workflowRecord?.workspaceId ?? null,
|
||||||
|
actorId: session!.user.id,
|
||||||
|
action: AuditAction.WORKFLOW_DEPLOYMENT_REVERTED,
|
||||||
|
resourceType: AuditResourceType.WORKFLOW,
|
||||||
|
resourceId: id,
|
||||||
|
actorName: session!.user.name ?? undefined,
|
||||||
|
actorEmail: session!.user.email ?? undefined,
|
||||||
|
resourceName: workflowRecord?.name ?? undefined,
|
||||||
|
description: `Reverted workflow to deployment version ${version}`,
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
return createSuccessResponse({
|
return createSuccessResponse({
|
||||||
message: 'Reverted to deployment version',
|
message: 'Reverted to deployment version',
|
||||||
lastSaved: Date.now(),
|
lastSaved: Date.now(),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { and, eq } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
import type { NextRequest } from 'next/server'
|
import type { NextRequest } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
|
import { syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
|
||||||
import { restorePreviousVersionWebhooks, saveTriggerWebhooksForDeploy } from '@/lib/webhooks/deploy'
|
import { restorePreviousVersionWebhooks, saveTriggerWebhooksForDeploy } from '@/lib/webhooks/deploy'
|
||||||
@@ -297,6 +298,19 @@ export async function PATCH(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: workflowData?.workspaceId,
|
||||||
|
actorId: actorUserId,
|
||||||
|
actorName: session?.user?.name,
|
||||||
|
actorEmail: session?.user?.email,
|
||||||
|
action: AuditAction.WORKFLOW_DEPLOYMENT_ACTIVATED,
|
||||||
|
resourceType: AuditResourceType.WORKFLOW,
|
||||||
|
resourceId: id,
|
||||||
|
description: `Activated deployment version ${versionNum}`,
|
||||||
|
metadata: { version: versionNum },
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
return createSuccessResponse({
|
return createSuccessResponse({
|
||||||
success: true,
|
success: true,
|
||||||
deployedAt: result.deployedAt,
|
deployedAt: result.deployedAt,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||||
import { PlatformEvents } from '@/lib/core/telemetry'
|
import { PlatformEvents } from '@/lib/core/telemetry'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
@@ -61,6 +62,20 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
`[${requestId}] Successfully duplicated workflow ${sourceWorkflowId} to ${result.id} in ${elapsed}ms`
|
`[${requestId}] Successfully duplicated workflow ${sourceWorkflowId} to ${result.id} in ${elapsed}ms`
|
||||||
)
|
)
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: workspaceId || null,
|
||||||
|
actorId: userId,
|
||||||
|
actorName: auth.userName,
|
||||||
|
actorEmail: auth.userEmail,
|
||||||
|
action: AuditAction.WORKFLOW_DUPLICATED,
|
||||||
|
resourceType: AuditResourceType.WORKFLOW,
|
||||||
|
resourceId: result.id,
|
||||||
|
resourceName: result.name,
|
||||||
|
description: `Duplicated workflow from ${sourceWorkflowId}`,
|
||||||
|
metadata: { sourceWorkflowId },
|
||||||
|
request: req,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json(result, { status: 201 })
|
return NextResponse.json(result, { status: 201 })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { loggerMock, setupGlobalFetchMock } from '@sim/testing'
|
import { auditMock, loggerMock, setupGlobalFetchMock } from '@sim/testing'
|
||||||
import { NextRequest } from 'next/server'
|
import { NextRequest } from 'next/server'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
@@ -23,6 +23,8 @@ vi.mock('@/lib/auth', () => ({
|
|||||||
|
|
||||||
vi.mock('@sim/logger', () => loggerMock)
|
vi.mock('@sim/logger', () => loggerMock)
|
||||||
|
|
||||||
|
vi.mock('@/lib/audit/log', () => auditMock)
|
||||||
|
|
||||||
vi.mock('@/lib/workflows/persistence/utils', () => ({
|
vi.mock('@/lib/workflows/persistence/utils', () => ({
|
||||||
loadWorkflowFromNormalizedTables: (workflowId: string) =>
|
loadWorkflowFromNormalizedTables: (workflowId: string) =>
|
||||||
mockLoadWorkflowFromNormalizedTables(workflowId),
|
mockLoadWorkflowFromNormalizedTables(workflowId),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { eq } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { checkHybridAuth, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
import { checkHybridAuth, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||||
import { env } from '@/lib/core/config/env'
|
import { env } from '@/lib/core/config/env'
|
||||||
import { PlatformEvents } from '@/lib/core/telemetry'
|
import { PlatformEvents } from '@/lib/core/telemetry'
|
||||||
@@ -336,6 +337,19 @@ export async function DELETE(
|
|||||||
// Don't fail the deletion if Socket.IO notification fails
|
// Don't fail the deletion if Socket.IO notification fails
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: workflowData.workspaceId || null,
|
||||||
|
actorId: userId,
|
||||||
|
actorName: auth.userName,
|
||||||
|
actorEmail: auth.userEmail,
|
||||||
|
action: AuditAction.WORKFLOW_DELETED,
|
||||||
|
resourceType: AuditResourceType.WORKFLOW,
|
||||||
|
resourceId: workflowId,
|
||||||
|
resourceName: workflowData.name,
|
||||||
|
description: `Deleted workflow "${workflowData.name}"`,
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({ success: true }, { status: 200 })
|
return NextResponse.json({ success: true }, { status: 200 })
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const elapsed = Date.now() - startTime
|
const elapsed = Date.now() - startTime
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
|
auditMock,
|
||||||
databaseMock,
|
databaseMock,
|
||||||
defaultMockUser,
|
defaultMockUser,
|
||||||
mockAuth,
|
mockAuth,
|
||||||
@@ -27,6 +28,8 @@ describe('Workflow Variables API Route', () => {
|
|||||||
|
|
||||||
vi.doMock('@sim/db', () => databaseMock)
|
vi.doMock('@sim/db', () => databaseMock)
|
||||||
|
|
||||||
|
vi.doMock('@/lib/audit/log', () => auditMock)
|
||||||
|
|
||||||
vi.doMock('@/lib/workflows/utils', () => ({
|
vi.doMock('@/lib/workflows/utils', () => ({
|
||||||
authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission,
|
authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { eq } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||||
@@ -79,6 +80,19 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
})
|
})
|
||||||
.where(eq(workflow.id, workflowId))
|
.where(eq(workflow.id, workflowId))
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: workflowData.workspaceId ?? null,
|
||||||
|
actorId: userId,
|
||||||
|
actorName: auth.userName,
|
||||||
|
actorEmail: auth.userEmail,
|
||||||
|
action: AuditAction.WORKFLOW_VARIABLES_UPDATED,
|
||||||
|
resourceType: AuditResourceType.WORKFLOW,
|
||||||
|
resourceId: workflowId,
|
||||||
|
resourceName: workflowData.name ?? undefined,
|
||||||
|
description: `Updated workflow variables`,
|
||||||
|
request: req,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (validationError) {
|
} catch (validationError) {
|
||||||
if (validationError instanceof z.ZodError) {
|
if (validationError instanceof z.ZodError) {
|
||||||
|
|||||||
137
apps/sim/app/api/workflows/route.test.ts
Normal file
137
apps/sim/app/api/workflows/route.test.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment node
|
||||||
|
*/
|
||||||
|
import { auditMock, createMockRequest, mockConsoleLogger, setupCommonApiMocks } from '@sim/testing'
|
||||||
|
import { drizzleOrmMock } from '@sim/testing/mocks'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const mockCheckSessionOrInternalAuth = vi.fn()
|
||||||
|
const mockGetUserEntityPermissions = vi.fn()
|
||||||
|
const mockDbSelect = vi.fn()
|
||||||
|
const mockDbInsert = vi.fn()
|
||||||
|
const mockWorkflowCreated = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('drizzle-orm', () => ({
|
||||||
|
...drizzleOrmMock,
|
||||||
|
min: vi.fn((field) => ({ type: 'min', field })),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/audit/log', () => auditMock)
|
||||||
|
|
||||||
|
describe('Workflows API Route - POST ordering', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
|
||||||
|
setupCommonApiMocks()
|
||||||
|
mockConsoleLogger()
|
||||||
|
|
||||||
|
vi.stubGlobal('crypto', {
|
||||||
|
randomUUID: vi.fn().mockReturnValue('workflow-new-id'),
|
||||||
|
})
|
||||||
|
|
||||||
|
mockCheckSessionOrInternalAuth.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
userId: 'user-123',
|
||||||
|
userName: 'Test User',
|
||||||
|
userEmail: 'test@example.com',
|
||||||
|
})
|
||||||
|
mockGetUserEntityPermissions.mockResolvedValue('write')
|
||||||
|
|
||||||
|
vi.doMock('@sim/db', () => ({
|
||||||
|
db: {
|
||||||
|
select: (...args: unknown[]) => mockDbSelect(...args),
|
||||||
|
insert: (...args: unknown[]) => mockDbInsert(...args),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.doMock('@/lib/auth/hybrid', () => ({
|
||||||
|
checkSessionOrInternalAuth: (...args: unknown[]) => mockCheckSessionOrInternalAuth(...args),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.doMock('@/lib/workspaces/permissions/utils', () => ({
|
||||||
|
getUserEntityPermissions: (...args: unknown[]) => mockGetUserEntityPermissions(...args),
|
||||||
|
workspaceExists: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.doMock('@/app/api/workflows/utils', () => ({
|
||||||
|
verifyWorkspaceMembership: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.doMock('@/lib/core/telemetry', () => ({
|
||||||
|
PlatformEvents: {
|
||||||
|
workflowCreated: (...args: unknown[]) => mockWorkflowCreated(...args),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses top insertion against mixed siblings (folders + workflows)', async () => {
|
||||||
|
const minResultsQueue: Array<Array<{ minOrder: number }>> = [
|
||||||
|
[{ minOrder: 5 }],
|
||||||
|
[{ minOrder: 2 }],
|
||||||
|
]
|
||||||
|
|
||||||
|
mockDbSelect.mockImplementation(() => ({
|
||||||
|
from: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockImplementation(() => Promise.resolve(minResultsQueue.shift() ?? [])),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
let insertedValues: Record<string, unknown> | null = null
|
||||||
|
mockDbInsert.mockReturnValue({
|
||||||
|
values: vi.fn().mockImplementation((values: Record<string, unknown>) => {
|
||||||
|
insertedValues = values
|
||||||
|
return Promise.resolve(undefined)
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const req = createMockRequest('POST', {
|
||||||
|
name: 'New Workflow',
|
||||||
|
description: 'desc',
|
||||||
|
color: '#3972F6',
|
||||||
|
workspaceId: 'workspace-123',
|
||||||
|
folderId: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { POST } = await import('@/app/api/workflows/route')
|
||||||
|
const response = await POST(req)
|
||||||
|
const data = await response.json()
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(data.sortOrder).toBe(1)
|
||||||
|
expect(insertedValues).not.toBeNull()
|
||||||
|
expect(insertedValues?.sortOrder).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults to sortOrder 0 when there are no siblings', async () => {
|
||||||
|
const minResultsQueue: Array<Array<{ minOrder: number }>> = [[], []]
|
||||||
|
|
||||||
|
mockDbSelect.mockImplementation(() => ({
|
||||||
|
from: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockImplementation(() => Promise.resolve(minResultsQueue.shift() ?? [])),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
let insertedValues: Record<string, unknown> | null = null
|
||||||
|
mockDbInsert.mockReturnValue({
|
||||||
|
values: vi.fn().mockImplementation((values: Record<string, unknown>) => {
|
||||||
|
insertedValues = values
|
||||||
|
return Promise.resolve(undefined)
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const req = createMockRequest('POST', {
|
||||||
|
name: 'New Workflow',
|
||||||
|
description: 'desc',
|
||||||
|
color: '#3972F6',
|
||||||
|
workspaceId: 'workspace-123',
|
||||||
|
folderId: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { POST } = await import('@/app/api/workflows/route')
|
||||||
|
const response = await POST(req)
|
||||||
|
const data = await response.json()
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(data.sortOrder).toBe(0)
|
||||||
|
expect(insertedValues?.sortOrder).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { db } from '@sim/db'
|
import { db } from '@sim/db'
|
||||||
import { permissions, workflow } from '@sim/db/schema'
|
import { permissions, workflow, workflowFolder } from '@sim/db/schema'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { and, asc, eq, inArray, isNull, min } from 'drizzle-orm'
|
import { and, asc, eq, inArray, isNull, min } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { getUserEntityPermissions, workspaceExists } from '@/lib/workspaces/permissions/utils'
|
import { getUserEntityPermissions, workspaceExists } from '@/lib/workspaces/permissions/utils'
|
||||||
@@ -161,12 +162,33 @@ export async function POST(req: NextRequest) {
|
|||||||
if (providedSortOrder !== undefined) {
|
if (providedSortOrder !== undefined) {
|
||||||
sortOrder = providedSortOrder
|
sortOrder = providedSortOrder
|
||||||
} else {
|
} else {
|
||||||
const folderCondition = folderId ? eq(workflow.folderId, folderId) : isNull(workflow.folderId)
|
const workflowParentCondition = folderId
|
||||||
const [minResult] = await db
|
? eq(workflow.folderId, folderId)
|
||||||
.select({ minOrder: min(workflow.sortOrder) })
|
: isNull(workflow.folderId)
|
||||||
.from(workflow)
|
const folderParentCondition = folderId
|
||||||
.where(and(eq(workflow.workspaceId, workspaceId), folderCondition))
|
? eq(workflowFolder.parentId, folderId)
|
||||||
sortOrder = (minResult?.minOrder ?? 1) - 1
|
: isNull(workflowFolder.parentId)
|
||||||
|
|
||||||
|
const [[workflowMinResult], [folderMinResult]] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({ minOrder: min(workflow.sortOrder) })
|
||||||
|
.from(workflow)
|
||||||
|
.where(and(eq(workflow.workspaceId, workspaceId), workflowParentCondition)),
|
||||||
|
db
|
||||||
|
.select({ minOrder: min(workflowFolder.sortOrder) })
|
||||||
|
.from(workflowFolder)
|
||||||
|
.where(and(eq(workflowFolder.workspaceId, workspaceId), folderParentCondition)),
|
||||||
|
])
|
||||||
|
|
||||||
|
const minSortOrder = [workflowMinResult?.minOrder, folderMinResult?.minOrder].reduce<
|
||||||
|
number | null
|
||||||
|
>((currentMin, candidate) => {
|
||||||
|
if (candidate == null) return currentMin
|
||||||
|
if (currentMin == null) return candidate
|
||||||
|
return Math.min(currentMin, candidate)
|
||||||
|
}, null)
|
||||||
|
|
||||||
|
sortOrder = minSortOrder != null ? minSortOrder - 1 : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.insert(workflow).values({
|
await db.insert(workflow).values({
|
||||||
@@ -188,6 +210,20 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
logger.info(`[${requestId}] Successfully created empty workflow ${workflowId}`)
|
logger.info(`[${requestId}] Successfully created empty workflow ${workflowId}`)
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId,
|
||||||
|
actorId: userId,
|
||||||
|
actorName: auth.userName,
|
||||||
|
actorEmail: auth.userEmail,
|
||||||
|
action: AuditAction.WORKFLOW_CREATED,
|
||||||
|
resourceType: AuditResourceType.WORKFLOW,
|
||||||
|
resourceId: workflowId,
|
||||||
|
resourceName: name,
|
||||||
|
description: `Created workflow "${name}"`,
|
||||||
|
metadata: { name },
|
||||||
|
request: req,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
id: workflowId,
|
id: workflowId,
|
||||||
name,
|
name,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { and, eq, not } from 'drizzle-orm'
|
import { and, eq, not } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||||
@@ -86,6 +87,19 @@ export async function PUT(
|
|||||||
updatedAt: apiKey.updatedAt,
|
updatedAt: apiKey.updatedAt,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId,
|
||||||
|
actorId: userId,
|
||||||
|
action: AuditAction.API_KEY_UPDATED,
|
||||||
|
resourceType: AuditResourceType.API_KEY,
|
||||||
|
resourceId: keyId,
|
||||||
|
actorName: session.user.name ?? undefined,
|
||||||
|
actorEmail: session.user.email ?? undefined,
|
||||||
|
resourceName: name,
|
||||||
|
description: `Updated workspace API key: ${name}`,
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
logger.info(`[${requestId}] Updated workspace API key: ${keyId} in workspace ${workspaceId}`)
|
logger.info(`[${requestId}] Updated workspace API key: ${keyId} in workspace ${workspaceId}`)
|
||||||
return NextResponse.json({ key: updatedKey })
|
return NextResponse.json({ key: updatedKey })
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@@ -123,12 +137,27 @@ export async function DELETE(
|
|||||||
.where(
|
.where(
|
||||||
and(eq(apiKey.workspaceId, workspaceId), eq(apiKey.id, keyId), eq(apiKey.type, 'workspace'))
|
and(eq(apiKey.workspaceId, workspaceId), eq(apiKey.id, keyId), eq(apiKey.type, 'workspace'))
|
||||||
)
|
)
|
||||||
.returning({ id: apiKey.id })
|
.returning({ id: apiKey.id, name: apiKey.name })
|
||||||
|
|
||||||
if (deletedRows.length === 0) {
|
if (deletedRows.length === 0) {
|
||||||
return NextResponse.json({ error: 'API key not found' }, { status: 404 })
|
return NextResponse.json({ error: 'API key not found' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deletedKey = deletedRows[0]
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId,
|
||||||
|
actorId: userId,
|
||||||
|
action: AuditAction.API_KEY_REVOKED,
|
||||||
|
resourceType: AuditResourceType.API_KEY,
|
||||||
|
resourceId: keyId,
|
||||||
|
actorName: session.user.name ?? undefined,
|
||||||
|
actorEmail: session.user.email ?? undefined,
|
||||||
|
resourceName: deletedKey.name,
|
||||||
|
description: `Revoked workspace API key: ${deletedKey.name}`,
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
logger.info(`[${requestId}] Deleted workspace API key: ${keyId} from workspace ${workspaceId}`)
|
logger.info(`[${requestId}] Deleted workspace API key: ${keyId} from workspace ${workspaceId}`)
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { nanoid } from 'nanoid'
|
|||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/auth'
|
import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/auth'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { PlatformEvents } from '@/lib/core/telemetry'
|
import { PlatformEvents } from '@/lib/core/telemetry'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
@@ -159,6 +160,20 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
|
|
||||||
logger.info(`[${requestId}] Created workspace API key: ${name} in workspace ${workspaceId}`)
|
logger.info(`[${requestId}] Created workspace API key: ${name} in workspace ${workspaceId}`)
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId,
|
||||||
|
actorId: userId,
|
||||||
|
actorName: session?.user?.name,
|
||||||
|
actorEmail: session?.user?.email,
|
||||||
|
action: AuditAction.API_KEY_CREATED,
|
||||||
|
resourceType: AuditResourceType.API_KEY,
|
||||||
|
resourceId: newKey.id,
|
||||||
|
resourceName: name,
|
||||||
|
description: `Created API key "${name}"`,
|
||||||
|
metadata: { keyName: name },
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
key: {
|
key: {
|
||||||
...newKey,
|
...newKey,
|
||||||
@@ -222,6 +237,19 @@ export async function DELETE(
|
|||||||
logger.info(
|
logger.info(
|
||||||
`[${requestId}] Deleted ${deletedCount} workspace API keys from workspace ${workspaceId}`
|
`[${requestId}] Deleted ${deletedCount} workspace API keys from workspace ${workspaceId}`
|
||||||
)
|
)
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId,
|
||||||
|
actorId: userId,
|
||||||
|
actorName: session?.user?.name,
|
||||||
|
actorEmail: session?.user?.email,
|
||||||
|
action: AuditAction.API_KEY_REVOKED,
|
||||||
|
resourceType: AuditResourceType.API_KEY,
|
||||||
|
description: `Revoked ${deletedCount} API key(s)`,
|
||||||
|
metadata: { keyIds: keys, deletedCount },
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({ success: true, deletedCount })
|
return NextResponse.json({ success: true, deletedCount })
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
logger.error(`[${requestId}] Workspace API key DELETE error`, error)
|
logger.error(`[${requestId}] Workspace API key DELETE error`, error)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm'
|
|||||||
import { nanoid } from 'nanoid'
|
import { nanoid } from 'nanoid'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
|
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
@@ -185,6 +186,20 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
|
|
||||||
logger.info(`[${requestId}] Created BYOK key for ${providerId} in workspace ${workspaceId}`)
|
logger.info(`[${requestId}] Created BYOK key for ${providerId} in workspace ${workspaceId}`)
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId,
|
||||||
|
actorId: userId,
|
||||||
|
actorName: session?.user?.name,
|
||||||
|
actorEmail: session?.user?.email,
|
||||||
|
action: AuditAction.BYOK_KEY_CREATED,
|
||||||
|
resourceType: AuditResourceType.BYOK_KEY,
|
||||||
|
resourceId: newKey.id,
|
||||||
|
resourceName: providerId,
|
||||||
|
description: `Added BYOK key for ${providerId}`,
|
||||||
|
metadata: { providerId },
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
key: {
|
key: {
|
||||||
@@ -242,6 +257,19 @@ export async function DELETE(
|
|||||||
|
|
||||||
logger.info(`[${requestId}] Deleted BYOK key for ${providerId} from workspace ${workspaceId}`)
|
logger.info(`[${requestId}] Deleted BYOK key for ${providerId} from workspace ${workspaceId}`)
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId,
|
||||||
|
actorId: userId,
|
||||||
|
actorName: session?.user?.name,
|
||||||
|
actorEmail: session?.user?.email,
|
||||||
|
action: AuditAction.BYOK_KEY_DELETED,
|
||||||
|
resourceType: AuditResourceType.BYOK_KEY,
|
||||||
|
resourceName: providerId,
|
||||||
|
description: `Removed BYOK key for ${providerId}`,
|
||||||
|
metadata: { providerId },
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
logger.error(`[${requestId}] BYOK key DELETE error`, error)
|
logger.error(`[${requestId}] BYOK key DELETE error`, error)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { duplicateWorkspace } from '@/lib/workspaces/duplicate'
|
import { duplicateWorkspace } from '@/lib/workspaces/duplicate'
|
||||||
@@ -45,6 +46,19 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
`[${requestId}] Successfully duplicated workspace ${sourceWorkspaceId} to ${result.id} in ${elapsed}ms`
|
`[${requestId}] Successfully duplicated workspace ${sourceWorkspaceId} to ${result.id} in ${elapsed}ms`
|
||||||
)
|
)
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: sourceWorkspaceId,
|
||||||
|
actorId: session.user.id,
|
||||||
|
actorName: session.user.name,
|
||||||
|
actorEmail: session.user.email,
|
||||||
|
action: AuditAction.WORKSPACE_DUPLICATED,
|
||||||
|
resourceType: AuditResourceType.WORKSPACE,
|
||||||
|
resourceId: result.id,
|
||||||
|
resourceName: name,
|
||||||
|
description: `Duplicated workspace to "${name}"`,
|
||||||
|
request: req,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json(result, { status: 201 })
|
return NextResponse.json(result, { status: 201 })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { eq } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
|
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
@@ -156,6 +157,19 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
|||||||
set: { variables: merged, updatedAt: new Date() },
|
set: { variables: merged, updatedAt: new Date() },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId,
|
||||||
|
actorId: userId,
|
||||||
|
actorName: session?.user?.name,
|
||||||
|
actorEmail: session?.user?.email,
|
||||||
|
action: AuditAction.ENVIRONMENT_UPDATED,
|
||||||
|
resourceType: AuditResourceType.ENVIRONMENT,
|
||||||
|
resourceId: workspaceId,
|
||||||
|
description: `Updated environment variables`,
|
||||||
|
metadata: { keysUpdated: Object.keys(variables) },
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error(`[${requestId}] Workspace env PUT error`, error)
|
logger.error(`[${requestId}] Workspace env PUT error`, error)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { deleteWorkspaceFile } from '@/lib/uploads/contexts/workspace'
|
import { deleteWorkspaceFile } from '@/lib/uploads/contexts/workspace'
|
||||||
@@ -39,6 +40,18 @@ export async function DELETE(
|
|||||||
|
|
||||||
logger.info(`[${requestId}] Deleted workspace file: ${fileId}`)
|
logger.info(`[${requestId}] Deleted workspace file: ${fileId}`)
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId,
|
||||||
|
actorId: session.user.id,
|
||||||
|
actorName: session.user.name,
|
||||||
|
actorEmail: session.user.email,
|
||||||
|
action: AuditAction.FILE_DELETED,
|
||||||
|
resourceType: AuditResourceType.FILE,
|
||||||
|
resourceId: fileId,
|
||||||
|
description: `Deleted file "${fileId}"`,
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { listWorkspaceFiles, uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace'
|
import { listWorkspaceFiles, uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace'
|
||||||
@@ -104,6 +105,19 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
|
|
||||||
logger.info(`[${requestId}] Uploaded workspace file: ${file.name}`)
|
logger.info(`[${requestId}] Uploaded workspace file: ${file.name}`)
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId,
|
||||||
|
actorId: session.user.id,
|
||||||
|
actorName: session.user.name,
|
||||||
|
actorEmail: session.user.email,
|
||||||
|
action: AuditAction.FILE_UPLOADED,
|
||||||
|
resourceType: AuditResourceType.FILE,
|
||||||
|
resourceId: userFile.id,
|
||||||
|
resourceName: file.name,
|
||||||
|
description: `Uploaded file "${file.name}"`,
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
file: userFile,
|
file: userFile,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { and, eq, inArray } from 'drizzle-orm'
|
import { and, eq, inArray } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { encryptSecret } from '@/lib/core/security/encryption'
|
import { encryptSecret } from '@/lib/core/security/encryption'
|
||||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||||
@@ -251,6 +252,19 @@ export async function PUT(request: NextRequest, { params }: RouteParams) {
|
|||||||
subscriptionId: subscription.id,
|
subscriptionId: subscription.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId,
|
||||||
|
actorId: session.user.id,
|
||||||
|
action: AuditAction.NOTIFICATION_UPDATED,
|
||||||
|
resourceType: AuditResourceType.NOTIFICATION,
|
||||||
|
resourceId: notificationId,
|
||||||
|
resourceName: subscription.notificationType,
|
||||||
|
actorName: session.user.name ?? undefined,
|
||||||
|
actorEmail: session.user.email ?? undefined,
|
||||||
|
description: `Updated ${subscription.notificationType} notification subscription`,
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
data: {
|
data: {
|
||||||
id: subscription.id,
|
id: subscription.id,
|
||||||
@@ -300,17 +314,35 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
|||||||
eq(workspaceNotificationSubscription.workspaceId, workspaceId)
|
eq(workspaceNotificationSubscription.workspaceId, workspaceId)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.returning({ id: workspaceNotificationSubscription.id })
|
.returning({
|
||||||
|
id: workspaceNotificationSubscription.id,
|
||||||
|
notificationType: workspaceNotificationSubscription.notificationType,
|
||||||
|
})
|
||||||
|
|
||||||
if (deleted.length === 0) {
|
if (deleted.length === 0) {
|
||||||
return NextResponse.json({ error: 'Notification not found' }, { status: 404 })
|
return NextResponse.json({ error: 'Notification not found' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deletedSubscription = deleted[0]
|
||||||
|
|
||||||
logger.info('Deleted notification subscription', {
|
logger.info('Deleted notification subscription', {
|
||||||
workspaceId,
|
workspaceId,
|
||||||
subscriptionId: notificationId,
|
subscriptionId: notificationId,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId,
|
||||||
|
actorId: session.user.id,
|
||||||
|
action: AuditAction.NOTIFICATION_DELETED,
|
||||||
|
resourceType: AuditResourceType.NOTIFICATION,
|
||||||
|
resourceId: notificationId,
|
||||||
|
actorName: session.user.name ?? undefined,
|
||||||
|
actorEmail: session.user.email ?? undefined,
|
||||||
|
resourceName: deletedSubscription.notificationType,
|
||||||
|
description: `Deleted ${deletedSubscription.notificationType} notification subscription`,
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error deleting notification', { error })
|
logger.error('Error deleting notification', { error })
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { and, eq, inArray } from 'drizzle-orm'
|
|||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { encryptSecret } from '@/lib/core/security/encryption'
|
import { encryptSecret } from '@/lib/core/security/encryption'
|
||||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||||
@@ -256,6 +257,19 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
type: data.notificationType,
|
type: data.notificationType,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId,
|
||||||
|
actorId: session.user.id,
|
||||||
|
action: AuditAction.NOTIFICATION_CREATED,
|
||||||
|
resourceType: AuditResourceType.NOTIFICATION,
|
||||||
|
resourceId: subscription.id,
|
||||||
|
resourceName: data.notificationType,
|
||||||
|
actorName: session.user.name ?? undefined,
|
||||||
|
actorEmail: session.user.email ?? undefined,
|
||||||
|
description: `Created ${data.notificationType} notification subscription`,
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
data: {
|
data: {
|
||||||
id: subscription.id,
|
id: subscription.id,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { and, eq } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import {
|
import {
|
||||||
getUsersWithPermissions,
|
getUsersWithPermissions,
|
||||||
@@ -156,6 +157,21 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
|||||||
|
|
||||||
const updatedUsers = await getUsersWithPermissions(workspaceId)
|
const updatedUsers = await getUsersWithPermissions(workspaceId)
|
||||||
|
|
||||||
|
for (const update of body.updates) {
|
||||||
|
recordAudit({
|
||||||
|
workspaceId,
|
||||||
|
actorId: session.user.id,
|
||||||
|
action: AuditAction.MEMBER_ROLE_CHANGED,
|
||||||
|
resourceType: AuditResourceType.WORKSPACE,
|
||||||
|
resourceId: workspaceId,
|
||||||
|
actorName: session.user.name ?? undefined,
|
||||||
|
actorEmail: session.user.email ?? undefined,
|
||||||
|
description: `Changed permissions for user ${update.userId} to ${update.permissions}`,
|
||||||
|
metadata: { targetUserId: update.userId, newPermissions: update.permissions },
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
message: 'Permissions updated successfully',
|
message: 'Permissions updated successfully',
|
||||||
users: updatedUsers,
|
users: updatedUsers,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { and, eq, inArray } from 'drizzle-orm'
|
import { and, eq, inArray } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
|
|
||||||
const logger = createLogger('WorkspaceByIdAPI')
|
const logger = createLogger('WorkspaceByIdAPI')
|
||||||
@@ -228,6 +229,13 @@ export async function DELETE(
|
|||||||
`Deleting workspace ${workspaceId} for user ${session.user.id}, deleteTemplates: ${deleteTemplates}`
|
`Deleting workspace ${workspaceId} for user ${session.user.id}, deleteTemplates: ${deleteTemplates}`
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Fetch workspace name before deletion for audit logging
|
||||||
|
const [workspaceRecord] = await db
|
||||||
|
.select({ name: workspace.name })
|
||||||
|
.from(workspace)
|
||||||
|
.where(eq(workspace.id, workspaceId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
// Delete workspace and all related data in a transaction
|
// Delete workspace and all related data in a transaction
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
// Get all workflows in this workspace before deletion
|
// Get all workflows in this workspace before deletion
|
||||||
@@ -281,6 +289,19 @@ export async function DELETE(
|
|||||||
logger.info(`Successfully deleted workspace ${workspaceId} and all related data`)
|
logger.info(`Successfully deleted workspace ${workspaceId} and all related data`)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: null,
|
||||||
|
actorId: session.user.id,
|
||||||
|
actorName: session.user.name,
|
||||||
|
actorEmail: session.user.email,
|
||||||
|
action: AuditAction.WORKSPACE_DELETED,
|
||||||
|
resourceType: AuditResourceType.WORKSPACE,
|
||||||
|
resourceId: workspaceId,
|
||||||
|
resourceName: workspaceRecord?.name,
|
||||||
|
description: `Deleted workspace "${workspaceRecord?.name || workspaceId}"`,
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error deleting workspace ${workspaceId}:`, error)
|
logger.error(`Error deleting workspace ${workspaceId}:`, error)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createSession, createWorkspaceRecord, loggerMock } from '@sim/testing'
|
import { auditMock, createSession, createWorkspaceRecord, loggerMock } from '@sim/testing'
|
||||||
import { NextRequest } from 'next/server'
|
import { NextRequest } from 'next/server'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
@@ -55,6 +55,8 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({
|
|||||||
|
|
||||||
vi.mock('@sim/logger', () => loggerMock)
|
vi.mock('@sim/logger', () => loggerMock)
|
||||||
|
|
||||||
|
vi.mock('@/lib/audit/log', () => auditMock)
|
||||||
|
|
||||||
vi.mock('@/lib/core/utils/urls', () => ({
|
vi.mock('@/lib/core/utils/urls', () => ({
|
||||||
getBaseUrl: vi.fn().mockReturnValue('https://test.sim.ai'),
|
getBaseUrl: vi.fn().mockReturnValue('https://test.sim.ai'),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { and, eq } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { WorkspaceInvitationEmail } from '@/components/emails'
|
import { WorkspaceInvitationEmail } from '@/components/emails'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||||
@@ -162,6 +163,19 @@ export async function GET(
|
|||||||
.where(eq(workspaceInvitation.id, invitation.id))
|
.where(eq(workspaceInvitation.id, invitation.id))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: invitation.workspaceId,
|
||||||
|
actorId: session.user.id,
|
||||||
|
action: AuditAction.INVITATION_ACCEPTED,
|
||||||
|
resourceType: AuditResourceType.WORKSPACE,
|
||||||
|
resourceId: invitation.workspaceId,
|
||||||
|
actorName: session.user.name ?? undefined,
|
||||||
|
actorEmail: session.user.email ?? undefined,
|
||||||
|
resourceName: workspaceDetails.name,
|
||||||
|
description: `Accepted workspace invitation to "${workspaceDetails.name}"`,
|
||||||
|
request: req,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.redirect(new URL(`/workspace/${invitation.workspaceId}/w`, getBaseUrl()))
|
return NextResponse.redirect(new URL(`/workspace/${invitation.workspaceId}/w`, getBaseUrl()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,6 +230,19 @@ export async function DELETE(
|
|||||||
|
|
||||||
await db.delete(workspaceInvitation).where(eq(workspaceInvitation.id, invitationId))
|
await db.delete(workspaceInvitation).where(eq(workspaceInvitation.id, invitationId))
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: invitation.workspaceId,
|
||||||
|
actorId: session.user.id,
|
||||||
|
action: AuditAction.INVITATION_REVOKED,
|
||||||
|
resourceType: AuditResourceType.WORKSPACE,
|
||||||
|
resourceId: invitation.workspaceId,
|
||||||
|
actorName: session.user.name ?? undefined,
|
||||||
|
actorEmail: session.user.email ?? undefined,
|
||||||
|
description: `Revoked workspace invitation for ${invitation.email}`,
|
||||||
|
metadata: { invitationId, email: invitation.email },
|
||||||
|
request: _request,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error deleting workspace invitation:', error)
|
logger.error('Error deleting workspace invitation:', error)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createMockRequest, mockAuth, mockConsoleLogger } from '@sim/testing'
|
import { auditMock, createMockRequest, mockAuth, mockConsoleLogger } from '@sim/testing'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
describe('Workspace Invitations API Route', () => {
|
describe('Workspace Invitations API Route', () => {
|
||||||
@@ -96,6 +96,8 @@ describe('Workspace Invitations API Route', () => {
|
|||||||
getEmailDomain: vi.fn().mockReturnValue('sim.ai'),
|
getEmailDomain: vi.fn().mockReturnValue('sim.ai'),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.doMock('@/lib/audit/log', () => auditMock)
|
||||||
|
|
||||||
vi.doMock('drizzle-orm', () => ({
|
vi.doMock('drizzle-orm', () => ({
|
||||||
and: vi.fn().mockImplementation((...args) => ({ type: 'and', conditions: args })),
|
and: vi.fn().mockImplementation((...args) => ({ type: 'and', conditions: args })),
|
||||||
eq: vi.fn().mockImplementation((field, value) => ({ type: 'eq', field, value })),
|
eq: vi.fn().mockImplementation((field, value) => ({ type: 'eq', field, value })),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { and, eq, inArray } from 'drizzle-orm'
|
import { and, eq, inArray } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { WorkspaceInvitationEmail } from '@/components/emails'
|
import { WorkspaceInvitationEmail } from '@/components/emails'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { PlatformEvents } from '@/lib/core/telemetry'
|
import { PlatformEvents } from '@/lib/core/telemetry'
|
||||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||||
@@ -214,6 +215,20 @@ export async function POST(req: NextRequest) {
|
|||||||
token: token,
|
token: token,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId,
|
||||||
|
actorId: session.user.id,
|
||||||
|
actorName: session.user.name,
|
||||||
|
actorEmail: session.user.email,
|
||||||
|
action: AuditAction.MEMBER_INVITED,
|
||||||
|
resourceType: AuditResourceType.WORKSPACE,
|
||||||
|
resourceId: workspaceId,
|
||||||
|
resourceName: email,
|
||||||
|
description: `Invited ${email} as ${permission}`,
|
||||||
|
metadata: { email, role: permission },
|
||||||
|
request: req,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({ success: true, invitation: invitationData })
|
return NextResponse.json({ success: true, invitation: invitationData })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof InvitationsNotAllowedError) {
|
if (error instanceof InvitationsNotAllowedError) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { and, eq } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
|
import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
|
||||||
|
|
||||||
@@ -101,6 +102,19 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId,
|
||||||
|
actorId: session.user.id,
|
||||||
|
actorName: session.user.name,
|
||||||
|
actorEmail: session.user.email,
|
||||||
|
action: AuditAction.MEMBER_REMOVED,
|
||||||
|
resourceType: AuditResourceType.WORKSPACE,
|
||||||
|
resourceId: workspaceId,
|
||||||
|
description: isSelf ? 'Left the workspace' : 'Removed a member from the workspace',
|
||||||
|
metadata: { removedUserId: userId, selfRemoval: isSelf },
|
||||||
|
request: req,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error removing workspace member:', error)
|
logger.error('Error removing workspace member:', error)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { and, desc, eq, isNull } from 'drizzle-orm'
|
import { and, desc, eq, isNull } from 'drizzle-orm'
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { PlatformEvents } from '@/lib/core/telemetry'
|
import { PlatformEvents } from '@/lib/core/telemetry'
|
||||||
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
|
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
|
||||||
@@ -68,6 +69,20 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
const newWorkspace = await createWorkspace(session.user.id, name, skipDefaultWorkflow)
|
const newWorkspace = await createWorkspace(session.user.id, name, skipDefaultWorkflow)
|
||||||
|
|
||||||
|
recordAudit({
|
||||||
|
workspaceId: newWorkspace.id,
|
||||||
|
actorId: session.user.id,
|
||||||
|
actorName: session.user.name,
|
||||||
|
actorEmail: session.user.email,
|
||||||
|
action: AuditAction.WORKSPACE_CREATED,
|
||||||
|
resourceType: AuditResourceType.WORKSPACE,
|
||||||
|
resourceId: newWorkspace.id,
|
||||||
|
resourceName: newWorkspace.name,
|
||||||
|
description: `Created workspace "${newWorkspace.name}"`,
|
||||||
|
metadata: { name: newWorkspace.name },
|
||||||
|
request: req,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({ workspace: newWorkspace })
|
return NextResponse.json({ workspace: newWorkspace })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error creating workspace:', error)
|
logger.error('Error creating workspace:', error)
|
||||||
|
|||||||
@@ -618,6 +618,15 @@ export function Editor() {
|
|||||||
<div className='h-[1.25px] flex-1' style={DASHED_DIVIDER_STYLE} />
|
<div className='h-[1.25px] flex-1' style={DASHED_DIVIDER_STYLE} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{hasAdvancedOnlyFields && !canEditBlock && displayAdvancedOptions && (
|
||||||
|
<div className='flex items-center gap-[10px] px-[2px] pt-[14px] pb-[12px]'>
|
||||||
|
<div className='h-[1.25px] flex-1' style={DASHED_DIVIDER_STYLE} />
|
||||||
|
<span className='whitespace-nowrap font-medium text-[13px] text-[var(--text-secondary)]'>
|
||||||
|
Additional fields
|
||||||
|
</span>
|
||||||
|
<div className='h-[1.25px] flex-1' style={DASHED_DIVIDER_STYLE} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{advancedOnlySubBlocks.map((subBlock, index) => {
|
{advancedOnlySubBlocks.map((subBlock, index) => {
|
||||||
const stableKey = getSubBlockStableKey(
|
const stableKey = getSubBlockStableKey(
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ export function Integrations({ onOpenChange, registerCloseHandler }: Integration
|
|||||||
(acc, service) => {
|
(acc, service) => {
|
||||||
if (
|
if (
|
||||||
permissionConfig.allowedIntegrations !== null &&
|
permissionConfig.allowedIntegrations !== null &&
|
||||||
!permissionConfig.allowedIntegrations.includes(service.id.replace(/-/g, '_'))
|
!permissionConfig.allowedIntegrations.includes(service.id.replace(/-/g, '_').toLowerCase())
|
||||||
) {
|
) {
|
||||||
return acc
|
return acc
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,10 +109,29 @@ const logger = createLogger('McpSettings')
|
|||||||
/**
|
/**
|
||||||
* Checks if a URL's hostname is in the allowed domains list.
|
* Checks if a URL's hostname is in the allowed domains list.
|
||||||
* Returns true if no allowlist is configured (null) or the domain matches.
|
* Returns true if no allowlist is configured (null) or the domain matches.
|
||||||
|
* Env var references in the hostname bypass the check since the domain
|
||||||
|
* can't be determined until resolution — but env vars only in the path/query
|
||||||
|
* do NOT bypass the check.
|
||||||
*/
|
*/
|
||||||
|
const ENV_VAR_PATTERN = /\{\{[^}]+\}\}/
|
||||||
|
|
||||||
|
function hasEnvVarInHostname(url: string): boolean {
|
||||||
|
// If the entire URL is an env var, hostname is unknown
|
||||||
|
const globalPattern = new RegExp(ENV_VAR_PATTERN.source, 'g')
|
||||||
|
if (url.trim().replace(globalPattern, '').trim() === '') return true
|
||||||
|
const protocolEnd = url.indexOf('://')
|
||||||
|
if (protocolEnd === -1) return ENV_VAR_PATTERN.test(url)
|
||||||
|
// Extract authority per RFC 3986 (terminated by /, ?, or #)
|
||||||
|
const afterProtocol = url.substring(protocolEnd + 3)
|
||||||
|
const authorityEnd = afterProtocol.search(/[/?#]/)
|
||||||
|
const authority = authorityEnd === -1 ? afterProtocol : afterProtocol.substring(0, authorityEnd)
|
||||||
|
return ENV_VAR_PATTERN.test(authority)
|
||||||
|
}
|
||||||
|
|
||||||
function isDomainAllowed(url: string | undefined, allowedDomains: string[] | null): boolean {
|
function isDomainAllowed(url: string | undefined, allowedDomains: string[] | null): boolean {
|
||||||
if (allowedDomains === null) return true
|
if (allowedDomains === null) return true
|
||||||
if (!url) return true
|
if (!url) return false
|
||||||
|
if (hasEnvVarInHostname(url)) return true
|
||||||
try {
|
try {
|
||||||
const hostname = new URL(url).hostname.toLowerCase()
|
const hostname = new URL(url).hostname.toLowerCase()
|
||||||
return allowedDomains.includes(hostname)
|
return allowedDomains.includes(hostname)
|
||||||
@@ -1030,12 +1049,14 @@ export function MCP({ initialServerId }: MCPProps) {
|
|||||||
const showNoResults = searchTerm.trim() && filteredServers.length === 0 && servers.length > 0
|
const showNoResults = searchTerm.trim() && filteredServers.length === 0 && servers.length > 0
|
||||||
|
|
||||||
const isFormValid = formData.name.trim() && formData.url?.trim()
|
const isFormValid = formData.name.trim() && formData.url?.trim()
|
||||||
const isAddDomainBlocked = !isDomainAllowed(formData.url, allowedMcpDomains)
|
const isAddDomainBlocked =
|
||||||
|
!!formData.url?.trim() && !isDomainAllowed(formData.url, allowedMcpDomains)
|
||||||
const isSubmitDisabled = serversLoading || isAddingServer || !isFormValid || isAddDomainBlocked
|
const isSubmitDisabled = serversLoading || isAddingServer || !isFormValid || isAddDomainBlocked
|
||||||
const testButtonLabel = getTestButtonLabel(testResult, isTestingConnection)
|
const testButtonLabel = getTestButtonLabel(testResult, isTestingConnection)
|
||||||
|
|
||||||
const isEditFormValid = editFormData.name.trim() && editFormData.url?.trim()
|
const isEditFormValid = editFormData.name.trim() && editFormData.url?.trim()
|
||||||
const isEditDomainBlocked = !isDomainAllowed(editFormData.url, allowedMcpDomains)
|
const isEditDomainBlocked =
|
||||||
|
!!editFormData.url?.trim() && !isDomainAllowed(editFormData.url, allowedMcpDomains)
|
||||||
const editTestButtonLabel = getTestButtonLabel(editTestResult, isEditTestingConnection)
|
const editTestButtonLabel = getTestButtonLabel(editTestResult, isEditTestingConnection)
|
||||||
const hasEditChanges = useMemo(() => {
|
const hasEditChanges = useMemo(() => {
|
||||||
if (editFormData.name !== editOriginalData.name) return true
|
if (editFormData.name !== editOriginalData.name) return true
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export interface SubscriptionPermissions {
|
|||||||
canCancelSubscription: boolean
|
canCancelSubscription: boolean
|
||||||
showTeamMemberView: boolean
|
showTeamMemberView: boolean
|
||||||
showUpgradePlans: boolean
|
showUpgradePlans: boolean
|
||||||
|
isEnterpriseMember: boolean
|
||||||
|
canViewUsageInfo: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubscriptionState {
|
export interface SubscriptionState {
|
||||||
@@ -31,6 +33,9 @@ export function getSubscriptionPermissions(
|
|||||||
const { isFree, isPro, isTeam, isEnterprise, isPaid } = subscription
|
const { isFree, isPro, isTeam, isEnterprise, isPaid } = subscription
|
||||||
const { isTeamAdmin } = userRole
|
const { isTeamAdmin } = userRole
|
||||||
|
|
||||||
|
const isEnterpriseMember = isEnterprise && !isTeamAdmin
|
||||||
|
const canViewUsageInfo = !isEnterpriseMember
|
||||||
|
|
||||||
return {
|
return {
|
||||||
canUpgradeToPro: isFree,
|
canUpgradeToPro: isFree,
|
||||||
canUpgradeToTeam: isFree || (isPro && !isTeam),
|
canUpgradeToTeam: isFree || (isPro && !isTeam),
|
||||||
@@ -40,6 +45,8 @@ export function getSubscriptionPermissions(
|
|||||||
canCancelSubscription: isPaid && !isEnterprise && !(isTeam && !isTeamAdmin), // Team members can't cancel
|
canCancelSubscription: isPaid && !isEnterprise && !(isTeam && !isTeamAdmin), // Team members can't cancel
|
||||||
showTeamMemberView: isTeam && !isTeamAdmin,
|
showTeamMemberView: isTeam && !isTeamAdmin,
|
||||||
showUpgradePlans: isFree || (isPro && !isTeam) || (isTeam && isTeamAdmin), // Free users, Pro users, Team owners see plans
|
showUpgradePlans: isFree || (isPro && !isTeam) || (isTeam && isTeamAdmin), // Free users, Pro users, Team owners see plans
|
||||||
|
isEnterpriseMember,
|
||||||
|
canViewUsageInfo,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -300,12 +300,16 @@ export function Subscription() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const showBadge =
|
const showBadge =
|
||||||
(permissions.canEditUsageLimit && !permissions.showTeamMemberView) ||
|
!permissions.isEnterpriseMember &&
|
||||||
permissions.showTeamMemberView ||
|
((permissions.canEditUsageLimit && !permissions.showTeamMemberView) ||
|
||||||
subscription.isEnterprise ||
|
permissions.showTeamMemberView ||
|
||||||
isBlocked
|
subscription.isEnterprise ||
|
||||||
|
isBlocked)
|
||||||
|
|
||||||
const getBadgeConfig = (): { text: string; variant: 'blue-secondary' | 'red' } => {
|
const getBadgeConfig = (): { text: string; variant: 'blue-secondary' | 'red' } => {
|
||||||
|
if (permissions.isEnterpriseMember) {
|
||||||
|
return { text: '', variant: 'blue-secondary' }
|
||||||
|
}
|
||||||
if (permissions.showTeamMemberView || subscription.isEnterprise) {
|
if (permissions.showTeamMemberView || subscription.isEnterprise) {
|
||||||
return { text: `${subscription.seats} seats`, variant: 'blue-secondary' }
|
return { text: `${subscription.seats} seats`, variant: 'blue-secondary' }
|
||||||
}
|
}
|
||||||
@@ -443,67 +447,75 @@ export function Subscription() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex h-full flex-col gap-[20px]'>
|
<div className='flex h-full flex-col gap-[20px]'>
|
||||||
{/* Current Plan & Usage Overview */}
|
{/* Current Plan & Usage Overview - hidden from enterprise members (non-admin) */}
|
||||||
<UsageHeader
|
{permissions.canViewUsageInfo ? (
|
||||||
title={formatPlanName(subscription.plan)}
|
<UsageHeader
|
||||||
showBadge={showBadge}
|
title={formatPlanName(subscription.plan)}
|
||||||
badgeText={badgeConfig.text}
|
showBadge={showBadge}
|
||||||
badgeVariant={badgeConfig.variant}
|
badgeText={badgeConfig.text}
|
||||||
onBadgeClick={permissions.showTeamMemberView ? undefined : handleBadgeClick}
|
badgeVariant={badgeConfig.variant}
|
||||||
seatsText={
|
onBadgeClick={permissions.showTeamMemberView ? undefined : handleBadgeClick}
|
||||||
permissions.canManageTeam || subscription.isEnterprise
|
seatsText={
|
||||||
? `${subscription.seats} seats`
|
permissions.canManageTeam || subscription.isEnterprise
|
||||||
: undefined
|
? `${subscription.seats} seats`
|
||||||
}
|
: undefined
|
||||||
current={usage.current}
|
}
|
||||||
limit={
|
current={usage.current}
|
||||||
subscription.isEnterprise || subscription.isTeam
|
limit={
|
||||||
? organizationBillingData?.data?.totalUsageLimit
|
subscription.isEnterprise || subscription.isTeam
|
||||||
: !subscription.isFree &&
|
? organizationBillingData?.data?.totalUsageLimit
|
||||||
(permissions.canEditUsageLimit || permissions.showTeamMemberView)
|
: !subscription.isFree &&
|
||||||
? usage.current // placeholder; rightContent will render UsageLimit
|
(permissions.canEditUsageLimit || permissions.showTeamMemberView)
|
||||||
: usage.limit
|
? usage.current // placeholder; rightContent will render UsageLimit
|
||||||
}
|
: usage.limit
|
||||||
isBlocked={isBlocked}
|
}
|
||||||
progressValue={Math.min(usage.percentUsed, 100)}
|
isBlocked={isBlocked}
|
||||||
rightContent={
|
progressValue={Math.min(usage.percentUsed, 100)}
|
||||||
!subscription.isFree &&
|
rightContent={
|
||||||
(permissions.canEditUsageLimit || permissions.showTeamMemberView) ? (
|
!subscription.isFree &&
|
||||||
<UsageLimit
|
(permissions.canEditUsageLimit || permissions.showTeamMemberView) ? (
|
||||||
ref={usageLimitRef}
|
<UsageLimit
|
||||||
currentLimit={
|
ref={usageLimitRef}
|
||||||
(subscription.isTeam || subscription.isEnterprise) &&
|
currentLimit={
|
||||||
isTeamAdmin &&
|
(subscription.isTeam || subscription.isEnterprise) &&
|
||||||
organizationBillingData?.data
|
isTeamAdmin &&
|
||||||
? organizationBillingData.data.totalUsageLimit
|
organizationBillingData?.data
|
||||||
: usageLimitData.currentLimit || usage.limit
|
? organizationBillingData.data.totalUsageLimit
|
||||||
}
|
: usageLimitData.currentLimit || usage.limit
|
||||||
currentUsage={usage.current}
|
}
|
||||||
canEdit={permissions.canEditUsageLimit}
|
currentUsage={usage.current}
|
||||||
minimumLimit={
|
canEdit={permissions.canEditUsageLimit}
|
||||||
(subscription.isTeam || subscription.isEnterprise) &&
|
minimumLimit={
|
||||||
isTeamAdmin &&
|
(subscription.isTeam || subscription.isEnterprise) &&
|
||||||
organizationBillingData?.data
|
isTeamAdmin &&
|
||||||
? organizationBillingData.data.minimumBillingAmount
|
organizationBillingData?.data
|
||||||
: usageLimitData.minimumLimit || (subscription.isPro ? 20 : 40)
|
? organizationBillingData.data.minimumBillingAmount
|
||||||
}
|
: usageLimitData.minimumLimit || (subscription.isPro ? 20 : 40)
|
||||||
context={
|
}
|
||||||
(subscription.isTeam || subscription.isEnterprise) && isTeamAdmin
|
context={
|
||||||
? 'organization'
|
(subscription.isTeam || subscription.isEnterprise) && isTeamAdmin
|
||||||
: 'user'
|
? 'organization'
|
||||||
}
|
: 'user'
|
||||||
organizationId={
|
}
|
||||||
(subscription.isTeam || subscription.isEnterprise) && isTeamAdmin
|
organizationId={
|
||||||
? activeOrgId
|
(subscription.isTeam || subscription.isEnterprise) && isTeamAdmin
|
||||||
: undefined
|
? activeOrgId
|
||||||
}
|
: undefined
|
||||||
onLimitUpdated={() => {
|
}
|
||||||
logger.info('Usage limit updated')
|
onLimitUpdated={() => {
|
||||||
}}
|
logger.info('Usage limit updated')
|
||||||
/>
|
}}
|
||||||
) : undefined
|
/>
|
||||||
}
|
) : undefined
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className='flex items-center'>
|
||||||
|
<span className='font-medium text-[14px] text-[var(--text-primary)]'>
|
||||||
|
{formatPlanName(subscription.plan)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Upgrade Plans */}
|
{/* Upgrade Plans */}
|
||||||
{permissions.showUpgradePlans && (
|
{permissions.showUpgradePlans && (
|
||||||
@@ -539,8 +551,8 @@ export function Subscription() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Credit Balance */}
|
{/* Credit Balance - hidden from enterprise members (non-admin) */}
|
||||||
{subscription.isPaid && (
|
{subscription.isPaid && permissions.canViewUsageInfo && (
|
||||||
<CreditBalance
|
<CreditBalance
|
||||||
balance={subscriptionData?.data?.creditBalance ?? 0}
|
balance={subscriptionData?.data?.creditBalance ?? 0}
|
||||||
canPurchase={permissions.canEditUsageLimit}
|
canPurchase={permissions.canEditUsageLimit}
|
||||||
@@ -554,10 +566,11 @@ export function Subscription() {
|
|||||||
<ReferralCode onRedeemComplete={() => refetchSubscription()} />
|
<ReferralCode onRedeemComplete={() => refetchSubscription()} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Next Billing Date - hidden from team members */}
|
{/* Next Billing Date - hidden from team members and enterprise members (non-admin) */}
|
||||||
{subscription.isPaid &&
|
{subscription.isPaid &&
|
||||||
subscriptionData?.data?.periodEnd &&
|
subscriptionData?.data?.periodEnd &&
|
||||||
!permissions.showTeamMemberView && (
|
!permissions.showTeamMemberView &&
|
||||||
|
!permissions.isEnterpriseMember && (
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
<Label>Next Billing Date</Label>
|
<Label>Next Billing Date</Label>
|
||||||
<span className='text-[12px] text-[var(--text-secondary)]'>
|
<span className='text-[12px] text-[var(--text-secondary)]'>
|
||||||
@@ -566,8 +579,8 @@ export function Subscription() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Usage notifications */}
|
{/* Usage notifications - hidden from enterprise members (non-admin) */}
|
||||||
{subscription.isPaid && <BillingUsageNotificationsToggle />}
|
{subscription.isPaid && permissions.canViewUsageInfo && <BillingUsageNotificationsToggle />}
|
||||||
|
|
||||||
{/* Cancel Subscription */}
|
{/* Cancel Subscription */}
|
||||||
{permissions.canCancelSubscription && (
|
{permissions.canCancelSubscription && (
|
||||||
|
|||||||
@@ -285,6 +285,7 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
|
|||||||
const isPro = planType === 'pro'
|
const isPro = planType === 'pro'
|
||||||
const isTeam = planType === 'team'
|
const isTeam = planType === 'team'
|
||||||
const isEnterprise = planType === 'enterprise'
|
const isEnterprise = planType === 'enterprise'
|
||||||
|
const isEnterpriseMember = isEnterprise && !userCanManageBilling
|
||||||
|
|
||||||
const handleUpgradeToPro = useCallback(async () => {
|
const handleUpgradeToPro = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -463,6 +464,18 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isEnterpriseMember) {
|
||||||
|
return (
|
||||||
|
<div className='flex flex-shrink-0 flex-col border-t px-[13.5px] pt-[8px] pb-[10px]'>
|
||||||
|
<div className='flex h-[18px] items-center'>
|
||||||
|
<span className='font-medium text-[12px] text-[var(--text-primary)]'>
|
||||||
|
{PLAN_NAMES[planType]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
|
|||||||
1007
apps/sim/blocks/blocks/vercel.ts
Normal file
1007
apps/sim/blocks/blocks/vercel.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -146,6 +146,7 @@ import { TwilioSMSBlock } from '@/blocks/blocks/twilio'
|
|||||||
import { TwilioVoiceBlock } from '@/blocks/blocks/twilio_voice'
|
import { TwilioVoiceBlock } from '@/blocks/blocks/twilio_voice'
|
||||||
import { TypeformBlock } from '@/blocks/blocks/typeform'
|
import { TypeformBlock } from '@/blocks/blocks/typeform'
|
||||||
import { VariablesBlock } from '@/blocks/blocks/variables'
|
import { VariablesBlock } from '@/blocks/blocks/variables'
|
||||||
|
import { VercelBlock } from '@/blocks/blocks/vercel'
|
||||||
import { VideoGeneratorBlock, VideoGeneratorV2Block } from '@/blocks/blocks/video_generator'
|
import { VideoGeneratorBlock, VideoGeneratorV2Block } from '@/blocks/blocks/video_generator'
|
||||||
import { VisionBlock, VisionV2Block } from '@/blocks/blocks/vision'
|
import { VisionBlock, VisionV2Block } from '@/blocks/blocks/vision'
|
||||||
import { WaitBlock } from '@/blocks/blocks/wait'
|
import { WaitBlock } from '@/blocks/blocks/wait'
|
||||||
@@ -330,6 +331,7 @@ export const registry: Record<string, BlockConfig> = {
|
|||||||
twilio_sms: TwilioSMSBlock,
|
twilio_sms: TwilioSMSBlock,
|
||||||
twilio_voice: TwilioVoiceBlock,
|
twilio_voice: TwilioVoiceBlock,
|
||||||
typeform: TypeformBlock,
|
typeform: TypeformBlock,
|
||||||
|
vercel: VercelBlock,
|
||||||
variables: VariablesBlock,
|
variables: VariablesBlock,
|
||||||
video_generator: VideoGeneratorBlock,
|
video_generator: VideoGeneratorBlock,
|
||||||
video_generator_v2: VideoGeneratorV2Block,
|
video_generator_v2: VideoGeneratorV2Block,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user