Skip to content

Commit 6ef482e

Browse files
committed
feat(invite-workspace): users can now join workspaces; protected delete from members
1 parent b979c9e commit 6ef482e

File tree

13 files changed

+493
-179
lines changed

13 files changed

+493
-179
lines changed

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

Lines changed: 18 additions & 5 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)
3940
const [, setMounted] = useState(false)
4041
const { addNotification } = useNotificationStore()
4142
const [showPassword, setShowPassword] = useState(false)
4243
const [password, setPassword] = useState('')
4344

45+
// Extract callbackUrl from the URL for both form and OAuth providers
46+
const callbackUrl = searchParams?.get('callbackUrl') || '/w'
47+
const isInviteFlow = searchParams?.get('invite_flow') === 'true'
48+
4449
// Forgot password states
4550
const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false)
4651
const [forgotPasswordEmail, setForgotPasswordEmail] = useState('')
@@ -62,11 +67,12 @@ export default function LoginPage({
6267
const email = formData.get('email') as string
6368

6469
try {
70+
// Use the extracted callbackUrl instead of hardcoded value
6571
const result = await client.signIn.email(
6672
{
6773
email,
6874
password,
69-
callbackURL: '/w',
75+
callbackURL: callbackUrl,
7076
},
7177
{
7278
onError: (ctx) => {
@@ -214,14 +220,18 @@ export default function LoginPage({
214220
<Card className="w-full">
215221
<CardHeader>
216222
<CardTitle>Welcome back</CardTitle>
217-
<CardDescription>Enter your credentials to access your account</CardDescription>
223+
<CardDescription>
224+
{isInviteFlow
225+
? 'Sign in to continue to the invitation'
226+
: 'Enter your credentials to access your account'}
227+
</CardDescription>
218228
</CardHeader>
219229
<CardContent>
220230
<div className="grid gap-6">
221231
<SocialLoginButtons
222232
githubAvailable={githubAvailable}
223233
googleAvailable={googleAvailable}
224-
callbackURL="/w"
234+
callbackURL={callbackUrl}
225235
isProduction={isProduction}
226236
/>
227237
<div className="relative">
@@ -289,7 +299,10 @@ export default function LoginPage({
289299
<CardFooter>
290300
<p className="text-sm text-gray-500 text-center w-full">
291301
Don't have an account?{' '}
292-
<Link href="/signup" className="text-primary hover:underline">
302+
<Link
303+
href={`/signup${searchParams ? `?${searchParams.toString()}` : ''}`}
304+
className="text-primary hover:underline"
305+
>
293306
Sign up
294307
</Link>
295308
</p>

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/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/workspaces/invitations/accept/route.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@ export async function GET(req: NextRequest) {
1717
const session = await getSession()
1818

1919
if (!session?.user?.id) {
20-
// Store the token in a query param and redirect to login page
21-
return NextResponse.redirect(new URL(`/auth/signin?callbackUrl=${encodeURIComponent(`/api/workspaces/invitations/accept?token=${token}`)}`, process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'))
20+
// No need to encode API URL as callback, just redirect to invite page
21+
// The middleware will handle proper login flow and return to invite page
22+
return NextResponse.redirect(new URL(`/invite/${token}?token=${token}`, process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'))
2223
}
2324

2425
try {
@@ -43,9 +44,25 @@ export async function GET(req: NextRequest) {
4344
return NextResponse.redirect(new URL('/invite/invite-error?reason=already-processed', process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'))
4445
}
4546

47+
// Get the user's email from the session
48+
const userEmail = session.user.email.toLowerCase()
49+
const invitationEmail = invitation.email.toLowerCase()
50+
4651
// Check if invitation email matches the logged-in user
47-
if (invitation.email.toLowerCase() !== session.user.email.toLowerCase()) {
48-
return NextResponse.redirect(new URL('/invite/invite-error?reason=email-mismatch', process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'))
52+
// For new users who just signed up, we'll be more flexible by comparing domain parts
53+
const isExactMatch = userEmail === invitationEmail
54+
const isPartialMatch = userEmail.split('@')[1] === invitationEmail.split('@')[1] &&
55+
userEmail.split('@')[0].includes(invitationEmail.split('@')[0].substring(0, 3))
56+
57+
if (!isExactMatch && !isPartialMatch) {
58+
// Get user info to include in the error message
59+
const userData = await db
60+
.select()
61+
.from(user)
62+
.where(eq(user.id, session.user.id))
63+
.then(rows => rows[0])
64+
65+
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'))
4966
}
5067

5168
// Get the workspace details
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { and, eq } from 'drizzle-orm'
2+
import { NextRequest, NextResponse } from 'next/server'
3+
import { getSession } from '@/lib/auth'
4+
import { db } from '@/db'
5+
import { workspace, workspaceInvitation } from '@/db/schema'
6+
7+
// GET /api/workspaces/invitations/details - Get invitation details by token
8+
export async function GET(req: NextRequest) {
9+
const token = req.nextUrl.searchParams.get('token')
10+
11+
if (!token) {
12+
return NextResponse.json({ error: 'Token is required' }, { status: 400 })
13+
}
14+
15+
const session = await getSession()
16+
17+
if (!session?.user?.id) {
18+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
19+
}
20+
21+
try {
22+
// Find the invitation by token
23+
const invitation = await db
24+
.select()
25+
.from(workspaceInvitation)
26+
.where(eq(workspaceInvitation.token, token))
27+
.then(rows => rows[0])
28+
29+
if (!invitation) {
30+
return NextResponse.json({ error: 'Invitation not found or has expired' }, { status: 404 })
31+
}
32+
33+
// Check if invitation has expired
34+
if (new Date() > new Date(invitation.expiresAt)) {
35+
return NextResponse.json({ error: 'Invitation has expired' }, { status: 400 })
36+
}
37+
38+
// Get workspace details
39+
const workspaceDetails = await db
40+
.select()
41+
.from(workspace)
42+
.where(eq(workspace.id, invitation.workspaceId))
43+
.then(rows => rows[0])
44+
45+
if (!workspaceDetails) {
46+
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
47+
}
48+
49+
// Return the invitation with workspace name
50+
return NextResponse.json({
51+
...invitation,
52+
workspaceName: workspaceDetails.name
53+
})
54+
} catch (error) {
55+
console.error('Error fetching workspace invitation:', error)
56+
return NextResponse.json({ error: 'Failed to fetch invitation details' }, { status: 500 })
57+
}
58+
}

apps/sim/app/api/workspaces/invitations/route.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,8 @@ async function sendInvitationEmail({
198198
}) {
199199
try {
200200
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'
201-
const invitationLink = `${baseUrl}/api/workspaces/invitations/accept?token=${token}`
201+
// Always use the client-side invite route with token parameter
202+
const invitationLink = `${baseUrl}/invite/${token}?token=${token}`
202203

203204
const emailHtml = await render(
204205
WorkspaceInvitationEmail({
@@ -211,7 +212,7 @@ async function sendInvitationEmail({
211212
await resend.emails.send({
212213
from: process.env.RESEND_FROM_EMAIL || '[email protected]',
213214
to,
214-
subject: `You've been invited to join "${workspaceName}" on SimStudio`,
215+
subject: `You've been invited to join "${workspaceName}" on Sim Studio`,
215216
html: emailHtml,
216217
})
217218

0 commit comments

Comments
 (0)