diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 0ac27a3b9..839b9c3d7 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -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 = { diff --git a/apps/web/public/locales/en/app.json b/apps/web/public/locales/en/app.json index 3406e9eca..41a94c259 100644 --- a/apps/web/public/locales/en/app.json +++ b/apps/web/public/locales/en/app.json @@ -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." } diff --git a/apps/web/src/app/[locale]/(admin)/settings/profile/profile-page.tsx b/apps/web/src/app/[locale]/(admin)/settings/profile/profile-page.tsx index db70d7819..7858df7f7 100644 --- a/apps/web/src/app/[locale]/(admin)/settings/profile/profile-page.tsx +++ b/apps/web/src/app/[locale]/(admin)/settings/profile/profile-page.tsx @@ -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, diff --git a/apps/web/src/app/[locale]/(admin)/settings/profile/profile-picture.tsx b/apps/web/src/app/[locale]/(admin)/settings/profile/profile-picture.tsx new file mode 100644 index 000000000..051e16c28 --- /dev/null +++ b/apps/web/src/app/[locale]/(admin)/settings/profile/profile-picture.tsx @@ -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, + ) => { + 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 ( + <> + + + + ); +} + +function RemoveAvatarButton({ onSuccess }: { onSuccess?: () => void }) { + const [isLoading, setLoading] = React.useState(false); + const removeAvatar = trpc.user.removeAvatar.useMutation(); + return ( + + ); +} + +function Upload() { + const { user, refresh } = useUser(); + const isAvatarsEnabled = useAvatarsEnabled(); + + const posthog = usePostHog(); + + if (!isAvatarsEnabled) { + return null; + } + + return ( +
+
+ { + refresh(); + posthog?.capture("upload profile picture"); + }} + /> + {user.image ? ( + { + refresh(); + posthog?.capture("remove profile picture"); + }} + /> + ) : null} +
+

+ +

+
+ ); +} + +export function ProfilePicture() { + return ( +
+ + +
+ ); +} diff --git a/apps/web/src/app/[locale]/(admin)/settings/profile/profile-settings.tsx b/apps/web/src/app/[locale]/(admin)/settings/profile/profile-settings.tsx new file mode 100644 index 000000000..2cb9b0487 --- /dev/null +++ b/apps/web/src/app/[locale]/(admin)/settings/profile/profile-settings.tsx @@ -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 ( +
+
+ { + await refresh({ name: data.name }); + reset(data); + })} + > +
+ + ( + + + + + + + + + )} + /> + ( + + + + + + + + + )} + /> +
+ +
+
+
+ +
+ ); +}; diff --git a/apps/web/src/app/api/storage/[...key]/route.ts b/apps/web/src/app/api/storage/[...key]/route.ts index 8f04f62e3..e0ddf0674 100644 --- a/apps/web/src/app/api/storage/[...key]/route.ts +++ b/apps/web/src/app/api/storage/[...key]/route.ts @@ -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 }, diff --git a/apps/web/src/app/api/storage/route.ts b/apps/web/src/app/api/storage/route.ts deleted file mode 100644 index 6d6bdf6dc..000000000 --- a/apps/web/src/app/api/storage/route.ts +++ /dev/null @@ -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 }, - ); - } -} diff --git a/apps/web/src/components/optimized-avatar-image.tsx b/apps/web/src/components/optimized-avatar-image.tsx index 2324acb91..dfe657fce 100644 --- a/apps/web/src/components/optimized-avatar-image.tsx +++ b/apps/web/src/components/optimized-avatar-image.tsx @@ -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 ( - {!src || src.startsWith("https") ? ( - - ) : ( - {name} - )} - {name[0]} + {src ? ( + src.startsWith("https") ? ( + + ) : isAvatarsEnabled ? ( + {name} { + setLoaded(true); + }} + /> + ) : null + ) : null} + {!src || !isLoaded ? {name[0]} : null} ); -}; +} diff --git a/apps/web/src/components/settings/profile-settings.tsx b/apps/web/src/components/settings/profile-settings.tsx deleted file mode 100644 index a26dcc3b9..000000000 --- a/apps/web/src/components/settings/profile-settings.tsx +++ /dev/null @@ -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, - ) => { - 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 ( - <> - - - - ); -} - -function RemoveAvatarButton({ onSuccess }: { onSuccess?: () => void }) { - const { refresh } = useUser(); - const removeAvatarMutation = trpc.user.removeAvatar.useMutation({ - onSuccess: () => { - refresh({ image: null }); - onSuccess?.(); - }, - }); - - return ( - - ); -} - -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 ( -
-
- { - await refresh({ name: data.name }); - reset(data); - })} - > -
-
- -
-
- { - refresh({ image: imageKey }); - posthog?.capture("upload profile picture"); - }} - /> - {user.image ? ( - { - posthog?.capture("remove profile picture"); - }} - /> - ) : null} -
-

- -

-
-
- ( - - - - - - - - - )} - /> - ( - - - - - - - - - )} - /> -
- -
-
-
- -
- ); -}; diff --git a/apps/web/src/features/avatars.ts b/apps/web/src/features/avatars.ts new file mode 100644 index 000000000..a6aaf18a5 --- /dev/null +++ b/apps/web/src/features/avatars.ts @@ -0,0 +1,5 @@ +import { useFeatureFlagEnabled } from "posthog-js/react"; + +export function useAvatarsEnabled() { + return useFeatureFlagEnabled("avatars"); +} diff --git a/apps/web/src/trpc/routers/user.ts b/apps/web/src/trpc/routers/user.ts index 9d541867b..64a83a5a3 100644 --- a/apps/web/src/trpc/routers/user.ts +++ b/apps/web/src/trpc/routers/user.ts @@ -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({ diff --git a/apps/web/src/utils/auth.ts b/apps/web/src/utils/auth.ts index 24bae1a15..3f268664f 100644 --- a/apps/web/src/utils/auth.ts +++ b/apps/web/src/utils/auth.ts @@ -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; }, },