Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,13 @@ POSTHOG_PROJECT_KEY=
# OBJECT_STORE_R2_SECRET_ACCESS_KEY=
# OBJECT_STORE_R2_REGION=auto
# OBJECT_STORE_R2_SERVICE=s3
#
# Profile pictures get their own store, separate from task payloads
# AVATARS_OBJECT_STORE_BASE_URL=http://localhost:9005
# AVATARS_OBJECT_STORE_BUCKET=avatars
# AVATARS_OBJECT_STORE_ACCESS_KEY_ID=minioadmin
# AVATARS_OBJECT_STORE_SECRET_ACCESS_KEY=minioadmin
# AVATARS_OBJECT_STORE_REGION=us-east-1
# CHECKPOINT_THRESHOLD_IN_MS=10000

# These control the server-side internal telemetry
Expand Down
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, and remove it again whenever you like.
273 changes: 273 additions & 0 deletions apps/webapp/app/components/ProfilePhotoEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
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 { cn } from "~/utils/cn";
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;
currentAvatarUrl?: string;
onRemove?: () => void;
isSaving?: boolean;
};

export function ProfilePhotoEditor({
open,
onOpenChange,
isSaving = false,
...editorProps
}: 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 {...editorProps} isSaving={isSaving} />
</DialogContent>
</Dialog>
);
}

type EditorProps = Omit<ProfilePhotoEditorProps, "open" | "onOpenChange">;

function Editor({ onSave, currentAvatarUrl, onRemove, isSaving }: EditorProps) {
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>();
const [isDraggingOver, setIsDraggingOver] = useState(false);
// Holding the url rather than a flag resets the fallback when it changes.
const [failedUrl, setFailedUrl] = useState<string>();

const savedPhotoUrl = currentAvatarUrl === failedUrl ? undefined : currentAvatarUrl;

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

// A drop landing outside our own handlers would navigate the tab to the file
// and lose the crop. Editor only exists while the dialog is open.
useEffect(() => {
const suppress = (event: DragEvent) => event.preventDefault();
window.addEventListener("dragover", suppress);
window.addEventListener("drop", suppress);
return () => {
window.removeEventListener("dragover", suppress);
window.removeEventListener("drop", suppress);
};
}, []);

function selectFile(file: File | undefined) {
if (isSaving) return;
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"
onDragOver={(event) => {
event.preventDefault();
setIsDraggingOver(true);
}}
onDragLeave={(event) => {
// Moving between children fires dragleave too, so ignore inside targets.
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
setIsDraggingOver(false);
}}
onDrop={(event) => {
event.preventDefault();
setIsDraggingOver(false);
selectFile(event.dataTransfer.files[0]);
}}
>
<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={cn(
"relative h-64 w-full overflow-hidden rounded-md bg-charcoal-900 ring-1",
isDraggingOver ? "ring-primary" : "ring-transparent"
)}
>
<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}
/>
</>
) : savedPhotoUrl ? (
<div
className={cn(
"flex h-64 w-full items-center justify-center rounded-md bg-charcoal-900 ring-1",
isDraggingOver ? "ring-primary" : "ring-transparent"
)}
>
{/* Fills the box like the cropper's circle, so switching doesn't jump. */}
<img
src={savedPhotoUrl}
alt=""
className="aspect-square h-full rounded-full object-cover"
draggable={false}
onError={() => setFailedUrl(savedPhotoUrl)}
/>
</div>
) : (
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className={cn(
"flex h-64 w-full flex-col items-center justify-center gap-2 rounded-md border border-dashed text-text-dimmed transition hover:border-text-dimmed hover:text-text-bright",
isDraggingOver ? "border-primary" : "border-grid-bright"
)}
>
<Paragraph variant="small">Choose or drop 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 || savedPhotoUrl ? "Choose another" : "Choose image"}
</Button>
{/* Nothing to save until a new file is cropped, so the saved photo offers
Remove in the same slot instead. Still offered when the preview failed
to load: there is a stored photo worth removing. */}
{imageSrc ? (
<Button
variant="primary/medium"
onClick={save}
disabled={!croppedArea}
isLoading={isSaving}
>
Save
</Button>
) : (
onRemove &&
currentAvatarUrl && (
<Button variant="danger/medium" onClick={onRemove} disabled={isSaving}>
Remove
</Button>
)
)}
</DialogFooter>
</div>
);
}
2 changes: 1 addition & 1 deletion apps/webapp/app/components/UserProfilePhoto.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export function UserAvatar({
return (
<div className={cn("grid aspect-square place-items-center", className)}>
<img
className={cn("aspect-square rounded-full p-[7%]")}
className="size-full min-h-0 min-w-0 rounded-full object-cover"
src={avatarUrl}
alt={name ?? "User"}
referrerPolicy="no-referrer"
Expand Down
14 changes: 10 additions & 4 deletions apps/webapp/app/entry.server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,13 @@ import { assertRunOpsSplitSentinel, Prisma } from "./db.server";
import { env } from "./env.server";
import { eventLoopMonitor, eventLoopUtilizationMonitor } from "./eventLoopMonitor.server";
import { logger } from "./services/logger.server";
import { buildImgSrcDirective, parseCspImageOrigins, withImgSrc } from "./utils/cspImageOrigins";
import { avatarObjectStoreImageOrigin } from "./services/userAvatar.server";
import {
appendImageOrigin,
buildImgSrcDirective,
parseCspImageOrigins,
withImgSrc,
} from "./utils/cspImageOrigins";
import { singleton } from "./utils/singleton";
import { remoteBuildsEnabled } from "./v3/remoteImageBuilder.server";
import {
Expand Down Expand Up @@ -66,8 +72,8 @@ const ABORT_DELAY = 30000;
* ships in the stacked UI PR, so on this branch the policy is the only thing stopping
* a model- or customer-authored image from reaching a remote host.
*
* The hosts we store avatar URLs for, plus whatever `CSP_IMG_SRC_ALLOWLIST` adds
* (e.g. a self-hosted SSO avatar host).
* The hosts we store avatar URLs for, the object store uploaded avatars are presigned
* from, plus whatever `CSP_IMG_SRC_ALLOWLIST` adds (e.g. a self-hosted SSO avatar host).
*/
const IMG_SRC_DIRECTIVE = buildImgSrcDirective(
singleton("CspImageOrigins", () => {
Expand All @@ -81,7 +87,7 @@ const IMG_SRC_DIRECTIVE = buildImgSrcDirective(
);
}

return origins;
return appendImageOrigin(origins, avatarObjectStoreImageOrigin());
})
);

Expand Down
11 changes: 11 additions & 0 deletions apps/webapp/app/env.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -860,6 +860,17 @@ const EnvironmentSchema = z
.regex(/^[a-z0-9]+$/)
.optional(),

// Avatars get their own store, like artifacts: a public-facing image bucket is not
// the bucket task payloads live in.
AVATARS_OBJECT_STORE_BASE_URL: z.string().optional(),
AVATARS_OBJECT_STORE_BUCKET: z.string().optional(),
AVATARS_OBJECT_STORE_ACCESS_KEY_ID: z.string().optional(),
AVATARS_OBJECT_STORE_SECRET_ACCESS_KEY: z.string().optional(),
AVATARS_OBJECT_STORE_REGION: z.string().optional(),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Signed as "s3" unless told otherwise, like the shared store: aws4fetch otherwise
// guesses the SigV4 service from the hostname and gets it wrong off amazonaws.com.
AVATARS_OBJECT_STORE_SERVICE: z.string().default("s3"),

ARTIFACTS_OBJECT_STORE_BUCKET: z.string().optional(),
ARTIFACTS_OBJECT_STORE_BASE_URL: z.string().optional(),
ARTIFACTS_OBJECT_STORE_ACCESS_KEY_ID: z.string().optional(),
Expand Down
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
Loading