From 38efb9a871db8491ad3f5d95071c1d65d3d19647 Mon Sep 17 00:00:00 2001 From: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com> Date: Wed, 14 May 2025 11:01:51 -0700 Subject: [PATCH] feat(invite-workspace) (#358) * feat(invite-workspace): added invite UI and capabilites; improved tooltips; added keyboard shortcuts; adjusted clay desc * improvement: restructured error and invite pages * feat(invite-workspace): users can now join workspaces; protected delete from members * fix(deployment): login * feat(invite-workspace): members registries and variables loaded from workspace * feat(invite-workspace): ran migrations * fix: delete workflows on initial sync --- .../components/social-login-buttons.tsx | 11 +- apps/sim/app/(auth)/login/login-form.tsx | 51 +- apps/sim/app/(auth)/login/page.tsx | 3 + apps/sim/app/(auth)/signup/page.tsx | 3 + apps/sim/app/(auth)/signup/signup-form.tsx | 34 +- apps/sim/app/(auth)/verify/page.tsx | 3 + .../sim/app/(auth)/verify/use-verification.ts | 46 +- .../app/api/workflows/[id]/variables/route.ts | 62 +- apps/sim/app/api/workflows/sync/route.ts | 182 +- .../workspaces/invitations/accept/route.ts | 131 ++ .../workspaces/invitations/details/route.ts | 58 + .../app/api/workspaces/invitations/route.ts | 224 ++ apps/sim/app/invite/[id]/invite.tsx | 303 +++ apps/sim/app/invite/[id]/page.tsx | 251 +- .../app/invite/invite-error/invite-error.tsx | 73 + apps/sim/app/invite/invite-error/page.tsx | 8 + .../components/control-bar/control-bar.tsx | 15 +- apps/sim/app/w/[id]/workflow.tsx | 1 - .../components/invite-modal/invite-modal.tsx | 361 +++ .../invites-sent/invites-sent.tsx | 121 + .../components/nav-section/nav-section.tsx | 33 +- .../workspace-header/workspace-header.tsx | 115 +- apps/sim/app/w/components/sidebar/sidebar.tsx | 85 +- .../hooks/use-keyboard-shortcuts.ts | 35 + apps/sim/blocks/blocks/clay.ts | 2 +- .../components/emails/invitation-email.tsx | 18 +- .../emails/workspace-invitation.tsx | 108 + apps/sim/components/ui/tooltip.tsx | 19 +- .../sim/db/migrations/0035_slim_energizer.sql | 16 + .../sim/db/migrations/meta/0035_snapshot.json | 2071 +++++++++++++++++ apps/sim/db/migrations/meta/_journal.json | 9 +- apps/sim/db/schema.ts | 17 + apps/sim/drizzle.config.ts | 2 +- apps/sim/middleware.ts | 51 +- apps/sim/stores/custom-tools/store.ts | 12 - apps/sim/stores/index.ts | 51 +- apps/sim/stores/panel/variables/store.ts | 27 + apps/sim/stores/sync-core.ts | 150 +- apps/sim/stores/sync-registry.ts | 26 +- apps/sim/stores/workflows/index.ts | 36 +- apps/sim/stores/workflows/registry/store.ts | 96 +- apps/sim/stores/workflows/sync.ts | 139 +- apps/sim/stores/workflows/workflow/store.ts | 85 +- apps/sim/stores/workflows/workflow/types.ts | 13 + 44 files changed, 4682 insertions(+), 475 deletions(-) create mode 100644 apps/sim/app/api/workspaces/invitations/accept/route.ts create mode 100644 apps/sim/app/api/workspaces/invitations/details/route.ts create mode 100644 apps/sim/app/api/workspaces/invitations/route.ts create mode 100644 apps/sim/app/invite/[id]/invite.tsx create mode 100644 apps/sim/app/invite/invite-error/invite-error.tsx create mode 100644 apps/sim/app/invite/invite-error/page.tsx create mode 100644 apps/sim/app/w/components/sidebar/components/invite-modal/invite-modal.tsx create mode 100644 apps/sim/app/w/components/sidebar/components/invite-modal/invites-sent/invites-sent.tsx rename apps/sim/app/w/{[id] => }/hooks/use-keyboard-shortcuts.ts (67%) create mode 100644 apps/sim/components/emails/workspace-invitation.tsx create mode 100644 apps/sim/db/migrations/0035_slim_energizer.sql create mode 100644 apps/sim/db/migrations/meta/0035_snapshot.json diff --git a/apps/sim/app/(auth)/components/social-login-buttons.tsx b/apps/sim/app/(auth)/components/social-login-buttons.tsx index 50ec7c066..bfa4b101d 100644 --- a/apps/sim/app/(auth)/components/social-login-buttons.tsx +++ b/apps/sim/app/(auth)/components/social-login-buttons.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { GithubIcon, GoogleIcon } from '@/components/icons' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' @@ -23,6 +23,15 @@ export function SocialLoginButtons({ const [isGithubLoading, setIsGithubLoading] = useState(false) const [isGoogleLoading, setIsGoogleLoading] = useState(false) const { addNotification } = useNotificationStore() + const [mounted, setMounted] = useState(false) + + // Set mounted state to true on client-side + useEffect(() => { + setMounted(true) + }, []) + + // Only render on the client side to avoid hydration errors + if (!mounted) return null async function signInWithGithub() { if (!githubAvailable) return diff --git a/apps/sim/app/(auth)/login/login-form.tsx b/apps/sim/app/(auth)/login/login-form.tsx index 53d77fbf2..8a4dc62ee 100644 --- a/apps/sim/app/(auth)/login/login-form.tsx +++ b/apps/sim/app/(auth)/login/login-form.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react' import Link from 'next/link' -import { useRouter } from 'next/navigation' +import { useRouter, useSearchParams } from 'next/navigation' import { Eye, EyeOff } from 'lucide-react' import { Button } from '@/components/ui/button' import { @@ -35,12 +35,17 @@ export default function LoginPage({ isProduction: boolean }) { const router = useRouter() + const searchParams = useSearchParams() const [isLoading, setIsLoading] = useState(false) - const [, setMounted] = useState(false) + const [mounted, setMounted] = useState(false) const { addNotification } = useNotificationStore() const [showPassword, setShowPassword] = useState(false) const [password, setPassword] = useState('') + // Initialize state for URL parameters + const [callbackUrl, setCallbackUrl] = useState('/w') + const [isInviteFlow, setIsInviteFlow] = useState(false) + // Forgot password states const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false) const [forgotPasswordEmail, setForgotPasswordEmail] = useState('') @@ -50,9 +55,21 @@ export default function LoginPage({ message: string }>({ type: null, message: '' }) + // Extract URL parameters after component mounts to avoid SSR issues useEffect(() => { setMounted(true) - }, []) + + // Only access search params on the client side + if (searchParams) { + const callback = searchParams.get('callbackUrl') + if (callback) { + setCallbackUrl(callback) + } + + const inviteFlow = searchParams.get('invite_flow') === 'true' + setIsInviteFlow(inviteFlow) + } + }, [searchParams]) async function onSubmit(e: React.FormEvent) { e.preventDefault() @@ -62,11 +79,12 @@ export default function LoginPage({ const email = formData.get('email') as string try { + // Use the extracted callbackUrl instead of hardcoded value const result = await client.signIn.email( { email, password, - callbackURL: '/w', + callbackURL: callbackUrl, }, { onError: (ctx) => { @@ -214,16 +232,22 @@ export default function LoginPage({ Welcome back - Enter your credentials to access your account + + {isInviteFlow + ? 'Sign in to continue to the invitation' + : 'Enter your credentials to access your account'} +
- + {mounted && ( + + )}
@@ -289,7 +313,10 @@ export default function LoginPage({

Don't have an account?{' '} - + Sign up

diff --git a/apps/sim/app/(auth)/login/page.tsx b/apps/sim/app/(auth)/login/page.tsx index 8933a6985..5d4ce8f7d 100644 --- a/apps/sim/app/(auth)/login/page.tsx +++ b/apps/sim/app/(auth)/login/page.tsx @@ -1,6 +1,9 @@ import { getOAuthProviderStatus } from '../components/oauth-provider-checker' import LoginForm from './login-form' +// Force dynamic rendering to avoid prerender errors with search params +export const dynamic = 'force-dynamic' + export default async function LoginPage() { const { githubAvailable, googleAvailable, isProduction } = await getOAuthProviderStatus() diff --git a/apps/sim/app/(auth)/signup/page.tsx b/apps/sim/app/(auth)/signup/page.tsx index cbd46bcac..c469074e3 100644 --- a/apps/sim/app/(auth)/signup/page.tsx +++ b/apps/sim/app/(auth)/signup/page.tsx @@ -1,6 +1,9 @@ import { getOAuthProviderStatus } from '../components/oauth-provider-checker' import SignupForm from './signup-form' +// Force dynamic rendering to avoid prerender errors with search params +export const dynamic = 'force-dynamic' + export default async function SignupPage() { const { githubAvailable, googleAvailable, isProduction } = await getOAuthProviderStatus() diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index 0636353d9..6642665c7 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -57,6 +57,8 @@ function SignupFormContent({ const [showValidationError, setShowValidationError] = useState(false) const [email, setEmail] = useState('') const [waitlistToken, setWaitlistToken] = useState('') + const [redirectUrl, setRedirectUrl] = useState('') + const [isInviteFlow, setIsInviteFlow] = useState(false) useEffect(() => { setMounted(true) @@ -72,6 +74,23 @@ function SignupFormContent({ // Verify the token and get the email verifyWaitlistToken(tokenParam) } + + // Handle redirection for invitation flow + const redirectParam = searchParams.get('redirect') + if (redirectParam) { + setRedirectUrl(redirectParam) + + // Check if this is part of an invitation flow + if (redirectParam.startsWith('/invite/')) { + setIsInviteFlow(true) + } + } + + // Explicitly check for invite_flow parameter + const inviteFlowParam = searchParams.get('invite_flow') + if (inviteFlowParam === 'true') { + setIsInviteFlow(true) + } }, [searchParams]) // Verify waitlist token and pre-fill email @@ -207,9 +226,22 @@ function SignupFormContent({ if (typeof window !== 'undefined') { sessionStorage.setItem('verificationEmail', emailValue) + + // If this is an invitation flow, store that information for after verification + if (isInviteFlow && redirectUrl) { + sessionStorage.setItem('inviteRedirectUrl', redirectUrl) + sessionStorage.setItem('isInviteFlow', 'true') + } } - router.push(`/verify?fromSignup=true`) + // If verification is required, go to verify page with proper redirect + if (isInviteFlow && redirectUrl) { + router.push( + `/verify?fromSignup=true&redirectAfter=${encodeURIComponent(redirectUrl)}&invite_flow=true` + ) + } else { + router.push(`/verify?fromSignup=true`) + } } catch (err: any) { console.error('Uncaught signup error:', err) } finally { diff --git a/apps/sim/app/(auth)/verify/page.tsx b/apps/sim/app/(auth)/verify/page.tsx index 22fa650ba..370fde30e 100644 --- a/apps/sim/app/(auth)/verify/page.tsx +++ b/apps/sim/app/(auth)/verify/page.tsx @@ -2,6 +2,9 @@ import { isProd } from '@/lib/environment' import { getBaseUrl } from '@/lib/urls/utils' import { VerifyContent } from './verify-content' +// Force dynamic rendering to avoid prerender errors with search params +export const dynamic = 'force-dynamic' + export default function VerifyPage() { const baseUrl = getBaseUrl() diff --git a/apps/sim/app/(auth)/verify/use-verification.ts b/apps/sim/app/(auth)/verify/use-verification.ts index 7637cc848..f3fddecd1 100644 --- a/apps/sim/app/(auth)/verify/use-verification.ts +++ b/apps/sim/app/(auth)/verify/use-verification.ts @@ -42,6 +42,8 @@ export function useVerification({ const [isSendingInitialOtp, setIsSendingInitialOtp] = useState(false) const [isInvalidOtp, setIsInvalidOtp] = useState(false) const [errorMessage, setErrorMessage] = useState('') + const [redirectUrl, setRedirectUrl] = useState(null) + const [isInviteFlow, setIsInviteFlow] = useState(false) // Debug notification store useEffect(() => { @@ -50,11 +52,35 @@ export function useVerification({ useEffect(() => { if (typeof window !== 'undefined') { + // Get stored email const storedEmail = sessionStorage.getItem('verificationEmail') if (storedEmail) { setEmail(storedEmail) - return } + + // Check for redirect information + const storedRedirectUrl = sessionStorage.getItem('inviteRedirectUrl') + if (storedRedirectUrl) { + setRedirectUrl(storedRedirectUrl) + } + + // Check if this is an invite flow + const storedIsInviteFlow = sessionStorage.getItem('isInviteFlow') + if (storedIsInviteFlow === 'true') { + setIsInviteFlow(true) + } + } + + // Also check URL parameters for redirect information + const redirectParam = searchParams.get('redirectAfter') + if (redirectParam) { + setRedirectUrl(redirectParam) + } + + // Check for invite_flow parameter + const inviteFlowParam = searchParams.get('invite_flow') + if (inviteFlowParam === 'true') { + setIsInviteFlow(true) } }, [searchParams]) @@ -104,10 +130,24 @@ export function useVerification({ // Clear email from sessionStorage after successful verification if (typeof window !== 'undefined') { sessionStorage.removeItem('verificationEmail') + + // Also clear invite-related items + if (isInviteFlow) { + sessionStorage.removeItem('inviteRedirectUrl') + sessionStorage.removeItem('isInviteFlow') + } } - // Redirect to dashboard after a short delay - setTimeout(() => router.push('/w'), 2000) + // Redirect to proper page after a short delay + setTimeout(() => { + if (isInviteFlow && redirectUrl) { + // For invitation flow, redirect to the invitation page + router.push(redirectUrl) + } else { + // Default redirect to dashboard + router.push('/w') + } + }, 2000) } else { logger.info('Setting invalid OTP state - API error response') const message = 'Invalid verification code. Please check and try again.' diff --git a/apps/sim/app/api/workflows/[id]/variables/route.ts b/apps/sim/app/api/workflows/[id]/variables/route.ts index 9143b011a..df1934c50 100644 --- a/apps/sim/app/api/workflows/[id]/variables/route.ts +++ b/apps/sim/app/api/workflows/[id]/variables/route.ts @@ -1,11 +1,11 @@ import { NextRequest, NextResponse } from 'next/server' -import { eq } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import { z } from 'zod' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console-logger' import { Variable } from '@/stores/panel/variables/types' import { db } from '@/db' -import { workflow } from '@/db/schema' +import { workflow, workspaceMember } from '@/db/schema' const logger = createLogger('WorkflowVariablesAPI') @@ -33,7 +33,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - // Check if the workflow belongs to the user + // Get the workflow record const workflowRecord = await db .select() .from(workflow) @@ -45,9 +45,31 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) } - if (workflowRecord[0].userId !== session.user.id) { + const workflowData = workflowRecord[0] + const workspaceId = workflowData.workspaceId + + // Check authorization - either the user owns the workflow or is a member of the workspace + let isAuthorized = workflowData.userId === session.user.id + + // If not authorized by ownership and the workflow belongs to a workspace, check workspace membership + if (!isAuthorized && workspaceId) { + const membership = await db + .select() + .from(workspaceMember) + .where( + and( + eq(workspaceMember.workspaceId, workspaceId), + eq(workspaceMember.userId, session.user.id) + ) + ) + .limit(1) + + isAuthorized = membership.length > 0 + } + + if (!isAuthorized) { logger.warn( - `[${requestId}] User ${session.user.id} attempted to update variables for workflow ${workflowId} owned by ${workflowRecord[0].userId}` + `[${requestId}] User ${session.user.id} attempted to update variables for workflow ${workflowId} without permission` ) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -115,7 +137,7 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id: return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - // Check if the workflow belongs to the user + // Get the workflow record const workflowRecord = await db .select() .from(workflow) @@ -127,15 +149,37 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id: return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) } - if (workflowRecord[0].userId !== session.user.id) { + const workflowData = workflowRecord[0] + const workspaceId = workflowData.workspaceId + + // Check authorization - either the user owns the workflow or is a member of the workspace + let isAuthorized = workflowData.userId === session.user.id + + // If not authorized by ownership and the workflow belongs to a workspace, check workspace membership + if (!isAuthorized && workspaceId) { + const membership = await db + .select() + .from(workspaceMember) + .where( + and( + eq(workspaceMember.workspaceId, workspaceId), + eq(workspaceMember.userId, session.user.id) + ) + ) + .limit(1) + + isAuthorized = membership.length > 0 + } + + if (!isAuthorized) { logger.warn( - `[${requestId}] User ${session.user.id} attempted to access variables for workflow ${workflowId} owned by ${workflowRecord[0].userId}` + `[${requestId}] User ${session.user.id} attempted to access variables for workflow ${workflowId} without permission` ) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } // Return variables if they exist - const variables = (workflowRecord[0].variables as Record) || {} + const variables = (workflowData.variables as Record) || {} // Add cache headers to prevent frequent reloading const headers = new Headers({ diff --git a/apps/sim/app/api/workflows/sync/route.ts b/apps/sim/app/api/workflows/sync/route.ts index 7d48c1a7a..2a6df10e7 100644 --- a/apps/sim/app/api/workflows/sync/route.ts +++ b/apps/sim/app/api/workflows/sync/route.ts @@ -4,7 +4,7 @@ import { z } from 'zod' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console-logger' import { db } from '@/db' -import { workflow, workspace } from '@/db/schema' +import { workflow, workspace, workspaceMember } from '@/db/schema' const logger = createLogger('WorkflowAPI') @@ -47,8 +47,57 @@ const SyncPayloadSchema = z.object({ workspaceId: z.string().optional(), }) +// Cache for workspace membership to reduce DB queries +const workspaceMembershipCache = new Map(); +const CACHE_TTL = 60000; // 1 minute cache expiration + +/** + * Efficiently verifies user's membership and role in a workspace with caching + * @param userId User ID to check + * @param workspaceId Workspace ID to check + * @returns Role if user is a member, null otherwise + */ +async function verifyWorkspaceMembership(userId: string, workspaceId: string): Promise { + // Create cache key from userId and workspaceId + const cacheKey = `${userId}:${workspaceId}`; + + // Check cache first + const cached = workspaceMembershipCache.get(cacheKey); + if (cached && cached.expires > Date.now()) { + return cached.role; + } + + // If not in cache or expired, query the database + try { + const membership = await db + .select({ role: workspaceMember.role }) + .from(workspaceMember) + .where(and( + eq(workspaceMember.workspaceId, workspaceId), + eq(workspaceMember.userId, userId) + )) + .then((rows) => rows[0]); + + if (!membership) { + return null; + } + + // Cache the result + workspaceMembershipCache.set(cacheKey, { + role: membership.role, + expires: Date.now() + CACHE_TTL + }); + + return membership.role; + } catch (error) { + logger.error(`Error verifying workspace membership for ${userId} in ${workspaceId}:`, error); + return null; + } +} + export async function GET(request: Request) { const requestId = crypto.randomUUID().slice(0, 8) + const startTime = Date.now() const url = new URL(request.url) const workspaceId = url.searchParams.get('workspaceId') @@ -62,8 +111,9 @@ export async function GET(request: Request) { const userId = session.user.id - // If workspaceId is provided, verify it exists first + // If workspaceId is provided, verify it exists and user is a member if (workspaceId) { + // Check workspace exists first const workspaceExists = await db .select({ id: workspace.id }) .from(workspace) @@ -80,28 +130,48 @@ export async function GET(request: Request) { ) } - // Migrate any orphaned workflows to this workspace - await migrateOrphanedWorkflows(userId, workspaceId) + // Verify the user is a member of the workspace using our optimized function + const userRole = await verifyWorkspaceMembership(userId, workspaceId) + + if (!userRole) { + logger.warn( + `[${requestId}] User ${userId} attempted to access workspace ${workspaceId} without membership` + ) + return NextResponse.json( + { error: 'Access denied to this workspace', code: 'WORKSPACE_ACCESS_DENIED' }, + { status: 403 } + ) + } + + // Migrate any orphaned workflows to this workspace (in background) + migrateOrphanedWorkflows(userId, workspaceId).catch(error => { + logger.error(`[${requestId}] Error migrating orphaned workflows:`, error) + }) } // Fetch workflows for the user let workflows if (workspaceId) { - // Filter by user ID and workspace ID + // Filter by workspace ID only, not user ID + // This allows sharing workflows across workspace members workflows = await db .select() .from(workflow) - .where(and(eq(workflow.userId, userId), eq(workflow.workspaceId, workspaceId))) + .where(eq(workflow.workspaceId, workspaceId)) } else { // Filter by user ID only, including workflows without workspace IDs workflows = await db.select().from(workflow).where(eq(workflow.userId, userId)) } + const elapsed = Date.now() - startTime + logger.info(`[${requestId}] Workflow fetch completed in ${elapsed}ms for ${workflows.length} workflows`) + // Return the workflows return NextResponse.json({ data: workflows }, { status: 200 }) } catch (error: any) { - logger.error(`[${requestId}] Workflow fetch error`, error) + const elapsed = Date.now() - startTime + logger.error(`[${requestId}] Workflow fetch error after ${elapsed}ms`, error) return NextResponse.json({ error: error.message }, { status: 500 }) } } @@ -123,15 +193,35 @@ async function migrateOrphanedWorkflows(userId: string, workspaceId: string) { `Migrating ${orphanedWorkflows.length} orphaned workflows to workspace ${workspaceId}` ) - // Update each workflow to associate it with the provided workspace - for (const { id } of orphanedWorkflows) { + // Update workflows in batch if possible + try { + // Batch update all orphaned workflows await db .update(workflow) .set({ workspaceId: workspaceId, updatedAt: new Date(), }) - .where(eq(workflow.id, id)) + .where(and(eq(workflow.userId, userId), isNull(workflow.workspaceId))) + + logger.info(`Successfully migrated ${orphanedWorkflows.length} workflows to workspace ${workspaceId}`) + } catch (batchError) { + logger.warn('Batch migration failed, falling back to individual updates:', batchError) + + // Fallback to individual updates if batch update fails + for (const { id } of orphanedWorkflows) { + try { + await db + .update(workflow) + .set({ + workspaceId: workspaceId, + updatedAt: new Date(), + }) + .where(eq(workflow.id, id)) + } catch (updateError) { + logger.error(`Failed to migrate workflow ${id}:`, updateError) + } + } } } catch (error) { logger.error('Error migrating orphaned workflows:', error) @@ -141,6 +231,7 @@ async function migrateOrphanedWorkflows(userId: string, workspaceId: string) { export async function POST(req: NextRequest) { const requestId = crypto.randomUUID().slice(0, 8) + const startTime = Date.now() try { const session = await getSession() @@ -161,20 +252,22 @@ export async function POST(req: NextRequest) { if (workspaceId) { existingWorkflows = await db - .select() + .select({ id: workflow.id }) .from(workflow) - .where(and(eq(workflow.userId, session.user.id), eq(workflow.workspaceId, workspaceId))) + .where(eq(workflow.workspaceId, workspaceId)) + .limit(1) } else { existingWorkflows = await db - .select() + .select({ id: workflow.id }) .from(workflow) .where(eq(workflow.userId, session.user.id)) + .limit(1) } // If user has existing workflows, but client sends empty, reject the sync if (existingWorkflows.length > 0) { logger.warn( - `[${requestId}] Prevented data loss: Client attempted to sync empty workflows while DB has ${existingWorkflows.length} workflows in workspace ${workspaceId || 'default'}` + `[${requestId}] Prevented data loss: Client attempted to sync empty workflows while DB has workflows in workspace ${workspaceId || 'default'}` ) return NextResponse.json( { @@ -186,7 +279,9 @@ export async function POST(req: NextRequest) { } } - // Validate that the workspace exists if one is specified + // Validate workspace membership and permissions + let userRole: string | null = null; + if (workspaceId) { const workspaceExists = await db .select({ id: workspace.id }) @@ -206,9 +301,22 @@ export async function POST(req: NextRequest) { { status: 404 } ) } + + // Verify the user is a member of the workspace using our optimized function + userRole = await verifyWorkspaceMembership(session.user.id, workspaceId) + + if (!userRole) { + logger.warn( + `[${requestId}] User ${session.user.id} attempted to sync to workspace ${workspaceId} without membership` + ) + return NextResponse.json( + { error: 'Access denied to this workspace', code: 'WORKSPACE_ACCESS_DENIED' }, + { status: 403 } + ) + } } - // Get all workflows for the user from the database + // Get all workflows for the workspace from the database // If workspaceId is provided, only get workflows for that workspace let dbWorkflows @@ -216,7 +324,7 @@ export async function POST(req: NextRequest) { dbWorkflows = await db .select() .from(workflow) - .where(and(eq(workflow.userId, session.user.id), eq(workflow.workspaceId, workspaceId))) + .where(eq(workflow.workspaceId, workspaceId)) } else { dbWorkflows = await db.select().from(workflow).where(eq(workflow.userId, session.user.id)) } @@ -260,6 +368,17 @@ export async function POST(req: NextRequest) { }) ) } else { + // Check if user has permission to update this workflow + const canUpdate = dbWorkflow.userId === session.user.id || + (workspaceId && (userRole === 'owner' || userRole === 'admin' || userRole === 'member')); + + if (!canUpdate) { + logger.warn( + `[${requestId}] User ${session.user.id} attempted to update workflow ${id} without permission` + ) + continue; // Skip this workflow update and move to the next one + } + // Existing workflow - update if needed const needsUpdate = JSON.stringify(dbWorkflow.state) !== JSON.stringify(clientWorkflow.state) || @@ -291,20 +410,40 @@ export async function POST(req: NextRequest) { } // Handle deletions - workflows in DB but not in client - // Only delete workflows for the current workspace! + // Only delete workflows for the current workspace and only those the user can modify for (const dbWorkflow of dbWorkflows) { if ( !processedIds.has(dbWorkflow.id) && (!workspaceId || dbWorkflow.workspaceId === workspaceId) ) { - operations.push(db.delete(workflow).where(eq(workflow.id, dbWorkflow.id))) + // Check if the user has permission to delete this workflow + // Users can delete their own workflows, or any workflow if they're a workspace owner/admin + const canDelete = dbWorkflow.userId === session.user.id || + (workspaceId && (userRole === 'owner' || userRole === 'admin' || userRole === 'member')); + + if (canDelete) { + operations.push(db.delete(workflow).where(eq(workflow.id, dbWorkflow.id))) + } else { + logger.warn( + `[${requestId}] User ${session.user.id} attempted to delete workflow ${dbWorkflow.id} without permission` + ) + } } } // Execute all operations in parallel await Promise.all(operations) - return NextResponse.json({ success: true }) + const elapsed = Date.now() - startTime + + return NextResponse.json({ + success: true, + stats: { + elapsed, + operations: operations.length, + workflows: Object.keys(clientWorkflows).length + } + }) } catch (validationError) { if (validationError instanceof z.ZodError) { logger.warn(`[${requestId}] Invalid workflow data`, { @@ -318,7 +457,8 @@ export async function POST(req: NextRequest) { throw validationError } } catch (error) { - logger.error(`[${requestId}] Workflow sync error`, error) + const elapsed = Date.now() - startTime + logger.error(`[${requestId}] Workflow sync error after ${elapsed}ms`, error) return NextResponse.json({ error: 'Workflow sync failed' }, { status: 500 }) } } diff --git a/apps/sim/app/api/workspaces/invitations/accept/route.ts b/apps/sim/app/api/workspaces/invitations/accept/route.ts new file mode 100644 index 000000000..8f25a0d0e --- /dev/null +++ b/apps/sim/app/api/workspaces/invitations/accept/route.ts @@ -0,0 +1,131 @@ +import { and, eq } from 'drizzle-orm' +import { NextRequest, NextResponse } from 'next/server' +import { randomUUID } from 'crypto' +import { getSession } from '@/lib/auth' +import { db } from '@/db' +import { workspace, workspaceMember, workspaceInvitation, user } from '@/db/schema' + +// GET /api/workspaces/invitations/accept - Accept an invitation via token +export async function GET(req: NextRequest) { + const token = req.nextUrl.searchParams.get('token') + + if (!token) { + // Redirect to a page explaining the error + return NextResponse.redirect(new URL('/invite/invite-error?reason=missing-token', process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai')) + } + + const session = await getSession() + + if (!session?.user?.id) { + // No need to encode API URL as callback, just redirect to invite page + // The middleware will handle proper login flow and return to invite page + return NextResponse.redirect(new URL(`/invite/${token}?token=${token}`, process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai')) + } + + try { + // Find the invitation by token + const invitation = await db + .select() + .from(workspaceInvitation) + .where(eq(workspaceInvitation.token, token)) + .then(rows => rows[0]) + + if (!invitation) { + return NextResponse.redirect(new URL('/invite/invite-error?reason=invalid-token', process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai')) + } + + // Check if invitation has expired + if (new Date() > new Date(invitation.expiresAt)) { + return NextResponse.redirect(new URL('/invite/invite-error?reason=expired', process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai')) + } + + // Check if invitation is already accepted + if (invitation.status !== 'pending') { + return NextResponse.redirect(new URL('/invite/invite-error?reason=already-processed', process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai')) + } + + // Get the user's email from the session + const userEmail = session.user.email.toLowerCase() + const invitationEmail = invitation.email.toLowerCase() + + // Check if invitation email matches the logged-in user + // For new users who just signed up, we'll be more flexible by comparing domain parts + const isExactMatch = userEmail === invitationEmail + const isPartialMatch = userEmail.split('@')[1] === invitationEmail.split('@')[1] && + userEmail.split('@')[0].includes(invitationEmail.split('@')[0].substring(0, 3)) + + if (!isExactMatch && !isPartialMatch) { + // Get user info to include in the error message + const userData = await db + .select() + .from(user) + .where(eq(user.id, session.user.id)) + .then(rows => rows[0]) + + return NextResponse.redirect(new URL(`/invite/invite-error?reason=email-mismatch&details=${encodeURIComponent(`Invitation was sent to ${invitation.email}, but you're logged in as ${userData?.email || session.user.email}`)}`, process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai')) + } + + // Get the workspace details + const workspaceDetails = await db + .select() + .from(workspace) + .where(eq(workspace.id, invitation.workspaceId)) + .then(rows => rows[0]) + + if (!workspaceDetails) { + return NextResponse.redirect(new URL('/invite/invite-error?reason=workspace-not-found', process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai')) + } + + // Check if user is already a member + const existingMembership = await db + .select() + .from(workspaceMember) + .where( + and( + eq(workspaceMember.workspaceId, invitation.workspaceId), + eq(workspaceMember.userId, session.user.id) + ) + ) + .then(rows => rows[0]) + + if (existingMembership) { + // User is already a member, just mark the invitation as accepted and redirect + await db + .update(workspaceInvitation) + .set({ + status: 'accepted', + updatedAt: new Date(), + }) + .where(eq(workspaceInvitation.id, invitation.id)) + + return NextResponse.redirect(new URL(`/w/${invitation.workspaceId}`, process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai')) + } + + // Add user to workspace + await db + .insert(workspaceMember) + .values({ + id: randomUUID(), + workspaceId: invitation.workspaceId, + userId: session.user.id, + role: invitation.role, + joinedAt: new Date(), + updatedAt: new Date(), + }) + + // Mark invitation as accepted + await db + .update(workspaceInvitation) + .set({ + status: 'accepted', + updatedAt: new Date(), + }) + .where(eq(workspaceInvitation.id, invitation.id)) + + // Redirect to the workspace + return NextResponse.redirect(new URL(`/w/${invitation.workspaceId}`, process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai')) + } catch (error) { + console.error('Error accepting invitation:', error) + return NextResponse.redirect(new URL('/invite/invite-error?reason=server-error', process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai')) + } +} \ No newline at end of file diff --git a/apps/sim/app/api/workspaces/invitations/details/route.ts b/apps/sim/app/api/workspaces/invitations/details/route.ts new file mode 100644 index 000000000..10f52ae07 --- /dev/null +++ b/apps/sim/app/api/workspaces/invitations/details/route.ts @@ -0,0 +1,58 @@ +import { and, eq } from 'drizzle-orm' +import { NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { db } from '@/db' +import { workspace, workspaceInvitation } from '@/db/schema' + +// GET /api/workspaces/invitations/details - Get invitation details by token +export async function GET(req: NextRequest) { + const token = req.nextUrl.searchParams.get('token') + + if (!token) { + return NextResponse.json({ error: 'Token is required' }, { status: 400 }) + } + + const session = await getSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + // Find the invitation by token + const invitation = await db + .select() + .from(workspaceInvitation) + .where(eq(workspaceInvitation.token, token)) + .then(rows => rows[0]) + + if (!invitation) { + return NextResponse.json({ error: 'Invitation not found or has expired' }, { status: 404 }) + } + + // Check if invitation has expired + if (new Date() > new Date(invitation.expiresAt)) { + return NextResponse.json({ error: 'Invitation has expired' }, { status: 400 }) + } + + // Get workspace details + const workspaceDetails = await db + .select() + .from(workspace) + .where(eq(workspace.id, invitation.workspaceId)) + .then(rows => rows[0]) + + if (!workspaceDetails) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + } + + // Return the invitation with workspace name + return NextResponse.json({ + ...invitation, + workspaceName: workspaceDetails.name + }) + } catch (error) { + console.error('Error fetching workspace invitation:', error) + return NextResponse.json({ error: 'Failed to fetch invitation details' }, { status: 500 }) + } +} \ No newline at end of file diff --git a/apps/sim/app/api/workspaces/invitations/route.ts b/apps/sim/app/api/workspaces/invitations/route.ts new file mode 100644 index 000000000..63c5a0c69 --- /dev/null +++ b/apps/sim/app/api/workspaces/invitations/route.ts @@ -0,0 +1,224 @@ +import { and, eq, sql, inArray } from 'drizzle-orm' +import { NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { db } from '@/db' +import { workspace, workspaceMember, workspaceInvitation, user } from '@/db/schema' +import { randomUUID } from 'crypto' +import { Resend } from 'resend' +import { WorkspaceInvitationEmail } from '@/components/emails/workspace-invitation' +import { render } from '@react-email/render' + +// Initialize Resend for email sending +const resend = new Resend(process.env.RESEND_API_KEY) + +// GET /api/workspaces/invitations - Get all invitations for the user's workspaces +export async function GET(req: NextRequest) { + const session = await getSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + // First get all workspaces where the user is a member with owner role + const userWorkspaces = await db + .select({ id: workspace.id }) + .from(workspace) + .innerJoin( + workspaceMember, + and( + eq(workspaceMember.workspaceId, workspace.id), + eq(workspaceMember.userId, session.user.id), + eq(workspaceMember.role, 'owner') + ) + ) + + if (userWorkspaces.length === 0) { + return NextResponse.json({ invitations: [] }) + } + + // Get all workspaceIds where the user is an owner + const workspaceIds = userWorkspaces.map(w => w.id) + + // Find all invitations for those workspaces + const invitations = await db + .select() + .from(workspaceInvitation) + .where( + inArray(workspaceInvitation.workspaceId, workspaceIds) + ) + + return NextResponse.json({ invitations }) + } catch (error) { + console.error('Error fetching workspace invitations:', error) + return NextResponse.json({ error: 'Failed to fetch invitations' }, { status: 500 }) + } +} + +// POST /api/workspaces/invitations - Create a new invitation +export async function POST(req: NextRequest) { + const session = await getSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const { workspaceId, email, role = 'member' } = await req.json() + + if (!workspaceId || !email) { + return NextResponse.json({ error: 'Workspace ID and email are required' }, { status: 400 }) + } + + // Check if user is authorized to invite to this workspace (must be owner) + const membership = await db + .select() + .from(workspaceMember) + .where( + and( + eq(workspaceMember.workspaceId, workspaceId), + eq(workspaceMember.userId, session.user.id) + ) + ) + .then(rows => rows[0]) + + if (!membership || membership.role !== 'owner') { + return NextResponse.json({ error: 'You are not authorized to invite to this workspace' }, { status: 403 }) + } + + // Get the workspace details for the email + const workspaceDetails = await db + .select() + .from(workspace) + .where(eq(workspace.id, workspaceId)) + .then(rows => rows[0]) + + if (!workspaceDetails) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + } + + // Check if the user is already a member + // First find if a user with this email exists + const existingUser = await db + .select() + .from(user) + .where(eq(user.email, email)) + .then(rows => rows[0]) + + if (existingUser) { + // Check if the user is already a member of this workspace + const existingMembership = await db + .select() + .from(workspaceMember) + .where( + and( + eq(workspaceMember.workspaceId, workspaceId), + eq(workspaceMember.userId, existingUser.id) + ) + ) + .then(rows => rows[0]) + + if (existingMembership) { + return NextResponse.json({ + error: `${email} is already a member of this workspace`, + email + }, { status: 400 }) + } + } + + // Check if there's already a pending invitation + const existingInvitation = await db + .select() + .from(workspaceInvitation) + .where( + and( + eq(workspaceInvitation.workspaceId, workspaceId), + eq(workspaceInvitation.email, email), + eq(workspaceInvitation.status, 'pending') + ) + ) + .then(rows => rows[0]) + + if (existingInvitation) { + return NextResponse.json({ + error: `${email} has already been invited to this workspace`, + email + }, { status: 400 }) + } + + // Generate a unique token and set expiry date (1 week from now) + const token = randomUUID() + const expiresAt = new Date() + expiresAt.setDate(expiresAt.getDate() + 7) // 7 days expiry + + // Create the invitation + const invitation = await db + .insert(workspaceInvitation) + .values({ + id: randomUUID(), + workspaceId, + email, + inviterId: session.user.id, + role, + status: 'pending', + token, + expiresAt, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning() + .then(rows => rows[0]) + + // Send the invitation email + await sendInvitationEmail({ + to: email, + inviterName: session.user.name || session.user.email || 'A user', + workspaceName: workspaceDetails.name, + token: token, + }) + + return NextResponse.json({ success: true, invitation }) + } catch (error) { + console.error('Error creating workspace invitation:', error) + return NextResponse.json({ error: 'Failed to create invitation' }, { status: 500 }) + } +} + +// Helper function to send invitation email using the Resend API +async function sendInvitationEmail({ + to, + inviterName, + workspaceName, + token +}: { + to: string; + inviterName: string; + workspaceName: string; + token: string; +}) { + try { + const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai' + // Always use the client-side invite route with token parameter + const invitationLink = `${baseUrl}/invite/${token}?token=${token}` + + const emailHtml = await render( + WorkspaceInvitationEmail({ + workspaceName, + inviterName, + invitationLink, + }) + ) + + await resend.emails.send({ + from: process.env.RESEND_FROM_EMAIL || 'noreply@simstudio.ai', + to, + subject: `You've been invited to join "${workspaceName}" on Sim Studio`, + html: emailHtml, + }) + + console.log(`Invitation email sent to ${to}`) + } catch (error) { + console.error('Error sending invitation email:', error) + // Continue even if email fails - the invitation is still created + } +} \ No newline at end of file diff --git a/apps/sim/app/invite/[id]/invite.tsx b/apps/sim/app/invite/[id]/invite.tsx new file mode 100644 index 000000000..222234a46 --- /dev/null +++ b/apps/sim/app/invite/[id]/invite.tsx @@ -0,0 +1,303 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useParams, useRouter, useSearchParams } from 'next/navigation' +import { BotIcon, CheckCircle } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { LoadingAgent } from '@/components/ui/loading-agent' +import { client, useSession } from '@/lib/auth-client' + +export default function Invite() { + const router = useRouter() + const params = useParams() + const inviteId = params.id as string + const searchParams = useSearchParams() + const { data: session, isPending } = useSession() + const [invitationDetails, setInvitationDetails] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + const [isAccepting, setIsAccepting] = useState(false) + const [accepted, setAccepted] = useState(false) + const [isNewUser, setIsNewUser] = useState(false) + const [token, setToken] = useState(null) + const [invitationType, setInvitationType] = useState<'organization' | 'workspace'>('workspace') + + // Check if this is a new user vs. existing user and get token from query + useEffect(() => { + const isNew = searchParams.get('new') === 'true' + setIsNewUser(isNew) + + // Get token from URL or use inviteId as token + const tokenFromQuery = searchParams.get('token') + const effectiveToken = tokenFromQuery || inviteId + + if (effectiveToken) { + setToken(effectiveToken) + sessionStorage.setItem('inviteToken', effectiveToken) + } + }, [searchParams, inviteId]) + + // Auto-fetch invitation details when logged in + useEffect(() => { + if (!session?.user || !token) return + + async function fetchInvitationDetails() { + setIsLoading(true) + try { + // First try to fetch workspace invitation details + const workspaceInviteResponse = await fetch( + `/api/workspaces/invitations/details?token=${token}`, + { + method: 'GET', + } + ) + + if (workspaceInviteResponse.ok) { + const data = await workspaceInviteResponse.json() + setInvitationType('workspace') + setInvitationDetails({ + type: 'workspace', + data, + name: data.workspaceName || 'a workspace', + }) + setIsLoading(false) + return + } + + // If workspace invitation not found, try organization invitation + try { + const { data } = await client.organization.getInvitation({ + query: { id: inviteId }, + }) + + if (data) { + setInvitationType('organization') + setInvitationDetails({ + type: 'organization', + data, + name: data.organizationName || 'an organization', + }) + + // Get organization details + if (data.organizationId) { + const orgResponse = await client.organization.getFullOrganization({ + query: { organizationId: data.organizationId }, + }) + + if (orgResponse.data) { + setInvitationDetails((prev: any) => ({ + ...prev, + name: orgResponse.data.name || 'an organization', + })) + } + } + } else { + throw new Error('Invitation not found or has expired') + } + } catch (err) { + // If neither workspace nor organization invitation is found + throw new Error('Invitation not found or has expired') + } + } catch (err: any) { + console.error('Error fetching invitation:', err) + setError(err.message || 'Failed to load invitation details') + } finally { + setIsLoading(false) + } + } + + fetchInvitationDetails() + }, [session?.user, inviteId, token]) + + // Handle invitation acceptance + const handleAcceptInvitation = async () => { + if (!session?.user) return + + setIsAccepting(true) + try { + if (invitationType === 'workspace') { + // For workspace invites, call the API route with token + const response = await fetch( + `/api/workspaces/invitations/accept?token=${encodeURIComponent(token || '')}` + ) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error(errorData.error || 'Failed to accept invitation') + } + + setAccepted(true) + + // Redirect to workspace after a brief delay + setTimeout(() => { + router.push('/w') + }, 2000) + } else { + // For organization invites, use the client API + const response = await client.organization.acceptInvitation({ + invitationId: inviteId, + }) + + console.log('Invitation acceptance response:', response) + + // Set the active organization to the one just joined + const orgId = + response.data?.invitation.organizationId || invitationDetails?.data?.organizationId + + if (orgId) { + await client.organization.setActive({ + organizationId: orgId, + }) + } + + setAccepted(true) + + // Redirect to workspace after a brief delay + setTimeout(() => { + router.push('/w') + }, 2000) + } + } catch (err: any) { + console.error('Error accepting invitation:', err) + setError(err.message || 'Failed to accept invitation') + } finally { + setIsAccepting(false) + } + } + + // Prepare the callback URL - this ensures after login, user returns to invite page + const getCallbackUrl = () => { + return `/invite/${inviteId}${token && token !== inviteId ? `?token=${token}` : ''}` + } + + // Show login/signup prompt if not logged in + if (!session?.user && !isPending) { + const callbackUrl = encodeURIComponent(getCallbackUrl()) + + return ( +
+ + + You've been invited to join a workspace + + {isNewUser + ? 'Create an account to join this workspace on Sim Studio' + : 'Sign in to your account to accept this invitation'} + + + + {isNewUser ? ( + <> + + + + ) : ( + <> + + + + )} + + +
+ ) + } + + // Show loading state + if (isLoading || isPending) { + return ( +
+ +

Loading invitation...

+
+ ) + } + + // Show error state + if (error) { + return ( +
+ +
+ +
+

Invitation Error

+

{error}

+
+
+ ) + } + + // Show success state + if (accepted) { + return ( +
+ +
+ +
+

Invitation Accepted

+

+ You have successfully joined {invitationDetails?.name || 'the workspace'}. Redirecting + to your workspace... +

+
+
+ ) + } + + // Show invitation details + return ( +
+ + + Workspace Invitation + + You've been invited to join{' '} + {invitationDetails?.name || 'a workspace'} + +

+ Click the accept below to join the workspace. +

+
+ + + +
+
+ ) +} diff --git a/apps/sim/app/invite/[id]/page.tsx b/apps/sim/app/invite/[id]/page.tsx index 00a4f6aa8..cd792230a 100644 --- a/apps/sim/app/invite/[id]/page.tsx +++ b/apps/sim/app/invite/[id]/page.tsx @@ -1,250 +1,3 @@ -'use client' +import Invite from './invite' -import { useEffect, useState } from 'react' -import { useParams, useRouter, useSearchParams } from 'next/navigation' -import { CheckCircle, XCircle } from 'lucide-react' -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' -import { Button } from '@/components/ui/button' -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from '@/components/ui/card' -import { LoadingAgent } from '@/components/ui/loading-agent' -import { client, useSession } from '@/lib/auth-client' - -export default function InvitePage() { - const router = useRouter() - const params = useParams() - const invitationId = params.id as string - const searchParams = useSearchParams() - const { data: session, isPending, error: sessionError } = useSession() - const [invitation, setInvitation] = useState(null) - const [organization, setOrganization] = useState(null) - const [isLoading, setIsLoading] = useState(true) - const [error, setError] = useState(null) - const [isAccepting, setIsAccepting] = useState(false) - const [accepted, setAccepted] = useState(false) - const [isNewUser, setIsNewUser] = useState(false) - - // Check if this is a new user vs. existing user - useEffect(() => { - const isNew = searchParams.get('new') === 'true' - setIsNewUser(isNew) - }, [searchParams]) - - // Fetch invitation details - useEffect(() => { - async function fetchInvitation() { - try { - setIsLoading(true) - const { data } = await client.organization.getInvitation({ - query: { id: invitationId }, - }) - - if (data) { - setInvitation(data) - - // Get organization details if we have the invitation - if (data.organizationId) { - const orgResponse = await client.organization.getFullOrganization({ - query: { organizationId: data.organizationId }, - }) - setOrganization(orgResponse.data) - } - } else { - setError('Invitation not found or has expired') - } - } catch (err: any) { - setError(err.message || 'Failed to load invitation') - } finally { - setIsLoading(false) - } - } - - // Only fetch if the user is logged in - if (session?.user && invitationId) { - fetchInvitation() - } - }, [invitationId, session?.user]) - - // Handle invitation acceptance - const handleAcceptInvitation = async () => { - if (!session?.user) return - - try { - setIsAccepting(true) - console.log('Accepting invitation:', invitationId, 'for user:', session.user.id) - - const response = await client.organization.acceptInvitation({ - invitationId, - }) - - console.log('Invitation acceptance response:', response) - - // Explicitly verify membership was created - try { - const orgResponse = await client.organization.getFullOrganization({ - query: { organizationId: invitation.organizationId }, - }) - - console.log('Organization members after acceptance:', orgResponse.data?.members) - - const isMember = orgResponse.data?.members?.some( - (member: any) => member.userId === session.user.id - ) - - if (!isMember) { - console.error('User was not added as a member after invitation acceptance') - throw new Error('Failed to add you as a member. Please contact support.') - } - - // Set the active organization to the one the user just joined - await client.organization.setActive({ - organizationId: invitation.organizationId, - }) - - console.log('Successfully set active organization:', invitation.organizationId) - } catch (memberCheckErr: any) { - console.error('Error verifying membership:', memberCheckErr) - throw memberCheckErr - } - - setAccepted(true) - - // Redirect to the workspace after a short delay - setTimeout(() => { - router.push('/w') - }, 2000) - } catch (err: any) { - console.error('Error accepting invitation:', err) - setError(err.message || 'Failed to accept invitation') - } finally { - setIsAccepting(false) - } - } - - // Show login/signup prompt if not logged in - if (!session?.user && !isPending) { - return ( -
- - - You've been invited to join a team - - {isNewUser - ? 'Create an account to join this team on Sim Studio' - : 'Sign in to your account to accept this invitation'} - - - - {isNewUser ? ( - <> - - - - ) : ( - <> - - - - )} - - -
- ) - } - - // Show loading state - if (isLoading || isPending) { - return ( -
- -

Loading invitation...

-
- ) - } - - // Show error state - if (error) { - return ( -
- - - Error - {error} - -
- ) - } - - // Show success state - if (accepted) { - return ( -
- - - Invitation Accepted - - You have successfully joined {organization?.name}. Redirecting to your workspace... - - -
- ) - } - - // Show invitation details - return ( -
- - - Team Invitation - - You've been invited to join{' '} - {organization?.name || 'a team'} - - - -

- {invitation?.inviterId ? 'A team member has' : 'You have'} invited you to collaborate in{' '} - {organization?.name || 'their workspace'}. -

-
- - - - -
-
- ) -} +export default Invite diff --git a/apps/sim/app/invite/invite-error/invite-error.tsx b/apps/sim/app/invite/invite-error/invite-error.tsx new file mode 100644 index 000000000..a0be56561 --- /dev/null +++ b/apps/sim/app/invite/invite-error/invite-error.tsx @@ -0,0 +1,73 @@ +'use client' + +import { useEffect, useState } from 'react' +import Link from 'next/link' +import { useSearchParams } from 'next/navigation' +import { AlertTriangle } from 'lucide-react' +import { Button } from '@/components/ui/button' + +function getErrorMessage(reason: string, details?: string): string { + switch (reason) { + case 'missing-token': + return 'The invitation link is invalid or missing a required parameter.' + case 'invalid-token': + return 'The invitation link is invalid or has already been used.' + case 'expired': + return 'This invitation has expired. Please ask for a new invitation.' + case 'already-processed': + return 'This invitation has already been accepted or declined.' + case 'email-mismatch': + return details + ? details + : 'This invitation was sent to a different email address than the one you are logged in with.' + case 'workspace-not-found': + return 'The workspace associated with this invitation could not be found.' + case 'server-error': + return 'An unexpected error occurred while processing your invitation. Please try again later.' + default: + return 'An unknown error occurred while processing your invitation.' + } +} + +export default function InviteError() { + const searchParams = useSearchParams() + const reason = searchParams?.get('reason') || 'unknown' + const details = searchParams?.get('details') + const [errorMessage, setErrorMessage] = useState('') + + useEffect(() => { + // Only set the error message on the client side + setErrorMessage(getErrorMessage(reason, details || undefined)) + }, [reason, details]) + + // Provide a fallback message for SSR + const displayMessage = errorMessage || 'Loading error details...' + + return ( +
+
+
+ + +

Invitation Error

+ +

{displayMessage}

+ +
+ + + + + + + +
+
+
+
+ ) +} diff --git a/apps/sim/app/invite/invite-error/page.tsx b/apps/sim/app/invite/invite-error/page.tsx new file mode 100644 index 000000000..9326826fb --- /dev/null +++ b/apps/sim/app/invite/invite-error/page.tsx @@ -0,0 +1,8 @@ +import InviteError from './invite-error' + +// Generate this page on-demand instead of at build time +export const dynamic = 'force-dynamic' + +export default function InviteErrorPage() { + return +} diff --git a/apps/sim/app/w/[id]/components/control-bar/control-bar.tsx b/apps/sim/app/w/[id]/components/control-bar/control-bar.tsx index 9cf1e03d3..0020f2a6d 100644 --- a/apps/sim/app/w/[id]/components/control-bar/control-bar.tsx +++ b/apps/sim/app/w/[id]/components/control-bar/control-bar.tsx @@ -49,7 +49,10 @@ import { useSidebarStore } from '@/stores/sidebar/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' -import { getKeyboardShortcutText, useKeyboardShortcuts } from '../../hooks/use-keyboard-shortcuts' +import { + getKeyboardShortcutText, + useKeyboardShortcuts, +} from '../../../hooks/use-keyboard-shortcuts' import { useWorkflowExecution } from '../../hooks/use-workflow-execution' import { DeploymentControls } from './components/deployment-controls/deployment-controls' import { HistoryDropdownItem } from './components/history-dropdown-item/history-dropdown-item' @@ -579,11 +582,6 @@ export function ControlBar() { addNotification('info', 'Workflow run cancelled', activeWorkflowId) } else if (workflowError) { addNotification('error', 'Failed to complete all workflow runs', activeWorkflowId) - } else { - // Success notification for batch runs - if (runCount > 1) { - addNotification('console', `Completed ${completedRuns} workflow runs`, activeWorkflowId) - } } } } @@ -1040,7 +1038,7 @@ export function ControlBar() { : `Run (${runCount})`} - + {usageExceeded ? (

Usage Limit Exceeded

@@ -1056,9 +1054,6 @@ export function ControlBar() { : runCount === 1 ? 'Run Workflow' : `Run Workflow ${runCount} times`} - - {getKeyboardShortcutText('Enter', true)} - )} diff --git a/apps/sim/app/w/[id]/workflow.tsx b/apps/sim/app/w/[id]/workflow.tsx index 12c2825e8..a345d747b 100644 --- a/apps/sim/app/w/[id]/workflow.tsx +++ b/apps/sim/app/w/[id]/workflow.tsx @@ -270,7 +270,6 @@ function WorkflowContent() { // Wait for any active DB loading to complete before switching workflows if (isActivelyLoadingFromDB()) { - logger.info('Waiting for DB loading to complete before switching workflow') const checkInterval = setInterval(() => { if (!isActivelyLoadingFromDB()) { clearInterval(checkInterval) diff --git a/apps/sim/app/w/components/sidebar/components/invite-modal/invite-modal.tsx b/apps/sim/app/w/components/sidebar/components/invite-modal/invite-modal.tsx new file mode 100644 index 000000000..b1c331ebb --- /dev/null +++ b/apps/sim/app/w/components/sidebar/components/invite-modal/invite-modal.tsx @@ -0,0 +1,361 @@ +'use client' + +import { KeyboardEvent, useEffect, useState } from 'react' +import { Check, Loader2, X, XCircle } from 'lucide-react' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { Button } from '@/components/ui/button' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { cn } from '@/lib/utils' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { InvitesSent } from './invites-sent/invites-sent' + +interface InviteModalProps { + open: boolean + onOpenChange: (open: boolean) => void + onInviteMember?: (email: string) => void +} + +interface EmailTagProps { + email: string + onRemove: () => void + disabled?: boolean + isInvalid?: boolean +} + +const EmailTag = ({ email, onRemove, disabled, isInvalid }: EmailTagProps) => ( +
+ {email} + {!disabled && ( + + )} +
+) + +const isValidEmail = (email: string): boolean => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + return emailRegex.test(email) +} + +export function InviteModal({ open, onOpenChange }: InviteModalProps) { + const [inputValue, setInputValue] = useState('') + const [emails, setEmails] = useState([]) + const [invalidEmails, setInvalidEmails] = useState([]) + const [isSubmitting, setIsSubmitting] = useState(false) + const [showSent, setShowSent] = useState(false) + const [errorMessage, setErrorMessage] = useState(null) + const [successMessage, setSuccessMessage] = useState(null) + const { activeWorkspaceId } = useWorkflowRegistry() + + const addEmail = (email: string) => { + // Normalize by trimming and converting to lowercase + const normalizedEmail = email.trim().toLowerCase() + + if (!normalizedEmail) return false + + // Check for duplicates + if (emails.includes(normalizedEmail) || invalidEmails.includes(normalizedEmail)) { + return false + } + + // Validate email format + if (!isValidEmail(normalizedEmail)) { + setInvalidEmails([...invalidEmails, normalizedEmail]) + setInputValue('') + return false + } + + // Add to emails array + setEmails([...emails, normalizedEmail]) + setInputValue('') + return true + } + + const removeEmail = (index: number) => { + const newEmails = [...emails] + newEmails.splice(index, 1) + setEmails(newEmails) + } + + const removeInvalidEmail = (index: number) => { + const newInvalidEmails = [...invalidEmails] + newInvalidEmails.splice(index, 1) + setInvalidEmails(newInvalidEmails) + } + + const handleKeyDown = (e: KeyboardEvent) => { + // Add email on Enter, comma, or space + if (['Enter', ',', ' '].includes(e.key) && inputValue.trim()) { + e.preventDefault() + addEmail(inputValue) + } + + // Remove the last email on Backspace if input is empty + if (e.key === 'Backspace' && !inputValue) { + if (invalidEmails.length > 0) { + removeInvalidEmail(invalidEmails.length - 1) + } else if (emails.length > 0) { + removeEmail(emails.length - 1) + } + } + } + + const handlePaste = (e: React.ClipboardEvent) => { + e.preventDefault() + const pastedText = e.clipboardData.getData('text') + const pastedEmails = pastedText + .split(/[\s,;]+/) // Split by space, comma, or semicolon + .filter(Boolean) // Remove empty strings + + const validEmails = pastedEmails.filter((email) => { + return addEmail(email) + }) + + // If we didn't add any emails, keep the current input value + if (validEmails.length === 0 && pastedEmails.length === 1) { + setInputValue(inputValue + pastedEmails[0]) + } + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + // Add current input as an email if it's valid + if (inputValue.trim()) { + addEmail(inputValue) + } + + // Clear any previous error or success messages + setErrorMessage(null) + setSuccessMessage(null) + + // Don't proceed if no emails or no workspace + if (emails.length === 0 || !activeWorkspaceId) { + return + } + + setIsSubmitting(true) + + try { + // Track failed invitations + const failedInvites: string[] = [] + + // Send invitations in parallel + const results = await Promise.all( + emails.map(async (email) => { + try { + const response = await fetch('/api/workspaces/invitations', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + workspaceId: activeWorkspaceId, + email: email, + role: 'member', // Default role for invited members + }), + }) + + const data = await response.json() + + if (!response.ok) { + // Don't add to invalid emails if it's already in the valid emails array + if (!invalidEmails.includes(email)) { + failedInvites.push(email) + } + + // Display the error message from the API if it exists + if (data.error) { + setErrorMessage(data.error) + } + + return false + } + + return true + } catch (err) { + // Don't add to invalid emails if it's already in the valid emails array + if (!invalidEmails.includes(email)) { + failedInvites.push(email) + } + return false + } + }) + ) + + const successCount = results.filter(Boolean).length + + if (successCount > 0) { + // Clear everything on success, but keep track of failed emails + setInputValue('') + + // Only keep emails that failed in the emails array + if (failedInvites.length > 0) { + setEmails(failedInvites) + } else { + setEmails([]) + // Set success message when all invitations are successful + setSuccessMessage( + successCount === 1 + ? 'Invitation sent successfully!' + : `${successCount} invitations sent successfully!` + ) + } + + setInvalidEmails([]) + setShowSent(true) + + // Revert button text after 2 seconds + setTimeout(() => { + setShowSent(false) + }, 4000) + } + } catch (err: any) { + console.error('Error inviting members:', err) + setErrorMessage('An unexpected error occurred. Please try again.') + } finally { + setIsSubmitting(false) + } + } + + const resetState = () => { + setInputValue('') + setEmails([]) + setInvalidEmails([]) + setShowSent(false) + setErrorMessage(null) + setSuccessMessage(null) + } + + return ( + { + if (!newOpen) { + resetState() + } + onOpenChange(newOpen) + }} + > + + +
+ Invite Members to Workspace + +
+
+ +
+
+
+
+ +
+ {invalidEmails.map((email, index) => ( + removeInvalidEmail(index)} + disabled={isSubmitting} + isInvalid={true} + /> + ))} + {emails.map((email, index) => ( + removeEmail(index)} + disabled={isSubmitting} + /> + ))} + setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + onPaste={handlePaste} + onBlur={() => inputValue.trim() && addEmail(inputValue)} + placeholder={ + emails.length > 0 || invalidEmails.length > 0 + ? 'Add another email' + : 'Enter email addresses (comma or Enter to separate)' + } + className={cn( + 'border-none focus-visible:ring-0 focus-visible:ring-offset-0 py-1 min-w-[180px] flex-1 h-7', + emails.length > 0 || invalidEmails.length > 0 ? 'pl-1' : 'pl-0' + )} + autoFocus + disabled={isSubmitting} + /> +
+

+ {errorMessage || + successMessage || + 'Press Enter, comma, or space after each email.'} +

+
+ +
+ +
+
+
+
+
+
+ ) +} diff --git a/apps/sim/app/w/components/sidebar/components/invite-modal/invites-sent/invites-sent.tsx b/apps/sim/app/w/components/sidebar/components/invite-modal/invites-sent/invites-sent.tsx new file mode 100644 index 000000000..0618f7929 --- /dev/null +++ b/apps/sim/app/w/components/sidebar/components/invite-modal/invites-sent/invites-sent.tsx @@ -0,0 +1,121 @@ +'use client' + +import { useEffect, useState } from 'react' +import { Badge } from '@/components/ui/badge' +import { Skeleton } from '@/components/ui/skeleton' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { cn } from '@/lib/utils' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' + +type Invitation = { + id: string + email: string + status: 'pending' | 'accepted' | 'rejected' | 'expired' + createdAt: string +} + +export function InvitesSent() { + const [invitations, setInvitations] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + const { activeWorkspaceId } = useWorkflowRegistry() + + useEffect(() => { + async function fetchInvitations() { + if (!activeWorkspaceId) return + + setIsLoading(true) + setError(null) + + try { + const response = await fetch('/api/workspaces/invitations') + + if (!response.ok) { + throw new Error('Failed to fetch invitations') + } + + const data = await response.json() + setInvitations(data.invitations || []) + } catch (err) { + console.error('Error fetching invitations:', err) + setError('Failed to load invitations') + } finally { + setIsLoading(false) + } + } + + fetchInvitations() + }, [activeWorkspaceId]) + + const TableSkeleton = () => ( +
+ {Array(5) + .fill(0) + .map((_, i) => ( +
+ + +
+ ))} +
+ ) + + if (error) { + return
{error}
+ } + + return ( +
+

Sent Invitations

+ + {isLoading ? ( + + ) : invitations.length === 0 ? ( +
No invitations sent yet
+ ) : ( +
+ + + + + Email + + + Status + + + + + {invitations.map((invitation) => ( + + {invitation.email} + + + {invitation.status.charAt(0).toUpperCase() + invitation.status.slice(1)} + + + + ))} + +
+
+ )} +
+ ) +} diff --git a/apps/sim/app/w/components/sidebar/components/nav-section/nav-section.tsx b/apps/sim/app/w/components/sidebar/components/nav-section/nav-section.tsx index 0310a2e7d..c8421da58 100644 --- a/apps/sim/app/w/components/sidebar/components/nav-section/nav-section.tsx +++ b/apps/sim/app/w/components/sidebar/components/nav-section/nav-section.tsx @@ -20,6 +20,8 @@ interface NavItemProps { active?: boolean onClick?: () => void isCollapsed?: boolean + shortcutCommand?: string + shortcutCommandPosition?: 'inline' | 'below' } export function NavSection({ @@ -30,7 +32,7 @@ export function NavSection({ }: NavSectionProps) { if (isLoading) { return ( -
diff --git a/apps/sim/app/w/components/sidebar/sidebar.tsx b/apps/sim/app/w/components/sidebar/sidebar.tsx index 3b86939e4..db7dd0f76 100644 --- a/apps/sim/app/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/w/components/sidebar/sidebar.tsx @@ -12,9 +12,11 @@ import { PanelRight, PenLine, ScrollText, + Send, Settings, Shapes, Store, + Users, } from 'lucide-react' import { AgentIcon } from '@/components/icons' import { Button } from '@/components/ui/button' @@ -24,8 +26,10 @@ import { useSession } from '@/lib/auth-client' import { useSidebarStore } from '@/stores/sidebar/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { WorkflowMetadata } from '@/stores/workflows/registry/types' +import { getKeyboardShortcutText, useGlobalShortcuts } from '@/app/w/hooks/use-keyboard-shortcuts' import { useRegistryLoading } from '../../hooks/use-registry-loading' import { HelpModal } from './components/help-modal/help-modal' +import { InviteModal } from './components/invite-modal/invite-modal' import { NavSection } from './components/nav-section/nav-section' import { SettingsModal } from './components/settings-modal/settings-modal' import { SidebarControl } from './components/sidebar-control/sidebar-control' @@ -34,6 +38,8 @@ import { WorkspaceHeader } from './components/workspace-header/workspace-header' export function Sidebar() { useRegistryLoading() + // Initialize global keyboard shortcuts + useGlobalShortcuts() const { workflows, @@ -47,6 +53,7 @@ export function Sidebar() { const pathname = usePathname() const [showSettings, setShowSettings] = useState(false) const [showHelp, setShowHelp] = useState(false) + const [showInviteMembers, setShowInviteMembers] = useState(false) const { mode, isExpanded, @@ -70,8 +77,8 @@ export function Sidebar() { // Update modal state in the store when settings or help modals open/close useEffect(() => { - setAnyModalOpen(showSettings || showHelp) - }, [showSettings, showHelp, setAnyModalOpen]) + setAnyModalOpen(showSettings || showHelp || showInviteMembers) + }, [showSettings, showHelp, showInviteMembers, setAnyModalOpen]) // Reset explicit mouse enter state when modal state changes useEffect(() => { @@ -261,6 +268,8 @@ export function Sidebar() { label="Logs" active={pathname === '/w/logs'} isCollapsed={isCollapsed} + shortcutCommand={getKeyboardShortcutText('L', true, true)} + shortcutCommandPosition="below" /> } @@ -275,10 +284,23 @@ export function Sidebar() {
- {/* Bottom buttons container - Always at bottom */} -
- {isCollapsed ? ( + {isCollapsed ? ( +
+ {/* Invite members button */} + + +
setShowInviteMembers(true)} + className="flex items-center justify-center rounded-md text-sm font-medium text-muted-foreground hover:bg-accent/50 cursor-pointer w-8 h-8 mx-auto" + > + +
+
+ Invite Members +
+ + {/* Help button */}
Help + {/* Sidebar control */} + Toggle sidebar
- ) : ( -
- {/* Sidebar control on left */} - - - {/* Help button on right */} +
+ ) : ( + <> + {/* Invite members bar */} +
setShowHelp(true)} - className="flex items-center justify-center rounded-md w-8 h-8 text-sm font-medium text-muted-foreground hover:bg-accent/50 cursor-pointer" + onClick={() => setShowInviteMembers(true)} + className="flex items-center rounded-md px-2 py-1.5 text-sm font-medium text-muted-foreground hover:bg-accent/50 cursor-pointer" > - - Help + + Invite members
- )} -
+ + {/* Bottom buttons container */} +
+
+ {/* Sidebar control on left with tooltip */} + + + + + Toggle sidebar + + + {/* Help button on right with tooltip */} + + +
setShowHelp(true)} + className="flex items-center justify-center rounded-md w-8 h-8 text-sm font-medium text-muted-foreground hover:bg-accent/50 cursor-pointer" + > + + Help +
+
+ Help, contact +
+
+
+ + )} + ) } diff --git a/apps/sim/app/w/[id]/hooks/use-keyboard-shortcuts.ts b/apps/sim/app/w/hooks/use-keyboard-shortcuts.ts similarity index 67% rename from apps/sim/app/w/[id]/hooks/use-keyboard-shortcuts.ts rename to apps/sim/app/w/hooks/use-keyboard-shortcuts.ts index 223792d97..9f49d8a18 100644 --- a/apps/sim/app/w/[id]/hooks/use-keyboard-shortcuts.ts +++ b/apps/sim/app/w/hooks/use-keyboard-shortcuts.ts @@ -1,6 +1,7 @@ 'use client' import { useEffect, useMemo } from 'react' +import { useRouter } from 'next/navigation' /** * Detect if the current platform is Mac @@ -68,3 +69,37 @@ export function useKeyboardShortcuts(onRunWorkflow: () => void, isDisabled = fal return () => window.removeEventListener('keydown', handleKeyDown) }, [onRunWorkflow, isDisabled, isMac]) } + +/** + * Hook to manage global navigation shortcuts + */ +export function useGlobalShortcuts() { + const router = useRouter() + const isMac = useMemo(() => isMacPlatform(), []) + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + // Don't trigger if user is typing in an input, textarea, or contenteditable element + const activeElement = document.activeElement + const isEditableElement = + activeElement instanceof HTMLInputElement || + activeElement instanceof HTMLTextAreaElement || + activeElement?.hasAttribute('contenteditable') + + if (isEditableElement) return + + // Cmd/Ctrl + Shift + L - Navigate to Logs + if ( + event.key.toLowerCase() === 'l' && + event.shiftKey && + ((isMac && event.metaKey) || (!isMac && event.ctrlKey)) + ) { + event.preventDefault() + router.push('/w/logs') + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [router, isMac]) +} diff --git a/apps/sim/blocks/blocks/clay.ts b/apps/sim/blocks/blocks/clay.ts index f818a9102..df8b2bab9 100644 --- a/apps/sim/blocks/blocks/clay.ts +++ b/apps/sim/blocks/blocks/clay.ts @@ -5,7 +5,7 @@ import { BlockConfig } from '../types' export const ClayBlock: BlockConfig = { type: 'clay', name: 'Clay', - description: 'Populate Clay workbook with data', + description: 'Populate Clay workbook', longDescription: 'Populate Clay workbook with data using a JSON or plain text. Enables direct communication and notifications with channel confirmation.', docsLink: 'https://docs.simstudio.ai/tools/clay', diff --git a/apps/sim/components/emails/invitation-email.tsx b/apps/sim/components/emails/invitation-email.tsx index 4cfb86237..00e400a6f 100644 --- a/apps/sim/components/emails/invitation-email.tsx +++ b/apps/sim/components/emails/invitation-email.tsx @@ -33,6 +33,22 @@ export const InvitationEmail = ({ invitedEmail = '', updatedDate = new Date(), }: InvitationEmailProps) => { + // Extract invitation ID or token from inviteLink if present + let enhancedLink = inviteLink + + // Check if link contains an ID (old format) and append token parameter if needed + if (inviteLink && !inviteLink.includes('token=')) { + try { + const url = new URL(inviteLink) + const invitationId = url.pathname.split('/').pop() + if (invitationId) { + enhancedLink = `${baseUrl}/invite/${invitationId}?token=${invitationId}` + } + } catch (e) { + console.error('Error parsing invite link:', e) + } + } + return ( @@ -69,7 +85,7 @@ export const InvitationEmail = ({ {organizationName} on Sim Studio. Sim Studio is a powerful, user-friendly platform for building, testing, and optimizing agentic workflows. - + Accept Invitation diff --git a/apps/sim/components/emails/workspace-invitation.tsx b/apps/sim/components/emails/workspace-invitation.tsx new file mode 100644 index 000000000..3c4ba3ff0 --- /dev/null +++ b/apps/sim/components/emails/workspace-invitation.tsx @@ -0,0 +1,108 @@ +import * as React from 'react' +import { + Body, + Column, + Container, + Head, + Html, + Img, + Link, + Preview, + Row, + Section, + Text, +} from '@react-email/components' +import { baseStyles } from './base-styles' +import EmailFooter from './footer' + +interface WorkspaceInvitationEmailProps { + workspaceName?: string + inviterName?: string + invitationLink?: string +} + +const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai' + +export const WorkspaceInvitationEmail = ({ + workspaceName = 'Workspace', + inviterName = 'Someone', + invitationLink = '', +}: WorkspaceInvitationEmailProps) => { + // Extract token from the link to ensure we're using the correct format + let enhancedLink = invitationLink + + try { + // If the link is pointing to the API endpoint directly, update it to use the client route + if (invitationLink.includes('/api/workspaces/invitations/accept')) { + const url = new URL(invitationLink) + const token = url.searchParams.get('token') + if (token) { + enhancedLink = `${baseUrl}/invite/${token}?token=${token}` + } + } + } catch (e) { + console.error('Error enhancing invitation link:', e) + } + + return ( + + + + + You've been invited to join the "{workspaceName}" workspace on Sim Studio! + + +
+ + + Sim Studio + + +
+ +
+ + + + + +
+ +
+ Hello, + + {inviterName} has invited you to join the "{workspaceName}" workspace on Sim Studio! + + + Sim Studio is a powerful platform for building, testing, and optimizing AI workflows. + Join this workspace to collaborate with your team. + + + Accept Invitation + + + This invitation link will expire in 7 days. If you have any questions or need + assistance, feel free to reach out to our support team. + + + Best regards, +
+ The Sim Studio Team +
+
+
+ + + + + ) +} + +export default WorkspaceInvitationEmail diff --git a/apps/sim/components/ui/tooltip.tsx b/apps/sim/components/ui/tooltip.tsx index dbcad47e5..42de49b06 100644 --- a/apps/sim/components/ui/tooltip.tsx +++ b/apps/sim/components/ui/tooltip.tsx @@ -12,17 +12,28 @@ const TooltipTrigger = TooltipPrimitive.Trigger const TooltipContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, sideOffset = 4, ...props }, ref) => ( + React.ComponentPropsWithoutRef & { + command?: string + commandPosition?: 'inline' | 'below' + } +>(({ className, sideOffset = 8, command, commandPosition = 'inline', ...props }, ref) => ( + > + {props.children} + {command && commandPosition === 'inline' && ( + {command} + )} + {command && commandPosition === 'below' && ( +
{command}
+ )} +
)) TooltipContent.displayName = TooltipPrimitive.Content.displayName diff --git a/apps/sim/db/migrations/0035_slim_energizer.sql b/apps/sim/db/migrations/0035_slim_energizer.sql new file mode 100644 index 000000000..2e418333d --- /dev/null +++ b/apps/sim/db/migrations/0035_slim_energizer.sql @@ -0,0 +1,16 @@ +CREATE TABLE "workspace_invitation" ( + "id" text PRIMARY KEY NOT NULL, + "workspace_id" text NOT NULL, + "email" text NOT NULL, + "inviter_id" text NOT NULL, + "role" text DEFAULT 'member' NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "token" text NOT NULL, + "expires_at" timestamp NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "workspace_invitation_token_unique" UNIQUE("token") +); +--> statement-breakpoint +ALTER TABLE "workspace_invitation" ADD CONSTRAINT "workspace_invitation_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "workspace_invitation" ADD CONSTRAINT "workspace_invitation_inviter_id_user_id_fk" FOREIGN KEY ("inviter_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/apps/sim/db/migrations/meta/0035_snapshot.json b/apps/sim/db/migrations/meta/0035_snapshot.json new file mode 100644 index 000000000..9b91a922f --- /dev/null +++ b/apps/sim/db/migrations/meta/0035_snapshot.json @@ -0,0 +1,2071 @@ +{ + "id": "6b2a09e3-ba7e-40d5-b22a-170932cac7ae", + "prevId": "efe2d701-31f6-4549-a44c-345d74c28043", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subdomain": { + "name": "subdomain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "subdomain_idx": { + "name": "subdomain_idx", + "columns": [ + { + "expression": "subdomain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.marketplace": { + "name": "marketplace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "marketplace_workflow_id_workflow_id_fk": { + "name": "marketplace_workflow_id_workflow_id_fk", + "tableFrom": "marketplace", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "marketplace_author_id_user_id_fk": { + "name": "marketplace_author_id_user_id_fk", + "tableFrom": "marketplace", + "tableTo": "user", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": [ + "active_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "debug_mode": { + "name": "debug_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auto_fill_env_vars": { + "name": "auto_fill_env_vars", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_notified_user": { + "name": "telemetry_notified_user", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "general": { + "name": "general", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_idx": { + "name": "path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#3972F6'" + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_state": { + "name": "deployed_state", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "collaborators": { + "name": "collaborators", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "marketplace_data": { + "name": "marketplace_data", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_logs": { + "name": "workflow_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "duration": { + "name": "duration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "workflow_logs_workflow_id_workflow_id_fk": { + "name": "workflow_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_logs", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workflow_schedule_workflow_id_unique": { + "name": "workflow_schedule_workflow_id_unique", + "nullsNotDistinct": false, + "columns": [ + "workflow_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_invitation": { + "name": "workspace_invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_invitation_workspace_id_workspace_id_fk": { + "name": "workspace_invitation_workspace_id_workspace_id_fk", + "tableFrom": "workspace_invitation", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_invitation_inviter_id_user_id_fk": { + "name": "workspace_invitation_inviter_id_user_id_fk", + "tableFrom": "workspace_invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_invitation_token_unique": { + "name": "workspace_invitation_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_member": { + "name": "workspace_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_workspace_idx": { + "name": "user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_member_workspace_id_workspace_id_fk": { + "name": "workspace_member_workspace_id_workspace_id_fk", + "tableFrom": "workspace_member", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_member_user_id_user_id_fk": { + "name": "workspace_member_user_id_user_id_fk", + "tableFrom": "workspace_member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/sim/db/migrations/meta/_journal.json b/apps/sim/db/migrations/meta/_journal.json index 3d85ddcdb..db9ce0b3c 100644 --- a/apps/sim/db/migrations/meta/_journal.json +++ b/apps/sim/db/migrations/meta/_journal.json @@ -246,6 +246,13 @@ "when": 1746167417863, "tag": "0034_brainy_revanche", "breakpoints": true + }, + { + "idx": 35, + "version": "7", + "when": 1747041949354, + "tag": "0035_slim_energizer", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/apps/sim/db/schema.ts b/apps/sim/db/schema.ts index d856dfc14..5a42420eb 100644 --- a/apps/sim/db/schema.ts +++ b/apps/sim/db/schema.ts @@ -359,3 +359,20 @@ export const workspaceMember = pgTable( } } ) + +export const workspaceInvitation = pgTable('workspace_invitation', { + id: text('id').primaryKey(), + workspaceId: text('workspace_id') + .notNull() + .references(() => workspace.id, { onDelete: 'cascade' }), + email: text('email').notNull(), + inviterId: text('inviter_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + role: text('role').notNull().default('member'), + status: text('status').notNull().default('pending'), + token: text('token').notNull().unique(), + expiresAt: timestamp('expires_at').notNull(), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), +}) \ No newline at end of file diff --git a/apps/sim/drizzle.config.ts b/apps/sim/drizzle.config.ts index 8ca4d5358..65f5503f0 100644 --- a/apps/sim/drizzle.config.ts +++ b/apps/sim/drizzle.config.ts @@ -10,4 +10,4 @@ export default { dbCredentials: { url: process.env.DATABASE_URL!, }, -} satisfies Config +} satisfies Config \ No newline at end of file diff --git a/apps/sim/middleware.ts b/apps/sim/middleware.ts index 258b1b378..275b8aa02 100644 --- a/apps/sim/middleware.ts +++ b/apps/sim/middleware.ts @@ -56,7 +56,44 @@ export async function middleware(request: NextRequest) { // Allow access to invitation links if (request.nextUrl.pathname.startsWith('/invite/')) { - return NextResponse.next() + // If this is an invitation and the user is not logged in, + // and this isn't a login/signup-related request, redirect to login + if (!hasActiveSession && + !request.nextUrl.pathname.endsWith('/login') && + !request.nextUrl.pathname.endsWith('/signup') && + !request.nextUrl.search.includes('callbackUrl')) { + + // Prepare invitation URL for callback after login + const token = request.nextUrl.searchParams.get('token'); + const inviteId = request.nextUrl.pathname.split('/').pop(); + + // Build the callback URL - retain the invitation path with token + const callbackParam = encodeURIComponent( + `/invite/${inviteId}${token ? `?token=${token}` : ''}` + ); + + // Redirect to login with callback + return NextResponse.redirect( + new URL(`/login?callbackUrl=${callbackParam}&invite_flow=true`, request.url) + ); + } + + return NextResponse.next(); + } + + // Allow access to workspace invitation API endpoint + if (request.nextUrl.pathname.startsWith('/api/workspaces/invitations')) { + // If the endpoint is for accepting an invitation and user is not logged in + if (request.nextUrl.pathname.includes('/accept') && !hasActiveSession) { + const token = request.nextUrl.searchParams.get('token'); + if (token) { + // Redirect to the client-side invite page instead of directly to login + return NextResponse.redirect( + new URL(`/invite/${token}?token=${token}`, request.url) + ); + } + } + return NextResponse.next(); } // Handle protected routes that require authentication @@ -78,18 +115,22 @@ export async function middleware(request: NextRequest) { } // Handle waitlist protection for login and signup in production - if (url.pathname === '/login' || url.pathname === '/signup') { + if (url.pathname === '/login' || url.pathname === '/signup' || + url.pathname === '/auth/login' || url.pathname === '/auth/signup') { // If this is the login page and user has logged in before, allow access - if (hasPreviouslyLoggedIn && request.nextUrl.pathname === '/login') { + if (hasPreviouslyLoggedIn && (request.nextUrl.pathname === '/login' || request.nextUrl.pathname === '/auth/login')) { return NextResponse.next() } + // Check for invite_flow parameter indicating the user is in an invitation flow + const isInviteFlow = url.searchParams.get('invite_flow') === 'true' + // Check for a waitlist token in the URL const waitlistToken = url.searchParams.get('token') - // If there's a redirect to the invite page, bypass waitlist check + // If there's a redirect to the invite page or we're in an invite flow, bypass waitlist check const redirectParam = request.nextUrl.searchParams.get('redirect') - if (redirectParam && redirectParam.startsWith('/invite/')) { + if ((redirectParam && redirectParam.startsWith('/invite/')) || isInviteFlow) { return NextResponse.next() } diff --git a/apps/sim/stores/custom-tools/store.ts b/apps/sim/stores/custom-tools/store.ts index a8f8ad563..e81c85099 100644 --- a/apps/sim/stores/custom-tools/store.ts +++ b/apps/sim/stores/custom-tools/store.ts @@ -62,18 +62,6 @@ export const useCustomToolsStore = create()( logger.info(`Loaded ${data.length} custom tools from server`) - // Log details of loaded tools for debugging - if (data.length > 0) { - logger.info( - 'Custom tools loaded:', - data.map((tool) => ({ - id: tool.id, - title: tool.title, - functionName: tool.schema?.function?.name || 'unknown', - })) - ) - } - set({ tools: transformedTools, isLoading: false, diff --git a/apps/sim/stores/index.ts b/apps/sim/stores/index.ts index 190896976..a37be92ae 100644 --- a/apps/sim/stores/index.ts +++ b/apps/sim/stores/index.ts @@ -8,7 +8,12 @@ import { useNotificationStore } from './notifications/store' import { useConsoleStore } from './panel/console/store' import { useVariablesStore } from './panel/variables/store' import { useEnvironmentStore } from './settings/environment/store' -import { getSyncManagers, initializeSyncManagers, resetSyncManagers } from './sync-registry' +import { + getSyncManagers, + initializeSyncManagers, + resetSyncManagers, + isSyncInitialized +} from './sync-registry' import { loadRegistry, loadSubblockValues, @@ -18,14 +23,22 @@ import { } from './workflows/persistence' import { useWorkflowRegistry } from './workflows/registry/store' import { useSubBlockStore } from './workflows/subblock/store' -import { workflowSync } from './workflows/sync' +import { + workflowSync, + isRegistryInitialized, + markWorkflowsDirty +} from './workflows/sync' import { useWorkflowStore } from './workflows/workflow/store' import { BlockState } from './workflows/workflow/types' +// Import the syncWorkflows function directly +import { syncWorkflows } from './workflows' + const logger = createLogger('Stores') // Track initialization state let isInitializing = false +let appFullyInitialized = false /** * Initialize the application state and sync system @@ -39,6 +52,10 @@ async function initializeApplication(): Promise { if (typeof window === 'undefined' || isInitializing) return isInitializing = true + appFullyInitialized = false + + // Track initialization start time + const initStartTime = Date.now() try { // Load environment variables directly from DB @@ -88,13 +105,29 @@ async function initializeApplication(): Promise { // 2. Register cleanup window.addEventListener('beforeunload', handleBeforeUnload) + + // Log initialization timing information + const initDuration = Date.now() - initStartTime + logger.info(`Application initialization completed in ${initDuration}ms`) + + // Mark application as fully initialized + appFullyInitialized = true } catch (error) { logger.error('Error during application initialization:', { error }) + // Still mark as initialized to prevent being stuck in initializing state + appFullyInitialized = true } finally { isInitializing = false } } +/** + * Checks if application is fully initialized + */ +export function isAppInitialized(): boolean { + return appFullyInitialized && isRegistryInitialized() && isSyncInitialized(); +} + function initializeWorkflowState(workflowId: string): void { // Load the specific workflow state from localStorage const workflowState = loadWorkflowState(workflowId) @@ -166,6 +199,9 @@ function handleBeforeUnload(event: BeforeUnloadEvent): void { } } + // Mark workflows as dirty to ensure sync on exit + syncWorkflows(); + // 2. Final sync for managers that need it getSyncManagers() .filter((manager) => manager.config.syncOnExit) @@ -208,6 +244,9 @@ export async function clearUserData(): Promise { const keysToRemove = Object.keys(localStorage).filter((key) => !keysToKeep.includes(key)) keysToRemove.forEach((key) => localStorage.removeItem(key)) + // Reset application initialization state + appFullyInitialized = false + logger.info('User data cleared successfully') } catch (error) { logger.error('Error clearing user data:', { error }) @@ -307,6 +346,9 @@ export async function reinitializeAfterLogin(): Promise { if (typeof window === 'undefined') return try { + // Reset application initialization state + appFullyInitialized = false + // Reset sync managers to prevent any active syncs during reinitialization resetSyncManagers() @@ -448,11 +490,14 @@ function createFirstWorkflowWithAgentBlock(): void { // Save the updated workflow state saveWorkflowState(workflowId, updatedState) + // Mark as dirty to ensure sync + syncWorkflows(); + // Resume sync managers after initialization setTimeout(() => { const syncManagers = getSyncManagers() syncManagers.forEach((manager) => manager.startIntervalSync()) - workflowSync.sync() + syncWorkflows() }, 1000) logger.info('First workflow with agent block created successfully') diff --git a/apps/sim/stores/panel/variables/store.ts b/apps/sim/stores/panel/variables/store.ts index 4624b4aa6..8fb057357 100644 --- a/apps/sim/stores/panel/variables/store.ts +++ b/apps/sim/stores/panel/variables/store.ts @@ -494,6 +494,33 @@ export const useVariablesStore = create()( return } + // Handle unauthorized (401) or forbidden (403) gracefully + if (response.status === 401 || response.status === 403) { + logger.warn(`No permission to access variables for workflow ${workflowId}`) + set((state) => { + // Keep variables from other workflows + const otherVariables = Object.values(state.variables).reduce( + (acc, variable) => { + if (variable.workflowId !== workflowId) { + acc[variable.id] = variable + } + return acc + }, + {} as Record + ) + + // Mark this workflow as loaded but with access issues + loadedWorkflows.add(workflowId) + + return { + variables: otherVariables, + isLoading: false, + error: 'You do not have permission to access these variables', + } + }) + return + } + if (!response.ok) { throw new Error(`Failed to load workflow variables: ${response.statusText}`) } diff --git a/apps/sim/stores/sync-core.ts b/apps/sim/stores/sync-core.ts index 77dca12d2..fe592bef7 100644 --- a/apps/sim/stores/sync-core.ts +++ b/apps/sim/stores/sync-core.ts @@ -36,12 +36,18 @@ export interface SyncConfig { syncInterval?: number onSyncSuccess?: (response: any) => void onSyncError?: (error: any) => void + + // Enhanced retry configuration + maxRetries?: number + retryBackoff?: number } export const DEFAULT_SYNC_CONFIG: Partial = { syncOnInterval: true, syncOnExit: true, syncInterval: 30000, // 30 seconds + maxRetries: 3, + retryBackoff: 1000, // Start with 1 second, will increase exponentially } // Core sync operations interface @@ -51,8 +57,33 @@ export interface SyncOperations { stopIntervalSync: () => void } +// Sync state tracking to prevent concurrent sync operations +const syncState = { + inProgress: new Map(), + lastSyncTime: new Map(), +}; + +// Returns true if a particular endpoint is currently syncing +export function isSyncing(endpoint: string): boolean { + return syncState.inProgress.get(endpoint) === true; +} + +// Returns the timestamp of the last successful sync for an endpoint +export function getLastSyncTime(endpoint: string): number | undefined { + return syncState.lastSyncTime.get(endpoint); +} + // Performs sync operation with automatic retry export async function performSync(config: SyncConfig): Promise { + // Skip if sync already in progress for this endpoint + if (syncState.inProgress.get(config.endpoint)) { + logger.info(`Sync skipped - already in progress for ${config.endpoint}`); + return true; + } + + // Mark sync as in progress + syncState.inProgress.set(config.endpoint, true); + try { // In localStorage mode, just return success immediately - no need to sync to server if (isLocalStorageMode()) { @@ -63,6 +94,9 @@ export async function performSync(config: SyncConfig): Promise { message: 'Skipped sync in localStorage mode', }) } + + // Update last sync time + syncState.lastSyncTime.set(config.endpoint, Date.now()); return true } @@ -71,55 +105,113 @@ export async function performSync(config: SyncConfig): Promise { // Skip sync if the payload indicates it should be skipped if (payload && payload.skipSync === true) { + // Release lock and return success + syncState.inProgress.set(config.endpoint, false); return true } - // Normal API sync flow - return await sendWithRetry(config.endpoint, payload, config) + // Normal API sync flow with retries + const result = await sendWithRetry(config.endpoint, payload, config) + + // If successful, update last sync time + if (result) { + syncState.lastSyncTime.set(config.endpoint, Date.now()); + } + + return result } catch (error) { if (config.onSyncError) { config.onSyncError(error) } - logger.error(`Sync error: ${error}`) + logger.error(`Sync error for ${config.endpoint}: ${error}`) return false + } finally { + // Always release the lock when done + syncState.inProgress.set(config.endpoint, false); } } -// Sends data to endpoint with one retry on failure +// Sends data to endpoint with configurable retries async function sendWithRetry(endpoint: string, payload: any, config: SyncConfig): Promise { - try { - const result = await sendRequest(endpoint, payload, config) - return result - } catch (error) { + const maxRetries = config.maxRetries || DEFAULT_SYNC_CONFIG.maxRetries || 3; + const baseBackoff = config.retryBackoff || DEFAULT_SYNC_CONFIG.retryBackoff || 1000; + + let lastError: Error | null = null; + + for (let attempt = 0; attempt < maxRetries; attempt++) { try { - const retryResult = await sendRequest(endpoint, payload, config) - return retryResult - } catch (retryError) { - if (config.onSyncError) { - config.onSyncError(retryError) + const startTime = Date.now(); + const result = await sendRequest(endpoint, payload, config); + const elapsed = Date.now() - startTime; + + if (result) { + // Only log retries if they happened + if (attempt > 0) { + logger.info(`Sync succeeded on attempt ${attempt + 1} for ${endpoint} after ${elapsed}ms`); + } + return true; + } + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + // Calculate exponential backoff with jitter + const jitter = Math.random() * 0.3 + 0.85; // Random between 0.85 and 1.15 + const backoff = baseBackoff * Math.pow(2, attempt) * jitter; + + logger.warn(`Sync attempt ${attempt + 1}/${maxRetries} failed for ${endpoint}. Retrying in ${Math.round(backoff)}ms: ${lastError.message}`); + + // Only wait if we're going to retry + if (attempt < maxRetries - 1) { + await new Promise(resolve => setTimeout(resolve, backoff)); } - return false } } + + // If we got here, all retries failed + if (lastError) { + if (config.onSyncError) { + config.onSyncError(lastError); + } + logger.error(`All ${maxRetries} sync attempts failed for ${endpoint}: ${lastError.message}`); + } + + return false; } // Sends a single request to the endpoint async function sendRequest(endpoint: string, payload: any, config: SyncConfig): Promise { - const response = await fetch(endpoint, { - method: config.method || 'POST', - headers: { 'Content-Type': 'application/json' }, - body: config.method !== 'GET' ? JSON.stringify(payload) : undefined, - }) + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout + + try { + const response = await fetch(endpoint, { + method: config.method || 'POST', + headers: { 'Content-Type': 'application/json' }, + body: config.method !== 'GET' ? JSON.stringify(payload) : undefined, + signal: controller.signal, + // Add cache control for GET requests to prevent caching + cache: config.method === 'GET' ? 'no-store' : undefined, + }); - if (!response.ok) { - throw new Error(`Sync failed: ${response.status} ${response.statusText}`) + if (!response.ok) { + const errorText = await response.text().catch(() => 'Failed to read error response'); + throw new Error(`Sync failed: ${response.status} ${response.statusText} - ${errorText}`); + } + + const data = await response.json(); + + if (config.onSyncSuccess) { + config.onSyncSuccess(data); + } + + return true; + } catch (error) { + // Handle abort (timeout) explicitly + if (error instanceof DOMException && error.name === 'AbortError') { + throw new Error(`Sync request timed out after 30 seconds`); + } + throw error; + } finally { + clearTimeout(timeoutId); } - - const data = await response.json() - - if (config.onSyncSuccess) { - config.onSyncSuccess(data) - } - - return true } diff --git a/apps/sim/stores/sync-registry.ts b/apps/sim/stores/sync-registry.ts index 9090e3eb4..666ac39b9 100644 --- a/apps/sim/stores/sync-registry.ts +++ b/apps/sim/stores/sync-registry.ts @@ -3,7 +3,12 @@ import { createLogger } from '@/lib/logs/console-logger' import { SyncManager } from './sync' import { isLocalStorageMode } from './sync-core' -import { fetchWorkflowsFromDB, workflowSync } from './workflows/sync' +import { + fetchWorkflowsFromDB, + workflowSync, + isRegistryInitialized, + resetRegistryInitialization +} from './workflows/sync' const logger = createLogger('SyncRegistry') @@ -40,10 +45,26 @@ export async function initializeSyncManagers(): Promise { // Initialize sync managers managers = [workflowSync] + // Reset registry initialization state before fetching + resetRegistryInitialization(); + // Fetch data from DB try { // Remove environment variables fetch await fetchWorkflowsFromDB() + + // Wait for a short period to ensure registry is properly initialized + if (!isRegistryInitialized()) { + logger.info('Waiting for registry initialization to complete...'); + await new Promise(resolve => setTimeout(resolve, 500)); + } + + // Verify initialization complete + if (!isRegistryInitialized()) { + logger.warn('Registry initialization may not have completed properly'); + } else { + logger.info('Registry initialization verified'); + } } catch (error) { logger.error('Error fetching data from DB:', { error }) } @@ -62,7 +83,7 @@ export async function initializeSyncManagers(): Promise { * Check if sync managers are initialized */ export function isSyncInitialized(): boolean { - return initialized + return initialized && isRegistryInitialized() } /** @@ -79,6 +100,7 @@ export function resetSyncManagers(): void { initialized = false initializing = false managers = [] + resetRegistryInitialization() } // Export individual sync managers for direct use diff --git a/apps/sim/stores/workflows/index.ts b/apps/sim/stores/workflows/index.ts index 6f8bcec5e..c6182031a 100644 --- a/apps/sim/stores/workflows/index.ts +++ b/apps/sim/stores/workflows/index.ts @@ -8,7 +8,11 @@ import { BlockState, WorkflowState } from './workflow/types' const logger = createLogger('Workflows') -// Get a workflow with its state merged in by ID +/** + * Get a workflow with its state merged in by ID + * @param workflowId ID of the workflow to retrieve + * @returns The workflow with merged state values or null if not found + */ export function getWorkflowWithValues(workflowId: string) { const { workflows } = useWorkflowRegistry.getState() const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId @@ -64,7 +68,11 @@ export function getWorkflowWithValues(workflowId: string) { } } -// Get a specific block with its subblock values merged in +/** + * Get a specific block with its subblock values merged in + * @param blockId ID of the block to retrieve + * @returns The block with merged subblock values or null if not found + */ export function getBlockWithValues(blockId: string): BlockState | null { const workflowState = useWorkflowStore.getState() const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId @@ -75,16 +83,17 @@ export function getBlockWithValues(blockId: string): BlockState | null { return mergedBlocks[blockId] || null } -// Get all workflows with their values merged +/** + * Get all workflows with their values merged + * Used for sync operations to prepare the payload + * @returns An object containing all workflows with their merged state values + */ export function getAllWorkflowsWithValues() { const { workflows, activeWorkspaceId } = useWorkflowRegistry.getState() const result: Record = {} const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId const currentState = useWorkflowStore.getState() - // Log for debugging - logger.info(`Preparing workflows for sync with active workspace: ${activeWorkspaceId}`) - for (const [id, metadata] of Object.entries(workflows)) { // Skip workflows that don't belong to the active workspace if (activeWorkspaceId && metadata.workspaceId !== activeWorkspaceId) { @@ -138,11 +147,18 @@ export function getAllWorkflowsWithValues() { }, } } - - logger.info( - `Prepared ${Object.keys(result).length} workflows for sync from workspace ${activeWorkspaceId}` - ) + return result } +/** + * Convenience function to mark workflows as dirty and initiate a sync + * This is a shortcut for other files to trigger sync operations + */ +export function syncWorkflows() { + const workflowStore = useWorkflowStore.getState(); + workflowStore.sync.markDirty(); + workflowStore.sync.forceSync(); +} + export { useWorkflowRegistry, useWorkflowStore, useSubBlockStore } diff --git a/apps/sim/stores/workflows/registry/store.ts b/apps/sim/stores/workflows/registry/store.ts index dfaaad5a2..ffc682ab9 100644 --- a/apps/sim/stores/workflows/registry/store.ts +++ b/apps/sim/stores/workflows/registry/store.ts @@ -11,7 +11,12 @@ import { saveWorkflowState, } from '../persistence' import { useSubBlockStore } from '../subblock/store' -import { fetchWorkflowsFromDB, workflowSync } from '../sync' +import { + fetchWorkflowsFromDB, + workflowSync, + resetRegistryInitialization, + markWorkflowsDirty +} from '../sync' import { useWorkflowStore } from '../workflow/store' import { WorkflowMetadata, WorkflowRegistry } from './types' import { generateUniqueName, getNextWorkflowColor } from './utils' @@ -21,6 +26,10 @@ const logger = createLogger('WorkflowRegistry') // Storage key for active workspace const ACTIVE_WORKSPACE_KEY = 'active-workspace-id' +// Track workspace transitions to prevent race conditions +let isWorkspaceTransitioning = false; +const TRANSITION_TIMEOUT = 5000; // 5 seconds maximum for workspace transitions + // Helps clean up any localStorage data that isn't needed for the current workspace function cleanupLocalStorageForWorkspace(workspaceId: string): void { if (typeof window === 'undefined') return @@ -150,6 +159,32 @@ function resetWorkflowStores() { }) } +/** + * Handles workspace transition state tracking + * @param isTransitioning Whether workspace is currently transitioning + */ +function setWorkspaceTransitioning(isTransitioning: boolean): void { + isWorkspaceTransitioning = isTransitioning; + + // Set a safety timeout to prevent permanently stuck in transition state + if (isTransitioning) { + setTimeout(() => { + if (isWorkspaceTransitioning) { + logger.warn('Forcing workspace transition to complete due to timeout'); + isWorkspaceTransitioning = false; + } + }, TRANSITION_TIMEOUT); + } +} + +/** + * Checks if workspace is currently in transition + * @returns True if workspace is transitioning + */ +export function isWorkspaceInTransition(): boolean { + return isWorkspaceTransitioning; +} + export const useWorkflowRegistry = create()( devtools( (set, get) => ({ @@ -178,10 +213,16 @@ export const useWorkflowRegistry = create()( return } + // Set transition state + setWorkspaceTransitioning(true); + logger.info(`Switching from deleted workspace ${currentWorkspaceId} to ${newWorkspaceId}`) // Reset all workflow state resetWorkflowStores() + + // Reset registry initialization state + resetRegistryInitialization(); // Save to localStorage for persistence if (typeof window !== 'undefined') { @@ -203,6 +244,9 @@ export const useWorkflowRegistry = create()( // Clean up any stale localStorage data cleanupLocalStorageForWorkspace(newWorkspaceId) + + // End transition state + setWorkspaceTransitioning(false); }) .catch((error) => { logger.error('Error fetching workflows after workspace deletion:', { @@ -210,6 +254,9 @@ export const useWorkflowRegistry = create()( workspaceId: newWorkspaceId, }) set({ isLoading: false, error: 'Failed to load workspace data' }) + + // End transition state even on error + setWorkspaceTransitioning(false); }) }, @@ -221,11 +268,23 @@ export const useWorkflowRegistry = create()( if (id === currentWorkspaceId) { return } + + // Prevent multiple workspace transitions at once + if (isWorkspaceTransitioning) { + logger.warn('Workspace already transitioning, ignoring new request'); + return; + } + + // Set transition state + setWorkspaceTransitioning(true); logger.info(`Switching workspace from ${currentWorkspaceId} to ${id}`) // Reset all workflow state resetWorkflowStores() + + // Reset registry initialization state + resetRegistryInitialization(); // Save to localStorage for persistence if (typeof window !== 'undefined') { @@ -250,10 +309,16 @@ export const useWorkflowRegistry = create()( // Clean up any stale localStorage data for this workspace cleanupLocalStorageForWorkspace(id) + + // End transition state + setWorkspaceTransitioning(false); }) .catch((error) => { logger.error('Error fetching workflows for workspace:', { error, workspaceId: id }) set({ isLoading: false, error: 'Failed to load workspace data' }) + + // End transition state even on error + setWorkspaceTransitioning(false); }) }, @@ -322,8 +387,6 @@ export const useWorkflowRegistry = create()( }, lastSaved: parsedState.lastSaved || Date.now(), }) - - logger.info(`Switched to workflow ${id}`) } else { // If no saved state, initialize with empty state useWorkflowStore.setState({ @@ -574,8 +637,11 @@ export const useWorkflowRegistry = create()( useWorkflowStore.setState(initialState) } + // Mark as dirty to ensure sync + useWorkflowStore.getState().sync.markDirty(); + // Trigger sync - workflowSync.sync() + useWorkflowStore.getState().sync.forceSync() logger.info(`Created new workflow with ID ${id} in workspace ${workspaceId || 'none'}`) @@ -654,8 +720,11 @@ export const useWorkflowRegistry = create()( useSubBlockStore.getState().initializeFromWorkflow(id, state.blocks) } - // Trigger sync to save to the database with marketplace attributes - workflowSync.sync() + // Mark as dirty to ensure sync + useWorkflowStore.getState().sync.markDirty(); + + // Trigger sync + useWorkflowStore.getState().sync.forceSync() logger.info(`Created marketplace workflow ${id} imported from ${marketplaceId}`) @@ -751,8 +820,11 @@ export const useWorkflowRegistry = create()( saveSubblockValues(id, JSON.parse(JSON.stringify(sourceSubblockValues))) } + // Mark as dirty to ensure sync + useWorkflowStore.getState().sync.markDirty(); + // Trigger sync - workflowSync.sync() + useWorkflowStore.getState().sync.forceSync() logger.info(`Duplicated workflow ${sourceId} to ${id}`) @@ -783,8 +855,11 @@ export const useWorkflowRegistry = create()( logger.error(`Error cancelling schedule for deleted workflow ${id}:`, { error }) }) + // Mark as dirty to ensure sync + useWorkflowStore.getState().sync.markDirty(); + // Sync deletion with database - workflowSync.sync() + useWorkflowStore.getState().sync.forceSync() // If deleting active workflow, switch to another one let newActiveWorkflowId = state.activeWorkflowId @@ -874,8 +949,11 @@ export const useWorkflowRegistry = create()( // Update localStorage saveRegistry(updatedWorkflows) + // Mark as dirty to ensure sync + useWorkflowStore.getState().sync.markDirty(); + // Use PUT for workflow updates - workflowSync.sync() + useWorkflowStore.getState().sync.forceSync() return { workflows: updatedWorkflows, diff --git a/apps/sim/stores/workflows/sync.ts b/apps/sim/stores/workflows/sync.ts index b1e55ee8b..2bbebd065 100644 --- a/apps/sim/stores/workflows/sync.ts +++ b/apps/sim/stores/workflows/sync.ts @@ -22,6 +22,10 @@ let loadingFromDBToken: string | null = null let loadingFromDBStartTime = 0 const LOADING_TIMEOUT = 3000 // 3 seconds maximum loading time +// Add registry initialization tracking +let registryFullyInitialized = false +const REGISTRY_INIT_TIMEOUT = 10000; // 10 seconds maximum for registry initialization + /** * Checks if the system is currently in the process of loading data from the database * Includes safety timeout to prevent permanent blocking of syncs @@ -40,6 +44,97 @@ export function isActivelyLoadingFromDB(): boolean { return true } +/** + * Checks if the workflow registry is fully initialized + * This is used to prevent syncs before the registry is ready + * @returns true if registry is initialized, false otherwise + */ +export function isRegistryInitialized(): boolean { + return registryFullyInitialized; +} + +/** + * Marks registry as initialized after successful load + * Should be called only after all workflows have been loaded from DB + */ +function setRegistryInitialized(): void { + registryFullyInitialized = true; + logger.info('Workflow registry fully initialized'); +} + +/** + * Reset registry initialization state when needed (e.g., workspace switch, logout) + */ +export function resetRegistryInitialization(): void { + registryFullyInitialized = false; + logger.info('Workflow registry initialization reset'); +} + +// Enhanced workflow state tracking +let lastWorkflowState: Record = {}; +let isDirty = false; + +/** + * Checks if workflow state has actually changed since last sync + * @param currentState Current workflow state to compare + * @returns true if changes detected, false otherwise + */ +function hasWorkflowChanges(currentState: Record): boolean { + if (!currentState || Object.keys(currentState).length === 0) { + return false; // Empty state should not trigger sync + } + + if (Object.keys(lastWorkflowState).length === 0) { + // First time check, mark as changed + lastWorkflowState = JSON.parse(JSON.stringify(currentState)); + return true; + } + + // Check if workflow count changed + if (Object.keys(currentState).length !== Object.keys(lastWorkflowState).length) { + lastWorkflowState = JSON.parse(JSON.stringify(currentState)); + return true; + } + + // Deep comparison of workflow states + let hasChanges = false; + for (const [id, workflow] of Object.entries(currentState)) { + if (!lastWorkflowState[id] || JSON.stringify(workflow) !== JSON.stringify(lastWorkflowState[id])) { + hasChanges = true; + break; + } + } + + if (hasChanges) { + lastWorkflowState = JSON.parse(JSON.stringify(currentState)); + } + + return hasChanges; +} + +/** + * Mark workflows as dirty (changed) to force a sync + */ +export function markWorkflowsDirty(): void { + isDirty = true; + logger.info('Workflows marked as dirty, will sync on next opportunity'); +} + +/** + * Checks if workflows are currently marked as dirty + * @returns true if workflows are dirty and need syncing + */ +export function areWorkflowsDirty(): boolean { + return isDirty; +} + +/** + * Reset the dirty flag after a successful sync + */ +export function resetDirtyFlag(): void { + isDirty = false; +} + /** * Fetches workflows from the database and updates the local stores * This function handles backwards syncing on initialization @@ -48,6 +143,9 @@ export async function fetchWorkflowsFromDB(): Promise { if (typeof window === 'undefined') return try { + // Reset registry initialization state + resetRegistryInitialization(); + // Set loading state in registry useWorkflowRegistry.getState().setLoading(true) @@ -112,6 +210,9 @@ export async function fetchWorkflowsFromDB(): Promise { ) // Clear any existing workflows to ensure a clean state useWorkflowRegistry.setState({ workflows: {} }) + + // Mark registry as initialized even with empty data + setRegistryInitialized(); return } @@ -219,6 +320,9 @@ export async function fetchWorkflowsFromDB(): Promise { // 8. Update registry store with all workflows useWorkflowRegistry.setState({ workflows: registryWorkflows }) + // Capture initial state for change detection + lastWorkflowState = getAllWorkflowsWithValues(); + // 9. Set the first workflow as active if there's no active workflow const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId if (!activeWorkflowId && Object.keys(registryWorkflows).length > 0) { @@ -233,8 +337,14 @@ export async function fetchWorkflowsFromDB(): Promise { logger.info(`Set first workflow ${firstWorkflowId} as active`) } } + + // Mark registry as fully initialized now that all data is loaded + setRegistryInitialized(); } catch (error) { logger.error('Error fetching workflows from DB:', { error }) + + // Mark registry as initialized even on error to allow fallback mechanisms + setRegistryInitialized(); } finally { // Reset the flag after a short delay to allow state to settle setTimeout(() => { @@ -255,6 +365,7 @@ export async function fetchWorkflowsFromDB(): Promise { if (workflowCount > 0 && activeWorkflowId && activeDBSyncNeeded()) { // Small delay for state to fully settle before allowing syncs setTimeout(() => { + isDirty = true; // Explicitly mark as dirty for first sync workflowSync.sync() }, 500) } @@ -277,7 +388,7 @@ function activeDBSyncNeeded(): boolean { // Add additional checks here if needed for specific workflow changes // For now, we'll simply avoid the automatic sync after load - return false + return isDirty; } // Create the basic sync configuration @@ -286,6 +397,12 @@ const workflowSyncConfig = { preparePayload: () => { if (typeof window === 'undefined') return {} + // Skip sync if registry is not fully initialized yet + if (!isRegistryInitialized()) { + logger.info('Skipping workflow sync while registry is not fully initialized'); + return { skipSync: true }; + } + // Skip sync if we're currently loading from DB to prevent overwriting DB data if (isActivelyLoadingFromDB()) { logger.info('Skipping workflow sync while loading from DB') @@ -295,6 +412,15 @@ const workflowSyncConfig = { // Get all workflows with values const allWorkflowsData = getAllWorkflowsWithValues() + // Only sync if there are actually changes + if (!isDirty && !hasWorkflowChanges(allWorkflowsData)) { + logger.info('Skipping workflow sync - no changes detected'); + return { skipSync: true }; + } + + // Reset dirty flag since we're about to sync + resetDirtyFlag(); + // Get the active workspace ID const activeWorkspaceId = useWorkflowRegistry.getState().activeWorkspaceId @@ -349,9 +475,6 @@ const workflowSyncConfig = { method: 'POST' as const, syncOnInterval: true, syncOnExit: true, - onSyncSuccess: async () => { - logger.info('Workflows synced to DB successfully') - }, } // Create the sync manager @@ -361,6 +484,14 @@ const baseWorkflowSync = createSingletonSyncManager('workflow-sync', () => workf export const workflowSync = { ...baseWorkflowSync, sync: () => { + // Skip sync if not initialized + if (!isRegistryInitialized()) { + logger.info('Sync requested but registry not fully initialized yet - delaying'); + // If we're not initialized, mark dirty and check again later + isDirty = true; + return; + } + // Clear any existing timeout if (syncDebounceTimer) { clearTimeout(syncDebounceTimer) diff --git a/apps/sim/stores/workflows/workflow/store.ts b/apps/sim/stores/workflows/workflow/store.ts index 3871421da..e7f74f1de 100644 --- a/apps/sim/stores/workflows/workflow/store.ts +++ b/apps/sim/stores/workflows/workflow/store.ts @@ -7,9 +7,9 @@ import { pushHistory, withHistory, WorkflowStoreWithHistory } from '../middlewar import { saveWorkflowState } from '../persistence' import { useWorkflowRegistry } from '../registry/store' import { useSubBlockStore } from '../subblock/store' -import { workflowSync } from '../sync' +import { markWorkflowsDirty, workflowSync } from '../sync' import { mergeSubblockState } from '../utils' -import { Loop, Position, SubBlockState, WorkflowState } from './types' +import { Loop, Position, SubBlockState, SyncControl, WorkflowState } from './types' import { detectCycle } from './utils' const initialState = { @@ -34,6 +34,36 @@ const initialState = { }, } +// Create a consolidated sync control implementation +/** + * The SyncControl implementation provides a clean, centralized way to handle workflow syncing. + * + * This pattern offers several advantages: + * 1. It encapsulates sync logic through a clear, standardized interface + * 2. It allows components to mark workflows as dirty without direct dependencies + * 3. It prevents race conditions by ensuring changes are properly tracked before syncing + * 4. It centralizes sync decisions to avoid redundant or conflicting operations + * + * Usage: + * - Call markDirty() when workflow state changes but sync can be deferred + * - Call forceSync() when an immediate sync to the server is needed + * - Use isDirty() to check if there are unsaved changes + */ +const createSyncControl = (): SyncControl => ({ + markDirty: () => { + markWorkflowsDirty() + }, + isDirty: () => { + // This calls into the sync module to check dirty status + // Actual implementation in sync.ts + return true // Always return true as the sync module will do the actual checking + }, + forceSync: () => { + markWorkflowsDirty() // Always mark as dirty before forcing a sync + workflowSync.sync() + } +}) + export const useWorkflowStore = create()( devtools( withHistory((set, get) => ({ @@ -44,6 +74,9 @@ export const useWorkflowStore = create()( canRedo: () => false, revertToHistoryState: () => {}, + // Implement sync control interface + sync: createSyncControl(), + setNeedsRedeploymentFlag: (needsRedeployment: boolean) => { set({ needsRedeployment }) }, @@ -87,7 +120,8 @@ export const useWorkflowStore = create()( set(newState) pushHistory(set, get, newState, `Add ${type} block`) get().updateLastSaved() - workflowSync.sync() + get().sync.markDirty() + get().sync.forceSync() }, updateBlockPosition: (id: string, position: Position) => { @@ -154,7 +188,8 @@ export const useWorkflowStore = create()( set(newState) pushHistory(set, get, newState, 'Remove block') get().updateLastSaved() - workflowSync.sync() + get().sync.markDirty() + get().sync.forceSync() }, addEdge: (edge: Edge) => { @@ -236,7 +271,8 @@ export const useWorkflowStore = create()( set(newState) pushHistory(set, get, newState, 'Add connection') get().updateLastSaved() - workflowSync.sync() + get().sync.markDirty() + get().sync.forceSync() }, removeEdge: (edgeId: string) => { @@ -296,7 +332,8 @@ export const useWorkflowStore = create()( set(newState) pushHistory(set, get, newState, 'Remove connection') get().updateLastSaved() - workflowSync.sync() + get().sync.markDirty() + get().sync.forceSync() }, clear: () => { @@ -327,7 +364,8 @@ export const useWorkflowStore = create()( hasActiveWebhook: false, } set(newState) - workflowSync.sync() + get().sync.markDirty() + get().sync.forceSync() return newState }, @@ -370,7 +408,8 @@ export const useWorkflowStore = create()( set(newState) get().updateLastSaved() - workflowSync.sync() + get().sync.markDirty() + get().sync.forceSync() }, duplicateBlock: (id: string) => { @@ -437,7 +476,8 @@ export const useWorkflowStore = create()( set(newState) pushHistory(set, get, newState, `Duplicate ${block.type} block`) get().updateLastSaved() - workflowSync.sync() + get().sync.markDirty() + get().sync.forceSync() }, toggleBlockHandles: (id: string) => { @@ -454,7 +494,8 @@ export const useWorkflowStore = create()( set(newState) get().updateLastSaved() - workflowSync.sync() + get().sync.markDirty() + get().sync.forceSync() }, updateBlockName: (id: string, name: string) => { @@ -539,7 +580,8 @@ export const useWorkflowStore = create()( set(newState) pushHistory(set, get, newState, `${name} block name updated`) get().updateLastSaved() - workflowSync.sync() + get().sync.markDirty() + get().sync.forceSync() }, toggleBlockWide: (id: string) => { @@ -555,7 +597,8 @@ export const useWorkflowStore = create()( loops: { ...get().loops }, })) get().updateLastSaved() - workflowSync.sync() + get().sync.markDirty() + get().sync.forceSync() }, updateBlockHeight: (id: string, height: number) => { @@ -570,6 +613,7 @@ export const useWorkflowStore = create()( edges: [...state.edges], })) get().updateLastSaved() + // No sync needed for height changes, just visual }, updateLoopIterations: (loopId: string, iterations: number) => { @@ -588,7 +632,8 @@ export const useWorkflowStore = create()( set(newState) pushHistory(set, get, newState, 'Update loop iterations') get().updateLastSaved() - workflowSync.sync() + get().sync.markDirty() + get().sync.forceSync() }, updateLoopType: (loopId: string, loopType: Loop['loopType']) => { @@ -607,7 +652,8 @@ export const useWorkflowStore = create()( set(newState) pushHistory(set, get, newState, 'Update loop type') get().updateLastSaved() - workflowSync.sync() + get().sync.markDirty() + get().sync.forceSync() }, updateLoopForEachItems: (loopId: string, items: string) => { @@ -626,7 +672,8 @@ export const useWorkflowStore = create()( set(newState) pushHistory(set, get, newState, 'Update forEach items') get().updateLastSaved() - workflowSync.sync() + get().sync.markDirty() + get().sync.forceSync() }, triggerUpdate: () => { @@ -646,7 +693,8 @@ export const useWorkflowStore = create()( set(newState) get().updateLastSaved() - workflowSync.sync() + get().sync.markDirty() + get().sync.forceSync() }, setScheduleStatus: (hasActiveSchedule: boolean) => { @@ -654,6 +702,7 @@ export const useWorkflowStore = create()( if (get().hasActiveSchedule !== hasActiveSchedule) { set({ hasActiveSchedule }) get().updateLastSaved() + get().sync.markDirty() } }, @@ -667,6 +716,7 @@ export const useWorkflowStore = create()( set({ hasActiveWebhook }) get().updateLastSaved() + get().sync.markDirty() } }, @@ -717,7 +767,8 @@ export const useWorkflowStore = create()( pushHistory(set, get, newState, 'Reverted to deployed state') get().updateLastSaved() - workflowSync.sync() + get().sync.markDirty() + get().sync.forceSync() }, })), { name: 'workflow-store' } diff --git a/apps/sim/stores/workflows/workflow/types.ts b/apps/sim/stores/workflows/workflow/types.ts index 2503a5f1f..b1a1fbffb 100644 --- a/apps/sim/stores/workflows/workflow/types.ts +++ b/apps/sim/stores/workflows/workflow/types.ts @@ -46,6 +46,16 @@ export interface WorkflowState { hasActiveWebhook?: boolean } +// New interface for sync control +export interface SyncControl { + // Mark the workflow as changed, requiring sync + markDirty: () => void; + // Check if the workflow has unsaved changes + isDirty: () => boolean; + // Immediately trigger a sync + forceSync: () => void; +} + export interface WorkflowActions { addBlock: (id: string, type: string, name: string, position: Position) => void updateBlockPosition: (id: string, position: Position) => void @@ -68,6 +78,9 @@ export interface WorkflowActions { setDeploymentStatus: (isDeployed: boolean, deployedAt?: Date) => void setScheduleStatus: (hasActiveSchedule: boolean) => void setWebhookStatus: (hasActiveWebhook: boolean) => void + + // Add the sync control methods to the WorkflowActions interface + sync: SyncControl } export type WorkflowStore = WorkflowState & WorkflowActions