diff --git a/.eslintrc.cjs b/.eslintrc.cjs
index 8113ada52..d6a275a59 100644
--- a/.eslintrc.cjs
+++ b/.eslintrc.cjs
@@ -4,6 +4,7 @@ module.exports = {
extends: ['@documenso/eslint-config'],
rules: {
'@next/next/no-img-element': 'off',
+ 'no-unreachable': 'error',
},
settings: {
next: {
diff --git a/apps/web/public/static/early-supporter-badge.svg b/apps/web/public/static/early-supporter-badge.svg
new file mode 100644
index 000000000..11efe2193
--- /dev/null
+++ b/apps/web/public/static/early-supporter-badge.svg
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/static/premium-user-badge.svg b/apps/web/public/static/premium-user-badge.svg
new file mode 100644
index 000000000..0a448c4e7
--- /dev/null
+++ b/apps/web/public/static/premium-user-badge.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/src/app/(dashboard)/settings/profile/claim-profile-alert-dialog.tsx b/apps/web/src/app/(dashboard)/settings/profile/claim-profile-alert-dialog.tsx
deleted file mode 100644
index c894113b6..000000000
--- a/apps/web/src/app/(dashboard)/settings/profile/claim-profile-alert-dialog.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-'use client';
-
-import { useState } from 'react';
-
-import type { User } from '@documenso/prisma/client';
-import { cn } from '@documenso/ui/lib/utils';
-import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
-import { Button } from '@documenso/ui/primitives/button';
-
-import { ClaimPublicProfileDialogForm } from '~/components/forms/public-profile-claim-dialog';
-
-export type ClaimProfileAlertDialogProps = {
- className?: string;
- user: User;
-};
-
-export const ClaimProfileAlertDialog = ({ className, user }: ClaimProfileAlertDialogProps) => {
- const [open, setOpen] = useState(false);
-
- return (
- <>
-
-
-
{user.url ? 'Update your profile' : 'Claim your profile'}
-
- {user.url
- ? 'Profiles are coming soon! Update your profile username to reserve your corner of the signing revolution.'
- : 'Profiles are coming soon! Claim your profile username now to reserve your corner of the signing revolution.'}
-
-
-
-
- setOpen(true)}>{user.url ? 'Update Now' : 'Claim Now'}
-
-
-
-
- >
- );
-};
diff --git a/apps/web/src/app/(dashboard)/settings/profile/page.tsx b/apps/web/src/app/(dashboard)/settings/profile/page.tsx
index 669c149b5..07b536a35 100644
--- a/apps/web/src/app/(dashboard)/settings/profile/page.tsx
+++ b/apps/web/src/app/(dashboard)/settings/profile/page.tsx
@@ -3,9 +3,9 @@ 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 { ClaimProfileAlertDialog } from './claim-profile-alert-dialog';
import { DeleteAccountDialog } from './delete-account-dialog';
export const metadata: Metadata = {
@@ -19,10 +19,9 @@ export default async function ProfileSettingsPage() {
+
-
-
diff --git a/apps/web/src/app/(dashboard)/settings/public-profile/page.tsx b/apps/web/src/app/(dashboard)/settings/public-profile/page.tsx
new file mode 100644
index 000000000..f622b5636
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/settings/public-profile/page.tsx
@@ -0,0 +1,14 @@
+import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
+import { getUserPublicProfile } from '@documenso/lib/server-only/user/get-user-public-profile';
+
+import { PublicProfilePageView } from './public-profile-page-view';
+
+export default async function Page() {
+ const { user } = await getRequiredServerComponentSession();
+
+ const { profile } = await getUserPublicProfile({
+ userId: user.id,
+ });
+
+ return
;
+}
diff --git a/apps/web/src/app/(dashboard)/settings/public-profile/public-profile-page-view.tsx b/apps/web/src/app/(dashboard)/settings/public-profile/public-profile-page-view.tsx
new file mode 100644
index 000000000..90759f68e
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/settings/public-profile/public-profile-page-view.tsx
@@ -0,0 +1,207 @@
+'use client';
+
+import { useEffect, useMemo, useState } from 'react';
+
+import type { FindTemplateRow } from '@documenso/lib/server-only/template/find-templates';
+import type {
+ Team,
+ TeamProfile,
+ TemplateDirectLink,
+ User,
+ UserProfile,
+} from '@documenso/prisma/client';
+import { TemplateType } from '@documenso/prisma/client';
+import { trpc } from '@documenso/trpc/react';
+import { cn } from '@documenso/ui/lib/utils';
+import { Button } from '@documenso/ui/primitives/button';
+import { Switch } from '@documenso/ui/primitives/switch';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
+import type { TPublicProfileFormSchema } from '~/components/forms/public-profile-form';
+import { PublicProfileForm } from '~/components/forms/public-profile-form';
+import { ManagePublicTemplateDialog } from '~/components/templates/manage-public-template-dialog';
+
+import { PublicTemplatesDataTable } from './public-templates-data-table';
+
+export type PublicProfilePageViewOptions = {
+ user: User;
+ team?: Team;
+ profile: UserProfile | TeamProfile;
+};
+
+type DirectTemplate = FindTemplateRow & {
+ directLink: Pick
;
+};
+
+const userProfileText = {
+ settingsTitle: 'Public Profile',
+ settingsSubtitle: 'You can choose to enable or disable your profile for public view.',
+ templatesTitle: 'My templates',
+ templatesSubtitle:
+ 'Show templates in your public profile for your audience to sign and get started quickly',
+};
+
+const teamProfileText = {
+ settingsTitle: 'Team Public Profile',
+ settingsSubtitle: 'You can choose to enable or disable your team profile for public view.',
+ templatesTitle: 'Team templates',
+ templatesSubtitle:
+ 'Show templates in your team public profile for your audience to sign and get started quickly',
+};
+
+export const PublicProfilePageView = ({ user, team, profile }: PublicProfilePageViewOptions) => {
+ const { toast } = useToast();
+
+ const [isPublicProfileVisible, setIsPublicProfileVisible] = useState(profile.enabled);
+ const [isTooltipOpen, setIsTooltipOpen] = useState(false);
+
+ const { data } = trpc.template.findTemplates.useQuery({
+ perPage: 100,
+ teamId: team?.id,
+ });
+
+ const { mutateAsync: updateUserProfile, isLoading: isUpdatingUserProfile } =
+ trpc.profile.updatePublicProfile.useMutation();
+
+ const { mutateAsync: updateTeamProfile, isLoading: isUpdatingTeamProfile } =
+ trpc.team.updateTeamPublicProfile.useMutation();
+
+ const isUpdating = isUpdatingUserProfile || isUpdatingTeamProfile;
+ const profileText = team ? teamProfileText : userProfileText;
+
+ const enabledPrivateDirectTemplates = useMemo(
+ () =>
+ (data?.templates ?? []).filter(
+ (template): template is DirectTemplate =>
+ template.directLink?.enabled === true && template.type !== TemplateType.PUBLIC,
+ ),
+ [data],
+ );
+
+ const onProfileUpdate = async (data: TPublicProfileFormSchema) => {
+ if (team) {
+ await updateTeamProfile({
+ teamId: team.id,
+ ...data,
+ });
+ } else {
+ await updateUserProfile(data);
+ }
+
+ if (data.enabled === undefined && !isPublicProfileVisible) {
+ setIsTooltipOpen(true);
+ }
+ };
+
+ const togglePublicProfileVisibility = async (isVisible: boolean) => {
+ setIsTooltipOpen(false);
+
+ if (isUpdating) {
+ return;
+ }
+
+ if (isVisible && !user.url) {
+ toast({
+ title: 'You must set a profile URL before enabling your public profile.',
+ variant: 'destructive',
+ });
+
+ return;
+ }
+
+ setIsPublicProfileVisible(isVisible);
+
+ try {
+ await onProfileUpdate({
+ enabled: isVisible,
+ });
+ } catch {
+ toast({
+ title: 'Something went wrong',
+ description: 'We were unable to set your public profile to public. Please try again.',
+ variant: 'destructive',
+ });
+
+ setIsPublicProfileVisible(!isVisible);
+ }
+ };
+
+ useEffect(() => {
+ setIsPublicProfileVisible(profile.enabled);
+ }, [profile.enabled]);
+
+ return (
+
+
+
+
+ *:first-child]:text-muted-foreground': !isPublicProfileVisible,
+ '[&>*:last-child]:text-muted-foreground': isPublicProfileVisible,
+ },
+ )}
+ >
+ Hide
+
+ Show
+
+
+
+
+ {isPublicProfileVisible ? (
+ <>
+
+ Profile is currently visible .
+
+
+ Toggle the switch to hide your profile from the public.
+ >
+ ) : (
+ <>
+
+ Profile is currently hidden .
+
+
+ Toggle the switch to show your profile to the public.
+ >
+ )}
+
+
+
+
+
+
+
+
+ Link template}
+ />
+
+
+
+
+
+ );
+};
diff --git a/apps/web/src/app/(dashboard)/settings/public-profile/public-templates-data-table.tsx b/apps/web/src/app/(dashboard)/settings/public-profile/public-templates-data-table.tsx
new file mode 100644
index 000000000..692e01ac6
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/settings/public-profile/public-templates-data-table.tsx
@@ -0,0 +1,209 @@
+'use client';
+
+import { useMemo, useState } from 'react';
+
+import { EditIcon, FileIcon, LinkIcon, MoreHorizontalIcon, Trash2Icon } from 'lucide-react';
+
+import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
+import type { FindTemplateRow } from '@documenso/lib/server-only/template/find-templates';
+import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
+import type { TemplateDirectLink } from '@documenso/prisma/client';
+import { TemplateType } from '@documenso/prisma/client';
+import { trpc } from '@documenso/trpc/react';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuTrigger,
+} from '@documenso/ui/primitives/dropdown-menu';
+import { Skeleton } from '@documenso/ui/primitives/skeleton';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+import { ManagePublicTemplateDialog } from '~/components/templates/manage-public-template-dialog';
+import { useOptionalCurrentTeam } from '~/providers/team';
+
+type DirectTemplate = FindTemplateRow & {
+ directLink: Pick;
+};
+
+export const PublicTemplatesDataTable = () => {
+ const team = useOptionalCurrentTeam();
+
+ const { toast } = useToast();
+
+ const [, copy] = useCopyToClipboard();
+
+ const [publicTemplateDialogPayload, setPublicTemplateDialogPayload] = useState<{
+ step: 'MANAGE' | 'CONFIRM_DISABLE';
+ templateId: number;
+ } | null>(null);
+
+ const { data, isInitialLoading, isLoadingError, refetch } = trpc.template.findTemplates.useQuery(
+ {
+ teamId: team?.id,
+ },
+ {
+ keepPreviousData: true,
+ },
+ );
+
+ const { directTemplates, publicDirectTemplates, privateDirectTemplates } = useMemo(() => {
+ const directTemplates = (data?.templates ?? []).filter(
+ (template): template is DirectTemplate => template.directLink?.enabled === true,
+ );
+
+ const publicDirectTemplates = directTemplates.filter(
+ (template) => template.directLink?.enabled === true && template.type === TemplateType.PUBLIC,
+ );
+
+ const privateDirectTemplates = directTemplates.filter(
+ (template) => template.directLink?.enabled === true && template.type === TemplateType.PRIVATE,
+ );
+
+ return {
+ directTemplates,
+ publicDirectTemplates,
+ privateDirectTemplates,
+ };
+ }, [data]);
+
+ const onCopyClick = async (token: string) =>
+ copy(formatDirectTemplatePath(token)).then(() => {
+ toast({
+ title: 'Copied to clipboard',
+ description: 'The direct link has been copied to your clipboard',
+ });
+ });
+
+ return (
+
+
+ {/* Loading and error handling states. */}
+ {publicDirectTemplates.length === 0 && (
+ <>
+ {isInitialLoading &&
+ Array(3)
+ .fill(0)
+ .map((_, index) => (
+
+ ))}
+
+ {isLoadingError && (
+
+ Unable to load your public profile templates at this time
+ {
+ e.preventDefault();
+ void refetch();
+ }}
+ >
+ Click here to retry
+
+
+ )}
+
+ {!isInitialLoading && (
+
+ No public profile templates found
+
+ Click here to get started
+
+ }
+ />
+
+ )}
+ >
+ )}
+
+ {/* Public templates list. */}
+ {publicDirectTemplates.map((template) => (
+
+
+
+
+
+
{template.publicTitle}
+
{template.publicDescription}
+
+
+
+
+
+
+
+
+
+ Action
+
+ void onCopyClick(template.directLink.token)}>
+
+ Copy sharable link
+
+
+ {
+ setPublicTemplateDialogPayload({
+ step: 'MANAGE',
+ templateId: template.id,
+ });
+ }}
+ >
+
+ Update
+
+
+
+ setPublicTemplateDialogPayload({
+ step: 'CONFIRM_DISABLE',
+ templateId: template.id,
+ })
+ }
+ >
+
+ Remove
+
+
+
+
+ ))}
+
+
+
{
+ if (!value) {
+ setPublicTemplateDialogPayload(null);
+ }
+ }}
+ />
+
+ );
+};
diff --git a/apps/web/src/app/(profile)/layout.tsx b/apps/web/src/app/(profile)/layout.tsx
new file mode 100644
index 000000000..9bb26f5b2
--- /dev/null
+++ b/apps/web/src/app/(profile)/layout.tsx
@@ -0,0 +1,32 @@
+import React from 'react';
+
+import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
+import { getTeams } from '@documenso/lib/server-only/team/get-teams';
+
+import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
+import { NextAuthProvider } from '~/providers/next-auth';
+
+import { ProfileHeader } from './profile-header';
+
+type PublicProfileLayoutProps = {
+ children: React.ReactNode;
+};
+
+export default async function PublicProfileLayout({ children }: PublicProfileLayoutProps) {
+ const { user, session } = await getServerComponentSession();
+
+ // 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 (
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(profile)/p/[url]/not-found.tsx b/apps/web/src/app/(profile)/p/[url]/not-found.tsx
new file mode 100644
index 000000000..e954255b5
--- /dev/null
+++ b/apps/web/src/app/(profile)/p/[url]/not-found.tsx
@@ -0,0 +1,32 @@
+'use client';
+
+import Link from 'next/link';
+
+import { ChevronLeft } from 'lucide-react';
+
+import { Button } from '@documenso/ui/primitives/button';
+
+export default function NotFound() {
+ return (
+
+
+
404 Profile not found
+
+
Oops! Something went wrong.
+
+
+ The profile you are looking for could not be found.
+
+
+
+
+
+
+ Go Back
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(profile)/p/[url]/page.tsx b/apps/web/src/app/(profile)/p/[url]/page.tsx
new file mode 100644
index 000000000..f8d197fff
--- /dev/null
+++ b/apps/web/src/app/(profile)/p/[url]/page.tsx
@@ -0,0 +1,194 @@
+import Image from 'next/image';
+import Link from 'next/link';
+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, AvatarImage } from '@documenso/ui/primitives/avatar';
+import { Button } from '@documenso/ui/primitives/button';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@documenso/ui/primitives/table';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
+
+export type PublicProfilePageProps = {
+ params: {
+ url: string;
+ };
+};
+
+const BADGE_DATA = {
+ Premium: {
+ imageSrc: '/static/premium-user-badge.svg',
+ name: 'Premium',
+ },
+ EarlySupporter: {
+ imageSrc: '/static/early-supporter-badge.svg',
+ name: 'Early supporter',
+ },
+};
+
+export default async function PublicProfilePage({ params }: PublicProfilePageProps) {
+ const { url: profileUrl } = params;
+
+ if (!profileUrl) {
+ redirect('/');
+ }
+
+ const publicProfile = await getPublicProfileByUrl({
+ profileUrl,
+ }).catch(() => null);
+
+ if (!publicProfile || !publicProfile.profile.enabled) {
+ notFound();
+ }
+
+ const { user } = await getServerComponentSession();
+
+ const { profile, templates } = publicProfile;
+
+ return (
+
+
+
+ {publicProfile.avatarImageId && (
+
+ )}
+
+
+ {extractInitials(publicProfile.name)}
+
+
+
+
+
{publicProfile.name}
+
+ {publicProfile.badge && (
+
+
+
+
+
+
+
+
+
+
+ {BADGE_DATA[publicProfile.badge.type].name}
+
+
+ Since {DateTime.fromJSDate(publicProfile.badge.since).toFormat('LLL ‘yy')}
+
+
+
+
+ )}
+
+
+
+ {(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 && (
+
+
+
+
+
+ Documents
+
+
+
+
+ {templates.map((template) => (
+
+
+
+
+
+
+
+
+ {template.publicTitle}
+
+
+ {template.publicDescription}
+
+
+
+
+
+ Sign
+
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/app/(profile)/profile-header.tsx b/apps/web/src/app/(profile)/profile-header.tsx
new file mode 100644
index 000000000..b29569dce
--- /dev/null
+++ b/apps/web/src/app/(profile)/profile-header.tsx
@@ -0,0 +1,86 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+
+import Image from 'next/image';
+import Link from 'next/link';
+
+import { PlusIcon } from 'lucide-react';
+
+import LogoIcon from '@documenso/assets/logo_icon.png';
+import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
+import type { User } from '@documenso/prisma/client';
+import { cn } from '@documenso/ui/lib/utils';
+import { Button } from '@documenso/ui/primitives/button';
+
+import { Header as AuthenticatedHeader } from '~/components/(dashboard)/layout/header';
+import { Logo } from '~/components/branding/logo';
+
+type ProfileHeaderProps = {
+ user?: User | null;
+ teams?: GetTeamsResponse;
+};
+
+export const ProfileHeader = ({ user, teams = [] }: ProfileHeaderProps) => {
+ const [scrollY, setScrollY] = useState(0);
+
+ useEffect(() => {
+ const onScroll = () => {
+ setScrollY(window.scrollY);
+ };
+
+ window.addEventListener('scroll', onScroll);
+
+ return () => window.removeEventListener('scroll', onScroll);
+ }, []);
+
+ if (user) {
+ return ;
+ }
+
+ return (
+ 5 && 'border-b-border',
+ )}
+ >
+
+
+
+
+
+
+
+
+
+ Want your own public profile?
+
+ Like to have your own public profile with agreements?
+
+
+
+
+
+
+
+ Create
+
+
+
+
+
+ );
+};
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 a86797191..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';
@@ -35,7 +36,7 @@ export default async function TeamsSettingsPage({ params }: TeamsSettingsPagePro
return (
-
+
+
+
diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/public-profile/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/public-profile/page.tsx
new file mode 100644
index 000000000..494faaa9b
--- /dev/null
+++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/public-profile/page.tsx
@@ -0,0 +1,28 @@
+import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
+import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
+import { getTeamPublicProfile } from '@documenso/lib/server-only/team/get-team-public-profile';
+
+import { PublicProfilePageView } from '~/app/(dashboard)/settings/public-profile/public-profile-page-view';
+
+export type TeamsSettingsPublicProfilePageProps = {
+ params: {
+ teamUrl: string;
+ };
+};
+
+export default async function TeamsSettingsPublicProfilePage({
+ params,
+}: TeamsSettingsPublicProfilePageProps) {
+ const { teamUrl } = params;
+
+ const { user } = await getRequiredServerComponentSession();
+
+ const team = await getTeamByUrl({ userId: user.id, teamUrl });
+
+ const { profile } = await getTeamPublicProfile({
+ userId: user.id,
+ teamId: team.id,
+ });
+
+ return ;
+}
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"
>
+
{
const { getFlag } = useFeatureFlags();
const isBillingEnabled = getFlag('app_billing');
+ const isPublicProfileEnabled = getFlag('app_public_profile');
return (
@@ -35,6 +36,21 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
+ {isPublicProfileEnabled && (
+
+
+
+ Public Profile
+
+
+ )}
+
{
const { getFlag } = useFeatureFlags();
const isBillingEnabled = getFlag('app_billing');
+ const isPublicProfileEnabled = getFlag('app_public_profile');
return (
{
+ {isPublicProfileEnabled && (
+
+
+
+ Public Profile
+
+
+ )}
+
{
const pathname = usePathname();
const params = useParams();
+ const { getFlag } = useFeatureFlags();
+
+ const isPublicProfileEnabled = getFlag('app_public_profile');
+
const teamUrl = typeof params?.teamUrl === 'string' ? params?.teamUrl : '';
const settingsPath = `/t/${teamUrl}/settings`;
+ const publicProfilePath = `/t/${teamUrl}/settings/public-profile`;
const membersPath = `/t/${teamUrl}/settings/members`;
const tokensPath = `/t/${teamUrl}/settings/tokens`;
const webhooksPath = `/t/${teamUrl}/settings/webhooks`;
@@ -37,6 +43,21 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
+ {isPublicProfileEnabled && (
+
+
+
+ Public Profile
+
+
+ )}
+
{
const pathname = usePathname();
const params = useParams();
+ const { getFlag } = useFeatureFlags();
+
+ const isPublicProfileEnabled = getFlag('app_public_profile');
+
const teamUrl = typeof params?.teamUrl === 'string' ? params?.teamUrl : '';
const settingsPath = `/t/${teamUrl}/settings`;
+ const publicProfilePath = `/t/${teamUrl}/settings/public-profile`;
const membersPath = `/t/${teamUrl}/settings/members`;
const tokensPath = `/t/${teamUrl}/settings/tokens`;
const webhooksPath = `/t/${teamUrl}/settings/webhooks`;
@@ -45,6 +51,21 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
+ {isPublicProfileEnabled && (
+
+
+
+ Public Profile
+
+
+ )}
+
;
+
+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/forms/public-profile-form.tsx b/apps/web/src/components/forms/public-profile-form.tsx
new file mode 100644
index 000000000..3607d7fe1
--- /dev/null
+++ b/apps/web/src/components/forms/public-profile-form.tsx
@@ -0,0 +1,265 @@
+'use client';
+
+import { useState } from 'react';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import { motion } from 'framer-motion';
+import { AnimatePresence } from 'framer-motion';
+import { CheckSquareIcon, CopyIcon } from 'lucide-react';
+import { useForm } from 'react-hook-form';
+import type { z } from 'zod';
+
+import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
+import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
+import { formatUserProfilePath } from '@documenso/lib/utils/public-profiles';
+import type { TeamProfile, UserProfile } from '@documenso/prisma/client';
+import {
+ MAX_PROFILE_BIO_LENGTH,
+ ZUpdatePublicProfileMutationSchema,
+} from '@documenso/trpc/server/profile-router/schema';
+import { cn } from '@documenso/ui/lib/utils';
+import { Button } from '@documenso/ui/primitives/button';
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@documenso/ui/primitives/form/form';
+import { Input } from '@documenso/ui/primitives/input';
+import { Textarea } from '@documenso/ui/primitives/textarea';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+export const ZPublicProfileFormSchema = ZUpdatePublicProfileMutationSchema.pick({
+ bio: true,
+ enabled: true,
+ url: true,
+});
+
+export type TPublicProfileFormSchema = z.infer;
+
+export type PublicProfileFormProps = {
+ className?: string;
+ profileUrl?: string | null;
+ teamUrl?: string;
+ onProfileUpdate: (data: TPublicProfileFormSchema) => Promise;
+ profile: UserProfile | TeamProfile;
+};
+export const PublicProfileForm = ({
+ className,
+ profileUrl,
+ profile,
+ teamUrl,
+ onProfileUpdate,
+}: PublicProfileFormProps) => {
+ const { toast } = useToast();
+
+ const [, copy] = useCopyToClipboard();
+
+ const [copiedTimeout, setCopiedTimeout] = useState(null);
+
+ const form = useForm({
+ values: {
+ url: profileUrl ?? '',
+ bio: profile?.bio ?? '',
+ },
+ resolver: zodResolver(ZPublicProfileFormSchema),
+ });
+
+ const isSubmitting = form.formState.isSubmitting;
+
+ const onFormSubmit = async (data: TPublicProfileFormSchema) => {
+ try {
+ await onProfileUpdate(data);
+
+ toast({
+ title: 'Success',
+ description: 'Your public profile has been updated.',
+ duration: 5000,
+ });
+
+ form.reset({
+ url: data.url,
+ bio: data.bio,
+ });
+ } catch (err) {
+ const error = AppError.parseError(err);
+
+ switch (error.code) {
+ case AppErrorCode.PREMIUM_PROFILE_URL:
+ case AppErrorCode.PROFILE_URL_TAKEN:
+ form.setError('url', {
+ type: 'manual',
+ message: error.message,
+ });
+
+ break;
+
+ default:
+ toast({
+ title: 'An unknown error occurred',
+ variant: 'destructive',
+ description:
+ 'We encountered an unknown error while attempting to update your public profile. Please try again later.',
+ });
+ }
+ }
+ };
+
+ const onCopy = async () => {
+ await copy(formatUserProfilePath(form.getValues('url') ?? '')).then(() => {
+ toast({
+ title: 'Copied to clipboard',
+ description: 'The profile link has been copied to your clipboard',
+ });
+ });
+
+ if (copiedTimeout) {
+ clearTimeout(copiedTimeout);
+ }
+
+ setCopiedTimeout(
+ setTimeout(() => {
+ setCopiedTimeout(null);
+ }, 2000),
+ );
+ };
+
+ 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
new file mode 100644
index 000000000..7bba43fe5
--- /dev/null
+++ b/apps/web/src/components/templates/manage-public-template-dialog.tsx
@@ -0,0 +1,429 @@
+'use client';
+
+import { useEffect, useMemo, useState } from 'react';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import type * as DialogPrimitive from '@radix-ui/react-dialog';
+import { CheckCircle2Icon, CircleIcon } from 'lucide-react';
+import { useForm } from 'react-hook-form';
+import { P, match } from 'ts-pattern';
+import { z } from 'zod';
+
+import type { Template, TemplateDirectLink } from '@documenso/prisma/client';
+import { TemplateType } from '@documenso/prisma/client';
+import { trpc } from '@documenso/trpc/react';
+import {
+ MAX_TEMPLATE_PUBLIC_DESCRIPTION_LENGTH,
+ MAX_TEMPLATE_PUBLIC_TITLE_LENGTH,
+} from '@documenso/trpc/server/template-router/schema';
+import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
+import { Button } from '@documenso/ui/primitives/button';
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@documenso/ui/primitives/dialog';
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@documenso/ui/primitives/form/form';
+import { Input } from '@documenso/ui/primitives/input';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@documenso/ui/primitives/table';
+import { Textarea } from '@documenso/ui/primitives/textarea';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+import { useOptionalCurrentTeam } from '~/providers/team';
+
+import { LocaleDate } from '../formatter/locale-date';
+
+export type ManagePublicTemplateDialogProps = {
+ directTemplates: (Template & {
+ directLink: Pick;
+ })[];
+ initialTemplateId?: number | null;
+ initialStep?: ProfileTemplateStep;
+ trigger?: React.ReactNode;
+ isOpen?: boolean;
+ onIsOpenChange?: (value: boolean) => unknown;
+} & Omit;
+
+const ZUpdatePublicTemplateFormSchema = z.object({
+ publicTitle: z
+ .string()
+ .min(1, { message: 'Title is required' })
+ .max(MAX_TEMPLATE_PUBLIC_TITLE_LENGTH, {
+ message: `Title cannot be longer than ${MAX_TEMPLATE_PUBLIC_TITLE_LENGTH} characters`,
+ }),
+ publicDescription: z
+ .string()
+ .min(1, { message: 'Description is required' })
+ .max(MAX_TEMPLATE_PUBLIC_DESCRIPTION_LENGTH, {
+ message: `Description cannot be longer than ${MAX_TEMPLATE_PUBLIC_DESCRIPTION_LENGTH} characters`,
+ }),
+});
+
+type TUpdatePublicTemplateFormSchema = z.infer;
+
+type ProfileTemplateStep = 'SELECT_TEMPLATE' | 'MANAGE' | 'CONFIRM_DISABLE';
+
+export const ManagePublicTemplateDialog = ({
+ directTemplates,
+ trigger,
+ initialTemplateId = null,
+ initialStep = 'SELECT_TEMPLATE',
+ isOpen = false,
+ onIsOpenChange,
+ ...props
+}: ManagePublicTemplateDialogProps) => {
+ const { toast } = useToast();
+
+ const [open, onOpenChange] = useState(isOpen);
+
+ const team = useOptionalCurrentTeam();
+
+ const [selectedTemplateId, setSelectedTemplateId] = useState(initialTemplateId);
+
+ const [currentStep, setCurrentStep] = useState(() => {
+ if (initialStep) {
+ return initialStep;
+ }
+
+ return selectedTemplateId ? 'MANAGE' : 'SELECT_TEMPLATE';
+ });
+
+ const form = useForm({
+ resolver: zodResolver(ZUpdatePublicTemplateFormSchema),
+ defaultValues: {
+ publicTitle: '',
+ publicDescription: '',
+ },
+ });
+
+ const { mutateAsync: updateTemplateSettings, isLoading: isUpdatingTemplateSettings } =
+ trpc.template.updateTemplateSettings.useMutation();
+
+ const setTemplateToPrivate = async (templateId: number) => {
+ try {
+ await updateTemplateSettings({
+ templateId,
+ teamId: team?.id,
+ data: {
+ type: TemplateType.PRIVATE,
+ },
+ });
+
+ toast({
+ title: 'Success',
+ description: 'Template has been removed from your public profile.',
+ duration: 5000,
+ });
+
+ handleOnOpenChange(false);
+ } catch {
+ toast({
+ title: 'An unknown error occurred',
+ variant: 'destructive',
+ description:
+ 'We encountered an unknown error while attempting to remove this template from your profile. Please try again later.',
+ });
+ }
+ };
+
+ const onFormSubmit = async ({
+ publicTitle,
+ publicDescription,
+ }: TUpdatePublicTemplateFormSchema) => {
+ if (!selectedTemplateId) {
+ return;
+ }
+
+ try {
+ await updateTemplateSettings({
+ templateId: selectedTemplateId,
+ teamId: team?.id,
+ data: {
+ type: TemplateType.PUBLIC,
+ publicTitle,
+ publicDescription,
+ },
+ });
+
+ toast({
+ title: 'Success',
+ description: 'Template has been updated.',
+ duration: 5000,
+ });
+
+ onOpenChange(false);
+ } catch {
+ toast({
+ title: 'An unknown error occurred',
+ variant: 'destructive',
+ description:
+ 'We encountered an unknown error while attempting to update the template. Please try again later.',
+ });
+ }
+ };
+
+ const selectedTemplate = useMemo(
+ () => directTemplates.find((template) => template.id === selectedTemplateId),
+ [directTemplates, selectedTemplateId],
+ );
+
+ const onManageStep = () => {
+ if (!selectedTemplate) {
+ return;
+ }
+
+ form.reset({
+ publicTitle: selectedTemplate.publicTitle,
+ publicDescription: selectedTemplate.publicDescription,
+ });
+
+ setCurrentStep('MANAGE');
+ };
+
+ const isLoading = isUpdatingTemplateSettings || form.formState.isSubmitting;
+
+ useEffect(() => {
+ const initialTemplate = directTemplates.find((template) => template.id === initialTemplateId);
+
+ if (initialTemplate) {
+ setSelectedTemplateId(initialTemplate.id);
+
+ form.reset({
+ publicTitle: initialTemplate.publicTitle,
+ publicDescription: initialTemplate.publicDescription,
+ });
+ } else {
+ setSelectedTemplateId(null);
+ }
+
+ const step = initialStep || (selectedTemplateId ? 'MANAGE' : 'SELECT_TEMPLATE');
+
+ setCurrentStep(step);
+
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [initialTemplateId, initialStep, open, isOpen]);
+
+ const handleOnOpenChange = (value: boolean) => {
+ if (isLoading || typeof value !== 'boolean') {
+ return;
+ }
+
+ onOpenChange(value);
+ onIsOpenChange?.(value);
+ };
+
+ return (
+
+
+ {trigger}
+
+
+ {match({ templateId: selectedTemplateId, currentStep })
+ .with({ currentStep: 'SELECT_TEMPLATE' }, () => (
+
+
+ {team?.name || 'Your'} direct signing templates
+
+
+ Select a template you'd like to display on your {team && `team's`} public
+ profile
+
+
+
+
+
+
+
+ Template
+ Created
+
+
+
+
+ {directTemplates.length === 0 && (
+
+
+ No valid direct templates found
+
+
+ )}
+
+ {directTemplates.map((row) => (
+ setSelectedTemplateId(row.id)}
+ >
+
+ {row.title}
+
+
+
+
+
+
+
+ {selectedTemplateId === row.id ? (
+
+ ) : (
+
+ )}
+
+
+ ))}
+
+
+
+
+
+
+ Close
+
+
+
+ onManageStep()}
+ >
+ Continue
+
+
+
+ ))
+ .with({ templateId: P.number, currentStep: 'MANAGE' }, () => (
+
+
+ Configure template
+
+ Manage details for this public template
+
+
+
+
+ (
+
+ Title
+
+
+
+
+
+ )}
+ />
+
+ {
+ const remaningLength =
+ MAX_TEMPLATE_PUBLIC_DESCRIPTION_LENGTH - (field.value || '').length;
+ const pluralWord =
+ Math.abs(remaningLength) === 1 ? 'character' : 'characters';
+
+ return (
+
+ Description
+
+
+
+
+ {!form.formState.errors.publicDescription && (
+
+ {remaningLength >= 0
+ ? `${remaningLength} ${pluralWord} remaining`
+ : `${Math.abs(remaningLength)} ${pluralWord} over the limit`}
+
+ )}
+
+
+
+ );
+ }}
+ />
+
+
+ {selectedTemplate?.type === TemplateType.PUBLIC && (
+ setCurrentStep('CONFIRM_DISABLE')}
+ >
+ Disable
+
+ )}
+
+
+ Close
+
+
+
+ Update
+
+
+
+
+
+ ))
+ .with({ templateId: P.number, currentStep: 'CONFIRM_DISABLE' }, ({ templateId }) => (
+
+
+ Are you sure?
+
+
+ The template will be removed from your profile
+
+
+
+
+
+
+ Cancel
+
+
+
+ void setTemplateToPrivate(templateId)}
+ >
+ Confirm
+
+
+
+ ))
+ .otherwise(() => null)}
+
+
+
+ );
+};
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 679dade3f..339753fd6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -31904,6 +31904,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/constants/feature-flags.ts b/packages/lib/constants/feature-flags.ts
index 9533f4d18..68b6e7de7 100644
--- a/packages/lib/constants/feature-flags.ts
+++ b/packages/lib/constants/feature-flags.ts
@@ -25,6 +25,7 @@ export const LOCAL_FEATURE_FLAGS: Record = {
app_billing: NEXT_PUBLIC_FEATURE_BILLING_ENABLED() === 'true',
app_document_page_view_history_sheet: false,
app_passkey: WEBAPP_BASE_URL === 'http://localhost:3000', // Temp feature flag.
+ app_public_profile: true,
marketing_header_single_player_mode: false,
marketing_profiles_announcement_bar: true,
} as const;
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
new file mode 100644
index 000000000..2f39053a4
--- /dev/null
+++ b/packages/lib/server-only/profile/get-public-profile-by-url.ts
@@ -0,0 +1,181 @@
+import { getCommunityPlanPriceIds } from '@documenso/ee/server-only/stripe/get-community-plan-prices';
+import { prisma } from '@documenso/prisma';
+import type { Template, TemplateDirectLink } from '@documenso/prisma/client';
+import {
+ SubscriptionStatus,
+ type TeamProfile,
+ TemplateType,
+ type UserProfile,
+} from '@documenso/prisma/client';
+
+import { IS_BILLING_ENABLED } from '../../constants/app';
+import { AppError, AppErrorCode } from '../../errors/app-error';
+
+export type GetPublicProfileByUrlOptions = {
+ profileUrl: string;
+};
+
+type PublicDirectLinkTemplate = Template & {
+ type: 'PUBLIC';
+ directLink: TemplateDirectLink & {
+ enabled: true;
+ };
+};
+
+type BaseResponse = {
+ url: string;
+ name: string;
+ avatarImageId?: string | null;
+ badge?: {
+ type: 'Premium' | 'EarlySupporter';
+ since: Date;
+ };
+ templates: PublicDirectLinkTemplate[];
+};
+
+type GetPublicProfileByUrlResponse = BaseResponse &
+ (
+ | {
+ type: 'User';
+ profile: UserProfile;
+ }
+ | {
+ type: 'Team';
+ profile: TeamProfile;
+ }
+ );
+
+/**
+ * Get the user or team public profile by URL.
+ */
+export const getPublicProfileByUrl = async ({
+ profileUrl,
+}: GetPublicProfileByUrlOptions): Promise => {
+ const [user, team] = await Promise.all([
+ prisma.user.findFirst({
+ where: {
+ url: profileUrl,
+ profile: {
+ enabled: true,
+ },
+ },
+ include: {
+ profile: true,
+ Template: {
+ where: {
+ directLink: {
+ enabled: true,
+ },
+ type: TemplateType.PUBLIC,
+ },
+ include: {
+ directLink: true,
+ },
+ },
+ // Subscriptions and teamMembers are used to calculate the badges.
+ Subscription: {
+ where: {
+ status: SubscriptionStatus.ACTIVE,
+ },
+ },
+ teamMembers: {
+ select: {
+ createdAt: true,
+ },
+ orderBy: {
+ createdAt: 'asc',
+ },
+ },
+ },
+ }),
+ prisma.team.findFirst({
+ where: {
+ url: profileUrl,
+ profile: {
+ enabled: true,
+ },
+ },
+ include: {
+ profile: true,
+ templates: {
+ where: {
+ directLink: {
+ enabled: true,
+ },
+ type: TemplateType.PUBLIC,
+ },
+ include: {
+ directLink: true,
+ },
+ },
+ },
+ }),
+ ]);
+
+ // Log as critical error.
+ if (user?.profile && team?.profile) {
+ console.error('Profile URL is ambiguous', { profileUrl, userId: user.id, teamId: team.id });
+ throw new AppError(AppErrorCode.INVALID_REQUEST, 'Profile URL is ambiguous');
+ }
+
+ if (user?.profile?.enabled) {
+ let badge: BaseResponse['badge'] = undefined;
+
+ if (user.teamMembers[0]) {
+ badge = {
+ type: 'Premium',
+ since: user.teamMembers[0]['createdAt'],
+ };
+ }
+
+ if (IS_BILLING_ENABLED()) {
+ const earlyAdopterPriceIds = await getCommunityPlanPriceIds();
+
+ const activeEarlyAdopterSub = user.Subscription.find(
+ (subscription) =>
+ subscription.status === SubscriptionStatus.ACTIVE &&
+ earlyAdopterPriceIds.includes(subscription.priceId),
+ );
+
+ if (activeEarlyAdopterSub) {
+ badge = {
+ type: 'EarlySupporter',
+ since: activeEarlyAdopterSub.createdAt,
+ };
+ }
+ }
+
+ return {
+ type: 'User',
+ badge,
+ profile: user.profile,
+ url: profileUrl,
+ avatarImageId: user.avatarImageId,
+ name: user.name || '',
+ templates: user.Template.filter(
+ (template): template is PublicDirectLinkTemplate =>
+ template.directLink?.enabled === true && template.type === TemplateType.PUBLIC,
+ ),
+ };
+ }
+
+ if (team?.profile?.enabled) {
+ return {
+ type: 'Team',
+ badge: {
+ type: 'Premium',
+ since: team.createdAt,
+ },
+ profile: team.profile,
+ url: profileUrl,
+ avatarImageId: team.avatarImageId,
+ name: team.name || '',
+ templates: team.templates.filter(
+ (template): template is PublicDirectLinkTemplate =>
+ template.directLink?.enabled === true && template.type === TemplateType.PUBLIC,
+ ),
+ };
+ }
+
+ throw new AppError(AppErrorCode.NOT_FOUND, 'Profile not found');
+};
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/lib/server-only/team/create-team.ts b/packages/lib/server-only/team/create-team.ts
index 3461d49bf..6c340f8fd 100644
--- a/packages/lib/server-only/team/create-team.ts
+++ b/packages/lib/server-only/team/create-team.ts
@@ -76,21 +76,36 @@ export const createTeam = async ({
try {
// Create the team directly if no payment is required.
if (!isPaymentRequired) {
- await prisma.team.create({
- data: {
- name: teamName,
- url: teamUrl,
- ownerUserId: user.id,
- customerId,
- members: {
- create: [
- {
- userId,
- role: TeamMemberRole.ADMIN,
- },
- ],
+ await prisma.$transaction(async (tx) => {
+ const existingUserProfileWithUrl = await tx.user.findUnique({
+ where: {
+ url: teamUrl,
},
- },
+ select: {
+ id: true,
+ },
+ });
+
+ if (existingUserProfileWithUrl) {
+ throw new AppError(AppErrorCode.ALREADY_EXISTS, 'URL already taken.');
+ }
+
+ await tx.team.create({
+ data: {
+ name: teamName,
+ url: teamUrl,
+ ownerUserId: user.id,
+ customerId,
+ members: {
+ create: [
+ {
+ userId,
+ role: TeamMemberRole.ADMIN,
+ },
+ ],
+ },
+ },
+ });
});
return {
@@ -106,6 +121,19 @@ export const createTeam = async ({
},
});
+ const existingUserProfileWithUrl = await tx.user.findUnique({
+ where: {
+ url: teamUrl,
+ },
+ select: {
+ id: true,
+ },
+ });
+
+ if (existingUserProfileWithUrl) {
+ throw new AppError(AppErrorCode.ALREADY_EXISTS, 'URL already taken.');
+ }
+
if (existingTeamWithUrl) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Team URL already exists.');
}
diff --git a/packages/lib/server-only/team/get-team-public-profile.ts b/packages/lib/server-only/team/get-team-public-profile.ts
new file mode 100644
index 000000000..fc0828b3f
--- /dev/null
+++ b/packages/lib/server-only/team/get-team-public-profile.ts
@@ -0,0 +1,63 @@
+import { prisma } from '@documenso/prisma';
+import type { TeamProfile } from '@documenso/prisma/client';
+
+import { AppError, AppErrorCode } from '../../errors/app-error';
+import { updateTeamPublicProfile } from './update-team-public-profile';
+
+export type GetTeamPublicProfileOptions = {
+ userId: number;
+ teamId: number;
+};
+
+type GetTeamPublicProfileResponse = {
+ profile: TeamProfile;
+ url: string | null;
+};
+
+export const getTeamPublicProfile = async ({
+ userId,
+ teamId,
+}: GetTeamPublicProfileOptions): Promise => {
+ const team = await prisma.team.findFirst({
+ where: {
+ id: teamId,
+ members: {
+ some: {
+ userId,
+ },
+ },
+ },
+ include: {
+ profile: true,
+ },
+ });
+
+ if (!team) {
+ throw new AppError(AppErrorCode.NOT_FOUND, 'Team not found');
+ }
+
+ // Create and return the public profile.
+ if (!team.profile) {
+ const { url, profile } = await updateTeamPublicProfile({
+ userId: userId,
+ teamId,
+ data: {
+ enabled: false,
+ },
+ });
+
+ if (!profile) {
+ throw new AppError(AppErrorCode.NOT_FOUND, 'Failed to create public profile');
+ }
+
+ return {
+ profile,
+ url,
+ };
+ }
+
+ return {
+ profile: team.profile,
+ url: team.url,
+ };
+};
diff --git a/packages/lib/server-only/team/update-team-public-profile.ts b/packages/lib/server-only/team/update-team-public-profile.ts
new file mode 100644
index 000000000..cb3477a6b
--- /dev/null
+++ b/packages/lib/server-only/team/update-team-public-profile.ts
@@ -0,0 +1,38 @@
+import { prisma } from '@documenso/prisma';
+
+export type UpdatePublicProfileOptions = {
+ userId: number;
+ teamId: number;
+ data: {
+ bio?: string;
+ enabled?: boolean;
+ };
+};
+
+export const updateTeamPublicProfile = async ({
+ userId,
+ teamId,
+ data,
+}: UpdatePublicProfileOptions) => {
+ return await prisma.team.update({
+ where: {
+ id: teamId,
+ members: {
+ some: {
+ userId,
+ },
+ },
+ },
+ data: {
+ profile: {
+ upsert: {
+ create: data,
+ update: data,
+ },
+ },
+ },
+ include: {
+ profile: true,
+ },
+ });
+};
diff --git a/packages/lib/server-only/template/find-templates.ts b/packages/lib/server-only/template/find-templates.ts
index d5d38adf1..27cba8b20 100644
--- a/packages/lib/server-only/template/find-templates.ts
+++ b/packages/lib/server-only/template/find-templates.ts
@@ -1,11 +1,12 @@
import { prisma } from '@documenso/prisma';
-import type { Prisma } from '@documenso/prisma/client';
+import type { Prisma, Template } from '@documenso/prisma/client';
export type FindTemplatesOptions = {
userId: number;
teamId?: number;
- page: number;
- perPage: number;
+ type?: Template['type'];
+ page?: number;
+ perPage?: number;
};
export type FindTemplatesResponse = Awaited>;
@@ -14,12 +15,14 @@ export type FindTemplateRow = FindTemplatesResponse['templates'][number];
export const findTemplates = async ({
userId,
teamId,
+ type,
page = 1,
perPage = 10,
}: FindTemplatesOptions) => {
let whereFilter: Prisma.TemplateWhereInput = {
userId,
teamId: null,
+ type,
};
if (teamId !== undefined) {
diff --git a/packages/lib/server-only/template/update-template-settings.ts b/packages/lib/server-only/template/update-template-settings.ts
index ebf15bac0..8383e23d3 100644
--- a/packages/lib/server-only/template/update-template-settings.ts
+++ b/packages/lib/server-only/template/update-template-settings.ts
@@ -3,7 +3,7 @@
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma';
-import type { TemplateMeta } from '@documenso/prisma/client';
+import type { Template, TemplateMeta } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
@@ -17,6 +17,9 @@ export type UpdateTemplateSettingsOptions = {
title?: string;
globalAccessAuth?: TDocumentAccessAuthTypes | null;
globalActionAuth?: TDocumentActionAuthTypes | null;
+ publicTitle?: string;
+ publicDescription?: string;
+ type?: Template['type'];
};
meta?: Partial>;
requestMetadata?: RequestMetadata;
@@ -29,7 +32,7 @@ export const updateTemplateSettings = async ({
meta,
data,
}: UpdateTemplateSettingsOptions) => {
- if (!data.title && !data.globalAccessAuth && !data.globalActionAuth) {
+ if (Object.values(data).length === 0) {
throw new AppError(AppErrorCode.INVALID_BODY, 'Missing data to update');
}
@@ -61,30 +64,6 @@ export const updateTemplateSettings = async ({
documentAuth: template.authOptions,
});
- const { templateMeta } = template;
-
- const isDateSame = (templateMeta?.dateFormat || null) === (meta?.dateFormat || null);
- const isMessageSame = (templateMeta?.message || null) === (meta?.message || null);
- const isPasswordSame = (templateMeta?.password || null) === (meta?.password || null);
- const isSubjectSame = (templateMeta?.subject || null) === (meta?.subject || null);
- const isRedirectUrlSame = (templateMeta?.redirectUrl || null) === (meta?.redirectUrl || null);
- const isTimezoneSame = (templateMeta?.timezone || null) === (meta?.timezone || null);
-
- // Early return to avoid unnecessary updates.
- if (
- template.title === data.title &&
- data.globalAccessAuth === documentAuthOption.globalAccessAuth &&
- data.globalActionAuth === documentAuthOption.globalActionAuth &&
- isDateSame &&
- isMessageSame &&
- isPasswordSame &&
- isSubjectSame &&
- isRedirectUrlSame &&
- isTimezoneSame
- ) {
- return template;
- }
-
const documentGlobalAccessAuth = documentAuthOption?.globalAccessAuth ?? null;
const documentGlobalActionAuth = documentAuthOption?.globalActionAuth ?? null;
@@ -120,6 +99,9 @@ export const updateTemplateSettings = async ({
},
data: {
title: data.title,
+ type: data.type,
+ publicDescription: data.publicDescription,
+ publicTitle: data.publicTitle,
authOptions,
templateMeta: {
upsert: {
diff --git a/packages/lib/server-only/user/get-user-public-profile.ts b/packages/lib/server-only/user/get-user-public-profile.ts
new file mode 100644
index 000000000..e248008d6
--- /dev/null
+++ b/packages/lib/server-only/user/get-user-public-profile.ts
@@ -0,0 +1,55 @@
+import { prisma } from '@documenso/prisma';
+import type { UserProfile } from '@documenso/prisma/client';
+
+import { AppError, AppErrorCode } from '../../errors/app-error';
+import { updatePublicProfile } from './update-public-profile';
+
+export type GetUserPublicProfileOptions = {
+ userId: number;
+};
+
+type GetUserPublicProfileResponse = {
+ profile: UserProfile;
+ url: string | null;
+};
+
+export const getUserPublicProfile = async ({
+ userId,
+}: GetUserPublicProfileOptions): Promise => {
+ const user = await prisma.user.findFirst({
+ where: {
+ id: userId,
+ },
+ include: {
+ profile: true,
+ },
+ });
+
+ if (!user) {
+ throw new AppError(AppErrorCode.NOT_FOUND, 'User not found');
+ }
+
+ // Create and return the public profile.
+ if (!user.profile) {
+ const { url, profile } = await updatePublicProfile({
+ userId: user.id,
+ data: {
+ enabled: false,
+ },
+ });
+
+ if (!profile) {
+ throw new AppError(AppErrorCode.NOT_FOUND, 'Failed to create public profile');
+ }
+
+ return {
+ profile,
+ url,
+ };
+ }
+
+ return {
+ profile: user.profile,
+ url: user.url,
+ };
+};
diff --git a/packages/lib/server-only/user/update-public-profile.ts b/packages/lib/server-only/user/update-public-profile.ts
index f70f02cf2..88a3ddba5 100644
--- a/packages/lib/server-only/user/update-public-profile.ts
+++ b/packages/lib/server-only/user/update-public-profile.ts
@@ -4,28 +4,61 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
export type UpdatePublicProfileOptions = {
userId: number;
- url: string;
+ data: {
+ url?: string;
+ bio?: string;
+ enabled?: boolean;
+ };
};
-export const updatePublicProfile = async ({ userId, url }: UpdatePublicProfileOptions) => {
- const isUrlTaken = await prisma.user.findFirst({
- select: {
- id: true,
- },
+export const updatePublicProfile = async ({ userId, data }: UpdatePublicProfileOptions) => {
+ if (Object.values(data).length === 0) {
+ throw new AppError(AppErrorCode.INVALID_BODY, 'Missing data to update');
+ }
+
+ const { url, bio, enabled } = data;
+
+ const user = await prisma.user.findFirst({
where: {
- id: {
- not: userId,
- },
- url,
+ id: userId,
},
});
- if (isUrlTaken) {
- throw new AppError(
- AppErrorCode.PROFILE_URL_TAKEN,
- 'Profile username is taken',
- 'The profile username is already taken',
- );
+ if (!user) {
+ throw new AppError(AppErrorCode.NOT_FOUND, 'User not found');
+ }
+
+ const finalUrl = url ?? user.url;
+
+ if (!finalUrl && enabled) {
+ throw new AppError(AppErrorCode.INVALID_REQUEST, 'Cannot enable a profile without a URL');
+ }
+
+ if (url) {
+ const isUrlTakenByAnotherUser = await prisma.user.findFirst({
+ select: {
+ id: true,
+ },
+ where: {
+ id: {
+ not: userId,
+ },
+ url,
+ },
+ });
+
+ const isUrlTakenByAnotherTeam = await prisma.team.findFirst({
+ select: {
+ id: true,
+ },
+ where: {
+ url,
+ },
+ });
+
+ if (isUrlTakenByAnotherUser || isUrlTakenByAnotherTeam) {
+ throw new AppError(AppErrorCode.PROFILE_URL_TAKEN, 'The profile username is already taken');
+ }
}
return await prisma.user.update({
@@ -34,16 +67,21 @@ export const updatePublicProfile = async ({ userId, url }: UpdatePublicProfileOp
},
data: {
url,
- userProfile: {
+ profile: {
upsert: {
create: {
- bio: '',
+ bio,
+ enabled,
},
update: {
- bio: '',
+ bio,
+ enabled,
},
},
},
},
+ include: {
+ profile: true,
+ },
});
};
diff --git a/packages/lib/utils/billing.ts b/packages/lib/utils/billing.ts
index 6d2926420..6ac4fabf9 100644
--- a/packages/lib/utils/billing.ts
+++ b/packages/lib/utils/billing.ts
@@ -16,6 +16,18 @@ export const subscriptionsContainsActivePlan = (
subscription.status === SubscriptionStatus.ACTIVE && priceIds.includes(subscription.priceId),
);
};
+/**
+ * Returns true if there is a subscription that is active and is one of the provided product IDs.
+ */
+export const subscriptionsContainsActiveProductId = (
+ subscriptions: Subscription[],
+ productId: string[],
+) => {
+ return subscriptions.some(
+ (subscription) =>
+ subscription.status === SubscriptionStatus.ACTIVE && productId.includes(subscription.planId),
+ );
+};
export const subscriptionsContainActiveEnterprisePlan = (
subscriptions?: Subscription[],
diff --git a/packages/lib/utils/public-profiles.ts b/packages/lib/utils/public-profiles.ts
new file mode 100644
index 000000000..d303c9cb7
--- /dev/null
+++ b/packages/lib/utils/public-profiles.ts
@@ -0,0 +1,15 @@
+import { WEBAPP_BASE_URL } from '../constants/app';
+
+export const formatUserProfilePath = (
+ profileUrl: string,
+ options: { excludeBaseUrl?: boolean } = {},
+) => {
+ return `${!options?.excludeBaseUrl ? WEBAPP_BASE_URL : ''}/p/${profileUrl}`;
+};
+
+export const formatTeamProfilePath = (
+ profileUrl: string,
+ options: { excludeBaseUrl?: boolean } = {},
+) => {
+ return `${!options?.excludeBaseUrl ? WEBAPP_BASE_URL : ''}/p/${profileUrl}`;
+};
diff --git a/packages/prisma/migrations/20240606060033_add_public_profiles/migration.sql b/packages/prisma/migrations/20240606060033_add_public_profiles/migration.sql
new file mode 100644
index 000000000..41ef16aa1
--- /dev/null
+++ b/packages/prisma/migrations/20240606060033_add_public_profiles/migration.sql
@@ -0,0 +1,65 @@
+/*
+ Warnings:
+
+ - The primary key for the `UserProfile` table will be changed. If it partially fails, the table could be left without primary key constraint.
+ - A unique constraint covering the columns `[userId]` on the table `UserProfile` will be added. If there are existing duplicate values, this will fail.
+ - Added the required column `userId` to the `UserProfile` table without a default value. This is not possible if the table is not empty.
+
+*/
+
+-- Custom (Drop duplicate)
+UPDATE "User"
+SET "url" = NULL
+WHERE "User"."url" IN (
+ SELECT "UserTeamUrl"."url"
+ FROM (
+ SELECT "url"
+ FROM "User"
+ WHERE "User"."url" IS NOT null
+ UNION ALL
+ SELECT "url"
+ FROM "Team"
+ WHERE "Team"."url" IS NOT null
+ ) as "UserTeamUrl"
+ GROUP BY "UserTeamUrl"."url"
+ HAVING COUNT("UserTeamUrl"."url") > 1
+);
+
+-- Custom (Drop existing profiles since they're not used)
+DELETE FROM "UserProfile";
+
+-- DropForeignKey
+ALTER TABLE "UserProfile" DROP CONSTRAINT "UserProfile_id_fkey";
+
+-- AlterTable
+ALTER TABLE "Template" ADD COLUMN "publicDescription" TEXT NOT NULL DEFAULT '',
+ADD COLUMN "publicTitle" TEXT NOT NULL DEFAULT '';
+
+-- AlterTable
+ALTER TABLE "UserProfile" DROP CONSTRAINT "UserProfile_pkey",
+ADD COLUMN "enabled" BOOLEAN NOT NULL DEFAULT false,
+ADD COLUMN "userId" INTEGER NOT NULL,
+ALTER COLUMN "id" SET DATA TYPE TEXT,
+ADD CONSTRAINT "UserProfile_pkey" PRIMARY KEY ("id");
+
+-- CreateTable
+CREATE TABLE "TeamProfile" (
+ "id" TEXT NOT NULL,
+ "enabled" BOOLEAN NOT NULL DEFAULT false,
+ "teamId" INTEGER NOT NULL,
+ "bio" TEXT,
+
+ CONSTRAINT "TeamProfile_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "TeamProfile_teamId_key" ON "TeamProfile"("teamId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "UserProfile_userId_key" ON "UserProfile"("userId");
+
+-- AddForeignKey
+ALTER TABLE "UserProfile" ADD CONSTRAINT "UserProfile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "TeamProfile" ADD CONSTRAINT "TeamProfile_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
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 c99acdc2a..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[]
@@ -50,7 +52,7 @@ model User {
twoFactorBackupCodes String?
url String? @unique
- userProfile UserProfile?
+ profile UserProfile?
VerificationToken VerificationToken[]
ApiToken ApiToken[]
Template Template[]
@@ -58,15 +60,27 @@ model User {
Webhooks Webhook[]
siteSettings SiteSettings[]
passkeys Passkey[]
+ avatarImage AvatarImage? @relation(fields: [avatarImageId], references: [id], onDelete: SetNull)
@@index([email])
}
model UserProfile {
- id Int @id
- bio String?
+ id String @id @default(cuid())
+ enabled Boolean @default(false)
+ userId Int @unique
+ bio String?
- User User? @relation(fields: [id], references: [id], onDelete: Cascade)
+ User User? @relation(fields: [userId], references: [id], onDelete: Cascade)
+}
+
+model TeamProfile {
+ id String @id @default(cuid())
+ enabled Boolean @default(false)
+ teamId Int @unique
+ bio String?
+
+ team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
}
enum UserSecurityAuditLogType {
@@ -463,18 +477,22 @@ 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)
subscription Subscription?
@@ -580,6 +598,8 @@ model Template {
templateDocumentDataId String
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
+ publicTitle String @default("")
+ publicDescription String @default("")
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade)
@@ -662,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 921a0353c..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,
@@ -88,9 +90,9 @@ export const profileRouter = router({
.input(ZUpdatePublicProfileMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
- const { url } = input;
+ const { url, bio, enabled } = input;
- if (IS_BILLING_ENABLED() && url.length < 6) {
+ if (IS_BILLING_ENABLED() && url !== undefined && url.length < 6) {
const subscriptions = await getSubscriptionsByUserId({
userId: ctx.user.id,
}).then((subscriptions) =>
@@ -107,7 +109,11 @@ export const profileRouter = router({
const user = await updatePublicProfile({
userId: ctx.user.id,
- url,
+ data: {
+ url,
+ bio,
+ enabled,
+ },
});
return { success: true, url: user.url };
@@ -242,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 dc62f83ba..92384e46e 100644
--- a/packages/trpc/server/profile-router/schema.ts
+++ b/packages/trpc/server/profile-router/schema.ts
@@ -2,21 +2,36 @@ import { z } from 'zod';
import { ZCurrentPasswordSchema, ZPasswordSchema } from '../auth-router/schema';
+export const MAX_PROFILE_BIO_LENGTH = 256;
+
export const ZFindUserSecurityAuditLogsSchema = z.object({
page: z.number().optional(),
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()
+ .max(MAX_PROFILE_BIO_LENGTH, {
+ message: `Bio must be shorter than ${MAX_PROFILE_BIO_LENGTH + 1} characters`,
+ })
+ .optional(),
+ enabled: z.boolean().optional(),
url: z
.string()
.trim()
@@ -24,31 +39,41 @@ export const ZUpdatePublicProfileMutationSchema = z.object({
.min(1, { message: 'Please enter a valid username.' })
.regex(/^[a-z0-9-]+$/, {
message: 'Username can only container alphanumeric characters and dashes.',
- }),
+ })
+ .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/trpc/server/team-router/router.ts b/packages/trpc/server/team-router/router.ts
index dd2032daf..50d9431a7 100644
--- a/packages/trpc/server/team-router/router.ts
+++ b/packages/trpc/server/team-router/router.ts
@@ -1,5 +1,7 @@
+import { TRPCError } from '@trpc/server';
+
import { getTeamPrices } from '@documenso/ee/server-only/stripe/get-team-prices';
-import { AppError } from '@documenso/lib/errors/app-error';
+import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { acceptTeamInvitation } from '@documenso/lib/server-only/team/accept-team-invitation';
import { createTeam } from '@documenso/lib/server-only/team/create-team';
import { createTeamBillingPortal } from '@documenso/lib/server-only/team/create-team-billing-portal';
@@ -30,6 +32,7 @@ import { resendTeamMemberInvitation } from '@documenso/lib/server-only/team/rese
import { updateTeam } from '@documenso/lib/server-only/team/update-team';
import { updateTeamEmail } from '@documenso/lib/server-only/team/update-team-email';
import { updateTeamMember } from '@documenso/lib/server-only/team/update-team-member';
+import { updateTeamPublicProfile } from '@documenso/lib/server-only/team/update-team-public-profile';
import { authenticatedProcedure, router } from '../trpc';
import {
@@ -60,6 +63,7 @@ import {
ZUpdateTeamEmailMutationSchema,
ZUpdateTeamMemberMutationSchema,
ZUpdateTeamMutationSchema,
+ ZUpdateTeamPublicProfileMutationSchema,
} from './schema';
export const teamRouter = router({
@@ -459,6 +463,39 @@ export const teamRouter = router({
}
}),
+ updateTeamPublicProfile: authenticatedProcedure
+ .input(ZUpdateTeamPublicProfileMutationSchema)
+ .mutation(async ({ input, ctx }) => {
+ try {
+ const { teamId, bio, enabled } = input;
+
+ const team = await updateTeamPublicProfile({
+ userId: ctx.user.id,
+ teamId,
+ data: {
+ bio,
+ enabled,
+ },
+ });
+
+ return { success: true, url: team.url };
+ } catch (err) {
+ console.error(err);
+
+ const error = AppError.parseError(err);
+
+ if (error.code !== AppErrorCode.UNKNOWN_ERROR) {
+ throw AppError.parseErrorToTRPCError(error);
+ }
+
+ throw new TRPCError({
+ code: 'BAD_REQUEST',
+ message:
+ 'We were unable to update your public profile. Please review the information you provided and try again.',
+ });
+ }
+ }),
+
requestTeamOwnershipTransfer: authenticatedProcedure
.input(ZRequestTeamOwnerhsipTransferMutationSchema)
.mutation(async ({ input, ctx }) => {
diff --git a/packages/trpc/server/team-router/schema.ts b/packages/trpc/server/team-router/schema.ts
index 75c307e35..9c835ac33 100644
--- a/packages/trpc/server/team-router/schema.ts
+++ b/packages/trpc/server/team-router/schema.ts
@@ -3,6 +3,8 @@ import { z } from 'zod';
import { PROTECTED_TEAM_URLS } from '@documenso/lib/constants/teams';
import { TeamMemberRole } from '@documenso/prisma/client';
+import { ZUpdatePublicProfileMutationSchema } from '../profile-router/schema';
+
// Consider refactoring to use ZBaseTableSearchParamsSchema.
const GenericFindQuerySchema = z.object({
term: z.string().optional(),
@@ -162,6 +164,13 @@ export const ZUpdateTeamMemberMutationSchema = z.object({
}),
});
+export const ZUpdateTeamPublicProfileMutationSchema = ZUpdatePublicProfileMutationSchema.pick({
+ bio: true,
+ enabled: true,
+}).extend({
+ teamId: z.number(),
+});
+
export const ZRequestTeamOwnerhsipTransferMutationSchema = z.object({
teamId: z.number(),
newOwnerUserId: z.number(),
diff --git a/packages/trpc/server/template-router/router.ts b/packages/trpc/server/template-router/router.ts
index 6ef02f247..10af95982 100644
--- a/packages/trpc/server/template-router/router.ts
+++ b/packages/trpc/server/template-router/router.ts
@@ -10,6 +10,7 @@ import { createTemplateDirectLink } from '@documenso/lib/server-only/template/cr
import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template';
import { deleteTemplateDirectLink } from '@documenso/lib/server-only/template/delete-template-direct-link';
import { duplicateTemplate } from '@documenso/lib/server-only/template/duplicate-template';
+import { findTemplates } from '@documenso/lib/server-only/template/find-templates';
import { getTemplateWithDetailsById } from '@documenso/lib/server-only/template/get-template-with-details-by-id';
import { toggleTemplateDirectLink } from '@documenso/lib/server-only/template/toggle-template-direct-link';
import { updateTemplateSettings } from '@documenso/lib/server-only/template/update-template-settings';
@@ -25,6 +26,7 @@ import {
ZDeleteTemplateDirectLinkMutationSchema,
ZDeleteTemplateMutationSchema,
ZDuplicateTemplateMutationSchema,
+ ZFindTemplatesQuerySchema,
ZGetTemplateWithDetailsByIdQuerySchema,
ZToggleTemplateDirectLinkMutationSchema,
ZUpdateTemplateSettingsMutationSchema,
@@ -214,6 +216,21 @@ export const templateRouter = router({
}
}),
+ findTemplates: authenticatedProcedure
+ .input(ZFindTemplatesQuerySchema)
+ .query(async ({ input, ctx }) => {
+ try {
+ return await findTemplates({
+ userId: ctx.user.id,
+ ...input,
+ });
+ } catch (err) {
+ console.error(err);
+
+ throw AppError.parseErrorToTRPCError(err);
+ }
+ }),
+
createTemplateDirectLink: authenticatedProcedure
.input(ZCreateTemplateDirectLinkMutationSchema)
.mutation(async ({ input, ctx }) => {
diff --git a/packages/trpc/server/template-router/schema.ts b/packages/trpc/server/template-router/schema.ts
index 5706eb7bd..be31ac0e7 100644
--- a/packages/trpc/server/template-router/schema.ts
+++ b/packages/trpc/server/template-router/schema.ts
@@ -5,6 +5,8 @@ import {
ZDocumentAccessAuthTypesSchema,
ZDocumentActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth';
+import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
+import { TemplateType } from '@documenso/prisma/client';
import { ZSignFieldWithTokenMutationSchema } from '../field-router/schema';
@@ -63,6 +65,9 @@ export const ZDeleteTemplateMutationSchema = z.object({
teamId: z.number().optional(),
});
+export const MAX_TEMPLATE_PUBLIC_TITLE_LENGTH = 50;
+export const MAX_TEMPLATE_PUBLIC_DESCRIPTION_LENGTH = 256;
+
export const ZUpdateTemplateSettingsMutationSchema = z.object({
templateId: z.number(),
teamId: z.number().min(1).optional(),
@@ -70,19 +75,34 @@ export const ZUpdateTemplateSettingsMutationSchema = z.object({
title: z.string().min(1).optional(),
globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullable().optional(),
globalActionAuth: ZDocumentActionAuthTypesSchema.nullable().optional(),
- }),
- meta: z.object({
- subject: z.string(),
- message: z.string(),
- timezone: z.string(),
- dateFormat: z.string(),
- redirectUrl: z
+ publicTitle: z.string().trim().min(1).max(MAX_TEMPLATE_PUBLIC_TITLE_LENGTH).optional(),
+ publicDescription: z
.string()
- .optional()
- .refine((value) => value === undefined || value === '' || URL_REGEX.test(value), {
- message: 'Please enter a valid URL',
- }),
+ .trim()
+ .min(1)
+ .max(MAX_TEMPLATE_PUBLIC_DESCRIPTION_LENGTH)
+ .optional(),
+ type: z.nativeEnum(TemplateType).optional(),
}),
+ meta: z
+ .object({
+ subject: z.string(),
+ message: z.string(),
+ timezone: z.string(),
+ dateFormat: z.string(),
+ redirectUrl: z
+ .string()
+ .optional()
+ .refine((value) => value === undefined || value === '' || URL_REGEX.test(value), {
+ message: 'Please enter a valid URL',
+ }),
+ })
+ .optional(),
+});
+
+export const ZFindTemplatesQuerySchema = ZBaseTableSearchParamsSchema.extend({
+ teamId: z.number().optional(),
+ type: z.nativeEnum(TemplateType).optional(),
});
export const ZGetTemplateWithDetailsByIdQuerySchema = z.object({
diff --git a/packages/tsconfig/tsconfig.json b/packages/tsconfig/tsconfig.json
index 8b7637a27..529b9bd98 100644
--- a/packages/tsconfig/tsconfig.json
+++ b/packages/tsconfig/tsconfig.json
@@ -2,6 +2,7 @@
"extends": "./base.json",
"compilerOptions": {
"noEmit": true,
+ "allowUnreachableCode": true
},
"include": ["**/*.ts", "**/*.tsx", "**/*.d.ts", "**/*.json"],
"exclude": ["dist", "build", "node_modules"]
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}
diff --git a/packages/ui/primitives/textarea.tsx b/packages/ui/primitives/textarea.tsx
index 71bbb9454..0b10596d5 100644
--- a/packages/ui/primitives/textarea.tsx
+++ b/packages/ui/primitives/textarea.tsx
@@ -9,8 +9,11 @@ const Textarea = React.forwardRef(
return (