Rewrite profile settings page to server component (#642)

* Chore: moved profile settings to server component

* ran pnpm format

* fisxed a build issue

* made requested changes

* made some refactors

---------

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Dhruwang Jariwala
2023-08-09 16:43:58 +05:30
committed by GitHub
parent 580e51dcea
commit 2bebc9598c
9 changed files with 349 additions and 92 deletions

View File

@@ -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 <LoadingSpinner />;
}
if (isErrorProfile) {
return <ErrorComponent />;
}
return (
<form
className="w-full max-w-sm items-center"
onSubmit={handleSubmit((data) => {
triggerProfileMutate(data)
.then(() => {
toast.success("Your name was updated successfully.");
})
.catch((error) => {
toast.error(`Error: ${error.message}`);
});
})}>
<Label htmlFor="fullname">Full Name</Label>
<Input
type="text"
id="fullname"
defaultValue={profile.name}
{...register("name")}
className={isProfileNameInputEmpty ? "border-red-300 focus:border-red-300" : ""}
/>
<div className="mt-4">
<Label htmlFor="email">Email</Label>
<Input type="email" id="fullname" defaultValue={profile.email} disabled />
</div>
<Button
type="submit"
variant="darkCTA"
className="mt-4"
loading={isMutatingProfile}
disabled={isProfileNameInputEmpty || currentProfileName === previousProfileName}>
Update
</Button>
</form>
);
}
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<SetStateAction<boolean>>;
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 (
<div>
<DeleteAccountModal open={isModalOpen} setOpen={setModalOpen} session={session} />
<DeleteAccountModal open={isModalOpen} setOpen={setModalOpen} session={session} profile={profile} />
<p className="text-sm text-slate-700">
Delete your account with all personal data. <strong>This cannot be undone!</strong>
</p>

View File

@@ -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 (
<div>
{session?.user?.image ? (
<Image
src={AvatarPlaceholder}
width="100"
height="100"
className="h-24 w-24 rounded-full"
alt="Avatar placeholder"
/>
) : (
<ProfileAvatar userId={session!.user.id} />
)}
<Button className="mt-4" variant="darkCTA" disabled={true}>
Upload Image
</Button>
</div>
);
}

View File

@@ -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 (
<>
<form
className="w-full max-w-sm items-center"
onSubmit={handleSubmit(async(data) => {
try {
await profileEditAction(profile.id, data);
toast.success("Your name was updated successfully.");
} catch (error) {
toast.error(`Error: ${error.message}`);
}
})}>
<Label htmlFor="fullname">Full Name</Label>
<Input
type="text"
id="fullname"
defaultValue={profile.name ? profile.name : ""}
{...register("name")}
/>
<div className="mt-4">
<Label htmlFor="email">Email</Label>
<Input type="email" id="fullname" defaultValue={profile.email} disabled />
</div>
<Button type="submit" variant="darkCTA" className="mt-4" loading={isSubmitting}>
Update
</Button>
</form>
</>
);
}

View File

@@ -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);
}

View File

@@ -0,0 +1,54 @@
function LoadingCard({ title, description, skeletonLines }) {
return (
<div className="my-4 rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-100 px-6 py-5 text-left text-slate-900">
<h3 className="text-lg font-medium leading-6">{title}</h3>
<p className="mt-1 text-sm text-slate-500">{description}</p>
</div>
<div className="w-full">
<div className="rounded-lg px-6 py-5 hover:bg-slate-100">
{skeletonLines.map((line, index) => (
<div key={index} className="mt-4">
<div className={`animate-pulse rounded-full bg-gray-200 ${line.classes}`}></div>
</div>
))}
</div>
</div>
</div>
);
}
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 (
<div>
<h2 className="my-4 text-2xl font-medium leading-6 text-slate-800">Profile</h2>
{cards.map((card, index) => (
<LoadingCard key={index} {...card} />
))}
</div>
);
}

View File

@@ -1,25 +1,37 @@
export const revalidate = REVALIDATION_INTERVAL;
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import SettingsCard from "../SettingsCard";
import SettingsTitle from "../SettingsTitle";
import { getServerSession } from "next-auth";
import { EditName, EditAvatar, DeleteAccount } from "./editProfile";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
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 (
<div>
<SettingsTitle title="Profile" />
<SettingsCard title="Personal Information" description="Update your personal information.">
<EditName />
</SettingsCard>
<SettingsCard title="Avatar" description="Assist your team in identifying you on Formbricks.">
<EditAvatar session={session} />
</SettingsCard>
<SettingsCard
title="Delete account"
description="Delete your account with all of your personal information and data.">
<DeleteAccount session={session} />
</SettingsCard>
</div>
<>
{profile && (
<div>
<SettingsTitle title="Profile" />
<SettingsCard title="Personal Information" description="Update your personal information.">
<EditName profile={profile} />
</SettingsCard>
<SettingsCard title="Avatar" description="Assist your team in identifying you on Formbricks.">
<EditAvatar session={session} />
</SettingsCard>
<SettingsCard
title="Delete account"
description="Delete your account with all of your personal information and data.">
<DeleteAccount session={session} profile={profile} />
</SettingsCard>
</div>
)}
</>
);
}

View File

@@ -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<TProfile | null> => {
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<TProfile> => {
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<void> => {
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;
}
};

View File

@@ -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<Prisma.ProductArgs>()({
include: {

View File

@@ -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<typeof ZProfile>;