Skip to content

Commit c3c35a6

Browse files
committed
User API fixes
1 parent aea9469 commit c3c35a6

11 files changed

Lines changed: 267 additions & 56 deletions

File tree

app/api/user/avatar/route.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { getTokens } from "next-firebase-auth-edge";
3+
import { authConfig } from "@/lib/firebase/auth-edge";
4+
import { adminStorage } from "@/lib/firebase/admin";
5+
import { updateUserProfile } from "@/lib/firestore/users";
6+
import sharp from "sharp";
7+
8+
const BUCKET = process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET!;
9+
const SIZE = 400;
10+
11+
export async function POST(request: NextRequest) {
12+
const tokens = await getTokens(request.cookies, authConfig);
13+
if (!tokens) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
14+
15+
const uid = tokens.decodedToken.uid;
16+
17+
const formData = await request.formData();
18+
const file = formData.get("file") as File | null;
19+
if (!file) return NextResponse.json({ error: "No file provided" }, { status: 400 });
20+
21+
const arrayBuffer = await file.arrayBuffer();
22+
const buffer = Buffer.from(arrayBuffer);
23+
24+
const processed = await sharp(buffer)
25+
.resize(SIZE, SIZE, { fit: "cover" })
26+
.webp({ quality: 85 })
27+
.toBuffer();
28+
29+
const filePath = `profile-pictures/${uid}.webp`;
30+
const bucket = adminStorage.bucket(BUCKET);
31+
const fileRef = bucket.file(filePath);
32+
const token = crypto.randomUUID();
33+
34+
await fileRef.save(processed, {
35+
metadata: {
36+
contentType: "image/webp",
37+
metadata: { firebaseStorageDownloadTokens: token },
38+
},
39+
});
40+
41+
const host =
42+
process.env.NODE_ENV === "development"
43+
? `http://localhost:9199`
44+
: `https://firebasestorage.googleapis.com`;
45+
46+
const url = `${host}/v0/b/${BUCKET}/o/${encodeURIComponent(filePath)}?alt=media&token=${token}`;
47+
48+
await updateUserProfile(uid, { profilePictureUrl: url });
49+
50+
return NextResponse.json({ url });
51+
}

components/auth/CompleteProfileForm.tsx

Lines changed: 10 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { signOut } from "firebase/auth";
99
import { auth } from "@/lib/firebase/client";
1010
import { useAuth } from "@/lib/auth/context";
1111
import { OrganizationSelector } from "@/components/profile/OrganizationSelector";
12-
import { uploadProfilePicture } from "@/lib/storage/upload";
12+
import { ImageUpload } from "@/components/profile/ImageUpload";
1313
import { AppDevRole } from "@/types";
1414
import { Button } from "@/components/ui/button";
1515
import { Input } from "@/components/ui/input";
@@ -36,7 +36,7 @@ export function CompleteProfileForm() {
3636
const { user, profile, loading } = useAuth();
3737
const router = useRouter();
3838
const [submitting, setSubmitting] = useState(false);
39-
const [profileFile, setProfileFile] = useState<File | null>(null);
39+
const [profilePictureUrl, setProfilePictureUrl] = useState<string | undefined>(undefined);
4040
const [selectedRoles, setSelectedRoles] = useState<AppDevRole[]>([]);
4141
const [selectedOrganizationIds, setSelectedOrganizationIds] = useState<string[]>([]);
4242

@@ -62,12 +62,7 @@ export function CompleteProfileForm() {
6262
if (!user) return;
6363
setSubmitting(true);
6464
try {
65-
let profilePictureUrl: string | undefined = user.photoURL ?? undefined;
66-
if (profileFile) {
67-
profilePictureUrl = await uploadProfilePicture(user.uid, profileFile);
68-
}
69-
70-
const res = await fetch("/api/me/complete", {
65+
const res = await fetch("/api/user/complete", {
7166
method: "POST",
7267
headers: { "Content-Type": "application/json" },
7368
body: JSON.stringify({
@@ -78,7 +73,7 @@ export function CompleteProfileForm() {
7873
phoneNumber: data.phoneNumber || undefined,
7974
organizationIds: selectedOrganizationIds,
8075
appDevRoles: selectedRoles,
81-
profilePictureUrl,
76+
profilePictureUrl: profilePictureUrl ?? user.photoURL ?? undefined,
8277
}),
8378
});
8479

@@ -175,20 +170,12 @@ export function CompleteProfileForm() {
175170
<p className="text-sm text-destructive">{errors.phoneNumber.message}</p>
176171
)}
177172
</div>
178-
<div className="space-y-1">
179-
<Label htmlFor="picture">Profile picture (optional)</Label>
180-
{user.photoURL && !profileFile && (
181-
<p className="text-xs text-muted-foreground">
182-
Using your Google photo — upload to replace.
183-
</p>
184-
)}
185-
<Input
186-
id="picture"
187-
type="file"
188-
accept="image/*"
189-
onChange={(e) => setProfileFile(e.target.files?.[0] ?? null)}
190-
/>
191-
</div>
173+
<ImageUpload
174+
currentUrl={user.photoURL ?? undefined}
175+
onUploaded={setProfilePictureUrl}
176+
name={user.displayName ?? undefined}
177+
label="Profile picture (optional)"
178+
/>
192179
<Button type="submit" className="w-full" disabled={submitting}>
193180
{submitting ? "Saving…" : "Complete sign-up"}
194181
</Button>

components/profile/ImageUpload.tsx

Lines changed: 136 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,155 @@
11
"use client";
22

3-
import { useState } from "react";
3+
import { useRef, useState } from "react";
4+
import ReactCrop, { centerCrop, makeAspectCrop, type Crop } from "react-image-crop";
5+
import "react-image-crop/dist/ReactCrop.css";
6+
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
7+
import { Button } from "@/components/ui/button";
8+
import {
9+
Dialog,
10+
DialogContent,
11+
DialogFooter,
12+
DialogHeader,
13+
DialogTitle,
14+
} from "@/components/ui/dialog";
415
import { Input } from "@/components/ui/input";
516
import { Label } from "@/components/ui/label";
6-
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
717

818
interface ImageUploadProps {
919
currentUrl?: string;
10-
onFileSelect: (file: File) => void;
20+
onUploaded: (url: string) => void;
1121
label?: string;
22+
name?: string;
1223
}
1324

14-
export function ImageUpload({ currentUrl, onFileSelect, label = "Photo" }: ImageUploadProps) {
15-
const [preview, setPreview] = useState<string | undefined>(currentUrl);
25+
function getCroppedBlob(image: HTMLImageElement, crop: Crop): Promise<Blob> {
26+
const canvas = document.createElement("canvas");
27+
canvas.width = 800;
28+
canvas.height = 800;
29+
const ctx = canvas.getContext("2d")!;
30+
31+
const scaleX = image.naturalWidth / image.width;
32+
const scaleY = image.naturalHeight / image.height;
33+
34+
const px =
35+
crop.unit === "%"
36+
? {
37+
x: (crop.x / 100) * image.width * scaleX,
38+
y: (crop.y / 100) * image.height * scaleY,
39+
w: (crop.width / 100) * image.width * scaleX,
40+
h: (crop.height / 100) * image.height * scaleY,
41+
}
42+
: {
43+
x: crop.x * scaleX,
44+
y: crop.y * scaleY,
45+
w: crop.width * scaleX,
46+
h: crop.height * scaleY,
47+
};
48+
49+
ctx.drawImage(image, px.x, px.y, px.w, px.h, 0, 0, 800, 800);
50+
51+
return new Promise((resolve, reject) => {
52+
canvas.toBlob(
53+
(blob) => (blob ? resolve(blob) : reject(new Error("Canvas is empty"))),
54+
"image/jpeg",
55+
0.95,
56+
);
57+
});
58+
}
1659

17-
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
60+
export function ImageUpload({ currentUrl, onUploaded, label = "Photo", name }: ImageUploadProps) {
61+
const [preview, setPreview] = useState<string | undefined>(currentUrl || undefined);
62+
const [imgSrc, setImgSrc] = useState("");
63+
const [crop, setCrop] = useState<Crop>();
64+
const [uploading, setUploading] = useState(false);
65+
const imgRef = useRef<HTMLImageElement>(null);
66+
const inputRef = useRef<HTMLInputElement>(null);
67+
68+
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
1869
const file = e.target.files?.[0];
1970
if (!file) return;
20-
setPreview(URL.createObjectURL(file));
21-
onFileSelect(file);
71+
const reader = new FileReader();
72+
reader.onload = () => setImgSrc(reader.result as string);
73+
reader.readAsDataURL(file);
74+
// reset so the same file can be re-selected
75+
e.target.value = "";
76+
};
77+
78+
const onImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
79+
const { width, height } = e.currentTarget;
80+
setCrop(centerCrop(makeAspectCrop({ unit: "%", width: 90 }, 1, width, height), width, height));
81+
};
82+
83+
const handleApply = async () => {
84+
if (!imgRef.current || !crop) return;
85+
setUploading(true);
86+
try {
87+
const blob = await getCroppedBlob(imgRef.current, crop);
88+
const form = new FormData();
89+
form.append("file", blob, "avatar.jpg");
90+
const res = await fetch("/api/user/avatar", { method: "POST", body: form });
91+
if (!res.ok) throw new Error("Upload failed");
92+
const { url } = await res.json();
93+
setPreview(url);
94+
onUploaded(url);
95+
setImgSrc("");
96+
} catch (err) {
97+
console.error(err);
98+
} finally {
99+
setUploading(false);
100+
}
22101
};
23102

24103
return (
25-
<div className="space-y-2">
26-
<Label>{label}</Label>
27-
<div className="flex items-center gap-3">
28-
<Avatar className="h-14 w-14">
29-
<AvatarImage src={preview} />
30-
<AvatarFallback className="text-xs">IMG</AvatarFallback>
31-
</Avatar>
32-
<Input type="file" accept="image/*" onChange={handleChange} className="max-w-xs" />
104+
<>
105+
<div className="space-y-2">
106+
<Label>{label}</Label>
107+
<div className="flex items-center gap-3">
108+
<Avatar className="h-14 w-14">
109+
<AvatarImage src={preview} />
110+
<AvatarFallback className="text-xs">
111+
{name
112+
? name.split(" ").map((n) => n[0]).join("").toUpperCase().slice(0, 2)
113+
: "?"}
114+
</AvatarFallback>
115+
</Avatar>
116+
<Input
117+
ref={inputRef}
118+
type="file"
119+
accept="image/*"
120+
onChange={handleFileChange}
121+
className="max-w-xs"
122+
/>
123+
</div>
33124
</div>
34-
</div>
125+
126+
<Dialog open={!!imgSrc} onOpenChange={(open) => !open && setImgSrc("")}>
127+
<DialogContent className="max-w-lg">
128+
<DialogHeader>
129+
<DialogTitle>Crop photo</DialogTitle>
130+
</DialogHeader>
131+
<div className="flex justify-center">
132+
<ReactCrop
133+
crop={crop}
134+
onChange={(_, pct) => setCrop(pct)}
135+
aspect={1}
136+
circularCrop
137+
keepSelection
138+
>
139+
{/* eslint-disable-next-line @next/next/no-img-element */}
140+
<img ref={imgRef} src={imgSrc} onLoad={onImageLoad} alt="Crop preview" />
141+
</ReactCrop>
142+
</div>
143+
<DialogFooter>
144+
<Button variant="outline" onClick={() => setImgSrc("")} disabled={uploading}>
145+
Cancel
146+
</Button>
147+
<Button onClick={handleApply} disabled={!crop || uploading}>
148+
{uploading ? "Uploading…" : "Apply"}
149+
</Button>
150+
</DialogFooter>
151+
</DialogContent>
152+
</Dialog>
153+
</>
35154
);
36155
}

components/profile/OrganizationSelector.tsx

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export function OrganizationSelector({ selectedIds, onChange }: OrganizationSele
1616
const [search, setSearch] = useState("");
1717
const [open, setOpen] = useState(false);
1818
const [creating, setCreating] = useState(false);
19+
const [highlightedIndex, setHighlightedIndex] = useState(0);
1920
const containerRef = useRef<HTMLDivElement>(null);
2021

2122
useEffect(() => {
@@ -44,11 +45,34 @@ export function OrganizationSelector({ selectedIds, onChange }: OrganizationSele
4445
(o) => o.name.toLowerCase() === search.trim().toLowerCase()
4546
);
4647
const showDropdown = open && (filtered.length > 0 || (search.trim() && !exactMatch));
48+
// Total items in dropdown: filtered orgs + optional "Create" row
49+
const dropdownCount = filtered.length + (search.trim() && !exactMatch ? 1 : 0);
50+
51+
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
52+
if (!showDropdown) return;
53+
if (e.key === "ArrowDown") {
54+
e.preventDefault();
55+
setHighlightedIndex((i) => Math.min(i + 1, dropdownCount - 1));
56+
} else if (e.key === "ArrowUp") {
57+
e.preventDefault();
58+
setHighlightedIndex((i) => Math.max(i - 1, 0));
59+
} else if (e.key === "Enter") {
60+
e.preventDefault();
61+
if (highlightedIndex < filtered.length) {
62+
handleSelect(filtered[highlightedIndex]);
63+
} else {
64+
handleCreate();
65+
}
66+
} else if (e.key === "Escape") {
67+
setOpen(false);
68+
}
69+
};
4770

4871
const handleSelect = (org: Organization) => {
4972
onChange([...selectedIds, org.id]);
5073
setSearch("");
5174
setOpen(false);
75+
setHighlightedIndex(0);
5276
};
5377

5478
const handleRemove = (id: string) => {
@@ -98,32 +122,35 @@ export function OrganizationSelector({ selectedIds, onChange }: OrganizationSele
98122
<Input
99123
placeholder="Search organizations…"
100124
value={search}
101-
onChange={(e) => setSearch(e.target.value)}
125+
onChange={(e) => { setSearch(e.target.value); setHighlightedIndex(0); setOpen(true); }}
102126
onFocus={() => setOpen(true)}
127+
onKeyDown={handleKeyDown}
103128
/>
104129
{showDropdown && (
105130
<div className="absolute z-10 top-full mt-1 w-full bg-background border rounded-md shadow-md max-h-48 overflow-y-auto">
106-
{filtered.map((o) => (
131+
{filtered.map((o, i) => (
107132
<button
108133
key={o.id}
109134
type="button"
110-
className="w-full text-left px-3 py-2 text-sm hover:bg-muted"
135+
className={`w-full text-left px-3 py-2 text-sm hover:bg-muted ${i === highlightedIndex ? "bg-muted" : ""}`}
111136
onMouseDown={(e) => {
112137
e.preventDefault();
113138
handleSelect(o);
114139
}}
140+
onMouseEnter={() => setHighlightedIndex(i)}
115141
>
116142
{o.name}
117143
</button>
118144
))}
119145
{search.trim() && !exactMatch && (
120146
<button
121147
type="button"
122-
className="w-full text-left px-3 py-2 text-sm hover:bg-muted flex items-center gap-2 text-primary border-t"
148+
className={`w-full text-left px-3 py-2 text-sm hover:bg-muted flex items-center gap-2 text-primary border-t ${highlightedIndex === filtered.length ? "bg-muted" : ""}`}
123149
onMouseDown={(e) => {
124150
e.preventDefault();
125151
handleCreate();
126152
}}
153+
onMouseEnter={() => setHighlightedIndex(filtered.length)}
127154
disabled={creating}
128155
>
129156
<Plus className="h-4 w-4" />

0 commit comments

Comments
 (0)