Skip to content

Commit

Permalink
organization invitation signup flow
Browse files Browse the repository at this point in the history
  • Loading branch information
imbhargav5 committed Oct 9, 2024
1 parent 71465f4 commit 43aa999
Show file tree
Hide file tree
Showing 13 changed files with 222 additions and 26 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, For
import { Input } from "@/components/ui/input";
import { updateOrganizationInfo } from "@/data/user/organizations";
import { useSAToastMutation } from "@/hooks/useSAToastMutation";
import { generateSlug } from "@/lib/utils";
import { generateOrganizationSlug } from "@/lib/utils";
import { zodResolver } from "@hookform/resolvers/zod";
import { motion } from "framer-motion";
import { Loader2 } from "lucide-react";
Expand Down Expand Up @@ -87,7 +87,7 @@ export function EditOrganizationForm({
{...field}
onChange={(e) => {
field.onChange(e);
form.setValue("organizationSlug", generateSlug(e.target.value), { shouldValidate: true });
form.setValue("organizationSlug", generateOrganizationSlug(e.target.value), { shouldValidate: true });
}}
/>
</FormControl>
Expand Down Expand Up @@ -116,4 +116,4 @@ export function EditOrganizationForm({
</Card>
</motion.div>
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { useToast } from "@/components/ui/use-toast";
import { getPendingInvitationsOfUser } from "@/data/user/invitation";
import { autoAcceptFirstInvitation } from "@/data/user/user";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useEffect } from "react";

type AcceptInvitationsProps = {
onSuccess: () => void;
};

function Spinner() {
return <div className="w-4 h-4 border-t-2 border-b-2 border-gray-900 rounded-full animate-spin"></div>
}

export function AcceptInvitations({ onSuccess }: AcceptInvitationsProps) {
const { toast } = useToast();

const { data: invitations, isLoading, error } = useQuery({
queryKey: ["pendingInvitations"],
queryFn: getPendingInvitationsOfUser,
});



const acceptInvitationMutation = useMutation({
mutationFn: autoAcceptFirstInvitation,
onSuccess: () => {
toast({ title: "Organization setup complete!", description: "You've joined the organization." });
onSuccess();
},
onError: (error) => {
const errorMessage = String(error);
toast({ title: "Failed to accept invitation", description: errorMessage, variant: "destructive" });
},
});

useEffect(() => {
acceptInvitationMutation.mutate();
}, []);



if (isLoading) {
return (
<>
<CardHeader>
<CardTitle>Checking Invitations</CardTitle>
<CardDescription>Please wait while we process your invitations.</CardDescription>
</CardHeader>
<CardContent className="flex justify-center">
<Spinner />
</CardContent>
</>
);
}

if (error) {
const errorMessage = String(error);
return (
<>
<CardHeader>
<CardTitle>Error</CardTitle>
<CardDescription>An error occurred while processing invitations.</CardDescription>
</CardHeader>
<CardContent>
<p className="text-destructive">{errorMessage}</p>
</CardContent>
</>
);
}

return (
<>
<CardHeader>
<CardTitle>Processing Invitations</CardTitle>
<CardDescription>
{invitations && invitations.length > 0
? "Accepting your invitations..."
: "No pending invitations found."}
</CardDescription>
</CardHeader>
<CardContent className="flex justify-center">
<Spinner />
</CardContent>
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@ import { useCallback, useEffect, useMemo, useState } from "react";

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

import { AcceptInvitations } from "./AcceptInvitations";
import { OrganizationCreation } from "./OrganizationCreation";
import { ProfileUpdate } from "./ProfileUpdate";
import { TermsAcceptance } from "./TermsAcceptance";

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

type FLOW_STATE = "TERMS" | "PROFILE" | "ORGANIZATION" | "COMPLETE";
type FLOW_STATE = "TERMS" | "PROFILE" | "ORGANIZATION" | "JOIN_INVITED_ORG" | "COMPLETE";

type UserOnboardingFlowProps = {
userProfile: Table<"user_profiles">;
Expand Down Expand Up @@ -82,6 +83,9 @@ export function UserOnboardingFlow({
{currentStep === "ORGANIZATION" && (
<OrganizationCreation onSuccess={nextStep} />
)}
{currentStep === "JOIN_INVITED_ORG" && (
<AcceptInvitations onSuccess={nextStep} />
)}
</MotionCard>
</AnimatePresence>
);
Expand All @@ -92,6 +96,7 @@ function getAllFlowStates(onboardingStatus: AuthUserMetadata): FLOW_STATE[] {
onboardingHasAcceptedTerms,
onboardingHasCompletedProfile,
onboardingHasCreatedOrganization,
isUserCreatedThroughOrgInvitation
} = onboardingStatus;
const flowStates: FLOW_STATE[] = [];

Expand All @@ -105,8 +110,13 @@ function getAllFlowStates(onboardingStatus: AuthUserMetadata): FLOW_STATE[] {
flowStates.push("PROFILE");
}
if (!onboardingHasCreatedOrganization) {
flowStates.push("ORGANIZATION");
if (isUserCreatedThroughOrgInvitation) {
flowStates.push("JOIN_INVITED_ORG");
} else {
flowStates.push("ORGANIZATION");
}
}

flowStates.push("COMPLETE");

return flowStates;
Expand All @@ -133,12 +143,15 @@ function getInitialFlowState(
return "PROFILE";
}

if (
!onboardingHasCreatedOrganization &&
flowStates.includes("ORGANIZATION")
) {
return "ORGANIZATION";
if (!onboardingHasCreatedOrganization) {
if (flowStates.includes("JOIN_INVITED_ORG")) {
return "JOIN_INVITED_ORG";
} else if (flowStates.includes("ORGANIZATION")) {
return "ORGANIZATION";
}
}



return "COMPLETE";
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useToast } from "@/components/ui/use-toast";
import { createOrganization } from "@/data/user/organizations";
import { generateSlug } from "@/lib/utils";
import { generateOrganizationSlug } from "@/lib/utils";
import { CreateOrganizationSchema, createOrganizationSchema } from "@/utils/zod-schemas/organization";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
Expand Down Expand Up @@ -55,7 +55,7 @@ export function OrganizationCreation({ onSuccess }: OrganizationCreationProps) {
{...register("organizationTitle")}
placeholder="Enter organization name"
onChange={(e) => {
setValue("organizationSlug", generateSlug(e.target.value), { shouldValidate: true });
setValue("organizationSlug", generateOrganizationSlug(e.target.value), { shouldValidate: true });
setValue("organizationTitle", e.target.value, { shouldValidate: true });
}}
/>
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ async function OnboardingFlowWrapper({ userId, userEmail }: { userId: string; us
serverGetLoggedInUser(),
]);
const { userProfile } = onboardingConditions;
console.log(userProfile);
console.log("userProfile", userProfile);
const onboardingStatus = authUserMetadataSchema.parse(user.user_metadata);
console.log(onboardingStatus);
console.log(userEmail);
Expand Down
10 changes: 7 additions & 3 deletions src/app/(dynamic-pages)/(login-pages)/login/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
signInWithPassword,
signInWithProvider,
} from '@/data/auth/auth';
import { getInitialOrganizationToRedirectTo } from '@/data/user/organizations';
import { getMaybeInitialOrganizationToRedirectTo } from '@/data/user/organizations';
import { useSAToastMutation } from '@/hooks/useSAToastMutation';
import type { AuthProvider } from '@/types';
import { PrefetchKind } from 'next/dist/client/components/router-reducer/router-reducer-types';
Expand All @@ -44,12 +44,16 @@ export function Login({
})
})

const initialOrgRedirectMutation = useSAToastMutation(getInitialOrganizationToRedirectTo, {
const initialOrgRedirectMutation = useSAToastMutation(getMaybeInitialOrganizationToRedirectTo, {
loadingMessage: 'Loading your dashboard...',
errorMessage: 'Failed to load dashboard',
successMessage: 'Redirecting to your dashboard...',
onSuccess: (successPayload) => {
router.push(`/org/${successPayload.data}`);
if (successPayload.data) {
router.push(`/org/${successPayload.data}`);
} else {
router.push('/dashboard');
}
},
onError: (errorPayload) => {
console.error(errorPayload);
Expand Down
4 changes: 2 additions & 2 deletions src/components/CreateOrganizationDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { createOrganization } from "@/data/user/organizations";
import { useSAToastMutation } from "@/hooks/useSAToastMutation";
import { generateSlug } from "@/lib/utils";
import { generateOrganizationSlug } from "@/lib/utils";
import { CreateOrganizationSchema, createOrganizationSchema } from "@/utils/zod-schemas/organization";
import { zodResolver } from "@hookform/resolvers/zod";
import { Network, Plus } from "lucide-react";
Expand Down Expand Up @@ -124,7 +124,7 @@ export function CreateOrganizationDialog({
id="name"
type="text"
onChange={(e) => {
setValue("organizationSlug", generateSlug(e.target.value), { shouldValidate: true });
setValue("organizationSlug", generateOrganizationSlug(e.target.value), { shouldValidate: true });
setValue("organizationTitle", e.target.value, { shouldValidate: true });
}}
placeholder="Organization Name"
Expand Down
6 changes: 6 additions & 0 deletions src/data/user/invitation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ async function setupInviteeUserDetails(email: string): Promise<{
if (!inviteeUserId) {
const { data, error } = await supabaseAdminClient.auth.admin.createUser({
email: email,
user_metadata: {
isUserCreatedThroughOrgInvitation: true
}
});
if (error) {
throw error;
Expand Down Expand Up @@ -221,6 +224,7 @@ export async function createInvitationHandler({
return { status: 'success', data: invitationResponse.data };
}


export async function acceptInvitationAction(
invitationId: string,
): Promise<SAPayload<string>> {
Expand Down Expand Up @@ -314,6 +318,8 @@ export async function getPendingInvitationsOfUser() {
return Promise.all(invitationListPromise);
}



export const getInvitationById = async (invitationId: string) => {
const supabaseClient = createSupabaseUserServerComponentClient();

Expand Down
11 changes: 11 additions & 0 deletions src/data/user/organizations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -620,3 +620,14 @@ export async function getInitialOrganizationToRedirectTo(): Promise<
status: 'success',
};
}

export async function getMaybeInitialOrganizationToRedirectTo(): Promise<SAPayload<string | null>> {
const initialOrganization = await getInitialOrganizationToRedirectTo();
if (initialOrganization.status === 'error') {
return {
data: null,
status: 'success',
};
}
return initialOrganization;
}
57 changes: 57 additions & 0 deletions src/data/user/user.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use server";
import { PRODUCT_NAME } from "@/constants";
import { generateOrganizationSlug } from "@/lib/utils";
import { createSupabaseUserServerActionClient } from "@/supabase-clients/user/createSupabaseUserServerActionClient";
import { createSupabaseUserServerComponentClient } from "@/supabase-clients/user/createSupabaseUserServerComponentClient";
import type { SAPayload, SupabaseFileUploadOptions, Table } from "@/types";
Expand All @@ -12,6 +13,8 @@ import ConfirmAccountDeletionEmail from "emails/account-deletion-request";
import { revalidatePath } from "next/cache";
import slugify from "slugify";
import urlJoin from "url-join";
import { acceptInvitationAction } from "./invitation";
import { createOrganization, setDefaultOrganization } from "./organizations";
import { refreshSessionAction } from "./session";

export async function getIsAppAdmin(): Promise<boolean> {
Expand Down Expand Up @@ -238,6 +241,60 @@ export const acceptTermsOfService = async (
};
};


export const autoAcceptFirstInvitation = async () => {
const user = await serverGetLoggedInUser();
const pendingInvitations = await getUserPendingInvitationsById(user.id);
const supabaseClient = createSupabaseUserServerActionClient();

if (pendingInvitations.length > 0) {
const invitation = pendingInvitations[0];
const invitationAcceptanceResponse = await acceptInvitationAction(invitation.id);
if (invitationAcceptanceResponse.status === "error") {
throw invitationAcceptanceResponse.message;
} else if (invitationAcceptanceResponse.status === "success") {
const joinedOrganizationId = invitationAcceptanceResponse.data;
// let's make the joined organization the default one
await setDefaultOrganization(joinedOrganizationId);
}
const userProfile = await getUserProfile(user.id);
const userFullName = userProfile?.full_name ?? `User ${user.email ?? ""}`;
const defaultOrganizationCreationResponse = await createOrganization(userFullName, generateOrganizationSlug(userFullName));

if (defaultOrganizationCreationResponse.status === "error") {
throw defaultOrganizationCreationResponse.message;
}
}

console.log('updating user metadata')


const updateUserMetadataPayload: Partial<AuthUserMetadata> = {
onboardingHasCreatedOrganization: true,
};

const updateUserMetadataResponse = await supabaseClient.auth.updateUser({
data: updateUserMetadataPayload,
});

if (updateUserMetadataResponse.error) {
return {
status: "error",
message: updateUserMetadataResponse.error.message,
};
}

const refreshSessionResponse = await refreshSessionAction();
if (refreshSessionResponse.status === "error") {
return refreshSessionResponse;
}

return {
status: "success",
data: true,
};
}

export async function requestAccountDeletion(): Promise<
SAPayload<Table<"account_delete_tokens">>
> {
Expand Down
Loading

0 comments on commit 43aa999

Please sign in to comment.