Compare commits

..

4 Commits

88 changed files with 2150 additions and 557 deletions

View File

@@ -1,6 +1,6 @@
import { redirect } from 'next/navigation'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import SSOForm from '@/ee/sso/components/sso-form'
import SSOForm from '@/app/(auth)/sso/sso-form'
export const dynamic = 'force-dynamic'

View File

@@ -6,9 +6,11 @@ import { getSession } from '@/lib/auth'
import { refreshOAuthToken } from '@/lib/oauth'
import {
getMicrosoftRefreshTokenExpiry,
getTikTokRefreshTokenExpiry,
isMicrosoftProvider,
isTikTokProvider,
PROACTIVE_REFRESH_THRESHOLD_DAYS,
} from '@/lib/oauth/microsoft'
} from '@/lib/oauth/utils'
const logger = createLogger('OAuthUtilsAPI')
@@ -220,13 +222,13 @@ export async function refreshAccessTokenIfNeeded(
(!credential.accessToken || (accessTokenExpiresAt && accessTokenExpiresAt <= now))
// Check if we should proactively refresh to prevent refresh token expiry
// This applies to Microsoft providers whose refresh tokens expire after 90 days of inactivity
// This applies to providers with expiring refresh tokens (Microsoft: 90 days, TikTok: 365 days)
const proactiveRefreshThreshold = new Date(
now.getTime() + PROACTIVE_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000
)
const refreshTokenNeedsProactiveRefresh =
!!credential.refreshToken &&
isMicrosoftProvider(credential.providerId) &&
(isMicrosoftProvider(credential.providerId) || isTikTokProvider(credential.providerId)) &&
refreshTokenExpiresAt &&
refreshTokenExpiresAt <= proactiveRefreshThreshold
@@ -271,6 +273,8 @@ export async function refreshAccessTokenIfNeeded(
if (isMicrosoftProvider(credential.providerId)) {
updateData.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry()
} else if (isTikTokProvider(credential.providerId)) {
updateData.refreshTokenExpiresAt = getTikTokRefreshTokenExpiry()
}
// Update the token in the database
@@ -321,13 +325,13 @@ export async function refreshTokenIfNeeded(
(!credential.accessToken || (accessTokenExpiresAt && accessTokenExpiresAt <= now))
// Check if we should proactively refresh to prevent refresh token expiry
// This applies to Microsoft providers whose refresh tokens expire after 90 days of inactivity
// This applies to providers with expiring refresh tokens (Microsoft: 90 days, TikTok: 365 days)
const proactiveRefreshThreshold = new Date(
now.getTime() + PROACTIVE_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000
)
const refreshTokenNeedsProactiveRefresh =
!!credential.refreshToken &&
isMicrosoftProvider(credential.providerId) &&
(isMicrosoftProvider(credential.providerId) || isTikTokProvider(credential.providerId)) &&
refreshTokenExpiresAt &&
refreshTokenExpiresAt <= proactiveRefreshThreshold
@@ -368,6 +372,8 @@ export async function refreshTokenIfNeeded(
if (isMicrosoftProvider(credential.providerId)) {
updateData.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry()
} else if (isTikTokProvider(credential.providerId)) {
updateData.refreshTokenExpiresAt = getTikTokRefreshTokenExpiry()
}
await db.update(account).set(updateData).where(eq(account.id, credentialId))

View File

@@ -0,0 +1,70 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { env } from '@/lib/core/config/env'
import { getBaseUrl } from '@/lib/core/utils/urls'
const logger = createLogger('TikTokAuthorize')
export const dynamic = 'force-dynamic'
export async function GET(request: NextRequest) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const clientKey = env.TIKTOK_CLIENT_ID
if (!clientKey) {
logger.error('TIKTOK_CLIENT_ID not configured')
return NextResponse.json({ error: 'TikTok client key not configured' }, { status: 500 })
}
// Get the return URL from query params or use default
const searchParams = request.nextUrl.searchParams
const returnUrl = searchParams.get('returnUrl') || `${getBaseUrl()}/workspace`
const baseUrl = getBaseUrl()
const redirectUri = `${baseUrl}/api/auth/tiktok/callback`
// Generate a random state for CSRF protection
const state = Buffer.from(
JSON.stringify({
returnUrl,
timestamp: Date.now(),
})
).toString('base64url')
// TikTok scopes
const scopes = [
'user.info.basic',
'user.info.profile',
'user.info.stats',
'video.list',
'video.publish',
]
// Build TikTok authorization URL with client_key (not client_id)
// Note: TikTok expects raw commas in scope parameter, not URL-encoded %2C
// So we manually construct the URL to avoid automatic encoding
const scopeString = scopes.join(',')
const encodedRedirectUri = encodeURIComponent(redirectUri)
const encodedState = encodeURIComponent(state)
const authUrl = `https://www.tiktok.com/v2/auth/authorize/?client_key=${clientKey}&response_type=code&scope=${scopeString}&redirect_uri=${encodedRedirectUri}&state=${encodedState}`
logger.info('Redirecting to TikTok authorization', {
clientKey: clientKey ? `${clientKey.substring(0, 8)}...` : 'NOT SET',
redirectUri,
scopes: scopeString,
fullUrl: authUrl,
})
return NextResponse.redirect(authUrl)
} catch (error) {
logger.error('Error initiating TikTok authorization:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,130 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { env } from '@/lib/core/config/env'
import { getBaseUrl } from '@/lib/core/utils/urls'
const logger = createLogger('TikTokCallback')
export const dynamic = 'force-dynamic'
export async function GET(request: NextRequest) {
const baseUrl = getBaseUrl()
try {
const session = await getSession()
if (!session?.user?.id) {
logger.error('No session found during TikTok callback')
return NextResponse.redirect(`${baseUrl}/workspace?error=unauthorized`)
}
const searchParams = request.nextUrl.searchParams
const code = searchParams.get('code')
const state = searchParams.get('state')
const error = searchParams.get('error')
const errorDescription = searchParams.get('error_description')
// Handle errors from TikTok
if (error) {
logger.error('TikTok authorization error:', { error, errorDescription })
return NextResponse.redirect(
`${baseUrl}/workspace?error=tiktok_auth_failed&message=${encodeURIComponent(errorDescription || error)}`
)
}
if (!code) {
logger.error('No authorization code received from TikTok')
return NextResponse.redirect(`${baseUrl}/workspace?error=no_code`)
}
// Parse state to get return URL
let returnUrl = `${baseUrl}/workspace`
if (state) {
try {
const stateData = JSON.parse(Buffer.from(state, 'base64url').toString())
returnUrl = stateData.returnUrl || returnUrl
} catch {
logger.warn('Failed to parse state parameter')
}
}
const clientKey = env.TIKTOK_CLIENT_ID
const clientSecret = env.TIKTOK_CLIENT_SECRET
if (!clientKey || !clientSecret) {
logger.error('TikTok credentials not configured')
return NextResponse.redirect(`${baseUrl}/workspace?error=config_error`)
}
const redirectUri = `${baseUrl}/api/auth/tiktok/callback`
// Exchange authorization code for access token
// TikTok uses client_key instead of client_id
const tokenResponse = await fetch('https://open.tiktokapis.com/v2/oauth/token/', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_key: clientKey,
client_secret: clientSecret,
code,
grant_type: 'authorization_code',
redirect_uri: redirectUri,
}).toString(),
})
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text()
logger.error('Failed to exchange code for token:', {
status: tokenResponse.status,
error: errorText,
})
return NextResponse.redirect(`${baseUrl}/workspace?error=token_exchange_failed`)
}
const tokenData = await tokenResponse.json()
if (tokenData.error) {
logger.error('TikTok token error:', tokenData)
return NextResponse.redirect(
`${baseUrl}/workspace?error=tiktok_token_error&message=${encodeURIComponent(tokenData.error_description || tokenData.error)}`
)
}
const { access_token, refresh_token, expires_in, open_id, scope } = tokenData
if (!access_token) {
logger.error('No access token in TikTok response:', tokenData)
return NextResponse.redirect(`${baseUrl}/workspace?error=no_access_token`)
}
// Store the tokens by calling the store endpoint
const storeResponse = await fetch(`${baseUrl}/api/auth/tiktok/store`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Cookie: request.headers.get('cookie') || '',
},
body: JSON.stringify({
accessToken: access_token,
refreshToken: refresh_token,
expiresIn: expires_in,
openId: open_id,
scope,
}),
})
if (!storeResponse.ok) {
const storeError = await storeResponse.text()
logger.error('Failed to store TikTok tokens:', storeError)
return NextResponse.redirect(`${baseUrl}/workspace?error=store_failed`)
}
logger.info('TikTok authorization successful')
return NextResponse.redirect(`${returnUrl}?tiktok_connected=true`)
} catch (error) {
logger.error('Error in TikTok callback:', error)
return NextResponse.redirect(`${baseUrl}/workspace?error=callback_error`)
}
}

View File

@@ -0,0 +1,108 @@
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { getTikTokRefreshTokenExpiry } from '@/lib/oauth/utils'
import { safeAccountInsert } from '@/app/api/auth/oauth/utils'
import { db } from '@/../../packages/db'
import { account } from '@/../../packages/db/schema'
const logger = createLogger('TikTokStore')
export const dynamic = 'force-dynamic'
export async function POST(request: NextRequest) {
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn('Unauthorized attempt to store TikTok token')
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { accessToken, refreshToken, expiresIn, openId, scope } = body
if (!accessToken || !openId) {
return NextResponse.json(
{ success: false, error: 'Access token and open_id required' },
{ status: 400 }
)
}
// Fetch user info from TikTok to get display name
let displayName = 'TikTok User'
let avatarUrl: string | undefined
try {
const userResponse = await fetch(
'https://open.tiktokapis.com/v2/user/info/?fields=open_id,union_id,avatar_url,display_name',
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
)
if (userResponse.ok) {
const userData = await userResponse.json()
if (userData.data?.user) {
displayName = userData.data.user.display_name || displayName
avatarUrl = userData.data.user.avatar_url
}
}
} catch (error) {
logger.warn('Failed to fetch TikTok user info:', error)
}
const existing = await db.query.account.findFirst({
where: and(eq(account.userId, session.user.id), eq(account.providerId, 'tiktok')),
})
const now = new Date()
const accessTokenExpiresAt = expiresIn ? new Date(Date.now() + expiresIn * 1000) : undefined
const refreshTokenExpiresAt = getTikTokRefreshTokenExpiry()
if (existing) {
await db
.update(account)
.set({
accessToken,
refreshToken,
accountId: openId,
scope:
scope || 'user.info.basic,user.info.profile,user.info.stats,video.list,video.publish',
accessTokenExpiresAt,
refreshTokenExpiresAt,
updatedAt: now,
})
.where(eq(account.id, existing.id))
logger.info('Updated existing TikTok account', { accountId: openId })
} else {
await safeAccountInsert(
{
id: `tiktok_${session.user.id}_${Date.now()}`,
userId: session.user.id,
providerId: 'tiktok',
accountId: openId,
accessToken,
refreshToken,
scope:
scope || 'user.info.basic,user.info.profile,user.info.stats,video.list,video.publish',
accessTokenExpiresAt,
refreshTokenExpiresAt,
createdAt: now,
updatedAt: now,
},
{ provider: 'TikTok', identifier: openId }
)
logger.info('Created new TikTok account', { accountId: openId })
}
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Error storing TikTok token:', error)
return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -8,7 +8,6 @@ import { verifyCronAuth } from '@/lib/auth/internal'
const logger = createLogger('CleanupStaleExecutions')
const STALE_THRESHOLD_MINUTES = 30
const MAX_INT32 = 2_147_483_647
export async function GET(request: NextRequest) {
try {
@@ -46,14 +45,13 @@ export async function GET(request: NextRequest) {
try {
const staleDurationMs = Date.now() - new Date(execution.startedAt).getTime()
const staleDurationMinutes = Math.round(staleDurationMs / 60000)
const totalDurationMs = Math.min(staleDurationMs, MAX_INT32)
await db
.update(workflowExecutionLogs)
.set({
status: 'failed',
endedAt: new Date(),
totalDurationMs,
totalDurationMs: staleDurationMs,
executionData: sql`jsonb_set(
COALESCE(execution_data, '{}'::jsonb),
ARRAY['error'],

View File

@@ -284,7 +284,7 @@ async function handleToolsCall(
content: [
{ type: 'text', text: JSON.stringify(executeResult.output || executeResult, null, 2) },
],
isError: executeResult.success === false,
isError: !executeResult.success,
}
return NextResponse.json(createResponse(id, result))

View File

@@ -20,7 +20,6 @@ import { z } from 'zod'
import { getEmailSubject, renderInvitationEmail } from '@/components/emails'
import { getSession } from '@/lib/auth'
import { hasAccessControlAccess } from '@/lib/billing'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'
@@ -502,18 +501,6 @@ export async function PUT(
}
}
if (status === 'accepted') {
try {
await syncUsageLimitsFromSubscription(session.user.id)
} catch (syncError) {
logger.error('Failed to sync usage limits after joining org', {
userId: session.user.id,
organizationId,
error: syncError,
})
}
}
logger.info(`Organization invitation ${status}`, {
organizationId,
invitationId,

View File

@@ -29,7 +29,7 @@ import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
import {
InvitationsNotAllowedError,
validateInvitationsAllowed,
} from '@/ee/access-control/utils/permission-check'
} from '@/executor/utils/permission-check'
const logger = createLogger('OrganizationInvitations')

View File

@@ -5,7 +5,6 @@ import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { hasActiveSubscription } from '@/lib/billing'
const logger = createLogger('SubscriptionTransferAPI')
@@ -89,14 +88,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
)
}
// Check if org already has an active subscription (prevent duplicates)
if (await hasActiveSubscription(organizationId)) {
return NextResponse.json(
{ error: 'Organization already has an active subscription' },
{ status: 409 }
)
}
await db
.update(subscription)
.set({ referenceId: organizationId })

View File

@@ -203,10 +203,6 @@ export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) =
}
updateData.billingBlocked = body.billingBlocked
// Clear the reason when unblocking
if (body.billingBlocked === false) {
updateData.billingBlockedReason = null
}
updated.push('billingBlocked')
}

View File

@@ -1,4 +1,6 @@
import { db, workflow as workflowTable } from '@sim/db'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { z } from 'zod'
@@ -6,7 +8,6 @@ import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { SSE_HEADERS } from '@/lib/core/utils/sse'
import { markExecutionCancelled } from '@/lib/execution/cancellation'
import { preprocessExecution } from '@/lib/execution/preprocessing'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
import { createSSECallbacks } from '@/lib/workflows/executor/execution-events'
@@ -74,31 +75,12 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
const { startBlockId, sourceSnapshot, input } = validation.data
const executionId = uuidv4()
// Run preprocessing checks (billing, rate limits, usage limits)
const preprocessResult = await preprocessExecution({
workflowId,
userId,
triggerType: 'manual',
executionId,
requestId,
checkRateLimit: false, // Manual executions don't rate limit
checkDeployment: false, // Run-from-block doesn't require deployment
})
const [workflowRecord] = await db
.select({ workspaceId: workflowTable.workspaceId, userId: workflowTable.userId })
.from(workflowTable)
.where(eq(workflowTable.id, workflowId))
.limit(1)
if (!preprocessResult.success) {
const { error } = preprocessResult
logger.warn(`[${requestId}] Preprocessing failed for run-from-block`, {
workflowId,
error: error?.message,
statusCode: error?.statusCode,
})
return NextResponse.json(
{ error: error?.message || 'Execution blocked' },
{ status: error?.statusCode || 500 }
)
}
const workflowRecord = preprocessResult.workflowRecord
if (!workflowRecord?.workspaceId) {
return NextResponse.json({ error: 'Workflow not found or has no workspace' }, { status: 404 })
}
@@ -110,7 +92,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
workflowId,
startBlockId,
executedBlocksCount: sourceSnapshot.executedBlocks.length,
billingActorUserId: preprocessResult.actorUserId,
})
const loggingSession = new LoggingSession(workflowId, executionId, 'manual', requestId)

View File

@@ -102,7 +102,7 @@ describe('Workspace Invitations API Route', () => {
inArray: vi.fn().mockImplementation((field, values) => ({ type: 'inArray', field, values })),
}))
vi.doMock('@/ee/access-control/utils/permission-check', () => ({
vi.doMock('@/executor/utils/permission-check', () => ({
validateInvitationsAllowed: vi.fn().mockResolvedValue(undefined),
InvitationsNotAllowedError: class InvitationsNotAllowedError extends Error {
constructor() {

View File

@@ -21,7 +21,7 @@ import { getFromEmailAddress } from '@/lib/messaging/email/utils'
import {
InvitationsNotAllowedError,
validateInvitationsAllowed,
} from '@/ee/access-control/utils/permission-check'
} from '@/executor/utils/permission-check'
export const dynamic = 'force-dynamic'
@@ -38,6 +38,7 @@ export async function GET(req: NextRequest) {
}
try {
// Get all workspaces where the user has permissions
const userWorkspaces = await db
.select({ id: workspace.id })
.from(workspace)
@@ -54,8 +55,10 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ invitations: [] })
}
// Get all workspaceIds where the user is a member
const workspaceIds = userWorkspaces.map((w) => w.id)
// Find all invitations for those workspaces
const invitations = await db
.select()
.from(workspaceInvitation)

View File

@@ -14,11 +14,11 @@ import {
ChatMessageContainer,
EmailAuth,
PasswordAuth,
SSOAuth,
VoiceInterface,
} from '@/app/chat/components'
import { CHAT_ERROR_MESSAGES, CHAT_REQUEST_TIMEOUT_MS } from '@/app/chat/constants'
import { useAudioStreaming, useChatStreaming } from '@/app/chat/hooks'
import SSOAuth from '@/ee/sso/components/sso-auth'
const logger = createLogger('ChatClient')

View File

@@ -1,5 +1,6 @@
export { default as EmailAuth } from './auth/email/email-auth'
export { default as PasswordAuth } from './auth/password/password-auth'
export { default as SSOAuth } from './auth/sso/sso-auth'
export { ChatErrorState } from './error-state/error-state'
export { ChatHeader } from './header/header'
export { ChatInput } from './input/input'

View File

@@ -1,7 +1,7 @@
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import { Knowledge } from './knowledge'
interface KnowledgePageProps {
@@ -23,6 +23,7 @@ export default async function KnowledgePage({ params }: KnowledgePageProps) {
redirect('/')
}
// Check permission group restrictions
const permissionConfig = await getUserPermissionConfig(session.user.id)
if (permissionConfig?.hideKnowledgeBaseTab) {
redirect(`/workspace/${workspaceId}`)

View File

@@ -18,7 +18,6 @@ import {
import { ScrollArea } from '@/components/ui/scroll-area'
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
import { cn } from '@/lib/core/utils/cn'
import { formatDuration } from '@/lib/core/utils/formatting'
import { filterHiddenOutputKeys } from '@/lib/logs/execution/trace-spans/trace-spans'
import {
ExecutionSnapshot,
@@ -454,7 +453,7 @@ export const LogDetails = memo(function LogDetails({
Duration
</span>
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>
{formatDuration(log.duration, { precision: 2 }) || '—'}
{log.duration || '—'}
</span>
</div>

View File

@@ -6,11 +6,11 @@ import Link from 'next/link'
import { List, type RowComponentProps, useListRef } from 'react-window'
import { Badge, buttonVariants } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { formatDuration } from '@/lib/core/utils/formatting'
import {
DELETED_WORKFLOW_COLOR,
DELETED_WORKFLOW_LABEL,
formatDate,
formatDuration,
getDisplayStatus,
LOG_COLUMNS,
StatusBadge,
@@ -113,7 +113,7 @@ const LogRow = memo(
<div className={`${LOG_COLUMNS.duration.width} ${LOG_COLUMNS.duration.minWidth}`}>
<Badge variant='default' className='rounded-[6px] px-[9px] py-[2px] text-[12px]'>
{formatDuration(log.duration, { precision: 2 }) || '—'}
{formatDuration(log.duration) || '—'}
</Badge>
</div>
</div>

View File

@@ -1,7 +1,6 @@
import React from 'react'
import { format } from 'date-fns'
import { Badge } from '@/components/emcn'
import { formatDuration } from '@/lib/core/utils/formatting'
import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options'
import { getBlock } from '@/blocks/registry'
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
@@ -363,14 +362,47 @@ export function mapToExecutionLogAlt(log: RawLogResponse): ExecutionLog {
}
}
/**
* Format duration for display in logs UI
* If duration is under 1 second, displays as milliseconds (e.g., "500ms")
* If duration is 1 second or more, displays as seconds (e.g., "1.23s")
* @param duration - Duration string (e.g., "500ms") or null
* @returns Formatted duration string or null
*/
export function formatDuration(duration: string | null): string | null {
if (!duration) return null
// Extract numeric value from duration string (e.g., "500ms" -> 500)
const ms = Number.parseInt(duration.replace(/[^0-9]/g, ''), 10)
if (!Number.isFinite(ms)) return duration
if (ms < 1000) {
return `${ms}ms`
}
// Convert to seconds with up to 2 decimal places
const seconds = ms / 1000
return `${seconds.toFixed(2).replace(/\.?0+$/, '')}s`
}
/**
* Format latency value for display in dashboard UI
* If latency is under 1 second, displays as milliseconds (e.g., "500ms")
* If latency is 1 second or more, displays as seconds (e.g., "1.23s")
* @param ms - Latency in milliseconds (number)
* @returns Formatted latency string
*/
export function formatLatency(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return '—'
return formatDuration(ms, { precision: 2 }) ?? '—'
if (ms < 1000) {
return `${Math.round(ms)}ms`
}
// Convert to seconds with up to 2 decimal places
const seconds = ms / 1000
return `${seconds.toFixed(2).replace(/\.?0+$/, '')}s`
}
export const formatDate = (dateString: string) => {

View File

@@ -6,7 +6,7 @@ import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import type { Template as WorkspaceTemplate } from '@/app/workspace/[workspaceId]/templates/templates'
import Templates from '@/app/workspace/[workspaceId]/templates/templates'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
interface TemplatesPageProps {
params: Promise<{

View File

@@ -3,7 +3,6 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
import { ChevronUp } from 'lucide-react'
import { formatDuration } from '@/lib/core/utils/formatting'
import { CopilotMarkdownRenderer } from '../markdown-renderer'
/** Removes thinking tags (raw or escaped) and special tags from streamed content */
@@ -242,11 +241,15 @@ export function ThinkingBlock({
return () => window.clearInterval(intervalId)
}, [isStreaming, isExpanded, userHasScrolledAway])
/** Formats duration in milliseconds to seconds (minimum 1s) */
const formatDuration = (ms: number) => {
const seconds = Math.max(1, Math.round(ms / 1000))
return `${seconds}s`
}
const hasContent = cleanContent.length > 0
const isThinkingDone = !isStreaming || hasFollowingContent || hasSpecialTags
// Round to nearest second (minimum 1s) to match original behavior
const roundedMs = Math.max(1000, Math.round(duration / 1000) * 1000)
const durationText = `${label} for ${formatDuration(roundedMs)}`
const durationText = `${label} for ${formatDuration(duration)}`
const getStreamingLabel = (lbl: string) => {
if (lbl === 'Thought') return 'Thinking'

View File

@@ -15,7 +15,6 @@ import {
hasInterrupt as hasInterruptFromConfig,
isSpecialTool as isSpecialToolFromConfig,
} from '@/lib/copilot/tools/client/ui-config'
import { formatDuration } from '@/lib/core/utils/formatting'
import { CopilotMarkdownRenderer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
import { SmoothStreamingText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming'
import { ThinkingBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block'
@@ -849,10 +848,13 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
(allParsed.options && Object.keys(allParsed.options).length > 0)
)
const formatDuration = (ms: number) => {
const seconds = Math.max(1, Math.round(ms / 1000))
return `${seconds}s`
}
const outerLabel = getSubagentCompletionLabel(toolCall.name)
// Round to nearest second (minimum 1s) to match original behavior
const roundedMs = Math.max(1000, Math.round(duration / 1000) * 1000)
const durationText = `${outerLabel} for ${formatDuration(roundedMs)}`
const durationText = `${outerLabel} for ${formatDuration(duration)}`
const renderCollapsibleContent = () => (
<>

View File

@@ -294,6 +294,13 @@ const SCOPE_DESCRIPTIONS: Record<string, string> = {
'user-follow-modify': 'Follow and unfollow artists and users',
'user-read-playback-position': 'View playback position in podcasts',
'ugc-image-upload': 'Upload images to Spotify playlists',
// TikTok scopes
'user.info.basic': 'View basic profile info (avatar, display name)',
'user.info.profile': 'View profile details (bio, verified status)',
'user.info.stats': 'View account statistics (likes, followers, video count)',
'video.list': 'View public videos',
'video.publish': 'Post content to profile',
'video.upload': 'Upload content as draft',
}
function getScopeDescription(scope: string): string {
@@ -373,6 +380,13 @@ export function OAuthRequiredModal({
return
}
if (providerId === 'tiktok') {
onClose()
const returnUrl = encodeURIComponent(window.location.href)
window.location.href = `/api/auth/tiktok/authorize?returnUrl=${returnUrl}`
return
}
await client.oauth2.link({
providerId,
callbackURL: window.location.href,

View File

@@ -50,12 +50,6 @@ import { useSubBlockStore } from '@/stores/workflows/subblock/store'
/** Stable empty object to avoid creating new references */
const EMPTY_SUBBLOCK_VALUES = {} as Record<string, any>
/** Shared style for dashed divider lines */
const DASHED_DIVIDER_STYLE = {
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
} as const
/**
* Icon component for rendering block icons.
*
@@ -95,23 +89,31 @@ export function Editor() {
const blockConfig = currentBlock ? getBlock(currentBlock.type) : null
const title = currentBlock?.name || 'Editor'
// Check if selected block is a subflow (loop or parallel)
const isSubflow =
currentBlock && (currentBlock.type === 'loop' || currentBlock.type === 'parallel')
// Get subflow display properties from configs
const subflowConfig = isSubflow ? (currentBlock.type === 'loop' ? LoopTool : ParallelTool) : null
// Check if selected block is a workflow block
const isWorkflowBlock =
currentBlock && (currentBlock.type === 'workflow' || currentBlock.type === 'workflow_input')
// Get workspace ID from params
const params = useParams()
const workspaceId = params.workspaceId as string
// Refs for resize functionality
const subBlocksRef = useRef<HTMLDivElement>(null)
// Get user permissions
const userPermissions = useUserPermissionsContext()
// Get active workflow ID
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
// Get block properties (advanced/trigger modes)
const { advancedMode, triggerMode } = useEditorBlockProperties(
currentBlockId,
currentWorkflow.isSnapshotView
@@ -143,9 +145,10 @@ export function Editor() {
[subBlocksForCanonical]
)
const canonicalModeOverrides = currentBlock?.data?.canonicalModes
const advancedValuesPresent = useMemo(
() => hasAdvancedValues(subBlocksForCanonical, blockSubBlockValues, canonicalIndex),
[subBlocksForCanonical, blockSubBlockValues, canonicalIndex]
const advancedValuesPresent = hasAdvancedValues(
subBlocksForCanonical,
blockSubBlockValues,
canonicalIndex
)
const displayAdvancedOptions = userPermissions.canEdit
? advancedMode
@@ -153,9 +156,11 @@ export function Editor() {
const hasAdvancedOnlyFields = useMemo(() => {
for (const subBlock of subBlocksForCanonical) {
// Must be standalone advanced (mode: 'advanced' without canonicalParamId)
if (subBlock.mode !== 'advanced') continue
if (canonicalIndex.canonicalIdBySubBlockId[subBlock.id]) continue
// Check condition - skip if condition not met for current values
if (
subBlock.condition &&
!evaluateSubBlockCondition(subBlock.condition, blockSubBlockValues)
@@ -168,6 +173,7 @@ export function Editor() {
return false
}, [subBlocksForCanonical, canonicalIndex.canonicalIdBySubBlockId, blockSubBlockValues])
// Get subblock layout using custom hook
const { subBlocks, stateToUse: subBlockState } = useEditorSubblockLayout(
blockConfig || ({} as any),
currentBlockId || '',
@@ -200,34 +206,31 @@ export function Editor() {
return { regularSubBlocks: regular, advancedOnlySubBlocks: advancedOnly }
}, [subBlocks, canonicalIndex.canonicalIdBySubBlockId])
// Get block connections
const { incomingConnections, hasIncomingConnections } = useBlockConnections(currentBlockId || '')
// Connections resize hook
const { handleMouseDown: handleConnectionsResizeMouseDown, isResizing } = useConnectionsResize({
subBlocksRef,
})
// Collaborative actions
const {
collaborativeSetBlockCanonicalMode,
collaborativeUpdateBlockName,
collaborativeToggleBlockAdvancedMode,
} = useCollaborativeWorkflow()
// Advanced mode toggle handler
const handleToggleAdvancedMode = useCallback(() => {
if (!currentBlockId || !userPermissions.canEdit) return
collaborativeToggleBlockAdvancedMode(currentBlockId)
}, [currentBlockId, userPermissions.canEdit, collaborativeToggleBlockAdvancedMode])
// Rename state
const [isRenaming, setIsRenaming] = useState(false)
const [editedName, setEditedName] = useState('')
/**
* Ref callback that auto-selects the input text when mounted.
*/
const nameInputRefCallback = useCallback((element: HTMLInputElement | null) => {
if (element) {
element.select()
}
}, [])
const nameInputRef = useRef<HTMLInputElement>(null)
/**
* Handles starting the rename process.
@@ -248,6 +251,7 @@ export function Editor() {
if (trimmedName && trimmedName !== currentBlock?.name) {
const result = collaborativeUpdateBlockName(currentBlockId, trimmedName)
if (!result.success) {
// Keep rename mode open on error so user can correct the name
return
}
}
@@ -262,6 +266,14 @@ export function Editor() {
setEditedName('')
}, [])
// Focus input when entering rename mode
useEffect(() => {
if (isRenaming && nameInputRef.current) {
nameInputRef.current.select()
}
}, [isRenaming])
// Trigger rename mode when signaled from context menu
useEffect(() => {
if (shouldFocusRename && currentBlock) {
handleStartRename()
@@ -272,13 +284,17 @@ export function Editor() {
/**
* Handles opening documentation link in a new secure tab.
*/
const handleOpenDocs = useCallback(() => {
const handleOpenDocs = () => {
const docsLink = isSubflow ? subflowConfig?.docsLink : blockConfig?.docsLink
window.open(docsLink || 'https://docs.sim.ai/quick-reference', '_blank', 'noopener,noreferrer')
}, [isSubflow, subflowConfig?.docsLink, blockConfig?.docsLink])
if (docsLink) {
window.open(docsLink, '_blank', 'noopener,noreferrer')
}
}
// Get child workflow ID for workflow blocks
const childWorkflowId = isWorkflowBlock ? blockSubBlockValues?.workflowId : null
// Fetch child workflow state for preview (only for workflow blocks with a selected workflow)
const { data: childWorkflowState, isLoading: isLoadingChildWorkflow } =
useWorkflowState(childWorkflowId)
@@ -291,6 +307,7 @@ export function Editor() {
}
}, [childWorkflowId, workspaceId])
// Determine if connections are at minimum height (collapsed state)
const isConnectionsAtMinHeight = connectionsHeight <= 35
return (
@@ -311,7 +328,7 @@ export function Editor() {
)}
{isRenaming ? (
<input
ref={nameInputRefCallback}
ref={nameInputRef}
type='text'
value={editedName}
onChange={(e) => setEditedName(e.target.value)}
@@ -382,21 +399,23 @@ export function Editor() {
</Tooltip.Content>
</Tooltip.Root>
)} */}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='p-0'
onClick={handleOpenDocs}
aria-label='Open documentation'
>
<BookOpen className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>Open docs</p>
</Tooltip.Content>
</Tooltip.Root>
{currentBlock && (isSubflow ? subflowConfig?.docsLink : blockConfig?.docsLink) && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='p-0'
onClick={handleOpenDocs}
aria-label='Open documentation'
>
<BookOpen className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>Open docs</p>
</Tooltip.Content>
</Tooltip.Root>
)}
</div>
</div>
@@ -476,7 +495,13 @@ export function Editor() {
</div>
</div>
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
<div className='h-[1.25px]' style={DASHED_DIVIDER_STYLE} />
<div
className='h-[1.25px]'
style={{
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
}}
/>
</div>
</>
)}
@@ -541,7 +566,13 @@ export function Editor() {
/>
{showDivider && (
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
<div className='h-[1.25px]' style={DASHED_DIVIDER_STYLE} />
<div
className='h-[1.25px]'
style={{
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
}}
/>
</div>
)}
</div>
@@ -550,7 +581,13 @@ export function Editor() {
{hasAdvancedOnlyFields && userPermissions.canEdit && (
<div className='flex items-center gap-[10px] px-[2px] pt-[14px] pb-[12px]'>
<div className='h-[1.25px] flex-1' style={DASHED_DIVIDER_STYLE} />
<div
className='h-[1.25px] flex-1'
style={{
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
}}
/>
<button
type='button'
onClick={handleToggleAdvancedMode}
@@ -563,7 +600,13 @@ export function Editor() {
className={`h-[14px] w-[14px] transition-transform duration-200 ${displayAdvancedOptions ? 'rotate-180' : ''}`}
/>
</button>
<div className='h-[1.25px] flex-1' style={DASHED_DIVIDER_STYLE} />
<div
className='h-[1.25px] flex-1'
style={{
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
}}
/>
</div>
)}
@@ -587,7 +630,13 @@ export function Editor() {
/>
{index < advancedOnlySubBlocks.length - 1 && (
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
<div className='h-[1.25px]' style={DASHED_DIVIDER_STYLE} />
<div
className='h-[1.25px]'
style={{
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
}}
/>
</div>
)}
</div>

View File

@@ -24,7 +24,6 @@ import {
Tooltip,
} from '@/components/emcn'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { formatDuration } from '@/lib/core/utils/formatting'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
import {
@@ -44,6 +43,7 @@ import {
type EntryNode,
type ExecutionGroup,
flattenBlockEntriesOnly,
formatDuration,
getBlockColor,
getBlockIcon,
groupEntriesByExecution,
@@ -128,7 +128,7 @@ const BlockRow = memo(function BlockRow({
<StatusDisplay
isRunning={isRunning}
isCanceled={isCanceled}
formattedDuration={formatDuration(entry.durationMs, { precision: 2 }) ?? '-'}
formattedDuration={formatDuration(entry.durationMs)}
/>
</span>
</div>
@@ -201,7 +201,7 @@ const IterationNodeRow = memo(function IterationNodeRow({
<StatusDisplay
isRunning={hasRunningChild}
isCanceled={hasCanceledChild}
formattedDuration={formatDuration(entry.durationMs, { precision: 2 }) ?? '-'}
formattedDuration={formatDuration(entry.durationMs)}
/>
</span>
</div>
@@ -314,7 +314,7 @@ const SubflowNodeRow = memo(function SubflowNodeRow({
<StatusDisplay
isRunning={hasRunningDescendant}
isCanceled={hasCanceledDescendant}
formattedDuration={formatDuration(entry.durationMs, { precision: 2 }) ?? '-'}
formattedDuration={formatDuration(entry.durationMs)}
/>
</span>
</div>

View File

@@ -53,6 +53,17 @@ export function getBlockColor(blockType: string): string {
return '#6b7280'
}
/**
* Formats duration from milliseconds to readable format
*/
export function formatDuration(ms?: number): string {
if (ms === undefined || ms === null) return '-'
if (ms < 1000) {
return `${Math.round(ms)}ms`
}
return `${(ms / 1000).toFixed(2)}s`
}
/**
* Determines if a keyboard event originated from a text-editable element
*/

View File

@@ -30,7 +30,6 @@ import {
Textarea,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { formatDuration } from '@/lib/core/utils/formatting'
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
import { formatEditSequence } from '@/lib/workflows/training/compute-edit-sequence'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow'
@@ -576,9 +575,7 @@ export function TrainingModal() {
<span className='text-[var(--text-muted)]'>Duration:</span>{' '}
<span className='text-[var(--text-secondary)]'>
{dataset.metadata?.duration
? formatDuration(dataset.metadata.duration, {
precision: 1,
})
? `${(dataset.metadata.duration / 1000).toFixed(1)}s`
: 'N/A'}
</span>
</div>

View File

@@ -29,6 +29,7 @@ import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
import { getUserColor } from '@/lib/workspaces/colors'
import { getUserRole } from '@/lib/workspaces/organization'
import { getAllBlocks } from '@/blocks'
import { useOrganization, useOrganizations } from '@/hooks/queries/organization'
import {
type PermissionGroup,
useBulkAddPermissionGroupMembers,
@@ -38,8 +39,7 @@ import {
usePermissionGroups,
useRemovePermissionGroupMember,
useUpdatePermissionGroup,
} from '@/ee/access-control/hooks/permission-groups'
import { useOrganization, useOrganizations } from '@/hooks/queries/organization'
} from '@/hooks/queries/permission-groups'
import { useSubscriptionData } from '@/hooks/queries/subscription'
import { PROVIDER_DEFINITIONS } from '@/providers/models'
import { getAllProviderIds } from '@/providers/utils'
@@ -255,6 +255,7 @@ export function AccessControl() {
queryEnabled
)
// Show loading while dependencies load, or while permission groups query is pending
const isLoading = orgsLoading || subLoading || (queryEnabled && groupsLoading)
const { data: organization } = useOrganization(activeOrganization?.id || '')
@@ -409,8 +410,10 @@ export function AccessControl() {
}, [viewingGroup, editingConfig])
const allBlocks = useMemo(() => {
// Filter out hidden blocks and start_trigger (which should never be disabled)
const blocks = getAllBlocks().filter((b) => !b.hideFromToolbar && b.type !== 'start_trigger')
return blocks.sort((a, b) => {
// Group by category: triggers first, then blocks, then tools
const categoryOrder = { triggers: 0, blocks: 1, tools: 2 }
const catA = categoryOrder[a.category] ?? 3
const catB = categoryOrder[b.category] ?? 3
@@ -552,9 +555,10 @@ export function AccessControl() {
}, [viewingGroup, editingConfig, activeOrganization?.id, updatePermissionGroup])
const handleOpenAddMembersModal = useCallback(() => {
const existingMemberUserIds = new Set(members.map((m) => m.userId))
setSelectedMemberIds(new Set())
setShowAddMembersModal(true)
}, [])
}, [members])
const handleAddSelectedMembers = useCallback(async () => {
if (!viewingGroup || selectedMemberIds.size === 0) return
@@ -887,6 +891,7 @@ export function AccessControl() {
prev
? {
...prev,
// When deselecting all, keep start_trigger allowed (it should never be disabled)
allowedIntegrations: allAllowed ? ['start_trigger'] : null,
}
: prev

View File

@@ -246,6 +246,7 @@ export function CredentialSets() {
setNewSetDescription('')
setNewSetProvider('google-email')
// Open detail view for the newly created group
if (result?.credentialSet) {
setViewingSet(result.credentialSet)
}
@@ -335,6 +336,7 @@ export function CredentialSets() {
email,
})
// Start 60s cooldown
setResendCooldowns((prev) => ({ ...prev, [invitationId]: 60 }))
const interval = setInterval(() => {
setResendCooldowns((prev) => {
@@ -391,6 +393,7 @@ export function CredentialSets() {
return <GmailIcon className='h-4 w-4' />
}
// All hooks must be called before any early returns
const activeMemberships = useMemo(
() => memberships.filter((m) => m.status === 'active'),
[memberships]
@@ -444,6 +447,7 @@ export function CredentialSets() {
<div className='flex h-full flex-col gap-[16px]'>
<div className='min-h-0 flex-1 overflow-y-auto'>
<div className='flex flex-col gap-[16px]'>
{/* Group Info */}
<div className='flex items-center gap-[16px]'>
<div className='flex items-center gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
@@ -467,6 +471,7 @@ export function CredentialSets() {
</div>
</div>
{/* Invite Section - Email Tags Input */}
<div className='flex flex-col gap-[4px]'>
<div className='flex items-center gap-[8px]'>
<TagInput
@@ -490,6 +495,7 @@ export function CredentialSets() {
{emailError && <p className='text-[12px] text-[var(--text-error)]'>{emailError}</p>}
</div>
{/* Members List - styled like team members */}
<div className='flex flex-col gap-[16px]'>
<h4 className='font-medium text-[14px] text-[var(--text-primary)]'>Members</h4>
@@ -513,6 +519,7 @@ export function CredentialSets() {
</p>
) : (
<div className='flex flex-col gap-[16px]'>
{/* Active Members */}
{activeMembers.map((member) => {
const name = member.userName || 'Unknown'
const avatarInitial = name.charAt(0).toUpperCase()
@@ -565,6 +572,7 @@ export function CredentialSets() {
)
})}
{/* Pending Invitations */}
{pendingInvitations.map((invitation) => {
const email = invitation.email || 'Unknown'
const emailPrefix = email.split('@')[0]
@@ -633,6 +641,7 @@ export function CredentialSets() {
</div>
</div>
{/* Footer Actions */}
<div className='mt-auto flex items-center justify-end'>
<Button onClick={handleBackToList} variant='tertiary'>
Back
@@ -813,6 +822,7 @@ export function CredentialSets() {
</div>
</div>
{/* Create Polling Group Modal */}
<Modal open={showCreateModal} onOpenChange={handleCloseCreateModal}>
<ModalContent size='sm'>
<ModalHeader>Create Polling Group</ModalHeader>
@@ -885,6 +895,7 @@ export function CredentialSets() {
</ModalContent>
</Modal>
{/* Leave Confirmation Modal */}
<Modal open={!!leavingMembership} onOpenChange={() => setLeavingMembership(null)}>
<ModalContent size='sm'>
<ModalHeader>Leave Polling Group</ModalHeader>
@@ -912,6 +923,7 @@ export function CredentialSets() {
</ModalContent>
</Modal>
{/* Delete Confirmation Modal */}
<Modal open={!!deletingSet} onOpenChange={() => setDeletingSet(null)}>
<ModalContent size='sm'>
<ModalHeader>Delete Polling Group</ModalHeader>

View File

@@ -1,3 +1,4 @@
export { AccessControl } from './access-control/access-control'
export { ApiKeys } from './api-keys/api-keys'
export { BYOK } from './byok/byok'
export { Copilot } from './copilot/copilot'
@@ -9,6 +10,7 @@ export { Files as FileUploads } from './files/files'
export { General } from './general/general'
export { Integrations } from './integrations/integrations'
export { MCP } from './mcp/mcp'
export { SSO } from './sso/sso'
export { Subscription } from './subscription/subscription'
export { TeamManagement } from './team-management/team-management'
export { WorkflowMcpServers } from './workflow-mcp-servers/workflow-mcp-servers'

View File

@@ -407,12 +407,14 @@ export function MCP({ initialServerId }: MCPProps) {
const [urlScrollLeft, setUrlScrollLeft] = useState(0)
const [headerScrollLeft, setHeaderScrollLeft] = useState<Record<string, number>>({})
// Auto-select server when initialServerId is provided
useEffect(() => {
if (initialServerId && servers.some((s) => s.id === initialServerId)) {
setSelectedServerId(initialServerId)
}
}, [initialServerId, servers])
// Force refresh tools when entering server detail view to detect stale schemas
useEffect(() => {
if (selectedServerId) {
forceRefreshTools(workspaceId)
@@ -715,6 +717,7 @@ export function MCP({ initialServerId }: MCPProps) {
`Refreshed MCP server: ${serverId}, workflows updated: ${result.workflowsUpdated}`
)
// If the active workflow was updated, reload its subblock values from DB
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (activeWorkflowId && result.updatedWorkflowIds?.includes(activeWorkflowId)) {
logger.info(`Active workflow ${activeWorkflowId} was updated, reloading subblock values`)

View File

@@ -11,13 +11,55 @@ import { isBillingEnabled } from '@/lib/core/config/feature-flags'
import { cn } from '@/lib/core/utils/cn'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getUserRole } from '@/lib/workspaces/organization/utils'
import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/constants'
import { useConfigureSSO, useSSOProviders } from '@/ee/sso/hooks/sso'
import { useOrganizations } from '@/hooks/queries/organization'
import { useConfigureSSO, useSSOProviders } from '@/hooks/queries/sso'
import { useSubscriptionData } from '@/hooks/queries/subscription'
const logger = createLogger('SSO')
const TRUSTED_SSO_PROVIDERS = [
'okta',
'okta-saml',
'okta-prod',
'okta-dev',
'okta-staging',
'okta-test',
'azure-ad',
'azure-active-directory',
'azure-corp',
'azure-enterprise',
'adfs',
'adfs-company',
'adfs-corp',
'adfs-enterprise',
'auth0',
'auth0-prod',
'auth0-dev',
'auth0-staging',
'onelogin',
'onelogin-prod',
'onelogin-corp',
'jumpcloud',
'jumpcloud-prod',
'jumpcloud-corp',
'ping-identity',
'ping-federate',
'pingone',
'shibboleth',
'shibboleth-idp',
'google-workspace',
'google-sso',
'saml',
'saml2',
'saml-sso',
'oidc',
'oidc-sso',
'openid-connect',
'custom-sso',
'enterprise-sso',
'company-sso',
]
interface SSOProvider {
id: string
providerId: string
@@ -523,7 +565,7 @@ export function SSO() {
<Combobox
value={formData.providerId}
onChange={(value: string) => handleInputChange('providerId', value)}
options={SSO_TRUSTED_PROVIDERS.map((id) => ({
options={TRUSTED_SSO_PROVIDERS.map((id) => ({
label: id,
value: id,
}))}

View File

@@ -41,6 +41,7 @@ import { getEnv, isTruthy } from '@/lib/core/config/env'
import { isHosted } from '@/lib/core/config/feature-flags'
import { getUserRole } from '@/lib/workspaces/organization'
import {
AccessControl,
ApiKeys,
BYOK,
Copilot,
@@ -52,16 +53,15 @@ import {
General,
Integrations,
MCP,
SSO,
Subscription,
TeamManagement,
WorkflowMcpServers,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components'
import { TemplateProfile } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/template-profile/template-profile'
import { AccessControl } from '@/ee/access-control/components/access-control'
import { SSO } from '@/ee/sso/components/sso-settings'
import { ssoKeys, useSSOProviders } from '@/ee/sso/hooks/sso'
import { generalSettingsKeys, useGeneralSettings } from '@/hooks/queries/general-settings'
import { organizationKeys, useOrganizations } from '@/hooks/queries/organization'
import { ssoKeys, useSSOProviders } from '@/hooks/queries/sso'
import { subscriptionKeys, useSubscriptionData } from '@/hooks/queries/subscription'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSettingsModalStore } from '@/stores/modals/settings/store'

View File

@@ -19,7 +19,6 @@ import { checkUsageStatus } from '@/lib/billing/calculations/usage-monitor'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { RateLimiter } from '@/lib/core/rate-limiter'
import { decryptSecret } from '@/lib/core/security/encryption'
import { formatDuration } from '@/lib/core/utils/formatting'
import { getBaseUrl } from '@/lib/core/utils/urls'
import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types'
import { sendEmail } from '@/lib/messaging/email/mailer'
@@ -228,6 +227,12 @@ async function deliverWebhook(
}
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
return `${(ms / 60000).toFixed(1)}m`
}
function formatCost(cost?: Record<string, unknown>): string {
if (!cost?.total) return 'N/A'
const total = cost.total as number
@@ -297,7 +302,7 @@ async function deliverEmail(
workflowName: payload.data.workflowName || 'Unknown Workflow',
status: payload.data.status,
trigger: payload.data.trigger,
duration: formatDuration(payload.data.totalDurationMs, { precision: 1 }) ?? '-',
duration: formatDuration(payload.data.totalDurationMs),
cost: formatCost(payload.data.cost),
logUrl,
alertReason,
@@ -310,7 +315,7 @@ async function deliverEmail(
to: subscription.emailRecipients,
subject,
html,
text: `${subject}\n${alertReason ? `\nReason: ${alertReason}\n` : ''}\nWorkflow: ${payload.data.workflowName}\nStatus: ${statusText}\nTrigger: ${payload.data.trigger}\nDuration: ${formatDuration(payload.data.totalDurationMs, { precision: 1 }) ?? '-'}\nCost: ${formatCost(payload.data.cost)}\n\nView Log: ${logUrl}${includedDataText}`,
text: `${subject}\n${alertReason ? `\nReason: ${alertReason}\n` : ''}\nWorkflow: ${payload.data.workflowName}\nStatus: ${statusText}\nTrigger: ${payload.data.trigger}\nDuration: ${formatDuration(payload.data.totalDurationMs)}\nCost: ${formatCost(payload.data.cost)}\n\nView Log: ${logUrl}${includedDataText}`,
emailType: 'notifications',
})
@@ -368,10 +373,7 @@ async function deliverSlack(
fields: [
{ type: 'mrkdwn', text: `*Status:*\n${payload.data.status}` },
{ type: 'mrkdwn', text: `*Trigger:*\n${payload.data.trigger}` },
{
type: 'mrkdwn',
text: `*Duration:*\n${formatDuration(payload.data.totalDurationMs, { precision: 1 }) ?? '-'}`,
},
{ type: 'mrkdwn', text: `*Duration:*\n${formatDuration(payload.data.totalDurationMs)}` },
{ type: 'mrkdwn', text: `*Cost:*\n${formatCost(payload.data.cost)}` },
],
},

View File

@@ -0,0 +1,291 @@
import { TikTokIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import type { TikTokResponse } from '@/tools/tiktok/types'
export const TikTokBlock: BlockConfig<TikTokResponse> = {
type: 'tiktok',
name: 'TikTok',
description: 'Access TikTok user profiles, videos, and publish content',
authMode: AuthMode.OAuth,
longDescription:
'Integrate TikTok into your workflow. Get user profile information including follower counts and video statistics. List and query videos with cover images, embed links, and metadata. Publish videos directly to TikTok from public URLs.',
docsLink: 'https://docs.sim.ai/tools/tiktok',
category: 'tools',
bgColor: '#000000',
icon: TikTokIcon,
subBlocks: [
// Operation selection
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [
{ label: 'Get User Info', id: 'get_user' },
{ label: 'List Videos', id: 'list_videos' },
{ label: 'Query Videos', id: 'query_videos' },
{ label: 'Query Creator Info', id: 'query_creator_info' },
{ label: 'Direct Post Video', id: 'direct_post_video' },
{ label: 'Get Post Status', id: 'get_post_status' },
],
value: () => 'get_user',
},
// TikTok OAuth Authentication
{
id: 'credential',
title: 'TikTok Account',
type: 'oauth-input',
serviceId: 'tiktok',
placeholder: 'Select TikTok account',
required: true,
},
// Get User Info specific fields
{
id: 'fields',
title: 'Fields',
type: 'short-input',
placeholder: 'open_id,display_name,avatar_url,follower_count,video_count',
condition: {
field: 'operation',
value: 'get_user',
},
},
// List Videos specific fields
{
id: 'maxCount',
title: 'Max Count',
type: 'short-input',
placeholder: '20',
condition: {
field: 'operation',
value: 'list_videos',
},
},
{
id: 'cursor',
title: 'Cursor',
type: 'short-input',
placeholder: 'Pagination cursor from previous response',
condition: {
field: 'operation',
value: 'list_videos',
},
},
// Query Videos specific fields
{
id: 'videoIds',
title: 'Video IDs',
type: 'long-input',
placeholder: 'Comma-separated video IDs (e.g., 7077642457847994444,7080217258529732386)',
condition: {
field: 'operation',
value: 'query_videos',
},
required: {
field: 'operation',
value: 'query_videos',
},
},
// Direct Post Video specific fields
{
id: 'videoUrl',
title: 'Video URL',
type: 'short-input',
placeholder: 'https://example.com/video.mp4',
condition: {
field: 'operation',
value: 'direct_post_video',
},
required: {
field: 'operation',
value: 'direct_post_video',
},
},
{
id: 'title',
title: 'Caption',
type: 'long-input',
placeholder: 'Video caption with #hashtags and @mentions',
condition: {
field: 'operation',
value: 'direct_post_video',
},
},
{
id: 'privacyLevel',
title: 'Privacy Level',
type: 'dropdown',
options: [
{ label: 'Public', id: 'PUBLIC_TO_EVERYONE' },
{ label: 'Friends', id: 'MUTUAL_FOLLOW_FRIENDS' },
{ label: 'Followers', id: 'FOLLOWER_OF_CREATOR' },
{ label: 'Only Me', id: 'SELF_ONLY' },
],
value: () => 'PUBLIC_TO_EVERYONE',
condition: {
field: 'operation',
value: 'direct_post_video',
},
},
{
id: 'disableComment',
title: 'Disable Comments',
type: 'dropdown',
options: [
{ label: 'No', id: 'false' },
{ label: 'Yes', id: 'true' },
],
value: () => 'false',
condition: {
field: 'operation',
value: 'direct_post_video',
},
},
// Get Post Status specific fields
{
id: 'publishId',
title: 'Publish ID',
type: 'short-input',
placeholder: 'v_pub_file~v2-1.123456789',
condition: {
field: 'operation',
value: 'get_post_status',
},
required: {
field: 'operation',
value: 'get_post_status',
},
},
],
tools: {
access: [
'tiktok_get_user',
'tiktok_list_videos',
'tiktok_query_videos',
'tiktok_query_creator_info',
'tiktok_direct_post_video',
'tiktok_get_post_status',
],
config: {
tool: (inputs) => {
const operation = inputs.operation || 'get_user'
switch (operation) {
case 'list_videos':
return 'tiktok_list_videos'
case 'query_videos':
return 'tiktok_query_videos'
case 'query_creator_info':
return 'tiktok_query_creator_info'
case 'direct_post_video':
return 'tiktok_direct_post_video'
case 'get_post_status':
return 'tiktok_get_post_status'
default:
return 'tiktok_get_user'
}
},
params: (inputs) => {
const operation = inputs.operation || 'get_user'
const { credential } = inputs
switch (operation) {
case 'get_user':
return {
accessToken: credential,
...(inputs.fields && { fields: inputs.fields }),
}
case 'list_videos':
return {
accessToken: credential,
...(inputs.maxCount && { maxCount: Number(inputs.maxCount) }),
...(inputs.cursor && { cursor: Number(inputs.cursor) }),
}
case 'query_videos':
return {
accessToken: credential,
videoIds: inputs.videoIds
? inputs.videoIds.split(',').map((id: string) => id.trim())
: [],
}
case 'query_creator_info':
return {
accessToken: credential,
}
case 'direct_post_video':
return {
accessToken: credential,
videoUrl: inputs.videoUrl || '',
privacyLevel: inputs.privacyLevel || 'PUBLIC_TO_EVERYONE',
...(inputs.title && { title: inputs.title }),
...(inputs.disableComment === 'true' && { disableComment: true }),
}
case 'get_post_status':
return {
accessToken: credential,
publishId: inputs.publishId || '',
}
default:
return {
accessToken: credential,
}
}
},
},
},
inputs: {
operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'TikTok access token' },
fields: { type: 'string', description: 'Comma-separated list of user fields to return' },
maxCount: { type: 'number', description: 'Maximum number of videos to return (1-20)' },
cursor: { type: 'number', description: 'Pagination cursor from previous response' },
videoIds: { type: 'string', description: 'Comma-separated list of video IDs to query' },
videoUrl: { type: 'string', description: 'Public URL of the video to post' },
title: { type: 'string', description: 'Video caption/description' },
privacyLevel: { type: 'string', description: 'Privacy level for the video' },
disableComment: { type: 'string', description: 'Whether to disable comments' },
publishId: { type: 'string', description: 'Publish ID to check status for' },
},
outputs: {
// Get User outputs
openId: { type: 'string', description: 'TikTok user ID' },
displayName: { type: 'string', description: 'User display name' },
avatarUrl: { type: 'string', description: 'Profile image URL' },
bioDescription: { type: 'string', description: 'User bio' },
followerCount: { type: 'number', description: 'Number of followers' },
followingCount: { type: 'number', description: 'Number of accounts followed' },
likesCount: { type: 'number', description: 'Total likes received' },
videoCount: { type: 'number', description: 'Total public videos' },
isVerified: { type: 'boolean', description: 'Whether account is verified' },
// List/Query Videos outputs
videos: { type: 'json', description: 'Array of video objects' },
hasMore: { type: 'boolean', description: 'Whether more videos are available' },
// Query Creator Info outputs
creatorAvatarUrl: { type: 'string', description: 'Creator avatar URL' },
creatorUsername: { type: 'string', description: 'Creator username' },
creatorNickname: { type: 'string', description: 'Creator nickname' },
privacyLevelOptions: { type: 'json', description: 'Available privacy levels for posting' },
commentDisabled: { type: 'boolean', description: 'Whether comments are disabled by default' },
duetDisabled: { type: 'boolean', description: 'Whether duets are disabled by default' },
stitchDisabled: { type: 'boolean', description: 'Whether stitches are disabled by default' },
maxVideoPostDurationSec: { type: 'number', description: 'Max video duration in seconds' },
// Direct Post Video outputs
publishId: { type: 'string', description: 'Publish ID for tracking post status' },
// Get Post Status outputs
status: {
type: 'string',
description: 'Post status (PROCESSING_DOWNLOAD, PUBLISH_COMPLETE, FAILED)',
},
failReason: { type: 'string', description: 'Reason for failure if status is FAILED' },
publiclyAvailablePostId: {
type: 'json',
description: 'Array of public post IDs when published',
},
},
}

View File

@@ -131,6 +131,7 @@ import { TavilyBlock } from '@/blocks/blocks/tavily'
import { TelegramBlock } from '@/blocks/blocks/telegram'
import { TextractBlock } from '@/blocks/blocks/textract'
import { ThinkingBlock } from '@/blocks/blocks/thinking'
import { TikTokBlock } from '@/blocks/blocks/tiktok'
import { TinybirdBlock } from '@/blocks/blocks/tinybird'
import { TranslateBlock } from '@/blocks/blocks/translate'
import { TrelloBlock } from '@/blocks/blocks/trello'
@@ -303,6 +304,7 @@ export const registry: Record<string, BlockConfig> = {
supabase: SupabaseBlock,
tavily: TavilyBlock,
telegram: TelegramBlock,
tiktok: TikTokBlock,
textract: TextractBlock,
thinking: ThinkingBlock,
tinybird: TinybirdBlock,

View File

@@ -3472,6 +3472,14 @@ export function HumanInTheLoopIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function TikTokIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'>
<path d='M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1z' />
</svg>
)
}
export function TrelloIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg

View File

@@ -7,7 +7,6 @@ import { Button } from '@/components/ui/button'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import type { ToolCallGroup, ToolCallState } from '@/lib/copilot/types'
import { cn } from '@/lib/core/utils/cn'
import { formatDuration } from '@/lib/core/utils/formatting'
interface ToolCallProps {
toolCall: ToolCallState
@@ -226,6 +225,11 @@ export function ToolCallCompletion({ toolCall, isCompact = false }: ToolCallProp
const isError = toolCall.state === 'error'
const isAborted = toolCall.state === 'aborted'
const formatDuration = (duration?: number) => {
if (!duration) return ''
return duration < 1000 ? `${duration}ms` : `${(duration / 1000).toFixed(1)}s`
}
return (
<div
className={cn(
@@ -275,7 +279,7 @@ export function ToolCallCompletion({ toolCall, isCompact = false }: ToolCallProp
)}
style={{ fontSize: '0.625rem' }}
>
{toolCall.duration ? formatDuration(toolCall.duration, { precision: 1 }) : ''}
{formatDuration(toolCall.duration)}
</Badge>
)}
</div>

View File

@@ -1,43 +0,0 @@
Sim Enterprise License
Copyright (c) 2025-present Sim Studio, Inc.
This software and associated documentation files (the "Software") are licensed
under the following terms:
1. LICENSE GRANT
Subject to the terms of this license, Sim Studio, Inc. grants you a limited,
non-exclusive, non-transferable license to use the Software for:
- Development, testing, and evaluation purposes
- Internal non-production use
Production use of the Software requires a valid Sim Enterprise subscription.
2. RESTRICTIONS
You may not:
- Use the Software in production without a valid Enterprise subscription
- Modify, adapt, or create derivative works of the Software
- Redistribute, sublicense, or transfer the Software
- Remove or alter any proprietary notices in the Software
3. ENTERPRISE SUBSCRIPTION
Production deployment of enterprise features requires an active Sim Enterprise
subscription. Contact sales@simstudio.ai for licensing information.
4. DISCLAIMER
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
5. LIMITATION OF LIABILITY
IN NO EVENT SHALL SIM STUDIO, INC. BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY ARISING FROM THE USE OF THE SOFTWARE.
For questions about enterprise licensing, contact: sales@simstudio.ai

View File

@@ -1,21 +0,0 @@
# Sim Enterprise Edition
This directory contains enterprise features that require a Sim Enterprise subscription
for production use.
## Features
- **SSO (Single Sign-On)**: OIDC and SAML authentication integration
- **Access Control**: Permission groups for fine-grained user access management
- **Credential Sets**: Shared credential pools for email polling workflows
## Licensing
See [LICENSE](./LICENSE) for terms. Development and testing use is permitted.
Production deployment requires an active Enterprise subscription.
## Architecture
Enterprise features are imported directly throughout the codebase. The `ee/` directory
is required at build time. Feature visibility is controlled at runtime via environment
variables (e.g., `NEXT_PUBLIC_ACCESS_CONTROL_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED`).

View File

@@ -5,7 +5,6 @@ import {
hydrateUserFilesWithBase64,
} from '@/lib/uploads/utils/user-file-base64.server'
import { sanitizeInputFormat, sanitizeTools } from '@/lib/workflows/comparison/normalize'
import { validateBlockType } from '@/ee/access-control/utils/permission-check'
import {
BlockType,
buildResumeApiUrl,
@@ -32,6 +31,7 @@ import { streamingResponseFormatProcessor } from '@/executor/utils'
import { buildBlockExecutionError, normalizeError } from '@/executor/utils/errors'
import { isJSONString } from '@/executor/utils/json'
import { filterOutputForLog } from '@/executor/utils/output-filter'
import { validateBlockType } from '@/executor/utils/permission-check'
import type { VariableResolver } from '@/executor/variables/resolver'
import type { SerializedBlock } from '@/serializer/types'
import type { SubflowType } from '@/stores/workflows/workflow/types'

View File

@@ -6,12 +6,6 @@ import { createMcpToolId } from '@/lib/mcp/utils'
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { getAllBlocks } from '@/blocks'
import type { BlockOutput } from '@/blocks/types'
import {
validateBlockType,
validateCustomToolsAllowed,
validateMcpToolsAllowed,
validateModelProvider,
} from '@/ee/access-control/utils/permission-check'
import { AGENT, BlockType, DEFAULTS, REFERENCE, stripCustomToolPrefix } from '@/executor/constants'
import { memoryService } from '@/executor/handlers/agent/memory'
import type {
@@ -24,6 +18,12 @@ import type { BlockHandler, ExecutionContext, StreamingExecution } from '@/execu
import { collectBlockData } from '@/executor/utils/block-data'
import { buildAPIUrl, buildAuthHeaders } from '@/executor/utils/http'
import { stringifyJSON } from '@/executor/utils/json'
import {
validateBlockType,
validateCustomToolsAllowed,
validateMcpToolsAllowed,
validateModelProvider,
} from '@/executor/utils/permission-check'
import { executeProviderRequest } from '@/providers'
import { getProviderFromModel, transformBlockTool } from '@/providers/utils'
import type { SerializedBlock } from '@/serializer/types'

View File

@@ -4,11 +4,11 @@ import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import type { BlockOutput } from '@/blocks/types'
import { validateModelProvider } from '@/ee/access-control/utils/permission-check'
import { BlockType, DEFAULTS, EVALUATOR } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { buildAPIUrl, buildAuthHeaders, extractAPIErrorMessage } from '@/executor/utils/http'
import { isJSONString, parseJSON, stringifyJSON } from '@/executor/utils/json'
import { validateModelProvider } from '@/executor/utils/permission-check'
import { calculateCost, getProviderFromModel } from '@/providers/utils'
import type { SerializedBlock } from '@/serializer/types'

View File

@@ -6,7 +6,6 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { generateRouterPrompt, generateRouterV2Prompt } from '@/blocks/blocks/router'
import type { BlockOutput } from '@/blocks/types'
import { validateModelProvider } from '@/ee/access-control/utils/permission-check'
import {
BlockType,
DEFAULTS,
@@ -16,6 +15,7 @@ import {
} from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { buildAuthHeaders } from '@/executor/utils/http'
import { validateModelProvider } from '@/executor/utils/permission-check'
import { calculateCost, getProviderFromModel } from '@/providers/utils'
import type { SerializedBlock } from '@/serializer/types'

View File

@@ -1,5 +1,3 @@
'use client'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { fetchJson } from '@/hooks/selectors/helpers'

View File

@@ -1,5 +1,3 @@
'use client'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
import { fetchJson } from '@/hooks/selectors/helpers'

View File

@@ -1,5 +1,3 @@
'use client'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { organizationKeys } from '@/hooks/queries/organization'
@@ -77,3 +75,39 @@ export function useConfigureSSO() {
},
})
}
/**
* Delete SSO provider mutation
*/
interface DeleteSSOParams {
providerId: string
orgId?: string
}
export function useDeleteSSO() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ providerId }: DeleteSSOParams) => {
const response = await fetch(`/api/auth/sso/providers/${providerId}`, {
method: 'DELETE',
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.message || 'Failed to delete SSO provider')
}
return response.json()
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ssoKeys.providers() })
if (variables.orgId) {
queryClient.invalidateQueries({
queryKey: organizationKeys.detail(variables.orgId),
})
}
},
})
}

View File

@@ -1,5 +1,3 @@
'use client'
import { useMemo } from 'react'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { isAccessControlEnabled, isHosted } from '@/lib/core/config/feature-flags'
@@ -7,8 +5,8 @@ import {
DEFAULT_PERMISSION_GROUP_CONFIG,
type PermissionGroupConfig,
} from '@/lib/permission-groups/types'
import { useUserPermissionConfig } from '@/ee/access-control/hooks/permission-groups'
import { useOrganizations } from '@/hooks/queries/organization'
import { useUserPermissionConfig } from '@/hooks/queries/permission-groups'
export interface PermissionConfigResult {
config: PermissionGroupConfig

View File

@@ -23,6 +23,8 @@ import {
renderPasswordResetEmail,
renderWelcomeEmail,
} from '@/components/emails'
import { createAnonymousSession, ensureAnonymousUserExists } from '@/lib/auth/anonymous'
import { SSO_TRUSTED_PROVIDERS } from '@/lib/auth/sso/constants'
import { sendPlanWelcomeEmail } from '@/lib/billing'
import { authorizeSubscriptionReference } from '@/lib/billing/authorization'
import { handleNewUser } from '@/lib/billing/core/usage'
@@ -59,12 +61,15 @@ import { sendEmail } from '@/lib/messaging/email/mailer'
import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/constants'
import { createAnonymousSession, ensureAnonymousUserExists } from './anonymous'
const logger = createLogger('Auth')
import { getMicrosoftRefreshTokenExpiry, isMicrosoftProvider } from '@/lib/oauth/microsoft'
import {
getMicrosoftRefreshTokenExpiry,
getTikTokRefreshTokenExpiry,
isMicrosoftProvider,
isTikTokProvider,
} from '@/lib/oauth/utils'
const validStripeKey = env.STRIPE_SECRET_KEY
@@ -191,7 +196,9 @@ export const auth = betterAuth({
const refreshTokenExpiresAt = isMicrosoftProvider(account.providerId)
? getMicrosoftRefreshTokenExpiry()
: account.refreshTokenExpiresAt
: isTikTokProvider(account.providerId)
? getTikTokRefreshTokenExpiry()
: account.refreshTokenExpiresAt
await db
.update(schema.account)
@@ -316,6 +323,13 @@ export const auth = betterAuth({
.where(eq(schema.account.id, account.id))
}
if (isTikTokProvider(account.providerId)) {
await db
.update(schema.account)
.set({ refreshTokenExpiresAt: getTikTokRefreshTokenExpiry() })
.where(eq(schema.account.id, account.id))
}
// Sync webhooks for credential sets after connecting a new credential
const requestId = crypto.randomUUID().slice(0, 8)
const userMemberships = await db
@@ -2495,6 +2509,11 @@ export const auth = betterAuth({
},
},
// TikTok provider - REMOVED from generic OAuth
// TikTok uses non-standard OAuth (client_key instead of client_id)
// and cannot work with the generic OAuth plugin.
// TikTok OAuth is handled via custom routes at /api/auth/tiktok/*
// WordPress.com provider
{
providerId: 'wordpress',

View File

@@ -1,7 +1,3 @@
/**
* List of trusted SSO provider identifiers.
* Used for validation and autocomplete in SSO configuration.
*/
export const SSO_TRUSTED_PROVIDERS = [
'okta',
'okta-saml',

View File

@@ -1,37 +1,20 @@
import { db } from '@sim/db'
import * as schema from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { hasActiveSubscription } from '@/lib/billing'
const logger = createLogger('BillingAuthorization')
/**
* Check if a user is authorized to manage billing for a given reference ID
* Reference ID can be either a user ID (individual subscription) or organization ID (team subscription)
*
* This function also performs duplicate subscription validation for organizations:
* - Rejects if an organization already has an active subscription (prevents duplicates)
* - Personal subscriptions (referenceId === userId) skip this check to allow upgrades
*/
export async function authorizeSubscriptionReference(
userId: string,
referenceId: string
): Promise<boolean> {
// User can always manage their own subscriptions (Pro upgrades, etc.)
// User can always manage their own subscriptions
if (referenceId === userId) {
return true
}
// For organizations: check for existing active subscriptions to prevent duplicates
if (await hasActiveSubscription(referenceId)) {
logger.warn('Blocking checkout - active subscription already exists for organization', {
userId,
referenceId,
})
return false
}
// Check if referenceId is an organizationId the user has admin rights to
const members = await db
.select()

View File

@@ -25,11 +25,9 @@ export function useSubscriptionUpgrade() {
}
let currentSubscriptionId: string | undefined
let allSubscriptions: any[] = []
try {
const listResult = await client.subscription.list()
allSubscriptions = listResult.data || []
const activePersonalSub = allSubscriptions.find(
const activePersonalSub = listResult.data?.find(
(sub: any) => sub.status === 'active' && sub.referenceId === userId
)
currentSubscriptionId = activePersonalSub?.id
@@ -52,25 +50,6 @@ export function useSubscriptionUpgrade() {
)
if (existingOrg) {
// Check if this org already has an active team subscription
const existingTeamSub = allSubscriptions.find(
(sub: any) =>
sub.status === 'active' &&
sub.referenceId === existingOrg.id &&
(sub.plan === 'team' || sub.plan === 'enterprise')
)
if (existingTeamSub) {
logger.warn('Organization already has an active team subscription', {
userId,
organizationId: existingOrg.id,
existingSubscriptionId: existingTeamSub.id,
})
throw new Error(
'This organization already has an active team subscription. Please manage it from the billing settings.'
)
}
logger.info('Using existing organization for team plan upgrade', {
userId,
organizationId: existingOrg.id,

View File

@@ -1,5 +1,5 @@
import { db } from '@sim/db'
import { member, organization, subscription } from '@sim/db/schema'
import { member, subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray } from 'drizzle-orm'
import { checkEnterprisePlan, checkProPlan, checkTeamPlan } from '@/lib/billing/subscriptions/utils'
@@ -26,22 +26,10 @@ export async function getHighestPrioritySubscription(userId: string) {
let orgSubs: typeof personalSubs = []
if (orgIds.length > 0) {
// Verify orgs exist to filter out orphaned subscriptions
const existingOrgs = await db
.select({ id: organization.id })
.from(organization)
.where(inArray(organization.id, orgIds))
const validOrgIds = existingOrgs.map((o) => o.id)
if (validOrgIds.length > 0) {
orgSubs = await db
.select()
.from(subscription)
.where(
and(inArray(subscription.referenceId, validOrgIds), eq(subscription.status, 'active'))
)
}
orgSubs = await db
.select()
.from(subscription)
.where(and(inArray(subscription.referenceId, orgIds), eq(subscription.status, 'active')))
}
const allSubs = [...personalSubs, ...orgSubs]

View File

@@ -25,28 +25,6 @@ const logger = createLogger('SubscriptionCore')
export { getHighestPrioritySubscription }
/**
* Check if a referenceId (user ID or org ID) has an active subscription
* Used for duplicate subscription prevention
*
* Fails closed: returns true on error to prevent duplicate creation
*/
export async function hasActiveSubscription(referenceId: string): Promise<boolean> {
try {
const [activeSub] = await db
.select({ id: subscription.id })
.from(subscription)
.where(and(eq(subscription.referenceId, referenceId), eq(subscription.status, 'active')))
.limit(1)
return !!activeSub
} catch (error) {
logger.error('Error checking active subscription', { error, referenceId })
// Fail closed: assume subscription exists to prevent duplicate creation
return true
}
}
/**
* Check if user is on Pro plan (direct or via organization)
*/

View File

@@ -11,7 +11,6 @@ export {
getHighestPrioritySubscription as getActiveSubscription,
getUserSubscriptionState as getSubscriptionState,
hasAccessControlAccess,
hasActiveSubscription,
hasCredentialSetsAccess,
hasSSOAccess,
isEnterpriseOrgAdminOrOwner,
@@ -33,11 +32,6 @@ export {
} from '@/lib/billing/core/usage'
export * from '@/lib/billing/credits/balance'
export * from '@/lib/billing/credits/purchase'
export {
blockOrgMembers,
getOrgMemberIds,
unblockOrgMembers,
} from '@/lib/billing/organizations/membership'
export * from '@/lib/billing/subscriptions/utils'
export { canEditUsageLimit as canEditLimit } from '@/lib/billing/subscriptions/utils'
export * from '@/lib/billing/types'

View File

@@ -8,7 +8,6 @@ import {
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { hasActiveSubscription } from '@/lib/billing'
import { getPlanPricing } from '@/lib/billing/core/billing'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
@@ -160,16 +159,6 @@ export async function ensureOrganizationForTeamSubscription(
if (existingMembership.length > 0) {
const membership = existingMembership[0]
if (membership.role === 'owner' || membership.role === 'admin') {
// Check if org already has an active subscription (prevent duplicates)
if (await hasActiveSubscription(membership.organizationId)) {
logger.error('Organization already has an active subscription', {
userId,
organizationId: membership.organizationId,
newSubscriptionId: subscription.id,
})
throw new Error('Organization already has an active subscription')
}
logger.info('User already owns/admins an org, using it', {
userId,
organizationId: membership.organizationId,

View File

@@ -15,86 +15,13 @@ import {
userStats,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray, isNull, ne, or, sql } from 'drizzle-orm'
import { and, eq, sql } from 'drizzle-orm'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { validateSeatAvailability } from '@/lib/billing/validation/seat-management'
const logger = createLogger('OrganizationMembership')
export type BillingBlockReason = 'payment_failed' | 'dispute'
/**
* Get all member user IDs for an organization
*/
export async function getOrgMemberIds(organizationId: string): Promise<string[]> {
const members = await db
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, organizationId))
return members.map((m) => m.userId)
}
/**
* Block all members of an organization for billing reasons
* Returns the number of members actually blocked
*
* Reason priority: dispute > payment_failed
* A payment_failed block won't overwrite an existing dispute block
*/
export async function blockOrgMembers(
organizationId: string,
reason: BillingBlockReason
): Promise<number> {
const memberIds = await getOrgMemberIds(organizationId)
if (memberIds.length === 0) {
return 0
}
// Don't overwrite dispute blocks with payment_failed (dispute is higher priority)
const whereClause =
reason === 'payment_failed'
? and(
inArray(userStats.userId, memberIds),
or(ne(userStats.billingBlockedReason, 'dispute'), isNull(userStats.billingBlockedReason))
)
: inArray(userStats.userId, memberIds)
const result = await db
.update(userStats)
.set({ billingBlocked: true, billingBlockedReason: reason })
.where(whereClause)
.returning({ userId: userStats.userId })
return result.length
}
/**
* Unblock all members of an organization blocked for a specific reason
* Only unblocks members blocked for the specified reason (not other reasons)
* Returns the number of members actually unblocked
*/
export async function unblockOrgMembers(
organizationId: string,
reason: BillingBlockReason
): Promise<number> {
const memberIds = await getOrgMemberIds(organizationId)
if (memberIds.length === 0) {
return 0
}
const result = await db
.update(userStats)
.set({ billingBlocked: false, billingBlockedReason: null })
.where(and(inArray(userStats.userId, memberIds), eq(userStats.billingBlockedReason, reason)))
.returning({ userId: userStats.userId })
return result.length
}
export interface RestoreProResult {
restored: boolean
usageRestored: boolean

View File

@@ -1,9 +1,8 @@
import { db } from '@sim/db'
import { subscription, user, userStats } from '@sim/db/schema'
import { member, subscription, user, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import type Stripe from 'stripe'
import { blockOrgMembers, unblockOrgMembers } from '@/lib/billing'
import { requireStripeClient } from '@/lib/billing/stripe-client'
const logger = createLogger('DisputeWebhooks')
@@ -58,34 +57,36 @@ export async function handleChargeDispute(event: Stripe.Event): Promise<void> {
if (subs.length > 0) {
const orgId = subs[0].referenceId
const memberCount = await blockOrgMembers(orgId, 'dispute')
if (memberCount > 0) {
logger.warn('Blocked all org members due to dispute', {
const owners = await db
.select({ userId: member.userId })
.from(member)
.where(and(eq(member.organizationId, orgId), eq(member.role, 'owner')))
.limit(1)
if (owners.length > 0) {
await db
.update(userStats)
.set({ billingBlocked: true, billingBlockedReason: 'dispute' })
.where(eq(userStats.userId, owners[0].userId))
logger.warn('Blocked org owner due to dispute', {
disputeId: dispute.id,
ownerId: owners[0].userId,
organizationId: orgId,
memberCount,
})
}
}
}
/**
* Handles charge.dispute.closed - unblocks user if dispute was won or warning closed
*
* Status meanings:
* - 'won': Merchant won, customer's chargeback denied → unblock
* - 'lost': Customer won, money refunded → stay blocked (they owe us)
* - 'warning_closed': Pre-dispute inquiry closed without chargeback → unblock (false alarm)
* Handles charge.dispute.closed - unblocks user if dispute was won
*/
export async function handleDisputeClosed(event: Stripe.Event): Promise<void> {
const dispute = event.data.object as Stripe.Dispute
// Only unblock if we won or the warning was closed without a full dispute
const shouldUnblock = dispute.status === 'won' || dispute.status === 'warning_closed'
if (!shouldUnblock) {
logger.info('Dispute resolved against us, user remains blocked', {
if (dispute.status !== 'won') {
logger.info('Dispute not won, user remains blocked', {
disputeId: dispute.id,
status: dispute.status,
})
@@ -97,7 +98,7 @@ export async function handleDisputeClosed(event: Stripe.Event): Promise<void> {
return
}
// Find and unblock user (Pro plans) - only if blocked for dispute, not other reasons
// Find and unblock user (Pro plans)
const users = await db
.select({ id: user.id })
.from(user)
@@ -108,17 +109,16 @@ export async function handleDisputeClosed(event: Stripe.Event): Promise<void> {
await db
.update(userStats)
.set({ billingBlocked: false, billingBlockedReason: null })
.where(and(eq(userStats.userId, users[0].id), eq(userStats.billingBlockedReason, 'dispute')))
.where(eq(userStats.userId, users[0].id))
logger.info('Unblocked user after dispute resolved in our favor', {
logger.info('Unblocked user after winning dispute', {
disputeId: dispute.id,
userId: users[0].id,
status: dispute.status,
})
return
}
// Find and unblock all org members (Team/Enterprise) - consistent with payment success
// Find and unblock org owner (Team/Enterprise)
const subs = await db
.select({ referenceId: subscription.referenceId })
.from(subscription)
@@ -127,13 +127,24 @@ export async function handleDisputeClosed(event: Stripe.Event): Promise<void> {
if (subs.length > 0) {
const orgId = subs[0].referenceId
const memberCount = await unblockOrgMembers(orgId, 'dispute')
logger.info('Unblocked all org members after dispute resolved in our favor', {
disputeId: dispute.id,
organizationId: orgId,
memberCount,
status: dispute.status,
})
const owners = await db
.select({ userId: member.userId })
.from(member)
.where(and(eq(member.organizationId, orgId), eq(member.role, 'owner')))
.limit(1)
if (owners.length > 0) {
await db
.update(userStats)
.set({ billingBlocked: false, billingBlockedReason: null })
.where(eq(userStats.userId, owners[0].userId))
logger.info('Unblocked org owner after winning dispute', {
disputeId: dispute.id,
ownerId: owners[0].userId,
organizationId: orgId,
})
}
}
}

View File

@@ -8,13 +8,12 @@ import {
userStats,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray, isNull, ne, or } from 'drizzle-orm'
import { and, eq, inArray } from 'drizzle-orm'
import type Stripe from 'stripe'
import { getEmailSubject, PaymentFailedEmail, renderCreditPurchaseEmail } from '@/components/emails'
import { calculateSubscriptionOverage } from '@/lib/billing/core/billing'
import { addCredits, getCreditBalance, removeCredits } from '@/lib/billing/credits/balance'
import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase'
import { blockOrgMembers, unblockOrgMembers } from '@/lib/billing/organizations/membership'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'
@@ -503,7 +502,24 @@ export async function handleInvoicePaymentSucceeded(event: Stripe.Event) {
}
if (sub.plan === 'team' || sub.plan === 'enterprise') {
await unblockOrgMembers(sub.referenceId, 'payment_failed')
const members = await db
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, sub.referenceId))
const memberIds = members.map((m) => m.userId)
if (memberIds.length > 0) {
// Only unblock users blocked for payment_failed, not disputes
await db
.update(userStats)
.set({ billingBlocked: false, billingBlockedReason: null })
.where(
and(
inArray(userStats.userId, memberIds),
eq(userStats.billingBlockedReason, 'payment_failed')
)
)
}
} else {
// Only unblock users blocked for payment_failed, not disputes
await db
@@ -600,26 +616,28 @@ export async function handleInvoicePaymentFailed(event: Stripe.Event) {
if (records.length > 0) {
const sub = records[0]
if (sub.plan === 'team' || sub.plan === 'enterprise') {
const memberCount = await blockOrgMembers(sub.referenceId, 'payment_failed')
const members = await db
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, sub.referenceId))
const memberIds = members.map((m) => m.userId)
if (memberIds.length > 0) {
await db
.update(userStats)
.set({ billingBlocked: true, billingBlockedReason: 'payment_failed' })
.where(inArray(userStats.userId, memberIds))
}
logger.info('Blocked team/enterprise members due to payment failure', {
organizationId: sub.referenceId,
memberCount,
memberCount: members.length,
isOverageInvoice,
})
} else {
// Don't overwrite dispute blocks (dispute > payment_failed priority)
await db
.update(userStats)
.set({ billingBlocked: true, billingBlockedReason: 'payment_failed' })
.where(
and(
eq(userStats.userId, sub.referenceId),
or(
ne(userStats.billingBlockedReason, 'dispute'),
isNull(userStats.billingBlockedReason)
)
)
)
.where(eq(userStats.userId, sub.referenceId))
logger.info('Blocked user due to payment failure', {
userId: sub.referenceId,
isOverageInvoice,

View File

@@ -3,7 +3,6 @@ import { member, organization, subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, ne } from 'drizzle-orm'
import { calculateSubscriptionOverage } from '@/lib/billing/core/billing'
import { hasActiveSubscription } from '@/lib/billing/core/subscription'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { restoreUserProSubscription } from '@/lib/billing/organizations/membership'
import { requireStripeClient } from '@/lib/billing/stripe-client'
@@ -53,37 +52,14 @@ async function restoreMemberProSubscriptions(organizationId: string): Promise<nu
/**
* Cleanup organization when team/enterprise subscription is deleted.
* - Checks if other active subscriptions point to this org (skip deletion if so)
* - Restores member Pro subscriptions
* - Deletes the organization (only if no other active subs)
* - Deletes the organization
* - Syncs usage limits for former members (resets to free or Pro tier)
*/
async function cleanupOrganizationSubscription(organizationId: string): Promise<{
restoredProCount: number
membersSynced: number
organizationDeleted: boolean
}> {
// Check if other active subscriptions still point to this org
// Note: The subscription being deleted is already marked as 'canceled' by better-auth
// before this handler runs, so we only find truly active ones
if (await hasActiveSubscription(organizationId)) {
logger.info('Skipping organization deletion - other active subscriptions exist', {
organizationId,
})
// Still sync limits for members since this subscription was deleted
const memberUserIds = await db
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, organizationId))
for (const m of memberUserIds) {
await syncUsageLimitsFromSubscription(m.userId)
}
return { restoredProCount: 0, membersSynced: memberUserIds.length, organizationDeleted: false }
}
// Get member userIds before deletion (needed for limit syncing after org deletion)
const memberUserIds = await db
.select({ userId: member.userId })
@@ -99,7 +75,7 @@ async function cleanupOrganizationSubscription(organizationId: string): Promise<
await syncUsageLimitsFromSubscription(m.userId)
}
return { restoredProCount, membersSynced: memberUserIds.length, organizationDeleted: true }
return { restoredProCount, membersSynced: memberUserIds.length }
}
/**
@@ -196,14 +172,15 @@ export async function handleSubscriptionDeleted(subscription: {
referenceId: subscription.referenceId,
})
const { restoredProCount, membersSynced, organizationDeleted } =
await cleanupOrganizationSubscription(subscription.referenceId)
const { restoredProCount, membersSynced } = await cleanupOrganizationSubscription(
subscription.referenceId
)
logger.info('Successfully processed enterprise subscription cancellation', {
subscriptionId: subscription.id,
stripeSubscriptionId,
restoredProCount,
organizationDeleted,
organizationDeleted: true,
membersSynced,
})
return
@@ -320,7 +297,7 @@ export async function handleSubscriptionDeleted(subscription: {
const cleanup = await cleanupOrganizationSubscription(subscription.referenceId)
restoredProCount = cleanup.restoredProCount
membersSynced = cleanup.membersSynced
organizationDeleted = cleanup.organizationDeleted
organizationDeleted = true
} else if (subscription.plan === 'pro') {
await syncUsageLimitsFromSubscription(subscription.referenceId)
membersSynced = 1

View File

@@ -5,8 +5,8 @@ import { and, eq, isNull } from 'drizzle-orm'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
import { isHiddenFromDisplay } from '@/blocks/types'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { escapeRegExp } from '@/executor/constants'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import type { ChatContext } from '@/stores/panel/copilot/types'
export type AgentContextType =

View File

@@ -7,7 +7,7 @@ import {
} from '@/lib/copilot/tools/shared/schemas'
import { registry as blockRegistry, getLatestBlock } from '@/blocks/registry'
import { isHiddenFromDisplay, type SubBlockConfig } from '@/blocks/types'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import { PROVIDER_DEFINITIONS } from '@/providers/models'
import { tools as toolsRegistry } from '@/tools/registry'
import { getTrigger, isTriggerValid } from '@/triggers'

View File

@@ -6,7 +6,7 @@ import {
type GetBlockOptionsResultType,
} from '@/lib/copilot/tools/shared/schemas'
import { registry as blockRegistry, getLatestBlock } from '@/blocks/registry'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import { tools as toolsRegistry } from '@/tools/registry'
export const getBlockOptionsServerTool: BaseServerTool<

View File

@@ -6,7 +6,7 @@ import {
} from '@/lib/copilot/tools/shared/schemas'
import { registry as blockRegistry } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
export const getBlocksAndToolsServerTool: BaseServerTool<
ReturnType<typeof GetBlocksAndToolsInput.parse>,

View File

@@ -8,7 +8,7 @@ import {
} from '@/lib/copilot/tools/shared/schemas'
import { registry as blockRegistry } from '@/blocks/registry'
import { AuthMode, type BlockConfig, isHiddenFromDisplay } from '@/blocks/types'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import { PROVIDER_DEFINITIONS } from '@/providers/models'
import { tools as toolsRegistry } from '@/tools/registry'
import { getTrigger, isTriggerValid } from '@/triggers'

View File

@@ -3,7 +3,7 @@ import { z } from 'zod'
import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool'
import { registry as blockRegistry } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
export const GetTriggerBlocksInput = z.object({})
export const GetTriggerBlocksResult = z.object({

View File

@@ -15,8 +15,8 @@ import { buildCanonicalIndex, isCanonicalPair } from '@/lib/workflows/subblocks/
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { getAllBlocks, getBlock } from '@/blocks/registry'
import type { BlockConfig, SubBlockConfig } from '@/blocks/types'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { EDGE, normalizeName, RESERVED_BLOCK_NAMES } from '@/executor/constants'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants'

View File

@@ -244,6 +244,8 @@ export const env = createEnv({
SPOTIFY_CLIENT_ID: z.string().optional(), // Spotify OAuth client ID
SPOTIFY_CLIENT_SECRET: z.string().optional(), // Spotify OAuth client secret
CALCOM_CLIENT_ID: z.string().optional(), // Cal.com OAuth client ID
TIKTOK_CLIENT_ID: z.string().optional(), // TikTok OAuth client ID
TIKTOK_CLIENT_SECRET: z.string().optional(), // TikTok OAuth client secret
// E2B Remote Code Execution
E2B_ENABLED: z.string().optional(), // Enable E2B remote code execution

View File

@@ -153,50 +153,22 @@ export function formatCompactTimestamp(iso: string): string {
}
/**
* Format a duration to a human-readable format
* @param duration - Duration in milliseconds (number) or as string (e.g., "500ms")
* Format a duration in milliseconds to a human-readable format
* @param durationMs - The duration in milliseconds
* @param options - Optional formatting options
* @param options.precision - Number of decimal places for seconds (default: 0), trailing zeros are stripped
* @returns A formatted duration string, or null if input is null/undefined
* @param options.precision - Number of decimal places for seconds (default: 0)
* @returns A formatted duration string
*/
export function formatDuration(
duration: number | string | undefined | null,
options?: { precision?: number }
): string | null {
if (duration === undefined || duration === null) {
return null
}
// Parse string durations (e.g., "500ms", "0.44ms", "1234")
let ms: number
if (typeof duration === 'string') {
ms = Number.parseFloat(duration.replace(/[^0-9.-]/g, ''))
if (!Number.isFinite(ms)) {
return duration
}
} else {
ms = duration
}
export function formatDuration(durationMs: number, options?: { precision?: number }): string {
const precision = options?.precision ?? 0
if (ms < 1) {
// Sub-millisecond: show with 2 decimal places
return `${ms.toFixed(2)}ms`
if (durationMs < 1000) {
return `${durationMs}ms`
}
if (ms < 1000) {
// Milliseconds: round to integer
return `${Math.round(ms)}ms`
}
const seconds = ms / 1000
const seconds = durationMs / 1000
if (seconds < 60) {
if (precision > 0) {
// Strip trailing zeros (e.g., "5.00s" -> "5s", "5.10s" -> "5.1s")
return `${seconds.toFixed(precision).replace(/\.?0+$/, '')}s`
}
return `${Math.floor(seconds)}s`
return precision > 0 ? `${seconds.toFixed(precision)}s` : `${Math.floor(seconds)}s`
}
const minutes = Math.floor(seconds / 60)

View File

@@ -33,7 +33,6 @@ import type {
WorkflowExecutionSnapshot,
WorkflowState,
} from '@/lib/logs/types'
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
export interface ToolCall {
name: string
@@ -504,7 +503,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
}
try {
// Get the workflow record to get workspace and fallback userId
// Get the workflow record to get the userId
const [workflowRecord] = await db
.select()
.from(workflow)
@@ -516,12 +515,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
return
}
let billingUserId: string | null = null
if (workflowRecord.workspaceId) {
billingUserId = await getWorkspaceBilledAccountUserId(workflowRecord.workspaceId)
}
const userId = billingUserId || workflowRecord.userId
const userId = workflowRecord.userId
const costToStore = costSummary.totalCost
const existing = await db.select().from(userStats).where(eq(userStats.userId, userId))

View File

@@ -1,4 +1,3 @@
export * from './microsoft'
export * from './oauth'
export * from './types'
export * from './utils'

View File

@@ -1,19 +0,0 @@
export const MICROSOFT_REFRESH_TOKEN_LIFETIME_DAYS = 90
export const PROACTIVE_REFRESH_THRESHOLD_DAYS = 7
export const MICROSOFT_PROVIDERS = new Set([
'microsoft-excel',
'microsoft-planner',
'microsoft-teams',
'outlook',
'onedrive',
'sharepoint',
])
export function isMicrosoftProvider(providerId: string): boolean {
return MICROSOFT_PROVIDERS.has(providerId)
}
export function getMicrosoftRefreshTokenExpiry(): Date {
return new Date(Date.now() + MICROSOFT_REFRESH_TOKEN_LIFETIME_DAYS * 24 * 60 * 60 * 1000)
}

View File

@@ -32,6 +32,7 @@ import {
ShopifyIcon,
SlackIcon,
SpotifyIcon,
TikTokIcon,
TrelloIcon,
VertexIcon,
WealthboxIcon,
@@ -796,6 +797,27 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
},
defaultService: 'spotify',
},
tiktok: {
name: 'TikTok',
icon: TikTokIcon,
services: {
tiktok: {
name: 'TikTok',
description: 'Access TikTok user profiles, videos, and publish content.',
providerId: 'tiktok',
icon: TikTokIcon,
baseProviderIcon: TikTokIcon,
scopes: [
'user.info.basic',
'user.info.profile',
'user.info.stats',
'video.list',
'video.publish',
],
},
},
defaultService: 'tiktok',
},
}
interface ProviderAuthConfig {
@@ -810,6 +832,11 @@ interface ProviderAuthConfig {
* instead of in the request body. Used by Cal.com.
*/
refreshTokenInAuthHeader?: boolean
/**
* Custom parameter name for client ID in request body.
* Defaults to 'client_id'. TikTok uses 'client_key'.
*/
clientIdParamName?: string
}
/**
@@ -1135,6 +1162,20 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
supportsRefreshTokenRotation: false,
}
}
case 'tiktok': {
const { clientId, clientSecret } = getCredentials(
env.TIKTOK_CLIENT_ID,
env.TIKTOK_CLIENT_SECRET
)
return {
tokenEndpoint: 'https://open.tiktokapis.com/v2/oauth/token/',
clientId,
clientSecret,
useBasicAuth: false,
supportsRefreshTokenRotation: true,
clientIdParamName: 'client_key', // TikTok uses client_key instead of client_id
}
}
default:
throw new Error(`Unsupported provider: ${provider}`)
}
@@ -1171,7 +1212,9 @@ function buildAuthRequest(
headers.Authorization = `Basic ${basicAuth}`
} else {
// Use body credentials - include client credentials in request body
bodyParams.client_id = config.clientId
// Use custom param name if specified (e.g., TikTok uses 'client_key' instead of 'client_id')
const clientIdParam = config.clientIdParamName || 'client_id'
bodyParams[clientIdParam] = config.clientId
if (config.clientSecret) {
bodyParams.client_secret = config.clientSecret
}

View File

@@ -42,6 +42,7 @@ export type OAuthProvider =
| 'wordpress'
| 'spotify'
| 'calcom'
| 'tiktok'
export type OAuthService =
| 'google'
@@ -83,6 +84,7 @@ export type OAuthService =
| 'wordpress'
| 'spotify'
| 'calcom'
| 'tiktok'
export interface OAuthProviderConfig {
name: string

View File

@@ -7,6 +7,49 @@ import type {
ScopeEvaluation,
} from './types'
// =============================================================================
// Refresh Token Configuration
// =============================================================================
// Microsoft refresh token configuration (90 days)
const MICROSOFT_REFRESH_TOKEN_LIFETIME_DAYS = 90
export const PROACTIVE_REFRESH_THRESHOLD_DAYS = 7
const MICROSOFT_PROVIDERS = new Set([
'microsoft-excel',
'microsoft-planner',
'microsoft-teams',
'outlook',
'onedrive',
'sharepoint',
])
export function isMicrosoftProvider(providerId: string): boolean {
return MICROSOFT_PROVIDERS.has(providerId)
}
export function getMicrosoftRefreshTokenExpiry(): Date {
return new Date(Date.now() + MICROSOFT_REFRESH_TOKEN_LIFETIME_DAYS * 24 * 60 * 60 * 1000)
}
// TikTok refresh token configuration (365 days)
// TikTok access tokens expire in 24 hours, refresh tokens are valid for 365 days
const TIKTOK_REFRESH_TOKEN_LIFETIME_DAYS = 365
const TIKTOK_PROVIDERS = new Set(['tiktok'])
export function isTikTokProvider(providerId: string): boolean {
return TIKTOK_PROVIDERS.has(providerId)
}
export function getTikTokRefreshTokenExpiry(): Date {
return new Date(Date.now() + TIKTOK_REFRESH_TOKEN_LIFETIME_DAYS * 24 * 60 * 60 * 1000)
}
// =============================================================================
// OAuth Service Utilities
// =============================================================================
/**
* Returns a flat list of all available OAuth services with metadata.
* This is safe to use on the server as it doesn't include React components.

View File

@@ -1625,6 +1625,14 @@ import {
} from '@/tools/telegram'
import { textractParserTool } from '@/tools/textract'
import { thinkingTool } from '@/tools/thinking'
import {
tiktokDirectPostVideoTool,
tiktokGetPostStatusTool,
tiktokGetUserTool,
tiktokListVideosTool,
tiktokQueryCreatorInfoTool,
tiktokQueryVideosTool,
} from '@/tools/tiktok'
import { tinybirdEventsTool, tinybirdQueryTool } from '@/tools/tinybird'
import {
trelloAddCommentTool,
@@ -2731,6 +2739,12 @@ export const tools: Record<string, ToolConfig> = {
telegram_send_photo: telegramSendPhotoTool,
telegram_send_video: telegramSendVideoTool,
telegram_send_document: telegramSendDocumentTool,
tiktok_get_user: tiktokGetUserTool,
tiktok_list_videos: tiktokListVideosTool,
tiktok_query_videos: tiktokQueryVideosTool,
tiktok_query_creator_info: tiktokQueryCreatorInfoTool,
tiktok_direct_post_video: tiktokDirectPostVideoTool,
tiktok_get_post_status: tiktokGetPostStatusTool,
clay_populate: clayPopulateTool,
clerk_list_users: clerkListUsersTool,
clerk_get_user: clerkGetUserTool,

View File

@@ -0,0 +1,156 @@
import type {
TikTokDirectPostVideoParams,
TikTokDirectPostVideoResponse,
} from '@/tools/tiktok/types'
import type { ToolConfig } from '@/tools/types'
export const tiktokDirectPostVideoTool: ToolConfig<
TikTokDirectPostVideoParams,
TikTokDirectPostVideoResponse
> = {
id: 'tiktok_direct_post_video',
name: 'TikTok Direct Post Video',
description:
'Publish a video to TikTok from a public URL. TikTok will fetch the video from the provided URL and post it to the authenticated user account. Rate limit: 6 requests per minute per user.',
version: '1.0.0',
oauth: {
required: true,
provider: 'tiktok',
requiredScopes: ['video.publish'],
},
params: {
videoUrl: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Public URL of the video to post. Must be accessible by TikTok servers.',
},
title: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Video caption/description. Maximum 2200 characters.',
},
privacyLevel: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description:
'Privacy level for the video. Options: PUBLIC_TO_EVERYONE, MUTUAL_FOLLOW_FRIENDS, FOLLOWER_OF_CREATOR, SELF_ONLY. Note: Unaudited apps may be restricted to SELF_ONLY.',
},
disableDuet: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Disable duet for this video. Defaults to false.',
},
disableStitch: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Disable stitch for this video. Defaults to false.',
},
disableComment: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Disable comments for this video. Defaults to false.',
},
videoCoverTimestampMs: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Timestamp in milliseconds to use as the video cover image.',
},
isAigc: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Set to true if the video is AI-generated content (AIGC).',
},
},
request: {
url: () => 'https://open.tiktokapis.com/v2/post/publish/video/init/',
method: 'POST',
headers: (params: TikTokDirectPostVideoParams) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json; charset=UTF-8',
}),
body: (params: TikTokDirectPostVideoParams) => {
const postInfo: Record<string, unknown> = {
privacy_level: params.privacyLevel,
}
if (params.title) {
postInfo.title = params.title
}
if (params.disableDuet !== undefined) {
postInfo.disable_duet = params.disableDuet
}
if (params.disableStitch !== undefined) {
postInfo.disable_stitch = params.disableStitch
}
if (params.disableComment !== undefined) {
postInfo.disable_comment = params.disableComment
}
if (params.videoCoverTimestampMs !== undefined) {
postInfo.video_cover_timestamp_ms = params.videoCoverTimestampMs
}
if (params.isAigc !== undefined) {
postInfo.is_aigc = params.isAigc
}
return {
post_info: postInfo,
source_info: {
source: 'PULL_FROM_URL',
video_url: params.videoUrl,
},
}
},
},
transformResponse: async (response: Response): Promise<TikTokDirectPostVideoResponse> => {
const data = await response.json()
if (data.error?.code !== 'ok' && data.error?.code) {
return {
success: false,
output: {
publishId: '',
},
error: data.error?.message || 'Failed to initiate video post',
}
}
const publishId = data.data?.publish_id
if (!publishId) {
return {
success: false,
output: {
publishId: '',
},
error: 'No publish ID returned',
}
}
return {
success: true,
output: {
publishId: publishId,
},
}
},
outputs: {
publishId: {
type: 'string',
description:
'Unique identifier for tracking the post status. Use this with the Get Post Status tool to check if the video was successfully published.',
},
},
}

View File

@@ -0,0 +1,97 @@
import type { TikTokGetPostStatusParams, TikTokGetPostStatusResponse } from '@/tools/tiktok/types'
import type { ToolConfig } from '@/tools/types'
export const tiktokGetPostStatusTool: ToolConfig<
TikTokGetPostStatusParams,
TikTokGetPostStatusResponse
> = {
id: 'tiktok_get_post_status',
name: 'TikTok Get Post Status',
description:
'Check the status of a video post initiated with Direct Post Video. Use the publishId returned from the post request to track progress.',
version: '1.0.0',
oauth: {
required: true,
provider: 'tiktok',
requiredScopes: ['video.publish'],
},
params: {
publishId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The publish ID returned from the Direct Post Video tool.',
},
},
request: {
url: () => 'https://open.tiktokapis.com/v2/post/publish/status/fetch/',
method: 'POST',
headers: (params: TikTokGetPostStatusParams) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json; charset=UTF-8',
}),
body: (params: TikTokGetPostStatusParams) => ({
publish_id: params.publishId,
}),
},
transformResponse: async (response: Response): Promise<TikTokGetPostStatusResponse> => {
const data = await response.json()
if (data.error?.code !== 'ok' && data.error?.code) {
return {
success: false,
output: {
status: '',
failReason: null,
publiclyAvailablePostId: [],
},
error: data.error?.message || 'Failed to fetch post status',
}
}
const statusData = data.data
if (!statusData) {
return {
success: false,
output: {
status: '',
failReason: null,
publiclyAvailablePostId: [],
},
error: 'No status data returned',
}
}
return {
success: true,
output: {
status: statusData.status ?? '',
failReason: statusData.fail_reason ?? null,
publiclyAvailablePostId: statusData.publicaly_available_post_id ?? [],
},
}
},
outputs: {
status: {
type: 'string',
description:
'Current status of the post. Values: PROCESSING_DOWNLOAD (TikTok is downloading the video), PUBLISH_COMPLETE (successfully posted), FAILED (check failReason).',
},
failReason: {
type: 'string',
description: 'Reason for failure if status is FAILED. Null otherwise.',
optional: true,
},
publiclyAvailablePostId: {
type: 'array',
description:
'Array of public post IDs once the video is published. Can be used to construct the TikTok video URL.',
},
},
}

View File

@@ -0,0 +1,185 @@
import type { TikTokGetUserParams, TikTokGetUserResponse } from '@/tools/tiktok/types'
import type { ToolConfig } from '@/tools/types'
export const tiktokGetUserTool: ToolConfig<TikTokGetUserParams, TikTokGetUserResponse> = {
id: 'tiktok_get_user',
name: 'TikTok Get User',
description:
'Get the authenticated TikTok user profile information including display name, avatar, bio, follower count, and video statistics.',
version: '1.0.0',
oauth: {
required: true,
provider: 'tiktok',
requiredScopes: ['user.info.basic'],
},
params: {
fields: {
type: 'string',
required: false,
visibility: 'user-or-llm',
default:
'open_id,union_id,avatar_url,avatar_url_100,avatar_large_url,display_name,bio_description,profile_deep_link,is_verified,username,follower_count,following_count,likes_count,video_count',
description:
'Comma-separated list of fields to return. Available: open_id, union_id, avatar_url, avatar_url_100, avatar_large_url, display_name, bio_description, profile_deep_link, is_verified, username, follower_count, following_count, likes_count, video_count',
},
},
request: {
url: (params: TikTokGetUserParams) => {
const fields =
params.fields ||
'open_id,union_id,avatar_url,avatar_url_100,avatar_large_url,display_name,bio_description,profile_deep_link,is_verified,username,follower_count,following_count,likes_count,video_count'
return `https://open.tiktokapis.com/v2/user/info/?fields=${encodeURIComponent(fields)}`
},
method: 'GET',
headers: (params: TikTokGetUserParams) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response): Promise<TikTokGetUserResponse> => {
const data = await response.json()
if (data.error?.code !== 'ok' && data.error?.code) {
return {
success: false,
output: {
openId: '',
unionId: null,
displayName: '',
avatarUrl: null,
avatarUrl100: null,
avatarLargeUrl: null,
bioDescription: null,
profileDeepLink: null,
isVerified: null,
username: null,
followerCount: null,
followingCount: null,
likesCount: null,
videoCount: null,
},
error: data.error?.message || 'Failed to fetch user info',
}
}
const user = data.data?.user
if (!user) {
return {
success: false,
output: {
openId: '',
unionId: null,
displayName: '',
avatarUrl: null,
avatarUrl100: null,
avatarLargeUrl: null,
bioDescription: null,
profileDeepLink: null,
isVerified: null,
username: null,
followerCount: null,
followingCount: null,
likesCount: null,
videoCount: null,
},
error: 'No user data returned',
}
}
return {
success: true,
output: {
openId: user.open_id ?? '',
unionId: user.union_id ?? null,
displayName: user.display_name ?? '',
avatarUrl: user.avatar_url ?? null,
avatarUrl100: user.avatar_url_100 ?? null,
avatarLargeUrl: user.avatar_large_url ?? null,
bioDescription: user.bio_description ?? null,
profileDeepLink: user.profile_deep_link ?? null,
isVerified: user.is_verified ?? null,
username: user.username ?? null,
followerCount: user.follower_count ?? null,
followingCount: user.following_count ?? null,
likesCount: user.likes_count ?? null,
videoCount: user.video_count ?? null,
},
}
},
outputs: {
openId: {
type: 'string',
description: 'Unique TikTok user ID for this application',
},
unionId: {
type: 'string',
description: 'Unique TikTok user ID across all apps from the same developer',
optional: true,
},
displayName: {
type: 'string',
description: 'User display name',
},
avatarUrl: {
type: 'string',
description: 'Profile image URL',
optional: true,
},
avatarUrl100: {
type: 'string',
description: 'Profile image URL (100x100)',
optional: true,
},
avatarLargeUrl: {
type: 'string',
description: 'Profile image URL (large)',
optional: true,
},
bioDescription: {
type: 'string',
description: 'User bio description',
optional: true,
},
profileDeepLink: {
type: 'string',
description: 'Deep link to user TikTok profile',
optional: true,
},
isVerified: {
type: 'boolean',
description: 'Whether the account is verified',
optional: true,
},
username: {
type: 'string',
description: 'TikTok username',
optional: true,
},
followerCount: {
type: 'number',
description: 'Number of followers',
optional: true,
},
followingCount: {
type: 'number',
description: 'Number of accounts the user follows',
optional: true,
},
likesCount: {
type: 'number',
description: 'Total likes received across all videos',
optional: true,
},
videoCount: {
type: 'number',
description: 'Total number of public videos',
optional: true,
},
},
}

View File

@@ -0,0 +1,13 @@
import { tiktokDirectPostVideoTool } from '@/tools/tiktok/direct_post_video'
import { tiktokGetPostStatusTool } from '@/tools/tiktok/get_post_status'
import { tiktokGetUserTool } from '@/tools/tiktok/get_user'
import { tiktokListVideosTool } from '@/tools/tiktok/list_videos'
import { tiktokQueryCreatorInfoTool } from '@/tools/tiktok/query_creator_info'
import { tiktokQueryVideosTool } from '@/tools/tiktok/query_videos'
export { tiktokGetUserTool }
export { tiktokListVideosTool }
export { tiktokQueryVideosTool }
export { tiktokQueryCreatorInfoTool }
export { tiktokDirectPostVideoTool }
export { tiktokGetPostStatusTool }

View File

@@ -0,0 +1,133 @@
import type {
TikTokListVideosParams,
TikTokListVideosResponse,
TikTokVideo,
} from '@/tools/tiktok/types'
import type { ToolConfig } from '@/tools/types'
export const tiktokListVideosTool: ToolConfig<TikTokListVideosParams, TikTokListVideosResponse> = {
id: 'tiktok_list_videos',
name: 'TikTok List Videos',
description:
"Get a list of the authenticated user's TikTok videos with cover images, titles, and metadata. Supports pagination.",
version: '1.0.0',
oauth: {
required: true,
provider: 'tiktok',
requiredScopes: ['video.list'],
},
params: {
maxCount: {
type: 'number',
required: false,
visibility: 'user-or-llm',
default: 20,
description: 'Maximum number of videos to return (1-20)',
},
cursor: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Cursor for pagination (from previous response)',
},
},
request: {
url: () =>
'https://open.tiktokapis.com/v2/video/list/?fields=id,title,cover_image_url,embed_link,duration,create_time,share_url,video_description,width,height',
method: 'POST',
headers: (params: TikTokListVideosParams) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
body: (params: TikTokListVideosParams) => ({
max_count: params.maxCount || 20,
...(params.cursor !== undefined && { cursor: params.cursor }),
}),
},
transformResponse: async (response: Response): Promise<TikTokListVideosResponse> => {
const data = await response.json()
if (data.error?.code !== 'ok' && data.error?.code) {
return {
success: false,
output: {
videos: [],
cursor: null,
hasMore: false,
},
error: data.error?.message || 'Failed to fetch videos',
}
}
const videos: TikTokVideo[] = (data.data?.videos ?? []).map(
(video: Record<string, unknown>) => ({
id: video.id ?? '',
title: video.title ?? null,
coverImageUrl: video.cover_image_url ?? null,
embedLink: video.embed_link ?? null,
duration: video.duration ?? null,
createTime: video.create_time ?? null,
shareUrl: video.share_url ?? null,
videoDescription: video.video_description ?? null,
width: video.width ?? null,
height: video.height ?? null,
})
)
return {
success: true,
output: {
videos,
cursor: data.data?.cursor ?? null,
hasMore: data.data?.has_more ?? false,
},
}
},
outputs: {
videos: {
type: 'array',
description: 'List of TikTok videos',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Video ID' },
title: { type: 'string', description: 'Video title', optional: true },
coverImageUrl: {
type: 'string',
description: 'Cover image URL (may expire)',
optional: true,
},
embedLink: { type: 'string', description: 'Embeddable video URL', optional: true },
duration: { type: 'number', description: 'Video duration in seconds', optional: true },
createTime: {
type: 'number',
description: 'Unix timestamp when video was created',
optional: true,
},
shareUrl: { type: 'string', description: 'Shareable video URL', optional: true },
videoDescription: {
type: 'string',
description: 'Video description/caption',
optional: true,
},
width: { type: 'number', description: 'Video width in pixels', optional: true },
height: { type: 'number', description: 'Video height in pixels', optional: true },
},
},
},
cursor: {
type: 'number',
description: 'Cursor for fetching the next page of results',
optional: true,
},
hasMore: {
type: 'boolean',
description: 'Whether there are more videos to fetch',
},
},
}

View File

@@ -0,0 +1,127 @@
import type {
TikTokQueryCreatorInfoParams,
TikTokQueryCreatorInfoResponse,
} from '@/tools/tiktok/types'
import type { ToolConfig } from '@/tools/types'
export const tiktokQueryCreatorInfoTool: ToolConfig<
TikTokQueryCreatorInfoParams,
TikTokQueryCreatorInfoResponse
> = {
id: 'tiktok_query_creator_info',
name: 'TikTok Query Creator Info',
description:
'Check if the authenticated TikTok user can post content and retrieve their available privacy options, interaction settings, and maximum video duration.',
version: '1.0.0',
oauth: {
required: true,
provider: 'tiktok',
requiredScopes: ['video.publish'],
},
params: {},
request: {
url: () => 'https://open.tiktokapis.com/v2/post/publish/creator_info/query/',
method: 'POST',
headers: (params: TikTokQueryCreatorInfoParams) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response): Promise<TikTokQueryCreatorInfoResponse> => {
const data = await response.json()
if (data.error?.code !== 'ok' && data.error?.code) {
return {
success: false,
output: {
creatorAvatarUrl: null,
creatorUsername: null,
creatorNickname: null,
privacyLevelOptions: [],
commentDisabled: false,
duetDisabled: false,
stitchDisabled: false,
maxVideoPostDurationSec: null,
},
error: data.error?.message || 'Failed to query creator info',
}
}
const creatorInfo = data.data
if (!creatorInfo) {
return {
success: false,
output: {
creatorAvatarUrl: null,
creatorUsername: null,
creatorNickname: null,
privacyLevelOptions: [],
commentDisabled: false,
duetDisabled: false,
stitchDisabled: false,
maxVideoPostDurationSec: null,
},
error: 'No creator info returned',
}
}
return {
success: true,
output: {
creatorAvatarUrl: creatorInfo.creator_avatar_url ?? null,
creatorUsername: creatorInfo.creator_username ?? null,
creatorNickname: creatorInfo.creator_nickname ?? null,
privacyLevelOptions: creatorInfo.privacy_level_options ?? [],
commentDisabled: creatorInfo.comment_disabled ?? false,
duetDisabled: creatorInfo.duet_disabled ?? false,
stitchDisabled: creatorInfo.stitch_disabled ?? false,
maxVideoPostDurationSec: creatorInfo.max_video_post_duration_sec ?? null,
},
}
},
outputs: {
creatorAvatarUrl: {
type: 'string',
description: 'URL of the creator avatar',
optional: true,
},
creatorUsername: {
type: 'string',
description: 'TikTok username of the creator',
optional: true,
},
creatorNickname: {
type: 'string',
description: 'Display name/nickname of the creator',
optional: true,
},
privacyLevelOptions: {
type: 'array',
description:
'Available privacy levels for posting (e.g., PUBLIC_TO_EVERYONE, MUTUAL_FOLLOW_FRIENDS, FOLLOWER_OF_CREATOR, SELF_ONLY)',
},
commentDisabled: {
type: 'boolean',
description: 'Whether the creator has disabled comments by default',
},
duetDisabled: {
type: 'boolean',
description: 'Whether the creator has disabled duets by default',
},
stitchDisabled: {
type: 'boolean',
description: 'Whether the creator has disabled stitches by default',
},
maxVideoPostDurationSec: {
type: 'number',
description: 'Maximum allowed video duration in seconds',
optional: true,
},
},
}

View File

@@ -0,0 +1,119 @@
import type {
TikTokQueryVideosParams,
TikTokQueryVideosResponse,
TikTokVideo,
} from '@/tools/tiktok/types'
import type { ToolConfig } from '@/tools/types'
export const tiktokQueryVideosTool: ToolConfig<TikTokQueryVideosParams, TikTokQueryVideosResponse> =
{
id: 'tiktok_query_videos',
name: 'TikTok Query Videos',
description:
'Query specific TikTok videos by their IDs to get fresh metadata including cover images, embed links, and video details.',
version: '1.0.0',
oauth: {
required: true,
provider: 'tiktok',
requiredScopes: ['video.list'],
},
params: {
videoIds: {
type: 'array',
required: true,
visibility: 'user-or-llm',
description: 'Array of video IDs to query (maximum 20)',
items: {
type: 'string',
description: 'TikTok video ID',
},
},
},
request: {
url: () =>
'https://open.tiktokapis.com/v2/video/query/?fields=id,title,cover_image_url,embed_link,duration,create_time,share_url,video_description,width,height',
method: 'POST',
headers: (params: TikTokQueryVideosParams) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
body: (params: TikTokQueryVideosParams) => ({
filters: {
video_ids: params.videoIds,
},
}),
},
transformResponse: async (response: Response): Promise<TikTokQueryVideosResponse> => {
const data = await response.json()
if (data.error?.code !== 'ok' && data.error?.code) {
return {
success: false,
output: {
videos: [],
},
error: data.error?.message || 'Failed to query videos',
}
}
const videos: TikTokVideo[] = (data.data?.videos ?? []).map(
(video: Record<string, unknown>) => ({
id: video.id ?? '',
title: video.title ?? null,
coverImageUrl: video.cover_image_url ?? null,
embedLink: video.embed_link ?? null,
duration: video.duration ?? null,
createTime: video.create_time ?? null,
shareUrl: video.share_url ?? null,
videoDescription: video.video_description ?? null,
width: video.width ?? null,
height: video.height ?? null,
})
)
return {
success: true,
output: {
videos,
},
}
},
outputs: {
videos: {
type: 'array',
description: 'List of queried TikTok videos',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Video ID' },
title: { type: 'string', description: 'Video title', optional: true },
coverImageUrl: {
type: 'string',
description: 'Cover image URL (fresh URL)',
optional: true,
},
embedLink: { type: 'string', description: 'Embeddable video URL', optional: true },
duration: { type: 'number', description: 'Video duration in seconds', optional: true },
createTime: {
type: 'number',
description: 'Unix timestamp when video was created',
optional: true,
},
shareUrl: { type: 'string', description: 'Shareable video URL', optional: true },
videoDescription: {
type: 'string',
description: 'Video description/caption',
optional: true,
},
width: { type: 'number', description: 'Video width in pixels', optional: true },
height: { type: 'number', description: 'Video height in pixels', optional: true },
},
},
},
},
}

View File

@@ -0,0 +1,140 @@
import type { ToolResponse } from '@/tools/types'
/**
* Base params that include OAuth access token
*/
export interface TikTokBaseParams {
accessToken: string
}
/**
* Get User Info
*/
export interface TikTokGetUserParams extends TikTokBaseParams {
fields?: string
}
export interface TikTokGetUserResponse extends ToolResponse {
output: {
openId: string
unionId: string | null
displayName: string
avatarUrl: string | null
avatarUrl100: string | null
avatarLargeUrl: string | null
bioDescription: string | null
profileDeepLink: string | null
isVerified: boolean | null
username: string | null
followerCount: number | null
followingCount: number | null
likesCount: number | null
videoCount: number | null
}
}
/**
* List Videos
*/
export interface TikTokListVideosParams extends TikTokBaseParams {
maxCount?: number
cursor?: number
}
export interface TikTokVideo {
id: string
title: string | null
coverImageUrl: string | null
embedLink: string | null
duration: number | null
createTime: number | null
shareUrl: string | null
videoDescription: string | null
width: number | null
height: number | null
}
export interface TikTokListVideosResponse extends ToolResponse {
output: {
videos: TikTokVideo[]
cursor: number | null
hasMore: boolean
}
}
/**
* Query Videos
*/
export interface TikTokQueryVideosParams extends TikTokBaseParams {
videoIds: string[]
}
export interface TikTokQueryVideosResponse extends ToolResponse {
output: {
videos: TikTokVideo[]
}
}
/**
* Query Creator Info - Check posting permissions and get privacy options
*/
export interface TikTokQueryCreatorInfoParams extends TikTokBaseParams {}
export interface TikTokQueryCreatorInfoResponse extends ToolResponse {
output: {
creatorAvatarUrl: string | null
creatorUsername: string | null
creatorNickname: string | null
privacyLevelOptions: string[]
commentDisabled: boolean
duetDisabled: boolean
stitchDisabled: boolean
maxVideoPostDurationSec: number | null
}
}
/**
* Direct Post Video - Publish video from URL to TikTok
*/
export interface TikTokDirectPostVideoParams extends TikTokBaseParams {
videoUrl: string
title?: string
privacyLevel: string
disableDuet?: boolean
disableStitch?: boolean
disableComment?: boolean
videoCoverTimestampMs?: number
isAigc?: boolean
}
export interface TikTokDirectPostVideoResponse extends ToolResponse {
output: {
publishId: string
}
}
/**
* Get Post Status - Check status of a published post
*/
export interface TikTokGetPostStatusParams extends TikTokBaseParams {
publishId: string
}
export interface TikTokGetPostStatusResponse extends ToolResponse {
output: {
status: string
failReason: string | null
publiclyAvailablePostId: string[]
}
}
/**
* Union type of all TikTok responses
*/
export type TikTokResponse =
| TikTokGetUserResponse
| TikTokListVideosResponse
| TikTokQueryVideosResponse
| TikTokQueryCreatorInfoResponse
| TikTokDirectPostVideoResponse
| TikTokGetPostStatusResponse