mirror of
https://github.com/lukevella/rallly.git
synced 2025-06-03 11:11:48 +02:00
✨ Add support for uploading profile pictures (#1332)
This commit is contained in:
parent
cf32e0da65
commit
32ba10b28a
24 changed files with 1615 additions and 65 deletions
|
@ -20,6 +20,8 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "^1.0.3",
|
||||
"@aws-sdk/client-s3": "^3.645.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.645.0",
|
||||
"@hookform/resolvers": "^3.3.1",
|
||||
"@next/bundle-analyzer": "^12.3.4",
|
||||
"@radix-ui/react-slot": "^1.0.1",
|
||||
|
|
|
@ -273,5 +273,14 @@
|
|||
"timeZoneChangeDetectorMessage": "Your timezone has changed to <b>{currentTimeZone}</b>. Do you want to update your preferences?",
|
||||
"yesUpdateTimezone": "Yes, update my timezone",
|
||||
"noKeepCurrentTimezone": "No, keep the current timezone",
|
||||
"annualBenefit": "{count} months free"
|
||||
"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."
|
||||
}
|
||||
|
|
|
@ -161,7 +161,7 @@ export function Sidebar() {
|
|||
>
|
||||
<Link href="/settings/profile">
|
||||
<div>
|
||||
<CurrentUserAvatar />
|
||||
<CurrentUserAvatar size={40} />
|
||||
</div>
|
||||
<span className="ml-1 grid grow">
|
||||
<span className="font-semibold">{user.name}</span>
|
||||
|
|
59
apps/web/src/app/api/storage/[...key]/route.ts
Normal file
59
apps/web/src/app/api/storage/[...key]/route.ts
Normal file
|
@ -0,0 +1,59 @@
|
|||
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,
|
||||
context: { params: { key: string[] } },
|
||||
) {
|
||||
const imageKey = context.params.key.join("/");
|
||||
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,
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
Sentry.captureException(error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch object" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,14 +1,40 @@
|
|||
"use client";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@rallly/ui/avatar";
|
||||
import { Avatar, AvatarFallback } from "@rallly/ui/avatar";
|
||||
import Image from "next/image";
|
||||
|
||||
import { useUser } from "@/components/user-provider";
|
||||
|
||||
export const CurrentUserAvatar = ({ className }: { className?: string }) => {
|
||||
function getAvatarUrl(imageKey: string) {
|
||||
// Some users have avatars that come from external providers (e.g. Google).
|
||||
if (imageKey.startsWith("https://")) {
|
||||
return imageKey;
|
||||
}
|
||||
|
||||
return `/api/storage/${imageKey}`;
|
||||
}
|
||||
|
||||
export const CurrentUserAvatar = ({
|
||||
size,
|
||||
className,
|
||||
}: {
|
||||
size: number;
|
||||
className?: string;
|
||||
}) => {
|
||||
const { user } = useUser();
|
||||
return (
|
||||
<Avatar className={className}>
|
||||
<AvatarImage src={user.image ?? undefined} />
|
||||
<AvatarFallback>{user.name[0]}</AvatarFallback>
|
||||
<Avatar className={className} style={{ width: size, height: size }}>
|
||||
{user.image ? (
|
||||
<Image
|
||||
src={getAvatarUrl(user.image)}
|
||||
width={128}
|
||||
height={128}
|
||||
alt={user.name}
|
||||
style={{ objectFit: "cover" }}
|
||||
objectFit="cover"
|
||||
/>
|
||||
) : (
|
||||
<AvatarFallback>{user.name[0]}</AvatarFallback>
|
||||
)}
|
||||
</Avatar>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -3,7 +3,6 @@ import { Form, FormField, FormItem, FormLabel } from "@rallly/ui/form";
|
|||
import { ArrowUpRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
@ -11,7 +10,7 @@ import { z } from "zod";
|
|||
import { LanguageSelect } from "@/components/poll/language-selector";
|
||||
import { Trans } from "@/components/trans";
|
||||
import { useUser } from "@/components/user-provider";
|
||||
import { trpc } from "@/utils/trpc/client";
|
||||
import { usePreferences } from "@/contexts/preferences";
|
||||
|
||||
const formSchema = z.object({
|
||||
language: z.string(),
|
||||
|
@ -28,18 +27,15 @@ export const LanguagePreference = () => {
|
|||
language: i18n.language,
|
||||
},
|
||||
});
|
||||
|
||||
const updatePreferences = trpc.user.updatePreferences.useMutation();
|
||||
const session = useSession();
|
||||
const { updatePreferences } = usePreferences();
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(async (data) => {
|
||||
if (!user.isGuest) {
|
||||
await updatePreferences.mutateAsync({ locale: data.language });
|
||||
await updatePreferences({ locale: data.language });
|
||||
}
|
||||
await session.update({ locale: data.language });
|
||||
router.refresh();
|
||||
})}
|
||||
>
|
||||
|
|
|
@ -6,12 +6,166 @@ import {
|
|||
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();
|
||||
|
@ -27,7 +181,7 @@ export const ProfileSettings = () => {
|
|||
});
|
||||
|
||||
const { control, handleSubmit, formState, reset } = form;
|
||||
|
||||
const posthog = usePostHog();
|
||||
return (
|
||||
<div className="grid gap-y-4">
|
||||
<Form {...form}>
|
||||
|
@ -38,8 +192,31 @@ export const ProfileSettings = () => {
|
|||
})}
|
||||
>
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<div>
|
||||
<CurrentUserAvatar className="size-14" />
|
||||
<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}
|
||||
|
|
|
@ -57,7 +57,7 @@ export const UserDropdown = ({ className }: { className?: string }) => {
|
|||
className={cn("group min-w-0", className)}
|
||||
>
|
||||
<Button variant="ghost">
|
||||
<CurrentUserAvatar className="size-6" />
|
||||
<CurrentUserAvatar size={24} />
|
||||
<span className="truncate">{user.name}</span>
|
||||
<Icon>
|
||||
<ChevronDownIcon />
|
||||
|
|
|
@ -9,6 +9,7 @@ import { Spinner } from "@/components/spinner";
|
|||
import { useSubscription } from "@/contexts/plan";
|
||||
import { PostHogProvider } from "@/contexts/posthog";
|
||||
import { PreferencesProvider } from "@/contexts/preferences";
|
||||
import { trpc } from "@/utils/trpc/client";
|
||||
|
||||
import { useRequiredContext } from "./use-required-context";
|
||||
|
||||
|
@ -56,7 +57,7 @@ export const UserProvider = (props: { children?: React.ReactNode }) => {
|
|||
const session = useSession();
|
||||
const user = session.data?.user;
|
||||
const subscription = useSubscription();
|
||||
|
||||
const updatePreferences = trpc.user.updatePreferences.useMutation();
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!user) {
|
||||
|
@ -96,6 +97,14 @@ export const UserProvider = (props: { children?: React.ReactNode }) => {
|
|||
weekStart: user.weekStart ?? undefined,
|
||||
}}
|
||||
onUpdate={async (newPreferences) => {
|
||||
if (!isGuest) {
|
||||
await updatePreferences.mutateAsync({
|
||||
locale: newPreferences.locale ?? undefined,
|
||||
timeZone: newPreferences.timeZone ?? undefined,
|
||||
timeFormat: newPreferences.timeFormat ?? undefined,
|
||||
weekStart: newPreferences.weekStart ?? undefined,
|
||||
});
|
||||
}
|
||||
await session.update(newPreferences);
|
||||
}}
|
||||
>
|
||||
|
|
|
@ -13,7 +13,7 @@ type Preferences = {
|
|||
|
||||
type PreferencesContextValue = {
|
||||
preferences: Preferences;
|
||||
updatePreferences: (preferences: Partial<Preferences>) => void;
|
||||
updatePreferences: (preferences: Partial<Preferences>) => Promise<void>;
|
||||
};
|
||||
|
||||
const PreferencesContext = React.createContext<PreferencesContextValue | null>(
|
||||
|
@ -27,7 +27,7 @@ export const PreferencesProvider = ({
|
|||
}: {
|
||||
children?: React.ReactNode;
|
||||
initialValue: Partial<Preferences>;
|
||||
onUpdate?: (preferences: Partial<Preferences>) => void;
|
||||
onUpdate?: (preferences: Partial<Preferences>) => Promise<void>;
|
||||
}) => {
|
||||
const [preferences, setPreferences] = useSetState<Preferences>(initialValue);
|
||||
|
||||
|
@ -35,9 +35,9 @@ export const PreferencesProvider = ({
|
|||
<PreferencesContext.Provider
|
||||
value={{
|
||||
preferences,
|
||||
updatePreferences: (newPreferences) => {
|
||||
updatePreferences: async (newPreferences) => {
|
||||
setPreferences(newPreferences);
|
||||
onUpdate?.(newPreferences);
|
||||
await onUpdate?.(newPreferences);
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
|
|
@ -55,6 +55,15 @@ export const env = createEnv({
|
|||
SUPPORT_EMAIL: z.string().email(),
|
||||
NOREPLY_EMAIL: z.string().email().optional(),
|
||||
NOREPLY_EMAIL_NAME: z.string().default("Rallly"),
|
||||
|
||||
/**
|
||||
* S3 Configuration
|
||||
*/
|
||||
S3_BUCKET_NAME: z.string().optional(),
|
||||
S3_ENDPOINT: z.string().optional(),
|
||||
S3_ACCESS_KEY_ID: z.string().optional(),
|
||||
S3_SECRET_ACCESS_KEY: z.string().optional(),
|
||||
S3_REGION: z.string().optional(),
|
||||
},
|
||||
/*
|
||||
* Environment variables available on the client (and server).
|
||||
|
@ -89,10 +98,15 @@ export const env = createEnv({
|
|||
SMTP_PWD: process.env.SMTP_PWD,
|
||||
SMTP_SECURE: process.env.SMTP_SECURE,
|
||||
SMTP_PORT: process.env.SMTP_PORT,
|
||||
ALLOWED_EMAILS: process.env.ALLOWED_EMAILS,
|
||||
AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID,
|
||||
AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY,
|
||||
AWS_REGION: process.env.AWS_REGION,
|
||||
ALLOWED_EMAILS: process.env.ALLOWED_EMAILS,
|
||||
S3_BUCKET_NAME: process.env.S3_BUCKET_NAME,
|
||||
S3_ENDPOINT: process.env.S3_ENDPOINT,
|
||||
S3_ACCESS_KEY_ID: process.env.S3_ACCESS_KEY_ID,
|
||||
S3_SECRET_ACCESS_KEY: process.env.S3_SECRET_ACCESS_KEY,
|
||||
S3_REGION: process.env.S3_REGION,
|
||||
NEXT_PUBLIC_POSTHOG_API_KEY: process.env.NEXT_PUBLIC_POSTHOG_API_KEY,
|
||||
NEXT_PUBLIC_POSTHOG_API_HOST: process.env.NEXT_PUBLIC_POSTHOG_API_HOST,
|
||||
NEXT_PUBLIC_SELF_HOSTED: process.env.NEXT_PUBLIC_SELF_HOSTED,
|
||||
|
|
|
@ -31,6 +31,7 @@ const trpcApiHandler = createNextApiHandler<AppRouter>({
|
|||
id: session.user.id,
|
||||
isGuest: session.user.email === null,
|
||||
locale: session.user.locale ?? undefined,
|
||||
image: session.user.image ?? undefined,
|
||||
getEmailClient: () => getEmailClient(session.user.locale ?? undefined),
|
||||
},
|
||||
req: opts.req,
|
||||
|
|
|
@ -7,6 +7,7 @@ export type TRPCContext = {
|
|||
isGuest: boolean;
|
||||
locale?: string;
|
||||
getEmailClient: (locale?: string) => EmailClient;
|
||||
image?: string;
|
||||
};
|
||||
req: NextApiRequest;
|
||||
res: NextApiResponse;
|
||||
|
|
|
@ -1,12 +1,19 @@
|
|||
import { DeleteObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
import { prisma } from "@rallly/database";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { waitUntil } from "@vercel/functions";
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "@/env";
|
||||
import { getS3Client } from "@/utils/s3";
|
||||
import { getSubscriptionStatus } from "@/utils/subscription";
|
||||
|
||||
import {
|
||||
possiblyPublicProcedure,
|
||||
privateProcedure,
|
||||
publicProcedure,
|
||||
rateLimitMiddleware,
|
||||
router,
|
||||
} from "../trpc";
|
||||
|
||||
|
@ -121,13 +128,116 @@ export const user = router({
|
|||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (ctx.user.isGuest === false) {
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: ctx.user.id,
|
||||
},
|
||||
data: input,
|
||||
if (ctx.user.isGuest) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Guest users cannot update preferences",
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: ctx.user.id,
|
||||
},
|
||||
data: input,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
getAvatarUploadUrl: privateProcedure
|
||||
.use(rateLimitMiddleware)
|
||||
.input(z.object({ fileType: z.string(), fileSize: z.number() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const s3Client = getS3Client();
|
||||
|
||||
if (!s3Client) {
|
||||
return {
|
||||
success: false,
|
||||
cause: "object-storage-not-enabled",
|
||||
} as const;
|
||||
}
|
||||
|
||||
const userId = ctx.user.id;
|
||||
const key = `avatars/${userId}-${Date.now()}.jpg`;
|
||||
|
||||
if (input.fileSize > 2 * 1024 * 1024) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "File size too large",
|
||||
});
|
||||
}
|
||||
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: env.S3_BUCKET_NAME,
|
||||
Key: key,
|
||||
ContentType: input.fileType,
|
||||
ContentLength: input.fileSize,
|
||||
});
|
||||
|
||||
const url = await getSignedUrl(s3Client, command, { expiresIn: 3600 });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
url,
|
||||
fields: {
|
||||
key,
|
||||
},
|
||||
} as const;
|
||||
}),
|
||||
updateAvatar: privateProcedure
|
||||
.input(z.object({ imageKey: z.string().max(255) }))
|
||||
.use(rateLimitMiddleware)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user.id;
|
||||
const oldImageKey = ctx.user.image;
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { image: input.imageKey },
|
||||
});
|
||||
|
||||
const s3Client = getS3Client();
|
||||
|
||||
if (oldImageKey && s3Client) {
|
||||
waitUntil(
|
||||
s3Client?.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: env.S3_BUCKET_NAME,
|
||||
Key: oldImageKey,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
removeAvatar: privateProcedure.mutation(async ({ ctx }) => {
|
||||
const userId = ctx.user.id;
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { image: null },
|
||||
});
|
||||
|
||||
// Delete the avatar from storage if it's an internal avatar
|
||||
const isInternalAvatar =
|
||||
ctx.user.image && !ctx.user.image.startsWith("https://");
|
||||
|
||||
if (isInternalAvatar) {
|
||||
const s3Client = getS3Client();
|
||||
|
||||
if (s3Client) {
|
||||
waitUntil(
|
||||
s3Client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: env.S3_BUCKET_NAME,
|
||||
Key: ctx.user.image,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
|
|
@ -252,36 +252,23 @@ const getAuthOptions = (...args: GetServerSessionParams) =>
|
|||
// merge accounts assigned to provider account id to the current user id
|
||||
await mergeGuestsIntoUser(user.id, [account.providerAccountId]);
|
||||
}
|
||||
if (trigger === "update" && session) {
|
||||
if (token.email) {
|
||||
// For registered users we want to save the preferences to the database
|
||||
try {
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: token.sub,
|
||||
},
|
||||
data: {
|
||||
locale: session.locale,
|
||||
timeFormat: session.timeFormat,
|
||||
timeZone: session.timeZone,
|
||||
weekStart: session.weekStart,
|
||||
name: session.name,
|
||||
image: session.image,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to update user preferences", session);
|
||||
}
|
||||
}
|
||||
token = { ...token, ...session };
|
||||
}
|
||||
if (trigger === "signIn" && user) {
|
||||
|
||||
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 }) {
|
||||
|
|
|
@ -1,9 +1,3 @@
|
|||
export const planIdMonthly = process.env
|
||||
.NEXT_PUBLIC_PRO_PLAN_ID_MONTHLY as string;
|
||||
|
||||
export const planIdYearly = process.env
|
||||
.NEXT_PUBLIC_PRO_PLAN_ID_YEARLY as string;
|
||||
|
||||
export const isSelfHosted = process.env.NEXT_PUBLIC_SELF_HOSTED === "true";
|
||||
|
||||
export const isFeedbackEnabled = false;
|
||||
|
|
26
apps/web/src/utils/s3.ts
Normal file
26
apps/web/src/utils/s3.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
import { S3Client } from "@aws-sdk/client-s3";
|
||||
|
||||
import { env } from "@/env";
|
||||
|
||||
export function getS3Client() {
|
||||
if (
|
||||
!env.S3_BUCKET_NAME ||
|
||||
!env.S3_ACCESS_KEY_ID ||
|
||||
!env.S3_SECRET_ACCESS_KEY
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const s3Client = new S3Client({
|
||||
region: env.S3_REGION,
|
||||
endpoint: env.S3_ENDPOINT,
|
||||
credentials: {
|
||||
accessKeyId: env.S3_ACCESS_KEY_ID,
|
||||
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
|
||||
},
|
||||
// S3 compatible storage requires path style
|
||||
forcePathStyle: true,
|
||||
});
|
||||
|
||||
return s3Client;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue