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:
Emir Karabeg
2025-05-14 11:01:51 -07:00
committed by GitHub
parent 575a482273
commit 38efb9a871
44 changed files with 4682 additions and 475 deletions

View File

@@ -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

View File

@@ -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>

View File

@@ -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()

View File

@@ -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()

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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.'

View File

@@ -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({

View File

@@ -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 })
}
}

View 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'))
}
}

View 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 })
}
}

View 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
}
}

View 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>
)
}

View File

@@ -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

View 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>
)
}

View 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 />
}

View File

@@ -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>

View File

@@ -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)

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>

View File

@@ -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>
)
}

View File

@@ -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])
}

View File

@@ -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',

View File

@@ -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}>

View 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

View File

@@ -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

View 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;

File diff suppressed because it is too large Load Diff

View File

@@ -246,6 +246,13 @@
"when": 1746167417863,
"tag": "0034_brainy_revanche",
"breakpoints": true
},
{
"idx": 35,
"version": "7",
"when": 1747041949354,
"tag": "0035_slim_energizer",
"breakpoints": true
}
]
}
}

View File

@@ -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(),
})

View File

@@ -10,4 +10,4 @@ export default {
dbCredentials: {
url: process.env.DATABASE_URL!,
},
} satisfies Config
} satisfies Config

View File

@@ -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()
}

View File

@@ -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,

View File

@@ -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')

View File

@@ -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}`)
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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 }

View File

@@ -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,

View File

@@ -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)

View File

@@ -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' }

View File

@@ -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