Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions apps/web/actions/organization/upload-organization-icon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { revalidatePath } from "next/cache";
import { sanitizeFile } from "@/lib/sanitizeFile";
import { runPromise } from "@/lib/server";

const MAX_FILE_SIZE_BYTES = 1 * 1024 * 1024; // 1MB

export async function uploadOrganizationIcon(
formData: FormData,
organizationId: Organisation.OrganisationId,
Expand Down Expand Up @@ -46,9 +48,8 @@ export async function uploadOrganizationIcon(
throw new Error("File must be an image");
}

// Validate file size (limit to 2MB)
if (file.size > 2 * 1024 * 1024) {
throw new Error("File size must be less than 2MB");
if (file.size > MAX_FILE_SIZE_BYTES) {
throw new Error("File size must be less than 1MB");
}

// Create a unique file key
Expand Down
58 changes: 27 additions & 31 deletions apps/web/app/(org)/dashboard/settings/account/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ import { useEffect, useId, useState } from "react";
import { toast } from "sonner";
import { removeProfileImage } from "@/actions/account/remove-profile-image";
import { uploadProfileImage } from "@/actions/account/upload-profile-image";
import { FileInput } from "@/components/FileInput";
import { useDashboardContext } from "../../Contexts";
import { ProfileImage } from "./components/ProfileImage";
import { patchAccountSettings } from "./server";

export const Settings = ({
Expand All @@ -32,7 +32,9 @@ export const Settings = ({
const [defaultOrgId, setDefaultOrgId] = useState<
Organisation.OrganisationId | undefined
>(user?.defaultOrgId || undefined);
const avatarInputId = useId();
const firstNameId = useId();
const lastNameId = useId();
const contactEmailId = useId();
const initialProfileImage = user?.image ?? null;
const [profileImageOverride, setProfileImageOverride] = useState<
string | null | undefined
Expand Down Expand Up @@ -87,10 +89,7 @@ export const Settings = ({
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, [hasChanges]);

const {
mutate: uploadProfileImageMutation,
isPending: isUploadingProfileImage,
} = useMutation({
const uploadProfileImageMutation = useMutation({
mutationFn: async (file: File) => {
const formData = new FormData();
formData.append("image", file);
Expand All @@ -114,10 +113,7 @@ export const Settings = ({
},
});

const {
mutate: removeProfileImageMutation,
isPending: isRemovingProfileImage,
} = useMutation({
const removeProfileImageMutation = useMutation({
mutationFn: removeProfileImage,
onSuccess: (result) => {
if (result.success) {
Expand All @@ -138,21 +134,22 @@ export const Settings = ({
});

const isProfileImageMutating =
isUploadingProfileImage || isRemovingProfileImage;
uploadProfileImageMutation.isPending ||
removeProfileImageMutation.isPending;

const handleProfileImageChange = (file: File | null) => {
if (!file || isProfileImageMutating) {
return;
}
uploadProfileImageMutation(file);
uploadProfileImageMutation.mutate(file);
};

const handleProfileImageRemove = () => {
if (isProfileImageMutating) {
return;
}
setProfileImageOverride(null);
removeProfileImageMutation();
removeProfileImageMutation.mutate();
};

return (
Expand All @@ -163,39 +160,38 @@ export const Settings = ({
}}
>
<div className="grid gap-6 w-full md:grid-cols-2">
<Card className="flex flex-col gap-4">
<Card className="space-y-4">
<div className="space-y-1">
<CardTitle>Profile image</CardTitle>
<CardDescription>
This image appears in your profile, comments, and shared caps.
</CardDescription>
</div>
<FileInput
id={avatarInputId}
name="profileImage"
height={120}
previewIconSize={28}
<ProfileImage
initialPreviewUrl={profileImagePreviewUrl}
onChange={handleProfileImageChange}
onRemove={handleProfileImageRemove}
disabled={isProfileImageMutating}
isLoading={isProfileImageMutating}
isUploading={uploadProfileImageMutation.isPending}
isRemoving={removeProfileImageMutation.isPending}
/>
</Card>
<Card className="space-y-1">
<CardTitle>Your name</CardTitle>
<CardDescription>
Changing your name below will update how your name appears when
sharing a Cap, and in your profile.
</CardDescription>
<div className="flex flex-col flex-wrap gap-5 pt-4 w-full md:flex-row">
<div className="flex-1 space-y-2">
<Card className="space-y-4">
<div className="space-y-1">
<CardTitle>Your name</CardTitle>
<CardDescription>
Changing your name below will update how your name appears when
sharing a Cap, and in your profile.
</CardDescription>
</div>
<div className="flex flex-col flex-wrap gap-3 w-full">
<div className="flex-1">
<Input
type="text"
placeholder="First name"
onChange={(e) => setFirstName(e.target.value)}
defaultValue={firstName as string}
id="firstName"
id={firstNameId}
name="firstName"
/>
</div>
Expand All @@ -205,7 +201,7 @@ export const Settings = ({
placeholder="Last name"
onChange={(e) => setLastName(e.target.value)}
defaultValue={lastName as string}
id="lastName"
id={lastNameId}
name="lastName"
/>
</div>
Expand All @@ -221,7 +217,7 @@ export const Settings = ({
<Input
type="email"
value={user?.email as string}
id="contactEmail"
id={contactEmailId}
name="contactEmail"
disabled
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
"use client";

import { Button } from "@cap/ui";
import { faImage, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import clsx from "clsx";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Tooltip } from "@/components/Tooltip";

interface ProfileImageProps {
initialPreviewUrl?: string | null;
onChange?: (file: File | null) => void;
onRemove?: () => void;
disabled?: boolean;
isUploading?: boolean;
isRemoving?: boolean;
}

export function ProfileImage({
initialPreviewUrl,
onChange,
onRemove,
disabled = false,
isUploading = false,
isRemoving = false,
}: ProfileImageProps) {
const [previewUrl, setPreviewUrl] = useState<string | null>(
initialPreviewUrl || null,
);
const fileInputRef = useRef<HTMLInputElement>(null);

// Reset isRemoving when the parent confirms the operation completed
useEffect(() => {
if (initialPreviewUrl !== undefined) {
setPreviewUrl(initialPreviewUrl);
}
}, [initialPreviewUrl]);

const handleFileChange = () => {
const file = fileInputRef.current?.files?.[0];
if (!file) return;
const sizeLimit = 1024 * 1024 * 1;
if (file.size > sizeLimit) {
toast.error("File size must be 1MB or less");
return;
}
if (previewUrl && previewUrl !== initialPreviewUrl) {
URL.revokeObjectURL(previewUrl);
}
const objectUrl = URL.createObjectURL(file);
setPreviewUrl(objectUrl);
onChange?.(file);
};

const handleRemove = () => {
setPreviewUrl(null);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
onRemove?.();
};
Comment on lines +57 to +63
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Memory leak: Object URL not revoked on remove.

When removing an image, if previewUrl is a local blob URL, it must be revoked before setting to null, otherwise it leaks memory.

Apply this diff:

 	const handleRemove = () => {
+		// Revoke local object URL before removing
+		if (previewUrl && previewUrl.startsWith("blob:")) {
+			URL.revokeObjectURL(previewUrl);
+		}
 		setPreviewUrl(null);
 		if (fileInputRef.current) {
 			fileInputRef.current.value = "";
 		}
 		onRemove?.();
 	};
🤖 Prompt for AI Agents
In apps/web/app/(org)/dashboard/settings/account/components/ProfileImage.tsx
around lines 57 to 63, the handleRemove function currently sets previewUrl to
null without revoking a blob URL, causing a memory leak; update handleRemove to
check if previewUrl is non-null and appears to be an object URL (e.g., starts
with "blob:"), call URL.revokeObjectURL(previewUrl) before clearing it, then
proceed to clear fileInputRef.current.value and call onRemove, and handle any
exceptions from revokeObjectURL safely (try/catch) so removal still proceeds.


const handleUploadClick = () => {
if (!disabled && !isUploading && !isRemoving) {
fileInputRef.current?.click();
}
};

const isLoading = isUploading || isRemoving;

return (
<div className="rounded-xl border border-dashed bg-gray-2 h-fit border-gray-4">
<div className="flex gap-5 p-5">
<div
className={clsx(
"flex justify-center items-center rounded-full border size-14 bg-gray-3 border-gray-6",
previewUrl ? "border-solid" : "border-dashed",
)}
>
{previewUrl ? (
<Image
src={previewUrl}
alt="Profile Image"
width={56}
className="object-cover rounded-full size-14"
height={56}
/>
) : (
<FontAwesomeIcon icon={faImage} className="size-4 text-gray-9" />
)}
</div>
<input
type="file"
className="hidden h-0"
accept="image/jpeg, image/jpg, image/png, image/svg+xml"
ref={fileInputRef}
onChange={handleFileChange}
disabled={disabled || isLoading}
/>
<div className="space-y-3">
<div className="flex gap-2">
{!isRemoving && (
<Button
type="button"
variant="gray"
disabled={disabled || isLoading}
size="xs"
onClick={handleUploadClick}
spinner={isUploading}
>
{isUploading ? "Uploading..." : "Upload Image"}
</Button>
)}
{(previewUrl || isRemoving) && (
<Tooltip content="Remove image">
<Button
type="button"
variant="outline"
className="p-0 size-8"
disabled={disabled || isLoading}
size="icon"
onClick={handleRemove}
spinner={isRemoving}
>
<FontAwesomeIcon
icon={faTrash}
className="size-2.5 text-gray-12"
/>
</Button>
</Tooltip>
)}
</div>
<p className="text-xs text-gray-10">Recommended size: 120x120</p>
</div>
</div>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ export const OrganizationIcon = () => {
toast.success("Organization icon updated successfully");
}
} catch (error) {
console.error("Error uploading organization icon:", error);
toast.error(
error instanceof Error ? error.message : "Failed to upload icon",
);
Expand Down Expand Up @@ -75,6 +74,7 @@ export const OrganizationIcon = () => {
isLoading={isUploading}
initialPreviewUrl={existingIconUrl || null}
onRemove={handleRemoveIcon}
maxFileSizeBytes={1 * 1024 * 1024} // 1MB
/>
</div>
);
Expand Down
Loading