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

View file

@ -275,12 +275,12 @@
"noKeepCurrentTimezone": "No, keep the current timezone",
"annualBenefit": "{count} months free",
"removeAvatar": "Remove",
"featureNotAvailable": "Feature not available",
"featureNotAvailableDescription": "This feature requires object storage to be enabled.",
"uploadProfilePicture": "Upload",
"profilePictureDescription": "Up to 2MB, JPG or PNG",
"invalidFileType": "Invalid file type",
"invalidFileTypeDescription": "Please upload a JPG or PNG file.",
"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 { 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 { ProfileSettings } from "@/components/settings/profile-settings";
import {
Settings,
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 * as Sentry from "@sentry/nextjs";
import { NextRequest, NextResponse } from "next/server";
import { env } from "@/env";
@ -37,6 +36,7 @@ export async function GET(
context: { params: { key: string[] } },
) {
const imageKey = context.params.key.join("/");
if (!imageKey) {
return new Response("No key provided", { status: 400 });
}
@ -50,7 +50,7 @@ export async function GET(
},
});
} catch (error) {
Sentry.captureException(error);
console.error(error);
return NextResponse.json(
{ error: "Failed to fetch object" },
{ 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";
import { Avatar, AvatarFallback, AvatarImage } from "@rallly/ui/avatar";
import Image from "next/image";
import React from "react";
function getAvatarUrl(imageKey: string) {
// Some users have avatars that come from external providers (e.g. Google).
if (imageKey.startsWith("https://")) {
return imageKey;
}
import { useAvatarsEnabled } from "@/features/avatars";
return `/api/storage?key=${encodeURIComponent(imageKey)}`;
}
export const OptimizedAvatarImage = ({
export function OptimizedAvatarImage({
size,
className,
src,
@ -21,21 +15,28 @@ export const OptimizedAvatarImage = ({
src?: string;
name: string;
className?: string;
}) => {
}) {
const isAvatarsEnabled = useAvatarsEnabled();
const [isLoaded, setLoaded] = React.useState(false);
return (
<Avatar className={className} style={{ width: size, height: size }}>
{!src || src.startsWith("https") ? (
<AvatarImage src={src} alt={name} />
) : (
<Image
src={getAvatarUrl(src)}
width={128}
height={128}
alt={name}
style={{ objectFit: "cover" }}
/>
)}
<AvatarFallback>{name[0]}</AvatarFallback>
{src ? (
src.startsWith("https") ? (
<AvatarImage src={src} alt={name} />
) : isAvatarsEnabled ? (
<Image
src={`/api/storage/${src}`}
width={128}
height={128}
alt={name}
style={{ objectFit: "cover" }}
onLoad={() => {
setLoaded(true);
}}
/>
) : null
) : null}
{!src || !isLoaded ? <AvatarFallback>{name[0]}</AvatarFallback> : null}
</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,
} from "../trpc";
const mimeToExtension = {
"image/jpeg": "jpg",
"image/png": "png",
} as const;
export const user = router({
getBilling: possiblyPublicProcedure.query(async ({ ctx }) => {
return await prisma.userPaymentData.findUnique({
@ -147,19 +152,24 @@ export const user = router({
}),
getAvatarUploadUrl: privateProcedure
.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 }) => {
const s3Client = getS3Client();
if (!s3Client) {
return {
success: false,
cause: "object-storage-not-enabled",
} as const;
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "S3 storage has not been configured",
});
}
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) {
throw new TRPCError({

View file

@ -253,32 +253,62 @@ const getAuthOptions = (...args: GetServerSessionParams) =>
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) {
token.locale = session.locale;
token.timeFormat = session.timeFormat;
token.timeZone = session.timeZone;
token.weekStart = session.weekStart;
token.picture = session.image;
}
return token;
},
async session({ session, token }) {
session.user.id = token.sub as string;
session.user.name = token.name;
session.user.timeFormat = token.timeFormat;
session.user.timeZone = token.timeZone;
session.user.locale = token.locale;
session.user.weekStart = token.weekStart;
session.user.image = token.picture;
if (token.sub?.startsWith("user-")) {
session.user.id = token.sub as string;
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;
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;
},
},