diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/profile/editProfile.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/profile/DeleteAccount.tsx similarity index 58% rename from apps/web/app/(app)/environments/[environmentId]/settings/profile/editProfile.tsx rename to apps/web/app/(app)/environments/[environmentId]/settings/profile/DeleteAccount.tsx index 0eabc98196f..55d25dcb8cf 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/profile/editProfile.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/profile/DeleteAccount.tsx @@ -1,81 +1,16 @@ "use client"; import DeleteDialog from "@/components/shared/DeleteDialog"; -import LoadingSpinner from "@/components/shared/LoadingSpinner"; import AvatarPlaceholder from "@/images/avatar-placeholder.png"; import { formbricksLogout } from "@/lib/formbricks"; -import { useProfileMutation } from "@/lib/profile/mutateProfile"; -import { useProfile } from "@/lib/profile/profile"; -import { deleteProfile } from "@/lib/users/users"; -import { Button, ErrorComponent, Input, Label, ProfileAvatar } from "@formbricks/ui"; +import { Button, Input, ProfileAvatar } from "@formbricks/ui"; import { Session } from "next-auth"; import { signOut } from "next-auth/react"; import Image from "next/image"; -import { Dispatch, SetStateAction, useEffect, useState } from "react"; -import { useForm, useWatch } from "react-hook-form"; +import { Dispatch, SetStateAction, useState } from "react"; import toast from "react-hot-toast"; - -export function EditName() { - const { register, handleSubmit, control, setValue } = useForm(); - const { profile, isLoadingProfile, isErrorProfile } = useProfile(); - - const { triggerProfileMutate, isMutatingProfile } = useProfileMutation(); - - const profileName = useWatch({ - control, - name: "name", - }); - const isProfileNameInputEmpty = !profileName?.trim(); - const currentProfileName = profileName?.trim().toLowerCase() ?? ""; - const previousProfileName = profile?.name?.trim().toLowerCase() ?? ""; - - useEffect(() => { - setValue("name", profile?.name ?? ""); - }, [profile?.name]); - - if (isLoadingProfile) { - return ; - } - if (isErrorProfile) { - return ; - } - - return ( -
{ - triggerProfileMutate(data) - .then(() => { - toast.success("Your name was updated successfully."); - }) - .catch((error) => { - toast.error(`Error: ${error.message}`); - }); - })}> - - - -
- - -
- -
- ); -} +import { profileDeleteAction } from "./actions"; +import { TProfile } from "@formbricks/types/v1/profile"; export function EditAvatar({ session }) { return ( @@ -103,9 +38,10 @@ interface DeleteAccountModalProps { open: boolean; setOpen: Dispatch>; session: Session; + profile: TProfile; } -function DeleteAccountModal({ setOpen, open, session }: DeleteAccountModalProps) { +function DeleteAccountModal({ setOpen, open, session, profile }: DeleteAccountModalProps) { const [deleting, setDeleting] = useState(false); const [inputValue, setInputValue] = useState(""); @@ -116,7 +52,7 @@ function DeleteAccountModal({ setOpen, open, session }: DeleteAccountModalProps) const deleteAccount = async () => { try { setDeleting(true); - await deleteProfile(); + await profileDeleteAction(profile.id); await signOut(); await formbricksLogout(); } catch (error) { @@ -169,7 +105,7 @@ function DeleteAccountModal({ setOpen, open, session }: DeleteAccountModalProps) ); } -export function DeleteAccount({ session }: { session: Session | null }) { +export function DeleteAccount({ session, profile }: { session: Session | null; profile: TProfile }) { const [isModalOpen, setModalOpen] = useState(false); if (!session) { @@ -178,7 +114,7 @@ export function DeleteAccount({ session }: { session: Session | null }) { return (
- +

Delete your account with all personal data. This cannot be undone!

diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/profile/EditAvatar.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/profile/EditAvatar.tsx new file mode 100644 index 00000000000..cfe31eade38 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/profile/EditAvatar.tsx @@ -0,0 +1,28 @@ +"use client"; + +import AvatarPlaceholder from "@/images/avatar-placeholder.png"; +import { Button, ProfileAvatar } from "@formbricks/ui"; +import Image from "next/image"; +import { Session } from "next-auth"; + +export function EditAvatar({ session }:{session: Session | null}) { + return ( +
+ {session?.user?.image ? ( + Avatar placeholder + ) : ( + + )} + + +
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/profile/EditName.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/profile/EditName.tsx new file mode 100644 index 00000000000..2db1dc360cc --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/profile/EditName.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { Button, Input, Label } from "@formbricks/ui"; +import { useForm } from "react-hook-form"; +import toast from "react-hot-toast"; +import { profileEditAction } from "./actions"; +import { TProfile } from "@formbricks/types/v1/profile"; + +export function EditName({ profile }: { profile: TProfile }) { + + const { + register, + handleSubmit, + formState: { isSubmitting }, + } = useForm<{name:string}>() + + return ( + <> +
{ + try { + await profileEditAction(profile.id, data); + toast.success("Your name was updated successfully."); + } catch (error) { + toast.error(`Error: ${error.message}`); + } + })}> + + + +
+ + +
+ +
+ + ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/profile/actions.ts b/apps/web/app/(app)/environments/[environmentId]/settings/profile/actions.ts new file mode 100644 index 00000000000..bc067a2fcf1 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/profile/actions.ts @@ -0,0 +1,12 @@ +"use server"; + +import { updateProfile, deleteProfile } from "@formbricks/lib/services/profile"; +import { Prisma } from "@prisma/client"; + +export async function profileEditAction(userId: string, data: Prisma.UserUpdateInput) { + return await updateProfile(userId, data); +} + +export async function profileDeleteAction(userId: string) { + return await deleteProfile(userId); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/profile/loading.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/profile/loading.tsx new file mode 100644 index 00000000000..e4aa56c89ef --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/profile/loading.tsx @@ -0,0 +1,54 @@ +function LoadingCard({ title, description, skeletonLines }) { + return ( +
+
+

{title}

+

{description}

+
+
+
+ {skeletonLines.map((line, index) => ( +
+
+
+ ))} +
+
+
+ ); +} + +export default function Loading() { + const cards = [ + { + title: "Personal Information", + description: "Update your personal information", + skeletonLines: [ + { classes: "h-4 w-28" }, + { classes: "h-6 w-64" }, + { classes: "h-4 w-28" }, + { classes: "h-6 w-64" }, + { classes: "h-8 w-24" }, + ], + }, + { + title: "Avatar", + description: "Assist your team in identifying you on Formbricks.", + skeletonLines: [{ classes: "h-10 w-10" }, { classes: "h-8 w-24" }], + }, + { + title: "Delete account", + description: "Delete your account with all of your personal information and data.", + skeletonLines: [{ classes: "h-4 w-60" }, { classes: "h-8 w-24" }], + }, + ]; + + return ( +
+

Profile

+ {cards.map((card, index) => ( + + ))} +
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/profile/page.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/profile/page.tsx index 492c55d5b51..6c54272d4f9 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/profile/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/profile/page.tsx @@ -1,25 +1,37 @@ -import SettingsCard from "../SettingsCard"; -import SettingsTitle from "../SettingsTitle"; +export const revalidate = REVALIDATION_INTERVAL; + +import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants"; import { getServerSession } from "next-auth"; -import { EditName, EditAvatar, DeleteAccount } from "./editProfile"; import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import SettingsCard from "../SettingsCard"; +import SettingsTitle from "../SettingsTitle"; +import { DeleteAccount } from "./DeleteAccount"; +import { EditName } from "./EditName"; +import { EditAvatar } from "./EditAvatar"; +import { getProfile } from "@formbricks/lib/services/profile"; export default async function ProfileSettingsPage() { const session = await getServerSession(authOptions); + const profile = session ? await getProfile(session.user.id) : null; + return ( -
- - - - - - - - - - -
+ <> + {profile && ( +
+ + + + + + + + + + +
+ )} + ); } diff --git a/packages/lib/services/profile.ts b/packages/lib/services/profile.ts new file mode 100644 index 00000000000..03642682456 --- /dev/null +++ b/packages/lib/services/profile.ts @@ -0,0 +1,141 @@ +import { prisma } from "@formbricks/database"; +import { DatabaseError, ResourceNotFoundError } from "@formbricks/errors"; +import { Prisma } from "@prisma/client"; +import { TProfile } from "@formbricks/types/v1/profile"; +import { deleteTeam } from "./team"; +import { MembershipRole } from "@prisma/client"; +import { cache } from "react"; + +const responseSelection = { + id: true, + name: true, + email: true, + createdAt: true, + updatedAt: true, +}; + +interface Membership { + role: MembershipRole; + userId: string; +} + +// function to retrive basic information about a user's profile +export const getProfile = cache(async (userId: string): Promise => { + try { + const profile = await prisma.user.findUnique({ + where: { + id: userId, + }, + select: responseSelection, + }); + + if (!profile) { + return null; + } + + return profile; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError("Database operation failed"); + } + + throw error; + } +}); + +const updateUserMembership = async (teamId: string, userId: string, role: MembershipRole) => { + await prisma.membership.update({ + where: { + userId_teamId: { + userId, + teamId, + }, + }, + data: { + role, + }, + }); +}; + +const getAdminMemberships = (memberships: Membership[]) => + memberships.filter((membership) => membership.role === MembershipRole.admin); + +// function to update a user's profile +export const updateProfile = async (personId: string, data: Prisma.UserUpdateInput): Promise => { + try { + const updatedProfile = await prisma.user.update({ + where: { + id: personId, + }, + data: data, + }); + + return updatedProfile; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2016") { + throw new ResourceNotFoundError("Profile", personId); + } else { + throw error; // Re-throw any other errors + } + } +}; +const deleteUser = async (userId: string) => { + await prisma.user.delete({ + where: { + id: userId, + }, + }); +}; + +// function to delete a user's profile including teams +export const deleteProfile = async (personId: string): Promise => { + try { + const currentUserMemberships = await prisma.membership.findMany({ + where: { + userId: personId, + }, + include: { + team: { + select: { + id: true, + name: true, + memberships: { + select: { + userId: true, + role: true, + }, + }, + }, + }, + }, + }); + + for (const currentUserMembership of currentUserMemberships) { + const teamMemberships = currentUserMembership.team.memberships; + const role = currentUserMembership.role; + const teamId = currentUserMembership.teamId; + + const teamAdminMemberships = getAdminMemberships(teamMemberships); + const teamHasAtLeastOneAdmin = teamAdminMemberships.length > 0; + const teamHasOnlyOneMember = teamMemberships.length === 1; + const currentUserIsTeamOwner = role === MembershipRole.owner; + + if (teamHasOnlyOneMember) { + await deleteTeam(teamId); + } else if (currentUserIsTeamOwner && teamHasAtLeastOneAdmin) { + const firstAdmin = teamAdminMemberships[0]; + await updateUserMembership(teamId, firstAdmin.userId, MembershipRole.owner); + } else if (currentUserIsTeamOwner) { + await deleteTeam(teamId); + } + } + + await deleteUser(personId); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError("Database operation failed"); + } + + throw error; + } +}; diff --git a/packages/lib/services/team.ts b/packages/lib/services/team.ts index e08de09575e..6babcebaafa 100644 --- a/packages/lib/services/team.ts +++ b/packages/lib/services/team.ts @@ -1,9 +1,9 @@ -import { cache } from "react"; import { prisma } from "@formbricks/database"; -import { Prisma } from "@prisma/client"; import { DatabaseError } from "@formbricks/errors"; import { TTeam } from "@formbricks/types/v1/teams"; import { createId } from "@paralleldrive/cuid2"; +import { Prisma } from "@prisma/client"; +import { cache } from "react"; import { ChurnResponses, ChurnSurvey, @@ -58,6 +58,22 @@ export const getTeamByEnvironmentId = cache(async (environmentId: string): Promi } }); +export const deleteTeam = async (teamId: string) => { + try { + await prisma.team.delete({ + where: { + id: teamId, + }, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError("Database operation failed"); + } + + throw error; + } +}; + export const createDemoProduct = cache(async (teamId: string) => { const productWithEnvironment = Prisma.validator()({ include: { diff --git a/packages/types/v1/profile.ts b/packages/types/v1/profile.ts new file mode 100644 index 00000000000..0cbee578db6 --- /dev/null +++ b/packages/types/v1/profile.ts @@ -0,0 +1,11 @@ +import z from "zod"; + +export const ZProfile = z.object({ + id: z.string(), + name: z.string().nullish(), + email: z.string(), + createdAt: z.date(), + updatedAt: z.date(), +}); + +export type TProfile = z.infer;