Skip to content

Commit b6436b0

Browse files
committed
feat: accept org invitation flow (#3083)
1 parent bfeba09 commit b6436b0

File tree

3 files changed

+249
-0
lines changed

3 files changed

+249
-0
lines changed

frontend/src/routeTree.gen.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { Route as JoinRouteImport } from './routes/join'
1616
import { Route as ContextRouteImport } from './routes/_context'
1717
import { Route as ContextIndexRouteImport } from './routes/_context/index'
1818
import { Route as OnboardingChooseOrganizationRouteImport } from './routes/onboarding/choose-organization'
19+
import { Route as OnboardingAcceptInvitationRouteImport } from './routes/onboarding/accept-invitation'
1920
import { Route as ContextEngineRouteImport } from './routes/_context/_engine'
2021
import { Route as ContextCloudRouteImport } from './routes/_context/_cloud'
2122
import { Route as ContextEngineNsNamespaceRouteImport } from './routes/_context/_engine/ns.$namespace'
@@ -65,6 +66,12 @@ const OnboardingChooseOrganizationRoute =
6566
path: '/choose-organization',
6667
getParentRoute: () => OnboardingRoute,
6768
} as any)
69+
const OnboardingAcceptInvitationRoute =
70+
OnboardingAcceptInvitationRouteImport.update({
71+
id: '/accept-invitation',
72+
path: '/accept-invitation',
73+
getParentRoute: () => OnboardingRoute,
74+
} as any)
6875
const ContextEngineRoute = ContextEngineRouteImport.update({
6976
id: '/_engine',
7077
getParentRoute: () => ContextRoute,
@@ -151,6 +158,7 @@ export interface FileRoutesByFullPath {
151158
'/login': typeof LoginRoute
152159
'/onboarding': typeof OnboardingRouteWithChildren
153160
'/sso-callback': typeof SsoCallbackRoute
161+
'/onboarding/accept-invitation': typeof OnboardingAcceptInvitationRoute
154162
'/onboarding/choose-organization': typeof OnboardingChooseOrganizationRoute
155163
'/': typeof ContextIndexRoute
156164
'/orgs/$organization': typeof ContextCloudOrgsOrganizationRouteWithChildren
@@ -170,6 +178,7 @@ export interface FileRoutesByTo {
170178
'/login': typeof LoginRoute
171179
'/onboarding': typeof OnboardingRouteWithChildren
172180
'/sso-callback': typeof SsoCallbackRoute
181+
'/onboarding/accept-invitation': typeof OnboardingAcceptInvitationRoute
173182
'/onboarding/choose-organization': typeof OnboardingChooseOrganizationRoute
174183
'/': typeof ContextIndexRoute
175184
'/ns/$namespace/connect': typeof ContextEngineNsNamespaceConnectRoute
@@ -189,6 +198,7 @@ export interface FileRoutesById {
189198
'/sso-callback': typeof SsoCallbackRoute
190199
'/_context/_cloud': typeof ContextCloudRouteWithChildren
191200
'/_context/_engine': typeof ContextEngineRouteWithChildren
201+
'/onboarding/accept-invitation': typeof OnboardingAcceptInvitationRoute
192202
'/onboarding/choose-organization': typeof OnboardingChooseOrganizationRoute
193203
'/_context/': typeof ContextIndexRoute
194204
'/_context/_cloud/orgs/$organization': typeof ContextCloudOrgsOrganizationRouteWithChildren
@@ -210,6 +220,7 @@ export interface FileRouteTypes {
210220
| '/login'
211221
| '/onboarding'
212222
| '/sso-callback'
223+
| '/onboarding/accept-invitation'
213224
| '/onboarding/choose-organization'
214225
| '/'
215226
| '/orgs/$organization'
@@ -229,6 +240,7 @@ export interface FileRouteTypes {
229240
| '/login'
230241
| '/onboarding'
231242
| '/sso-callback'
243+
| '/onboarding/accept-invitation'
232244
| '/onboarding/choose-organization'
233245
| '/'
234246
| '/ns/$namespace/connect'
@@ -247,6 +259,7 @@ export interface FileRouteTypes {
247259
| '/sso-callback'
248260
| '/_context/_cloud'
249261
| '/_context/_engine'
262+
| '/onboarding/accept-invitation'
250263
| '/onboarding/choose-organization'
251264
| '/_context/'
252265
| '/_context/_cloud/orgs/$organization'
@@ -321,6 +334,13 @@ declare module '@tanstack/react-router' {
321334
preLoaderRoute: typeof OnboardingChooseOrganizationRouteImport
322335
parentRoute: typeof OnboardingRoute
323336
}
337+
'/onboarding/accept-invitation': {
338+
id: '/onboarding/accept-invitation'
339+
path: '/accept-invitation'
340+
fullPath: '/onboarding/accept-invitation'
341+
preLoaderRoute: typeof OnboardingAcceptInvitationRouteImport
342+
parentRoute: typeof OnboardingRoute
343+
}
324344
'/_context/_engine': {
325345
id: '/_context/_engine'
326346
path: ''
@@ -529,10 +549,12 @@ const ContextRouteWithChildren =
529549
ContextRoute._addFileChildren(ContextRouteChildren)
530550

531551
interface OnboardingRouteChildren {
552+
OnboardingAcceptInvitationRoute: typeof OnboardingAcceptInvitationRoute
532553
OnboardingChooseOrganizationRoute: typeof OnboardingChooseOrganizationRoute
533554
}
534555

535556
const OnboardingRouteChildren: OnboardingRouteChildren = {
557+
OnboardingAcceptInvitationRoute: OnboardingAcceptInvitationRoute,
536558
OnboardingChooseOrganizationRoute: OnboardingChooseOrganizationRoute,
537559
}
538560

frontend/src/routes/_context.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ const searchSchema = z
3333
n: z.array(z.string()).optional(),
3434
u: z.string().optional(),
3535
t: z.string().optional(),
36+
// clerk related
37+
__clerk_ticket: z.string().optional(),
38+
__clerk_status: z.string().optional(),
3639
})
3740
.and(z.record(z.string(), z.any()));
3841

@@ -67,6 +70,17 @@ export const Route = createFileRoute("/_context")({
6770
return await match(route.context)
6871
.with({ __type: "cloud" }, () => async () => {
6972
await waitForClerk(route.context.clerk);
73+
74+
if (
75+
route.location.search.__clerk_ticket &&
76+
route.location.search.__clerk_status
77+
) {
78+
throw redirect({
79+
to: "/onboarding/accept-invitation",
80+
search: { ...route.location.search },
81+
});
82+
}
83+
7084
if (!route.context.clerk.user) {
7185
throw redirect({
7286
to: "/login",
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import { isClerkAPIResponseError } from "@clerk/clerk-js";
2+
import { useOrganization, useSignIn, useSignUp } from "@clerk/clerk-react";
3+
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
4+
import { useEffect, useState } from "react";
5+
import { useIsMounted } from "usehooks-ts";
6+
import * as OrgSignUpForm from "@/app/forms/org-sign-up-form";
7+
import { Logo } from "@/app/logo";
8+
import {
9+
Button,
10+
Card,
11+
CardContent,
12+
CardDescription,
13+
CardFooter,
14+
CardHeader,
15+
CardTitle,
16+
toast,
17+
} from "@/components";
18+
19+
export const Route = createFileRoute("/onboarding/accept-invitation")({
20+
component: RouteComponent,
21+
});
22+
23+
function RouteComponent() {
24+
const search = Route.useSearch();
25+
26+
if (search.__clerk_status === "sign_up") {
27+
// display sign up flow
28+
return (
29+
<div className="flex min-h-screen flex-col items-center justify-center bg-background py-4">
30+
<div className="flex flex-col items-center gap-6">
31+
<Logo className="h-10 mb-4" />
32+
<OrgSignUpFlow ticket={search.__clerk_ticket} />
33+
</div>
34+
</div>
35+
);
36+
}
37+
38+
if (search.__clerk_status === "sign_in") {
39+
// complete sign in flow
40+
return (
41+
<div className="flex min-h-screen flex-col items-center justify-center bg-background py-4">
42+
<div className="flex flex-col items-center gap-6">
43+
<Logo className="h-10 mb-4" />
44+
<OrgSignInFlow ticket={search.__clerk_ticket} />
45+
</div>
46+
</div>
47+
);
48+
}
49+
50+
if (search.__clerk_status === "complete") {
51+
// if we get here, the user is already signed in
52+
return (
53+
<div className="flex min-h-screen flex-col items-center justify-center bg-background py-4">
54+
<div className="flex flex-col items-center gap-6">
55+
<Logo className="h-10 mb-4" />
56+
<Card className="w-full sm:w-96">
57+
<CardHeader>
58+
<CardTitle>Invitation Accepted</CardTitle>
59+
<CardDescription>
60+
You have successfully accepted the invitation.
61+
You can now proceed to the dashboard.
62+
</CardDescription>
63+
</CardHeader>
64+
<CardFooter>
65+
<Button asChild>
66+
<Link to="/">Go Home</Link>
67+
</Button>
68+
</CardFooter>
69+
</Card>
70+
</div>
71+
</div>
72+
);
73+
}
74+
75+
return (
76+
<div className="flex min-h-screen flex-col items-center justify-center bg-background py-4">
77+
<div className="flex flex-col items-center gap-6">
78+
<Logo className="h-10 mb-4" />
79+
<Card className="w-full sm:w-96">
80+
<CardHeader>
81+
<CardTitle>Invalid Invitation</CardTitle>
82+
<CardDescription>
83+
The invitation link is invalid. Please check the
84+
link or contact support.
85+
</CardDescription>
86+
</CardHeader>
87+
</Card>
88+
</div>
89+
</div>
90+
);
91+
}
92+
93+
function OrgSignUpFlow({ ticket }: { ticket: string }) {
94+
const { signUp, setActive: setActiveSignUp } = useSignUp();
95+
const navigate = useNavigate();
96+
97+
return (
98+
<OrgSignUpForm.Form
99+
defaultValues={{ password: "" }}
100+
onSubmit={async ({ password }, form) => {
101+
try {
102+
const signUpAttempt = await signUp?.create({
103+
strategy: "ticket",
104+
ticket,
105+
password,
106+
});
107+
108+
if (signUpAttempt?.status === "complete") {
109+
await setActiveSignUp?.({
110+
session: signUpAttempt.createdSessionId,
111+
});
112+
await navigate({ to: "/" });
113+
} else {
114+
console.error(
115+
"Sign up attempt not complete",
116+
signUpAttempt,
117+
);
118+
toast.error(
119+
"An error occurred during sign up. Please try again.",
120+
);
121+
}
122+
} catch (e) {
123+
if (isClerkAPIResponseError(e)) {
124+
for (const error of e.errors) {
125+
form.setError(
126+
(error.meta?.paramName || "root") as "root",
127+
{
128+
message: error.longMessage,
129+
},
130+
);
131+
}
132+
} else {
133+
toast.error(
134+
"An unknown error occurred. Please try again.",
135+
);
136+
}
137+
}
138+
}}
139+
>
140+
<Card className="w-full sm:w-96">
141+
<CardHeader>
142+
<CardTitle>Welcome!</CardTitle>
143+
<CardDescription>
144+
Please set a password for your new account.
145+
</CardDescription>
146+
</CardHeader>
147+
<CardContent>
148+
<div>
149+
<OrgSignUpForm.Password className="mb-4" />
150+
</div>
151+
</CardContent>
152+
<CardFooter>
153+
<OrgSignUpForm.Submit className="w-full">
154+
Continue
155+
</OrgSignUpForm.Submit>
156+
</CardFooter>
157+
</Card>
158+
</OrgSignUpForm.Form>
159+
);
160+
}
161+
162+
function OrgSignInFlow({ ticket }: { ticket: string }) {
163+
const { organization } = useOrganization();
164+
const { signIn, setActive: setActiveSignIn } = useSignIn();
165+
const isMounted = useIsMounted();
166+
167+
const [error, setError] = useState<string | null>(null);
168+
169+
useEffect(() => {
170+
async function signInWithTicket() {
171+
const signInAttempt = await signIn?.create({
172+
strategy: "ticket",
173+
ticket,
174+
});
175+
176+
// If the sign-in was successful, set the session to active
177+
if (signInAttempt?.status === "complete") {
178+
await setActiveSignIn?.({
179+
session: signInAttempt?.createdSessionId,
180+
});
181+
} else {
182+
// If the sign-in attempt is not complete, check why.
183+
// User may need to complete further steps.
184+
console.error(JSON.stringify(signInAttempt, null, 2));
185+
}
186+
}
187+
188+
signInWithTicket().catch((e) => {
189+
if (isClerkAPIResponseError(e)) {
190+
setError(e.message);
191+
} else {
192+
setError("An unknown error occurred. Please try again.");
193+
}
194+
});
195+
}, [isMounted]);
196+
197+
return (
198+
<Card className="w-full sm:w-96">
199+
<CardHeader>
200+
<CardTitle>Welcome back!</CardTitle>
201+
<CardDescription>
202+
You are signing in to {organization?.name || "your account"}
203+
.
204+
</CardDescription>
205+
</CardHeader>
206+
{error && (
207+
<CardContent>
208+
<div className="text-destructive">{error}</div>
209+
</CardContent>
210+
)}
211+
</Card>
212+
);
213+
}

0 commit comments

Comments
 (0)