-
{template.publicTitle}
-
+
+ {template.publicTitle}
+
+
{template.publicDescription}
diff --git a/apps/web/src/app/(profile)/profile-header.tsx b/apps/web/src/app/(profile)/profile-header.tsx
index 2a968de24..b29569dce 100644
--- a/apps/web/src/app/(profile)/profile-header.tsx
+++ b/apps/web/src/app/(profile)/profile-header.tsx
@@ -62,9 +62,9 @@ export const ProfileHeader = ({ user, teams = [] }: ProfileHeaderProps) => {
-
+
Want your own public profile?
-
+
Like to have your own public profile with agreements?
diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/page.tsx
index 60ba4d26d..88782b1e7 100644
--- a/apps/web/src/app/(teams)/t/[teamUrl]/settings/page.tsx
+++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/page.tsx
@@ -13,6 +13,7 @@ import { AddTeamEmailDialog } from '~/components/(teams)/dialogs/add-team-email-
import { DeleteTeamDialog } from '~/components/(teams)/dialogs/delete-team-dialog';
import { TransferTeamDialog } from '~/components/(teams)/dialogs/transfer-team-dialog';
import { UpdateTeamForm } from '~/components/(teams)/forms/update-team-form';
+import { AvatarImageForm } from '~/components/forms/avatar-image';
import { TeamEmailDropdown } from './team-email-dropdown';
import { TeamTransferStatus } from './team-transfer-status';
@@ -44,6 +45,8 @@ export default async function TeamsSettingsPage({ params }: TeamsSettingsPagePro
transferVerification={team.transferVerification}
/>
+
+
diff --git a/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx b/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx
index 4895a61b3..760b9cad2 100644
--- a/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx
+++ b/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx
@@ -7,6 +7,7 @@ import { motion } from 'framer-motion';
import { CheckCircle2, ChevronsUpDown, Plus, Settings2 } from 'lucide-react';
import { signOut } from 'next-auth/react';
+import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { TEAM_MEMBER_ROLE_MAP, TEAM_URL_REGEX } from '@documenso/lib/constants/teams';
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
@@ -99,6 +100,9 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
className="relative flex h-12 flex-row items-center px-0 py-2 ring-0 focus:outline-none focus-visible:border-0 focus-visible:ring-0 focus-visible:ring-transparent md:px-2"
>
+
;
+
+export type AvatarImageFormProps = {
+ className?: string;
+ user: User;
+ team?: Team;
+};
+
+export const AvatarImageForm = ({ className, user, team }: AvatarImageFormProps) => {
+ const { toast } = useToast();
+ const router = useRouter();
+
+ const { mutateAsync: setProfileImage } = trpc.profile.setProfileImage.useMutation();
+
+ const initials = extractInitials(team?.name || user.name || '');
+
+ const hasAvatarImage = useMemo(() => {
+ if (team) {
+ return team.avatarImageId !== null;
+ }
+
+ return user.avatarImageId !== null;
+ }, [team, user.avatarImageId]);
+
+ const avatarImageId = team ? team.avatarImageId : user.avatarImageId;
+
+ const form = useForm({
+ values: {
+ bytes: null,
+ },
+ resolver: zodResolver(ZAvatarImageFormSchema),
+ });
+
+ const { getRootProps, getInputProps } = useDropzone({
+ maxSize: 1024 * 1024,
+ accept: {
+ 'image/*': ['.png', '.jpg', '.jpeg'],
+ },
+ multiple: false,
+ onDropAccepted: ([file]) => {
+ void file.arrayBuffer().then((buffer) => {
+ const contents = base64.encode(new Uint8Array(buffer));
+
+ form.setValue('bytes', contents);
+ void form.handleSubmit(onFormSubmit)();
+ });
+ },
+ onDropRejected: ([file]) => {
+ form.setError('bytes', {
+ type: 'onChange',
+ message: match(file.errors[0].code)
+ .with(ErrorCode.FileTooLarge, () => 'Uploaded file is too large')
+ .with(ErrorCode.FileTooSmall, () => 'Uploaded file is too small')
+ .with(ErrorCode.FileInvalidType, () => 'Uploaded file not an allowed file type')
+ .otherwise(() => 'An unknown error occurred'),
+ });
+ },
+ });
+
+ const onFormSubmit = async (data: TAvatarImageFormSchema) => {
+ try {
+ await setProfileImage({
+ bytes: data.bytes,
+ teamId: team?.id,
+ });
+
+ toast({
+ title: 'Avatar Updated',
+ description: 'Your avatar has been updated successfully.',
+ duration: 5000,
+ });
+
+ router.refresh();
+ } catch (err) {
+ if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
+ toast({
+ title: 'An error occurred',
+ description: err.message,
+ variant: 'destructive',
+ });
+ } else {
+ toast({
+ title: 'An unknown error occurred',
+ variant: 'destructive',
+ description:
+ 'We encountered an unknown error while attempting to update the avatar. Please try again later.',
+ });
+ }
+ }
+ };
+
+ return (
+
+
+ );
+};
diff --git a/apps/web/src/components/templates/manage-public-template-dialog.tsx b/apps/web/src/components/templates/manage-public-template-dialog.tsx
index 528a819c7..7bba43fe5 100644
--- a/apps/web/src/components/templates/manage-public-template-dialog.tsx
+++ b/apps/web/src/components/templates/manage-public-template-dialog.tsx
@@ -169,6 +169,8 @@ export const ManagePublicTemplateDialog = ({
description: 'Template has been updated.',
duration: 5000,
});
+
+ onOpenChange(false);
} catch {
toast({
title: 'An unknown error occurred',
diff --git a/apps/web/src/pages/api/avatar/[id].tsx b/apps/web/src/pages/api/avatar/[id].tsx
new file mode 100644
index 000000000..6809e235d
--- /dev/null
+++ b/apps/web/src/pages/api/avatar/[id].tsx
@@ -0,0 +1,34 @@
+import type { NextApiRequest, NextApiResponse } from 'next';
+
+import { getAvatarImage } from '@documenso/lib/server-only/profile/get-avatar-image';
+
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ if (req.method !== 'GET') {
+ return res.status(405).json({
+ status: 'error',
+ message: 'Method not allowed',
+ });
+ }
+
+ const { id } = req.query;
+
+ if (typeof id !== 'string') {
+ return res.status(400).json({
+ status: 'error',
+ message: 'Missing id',
+ });
+ }
+
+ const result = await getAvatarImage({ id });
+
+ if (!result) {
+ return res.status(404).json({
+ status: 'error',
+ message: 'Not found',
+ });
+ }
+
+ res.setHeader('Content-Type', result.contentType);
+ res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
+ res.send(result.content);
+}
diff --git a/package-lock.json b/package-lock.json
index fb96310ef..46a5c850c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -32,7 +32,7 @@
},
"engines": {
"node": ">=18.0.0",
- "npm": ">=8.6.0"
+ "npm": ">=10.7.0"
}
},
"apps/marketing": {
@@ -31843,6 +31843,7 @@
"playwright": "1.43.0",
"react": "18.2.0",
"remeda": "^1.27.1",
+ "sharp": "^0.33.1",
"stripe": "^12.7.0",
"ts-pattern": "^5.0.5",
"zod": "^3.22.4"
diff --git a/packages/lib/package.json b/packages/lib/package.json
index 7c4009d26..e439ca490 100644
--- a/packages/lib/package.json
+++ b/packages/lib/package.json
@@ -49,6 +49,7 @@
"playwright": "1.43.0",
"react": "18.2.0",
"remeda": "^1.27.1",
+ "sharp": "^0.33.1",
"stripe": "^12.7.0",
"ts-pattern": "^5.0.5",
"zod": "^3.22.4"
@@ -58,4 +59,4 @@
"@types/luxon": "^3.3.1",
"@types/pg": "^8.11.4"
}
-}
+}
\ No newline at end of file
diff --git a/packages/lib/server-only/profile/get-avatar-image.ts b/packages/lib/server-only/profile/get-avatar-image.ts
new file mode 100644
index 000000000..992869dcb
--- /dev/null
+++ b/packages/lib/server-only/profile/get-avatar-image.ts
@@ -0,0 +1,26 @@
+import sharp from 'sharp';
+
+import { prisma } from '@documenso/prisma';
+
+export type GetAvatarImageOptions = {
+ id: string;
+};
+
+export const getAvatarImage = async ({ id }: GetAvatarImageOptions) => {
+ const avatarImage = await prisma.avatarImage.findFirst({
+ where: {
+ id,
+ },
+ });
+
+ if (!avatarImage) {
+ return null;
+ }
+
+ const bytes = Buffer.from(avatarImage.bytes, 'base64');
+
+ return {
+ contentType: 'image/jpeg',
+ content: await sharp(bytes).toFormat('jpeg').toBuffer(),
+ };
+};
diff --git a/packages/lib/server-only/profile/get-public-profile-by-url.ts b/packages/lib/server-only/profile/get-public-profile-by-url.ts
index 00d97f1be..2f39053a4 100644
--- a/packages/lib/server-only/profile/get-public-profile-by-url.ts
+++ b/packages/lib/server-only/profile/get-public-profile-by-url.ts
@@ -25,6 +25,7 @@ type PublicDirectLinkTemplate = Template & {
type BaseResponse = {
url: string;
name: string;
+ avatarImageId?: string | null;
badge?: {
type: 'Premium' | 'EarlySupporter';
since: Date;
@@ -149,6 +150,7 @@ export const getPublicProfileByUrl = async ({
badge,
profile: user.profile,
url: profileUrl,
+ avatarImageId: user.avatarImageId,
name: user.name || '',
templates: user.Template.filter(
(template): template is PublicDirectLinkTemplate =>
@@ -166,6 +168,7 @@ export const getPublicProfileByUrl = async ({
},
profile: team.profile,
url: profileUrl,
+ avatarImageId: team.avatarImageId,
name: team.name || '',
templates: team.templates.filter(
(template): template is PublicDirectLinkTemplate =>
diff --git a/packages/lib/server-only/profile/set-avatar-image.ts b/packages/lib/server-only/profile/set-avatar-image.ts
new file mode 100644
index 000000000..79efda5bd
--- /dev/null
+++ b/packages/lib/server-only/profile/set-avatar-image.ts
@@ -0,0 +1,106 @@
+import sharp from 'sharp';
+
+import { prisma } from '@documenso/prisma';
+
+import type { RequestMetadata } from '../../universal/extract-request-metadata';
+
+export type SetAvatarImageOptions = {
+ userId: number;
+ teamId?: number | null;
+ bytes?: string | null;
+ requestMetadata?: RequestMetadata;
+};
+
+export const setAvatarImage = async ({
+ userId,
+ teamId,
+ bytes,
+ requestMetadata,
+}: SetAvatarImageOptions) => {
+ let oldAvatarImageId: string | null = null;
+
+ const user = await prisma.user.findUnique({
+ where: {
+ id: userId,
+ },
+ include: {
+ avatarImage: true,
+ },
+ });
+
+ if (!user) {
+ throw new Error('User not found');
+ }
+
+ oldAvatarImageId = user.avatarImageId;
+
+ if (teamId) {
+ const team = await prisma.team.findFirst({
+ where: {
+ id: teamId,
+ members: {
+ some: {
+ userId,
+ },
+ },
+ },
+ });
+
+ if (!team) {
+ throw new Error('Team not found');
+ }
+
+ oldAvatarImageId = team.avatarImageId;
+ }
+
+ if (oldAvatarImageId) {
+ await prisma.avatarImage.delete({
+ where: {
+ id: oldAvatarImageId,
+ },
+ });
+ }
+
+ let newAvatarImageId: string | null = null;
+
+ if (bytes) {
+ const optimisedBytes = await sharp(Buffer.from(bytes, 'base64'))
+ .resize(512, 512)
+ .toFormat('jpeg', { quality: 75 })
+ .toBuffer();
+
+ const avatarImage = await prisma.avatarImage.create({
+ data: {
+ bytes: optimisedBytes.toString('base64'),
+ },
+ });
+
+ newAvatarImageId = avatarImage.id;
+ }
+
+ if (teamId) {
+ await prisma.team.update({
+ where: {
+ id: teamId,
+ },
+ data: {
+ avatarImageId: newAvatarImageId,
+ },
+ });
+
+ // TODO: Audit Logs
+ } else {
+ await prisma.user.update({
+ where: {
+ id: userId,
+ },
+ data: {
+ avatarImageId: newAvatarImageId,
+ },
+ });
+
+ // TODO: Audit Logs
+ }
+
+ return newAvatarImageId;
+};
diff --git a/packages/prisma/migrations/20240627050809_add_avatar_image_model/migration.sql b/packages/prisma/migrations/20240627050809_add_avatar_image_model/migration.sql
new file mode 100644
index 000000000..1f1db2b37
--- /dev/null
+++ b/packages/prisma/migrations/20240627050809_add_avatar_image_model/migration.sql
@@ -0,0 +1,19 @@
+-- AlterTable
+ALTER TABLE "Team" ADD COLUMN "avatarImageId" TEXT;
+
+-- AlterTable
+ALTER TABLE "User" ADD COLUMN "avatarImageId" TEXT;
+
+-- CreateTable
+CREATE TABLE "AvatarImage" (
+ "id" TEXT NOT NULL,
+ "bytes" TEXT NOT NULL,
+
+ CONSTRAINT "AvatarImage_pkey" PRIMARY KEY ("id")
+);
+
+-- AddForeignKey
+ALTER TABLE "User" ADD CONSTRAINT "User_avatarImageId_fkey" FOREIGN KEY ("avatarImageId") REFERENCES "AvatarImage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Team" ADD CONSTRAINT "Team_avatarImageId_fkey" FOREIGN KEY ("avatarImageId") REFERENCES "AvatarImage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma
index e5680e94c..1cc8b77ea 100644
--- a/packages/prisma/schema.prisma
+++ b/packages/prisma/schema.prisma
@@ -24,19 +24,21 @@ enum Role {
}
model User {
- id Int @id @default(autoincrement())
- name String?
- customerId String? @unique
- email String @unique
- emailVerified DateTime?
- password String?
- source String?
- signature String?
- createdAt DateTime @default(now())
- updatedAt DateTime @default(now()) @updatedAt
- lastSignedIn DateTime @default(now())
- roles Role[] @default([USER])
- identityProvider IdentityProvider @default(DOCUMENSO)
+ id Int @id @default(autoincrement())
+ name String?
+ customerId String? @unique
+ email String @unique
+ emailVerified DateTime?
+ password String?
+ source String?
+ signature String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @default(now()) @updatedAt
+ lastSignedIn DateTime @default(now())
+ roles Role[] @default([USER])
+ identityProvider IdentityProvider @default(DOCUMENSO)
+ avatarImageId String?
+
accounts Account[]
sessions Session[]
Document Document[]
@@ -58,6 +60,7 @@ model User {
Webhooks Webhook[]
siteSettings SiteSettings[]
passkeys Passkey[]
+ avatarImage AvatarImage? @relation(fields: [avatarImageId], references: [id], onDelete: SetNull)
@@index([email])
}
@@ -474,17 +477,20 @@ enum TeamMemberInviteStatus {
}
model Team {
- id Int @id @default(autoincrement())
- name String
- url String @unique
- createdAt DateTime @default(now())
- customerId String? @unique
- ownerUserId Int
+ id Int @id @default(autoincrement())
+ name String
+ url String @unique
+ createdAt DateTime @default(now())
+ avatarImageId String?
+ customerId String? @unique
+ ownerUserId Int
+
members TeamMember[]
invites TeamMemberInvite[]
teamEmail TeamEmail?
emailVerification TeamEmailVerification?
transferVerification TeamTransferVerification?
+ avatarImage AvatarImage? @relation(fields: [avatarImageId], references: [id], onDelete: SetNull)
profile TeamProfile?
owner User @relation(fields: [ownerUserId], references: [id], onDelete: Cascade)
@@ -676,3 +682,11 @@ model BackgroundJobTask {
jobId String
backgroundJob BackgroundJob @relation(fields: [jobId], references: [id], onDelete: Cascade)
}
+
+model AvatarImage {
+ id String @id @default(cuid())
+ bytes String
+
+ team Team[]
+ user User[]
+}
diff --git a/packages/trpc/server/profile-router/router.ts b/packages/trpc/server/profile-router/router.ts
index 9718b6e4a..e2fdd8bed 100644
--- a/packages/trpc/server/profile-router/router.ts
+++ b/packages/trpc/server/profile-router/router.ts
@@ -3,6 +3,7 @@ import { TRPCError } from '@trpc/server';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { jobsClient } from '@documenso/lib/jobs/client';
+import { setAvatarImage } from '@documenso/lib/server-only/profile/set-avatar-image';
import { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscription/get-subscriptions-by-user-id';
import { deleteUser } from '@documenso/lib/server-only/user/delete-user';
import { findUserSecurityAuditLogs } from '@documenso/lib/server-only/user/find-user-security-audit-logs';
@@ -22,6 +23,7 @@ import {
ZForgotPasswordFormSchema,
ZResetPasswordFormSchema,
ZRetrieveUserByIdQuerySchema,
+ ZSetProfileImageMutationSchema,
ZUpdatePasswordMutationSchema,
ZUpdateProfileMutationSchema,
ZUpdatePublicProfileMutationSchema,
@@ -246,4 +248,32 @@ export const profileRouter = router({
});
}
}),
+
+ setProfileImage: authenticatedProcedure
+ .input(ZSetProfileImageMutationSchema)
+ .mutation(async ({ input, ctx }) => {
+ try {
+ const { bytes, teamId } = input;
+
+ return await setAvatarImage({
+ userId: ctx.user.id,
+ teamId,
+ bytes,
+ requestMetadata: extractNextApiRequestMetadata(ctx.req),
+ });
+ } catch (err) {
+ console.error(err);
+
+ let message = 'We were unable to update your profile image. Please try again.';
+
+ if (err instanceof Error) {
+ message = err.message;
+ }
+
+ throw new TRPCError({
+ code: 'BAD_REQUEST',
+ message,
+ });
+ }
+ }),
});
diff --git a/packages/trpc/server/profile-router/schema.ts b/packages/trpc/server/profile-router/schema.ts
index 9417b8279..92384e46e 100644
--- a/packages/trpc/server/profile-router/schema.ts
+++ b/packages/trpc/server/profile-router/schema.ts
@@ -9,15 +9,21 @@ export const ZFindUserSecurityAuditLogsSchema = z.object({
perPage: z.number().optional(),
});
+export type TFindUserSecurityAuditLogsSchema = z.infer;
+
export const ZRetrieveUserByIdQuerySchema = z.object({
id: z.number().min(1),
});
+export type TRetrieveUserByIdQuerySchema = z.infer;
+
export const ZUpdateProfileMutationSchema = z.object({
name: z.string().min(1),
signature: z.string(),
});
+export type TUpdateProfileMutationSchema = z.infer;
+
export const ZUpdatePublicProfileMutationSchema = z.object({
bio: z
.string()
@@ -37,28 +43,37 @@ export const ZUpdatePublicProfileMutationSchema = z.object({
.optional(),
});
+export type TUpdatePublicProfileMutationSchema = z.infer;
+
export const ZUpdatePasswordMutationSchema = z.object({
currentPassword: ZCurrentPasswordSchema,
password: ZPasswordSchema,
});
+export type TUpdatePasswordMutationSchema = z.infer;
+
export const ZForgotPasswordFormSchema = z.object({
email: z.string().email().min(1),
});
+export type TForgotPasswordFormSchema = z.infer;
+
export const ZResetPasswordFormSchema = z.object({
password: ZPasswordSchema,
token: z.string().min(1),
});
+export type TResetPasswordFormSchema = z.infer;
+
export const ZConfirmEmailMutationSchema = z.object({
email: z.string().email().min(1),
});
-export type TFindUserSecurityAuditLogsSchema = z.infer;
-export type TRetrieveUserByIdQuerySchema = z.infer;
-export type TUpdateProfileMutationSchema = z.infer;
-export type TUpdatePasswordMutationSchema = z.infer;
-export type TForgotPasswordFormSchema = z.infer;
-export type TResetPasswordFormSchema = z.infer;
export type TConfirmEmailMutationSchema = z.infer;
+
+export const ZSetProfileImageMutationSchema = z.object({
+ bytes: z.string().nullish(),
+ teamId: z.number().min(1).nullish(),
+});
+
+export type TSetProfileImageMutationSchema = z.infer;
diff --git a/packages/ui/primitives/avatar.tsx b/packages/ui/primitives/avatar.tsx
index aa2f522fe..63f94dd85 100644
--- a/packages/ui/primitives/avatar.tsx
+++ b/packages/ui/primitives/avatar.tsx
@@ -50,6 +50,7 @@ AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
type AvatarWithTextProps = {
avatarClass?: string;
+ avatarSrc?: string | null;
avatarFallback: string;
className?: string;
primaryText: React.ReactNode;
@@ -61,6 +62,7 @@ type AvatarWithTextProps = {
const AvatarWithText = ({
avatarClass,
+ avatarSrc,
avatarFallback,
className,
primaryText,
@@ -72,6 +74,7 @@ const AvatarWithText = ({
+ {avatarSrc && }
{avatarFallback}