Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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,133 @@
"use client";

import { Button } from "@cap/ui";
import { faImage } 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";

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]);
Comment thread
ameer2468 marked this conversation as resolved.

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);
};
Comment thread
ameer2468 marked this conversation as resolved.
Comment thread
ameer2468 marked this conversation as resolved.

const handleRemove = () => {
setPreviewUrl(null);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
onRemove?.();
};
Comment on lines +57 to +63
Copy link
Copy Markdown
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) && (
<Button
type="button"
variant="outline"
disabled={disabled || isLoading}
size="xs"
onClick={handleRemove}
spinner={isRemoving}
>
{isRemoving ? "Removing image..." : "Remove"}
</Button>
)}
</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
Loading