chore: custom avatar removal (#6408)

Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
This commit is contained in:
Piyush Gupta
2025-08-14 15:47:05 +05:30
committed by GitHub
parent a6269f0fd3
commit 41d60c8a02
44 changed files with 32 additions and 502 deletions

View File

@@ -45,7 +45,7 @@ afterEach(() => {
});
describe("LandingSidebar component", () => {
const user = { id: "u1", name: "Alice", email: "alice@example.com", imageUrl: "" } as any;
const user = { id: "u1", name: "Alice", email: "alice@example.com" } as any;
const organization = { id: "o1", name: "orgOne" } as any;
const organizations = [
{ id: "o2", name: "betaOrg" },

View File

@@ -82,7 +82,7 @@ export const LandingSidebar = ({
id="userDropdownTrigger"
className="w-full rounded-br-xl border-t p-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
<div tabIndex={0} className={cn("flex cursor-pointer flex-row items-center gap-3")}>
<ProfileAvatar userId={user.id} imageUrl={user.imageUrl} />
<ProfileAvatar userId={user.id} />
<>
<div className="grow overflow-hidden">
<p

View File

@@ -113,7 +113,6 @@ const mockUser = {
name: "Test User",
email: "test@example.com",
emailVerified: new Date(),
imageUrl: "",
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),

View File

@@ -111,7 +111,6 @@ const mockUser = {
id: "user1",
name: "Test User",
email: "test@example.com",
imageUrl: "http://example.com/avatar.png",
emailVerified: new Date(),
twoFactorEnabled: false,
identityProvider: "email",

View File

@@ -342,7 +342,7 @@ export const MainNavigation = ({
"flex cursor-pointer flex-row items-center gap-3",
isCollapsed ? "justify-center px-2" : "px-4"
)}>
<ProfileAvatar userId={user.id} imageUrl={user.imageUrl} />
<ProfileAvatar userId={user.id} />
{!isCollapsed && !isTextVisible && (
<>
<div

View File

@@ -37,7 +37,6 @@ describe("EnvironmentPage", () => {
id: mockUserId,
name: "Test User",
email: "test@example.com",
imageUrl: "",
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),

View File

@@ -5,8 +5,6 @@ import {
verifyUserPassword,
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
import { deleteFile } from "@/lib/storage/service";
import { getFileNameWithIdFromUrl } from "@/lib/storage/utils";
import { getUser, updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
@@ -15,8 +13,6 @@ import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { AuthenticationError, AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
import {
TUserPersonalInfoUpdateInput,
@@ -97,58 +93,6 @@ export const updateUserAction = authenticatedActionClient.schema(ZUserPersonalIn
)
);
const ZUpdateAvatarAction = z.object({
avatarUrl: z.string(),
});
export const updateAvatarAction = authenticatedActionClient.schema(ZUpdateAvatarAction).action(
withAuditLogging(
"updated",
"user",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const oldObject = await getUser(ctx.user.id);
const result = await updateUser(ctx.user.id, { imageUrl: parsedInput.avatarUrl });
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
}
)
);
const ZRemoveAvatarAction = z.object({
environmentId: ZId,
});
export const removeAvatarAction = authenticatedActionClient.schema(ZRemoveAvatarAction).action(
withAuditLogging(
"updated",
"user",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const oldObject = await getUser(ctx.user.id);
const imageUrl = ctx.user.imageUrl;
if (!imageUrl) {
throw new Error("Image not found");
}
const fileName = getFileNameWithIdFromUrl(imageUrl);
if (!fileName) {
throw new Error("Invalid filename");
}
const deletionResult = await deleteFile(parsedInput.environmentId, "public", fileName);
if (!deletionResult.success) {
throw new Error("Deletion failed");
}
const result = await updateUser(ctx.user.id, { imageUrl: null });
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
}
)
);
export const resetPasswordAction = authenticatedActionClient.action(
withAuditLogging(
"passwordReset",

View File

@@ -1,104 +0,0 @@
import * as profileActions from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions";
import * as fileUploadHooks from "@/app/lib/fileUpload";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Session } from "next-auth";
import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { EditProfileAvatarForm } from "./EditProfileAvatarForm";
vi.mock("@/modules/ui/components/avatars", () => ({
ProfileAvatar: ({ imageUrl }) => <div data-testid="profile-avatar">{imageUrl || "No Avatar"}</div>,
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({
refresh: vi.fn(),
}),
}));
vi.mock("@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions", () => ({
updateAvatarAction: vi.fn(),
removeAvatarAction: vi.fn(),
}));
vi.mock("@/app/lib/fileUpload", () => ({
handleFileUpload: vi.fn(),
}));
const mockSession: Session = {
user: { id: "user-id" },
expires: "session-expires-at",
};
const environmentId = "test-env-id";
describe("EditProfileAvatarForm", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
vi.mocked(profileActions.updateAvatarAction).mockResolvedValue({});
vi.mocked(profileActions.removeAvatarAction).mockResolvedValue({});
vi.mocked(fileUploadHooks.handleFileUpload).mockResolvedValue({
url: "new-avatar.jpg",
error: undefined,
});
});
test("renders correctly without an existing image", () => {
render(<EditProfileAvatarForm session={mockSession} environmentId={environmentId} imageUrl={null} />);
expect(screen.getByTestId("profile-avatar")).toHaveTextContent("No Avatar");
expect(screen.getByText("environments.settings.profile.upload_image")).toBeInTheDocument();
expect(screen.queryByText("environments.settings.profile.remove_image")).not.toBeInTheDocument();
});
test("renders correctly with an existing image", () => {
render(
<EditProfileAvatarForm
session={mockSession}
environmentId={environmentId}
imageUrl="existing-avatar.jpg"
/>
);
expect(screen.getByTestId("profile-avatar")).toHaveTextContent("existing-avatar.jpg");
expect(screen.getByText("environments.settings.profile.change_image")).toBeInTheDocument();
expect(screen.getByText("environments.settings.profile.remove_image")).toBeInTheDocument();
});
test("handles image removal successfully", async () => {
render(
<EditProfileAvatarForm
session={mockSession}
environmentId={environmentId}
imageUrl="existing-avatar.jpg"
/>
);
const removeButton = screen.getByText("environments.settings.profile.remove_image");
await userEvent.click(removeButton);
await waitFor(() => {
expect(profileActions.removeAvatarAction).toHaveBeenCalledWith({ environmentId });
});
});
test("shows error if removeAvatarAction fails", async () => {
vi.mocked(profileActions.removeAvatarAction).mockRejectedValue(new Error("API error"));
render(
<EditProfileAvatarForm
session={mockSession}
environmentId={environmentId}
imageUrl="existing-avatar.jpg"
/>
);
const removeButton = screen.getByText("environments.settings.profile.remove_image");
await userEvent.click(removeButton);
await waitFor(() => {
expect(vi.mocked(toast.error)).toHaveBeenCalledWith(
"environments.settings.profile.avatar_update_failed"
);
});
});
});

View File

@@ -1,178 +0,0 @@
"use client";
import {
removeAvatarAction,
updateAvatarAction,
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions";
import { handleFileUpload } from "@/app/lib/fileUpload";
import { ProfileAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
import { FormError, FormField, FormItem, FormProvider } from "@/modules/ui/components/form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react";
import { Session } from "next-auth";
import { useRouter } from "next/navigation";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { z } from "zod";
interface EditProfileAvatarFormProps {
session: Session;
environmentId: string;
imageUrl: string | null;
}
export const EditProfileAvatarForm = ({ session, environmentId, imageUrl }: EditProfileAvatarFormProps) => {
const inputRef = useRef<HTMLInputElement>(null);
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const { t } = useTranslate();
const fileSchema =
typeof window !== "undefined"
? z
.instanceof(FileList)
.refine((files) => files.length === 1, t("environments.settings.profile.you_must_select_a_file"))
.refine((files) => {
const file = files[0];
const allowedTypes = ["image/jpeg", "image/png", "image/webp"];
return allowedTypes.includes(file.type);
}, t("environments.settings.profile.invalid_file_type"))
.refine((files) => {
const file = files[0];
const maxSize = 10 * 1024 * 1024;
return file.size <= maxSize;
}, t("environments.settings.profile.file_size_must_be_less_than_10mb"))
: z.any();
const formSchema = z.object({
file: fileSchema,
});
type FormValues = z.infer<typeof formSchema>;
const form = useForm<FormValues>({
mode: "onChange",
resolver: zodResolver(formSchema),
});
const handleUpload = async (file: File, environmentId: string) => {
setIsLoading(true);
try {
if (imageUrl) {
// If avatar image already exists, then remove it before update action
await removeAvatarAction({ environmentId });
}
const { url, error } = await handleFileUpload(file, environmentId);
if (error) {
toast.error(error);
setIsLoading(false);
return;
}
await updateAvatarAction({ avatarUrl: url });
router.refresh();
} catch (err) {
toast.error(t("environments.settings.profile.avatar_update_failed"));
setIsLoading(false);
}
setIsLoading(false);
};
const handleRemove = async () => {
setIsLoading(true);
try {
await removeAvatarAction({ environmentId });
} catch (err) {
toast.error(t("environments.settings.profile.avatar_update_failed"));
} finally {
setIsLoading(false);
form.reset();
}
};
const onSubmit = async (data: FormValues) => {
const file = data.file[0];
if (file) {
await handleUpload(file, environmentId);
}
};
return (
<div>
<div className="relative h-10 w-10 overflow-hidden rounded-full">
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-30">
<svg className="h-7 w-7 animate-spin text-slate-200" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
)}
<ProfileAvatar userId={session.user.id} imageUrl={imageUrl} />
</div>
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-4">
<FormField
name="file"
control={form.control}
render={({ field, fieldState }) => (
<FormItem>
<div className="flex">
<Button
type="button"
size="sm"
className="mr-2"
variant={!!fieldState.error?.message ? "destructive" : "secondary"}
onClick={() => {
inputRef.current?.click();
}}>
{imageUrl
? t("environments.settings.profile.change_image")
: t("environments.settings.profile.upload_image")}
<input
type="file"
id="hiddenFileInput"
ref={(e) => {
field.ref(e);
inputRef.current = e;
}}
className="hidden"
accept="image/jpeg, image/png, image/webp"
onChange={(e) => {
field.onChange(e.target.files);
form.handleSubmit(onSubmit)();
}}
/>
</Button>
{imageUrl && (
<Button
type="button"
className="mr-2"
variant="destructive"
size="sm"
onClick={handleRemove}>
{t("environments.settings.profile.remove_image")}
</Button>
)}
</div>
<FormError />
</FormItem>
)}
/>
</form>
</FormProvider>
</div>
);
};

View File

@@ -49,15 +49,12 @@ describe("Loading", () => {
);
const loadingCards = screen.getAllByTestId("loading-card");
expect(loadingCards).toHaveLength(3);
expect(loadingCards).toHaveLength(2);
expect(loadingCards[0]).toHaveTextContent("environments.settings.profile.personal_information");
expect(loadingCards[0]).toHaveTextContent("environments.settings.profile.update_personal_info");
expect(loadingCards[1]).toHaveTextContent("common.avatar");
expect(loadingCards[1]).toHaveTextContent("environments.settings.profile.organization_identification");
expect(loadingCards[2]).toHaveTextContent("environments.settings.profile.delete_account");
expect(loadingCards[2]).toHaveTextContent("environments.settings.profile.confirm_delete_account");
expect(loadingCards[1]).toHaveTextContent("environments.settings.profile.delete_account");
expect(loadingCards[1]).toHaveTextContent("environments.settings.profile.confirm_delete_account");
});
});

View File

@@ -19,11 +19,6 @@ const Loading = () => {
{ classes: "h-6 w-64" },
],
},
{
title: t("common.avatar"),
description: t("environments.settings.profile.organization_identification"),
skeletonLines: [{ classes: "h-10 w-10" }, { classes: "h-8 w-24" }],
},
{
title: t("environments.settings.profile.delete_account"),
description: t("environments.settings.profile.confirm_delete_account"),

View File

@@ -55,11 +55,6 @@ vi.mock(
vi.mock("./components/DeleteAccount", () => ({
DeleteAccount: ({ user }) => <div data-testid="delete-account">DeleteAccount: {user.id}</div>,
}));
vi.mock("./components/EditProfileAvatarForm", () => ({
EditProfileAvatarForm: ({ _, environmentId }) => (
<div data-testid="edit-profile-avatar-form">EditProfileAvatarForm: {environmentId}</div>
),
}));
vi.mock("./components/EditProfileDetailsForm", () => ({
EditProfileDetailsForm: ({ user }) => (
<div data-testid="edit-profile-details-form">EditProfileDetailsForm: {user.id}</div>
@@ -73,7 +68,6 @@ const mockUser = {
id: "user-123",
name: "Test User",
email: "test@example.com",
imageUrl: "http://example.com/avatar.png",
twoFactorEnabled: false,
identityProvider: "email",
notificationSettings: { alert: {}, unsubscribedOrganizationIds: [] },
@@ -117,7 +111,6 @@ describe("ProfilePage", () => {
"AccountSettingsNavbar: env-123 profile"
);
expect(screen.getByTestId("edit-profile-details-form")).toBeInTheDocument();
expect(screen.getByTestId("edit-profile-avatar-form")).toBeInTheDocument();
expect(screen.getByTestId("account-security")).toBeInTheDocument(); // Shown because 2FA license is enabled
expect(screen.queryByTestId("upgrade-prompt")).not.toBeInTheDocument();
expect(screen.getByTestId("delete-account")).toBeInTheDocument();

View File

@@ -12,7 +12,6 @@ import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
import { getTranslate } from "@/tolgee/server";
import { SettingsCard } from "../../components/SettingsCard";
import { DeleteAccount } from "./components/DeleteAccount";
import { EditProfileAvatarForm } from "./components/EditProfileAvatarForm";
import { EditProfileDetailsForm } from "./components/EditProfileDetailsForm";
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
@@ -50,17 +49,6 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
isPasswordResetEnabled={isPasswordResetEnabled}
/>
</SettingsCard>
<SettingsCard
title={t("common.avatar")}
description={t("environments.settings.profile.organization_identification")}>
{user && (
<EditProfileAvatarForm
session={session}
environmentId={environmentId}
imageUrl={user.imageUrl}
/>
)}
</SettingsCard>
{user.identityProvider === "email" && (
<SettingsCard
title={t("common.security")}

View File

@@ -126,7 +126,6 @@ const mockUser = {
createdAt: new Date(),
updatedAt: new Date(),
emailVerified: new Date(),
imageUrl: "",
twoFactorEnabled: false,
identityProvider: "email",
notificationSettings: { alert: {} },

View File

@@ -128,7 +128,6 @@ const mockUser = {
name: "Test User",
email: "test@example.com",
emailVerified: new Date(),
imageUrl: "",
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),

View File

@@ -145,7 +145,6 @@ const mockUser = {
name: "Test User",
email: "test@example.com",
emailVerified: new Date(),
imageUrl: "",
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),

View File

@@ -291,7 +291,6 @@ const mockUser: TUser = {
name: "Test User",
email: "test@example.com",
emailVerified: new Date(),
imageUrl: "https://example.com/avatar.jpg",
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),

View File

@@ -250,7 +250,6 @@ const mockUser: TUser = {
name: "Test User",
email: "test@example.com",
emailVerified: new Date(),
imageUrl: "https://example.com/avatar.jpg",
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),

View File

@@ -158,7 +158,6 @@ const mockUser = {
name: "Test User",
email: "test@example.com",
emailVerified: new Date(),
imageUrl: "",
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
@@ -174,7 +173,6 @@ const mockSession = {
id: mockUserId,
name: mockUser.name,
email: mockUser.email,
image: mockUser.imageUrl,
role: mockUser.role,
plan: "free",
status: "active",

View File

@@ -118,7 +118,6 @@ describe("Page", () => {
name: "Test User",
email: "test@example.com",
emailVerified: null,
imageUrl: null,
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
@@ -161,7 +160,6 @@ describe("Page", () => {
name: "Test User",
email: "test@example.com",
emailVerified: null,
imageUrl: null,
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
@@ -250,7 +248,6 @@ describe("Page", () => {
name: "Test User",
email: "test@example.com",
emailVerified: null,
imageUrl: null,
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
@@ -339,7 +336,6 @@ describe("Page", () => {
name: "Test User",
email: "test@example.com",
emailVerified: null,
imageUrl: null,
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),

View File

@@ -122,7 +122,6 @@ export const mockUser: TUser = {
name: "mock User",
email: "test@unit.com",
emailVerified: currentDate,
imageUrl: "https://www.google.com",
createdAt: currentDate,
updatedAt: currentDate,
twoFactorEnabled: false,

View File

@@ -3,7 +3,7 @@ import { IdentityProvider, Objective, Prisma, Role } from "@prisma/client";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TOrganization } from "@formbricks/types/organizations";
import { TUserLocale, TUserUpdateInput } from "@formbricks/types/user";
import { deleteUser, getUser, getUserByEmail, getUsersWithOrganization, updateUser } from "./service";
@@ -20,10 +20,6 @@ vi.mock("@formbricks/database", () => ({
},
}));
vi.mock("@/lib/fileValidation", () => ({
isValidImageFile: vi.fn(),
}));
vi.mock("@/lib/organization/service", () => ({
getOrganizationsWhereUserIsSingleOwner: vi.fn(),
deleteOrganization: vi.fn(),
@@ -39,7 +35,6 @@ describe("User Service", () => {
name: "Test User",
email: "test@example.com",
emailVerified: new Date(),
imageUrl: null,
createdAt: new Date(),
updatedAt: new Date(),
role: Role.project_manager,
@@ -200,13 +195,6 @@ describe("User Service", () => {
await expect(updateUser("nonexistent", { name: "New Name" })).rejects.toThrow(ResourceNotFoundError);
});
test("should throw InvalidInputError when invalid image URL is provided", async () => {
const { isValidImageFile } = await import("@/lib/fileValidation");
vi.mocked(isValidImageFile).mockReturnValue(false);
await expect(updateUser("user1", { imageUrl: "invalid-image-url" })).rejects.toThrow(InvalidInputError);
});
});
describe("deleteUser", () => {

View File

@@ -1,5 +1,4 @@
import "server-only";
import { isValidImageFile } from "@/lib/fileValidation";
import { deleteOrganization, getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { deleteBrevoCustomerByEmail } from "@/modules/auth/lib/brevo";
import { Prisma } from "@prisma/client";
@@ -8,7 +7,7 @@ import { z } from "zod";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { ZId } from "@formbricks/types/common";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TUser, TUserLocale, TUserUpdateInput, ZUserUpdateInput } from "@formbricks/types/user";
import { validateInputs } from "../utils/validate";
@@ -17,7 +16,6 @@ const responseSelection = {
name: true,
email: true,
emailVerified: true,
imageUrl: true,
createdAt: true,
updatedAt: true,
role: true,
@@ -79,7 +77,6 @@ export const getUserByEmail = reactCache(async (email: string): Promise<TUser |
// function to update a user's user
export const updateUser = async (personId: string, data: TUserUpdateInput): Promise<TUser> => {
validateInputs([personId, ZId], [data, ZUserUpdateInput.partial()]);
if (data.imageUrl && !isValidImageFile(data.imageUrl)) throw new InvalidInputError("Invalid image file");
try {
const updatedUser = await prisma.user.update({

View File

@@ -112,7 +112,6 @@ describe("withAuditLogging", () => {
name: "Test User",
email: "test@example.com",
emailVerified: null,
imageUrl: null,
twoFactorEnabled: false,
identityProvider: "email" as const,
createdAt: new Date(),
@@ -151,7 +150,6 @@ describe("withAuditLogging", () => {
name: "Test User",
email: "test@example.com",
emailVerified: null,
imageUrl: null,
twoFactorEnabled: false,
identityProvider: "email" as const,
createdAt: new Date(),

View File

@@ -148,7 +148,6 @@ describe("authOptions", () => {
email: mockUser.email,
password: mockHashedPassword,
emailVerified: new Date(),
imageUrl: "http://example.com/avatar.png",
twoFactorEnabled: false,
};
@@ -161,7 +160,6 @@ describe("authOptions", () => {
id: fakeUser.id,
email: fakeUser.email,
emailVerified: fakeUser.emailVerified,
imageUrl: fakeUser.imageUrl,
});
});

View File

@@ -206,7 +206,6 @@ export const authOptions: NextAuthOptions = {
id: user.id,
email: user.email,
emailVerified: user.emailVerified,
imageUrl: user.imageUrl,
};
},
}),

View File

@@ -5,7 +5,6 @@ export const mockUser: TUser = {
name: "mock User",
email: "john.doe@example.com",
emailVerified: new Date("2024-01-01T00:00:00.000Z"),
imageUrl: "https://www.google.com",
createdAt: new Date("2024-01-01T00:00:00.000Z"),
updatedAt: new Date("2024-01-01T00:00:00.000Z"),
twoFactorEnabled: false,

View File

@@ -1,4 +1,3 @@
import { isValidImageFile } from "@/lib/fileValidation";
import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
@@ -11,10 +10,6 @@ import { TUserCreateInput, TUserUpdateInput, ZUserEmail, ZUserUpdateInput } from
export const updateUser = async (id: string, data: TUserUpdateInput) => {
validateInputs([id, ZId], [data, ZUserUpdateInput.partial()]);
if (data.imageUrl && !isValidImageFile(data.imageUrl)) {
throw new InvalidInputError("Invalid image file");
}
try {
const updatedUser = await prisma.user.update({
where: {

View File

@@ -94,7 +94,6 @@ const fullUser = {
updatedAt: new Date(),
email: "test@example.com",
emailVerified: null,
imageUrl: null,
twoFactorEnabled: false,
identityProvider: "email",
organizationId: "org1",

View File

@@ -28,7 +28,6 @@ describe("ResponseTimeline", () => {
name: "Test User",
createdAt: new Date(),
updatedAt: new Date(),
imageUrl: null,
objective: null,
role: "founder",
email: "test@example.com",

View File

@@ -51,7 +51,6 @@ export const getSSOProviders = () => [
id: profile.sub,
name: profile.name,
email: profile.email,
image: profile.picture,
};
},
},
@@ -76,7 +75,6 @@ export const getSSOProviders = () => [
id: profile.id,
email: profile.email,
name: [profile.firstName, profile.lastName].filter(Boolean).join(" "),
image: null,
};
},
options: {

View File

@@ -13,7 +13,6 @@ export const mockUser: TUser = {
unsubscribedOrganizationIds: [],
},
emailVerified: new Date(),
imageUrl: "https://example.com/image.png",
twoFactorEnabled: false,
identityProvider: "google",
locale: "en-US",

View File

@@ -56,7 +56,6 @@ const mockUser = {
id: "user-123",
name: "Test User",
email: "test@example.com",
imageUrl: null,
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),

View File

@@ -131,7 +131,6 @@ describe("CreateOrganizationPage", () => {
name: "Test User",
email: "test@example.com",
emailVerified: null,
imageUrl: null,
twoFactorEnabled: false,
identityProvider: "email" as const,
createdAt: new Date(),

View File

@@ -1,9 +1,8 @@
import { isValidImageFile } from "@/lib/fileValidation";
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TUser } from "@formbricks/types/user";
import { updateUser } from "./user";
@@ -24,7 +23,6 @@ describe("updateUser", () => {
id: "user-123",
name: "Test User",
email: "test@example.com",
imageUrl: "https://example.com/image.png",
createdAt: new Date(),
updatedAt: new Date(),
role: "project_manager",
@@ -41,7 +39,6 @@ describe("updateUser", () => {
});
test("successfully updates a user", async () => {
vi.mocked(isValidImageFile).mockReturnValue(true);
vi.mocked(prisma.user.update).mockResolvedValue(mockUser as any);
const updateData = { name: "Updated Name" };
@@ -55,7 +52,6 @@ describe("updateUser", () => {
name: true,
email: true,
emailVerified: true,
imageUrl: true,
createdAt: true,
updatedAt: true,
role: true,
@@ -72,17 +68,7 @@ describe("updateUser", () => {
expect(result).toEqual(mockUser);
});
test("throws InvalidInputError when image file is invalid", async () => {
vi.mocked(isValidImageFile).mockReturnValue(false);
const updateData = { imageUrl: "invalid-image.xyz" };
await expect(updateUser("user-123", updateData)).rejects.toThrow(InvalidInputError);
expect(prisma.user.update).not.toHaveBeenCalled();
});
test("throws ResourceNotFoundError when user does not exist", async () => {
vi.mocked(isValidImageFile).mockReturnValue(true);
const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", {
code: PrismaErrorType.RecordDoesNotExist,
clientVersion: "1.0.0",
@@ -96,8 +82,6 @@ describe("updateUser", () => {
});
test("re-throws other errors", async () => {
vi.mocked(isValidImageFile).mockReturnValue(true);
const otherError = new Error("Some other error");
vi.mocked(prisma.user.update).mockRejectedValue(otherError);

View File

@@ -1,14 +1,11 @@
import { isValidImageFile } from "@/lib/fileValidation";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TUser, TUserUpdateInput } from "@formbricks/types/user";
// function to update a user's user
export const updateUser = async (personId: string, data: TUserUpdateInput): Promise<TUser> => {
if (data.imageUrl && !isValidImageFile(data.imageUrl)) throw new InvalidInputError("Invalid image file");
try {
const updatedUser = await prisma.user.update({
where: {
@@ -20,7 +17,6 @@ export const updateUser = async (personId: string, data: TUserUpdateInput): Prom
name: true,
email: true,
emailVerified: true,
imageUrl: true,
createdAt: true,
updatedAt: true,
role: true,

View File

@@ -12,13 +12,6 @@ vi.mock("boring-avatars", () => ({
),
}));
// Mock next/image
vi.mock("next/image", () => ({
default: ({ src, width, height, className, alt }: any) => (
<img src={src} width={width} height={height} className={className} alt={alt} data-testid="next-image" />
),
}));
describe("Avatar Components", () => {
afterEach(() => {
cleanup();
@@ -44,7 +37,7 @@ describe("Avatar Components", () => {
});
describe("ProfileAvatar", () => {
test("renders Boring Avatar when imageUrl is not provided", () => {
test("renders Boring Avatar", () => {
render(<ProfileAvatar userId="user-123" />);
const avatar = screen.getByTestId("boring-avatar-bauhaus");
@@ -52,32 +45,5 @@ describe("Avatar Components", () => {
expect(avatar).toHaveAttribute("data-size", "40");
expect(avatar).toHaveAttribute("data-name", "user-123");
});
test("renders Boring Avatar when imageUrl is null", () => {
render(<ProfileAvatar userId="user-123" imageUrl={null} />);
const avatar = screen.getByTestId("boring-avatar-bauhaus");
expect(avatar).toBeInTheDocument();
});
test("renders Image component when imageUrl is provided", () => {
render(<ProfileAvatar userId="user-123" imageUrl="https://example.com/avatar.jpg" />);
const image = screen.getByTestId("next-image");
expect(image).toBeInTheDocument();
expect(image).toHaveAttribute("src", "https://example.com/avatar.jpg");
expect(image).toHaveAttribute("width", "40");
expect(image).toHaveAttribute("height", "40");
expect(image).toHaveAttribute("alt", "Avatar placeholder");
expect(image).toHaveClass("h-10", "w-10", "rounded-full", "object-cover");
});
test("renders Image component with different imageUrl", () => {
render(<ProfileAvatar userId="user-123" imageUrl="https://example.com/different-avatar.png" />);
const image = screen.getByTestId("next-image");
expect(image).toBeInTheDocument();
expect(image).toHaveAttribute("src", "https://example.com/different-avatar.png");
});
});
});

View File

@@ -1,5 +1,4 @@
import Avatar from "boring-avatars";
import Image from "next/image";
const colors = ["#00C4B8", "#ccfbf1", "#334155"];
@@ -13,20 +12,8 @@ export const PersonAvatar: React.FC<PersonAvatarProps> = ({ personId }) => {
interface ProfileAvatar {
userId: string;
imageUrl?: string | null;
}
export const ProfileAvatar: React.FC<ProfileAvatar> = ({ userId, imageUrl }) => {
if (imageUrl) {
return (
<Image
src={imageUrl}
width="40"
height="40"
className="h-10 w-10 rounded-full object-cover"
alt="Avatar placeholder"
/>
);
}
export const ProfileAvatar: React.FC<ProfileAvatar> = ({ userId }) => {
return <Avatar size={40} name={userId} variant="bauhaus" colors={colors} />;
};

View File

@@ -82,7 +82,7 @@
"@vercel/functions": "2.2.8",
"@vercel/og": "0.8.5",
"bcryptjs": "3.0.2",
"boring-avatars": "1.11.2",
"boring-avatars": "2.0.1",
"cache-manager": "6.4.3",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",

View File

@@ -0,0 +1,8 @@
/*
Warnings:
- You are about to drop the column `imageUrl` on the `User` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "User" DROP COLUMN "imageUrl";

View File

@@ -824,7 +824,6 @@ model User {
email String @unique
emailVerified DateTime? @map(name: "email_verified")
imageUrl String?
twoFactorSecret String?
twoFactorEnabled Boolean @default(false)
backupCodes String?

View File

@@ -57,7 +57,6 @@ export const ZUser = z.object({
Omit<
User,
| "emailVerified"
| "imageUrl"
| "twoFactorSecret"
| "twoFactorEnabled"
| "backupCodes"

View File

@@ -48,7 +48,6 @@ export const ZUser = z.object({
name: ZUserName,
email: ZUserEmail,
emailVerified: z.date().nullable(),
imageUrl: z.string().url().nullable(),
twoFactorEnabled: z.boolean(),
identityProvider: ZUserIdentityProvider,
createdAt: z.date(),
@@ -70,7 +69,6 @@ export const ZUserUpdateInput = z.object({
password: ZUserPassword.optional(),
role: ZRole.optional(),
objective: ZUserObjective.nullish(),
imageUrl: z.string().nullish(),
notificationSettings: ZUserNotificationSettings.optional(),
locale: ZUserLocale.optional(),
lastLoginAt: z.date().nullish(),

16
pnpm-lock.yaml generated
View File

@@ -292,8 +292,8 @@ importers:
specifier: 3.0.2
version: 3.0.2
boring-avatars:
specifier: 1.11.2
version: 1.11.2
specifier: 2.0.1
version: 2.0.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
cache-manager:
specifier: 6.4.3
version: 6.4.3
@@ -5069,8 +5069,11 @@ packages:
boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
boring-avatars@1.11.2:
resolution: {integrity: sha512-3+wkwPeObwS4R37FGXMYViqc4iTrIRj5yzfX9Qy4mnpZ26sX41dGMhsAgmKks1r/uufY1pl4vpgzMWHYfJRb2A==}
boring-avatars@2.0.1:
resolution: {integrity: sha512-TeBnZrp7WxHcQPuLhGQamklgNqaL7eUAUh3E11kFj9rTn0Hari2ZKVTchqNrp62UOHN/XOe5bZGcbzVGwHjHwg==}
peerDependencies:
react: '>=18.0.0'
react-dom: '>=18.0.0'
bowser@2.11.0:
resolution: {integrity: sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==}
@@ -15116,7 +15119,10 @@ snapshots:
boolbase@1.0.0: {}
boring-avatars@1.11.2: {}
boring-avatars@2.0.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
bowser@2.11.0: {}