Skip to content

Commit 43aa999

Browse files
committed
organization invitation signup flow
1 parent 71465f4 commit 43aa999

File tree

13 files changed

+222
-26
lines changed

13 files changed

+222
-26
lines changed

src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/(specific-organization-pages)/settings/EditOrganizationForm.tsx

+3-3
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, For
66
import { Input } from "@/components/ui/input";
77
import { updateOrganizationInfo } from "@/data/user/organizations";
88
import { useSAToastMutation } from "@/hooks/useSAToastMutation";
9-
import { generateSlug } from "@/lib/utils";
9+
import { generateOrganizationSlug } from "@/lib/utils";
1010
import { zodResolver } from "@hookform/resolvers/zod";
1111
import { motion } from "framer-motion";
1212
import { Loader2 } from "lucide-react";
@@ -87,7 +87,7 @@ export function EditOrganizationForm({
8787
{...field}
8888
onChange={(e) => {
8989
field.onChange(e);
90-
form.setValue("organizationSlug", generateSlug(e.target.value), { shouldValidate: true });
90+
form.setValue("organizationSlug", generateOrganizationSlug(e.target.value), { shouldValidate: true });
9191
}}
9292
/>
9393
</FormControl>
@@ -116,4 +116,4 @@ export function EditOrganizationForm({
116116
</Card>
117117
</motion.div>
118118
);
119-
}
119+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
2+
import { useToast } from "@/components/ui/use-toast";
3+
import { getPendingInvitationsOfUser } from "@/data/user/invitation";
4+
import { autoAcceptFirstInvitation } from "@/data/user/user";
5+
import { useMutation, useQuery } from "@tanstack/react-query";
6+
import { useEffect } from "react";
7+
8+
type AcceptInvitationsProps = {
9+
onSuccess: () => void;
10+
};
11+
12+
function Spinner() {
13+
return <div className="w-4 h-4 border-t-2 border-b-2 border-gray-900 rounded-full animate-spin"></div>
14+
}
15+
16+
export function AcceptInvitations({ onSuccess }: AcceptInvitationsProps) {
17+
const { toast } = useToast();
18+
19+
const { data: invitations, isLoading, error } = useQuery({
20+
queryKey: ["pendingInvitations"],
21+
queryFn: getPendingInvitationsOfUser,
22+
});
23+
24+
25+
26+
const acceptInvitationMutation = useMutation({
27+
mutationFn: autoAcceptFirstInvitation,
28+
onSuccess: () => {
29+
toast({ title: "Organization setup complete!", description: "You've joined the organization." });
30+
onSuccess();
31+
},
32+
onError: (error) => {
33+
const errorMessage = String(error);
34+
toast({ title: "Failed to accept invitation", description: errorMessage, variant: "destructive" });
35+
},
36+
});
37+
38+
useEffect(() => {
39+
acceptInvitationMutation.mutate();
40+
}, []);
41+
42+
43+
44+
if (isLoading) {
45+
return (
46+
<>
47+
<CardHeader>
48+
<CardTitle>Checking Invitations</CardTitle>
49+
<CardDescription>Please wait while we process your invitations.</CardDescription>
50+
</CardHeader>
51+
<CardContent className="flex justify-center">
52+
<Spinner />
53+
</CardContent>
54+
</>
55+
);
56+
}
57+
58+
if (error) {
59+
const errorMessage = String(error);
60+
return (
61+
<>
62+
<CardHeader>
63+
<CardTitle>Error</CardTitle>
64+
<CardDescription>An error occurred while processing invitations.</CardDescription>
65+
</CardHeader>
66+
<CardContent>
67+
<p className="text-destructive">{errorMessage}</p>
68+
</CardContent>
69+
</>
70+
);
71+
}
72+
73+
return (
74+
<>
75+
<CardHeader>
76+
<CardTitle>Processing Invitations</CardTitle>
77+
<CardDescription>
78+
{invitations && invitations.length > 0
79+
? "Accepting your invitations..."
80+
: "No pending invitations found."}
81+
</CardDescription>
82+
</CardHeader>
83+
<CardContent className="flex justify-center">
84+
<Spinner />
85+
</CardContent>
86+
</>
87+
);
88+
}

src/app/(dynamic-pages)/(authenticated-pages)/onboarding/OnboardingFlow.tsx

+20-7
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,15 @@ import { useCallback, useEffect, useMemo, useState } from "react";
66

77
import { Card } from "@/components/ui/card";
88

9+
import { AcceptInvitations } from "./AcceptInvitations";
910
import { OrganizationCreation } from "./OrganizationCreation";
1011
import { ProfileUpdate } from "./ProfileUpdate";
1112
import { TermsAcceptance } from "./TermsAcceptance";
1213

1314
import type { Table } from "@/types";
1415
import type { AuthUserMetadata } from "@/utils/zod-schemas/authUserMetadata";
1516

16-
type FLOW_STATE = "TERMS" | "PROFILE" | "ORGANIZATION" | "COMPLETE";
17+
type FLOW_STATE = "TERMS" | "PROFILE" | "ORGANIZATION" | "JOIN_INVITED_ORG" | "COMPLETE";
1718

1819
type UserOnboardingFlowProps = {
1920
userProfile: Table<"user_profiles">;
@@ -82,6 +83,9 @@ export function UserOnboardingFlow({
8283
{currentStep === "ORGANIZATION" && (
8384
<OrganizationCreation onSuccess={nextStep} />
8485
)}
86+
{currentStep === "JOIN_INVITED_ORG" && (
87+
<AcceptInvitations onSuccess={nextStep} />
88+
)}
8589
</MotionCard>
8690
</AnimatePresence>
8791
);
@@ -92,6 +96,7 @@ function getAllFlowStates(onboardingStatus: AuthUserMetadata): FLOW_STATE[] {
9296
onboardingHasAcceptedTerms,
9397
onboardingHasCompletedProfile,
9498
onboardingHasCreatedOrganization,
99+
isUserCreatedThroughOrgInvitation
95100
} = onboardingStatus;
96101
const flowStates: FLOW_STATE[] = [];
97102

@@ -105,8 +110,13 @@ function getAllFlowStates(onboardingStatus: AuthUserMetadata): FLOW_STATE[] {
105110
flowStates.push("PROFILE");
106111
}
107112
if (!onboardingHasCreatedOrganization) {
108-
flowStates.push("ORGANIZATION");
113+
if (isUserCreatedThroughOrgInvitation) {
114+
flowStates.push("JOIN_INVITED_ORG");
115+
} else {
116+
flowStates.push("ORGANIZATION");
117+
}
109118
}
119+
110120
flowStates.push("COMPLETE");
111121

112122
return flowStates;
@@ -133,12 +143,15 @@ function getInitialFlowState(
133143
return "PROFILE";
134144
}
135145

136-
if (
137-
!onboardingHasCreatedOrganization &&
138-
flowStates.includes("ORGANIZATION")
139-
) {
140-
return "ORGANIZATION";
146+
if (!onboardingHasCreatedOrganization) {
147+
if (flowStates.includes("JOIN_INVITED_ORG")) {
148+
return "JOIN_INVITED_ORG";
149+
} else if (flowStates.includes("ORGANIZATION")) {
150+
return "ORGANIZATION";
151+
}
141152
}
142153

154+
155+
143156
return "COMPLETE";
144157
}

src/app/(dynamic-pages)/(authenticated-pages)/onboarding/OrganizationCreation.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { Input } from "@/components/ui/input";
44
import { Label } from "@/components/ui/label";
55
import { useToast } from "@/components/ui/use-toast";
66
import { createOrganization } from "@/data/user/organizations";
7-
import { generateSlug } from "@/lib/utils";
7+
import { generateOrganizationSlug } from "@/lib/utils";
88
import { CreateOrganizationSchema, createOrganizationSchema } from "@/utils/zod-schemas/organization";
99
import { zodResolver } from "@hookform/resolvers/zod";
1010
import { useMutation } from "@tanstack/react-query";
@@ -55,7 +55,7 @@ export function OrganizationCreation({ onSuccess }: OrganizationCreationProps) {
5555
{...register("organizationTitle")}
5656
placeholder="Enter organization name"
5757
onChange={(e) => {
58-
setValue("organizationSlug", generateSlug(e.target.value), { shouldValidate: true });
58+
setValue("organizationSlug", generateOrganizationSlug(e.target.value), { shouldValidate: true });
5959
setValue("organizationTitle", e.target.value, { shouldValidate: true });
6060
}}
6161
/>

src/app/(dynamic-pages)/(authenticated-pages)/onboarding/complete/page.tsx

-8
This file was deleted.

src/app/(dynamic-pages)/(authenticated-pages)/onboarding/page.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ async function OnboardingFlowWrapper({ userId, userEmail }: { userId: string; us
5454
serverGetLoggedInUser(),
5555
]);
5656
const { userProfile } = onboardingConditions;
57-
console.log(userProfile);
57+
console.log("userProfile", userProfile);
5858
const onboardingStatus = authUserMetadataSchema.parse(user.user_metadata);
5959
console.log(onboardingStatus);
6060
console.log(userEmail);

src/app/(dynamic-pages)/(login-pages)/login/Login.tsx

+7-3
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
signInWithPassword,
1818
signInWithProvider,
1919
} from '@/data/auth/auth';
20-
import { getInitialOrganizationToRedirectTo } from '@/data/user/organizations';
20+
import { getMaybeInitialOrganizationToRedirectTo } from '@/data/user/organizations';
2121
import { useSAToastMutation } from '@/hooks/useSAToastMutation';
2222
import type { AuthProvider } from '@/types';
2323
import { PrefetchKind } from 'next/dist/client/components/router-reducer/router-reducer-types';
@@ -44,12 +44,16 @@ export function Login({
4444
})
4545
})
4646

47-
const initialOrgRedirectMutation = useSAToastMutation(getInitialOrganizationToRedirectTo, {
47+
const initialOrgRedirectMutation = useSAToastMutation(getMaybeInitialOrganizationToRedirectTo, {
4848
loadingMessage: 'Loading your dashboard...',
4949
errorMessage: 'Failed to load dashboard',
5050
successMessage: 'Redirecting to your dashboard...',
5151
onSuccess: (successPayload) => {
52-
router.push(`/org/${successPayload.data}`);
52+
if (successPayload.data) {
53+
router.push(`/org/${successPayload.data}`);
54+
} else {
55+
router.push('/dashboard');
56+
}
5357
},
5458
onError: (errorPayload) => {
5559
console.error(errorPayload);

src/components/CreateOrganizationDialog.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { Input } from "@/components/ui/input";
1313
import { Label } from "@/components/ui/label";
1414
import { createOrganization } from "@/data/user/organizations";
1515
import { useSAToastMutation } from "@/hooks/useSAToastMutation";
16-
import { generateSlug } from "@/lib/utils";
16+
import { generateOrganizationSlug } from "@/lib/utils";
1717
import { CreateOrganizationSchema, createOrganizationSchema } from "@/utils/zod-schemas/organization";
1818
import { zodResolver } from "@hookform/resolvers/zod";
1919
import { Network, Plus } from "lucide-react";
@@ -124,7 +124,7 @@ export function CreateOrganizationDialog({
124124
id="name"
125125
type="text"
126126
onChange={(e) => {
127-
setValue("organizationSlug", generateSlug(e.target.value), { shouldValidate: true });
127+
setValue("organizationSlug", generateOrganizationSlug(e.target.value), { shouldValidate: true });
128128
setValue("organizationTitle", e.target.value, { shouldValidate: true });
129129
}}
130130
placeholder="Organization Name"

src/data/user/invitation.tsx

+6
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ async function setupInviteeUserDetails(email: string): Promise<{
4545
if (!inviteeUserId) {
4646
const { data, error } = await supabaseAdminClient.auth.admin.createUser({
4747
email: email,
48+
user_metadata: {
49+
isUserCreatedThroughOrgInvitation: true
50+
}
4851
});
4952
if (error) {
5053
throw error;
@@ -221,6 +224,7 @@ export async function createInvitationHandler({
221224
return { status: 'success', data: invitationResponse.data };
222225
}
223226

227+
224228
export async function acceptInvitationAction(
225229
invitationId: string,
226230
): Promise<SAPayload<string>> {
@@ -314,6 +318,8 @@ export async function getPendingInvitationsOfUser() {
314318
return Promise.all(invitationListPromise);
315319
}
316320

321+
322+
317323
export const getInvitationById = async (invitationId: string) => {
318324
const supabaseClient = createSupabaseUserServerComponentClient();
319325

src/data/user/organizations.ts

+11
Original file line numberDiff line numberDiff line change
@@ -620,3 +620,14 @@ export async function getInitialOrganizationToRedirectTo(): Promise<
620620
status: 'success',
621621
};
622622
}
623+
624+
export async function getMaybeInitialOrganizationToRedirectTo(): Promise<SAPayload<string | null>> {
625+
const initialOrganization = await getInitialOrganizationToRedirectTo();
626+
if (initialOrganization.status === 'error') {
627+
return {
628+
data: null,
629+
status: 'success',
630+
};
631+
}
632+
return initialOrganization;
633+
}

src/data/user/user.tsx

+57
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"use server";
22
import { PRODUCT_NAME } from "@/constants";
3+
import { generateOrganizationSlug } from "@/lib/utils";
34
import { createSupabaseUserServerActionClient } from "@/supabase-clients/user/createSupabaseUserServerActionClient";
45
import { createSupabaseUserServerComponentClient } from "@/supabase-clients/user/createSupabaseUserServerComponentClient";
56
import type { SAPayload, SupabaseFileUploadOptions, Table } from "@/types";
@@ -12,6 +13,8 @@ import ConfirmAccountDeletionEmail from "emails/account-deletion-request";
1213
import { revalidatePath } from "next/cache";
1314
import slugify from "slugify";
1415
import urlJoin from "url-join";
16+
import { acceptInvitationAction } from "./invitation";
17+
import { createOrganization, setDefaultOrganization } from "./organizations";
1518
import { refreshSessionAction } from "./session";
1619

1720
export async function getIsAppAdmin(): Promise<boolean> {
@@ -238,6 +241,60 @@ export const acceptTermsOfService = async (
238241
};
239242
};
240243

244+
245+
export const autoAcceptFirstInvitation = async () => {
246+
const user = await serverGetLoggedInUser();
247+
const pendingInvitations = await getUserPendingInvitationsById(user.id);
248+
const supabaseClient = createSupabaseUserServerActionClient();
249+
250+
if (pendingInvitations.length > 0) {
251+
const invitation = pendingInvitations[0];
252+
const invitationAcceptanceResponse = await acceptInvitationAction(invitation.id);
253+
if (invitationAcceptanceResponse.status === "error") {
254+
throw invitationAcceptanceResponse.message;
255+
} else if (invitationAcceptanceResponse.status === "success") {
256+
const joinedOrganizationId = invitationAcceptanceResponse.data;
257+
// let's make the joined organization the default one
258+
await setDefaultOrganization(joinedOrganizationId);
259+
}
260+
const userProfile = await getUserProfile(user.id);
261+
const userFullName = userProfile?.full_name ?? `User ${user.email ?? ""}`;
262+
const defaultOrganizationCreationResponse = await createOrganization(userFullName, generateOrganizationSlug(userFullName));
263+
264+
if (defaultOrganizationCreationResponse.status === "error") {
265+
throw defaultOrganizationCreationResponse.message;
266+
}
267+
}
268+
269+
console.log('updating user metadata')
270+
271+
272+
const updateUserMetadataPayload: Partial<AuthUserMetadata> = {
273+
onboardingHasCreatedOrganization: true,
274+
};
275+
276+
const updateUserMetadataResponse = await supabaseClient.auth.updateUser({
277+
data: updateUserMetadataPayload,
278+
});
279+
280+
if (updateUserMetadataResponse.error) {
281+
return {
282+
status: "error",
283+
message: updateUserMetadataResponse.error.message,
284+
};
285+
}
286+
287+
const refreshSessionResponse = await refreshSessionAction();
288+
if (refreshSessionResponse.status === "error") {
289+
return refreshSessionResponse;
290+
}
291+
292+
return {
293+
status: "success",
294+
data: true,
295+
};
296+
}
297+
241298
export async function requestAccountDeletion(): Promise<
242299
SAPayload<Table<"account_delete_tokens">>
243300
> {

0 commit comments

Comments
 (0)