Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
62060b8
feat(webapp): add a profile photo editor modal with circular crop and…
kathiekiwi Aug 27, 2026
01619a9
feat(webapp): store profile photos in S3 and serve them presigned
kathiekiwi Aug 27, 2026
8068b39
feat(webapp): change your profile picture from the account page
kathiekiwi Aug 27, 2026
9245092
feat(webapp): delete the previous profile photo after a new one is st…
kathiekiwi Aug 27, 2026
41dbfad
feat(webapp): verify profile photo bytes and return fetchable avatar …
kathiekiwi Aug 27, 2026
569c46c
fix(webapp): size the avatar image to its container
kathiekiwi Aug 27, 2026
c25c7b0
fix(webapp): allow the avatar object store origin in the image policy
kathiekiwi Aug 27, 2026
cefdc34
fix(webapp): reject unsafe hosts when deriving an image policy origin
kathiekiwi Aug 27, 2026
b4cf0ae
feat(webapp): remove your profile photo from the account page
kathiekiwi Aug 27, 2026
5aca646
feat(webapp): show, remove and drag-drop the profile picture in the p…
kathiekiwi Aug 27, 2026
4516205
feat(webapp): show and remove the current profile picture from the ac…
kathiekiwi Aug 27, 2026
8128436
fix(webapp): hide the remove button once a new profile picture is picked
kathiekiwi Aug 27, 2026
89421bc
feat(webapp): serve avatar bytes from our own origin for re-cropping
kathiekiwi Aug 27, 2026
a651d06
feat(webapp): load the existing profile picture straight into the cro…
kathiekiwi Aug 27, 2026
fea2e6c
feat(webapp): show a tooltip on the account page profile picture
kathiekiwi Aug 27, 2026
820a2a7
feat(webapp): show the saved profile picture statically in the photo …
kathiekiwi Aug 27, 2026
a614903
fix(webapp): move the remove button to the footer's right slot
kathiekiwi Aug 27, 2026
3eac9a3
fix(webapp): drop refused uploads and block avatar writes while imper…
kathiekiwi Aug 27, 2026
237e84d
fix(webapp): fall back to the picker when the saved photo fails to load
kathiekiwi Aug 27, 2026
1a083d3
refactor(webapp): build the avatar routes with the dashboard route bu…
kathiekiwi Aug 27, 2026
529bfe6
feat(webapp): give profile pictures their own object store
kathiekiwi Aug 28, 2026
11afe1a
fix(webapp): only show uploaded photos in the profile picture editor
kathiekiwi Aug 28, 2026
64e24f4
fix(webapp): hide profile picture uploads when no avatar store is con…
kathiekiwi Aug 28, 2026
11af3dc
fix(webapp): reject protocol-relative avatar urls in the photo editor
kathiekiwi Aug 28, 2026
c4b7ae4
fix(webapp): sign avatar store requests as s3 and ignore blank config
kathiekiwi Aug 28, 2026
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
6 changes: 6 additions & 0 deletions .server-changes/profile-picture-upload.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---

You can now upload and crop your own profile picture from your account page.
198 changes: 198 additions & 0 deletions apps/webapp/app/components/ProfilePhotoEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { MagnifyingGlassMinusIcon, MagnifyingGlassPlusIcon } from "@heroicons/react/20/solid";
import { useEffect, useRef, useState } from "react";
import Cropper, { type Area, type Point } from "react-easy-crop";
import { Button } from "./primitives/Buttons";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "./primitives/Dialog";
import { Paragraph } from "./primitives/Paragraph";
import { Slider } from "./primitives/Slider";

const ACCEPTED_TYPES = ["image/png", "image/jpeg", "image/webp"];
const OUTPUT_SIZE = 512;
const MIN_ZOOM = 1;
const MAX_ZOOM = 3;
const ZOOM_STEP = 0.01;
const CENTER: Point = { x: 0, y: 0 };

async function cropImageToBlob(imageSrc: string, area: Area): Promise<Blob> {
const image = await loadImage(imageSrc);
const canvas = document.createElement("canvas");
canvas.width = OUTPUT_SIZE;
canvas.height = OUTPUT_SIZE;

const context = canvas.getContext("2d");
if (!context) {
throw new Error("Could not create a canvas to crop the image");
}

context.drawImage(image, area.x, area.y, area.width, area.height, 0, 0, OUTPUT_SIZE, OUTPUT_SIZE);

return await new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error("Could not crop the image"));
}
}, "image/png");
});
}

function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image();
image.addEventListener("load", () => resolve(image));
image.addEventListener("error", () => reject(new Error("Could not load the image")));
image.src = src;
});
}

type ProfilePhotoEditorProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (blob: Blob) => void;
isSaving?: boolean;
};

export function ProfilePhotoEditor({
open,
onOpenChange,
onSave,
isSaving = false,
}: ProfilePhotoEditorProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Profile picture</DialogTitle>
</DialogHeader>
{/* Radix unmounts the content when closed, so the crop state resets with it. */}
<Editor onSave={onSave} isSaving={isSaving} />
</DialogContent>
</Dialog>
);
}

function Editor({ onSave, isSaving }: Pick<ProfilePhotoEditorProps, "onSave" | "isSaving">) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [imageSrc, setImageSrc] = useState<string>();
const [crop, setCrop] = useState<Point>(CENTER);
const [zoom, setZoom] = useState(MIN_ZOOM);
const [croppedArea, setCroppedArea] = useState<Area>();
const [error, setError] = useState<string>();

useEffect(() => {
if (!imageSrc) return;
return () => URL.revokeObjectURL(imageSrc);
}, [imageSrc]);

function selectFile(file: File | undefined) {
if (!file) return;

if (!ACCEPTED_TYPES.includes(file.type)) {
setError("Choose a PNG, JPEG or WebP image.");
return;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

setCrop(CENTER);
setZoom(MIN_ZOOM);
setCroppedArea(undefined);
setError(undefined);
setImageSrc(URL.createObjectURL(file));
}

async function save() {
if (!imageSrc || !croppedArea) return;

try {
onSave(await cropImageToBlob(imageSrc, croppedArea));
} catch {
setError("Could not crop that image. Try another one.");
}
}

return (
<>
<div className="flex flex-col gap-4 pt-4">
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
className="hidden"
onChange={(event) => {
selectFile(event.target.files?.[0]);
// Or re-picking the same file after an error fires no change event.
event.target.value = "";
}}
/>
{imageSrc ? (
<>
<div className="relative h-64 w-full overflow-hidden rounded-md bg-charcoal-900">
<Cropper
image={imageSrc}
crop={crop}
zoom={zoom}
aspect={1}
cropShape="round"
showGrid={false}
minZoom={MIN_ZOOM}
maxZoom={MAX_ZOOM}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={(_, areaPixels) => setCroppedArea(areaPixels)}
/>
</div>
<Slider
variant="settings"
aria-label="Zoom"
min={MIN_ZOOM}
max={MAX_ZOOM}
step={ZOOM_STEP}
value={[zoom]}
onValueChange={([value]) => setZoom(value)}
disabled={isSaving}
LeadingIcon={MagnifyingGlassMinusIcon}
TrailingIcon={MagnifyingGlassPlusIcon}
/>
</>
) : (
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="flex h-64 w-full flex-col items-center justify-center gap-2 rounded-md border border-dashed border-grid-bright text-text-dimmed transition hover:border-text-dimmed hover:text-text-bright"
>
<Paragraph variant="small">Choose an image</Paragraph>
<Paragraph variant="extra-small">PNG, JPEG or WebP</Paragraph>
</button>
)}
{error && (
<Paragraph variant="small" className="text-error">
{error}
</Paragraph>
)}
</div>
<DialogFooter>
<Button
variant="tertiary/medium"
onClick={() => fileInputRef.current?.click()}
disabled={isSaving}
>
{imageSrc ? "Choose another" : "Choose image"}
</Button>
<Button
variant="primary/medium"
onClick={save}
disabled={!croppedArea}
isLoading={isSaving}
>
Save
</Button>
</DialogFooter>
</>
);
}
7 changes: 7 additions & 0 deletions apps/webapp/app/models/user.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,13 @@ export function updateUserEmail({ id, email }: Pick<User, "id" | "email">) {
});
}

export function updateUserAvatarUrl({ id, avatarUrl }: Pick<User, "id" | "avatarUrl">) {
return prisma.user.update({
where: { id },
data: { avatarUrl },
});
}

/**
* `updateMany` so the WHERE does the comparing: a redundant request updates zero
* rows rather than churning the row and its updatedAt.
Expand Down
59 changes: 58 additions & 1 deletion apps/webapp/app/routes/account._index/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from "@remix-run/server-runtime";
import { z } from "zod";
import { EditPencilIcon } from "~/assets/icons/EditPencilIcon";
import { ProfilePhotoEditor } from "~/components/ProfilePhotoEditor";
import { UserProfilePhoto } from "~/components/UserProfilePhoto";
import {
MainHorizontallyCenteredContainer,
Expand Down Expand Up @@ -447,6 +448,62 @@ function useProfileFieldUpdate({
return { fetcher, error, setError, isSubmitting: fetcher.state !== "idle" };
}

function ChangeProfilePhotoButton() {
const [isOpen, setIsOpen] = useState(false);
const fetcher = useFetcher<{ avatarUrl?: string; error?: string }>();
const toast = useToast();
const isSaving = fetcher.state !== "idle";
const submitSeenRef = useRef(false);

useEffect(() => {
if (fetcher.state !== "idle") {
submitSeenRef.current = true;
return;
}
if (!submitSeenRef.current) return;
submitSeenRef.current = false;

if (fetcher.data?.avatarUrl) {
// oxlint-disable-next-line react/set-state-in-effect -- Closes the modal once the upload has landed.
setIsOpen(false);
toast.success("Your profile picture has been updated.");
return;
}

toast.error(fetcher.data?.error ?? "Something went wrong. Please try again.");
}, [fetcher.state, fetcher.data, toast]);

const save = (blob: Blob) => {
const formData = new FormData();
formData.append("image", blob, "avatar.png");
fetcher.submit(formData, {
method: "post",
action: "/resources/account/avatar",
encType: "multipart/form-data",
});
};

return (
<>
<button
type="button"
onClick={() => setIsOpen(true)}
title="Change your profile picture"
aria-label="Change your profile picture"
className="focus-custom group cursor-pointer rounded-full outline-hidden"
>
<UserProfilePhoto className="size-8 transition group-hover:opacity-60" strokeWidth={1.5} />
</button>
<ProfilePhotoEditor
open={isOpen}
onOpenChange={setIsOpen}
onSave={save}
isSaving={isSaving}
/>
</>
);
}

function EditNameButton() {
const user = useUser();
const [isOpen, setIsOpen] = useState(false);
Expand Down Expand Up @@ -900,7 +957,7 @@ export default function Page() {
<Label>Profile picture</Label>
</InputGroup>
<div className="flex flex-none items-center">
<UserProfilePhoto className="size-8" strokeWidth={1.5} />
<ChangeProfilePhotoButton />
</div>
</div>
</div>
Expand Down
3 changes: 2 additions & 1 deletion apps/webapp/app/routes/api.v1.orgs.$orgParam.members.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { prisma } from "~/db.server";
import { getTeamMembersAndInvites } from "~/models/member.server";
import { resolveOrganizationForApiUser } from "~/services/organizationApiAccess.server";
import { createLoaderPATApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { absoluteUserAvatarUrl } from "~/services/userAvatar.server";

const ParamsSchema = z.object({
orgParam: z.string(),
Expand Down Expand Up @@ -51,7 +52,7 @@ export const loader = createLoaderPATApiRoute(
id: member.user.id,
name: member.user.name,
email: member.user.email,
avatarUrl: member.user.avatarUrl,
avatarUrl: absoluteUserAvatarUrl(member.user.avatarUrl),
},
})),
invites: result.invites.map((invite) => ({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { redirect, type LoaderFunctionArgs } from "@remix-run/node";
import { requireUser } from "~/services/session.server";
import { presignUserAvatarUrl, resolveUserAvatarObjectPath } from "~/services/userAvatar.server";

/**
* Presigned URLs expire, so the stored avatarUrl points here and we sign on each request.
*/
export async function loader({ request, params }: LoaderFunctionArgs) {
await requireUser(request);

const { userId, filename } = params;
const objectPath = userId && filename ? resolveUserAvatarObjectPath(userId, filename) : undefined;

if (!objectPath) {
throw new Response("Not found", { status: 404 });
}

return redirect(await presignUserAvatarUrl(objectPath));
}
33 changes: 33 additions & 0 deletions apps/webapp/app/routes/resources.account.avatar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { json, type ActionFunctionArgs } from "@remix-run/node";
import { updateUserAvatarUrl } from "~/models/user.server";
import { requireUser } from "~/services/session.server";
import {
deleteStaleUserAvatar,
isAvatarUploadRejection,
parseAvatarUpload,
uploadUserAvatar,
} from "~/services/userAvatar.server";

export async function action({ request }: ActionFunctionArgs) {
if (request.method.toUpperCase() !== "POST") {
return json({ error: "Method not allowed" }, { status: 405 });
}

const user = await requireUser(request);

const upload = await parseAvatarUpload(await request.formData());

if (isAvatarUploadRejection(upload)) {
return json({ error: upload.error }, { status: upload.status });
}

const previousAvatarUrl = user.avatarUrl;

const { avatarUrl, filename } = await uploadUserAvatar({ userId: user.id, ...upload });

await updateUserAvatarUrl({ id: user.id, avatarUrl });

await deleteStaleUserAvatar({ previousAvatarUrl, userId: user.id, filename });

return json({ avatarUrl });
}
Loading
Loading