Skip to content

Commit

Permalink
Set custom avatar picture in user profile (#1119)
Browse files Browse the repository at this point in the history
* Add API endpoint to upload user profile picture

* Add first version of image upload component

* Improve avatar uploader component

* Update user profile picture in session when needed

* Simplify the /account/me API endpoint

* Switch Next image pipeline to correct user data blob storage
  • Loading branch information
zoul authored Oct 29, 2024
1 parent 3682c54 commit ae76361
Show file tree
Hide file tree
Showing 6 changed files with 238 additions and 10 deletions.
136 changes: 136 additions & 0 deletions app/account/AvatarUploader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { useEffect, useRef, useState } from "react";
import Image from "next/image";

import { type PutBlobResult } from "@vercel/blob";
import clsx from "clsx";

import { FormError } from "~/components/form/FormError";
import { defaultAvatarUrl } from "~/src/utils";

type Props = {
currentImageUrl?: string;
onImageChange: (uploadedImageUrl: string | undefined) => void;
};

export function AvatarUploader({ currentImageUrl, onImageChange }: Props) {
const [previewImageUrl, setPreviewImageUrl] = useState(currentImageUrl);
const [errorMessage, setErrorMessage] = useState("");
const [uploading, setUploading] = useState(false);

useEffect(() => {
setPreviewImageUrl(currentImageUrl);
}, [currentImageUrl]);

const handleUpload = async (file: File, imageDataUrl: string) => {
setUploading(true);
setErrorMessage("");
setPreviewImageUrl(imageDataUrl);

const response = await fetch(
`/account/profile-picture?filename=${file.name}`,
{
method: "POST",
body: file,
},
);

if (response.ok) {
const newBlob = (await response.json()) as PutBlobResult;
onImageChange(newBlob.url);
} else {
setErrorMessage("Něco se nepovedlo, zkus to prosím ještě jednou?");
}

setUploading(false);
};

return (
<div className="flex flex-col gap-3">
<AvatarPreview
imageUrl={previewImageUrl}
showProgressIndicator={uploading}
/>
<ImageFilePicker handleImageData={handleUpload} />
{errorMessage && <FormError error={errorMessage} />}
</div>
);
}

type ImageFilePickerProps = {
handleImageData: (file: File, imageDataUrl: string) => void;
};

const ImageFilePicker = ({ handleImageData }: ImageFilePickerProps) => {
const inputFileRef = useRef<HTMLInputElement>(null);
const [errorMessage, setErrorMessage] = useState("");

const handleFileSelection = () => {
setErrorMessage("");

if (!inputFileRef.current?.files) {
setErrorMessage("Není vybraný soubor");
return;
}

const file = inputFileRef.current.files[0];

if (file.size > 4500000) {
setErrorMessage("Soubor musí být menší než 4,5 MB");
return;
}

if (!file.type.startsWith("image/")) {
setErrorMessage("Soubor musí mít formát obrázku");
return;
}

const fileReader = new FileReader();
fileReader.onload = (e) => {
if (e.target && typeof e.target.result === "string") {
handleImageData(file, e.target.result);
} else {
setErrorMessage("Něco se nepovedlo, zkus to znovu");
}
};

fileReader.readAsDataURL(file);
};

return (
<div className="flex flex-col gap-2">
<input
className="max-w-prose"
name="file"
ref={inputFileRef}
type="file"
required
onChange={handleFileSelection}
/>
{errorMessage && <FormError error={errorMessage} />}
</div>
);
};

const AvatarPreview = ({
imageUrl,
showProgressIndicator = false,
}: {
imageUrl?: string;
showProgressIndicator?: boolean;
}) => (
<div className="flex flex-col gap-2">
<label htmlFor="avatarImage" className="block">
Profilová fotka:
</label>
<Image
src={imageUrl ?? defaultAvatarUrl}
className={clsx(
"h-[100px] w-[100px] rounded-full bg-gray object-cover shadow",
showProgressIndicator && "animate-pulse",
)}
alt="Náhled současné profilovky"
width={100}
height={100}
/>
</div>
);
12 changes: 12 additions & 0 deletions app/account/UserProfileTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import {
import Link from "next/link";

import clsx from "clsx";
import { useSession } from "next-auth/react";

import { AvatarUploader } from "~/app/account/AvatarUploader";
import { CopyToClipboardButton } from "~/components/CopyToClipboardButton";
import { DistrictSelect } from "~/components/districts/DistrictSelect";
import { FormError } from "~/components/form/FormError";
Expand Down Expand Up @@ -50,6 +52,7 @@ export const UserProfileTab = () => {
};

const BasicInfoSection = ({ model, updating, onChange }: SectionProps) => {
const { update: updateSession } = useSession();
return (
<section className="flex max-w-prose flex-col gap-7">
<h2 className="typo-title2">Základní informace</h2>
Expand Down Expand Up @@ -94,6 +97,15 @@ const BasicInfoSection = ({ model, updating, onChange }: SectionProps) => {
onSave={(name) => onChange({ ...model!, name })}
/>

<AvatarUploader
currentImageUrl={model?.profilePictureUrl}
onImageChange={async (profilePictureUrl) => {
// TBD: Handle deletion
onChange({ ...model!, profilePictureUrl });
await updateSession({ image: profilePictureUrl });
}}
/>

<InputWithSaveButton
id="contactMail"
type="email"
Expand Down
8 changes: 3 additions & 5 deletions app/account/me/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,6 @@ export async function GET() {
/** Change user profile, used for stuff like updating user preferences */
export async function PATCH(request: NextRequest) {
return withAuthenticatedUser(async (user) => {
const profile = await getUserProfile(user.id);
if (!profile) {
return new Response("User profile not found.", { status: 404 });
}
// Make sure we do NOT include the `slackId` field nor `state` here
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
const {
Expand All @@ -92,14 +88,16 @@ export async function PATCH(request: NextRequest) {
maxSeniority,
occupation,
organizationName,
profilePictureUrl,
profileUrl,
} = await request.json();
await updateUserProfile(profile.id, {
await updateUserProfile(user.id, {
name,
notificationFlags,
privacyFlags,
contactEmail,
availableInDistricts,
profilePictureUrl,
bio,
tags,
maxSeniority,
Expand Down
46 changes: 46 additions & 0 deletions app/account/profile-picture/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import crypto from "crypto";

import { NextResponse } from "next/server";

import { put } from "@vercel/blob";

import { withAuthenticatedUser } from "~/src/auth";

export async function POST(request: Request): Promise<Response> {
return withAuthenticatedUser(async () => {
if (!request.body) {
return new Response("Missing payload", { status: 400 });
}

// We use the original filename to get the extension
const { searchParams } = new URL(request.url);
const originalFilename = searchParams.get("filename");
if (!originalFilename) {
return new Response("Missing filename", { status: 400 });
}

// But the file name itself is an 8-character prefix of the SHA1
// of file content
const data = await getArrayBufferView(request.body);
const hash = shasumPrefix(new Uint8Array(data));
const target = hash + "." + getFilenameExtension(originalFilename);

// Upload
const blob = await put(target, data, {
addRandomSuffix: false,
access: "public",
token: process.env.USER_BLOB_READ_WRITE_TOKEN,
});

return NextResponse.json(blob);
});
}

const getArrayBufferView = async (data: ReadableStream<Uint8Array>) =>
new Response(data).arrayBuffer();

const shasumPrefix = (data: crypto.BinaryLike) =>
crypto.createHash("sha1").update(data).digest("hex").slice(0, 8);

const getFilenameExtension = (name: string) =>
name.split(".").pop()?.toLowerCase();
2 changes: 1 addition & 1 deletion next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ module.exports = withAxiom({
},
{
protocol: "https",
hostname: "bbp30zne50ll9cz3.public.blob.vercel-storage.com",
hostname: "mogrfyhmal8klgqy.public.blob.vercel-storage.com",
},
],
},
Expand Down
44 changes: 40 additions & 4 deletions src/auth.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import sendgrid from "@sendgrid/mail";
import { getServerSession, type NextAuthOptions } from "next-auth";
import {
getServerSession,
type DefaultSession,
type NextAuthOptions,
} from "next-auth";
import EmailProvider from "next-auth/providers/email";
import SlackProvider from "next-auth/providers/slack";
import { optional, record, string } from "typescript-json-decoder";

import {
authDatabaseAdapter,
Expand Down Expand Up @@ -63,6 +68,8 @@ export const authOptions: NextAuthOptions = {
],

callbacks: {
// This callback is used to control whether a user can sign in.
// https://next-auth.js.org/configuration/callbacks#sign-in-callback
async signIn({ user }) {
if (user.email) {
const existingUser = await getUserByEmail(user.email);
Expand All @@ -82,14 +89,43 @@ export const authOptions: NextAuthOptions = {
}
},

// TBD: Comment
// The session callback is called whenever a session is checked.
// https://next-auth.js.org/configuration/callbacks#session-callback
async session({ session, token }) {
function assertIsOurUser(
user: DefaultSession["user"],
): asserts user is OurUser {
/* If there is a user it’s always OurUser */
}
if (session.user) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(session.user as any as OurUser).id = token.sub!;
assertIsOurUser(session.user);
// Expose our user ID to the client side
session.user.id = token.sub!;
// If there is user image, expose that to the client, too
if (typeof token.image === "string") {
session.user.image = token.image;
}
}
return session;
},

// This callback is called whenever a JSON Web Token is created (i.e. at sign in)
// or updated (i.e whenever a session is accessed in the client).
// https://next-auth.js.org/configuration/callbacks#jwt-callback
async jwt({ token, session: updateContext }) {
// The session object here is whatever was sent from the client using the `update` function.
// Currently our only use case for updating the session is changing the profile picture.
const decodeContext = record({
image: optional(string),
});
try {
const sessionUpdateData = decodeContext(updateContext);
token.image = sessionUpdateData.image;
} catch {
/* ignoring intentionally */
}
return token;
},
},

session: {
Expand Down

0 comments on commit ae76361

Please sign in to comment.