Skip to content

Commit 38efb9a

Browse files
authored
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
1 parent 575a482 commit 38efb9a

File tree

44 files changed

+4682
-475
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+4682
-475
lines changed

apps/sim/app/(auth)/components/social-login-buttons.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { useState } from 'react'
3+
import { useEffect, useState } from 'react'
44
import { GithubIcon, GoogleIcon } from '@/components/icons'
55
import { Button } from '@/components/ui/button'
66
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
@@ -23,6 +23,15 @@ export function SocialLoginButtons({
2323
const [isGithubLoading, setIsGithubLoading] = useState(false)
2424
const [isGoogleLoading, setIsGoogleLoading] = useState(false)
2525
const { addNotification } = useNotificationStore()
26+
const [mounted, setMounted] = useState(false)
27+
28+
// Set mounted state to true on client-side
29+
useEffect(() => {
30+
setMounted(true)
31+
}, [])
32+
33+
// Only render on the client side to avoid hydration errors
34+
if (!mounted) return null
2635

2736
async function signInWithGithub() {
2837
if (!githubAvailable) return

apps/sim/app/(auth)/login/login-form.tsx

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { useEffect, useState } from 'react'
44
import Link from 'next/link'
5-
import { useRouter } from 'next/navigation'
5+
import { useRouter, useSearchParams } from 'next/navigation'
66
import { Eye, EyeOff } from 'lucide-react'
77
import { Button } from '@/components/ui/button'
88
import {
@@ -35,12 +35,17 @@ export default function LoginPage({
3535
isProduction: boolean
3636
}) {
3737
const router = useRouter()
38+
const searchParams = useSearchParams()
3839
const [isLoading, setIsLoading] = useState(false)
39-
const [, setMounted] = useState(false)
40+
const [mounted, setMounted] = useState(false)
4041
const { addNotification } = useNotificationStore()
4142
const [showPassword, setShowPassword] = useState(false)
4243
const [password, setPassword] = useState('')
4344

45+
// Initialize state for URL parameters
46+
const [callbackUrl, setCallbackUrl] = useState('/w')
47+
const [isInviteFlow, setIsInviteFlow] = useState(false)
48+
4449
// Forgot password states
4550
const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false)
4651
const [forgotPasswordEmail, setForgotPasswordEmail] = useState('')
@@ -50,9 +55,21 @@ export default function LoginPage({
5055
message: string
5156
}>({ type: null, message: '' })
5257

58+
// Extract URL parameters after component mounts to avoid SSR issues
5359
useEffect(() => {
5460
setMounted(true)
55-
}, [])
61+
62+
// Only access search params on the client side
63+
if (searchParams) {
64+
const callback = searchParams.get('callbackUrl')
65+
if (callback) {
66+
setCallbackUrl(callback)
67+
}
68+
69+
const inviteFlow = searchParams.get('invite_flow') === 'true'
70+
setIsInviteFlow(inviteFlow)
71+
}
72+
}, [searchParams])
5673

5774
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
5875
e.preventDefault()
@@ -62,11 +79,12 @@ export default function LoginPage({
6279
const email = formData.get('email') as string
6380

6481
try {
82+
// Use the extracted callbackUrl instead of hardcoded value
6583
const result = await client.signIn.email(
6684
{
6785
email,
6886
password,
69-
callbackURL: '/w',
87+
callbackURL: callbackUrl,
7088
},
7189
{
7290
onError: (ctx) => {
@@ -214,16 +232,22 @@ export default function LoginPage({
214232
<Card className="w-full">
215233
<CardHeader>
216234
<CardTitle>Welcome back</CardTitle>
217-
<CardDescription>Enter your credentials to access your account</CardDescription>
235+
<CardDescription>
236+
{isInviteFlow
237+
? 'Sign in to continue to the invitation'
238+
: 'Enter your credentials to access your account'}
239+
</CardDescription>
218240
</CardHeader>
219241
<CardContent>
220242
<div className="grid gap-6">
221-
<SocialLoginButtons
222-
githubAvailable={githubAvailable}
223-
googleAvailable={googleAvailable}
224-
callbackURL="/w"
225-
isProduction={isProduction}
226-
/>
243+
{mounted && (
244+
<SocialLoginButtons
245+
githubAvailable={githubAvailable}
246+
googleAvailable={googleAvailable}
247+
callbackURL={callbackUrl}
248+
isProduction={isProduction}
249+
/>
250+
)}
227251
<div className="relative">
228252
<div className="absolute inset-0 flex items-center">
229253
<span className="w-full border-t" />
@@ -289,7 +313,10 @@ export default function LoginPage({
289313
<CardFooter>
290314
<p className="text-sm text-gray-500 text-center w-full">
291315
Don't have an account?{' '}
292-
<Link href="/signup" className="text-primary hover:underline">
316+
<Link
317+
href={mounted && searchParams ? `/signup?${searchParams.toString()}` : '/signup'}
318+
className="text-primary hover:underline"
319+
>
293320
Sign up
294321
</Link>
295322
</p>

apps/sim/app/(auth)/login/page.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { getOAuthProviderStatus } from '../components/oauth-provider-checker'
22
import LoginForm from './login-form'
33

4+
// Force dynamic rendering to avoid prerender errors with search params
5+
export const dynamic = 'force-dynamic'
6+
47
export default async function LoginPage() {
58
const { githubAvailable, googleAvailable, isProduction } = await getOAuthProviderStatus()
69

apps/sim/app/(auth)/signup/page.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { getOAuthProviderStatus } from '../components/oauth-provider-checker'
22
import SignupForm from './signup-form'
33

4+
// Force dynamic rendering to avoid prerender errors with search params
5+
export const dynamic = 'force-dynamic'
6+
47
export default async function SignupPage() {
58
const { githubAvailable, googleAvailable, isProduction } = await getOAuthProviderStatus()
69

apps/sim/app/(auth)/signup/signup-form.tsx

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ function SignupFormContent({
5757
const [showValidationError, setShowValidationError] = useState(false)
5858
const [email, setEmail] = useState('')
5959
const [waitlistToken, setWaitlistToken] = useState('')
60+
const [redirectUrl, setRedirectUrl] = useState('')
61+
const [isInviteFlow, setIsInviteFlow] = useState(false)
6062

6163
useEffect(() => {
6264
setMounted(true)
@@ -72,6 +74,23 @@ function SignupFormContent({
7274
// Verify the token and get the email
7375
verifyWaitlistToken(tokenParam)
7476
}
77+
78+
// Handle redirection for invitation flow
79+
const redirectParam = searchParams.get('redirect')
80+
if (redirectParam) {
81+
setRedirectUrl(redirectParam)
82+
83+
// Check if this is part of an invitation flow
84+
if (redirectParam.startsWith('/invite/')) {
85+
setIsInviteFlow(true)
86+
}
87+
}
88+
89+
// Explicitly check for invite_flow parameter
90+
const inviteFlowParam = searchParams.get('invite_flow')
91+
if (inviteFlowParam === 'true') {
92+
setIsInviteFlow(true)
93+
}
7594
}, [searchParams])
7695

7796
// Verify waitlist token and pre-fill email
@@ -207,9 +226,22 @@ function SignupFormContent({
207226

208227
if (typeof window !== 'undefined') {
209228
sessionStorage.setItem('verificationEmail', emailValue)
229+
230+
// If this is an invitation flow, store that information for after verification
231+
if (isInviteFlow && redirectUrl) {
232+
sessionStorage.setItem('inviteRedirectUrl', redirectUrl)
233+
sessionStorage.setItem('isInviteFlow', 'true')
234+
}
210235
}
211236

212-
router.push(`/verify?fromSignup=true`)
237+
// If verification is required, go to verify page with proper redirect
238+
if (isInviteFlow && redirectUrl) {
239+
router.push(
240+
`/verify?fromSignup=true&redirectAfter=${encodeURIComponent(redirectUrl)}&invite_flow=true`
241+
)
242+
} else {
243+
router.push(`/verify?fromSignup=true`)
244+
}
213245
} catch (err: any) {
214246
console.error('Uncaught signup error:', err)
215247
} finally {

apps/sim/app/(auth)/verify/page.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import { isProd } from '@/lib/environment'
22
import { getBaseUrl } from '@/lib/urls/utils'
33
import { VerifyContent } from './verify-content'
44

5+
// Force dynamic rendering to avoid prerender errors with search params
6+
export const dynamic = 'force-dynamic'
7+
58
export default function VerifyPage() {
69
const baseUrl = getBaseUrl()
710

apps/sim/app/(auth)/verify/use-verification.ts

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ export function useVerification({
4242
const [isSendingInitialOtp, setIsSendingInitialOtp] = useState(false)
4343
const [isInvalidOtp, setIsInvalidOtp] = useState(false)
4444
const [errorMessage, setErrorMessage] = useState('')
45+
const [redirectUrl, setRedirectUrl] = useState<string | null>(null)
46+
const [isInviteFlow, setIsInviteFlow] = useState(false)
4547

4648
// Debug notification store
4749
useEffect(() => {
@@ -50,11 +52,35 @@ export function useVerification({
5052

5153
useEffect(() => {
5254
if (typeof window !== 'undefined') {
55+
// Get stored email
5356
const storedEmail = sessionStorage.getItem('verificationEmail')
5457
if (storedEmail) {
5558
setEmail(storedEmail)
56-
return
5759
}
60+
61+
// Check for redirect information
62+
const storedRedirectUrl = sessionStorage.getItem('inviteRedirectUrl')
63+
if (storedRedirectUrl) {
64+
setRedirectUrl(storedRedirectUrl)
65+
}
66+
67+
// Check if this is an invite flow
68+
const storedIsInviteFlow = sessionStorage.getItem('isInviteFlow')
69+
if (storedIsInviteFlow === 'true') {
70+
setIsInviteFlow(true)
71+
}
72+
}
73+
74+
// Also check URL parameters for redirect information
75+
const redirectParam = searchParams.get('redirectAfter')
76+
if (redirectParam) {
77+
setRedirectUrl(redirectParam)
78+
}
79+
80+
// Check for invite_flow parameter
81+
const inviteFlowParam = searchParams.get('invite_flow')
82+
if (inviteFlowParam === 'true') {
83+
setIsInviteFlow(true)
5884
}
5985
}, [searchParams])
6086

@@ -104,10 +130,24 @@ export function useVerification({
104130
// Clear email from sessionStorage after successful verification
105131
if (typeof window !== 'undefined') {
106132
sessionStorage.removeItem('verificationEmail')
133+
134+
// Also clear invite-related items
135+
if (isInviteFlow) {
136+
sessionStorage.removeItem('inviteRedirectUrl')
137+
sessionStorage.removeItem('isInviteFlow')
138+
}
107139
}
108140

109-
// Redirect to dashboard after a short delay
110-
setTimeout(() => router.push('/w'), 2000)
141+
// Redirect to proper page after a short delay
142+
setTimeout(() => {
143+
if (isInviteFlow && redirectUrl) {
144+
// For invitation flow, redirect to the invitation page
145+
router.push(redirectUrl)
146+
} else {
147+
// Default redirect to dashboard
148+
router.push('/w')
149+
}
150+
}, 2000)
111151
} else {
112152
logger.info('Setting invalid OTP state - API error response')
113153
const message = 'Invalid verification code. Please check and try again.'

apps/sim/app/api/workflows/[id]/variables/route.ts

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { NextRequest, NextResponse } from 'next/server'
2-
import { eq } from 'drizzle-orm'
2+
import { and, eq } from 'drizzle-orm'
33
import { z } from 'zod'
44
import { getSession } from '@/lib/auth'
55
import { createLogger } from '@/lib/logs/console-logger'
66
import { Variable } from '@/stores/panel/variables/types'
77
import { db } from '@/db'
8-
import { workflow } from '@/db/schema'
8+
import { workflow, workspaceMember } from '@/db/schema'
99

1010
const logger = createLogger('WorkflowVariablesAPI')
1111

@@ -33,7 +33,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
3333
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
3434
}
3535

36-
// Check if the workflow belongs to the user
36+
// Get the workflow record
3737
const workflowRecord = await db
3838
.select()
3939
.from(workflow)
@@ -45,9 +45,31 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
4545
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
4646
}
4747

48-
if (workflowRecord[0].userId !== session.user.id) {
48+
const workflowData = workflowRecord[0]
49+
const workspaceId = workflowData.workspaceId
50+
51+
// Check authorization - either the user owns the workflow or is a member of the workspace
52+
let isAuthorized = workflowData.userId === session.user.id
53+
54+
// If not authorized by ownership and the workflow belongs to a workspace, check workspace membership
55+
if (!isAuthorized && workspaceId) {
56+
const membership = await db
57+
.select()
58+
.from(workspaceMember)
59+
.where(
60+
and(
61+
eq(workspaceMember.workspaceId, workspaceId),
62+
eq(workspaceMember.userId, session.user.id)
63+
)
64+
)
65+
.limit(1)
66+
67+
isAuthorized = membership.length > 0
68+
}
69+
70+
if (!isAuthorized) {
4971
logger.warn(
50-
`[${requestId}] User ${session.user.id} attempted to update variables for workflow ${workflowId} owned by ${workflowRecord[0].userId}`
72+
`[${requestId}] User ${session.user.id} attempted to update variables for workflow ${workflowId} without permission`
5173
)
5274
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
5375
}
@@ -115,7 +137,7 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
115137
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
116138
}
117139

118-
// Check if the workflow belongs to the user
140+
// Get the workflow record
119141
const workflowRecord = await db
120142
.select()
121143
.from(workflow)
@@ -127,15 +149,37 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
127149
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
128150
}
129151

130-
if (workflowRecord[0].userId !== session.user.id) {
152+
const workflowData = workflowRecord[0]
153+
const workspaceId = workflowData.workspaceId
154+
155+
// Check authorization - either the user owns the workflow or is a member of the workspace
156+
let isAuthorized = workflowData.userId === session.user.id
157+
158+
// If not authorized by ownership and the workflow belongs to a workspace, check workspace membership
159+
if (!isAuthorized && workspaceId) {
160+
const membership = await db
161+
.select()
162+
.from(workspaceMember)
163+
.where(
164+
and(
165+
eq(workspaceMember.workspaceId, workspaceId),
166+
eq(workspaceMember.userId, session.user.id)
167+
)
168+
)
169+
.limit(1)
170+
171+
isAuthorized = membership.length > 0
172+
}
173+
174+
if (!isAuthorized) {
131175
logger.warn(
132-
`[${requestId}] User ${session.user.id} attempted to access variables for workflow ${workflowId} owned by ${workflowRecord[0].userId}`
176+
`[${requestId}] User ${session.user.id} attempted to access variables for workflow ${workflowId} without permission`
133177
)
134178
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
135179
}
136180

137181
// Return variables if they exist
138-
const variables = (workflowRecord[0].variables as Record<string, Variable>) || {}
182+
const variables = (workflowData.variables as Record<string, Variable>) || {}
139183

140184
// Add cache headers to prevent frequent reloading
141185
const headers = new Headers({

0 commit comments

Comments
 (0)