mirror of
https://github.com/lukevella/rallly.git
synced 2025-06-07 21:21:49 +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": {
|
"dependencies": {
|
||||||
"@auth/prisma-adapter": "^1.0.3",
|
"@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",
|
"@hookform/resolvers": "^3.3.1",
|
||||||
"@next/bundle-analyzer": "^12.3.4",
|
"@next/bundle-analyzer": "^12.3.4",
|
||||||
"@radix-ui/react-slot": "^1.0.1",
|
"@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?",
|
"timeZoneChangeDetectorMessage": "Your timezone has changed to <b>{currentTimeZone}</b>. Do you want to update your preferences?",
|
||||||
"yesUpdateTimezone": "Yes, update my timezone",
|
"yesUpdateTimezone": "Yes, update my timezone",
|
||||||
"noKeepCurrentTimezone": "No, keep the current 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">
|
<Link href="/settings/profile">
|
||||||
<div>
|
<div>
|
||||||
<CurrentUserAvatar />
|
<CurrentUserAvatar size={40} />
|
||||||
</div>
|
</div>
|
||||||
<span className="ml-1 grid grow">
|
<span className="ml-1 grid grow">
|
||||||
<span className="font-semibold">{user.name}</span>
|
<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";
|
"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";
|
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();
|
const { user } = useUser();
|
||||||
return (
|
return (
|
||||||
<Avatar className={className}>
|
<Avatar className={className} style={{ width: size, height: size }}>
|
||||||
<AvatarImage src={user.image ?? undefined} />
|
{user.image ? (
|
||||||
|
<Image
|
||||||
|
src={getAvatarUrl(user.image)}
|
||||||
|
width={128}
|
||||||
|
height={128}
|
||||||
|
alt={user.name}
|
||||||
|
style={{ objectFit: "cover" }}
|
||||||
|
objectFit="cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<AvatarFallback>{user.name[0]}</AvatarFallback>
|
<AvatarFallback>{user.name[0]}</AvatarFallback>
|
||||||
|
)}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -3,7 +3,6 @@ import { Form, FormField, FormItem, FormLabel } from "@rallly/ui/form";
|
||||||
import { ArrowUpRight } from "lucide-react";
|
import { ArrowUpRight } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useSession } from "next-auth/react";
|
|
||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
@ -11,7 +10,7 @@ import { z } from "zod";
|
||||||
import { LanguageSelect } from "@/components/poll/language-selector";
|
import { LanguageSelect } from "@/components/poll/language-selector";
|
||||||
import { Trans } from "@/components/trans";
|
import { Trans } from "@/components/trans";
|
||||||
import { useUser } from "@/components/user-provider";
|
import { useUser } from "@/components/user-provider";
|
||||||
import { trpc } from "@/utils/trpc/client";
|
import { usePreferences } from "@/contexts/preferences";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
language: z.string(),
|
language: z.string(),
|
||||||
|
@ -28,18 +27,15 @@ export const LanguagePreference = () => {
|
||||||
language: i18n.language,
|
language: i18n.language,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const { updatePreferences } = usePreferences();
|
||||||
const updatePreferences = trpc.user.updatePreferences.useMutation();
|
|
||||||
const session = useSession();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(async (data) => {
|
onSubmit={form.handleSubmit(async (data) => {
|
||||||
if (!user.isGuest) {
|
if (!user.isGuest) {
|
||||||
await updatePreferences.mutateAsync({ locale: data.language });
|
await updatePreferences({ locale: data.language });
|
||||||
}
|
}
|
||||||
await session.update({ locale: data.language });
|
|
||||||
router.refresh();
|
router.refresh();
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
|
|
|
@ -6,12 +6,166 @@ import {
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
} from "@rallly/ui/form";
|
} from "@rallly/ui/form";
|
||||||
|
import { useToast } from "@rallly/ui/hooks/use-toast";
|
||||||
import { Input } from "@rallly/ui/input";
|
import { Input } from "@rallly/ui/input";
|
||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
|
import { useTranslation } from "@/app/i18n/client";
|
||||||
import { CurrentUserAvatar } from "@/components/current-user-avatar";
|
import { CurrentUserAvatar } from "@/components/current-user-avatar";
|
||||||
import { Trans } from "@/components/trans";
|
import { Trans } from "@/components/trans";
|
||||||
import { useUser } from "@/components/user-provider";
|
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 = () => {
|
export const ProfileSettings = () => {
|
||||||
const { user, refresh } = useUser();
|
const { user, refresh } = useUser();
|
||||||
|
@ -27,7 +181,7 @@ export const ProfileSettings = () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const { control, handleSubmit, formState, reset } = form;
|
const { control, handleSubmit, formState, reset } = form;
|
||||||
|
const posthog = usePostHog();
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-y-4">
|
<div className="grid gap-y-4">
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
|
@ -38,8 +192,31 @@ export const ProfileSettings = () => {
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-y-4">
|
<div className="flex flex-col gap-y-4">
|
||||||
<div>
|
<div className="flex items-center gap-x-4">
|
||||||
<CurrentUserAvatar className="size-14" />
|
<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>
|
</div>
|
||||||
<FormField
|
<FormField
|
||||||
control={control}
|
control={control}
|
||||||
|
|
|
@ -57,7 +57,7 @@ export const UserDropdown = ({ className }: { className?: string }) => {
|
||||||
className={cn("group min-w-0", className)}
|
className={cn("group min-w-0", className)}
|
||||||
>
|
>
|
||||||
<Button variant="ghost">
|
<Button variant="ghost">
|
||||||
<CurrentUserAvatar className="size-6" />
|
<CurrentUserAvatar size={24} />
|
||||||
<span className="truncate">{user.name}</span>
|
<span className="truncate">{user.name}</span>
|
||||||
<Icon>
|
<Icon>
|
||||||
<ChevronDownIcon />
|
<ChevronDownIcon />
|
||||||
|
|
|
@ -9,6 +9,7 @@ import { Spinner } from "@/components/spinner";
|
||||||
import { useSubscription } from "@/contexts/plan";
|
import { useSubscription } from "@/contexts/plan";
|
||||||
import { PostHogProvider } from "@/contexts/posthog";
|
import { PostHogProvider } from "@/contexts/posthog";
|
||||||
import { PreferencesProvider } from "@/contexts/preferences";
|
import { PreferencesProvider } from "@/contexts/preferences";
|
||||||
|
import { trpc } from "@/utils/trpc/client";
|
||||||
|
|
||||||
import { useRequiredContext } from "./use-required-context";
|
import { useRequiredContext } from "./use-required-context";
|
||||||
|
|
||||||
|
@ -56,7 +57,7 @@ export const UserProvider = (props: { children?: React.ReactNode }) => {
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
const user = session.data?.user;
|
const user = session.data?.user;
|
||||||
const subscription = useSubscription();
|
const subscription = useSubscription();
|
||||||
|
const updatePreferences = trpc.user.updatePreferences.useMutation();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
@ -96,6 +97,14 @@ export const UserProvider = (props: { children?: React.ReactNode }) => {
|
||||||
weekStart: user.weekStart ?? undefined,
|
weekStart: user.weekStart ?? undefined,
|
||||||
}}
|
}}
|
||||||
onUpdate={async (newPreferences) => {
|
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);
|
await session.update(newPreferences);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
@ -13,7 +13,7 @@ type Preferences = {
|
||||||
|
|
||||||
type PreferencesContextValue = {
|
type PreferencesContextValue = {
|
||||||
preferences: Preferences;
|
preferences: Preferences;
|
||||||
updatePreferences: (preferences: Partial<Preferences>) => void;
|
updatePreferences: (preferences: Partial<Preferences>) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PreferencesContext = React.createContext<PreferencesContextValue | null>(
|
const PreferencesContext = React.createContext<PreferencesContextValue | null>(
|
||||||
|
@ -27,7 +27,7 @@ export const PreferencesProvider = ({
|
||||||
}: {
|
}: {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
initialValue: Partial<Preferences>;
|
initialValue: Partial<Preferences>;
|
||||||
onUpdate?: (preferences: Partial<Preferences>) => void;
|
onUpdate?: (preferences: Partial<Preferences>) => Promise<void>;
|
||||||
}) => {
|
}) => {
|
||||||
const [preferences, setPreferences] = useSetState<Preferences>(initialValue);
|
const [preferences, setPreferences] = useSetState<Preferences>(initialValue);
|
||||||
|
|
||||||
|
@ -35,9 +35,9 @@ export const PreferencesProvider = ({
|
||||||
<PreferencesContext.Provider
|
<PreferencesContext.Provider
|
||||||
value={{
|
value={{
|
||||||
preferences,
|
preferences,
|
||||||
updatePreferences: (newPreferences) => {
|
updatePreferences: async (newPreferences) => {
|
||||||
setPreferences(newPreferences);
|
setPreferences(newPreferences);
|
||||||
onUpdate?.(newPreferences);
|
await onUpdate?.(newPreferences);
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
@ -55,6 +55,15 @@ export const env = createEnv({
|
||||||
SUPPORT_EMAIL: z.string().email(),
|
SUPPORT_EMAIL: z.string().email(),
|
||||||
NOREPLY_EMAIL: z.string().email().optional(),
|
NOREPLY_EMAIL: z.string().email().optional(),
|
||||||
NOREPLY_EMAIL_NAME: z.string().default("Rallly"),
|
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).
|
* Environment variables available on the client (and server).
|
||||||
|
@ -89,10 +98,15 @@ export const env = createEnv({
|
||||||
SMTP_PWD: process.env.SMTP_PWD,
|
SMTP_PWD: process.env.SMTP_PWD,
|
||||||
SMTP_SECURE: process.env.SMTP_SECURE,
|
SMTP_SECURE: process.env.SMTP_SECURE,
|
||||||
SMTP_PORT: process.env.SMTP_PORT,
|
SMTP_PORT: process.env.SMTP_PORT,
|
||||||
|
ALLOWED_EMAILS: process.env.ALLOWED_EMAILS,
|
||||||
AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID,
|
AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID,
|
||||||
AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY,
|
AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY,
|
||||||
AWS_REGION: process.env.AWS_REGION,
|
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_KEY: process.env.NEXT_PUBLIC_POSTHOG_API_KEY,
|
||||||
NEXT_PUBLIC_POSTHOG_API_HOST: process.env.NEXT_PUBLIC_POSTHOG_API_HOST,
|
NEXT_PUBLIC_POSTHOG_API_HOST: process.env.NEXT_PUBLIC_POSTHOG_API_HOST,
|
||||||
NEXT_PUBLIC_SELF_HOSTED: process.env.NEXT_PUBLIC_SELF_HOSTED,
|
NEXT_PUBLIC_SELF_HOSTED: process.env.NEXT_PUBLIC_SELF_HOSTED,
|
||||||
|
|
|
@ -31,6 +31,7 @@ const trpcApiHandler = createNextApiHandler<AppRouter>({
|
||||||
id: session.user.id,
|
id: session.user.id,
|
||||||
isGuest: session.user.email === null,
|
isGuest: session.user.email === null,
|
||||||
locale: session.user.locale ?? undefined,
|
locale: session.user.locale ?? undefined,
|
||||||
|
image: session.user.image ?? undefined,
|
||||||
getEmailClient: () => getEmailClient(session.user.locale ?? undefined),
|
getEmailClient: () => getEmailClient(session.user.locale ?? undefined),
|
||||||
},
|
},
|
||||||
req: opts.req,
|
req: opts.req,
|
||||||
|
|
|
@ -7,6 +7,7 @@ export type TRPCContext = {
|
||||||
isGuest: boolean;
|
isGuest: boolean;
|
||||||
locale?: string;
|
locale?: string;
|
||||||
getEmailClient: (locale?: string) => EmailClient;
|
getEmailClient: (locale?: string) => EmailClient;
|
||||||
|
image?: string;
|
||||||
};
|
};
|
||||||
req: NextApiRequest;
|
req: NextApiRequest;
|
||||||
res: NextApiResponse;
|
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 { prisma } from "@rallly/database";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { waitUntil } from "@vercel/functions";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { env } from "@/env";
|
||||||
|
import { getS3Client } from "@/utils/s3";
|
||||||
import { getSubscriptionStatus } from "@/utils/subscription";
|
import { getSubscriptionStatus } from "@/utils/subscription";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
possiblyPublicProcedure,
|
possiblyPublicProcedure,
|
||||||
privateProcedure,
|
privateProcedure,
|
||||||
publicProcedure,
|
publicProcedure,
|
||||||
|
rateLimitMiddleware,
|
||||||
router,
|
router,
|
||||||
} from "../trpc";
|
} from "../trpc";
|
||||||
|
|
||||||
|
@ -121,13 +128,116 @@ export const user = router({
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
if (ctx.user.isGuest === false) {
|
if (ctx.user.isGuest) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "Guest users cannot update preferences",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: {
|
where: {
|
||||||
id: ctx.user.id,
|
id: ctx.user.id,
|
||||||
},
|
},
|
||||||
data: input,
|
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
|
// merge accounts assigned to provider account id to the current user id
|
||||||
await mergeGuestsIntoUser(user.id, [account.providerAccountId]);
|
await mergeGuestsIntoUser(user.id, [account.providerAccountId]);
|
||||||
}
|
}
|
||||||
if (trigger === "update" && session) {
|
|
||||||
if (token.email) {
|
if (user) {
|
||||||
// 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) {
|
|
||||||
token.locale = user.locale;
|
token.locale = user.locale;
|
||||||
token.timeFormat = user.timeFormat;
|
token.timeFormat = user.timeFormat;
|
||||||
token.timeZone = user.timeZone;
|
token.timeZone = user.timeZone;
|
||||||
token.weekStart = user.weekStart;
|
token.weekStart = user.weekStart;
|
||||||
token.picture = user.image;
|
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;
|
return token;
|
||||||
},
|
},
|
||||||
async session({ session, 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 isSelfHosted = process.env.NEXT_PUBLIC_SELF_HOSTED === "true";
|
||||||
|
|
||||||
export const isFeedbackEnabled = false;
|
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;
|
||||||
|
}
|
|
@ -34,9 +34,35 @@ services:
|
||||||
SRH_MODE: env
|
SRH_MODE: env
|
||||||
SRH_TOKEN: dev_fake_token_123456789abcdefghijklmnop
|
SRH_TOKEN: dev_fake_token_123456789abcdefghijklmnop
|
||||||
SRH_CONNECTION_STRING: "redis://redis:6379"
|
SRH_CONNECTION_STRING: "redis://redis:6379"
|
||||||
|
minio:
|
||||||
|
image: minio/minio
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
ports:
|
||||||
|
- "9000:9000"
|
||||||
|
- "9001:9001"
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: minio
|
||||||
|
MINIO_ROOT_PASSWORD: minio123
|
||||||
|
volumes:
|
||||||
|
- s3-data:/data
|
||||||
|
# This service just make sure a bucket with the right policies is created
|
||||||
|
createbuckets:
|
||||||
|
image: minio/mc
|
||||||
|
depends_on:
|
||||||
|
- minio
|
||||||
|
entrypoint: >
|
||||||
|
/bin/sh -c "
|
||||||
|
sleep 10;
|
||||||
|
/usr/bin/mc config host add minio http://minio:9000 minio minio123;
|
||||||
|
/usr/bin/mc mb minio/rallly;
|
||||||
|
/usr/bin/mc anonymous set public minio/rallly/public;
|
||||||
|
exit 0;
|
||||||
|
"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
db-data:
|
db-data:
|
||||||
driver: local
|
driver: local
|
||||||
redis-data:
|
redis-data:
|
||||||
driver: local
|
driver: local
|
||||||
|
s3-data:
|
||||||
|
driver: local
|
||||||
|
|
|
@ -45,5 +45,6 @@
|
||||||
"register_subject": "Please verify your email address",
|
"register_subject": "Please verify your email address",
|
||||||
"common_viewOn": "View on {{domain}}",
|
"common_viewOn": "View on {{domain}}",
|
||||||
"newComment_preview": "Go to your poll to see what they said.",
|
"newComment_preview": "Go to your poll to see what they said.",
|
||||||
"newComment_heading": "New Comment"
|
"newComment_heading": "New Comment",
|
||||||
|
"newPoll_preview": "Share your participant link to start collecting responses."
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,7 +9,8 @@
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/lib/utils.ts",
|
".": "./src/lib/utils.ts",
|
||||||
"./*": "./src/*.tsx"
|
"./*": "./src/*.tsx",
|
||||||
|
"./hooks/*": "./src/hooks/*.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-accordion": "^1.1.2",
|
"@radix-ui/react-accordion": "^1.1.2",
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
// Inspired by react-hot-toast library
|
// Inspired by react-hot-toast library
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import type { ToastActionElement, ToastProps } from "./toast";
|
import type { ToastActionElement, ToastProps } from "../toast";
|
||||||
|
|
||||||
const TOAST_LIMIT = 1;
|
const TOAST_LIMIT = 1;
|
||||||
const TOAST_REMOVE_DELAY = 1000000;
|
const TOAST_REMOVE_DELAY = 1000000;
|
|
@ -7,7 +7,7 @@ import {
|
||||||
ToastTitle,
|
ToastTitle,
|
||||||
ToastViewport,
|
ToastViewport,
|
||||||
} from "./toast";
|
} from "./toast";
|
||||||
import { useToast } from "./use-toast";
|
import { useToast } from "./hooks/use-toast";
|
||||||
|
|
||||||
export function Toaster() {
|
export function Toaster() {
|
||||||
const { toasts } = useToast();
|
const { toasts } = useToast();
|
||||||
|
|
|
@ -66,6 +66,11 @@
|
||||||
"AWS_ACCESS_KEY_ID",
|
"AWS_ACCESS_KEY_ID",
|
||||||
"AWS_REGION",
|
"AWS_REGION",
|
||||||
"AWS_SECRET_ACCESS_KEY",
|
"AWS_SECRET_ACCESS_KEY",
|
||||||
|
"S3_BUCKET_NAME",
|
||||||
|
"S3_ENDPOINT",
|
||||||
|
"S3_ACCESS_KEY_ID",
|
||||||
|
"S3_SECRET_ACCESS_KEY",
|
||||||
|
"S3_REGION",
|
||||||
"DATABASE_URL",
|
"DATABASE_URL",
|
||||||
"DISABLE_LANDING_PAGE",
|
"DISABLE_LANDING_PAGE",
|
||||||
"EMAIL_PROVIDER",
|
"EMAIL_PROVIDER",
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue