From 22c02aac02c94b30cf96efc9083b51cdcbc94a20 Mon Sep 17 00:00:00 2001 From: Mythie Date: Thu, 27 Jun 2024 21:50:42 +1000 Subject: [PATCH] feat: avatar images --- .../app/(dashboard)/settings/profile/page.tsx | 2 + apps/web/src/app/(profile)/layout.tsx | 10 +- apps/web/src/app/(profile)/p/[url]/page.tsx | 62 +++++- apps/web/src/app/(profile)/profile-header.tsx | 4 +- .../app/(teams)/t/[teamUrl]/settings/page.tsx | 3 + .../(dashboard)/layout/menu-switcher.tsx | 16 +- .../web/src/components/forms/avatar-image.tsx | 188 ++++++++++++++++++ .../manage-public-template-dialog.tsx | 2 + apps/web/src/pages/api/avatar/[id].tsx | 34 ++++ package-lock.json | 3 +- packages/lib/package.json | 3 +- .../server-only/profile/get-avatar-image.ts | 26 +++ .../profile/get-public-profile-by-url.ts | 3 + .../server-only/profile/set-avatar-image.ts | 106 ++++++++++ .../migration.sql | 19 ++ packages/prisma/schema.prisma | 52 +++-- packages/trpc/server/profile-router/router.ts | 30 +++ packages/trpc/server/profile-router/schema.ts | 27 ++- packages/ui/primitives/avatar.tsx | 3 + 19 files changed, 546 insertions(+), 47 deletions(-) create mode 100644 apps/web/src/components/forms/avatar-image.tsx create mode 100644 apps/web/src/pages/api/avatar/[id].tsx create mode 100644 packages/lib/server-only/profile/get-avatar-image.ts create mode 100644 packages/lib/server-only/profile/set-avatar-image.ts create mode 100644 packages/prisma/migrations/20240627050809_add_avatar_image_model/migration.sql diff --git a/apps/web/src/app/(dashboard)/settings/profile/page.tsx b/apps/web/src/app/(dashboard)/settings/profile/page.tsx index 7d4fbe6f7..07b536a35 100644 --- a/apps/web/src/app/(dashboard)/settings/profile/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/profile/page.tsx @@ -3,6 +3,7 @@ import type { Metadata } from 'next'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header'; +import { AvatarImageForm } from '~/components/forms/avatar-image'; import { ProfileForm } from '~/components/forms/profile'; import { DeleteAccountDialog } from './delete-account-dialog'; @@ -18,6 +19,7 @@ export default async function ProfileSettingsPage() {
+
diff --git a/apps/web/src/app/(profile)/layout.tsx b/apps/web/src/app/(profile)/layout.tsx index cac1f3cb3..9bb26f5b2 100644 --- a/apps/web/src/app/(profile)/layout.tsx +++ b/apps/web/src/app/(profile)/layout.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; -import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams'; import { getTeams } from '@documenso/lib/server-only/team/get-teams'; import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus'; @@ -16,18 +15,15 @@ type PublicProfileLayoutProps = { export default async function PublicProfileLayout({ children }: PublicProfileLayoutProps) { const { user, session } = await getServerComponentSession(); - let teams: GetTeamsResponse = []; - - if (user && session) { - teams = await getTeams({ userId: user.id }); - } + // I wouldn't typically do this but it's better than the `let` statement + const teams = user && session ? await getTeams({ userId: user.id }) : undefined; return (
-
{children}
+
{children}
diff --git a/apps/web/src/app/(profile)/p/[url]/page.tsx b/apps/web/src/app/(profile)/p/[url]/page.tsx index efd555502..f8d197fff 100644 --- a/apps/web/src/app/(profile)/p/[url]/page.tsx +++ b/apps/web/src/app/(profile)/p/[url]/page.tsx @@ -5,10 +5,12 @@ import { notFound, redirect } from 'next/navigation'; import { FileIcon } from 'lucide-react'; import { DateTime } from 'luxon'; +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getPublicProfileByUrl } from '@documenso/lib/server-only/profile/get-public-profile-by-url'; import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; import { formatDirectTemplatePath } from '@documenso/lib/utils/templates'; -import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar'; +import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar'; import { Button } from '@documenso/ui/primitives/button'; import { Table, @@ -52,19 +54,27 @@ export default async function PublicProfilePage({ params }: PublicProfilePagePro notFound(); } + const { user } = await getServerComponentSession(); + const { profile, templates } = publicProfile; return (
+ {publicProfile.avatarImageId && ( + + )} + {extractInitials(publicProfile.name)}
-

{publicProfile.name}

+

{publicProfile.name}

{publicProfile.badge && ( @@ -88,7 +98,7 @@ export default async function PublicProfilePage({ params }: PublicProfilePagePro />
-

+

{BADGE_DATA[publicProfile.badge.type].name}

@@ -100,13 +110,43 @@ export default async function PublicProfilePage({ params }: PublicProfilePagePro )}

-
- {profile.bio} +
+ {(profile.bio ?? '').split('\n').map((line, index) => ( +

+ {line} +

+ ))}
+ {templates.length === 0 && ( +
+

+ It looks like {publicProfile.name} hasn't added any documents to their profile yet.{' '} + {!user?.id && ( + + While waiting for them to do so you can create your own Documenso account and get + started with document signing right away. + + )} + {'userId' in profile && user?.id === profile.userId && ( + + Go to your{' '} + + public profile settings + {' '} + to add documents. + + )} +

+
+ )} + {templates.length > 0 && ( -
+
@@ -119,16 +159,18 @@ export default async function PublicProfilePage({ params }: PublicProfilePagePro {templates.map((template) => ( -
+
-
+
-

{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 ( +
+ +
+ ( + + Avatar + + +
+
+ + {avatarImageId && ( + + )} + + {initials} + + + + {hasAvatarImage && ( + + )} +
+ + +
+
+ + +
+ )} + /> +
+ + + ); +}; 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}