Custom Profile Pictures (#1339)

This commit is contained in:
Luke Vella 2024-09-09 16:51:42 +01:00 committed by GitHub
parent 1276bc27b3
commit e723d9a933
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 383 additions and 376 deletions

View file

@ -17,9 +17,6 @@ const nextConfig = {
output: output:
process.env.NEXT_PUBLIC_SELF_HOSTED === "true" ? "standalone" : undefined, process.env.NEXT_PUBLIC_SELF_HOSTED === "true" ? "standalone" : undefined,
productionBrowserSourceMaps: true, productionBrowserSourceMaps: true,
images: {
domains: [process.env.NEXT_PUBLIC_BASE_URL || "localhost:3000"],
},
transpilePackages: [ transpilePackages: [
"@rallly/database", "@rallly/database",
"@rallly/icons", "@rallly/icons",
@ -54,6 +51,10 @@ const nextConfig = {
}, },
]; ];
}, },
experimental: {
// necessary for server actions using aws-sdk
serverComponentsExternalPackages: ["@aws-sdk"],
},
}; };
const sentryWebpackPluginOptions = { const sentryWebpackPluginOptions = {

View file

@ -275,12 +275,12 @@
"noKeepCurrentTimezone": "No, keep the current timezone", "noKeepCurrentTimezone": "No, keep the current timezone",
"annualBenefit": "{count} months free", "annualBenefit": "{count} months free",
"removeAvatar": "Remove", "removeAvatar": "Remove",
"featureNotAvailable": "Feature not available",
"featureNotAvailableDescription": "This feature requires object storage to be enabled.",
"uploadProfilePicture": "Upload", "uploadProfilePicture": "Upload",
"profilePictureDescription": "Up to 2MB, JPG or PNG", "profilePictureDescription": "Up to 2MB, JPG or PNG",
"invalidFileType": "Invalid file type", "invalidFileType": "Invalid file type",
"invalidFileTypeDescription": "Please upload a JPG or PNG file.", "invalidFileTypeDescription": "Please upload a JPG or PNG file.",
"fileTooLarge": "File too large", "fileTooLarge": "File too large",
"fileTooLargeDescription": "Please upload a file smaller than 2MB." "fileTooLargeDescription": "Please upload a file smaller than 2MB.",
"errorUploadPicture": "Failed to upload",
"errorUploadPictureDescription": "There was an issue uploading your picture. Please try again later."
} }

View file

@ -10,8 +10,8 @@ import Link from "next/link";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { DeleteAccountDialog } from "@/app/[locale]/(admin)/settings/profile/delete-account-dialog"; import { DeleteAccountDialog } from "@/app/[locale]/(admin)/settings/profile/delete-account-dialog";
import { ProfileSettings } from "@/app/[locale]/(admin)/settings/profile/profile-settings";
import { LogoutButton } from "@/app/components/logout-button"; import { LogoutButton } from "@/app/components/logout-button";
import { ProfileSettings } from "@/components/settings/profile-settings";
import { import {
Settings, Settings,
SettingsContent, SettingsContent,

View file

@ -0,0 +1,198 @@
import { Button } from "@rallly/ui/button";
import { useToast } from "@rallly/ui/hooks/use-toast";
import * as Sentry from "@sentry/nextjs";
import React, { useState } from "react";
import { z } from "zod";
import { useTranslation } from "@/app/i18n/client";
import { CurrentUserAvatar } from "@/components/current-user-avatar";
import { Trans } from "@/components/trans";
import { useUser } from "@/components/user-provider";
import { useAvatarsEnabled } from "@/features/avatars";
import { usePostHog } from "@/utils/posthog";
import { trpc } from "@/utils/trpc/client";
const allowedMimeTypes = z.enum(["image/jpeg", "image/png"]);
function ChangeAvatarButton({ onSuccess }: { onSuccess: () => void }) {
const getPresignedUrl = trpc.user.getAvatarUploadUrl.useMutation();
const updateAvatar = trpc.user.updateAvatar.useMutation();
const { t } = useTranslation();
const { toast } = useToast();
const [isUploading, setIsUploading] = useState(false);
const handleFileChange = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
if (!file) return;
const parsedFileType = allowedMimeTypes.safeParse(file.type);
if (!parsedFileType.success) {
toast({
title: t("invalidFileType", {
defaultValue: "Invalid file type",
}),
description: t("invalidFileTypeDescription", {
defaultValue: "Please upload a JPG or PNG file.",
}),
});
Sentry.captureMessage("Invalid file type", {
level: "info",
extra: {
fileType: file.type,
},
});
return;
}
const fileType = parsedFileType.data;
if (file.size > 2 * 1024 * 1024) {
toast({
title: t("fileTooLarge", {
defaultValue: "File too large",
}),
description: t("fileTooLargeDescription", {
defaultValue: "Please upload a file smaller than 2MB.",
}),
});
Sentry.captureMessage("File too large", {
level: "info",
extra: {
fileSize: file.size,
},
});
return;
}
setIsUploading(true);
try {
// Get pre-signed URL
const res = await getPresignedUrl.mutateAsync({
fileType,
fileSize: file.size,
});
const { url, fields } = res;
await fetch(url, {
method: "PUT",
body: file,
headers: {
"Content-Type": fileType,
"Content-Length": file.size.toString(),
},
});
await updateAvatar.mutateAsync({
imageKey: fields.key,
});
onSuccess();
} catch (error) {
toast({
title: t("errorUploadPicture", {
defaultValue: "Failed to upload",
}),
description: t("errorUploadPictureDescription", {
defaultValue:
"There was an issue uploading your picture. Please try again later.",
}),
});
Sentry.captureException(error);
} finally {
setIsUploading(false);
}
};
return (
<>
<Button
loading={isUploading}
onClick={() => {
document.getElementById("avatar-upload")?.click();
}}
>
<Trans i18nKey="uploadProfilePicture" defaults="Upload" />
</Button>
<input
id="avatar-upload"
type="file"
accept="image/*"
onChange={handleFileChange}
className="hidden"
/>
</>
);
}
function RemoveAvatarButton({ onSuccess }: { onSuccess?: () => void }) {
const [isLoading, setLoading] = React.useState(false);
const removeAvatar = trpc.user.removeAvatar.useMutation();
return (
<Button
loading={isLoading}
variant="ghost"
onClick={async () => {
setLoading(true);
try {
await removeAvatar.mutateAsync();
onSuccess?.();
} finally {
setLoading(false);
}
}}
>
<Trans i18nKey="removeAvatar" defaults="Remove" />
</Button>
);
}
function Upload() {
const { user, refresh } = useUser();
const isAvatarsEnabled = useAvatarsEnabled();
const posthog = usePostHog();
if (!isAvatarsEnabled) {
return null;
}
return (
<div className="flex flex-col gap-y-2">
<div className="flex gap-2">
<ChangeAvatarButton
onSuccess={() => {
refresh();
posthog?.capture("upload profile picture");
}}
/>
{user.image ? (
<RemoveAvatarButton
onSuccess={() => {
refresh();
posthog?.capture("remove profile picture");
}}
/>
) : null}
</div>
<p className="text-muted-foreground text-xs">
<Trans
i18nKey="profilePictureDescription"
defaults="Up to 2MB, JPG or PNG"
/>
</p>
</div>
);
}
export function ProfilePicture() {
return (
<div className="flex items-center gap-x-4">
<CurrentUserAvatar size={56} />
<Upload />
</div>
);
}

View file

@ -0,0 +1,84 @@
import { Button } from "@rallly/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
} from "@rallly/ui/form";
import { Input } from "@rallly/ui/input";
import { useForm } from "react-hook-form";
import { ProfilePicture } from "@/app/[locale]/(admin)/settings/profile/profile-picture";
import { Trans } from "@/components/trans";
import { useUser } from "@/components/user-provider";
export const ProfileSettings = () => {
const { user, refresh } = useUser();
const form = useForm<{
name: string;
email: string;
}>({
defaultValues: {
name: user.isGuest ? "" : user.name,
email: user.email ?? "",
},
});
const { control, handleSubmit, formState, reset } = form;
return (
<div className="grid gap-y-4">
<Form {...form}>
<form
onSubmit={handleSubmit(async (data) => {
await refresh({ name: data.name });
reset(data);
})}
>
<div className="flex flex-col gap-y-4">
<ProfilePicture />
<FormField
control={control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel htmlFor="name">
<Trans i18nKey="name" />
</FormLabel>
<FormControl>
<Input id="name" {...field} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey="email" />
</FormLabel>
<FormControl>
<Input {...field} disabled={true} />
</FormControl>
</FormItem>
)}
/>
<div className="mt-4 flex">
<Button
loading={formState.isSubmitting}
variant="primary"
type="submit"
disabled={!formState.isDirty}
>
<Trans i18nKey="save" />
</Button>
</div>
</div>
</form>
</Form>
</div>
);
};

View file

@ -1,5 +1,4 @@
import { GetObjectCommand } from "@aws-sdk/client-s3"; import { GetObjectCommand } from "@aws-sdk/client-s3";
import * as Sentry from "@sentry/nextjs";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { env } from "@/env"; import { env } from "@/env";
@ -37,6 +36,7 @@ export async function GET(
context: { params: { key: string[] } }, context: { params: { key: string[] } },
) { ) {
const imageKey = context.params.key.join("/"); const imageKey = context.params.key.join("/");
if (!imageKey) { if (!imageKey) {
return new Response("No key provided", { status: 400 }); return new Response("No key provided", { status: 400 });
} }
@ -50,7 +50,7 @@ export async function GET(
}, },
}); });
} catch (error) { } catch (error) {
Sentry.captureException(error); console.error(error);
return NextResponse.json( return NextResponse.json(
{ error: "Failed to fetch object" }, { error: "Failed to fetch object" },
{ status: 500 }, { status: 500 },

View file

@ -1,58 +0,0 @@
import { GetObjectCommand } from "@aws-sdk/client-s3";
import * as Sentry from "@sentry/nextjs";
import { NextRequest, NextResponse } from "next/server";
import { env } from "@/env";
import { getS3Client } from "@/utils/s3";
async function getAvatar(key: string) {
const s3Client = getS3Client();
if (!s3Client) {
throw new Error("S3 client not initialized");
}
const command = new GetObjectCommand({
Bucket: env.S3_BUCKET_NAME,
Key: key,
});
const response = await s3Client.send(command);
if (!response.Body) {
throw new Error("Object not found");
}
const arrayBuffer = await response.Body.transformToByteArray();
const buffer = Buffer.from(arrayBuffer);
return {
buffer,
contentType: response.ContentType || "application/octet-stream",
};
}
export async function GET(req: NextRequest) {
const imageKey = req.nextUrl.searchParams.get("key");
if (!imageKey) {
return new Response("No key provided", { status: 400 });
}
try {
const { buffer, contentType } = await getAvatar(imageKey);
return new NextResponse(buffer, {
headers: {
"Content-Type": contentType,
"Content-Length": buffer.length.toString(),
"Cache-Control": "max-age=86400",
},
});
} catch (error) {
Sentry.captureException(error);
return NextResponse.json(
{ error: "Failed to fetch object" },
{ status: 500 },
);
}
}

View file

@ -1,17 +1,11 @@
"use client"; "use client";
import { Avatar, AvatarFallback, AvatarImage } from "@rallly/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@rallly/ui/avatar";
import Image from "next/image"; import Image from "next/image";
import React from "react";
function getAvatarUrl(imageKey: string) { import { useAvatarsEnabled } from "@/features/avatars";
// Some users have avatars that come from external providers (e.g. Google).
if (imageKey.startsWith("https://")) {
return imageKey;
}
return `/api/storage?key=${encodeURIComponent(imageKey)}`; export function OptimizedAvatarImage({
}
export const OptimizedAvatarImage = ({
size, size,
className, className,
src, src,
@ -21,21 +15,28 @@ export const OptimizedAvatarImage = ({
src?: string; src?: string;
name: string; name: string;
className?: string; className?: string;
}) => { }) {
const isAvatarsEnabled = useAvatarsEnabled();
const [isLoaded, setLoaded] = React.useState(false);
return ( return (
<Avatar className={className} style={{ width: size, height: size }}> <Avatar className={className} style={{ width: size, height: size }}>
{!src || src.startsWith("https") ? ( {src ? (
src.startsWith("https") ? (
<AvatarImage src={src} alt={name} /> <AvatarImage src={src} alt={name} />
) : ( ) : isAvatarsEnabled ? (
<Image <Image
src={getAvatarUrl(src)} src={`/api/storage/${src}`}
width={128} width={128}
height={128} height={128}
alt={name} alt={name}
style={{ objectFit: "cover" }} style={{ objectFit: "cover" }}
onLoad={() => {
setLoaded(true);
}}
/> />
)} ) : null
<AvatarFallback>{name[0]}</AvatarFallback> ) : null}
{!src || !isLoaded ? <AvatarFallback>{name[0]}</AvatarFallback> : null}
</Avatar> </Avatar>
); );
}; }

View file

@ -1,264 +0,0 @@
import { Button } from "@rallly/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
} from "@rallly/ui/form";
import { useToast } from "@rallly/ui/hooks/use-toast";
import { Input } from "@rallly/ui/input";
import * as Sentry from "@sentry/nextjs";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "@/app/i18n/client";
import { CurrentUserAvatar } from "@/components/current-user-avatar";
import { Trans } from "@/components/trans";
import { useUser } from "@/components/user-provider";
import { usePostHog } from "@/utils/posthog";
import { trpc } from "@/utils/trpc/client";
const allowedMimeTypes = ["image/jpeg", "image/png"];
function ChangeAvatarButton({
onSuccess,
}: {
onSuccess: (imageKey: string) => void;
}) {
const { t } = useTranslation();
const { toast } = useToast();
const [isUploading, setIsUploading] = useState(false);
const getAvatarUploadUrlMutation = trpc.user.getAvatarUploadUrl.useMutation();
const updateAvatarMutation = trpc.user.updateAvatar.useMutation({
onSuccess: (_res, input) => {
onSuccess(input.imageKey);
},
});
const handleFileChange = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
if (!file) return;
const fileType = file.type;
if (!allowedMimeTypes.includes(fileType)) {
toast({
title: t("invalidFileType", {
defaultValue: "Invalid file type",
}),
description: t("invalidFileTypeDescription", {
defaultValue: "Please upload a JPG or PNG file.",
}),
});
Sentry.captureMessage("Invalid file type", {
level: "info",
extra: {
fileType,
},
});
return;
}
if (file.size > 2 * 1024 * 1024) {
toast({
title: t("fileTooLarge", {
defaultValue: "File too large",
}),
description: t("fileTooLargeDescription", {
defaultValue: "Please upload a file smaller than 2MB.",
}),
});
Sentry.captureMessage("File too large", {
level: "info",
extra: {
fileSize: file.size,
},
});
return;
}
setIsUploading(true);
try {
// Get pre-signed URL
const res = await getAvatarUploadUrlMutation.mutateAsync({
fileType,
fileSize: file.size,
});
if (!res.success) {
if (res.cause === "object-storage-not-enabled") {
toast({
title: t("featureNotAvailable", {
defaultValue: "Feature not available",
}),
description: t("featureNotAvailableDescription", {
defaultValue:
"This feature requires object storage to be enabled.",
}),
});
return;
}
}
const { url, fields } = res;
await fetch(url, {
method: "PUT",
body: file,
headers: {
"Content-Type": fileType,
"Content-Length": file.size.toString(),
},
});
await updateAvatarMutation.mutateAsync({
imageKey: fields.key,
});
} catch (error) {
console.error(error);
} finally {
setIsUploading(false);
}
};
return (
<>
<Button
loading={isUploading}
onClick={() => {
document.getElementById("avatar-upload")?.click();
}}
>
<Trans i18nKey="uploadProfilePicture" defaults="Upload" />
</Button>
<input
id="avatar-upload"
type="file"
accept="image/*"
onChange={handleFileChange}
className="hidden"
/>
</>
);
}
function RemoveAvatarButton({ onSuccess }: { onSuccess?: () => void }) {
const { refresh } = useUser();
const removeAvatarMutation = trpc.user.removeAvatar.useMutation({
onSuccess: () => {
refresh({ image: null });
onSuccess?.();
},
});
return (
<Button
loading={removeAvatarMutation.isLoading}
variant="ghost"
onClick={() => {
removeAvatarMutation.mutate();
}}
>
<Trans i18nKey="removeAvatar" defaults="Remove" />
</Button>
);
}
export const ProfileSettings = () => {
const { user, refresh } = useUser();
const form = useForm<{
name: string;
email: string;
}>({
defaultValues: {
name: user.isGuest ? "" : user.name,
email: user.email ?? "",
},
});
const { control, handleSubmit, formState, reset } = form;
const posthog = usePostHog();
return (
<div className="grid gap-y-4">
<Form {...form}>
<form
onSubmit={handleSubmit(async (data) => {
await refresh({ name: data.name });
reset(data);
})}
>
<div className="flex flex-col gap-y-4">
<div className="flex items-center gap-x-4">
<CurrentUserAvatar size={56} />
<div className="flex flex-col gap-y-2">
<div className="flex gap-2">
<ChangeAvatarButton
onSuccess={(imageKey) => {
refresh({ image: imageKey });
posthog?.capture("upload profile picture");
}}
/>
{user.image ? (
<RemoveAvatarButton
onSuccess={() => {
posthog?.capture("remove profile picture");
}}
/>
) : null}
</div>
<p className="text-muted-foreground text-xs">
<Trans
i18nKey="profilePictureDescription"
defaults="Up to 2MB, JPG or PNG"
/>
</p>
</div>
</div>
<FormField
control={control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel htmlFor="name">
<Trans i18nKey="name" />
</FormLabel>
<FormControl>
<Input id="name" {...field} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey="email" />
</FormLabel>
<FormControl>
<Input {...field} disabled={true} />
</FormControl>
</FormItem>
)}
/>
<div className="mt-4 flex">
<Button
loading={formState.isSubmitting}
variant="primary"
type="submit"
disabled={!formState.isDirty}
>
<Trans i18nKey="save" />
</Button>
</div>
</div>
</form>
</Form>
</div>
);
};

View file

@ -0,0 +1,5 @@
import { useFeatureFlagEnabled } from "posthog-js/react";
export function useAvatarsEnabled() {
return useFeatureFlagEnabled("avatars");
}

View file

@ -17,6 +17,11 @@ import {
router, router,
} from "../trpc"; } from "../trpc";
const mimeToExtension = {
"image/jpeg": "jpg",
"image/png": "png",
} as const;
export const user = router({ export const user = router({
getBilling: possiblyPublicProcedure.query(async ({ ctx }) => { getBilling: possiblyPublicProcedure.query(async ({ ctx }) => {
return await prisma.userPaymentData.findUnique({ return await prisma.userPaymentData.findUnique({
@ -147,19 +152,24 @@ export const user = router({
}), }),
getAvatarUploadUrl: privateProcedure getAvatarUploadUrl: privateProcedure
.use(rateLimitMiddleware) .use(rateLimitMiddleware)
.input(z.object({ fileType: z.string(), fileSize: z.number() })) .input(
z.object({
fileType: z.enum(["image/jpeg", "image/png"]),
fileSize: z.number(),
}),
)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const s3Client = getS3Client(); const s3Client = getS3Client();
if (!s3Client) { if (!s3Client) {
return { throw new TRPCError({
success: false, code: "INTERNAL_SERVER_ERROR",
cause: "object-storage-not-enabled", message: "S3 storage has not been configured",
} as const; });
} }
const userId = ctx.user.id; const userId = ctx.user.id;
const key = `avatars/${userId}-${Date.now()}.jpg`; const key = `avatars/${userId}-${Date.now()}.${mimeToExtension[input.fileType]}`;
if (input.fileSize > 2 * 1024 * 1024) { if (input.fileSize > 2 * 1024 * 1024) {
throw new TRPCError({ throw new TRPCError({

View file

@ -253,32 +253,62 @@ const getAuthOptions = (...args: GetServerSessionParams) =>
await mergeGuestsIntoUser(user.id, [account.providerAccountId]); await mergeGuestsIntoUser(user.id, [account.providerAccountId]);
} }
if (user) {
token.locale = user.locale;
token.timeFormat = user.timeFormat;
token.timeZone = user.timeZone;
token.weekStart = user.weekStart;
token.picture = user.image;
}
if (session) { if (session) {
token.locale = session.locale; token.locale = session.locale;
token.timeFormat = session.timeFormat; token.timeFormat = session.timeFormat;
token.timeZone = session.timeZone; token.timeZone = session.timeZone;
token.weekStart = session.weekStart; token.weekStart = session.weekStart;
token.picture = session.image;
} }
return token; return token;
}, },
async session({ session, token }) { async session({ session, token }) {
if (token.sub?.startsWith("user-")) {
session.user.id = token.sub as string; session.user.id = token.sub as string;
session.user.name = token.name; session.user.locale = token.locale;
session.user.timeFormat = token.timeFormat; session.user.timeFormat = token.timeFormat;
session.user.timeZone = token.timeZone; session.user.timeZone = token.timeZone;
session.user.locale = token.locale; session.user.locale = token.locale;
session.user.weekStart = token.weekStart; session.user.weekStart = token.weekStart;
session.user.image = token.picture; return session;
}
const user = await prisma.user.findUnique({
where: {
id: token.sub as string,
},
select: {
id: true,
name: true,
timeFormat: true,
timeZone: true,
locale: true,
weekStart: true,
email: true,
image: true,
},
});
if (!user) {
session.user.id = token.sub as string;
session.user.email = token.email;
session.user.locale = token.locale;
session.user.timeFormat = token.timeFormat;
session.user.timeZone = token.timeZone;
session.user.locale = token.locale;
session.user.weekStart = token.weekStart;
} else {
session.user.id = user.id;
session.user.name = user.name;
session.user.email = user.email;
session.user.locale = user.locale;
session.user.timeFormat = user.timeFormat;
session.user.timeZone = user.timeZone;
session.user.locale = user.locale;
session.user.weekStart = user.weekStart;
session.user.image = user.image;
}
return session; return session;
}, },
}, },