mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-10 07:27:57 -05:00
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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<HTMLFormElement>) {
|
||||
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({
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Welcome back</CardTitle>
|
||||
<CardDescription>Enter your credentials to access your account</CardDescription>
|
||||
<CardDescription>
|
||||
{isInviteFlow
|
||||
? 'Sign in to continue to the invitation'
|
||||
: 'Enter your credentials to access your account'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-6">
|
||||
<SocialLoginButtons
|
||||
githubAvailable={githubAvailable}
|
||||
googleAvailable={googleAvailable}
|
||||
callbackURL="/w"
|
||||
isProduction={isProduction}
|
||||
/>
|
||||
{mounted && (
|
||||
<SocialLoginButtons
|
||||
githubAvailable={githubAvailable}
|
||||
googleAvailable={googleAvailable}
|
||||
callbackURL={callbackUrl}
|
||||
isProduction={isProduction}
|
||||
/>
|
||||
)}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
@@ -289,7 +313,10 @@ export default function LoginPage({
|
||||
<CardFooter>
|
||||
<p className="text-sm text-gray-500 text-center w-full">
|
||||
Don't have an account?{' '}
|
||||
<Link href="/signup" className="text-primary hover:underline">
|
||||
<Link
|
||||
href={mounted && searchParams ? `/signup?${searchParams.toString()}` : '/signup'}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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<string | null>(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.'
|
||||
|
||||
@@ -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<string, Variable>) || {}
|
||||
const variables = (workflowData.variables as Record<string, Variable>) || {}
|
||||
|
||||
// Add cache headers to prevent frequent reloading
|
||||
const headers = new Headers({
|
||||
|
||||
@@ -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<string, { role: string, expires: number }>();
|
||||
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<string | null> {
|
||||
// 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 })
|
||||
}
|
||||
}
|
||||
|
||||
131
apps/sim/app/api/workspaces/invitations/accept/route.ts
Normal file
131
apps/sim/app/api/workspaces/invitations/accept/route.ts
Normal file
@@ -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'))
|
||||
}
|
||||
}
|
||||
58
apps/sim/app/api/workspaces/invitations/details/route.ts
Normal file
58
apps/sim/app/api/workspaces/invitations/details/route.ts
Normal file
@@ -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 })
|
||||
}
|
||||
}
|
||||
224
apps/sim/app/api/workspaces/invitations/route.ts
Normal file
224
apps/sim/app/api/workspaces/invitations/route.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
303
apps/sim/app/invite/[id]/invite.tsx
Normal file
303
apps/sim/app/invite/[id]/invite.tsx
Normal file
@@ -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<any>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isAccepting, setIsAccepting] = useState(false)
|
||||
const [accepted, setAccepted] = useState(false)
|
||||
const [isNewUser, setIsNewUser] = useState(false)
|
||||
const [token, setToken] = useState<string | null>(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 (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center p-4 bg-muted/40">
|
||||
<Card className="w-full max-w-md p-6">
|
||||
<CardHeader className="text-center pt-0 px-0">
|
||||
<CardTitle>You've been invited to join a workspace</CardTitle>
|
||||
<CardDescription>
|
||||
{isNewUser
|
||||
? 'Create an account to join this workspace on Sim Studio'
|
||||
: 'Sign in to your account to accept this invitation'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex flex-col space-y-2 px-0">
|
||||
{isNewUser ? (
|
||||
<>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => router.push(`/signup?callbackUrl=${callbackUrl}&invite_flow=true`)}
|
||||
>
|
||||
Create an account
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`)}
|
||||
>
|
||||
I already have an account
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`)}
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() =>
|
||||
router.push(`/signup?callbackUrl=${callbackUrl}&invite_flow=true&new=true`)
|
||||
}
|
||||
>
|
||||
Create an account
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
if (isLoading || isPending) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center p-4 bg-muted/40">
|
||||
<LoadingAgent size="lg" />
|
||||
<p className="mt-4 text-sm text-muted-foreground">Loading invitation...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center p-4 bg-muted/40">
|
||||
<Card className="p-6 max-w-md text-center space-y-2">
|
||||
<div className="flex justify-center">
|
||||
<BotIcon className="w-16 h-16 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">Invitation Error</h3>
|
||||
<p className="text-muted-foreground">{error}</p>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show success state
|
||||
if (accepted) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center p-4 bg-muted/40">
|
||||
<Card className="p-6 max-w-md text-center space-y-2">
|
||||
<div className="flex justify-center">
|
||||
<CheckCircle className="w-16 h-16 text-green-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">Invitation Accepted</h3>
|
||||
<p className="text-muted-foreground">
|
||||
You have successfully joined {invitationDetails?.name || 'the workspace'}. Redirecting
|
||||
to your workspace...
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show invitation details
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center p-4 bg-muted/40">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="mb-1">Workspace Invitation</CardTitle>
|
||||
<CardDescription className="text-md">
|
||||
You've been invited to join{' '}
|
||||
<span className="font-medium">{invitationDetails?.name || 'a workspace'}</span>
|
||||
</CardDescription>
|
||||
<p className="text-md text-muted-foreground mt-2">
|
||||
Click the accept below to join the workspace.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex justify-center">
|
||||
<Button onClick={handleAcceptInvitation} disabled={isAccepting} className="w-full">
|
||||
<span className="ml-2">{isAccepting ? '' : ''}Accept Invitation</span>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<any>(null)
|
||||
const [organization, setOrganization] = useState<any>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>You've been invited to join a team</CardTitle>
|
||||
<CardDescription>
|
||||
{isNewUser
|
||||
? 'Create an account to join this team on Sim Studio'
|
||||
: 'Sign in to your account to accept this invitation'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex flex-col space-y-2">
|
||||
{isNewUser ? (
|
||||
<>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => router.push(`/signup?redirect=/invite/${invitationId}`)}
|
||||
>
|
||||
Create an account
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => router.push(`/login?redirect=/invite/${invitationId}`)}
|
||||
>
|
||||
I already have an account
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => router.push(`/login?redirect=/invite/${invitationId}`)}
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => router.push(`/signup?redirect=/invite/${invitationId}&new=true`)}
|
||||
>
|
||||
Create an account
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
if (isLoading || isPending) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center p-4">
|
||||
<LoadingAgent size="lg" />
|
||||
<p className="mt-4 text-sm text-muted-foreground">Loading invitation...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center p-4">
|
||||
<Alert variant="destructive" className="max-w-md">
|
||||
<XCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show success state
|
||||
if (accepted) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center p-4">
|
||||
<Alert className="max-w-md bg-green-50">
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
<AlertTitle>Invitation Accepted</AlertTitle>
|
||||
<AlertDescription>
|
||||
You have successfully joined {organization?.name}. Redirecting to your workspace...
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show invitation details
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Team Invitation</CardTitle>
|
||||
<CardDescription>
|
||||
You've been invited to join{' '}
|
||||
<span className="font-medium">{organization?.name || 'a team'}</span>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{invitation?.inviterId ? 'A team member has' : 'You have'} invited you to collaborate in{' '}
|
||||
{organization?.name || 'their workspace'}.
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button variant="outline" onClick={() => router.push('/')}>
|
||||
Decline
|
||||
</Button>
|
||||
<Button onClick={handleAcceptInvitation} disabled={isAccepting}>
|
||||
{isAccepting ? <LoadingAgent size="sm" /> : null}
|
||||
<span className={isAccepting ? 'ml-2' : ''}>Accept Invitation</span>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default Invite
|
||||
|
||||
73
apps/sim/app/invite/invite-error/invite-error.tsx
Normal file
73
apps/sim/app/invite/invite-error/invite-error.tsx
Normal file
@@ -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 (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center">
|
||||
<div className="mx-auto max-w-md px-6 py-12 bg-card border rounded-lg">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<AlertTriangle className="h-12 w-12 text-amber-500 mb-4" />
|
||||
|
||||
<h1 className="text-2xl font-bold tracking-tight mb-2">Invitation Error</h1>
|
||||
|
||||
<p className="text-muted-foreground mb-6">{displayMessage}</p>
|
||||
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<Link href="/w" passHref>
|
||||
<Button variant="default" className="w-full">
|
||||
Go to Dashboard
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="/" passHref>
|
||||
<Button variant="outline" className="w-full">
|
||||
Return to Home
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
8
apps/sim/app/invite/invite-error/page.tsx
Normal file
8
apps/sim/app/invite/invite-error/page.tsx
Normal file
@@ -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 <InviteError />
|
||||
}
|
||||
@@ -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})`}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<TooltipContent command={getKeyboardShortcutText('Enter', true)}>
|
||||
{usageExceeded ? (
|
||||
<div className="text-center">
|
||||
<p className="font-medium text-destructive">Usage Limit Exceeded</p>
|
||||
@@ -1056,9 +1054,6 @@ export function ControlBar() {
|
||||
: runCount === 1
|
||||
? 'Run Workflow'
|
||||
: `Run Workflow ${runCount} times`}
|
||||
<span className="text-xs text-muted-foreground ml-1">
|
||||
{getKeyboardShortcutText('Enter', true)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</TooltipContent>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) => (
|
||||
<div
|
||||
className={`flex items-center ${isInvalid ? 'bg-red-50 border-red-200 text-red-700' : 'bg-gray-100 border-gray-200 text-slate-700'} border rounded-md py-0.5 px-2 gap-1 w-auto my-0 ml-0 text-sm`}
|
||||
>
|
||||
<span className="truncate max-w-[180px]">{email}</span>
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className={`${isInvalid ? 'text-red-400 hover:text-red-600' : 'text-gray-400 hover:text-gray-600'} focus:outline-none flex-shrink-0`}
|
||||
aria-label={`Remove ${email}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
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<string[]>([])
|
||||
const [invalidEmails, setInvalidEmails] = useState<string[]>([])
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [showSent, setShowSent] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(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<HTMLInputElement>) => {
|
||||
// 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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(newOpen) => {
|
||||
if (!newOpen) {
|
||||
resetState()
|
||||
}
|
||||
onOpenChange(newOpen)
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="sm:max-w-[500px] flex flex-col p-0 gap-0 overflow-hidden"
|
||||
hideCloseButton
|
||||
>
|
||||
<DialogHeader className="px-6 py-4 border-b flex-shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle className="text-lg font-medium">Invite Members to Workspace</DialogTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-6 pt-4 pb-6">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="emails" className="text-sm font-medium">
|
||||
Email Addresses
|
||||
</label>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-wrap items-center gap-x-2 gap-y-1 border rounded-md px-3 py-1 focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2'
|
||||
)}
|
||||
>
|
||||
{invalidEmails.map((email, index) => (
|
||||
<EmailTag
|
||||
key={`invalid-${index}`}
|
||||
email={email}
|
||||
onRemove={() => removeInvalidEmail(index)}
|
||||
disabled={isSubmitting}
|
||||
isInvalid={true}
|
||||
/>
|
||||
))}
|
||||
{emails.map((email, index) => (
|
||||
<EmailTag
|
||||
key={`valid-${index}`}
|
||||
email={email}
|
||||
onRemove={() => removeEmail(index)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
))}
|
||||
<Input
|
||||
id="emails"
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => 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}
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
'text-xs mt-1',
|
||||
errorMessage
|
||||
? 'text-destructive'
|
||||
: successMessage
|
||||
? 'text-green-600'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{errorMessage ||
|
||||
successMessage ||
|
||||
'Press Enter, comma, or space after each email.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={
|
||||
(emails.length === 0 && !inputValue.trim()) ||
|
||||
isSubmitting ||
|
||||
!activeWorkspaceId
|
||||
}
|
||||
className={cn(
|
||||
'gap-2 font-medium',
|
||||
'bg-[#802FFF] hover:bg-[#7028E6]',
|
||||
'shadow-[0_0_0_0_#802FFF] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
|
||||
'text-white transition-all duration-200',
|
||||
'disabled:opacity-50 disabled:hover:bg-[#802FFF] disabled:hover:shadow-none'
|
||||
)}
|
||||
>
|
||||
{isSubmitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{showSent ? 'Sent!' : 'Send Invitations'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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<Invitation[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(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 = () => (
|
||||
<div className="space-y-2">
|
||||
{Array(5)
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
<div key={i} className="flex items-center justify-between">
|
||||
<Skeleton className="h-6 w-3/4" />
|
||||
<Skeleton className="h-6 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (error) {
|
||||
return <div className="text-sm text-red-500 py-2">{error}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="transition-all duration-300 mt-4">
|
||||
<h3 className="text-sm font-medium">Sent Invitations</h3>
|
||||
|
||||
{isLoading ? (
|
||||
<TableSkeleton />
|
||||
) : invitations.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-2">No invitations sent yet</div>
|
||||
) : (
|
||||
<div className="overflow-auto" style={{ maxHeight: '250px' }}>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-b border-t-0 border-x-0 hover:bg-transparent">
|
||||
<TableHead className="text-muted-foreground px-2 w-3/4 !font-sm !text-sm">
|
||||
Email
|
||||
</TableHead>
|
||||
<TableHead className="text-muted-foreground px-2 w-1/4 !font-sm !text-sm">
|
||||
Status
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{invitations.map((invitation) => (
|
||||
<TableRow
|
||||
key={invitation.id}
|
||||
className={cn('border-b border-t-0 border-x-0', 'hover:bg-transparent')}
|
||||
>
|
||||
<TableCell className="py-2 px-2 w-3/4">{invitation.email}</TableCell>
|
||||
<TableCell className="py-2 px-2 w-1/4">
|
||||
<span
|
||||
className={`inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-semibold ${
|
||||
invitation.status === 'accepted'
|
||||
? 'bg-green-50 border-green-200 text-green-700'
|
||||
: 'bg-gray-100 border-gray-200 text-slate-700'
|
||||
}`}
|
||||
>
|
||||
{invitation.status.charAt(0).toUpperCase() + invitation.status.slice(1)}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<nav className="space-y-[1px]">
|
||||
<nav className="space-y-1">
|
||||
{Array(itemCount)
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
@@ -40,10 +42,19 @@ export function NavSection({
|
||||
)
|
||||
}
|
||||
|
||||
return <nav className="space-y-[1px]">{children}</nav>
|
||||
return <nav className="space-y-1">{children}</nav>
|
||||
}
|
||||
|
||||
function NavItem({ icon, label, href, active, onClick, isCollapsed }: NavItemProps) {
|
||||
function NavItem({
|
||||
icon,
|
||||
label,
|
||||
href,
|
||||
active,
|
||||
onClick,
|
||||
isCollapsed,
|
||||
shortcutCommand,
|
||||
shortcutCommandPosition = 'inline',
|
||||
}: NavItemProps) {
|
||||
const className = clsx(
|
||||
'flex items-center gap-2 rounded-md px-2 py-[6px] text-sm font-medium',
|
||||
active ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:bg-accent/50',
|
||||
@@ -71,7 +82,13 @@ function NavItem({ icon, label, href, active, onClick, isCollapsed }: NavItemPro
|
||||
{content}
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{label}</TooltipContent>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
command={shortcutCommand}
|
||||
commandPosition={shortcutCommandPosition}
|
||||
>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -83,7 +100,13 @@ function NavItem({ icon, label, href, active, onClick, isCollapsed }: NavItemPro
|
||||
{content}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{label}</TooltipContent>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
command={shortcutCommand}
|
||||
commandPosition={shortcutCommandPosition}
|
||||
>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -355,6 +355,13 @@ export function WorkspaceHeader({
|
||||
}
|
||||
|
||||
const handleUpdateWorkspace = async (id: string, name: string) => {
|
||||
// Check if user has permission to update the workspace
|
||||
const workspace = workspaces.find((w) => w.id === id)
|
||||
if (!workspace || workspace.role !== 'owner') {
|
||||
console.error('Permission denied: Only workspace owners can update workspaces')
|
||||
return
|
||||
}
|
||||
|
||||
setIsWorkspacesLoading(true)
|
||||
|
||||
try {
|
||||
@@ -389,6 +396,13 @@ export function WorkspaceHeader({
|
||||
}
|
||||
|
||||
const handleDeleteWorkspace = async (id: string) => {
|
||||
// Check if user has permission to delete the workspace
|
||||
const workspace = workspaces.find((w) => w.id === id)
|
||||
if (!workspace || workspace.role !== 'owner') {
|
||||
console.error('Permission denied: Only workspace owners can delete workspaces')
|
||||
return
|
||||
}
|
||||
|
||||
setIsDeleting(true)
|
||||
|
||||
try {
|
||||
@@ -422,6 +436,11 @@ export function WorkspaceHeader({
|
||||
|
||||
const openEditModal = (workspace: Workspace, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
// Check if user has permission to edit the workspace
|
||||
if (workspace.role !== 'owner') {
|
||||
console.error('Permission denied: Only workspace owners can edit workspaces')
|
||||
return
|
||||
}
|
||||
setEditingWorkspace(workspace)
|
||||
setIsEditModalOpen(true)
|
||||
}
|
||||
@@ -600,55 +619,57 @@ export function WorkspaceHeader({
|
||||
onClick={() => switchWorkspace(workspace)}
|
||||
>
|
||||
<span className="truncate pr-16">{workspace.name}</span>
|
||||
<div className="absolute right-2 opacity-0 group-hover:opacity-100 flex items-center gap-1 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 p-0 text-muted-foreground"
|
||||
onClick={(e) => openEditModal(workspace, e)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">Edit</span>
|
||||
</Button>
|
||||
{workspace.role === 'owner' && (
|
||||
<div className="absolute right-2 opacity-0 group-hover:opacity-100 flex items-center gap-1 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 p-0 text-muted-foreground"
|
||||
onClick={(e) => openEditModal(workspace, e)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">Edit</span>
|
||||
</Button>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 p-0 text-muted-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
disabled={isDeleting || workspaces.length <= 1}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">Delete</span>
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Workspace</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{workspace.name}"? This action cannot
|
||||
be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={(e) => e.stopPropagation()}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeleteWorkspace(workspace.id)
|
||||
}}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 p-0 text-muted-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
disabled={isDeleting || workspaces.length <= 1}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">Delete</span>
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Workspace</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{workspace.name}"? This action
|
||||
cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={(e) => e.stopPropagation()}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeleteWorkspace(workspace.id)
|
||||
}}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
<NavSection.Item
|
||||
icon={<Settings className="h-[18px] w-[18px]" />}
|
||||
@@ -275,10 +284,23 @@ export function Sidebar() {
|
||||
<div className="flex-grow"></div>
|
||||
</div>
|
||||
|
||||
{/* Bottom buttons container - Always at bottom */}
|
||||
<div className="flex-shrink-0 px-3 py-3">
|
||||
{isCollapsed ? (
|
||||
{isCollapsed ? (
|
||||
<div className="flex-shrink-0 px-3 pb-3 pt-1">
|
||||
<div className="flex flex-col space-y-[1px]">
|
||||
{/* Invite members button */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
onClick={() => 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"
|
||||
>
|
||||
<Send className="h-[18px] w-[18px]" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">Invite Members</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Help button */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
@@ -291,31 +313,60 @@ export function Sidebar() {
|
||||
<TooltipContent side="right">Help</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Sidebar control */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<SidebarControl />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">Toggle sidebar</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-between">
|
||||
{/* Sidebar control on left */}
|
||||
<SidebarControl />
|
||||
|
||||
{/* Help button on right */}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Invite members bar */}
|
||||
<div className="flex-shrink-0 px-3 pt-1">
|
||||
<div
|
||||
onClick={() => 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"
|
||||
>
|
||||
<HelpCircle className="h-[18px] w-[18px]" />
|
||||
<span className="sr-only">Help</span>
|
||||
<Send className="h-[18px] w-[18px]" />
|
||||
<span className="ml-2">Invite members</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom buttons container */}
|
||||
<div className="flex-shrink-0 px-3 pb-3 pt-1">
|
||||
<div className="flex justify-between">
|
||||
{/* Sidebar control on left with tooltip */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<SidebarControl />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Toggle sidebar</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Help button on right with tooltip */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
onClick={() => 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"
|
||||
>
|
||||
<HelpCircle className="h-[18px] w-[18px]" />
|
||||
<span className="sr-only">Help</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Help, contact</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<SettingsModal open={showSettings} onOpenChange={setShowSettings} />
|
||||
<HelpModal open={showHelp} onOpenChange={setShowHelp} />
|
||||
<InviteModal open={showInviteMembers} onOpenChange={setShowInviteMembers} />
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { BlockConfig } from '../types'
|
||||
export const ClayBlock: BlockConfig<ClayPopulateResponse> = {
|
||||
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',
|
||||
|
||||
@@ -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 (
|
||||
<Html>
|
||||
<Head />
|
||||
@@ -69,7 +85,7 @@ export const InvitationEmail = ({
|
||||
<strong>{organizationName}</strong> on Sim Studio. Sim Studio is a powerful,
|
||||
user-friendly platform for building, testing, and optimizing agentic workflows.
|
||||
</Text>
|
||||
<Link href={inviteLink} style={{ textDecoration: 'none' }}>
|
||||
<Link href={enhancedLink} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Accept Invitation</Text>
|
||||
</Link>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
|
||||
108
apps/sim/components/emails/workspace-invitation.tsx
Normal file
108
apps/sim/components/emails/workspace-invitation.tsx
Normal file
@@ -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 (
|
||||
<Html>
|
||||
<Head />
|
||||
<Body style={baseStyles.main}>
|
||||
<Preview>
|
||||
You've been invited to join the "{workspaceName}" workspace on Sim Studio!
|
||||
</Preview>
|
||||
<Container style={baseStyles.container}>
|
||||
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
|
||||
<Row>
|
||||
<Column style={{ textAlign: 'center' }}>
|
||||
<Img
|
||||
src={`${baseUrl}/static/sim.png`}
|
||||
width="114"
|
||||
alt="Sim Studio"
|
||||
style={{
|
||||
margin: '0 auto',
|
||||
}}
|
||||
/>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section style={baseStyles.sectionsBorders}>
|
||||
<Row>
|
||||
<Column style={baseStyles.sectionBorder} />
|
||||
<Column style={baseStyles.sectionCenter} />
|
||||
<Column style={baseStyles.sectionBorder} />
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section style={baseStyles.content}>
|
||||
<Text style={baseStyles.paragraph}>Hello,</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
{inviterName} has invited you to join the "{workspaceName}" workspace on Sim Studio!
|
||||
</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Sim Studio is a powerful platform for building, testing, and optimizing AI workflows.
|
||||
Join this workspace to collaborate with your team.
|
||||
</Text>
|
||||
<Link href={enhancedLink} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Accept Invitation</Text>
|
||||
</Link>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
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.
|
||||
</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Best regards,
|
||||
<br />
|
||||
The Sim Studio Team
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
<EmailFooter baseUrl={baseUrl} />
|
||||
</Body>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
|
||||
export default WorkspaceInvitationEmail
|
||||
@@ -12,17 +12,28 @@ const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> & {
|
||||
command?: string
|
||||
commandPosition?: 'inline' | 'below'
|
||||
}
|
||||
>(({ className, sideOffset = 8, command, commandPosition = 'inline', ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
'z-50 overflow-hidden rounded-md bg-black text-white dark:bg-white dark:text-black px-3 py-1.5 text-xs shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{props.children}
|
||||
{command && commandPosition === 'inline' && (
|
||||
<span className="pl-2 text-white/80 dark:text-black/70">{command}</span>
|
||||
)}
|
||||
{command && commandPosition === 'below' && (
|
||||
<div className="pt-[1px] text-white/80 dark:text-black/70">{command}</div>
|
||||
)}
|
||||
</TooltipPrimitive.Content>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
|
||||
16
apps/sim/db/migrations/0035_slim_energizer.sql
Normal file
16
apps/sim/db/migrations/0035_slim_energizer.sql
Normal file
@@ -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;
|
||||
2071
apps/sim/db/migrations/meta/0035_snapshot.json
Normal file
2071
apps/sim/db/migrations/meta/0035_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -246,6 +246,13 @@
|
||||
"when": 1746167417863,
|
||||
"tag": "0034_brainy_revanche",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 35,
|
||||
"version": "7",
|
||||
"when": 1747041949354,
|
||||
"tag": "0035_slim_energizer",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
@@ -10,4 +10,4 @@ export default {
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL!,
|
||||
},
|
||||
} satisfies Config
|
||||
} satisfies Config
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -62,18 +62,6 @@ export const useCustomToolsStore = create<CustomToolsStore>()(
|
||||
|
||||
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,
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
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')
|
||||
|
||||
@@ -494,6 +494,33 @@ export const useVariablesStore = create<VariablesStore>()(
|
||||
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<string, Variable>
|
||||
)
|
||||
|
||||
// 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}`)
|
||||
}
|
||||
|
||||
@@ -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<SyncConfig> = {
|
||||
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<string, boolean>(),
|
||||
lastSyncTime: new Map<string, number>(),
|
||||
};
|
||||
|
||||
// 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<boolean> {
|
||||
// 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<boolean> {
|
||||
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<boolean> {
|
||||
|
||||
// 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<boolean> {
|
||||
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<boolean> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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<boolean> {
|
||||
// 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<boolean> {
|
||||
* 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
|
||||
|
||||
@@ -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<string, any> = {}
|
||||
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 }
|
||||
|
||||
@@ -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<WorkflowRegistry>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
@@ -178,10 +213,16 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
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<WorkflowRegistry>()(
|
||||
|
||||
// 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<WorkflowRegistry>()(
|
||||
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<WorkflowRegistry>()(
|
||||
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<WorkflowRegistry>()(
|
||||
|
||||
// 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<WorkflowRegistry>()(
|
||||
},
|
||||
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<WorkflowRegistry>()(
|
||||
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<WorkflowRegistry>()(
|
||||
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<WorkflowRegistry>()(
|
||||
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<WorkflowRegistry>()(
|
||||
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<WorkflowRegistry>()(
|
||||
// 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,
|
||||
|
||||
@@ -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<string, any> = {};
|
||||
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<string, any>): 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<void> {
|
||||
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<void> {
|
||||
)
|
||||
// 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<void> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
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)
|
||||
|
||||
@@ -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<WorkflowStoreWithHistory>()(
|
||||
devtools(
|
||||
withHistory((set, get) => ({
|
||||
@@ -44,6 +74,9 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
|
||||
canRedo: () => false,
|
||||
revertToHistoryState: () => {},
|
||||
|
||||
// Implement sync control interface
|
||||
sync: createSyncControl(),
|
||||
|
||||
setNeedsRedeploymentFlag: (needsRedeployment: boolean) => {
|
||||
set({ needsRedeployment })
|
||||
},
|
||||
@@ -87,7 +120,8 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
|
||||
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<WorkflowStoreWithHistory>()(
|
||||
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<WorkflowStoreWithHistory>()(
|
||||
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<WorkflowStoreWithHistory>()(
|
||||
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<WorkflowStoreWithHistory>()(
|
||||
hasActiveWebhook: false,
|
||||
}
|
||||
set(newState)
|
||||
workflowSync.sync()
|
||||
get().sync.markDirty()
|
||||
get().sync.forceSync()
|
||||
|
||||
return newState
|
||||
},
|
||||
@@ -370,7 +408,8 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
|
||||
|
||||
set(newState)
|
||||
get().updateLastSaved()
|
||||
workflowSync.sync()
|
||||
get().sync.markDirty()
|
||||
get().sync.forceSync()
|
||||
},
|
||||
|
||||
duplicateBlock: (id: string) => {
|
||||
@@ -437,7 +476,8 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
|
||||
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<WorkflowStoreWithHistory>()(
|
||||
|
||||
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<WorkflowStoreWithHistory>()(
|
||||
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<WorkflowStoreWithHistory>()(
|
||||
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<WorkflowStoreWithHistory>()(
|
||||
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<WorkflowStoreWithHistory>()(
|
||||
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<WorkflowStoreWithHistory>()(
|
||||
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<WorkflowStoreWithHistory>()(
|
||||
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<WorkflowStoreWithHistory>()(
|
||||
|
||||
set(newState)
|
||||
get().updateLastSaved()
|
||||
workflowSync.sync()
|
||||
get().sync.markDirty()
|
||||
get().sync.forceSync()
|
||||
},
|
||||
|
||||
setScheduleStatus: (hasActiveSchedule: boolean) => {
|
||||
@@ -654,6 +702,7 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
|
||||
if (get().hasActiveSchedule !== hasActiveSchedule) {
|
||||
set({ hasActiveSchedule })
|
||||
get().updateLastSaved()
|
||||
get().sync.markDirty()
|
||||
}
|
||||
},
|
||||
|
||||
@@ -667,6 +716,7 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
|
||||
|
||||
set({ hasActiveWebhook })
|
||||
get().updateLastSaved()
|
||||
get().sync.markDirty()
|
||||
}
|
||||
},
|
||||
|
||||
@@ -717,7 +767,8 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
|
||||
|
||||
pushHistory(set, get, newState, 'Reverted to deployed state')
|
||||
get().updateLastSaved()
|
||||
workflowSync.sync()
|
||||
get().sync.markDirty()
|
||||
get().sync.forceSync()
|
||||
},
|
||||
})),
|
||||
{ name: 'workflow-store' }
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user