This commit is contained in:
David Nguyen
2025-02-04 16:24:26 +11:00
parent e5a9d9ddf0
commit 381a9d3fb8
61 changed files with 1932 additions and 300 deletions

View File

@ -0,0 +1,142 @@
import Link from 'next/link';
import { Trans } from '@lingui/macro';
import { DateTime } from 'luxon';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt';
import { declineTeamInvitation } from '@documenso/lib/server-only/team/decline-team-invitation';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { prisma } from '@documenso/prisma';
import { TeamMemberInviteStatus } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
type DeclineInvitationPageProps = {
params: {
token: string;
};
};
export default async function DeclineInvitationPage({
params: { token },
}: DeclineInvitationPageProps) {
await setupI18nSSR();
const session = await getServerComponentSession();
const teamMemberInvite = await prisma.teamMemberInvite.findUnique({
where: {
token,
},
});
if (!teamMemberInvite) {
return (
<div className="w-screen max-w-lg px-4">
<div className="w-full">
<h1 className="text-4xl font-semibold">
<Trans>Invalid token</Trans>
</h1>
<p className="text-muted-foreground mb-4 mt-2 text-sm">
<Trans>This token is invalid or has expired. No action is needed.</Trans>
</p>
<Button asChild>
<Link href="/">
<Trans>Return</Trans>
</Link>
</Button>
</div>
</div>
);
}
const team = await getTeamById({ teamId: teamMemberInvite.teamId });
const user = await prisma.user.findFirst({
where: {
email: {
equals: teamMemberInvite.email,
mode: 'insensitive',
},
},
});
if (user) {
await declineTeamInvitation({ userId: user.id, teamId: team.id });
}
if (!user && teamMemberInvite.status !== TeamMemberInviteStatus.DECLINED) {
await prisma.teamMemberInvite.update({
where: {
id: teamMemberInvite.id,
},
data: {
status: TeamMemberInviteStatus.DECLINED,
},
});
}
const email = encryptSecondaryData({
data: teamMemberInvite.email,
expiresAt: DateTime.now().plus({ days: 1 }).toMillis(),
});
if (!user) {
return (
<div>
<h1 className="text-4xl font-semibold">
<Trans>Team invitation</Trans>
</h1>
<p className="text-muted-foreground mt-2 text-sm">
<Trans>
You have been invited by <strong>{team.name}</strong> to join their team.
</Trans>
</p>
<p className="text-muted-foreground mb-4 mt-1 text-sm">
<Trans>To decline this invitation you must create an account.</Trans>
</p>
<Button asChild>
<Link href={`/signup?email=${encodeURIComponent(email)}`}>
<Trans>Create account</Trans>
</Link>
</Button>
</div>
);
}
const isSessionUserTheInvitedUser = user?.id === session.user?.id;
return (
<div className="w-screen max-w-lg px-4">
<h1 className="text-4xl font-semibold">
<Trans>Invitation declined</Trans>
</h1>
<p className="text-muted-foreground mb-4 mt-2 text-sm">
<Trans>
You have declined the invitation from <strong>{team.name}</strong> to join their team.
</Trans>
</p>
{isSessionUserTheInvitedUser ? (
<Button asChild>
<Link href="/">
<Trans>Return to Dashboard</Trans>
</Link>
</Button>
) : (
<Button asChild>
<Link href="/">
<Trans>Return to Home</Trans>
</Link>
</Button>
)}
</div>
);
}

View File

@ -0,0 +1,147 @@
import Link from 'next/link';
import { Trans } from '@lingui/macro';
import { DateTime } from 'luxon';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt';
import { acceptTeamInvitation } from '@documenso/lib/server-only/team/accept-team-invitation';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { prisma } from '@documenso/prisma';
import { TeamMemberInviteStatus } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
type AcceptInvitationPageProps = {
params: {
token: string;
};
};
export default async function AcceptInvitationPage({
params: { token },
}: AcceptInvitationPageProps) {
await setupI18nSSR();
const session = await getServerComponentSession();
const teamMemberInvite = await prisma.teamMemberInvite.findUnique({
where: {
token,
},
});
if (!teamMemberInvite) {
return (
<div className="w-screen max-w-lg px-4">
<div className="w-full">
<h1 className="text-4xl font-semibold">
<Trans>Invalid token</Trans>
</h1>
<p className="text-muted-foreground mb-4 mt-2 text-sm">
<Trans>
This token is invalid or has expired. Please contact your team for a new invitation.
</Trans>
</p>
<Button asChild>
<Link href="/">
<Trans>Return</Trans>
</Link>
</Button>
</div>
</div>
);
}
const team = await getTeamById({ teamId: teamMemberInvite.teamId });
const user = await prisma.user.findFirst({
where: {
email: {
equals: teamMemberInvite.email,
mode: 'insensitive',
},
},
});
// Directly convert the team member invite to a team member if they already have an account.
if (user) {
await acceptTeamInvitation({ userId: user.id, teamId: team.id });
}
// For users who do not exist yet, set the team invite status to accepted, which is checked during
// user creation to determine if we should add the user to the team at that time.
if (!user && teamMemberInvite.status !== TeamMemberInviteStatus.ACCEPTED) {
await prisma.teamMemberInvite.update({
where: {
id: teamMemberInvite.id,
},
data: {
status: TeamMemberInviteStatus.ACCEPTED,
},
});
}
const email = encryptSecondaryData({
data: teamMemberInvite.email,
expiresAt: DateTime.now().plus({ days: 1 }).toMillis(),
});
if (!user) {
return (
<div>
<h1 className="text-4xl font-semibold">
<Trans>Team invitation</Trans>
</h1>
<p className="text-muted-foreground mt-2 text-sm">
<Trans>
You have been invited by <strong>{team.name}</strong> to join their team.
</Trans>
</p>
<p className="text-muted-foreground mb-4 mt-1 text-sm">
<Trans>To accept this invitation you must create an account.</Trans>
</p>
<Button asChild>
<Link href={`/signup?email=${encodeURIComponent(email)}`}>
<Trans>Create account</Trans>
</Link>
</Button>
</div>
);
}
const isSessionUserTheInvitedUser = user.id === session.user?.id;
return (
<div>
<h1 className="text-4xl font-semibold">
<Trans>Invitation accepted!</Trans>
</h1>
<p className="text-muted-foreground mb-4 mt-2 text-sm">
<Trans>
You have accepted an invitation from <strong>{team.name}</strong> to join their team.
</Trans>
</p>
{isSessionUserTheInvitedUser ? (
<Button asChild>
<Link href="/">
<Trans>Continue</Trans>
</Link>
</Button>
) : (
<Button asChild>
<Link href={`/signin?email=${encodeURIComponent(email)}`}>
<Trans>Continue to login</Trans>
</Link>
</Button>
)}
</div>
);
}

View File

@ -0,0 +1,148 @@
import Link from 'next/link';
import { Trans } from '@lingui/macro';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { isTokenExpired } from '@documenso/lib/utils/token-verification';
import { prisma } from '@documenso/prisma';
import { Button } from '@documenso/ui/primitives/button';
type VerifyTeamEmailPageProps = {
params: {
token: string;
};
};
export default async function VerifyTeamEmailPage({ params: { token } }: VerifyTeamEmailPageProps) {
await setupI18nSSR();
const teamEmailVerification = await prisma.teamEmailVerification.findUnique({
where: {
token,
},
include: {
team: true,
},
});
if (!teamEmailVerification || isTokenExpired(teamEmailVerification.expiresAt)) {
return (
<div className="w-screen max-w-lg px-4">
<div className="w-full">
<h1 className="text-4xl font-semibold">
<Trans>Invalid link</Trans>
</h1>
<p className="text-muted-foreground mb-4 mt-2 text-sm">
<Trans>
This link is invalid or has expired. Please contact your team to resend a
verification.
</Trans>
</p>
<Button asChild>
<Link href="/">
<Trans>Return</Trans>
</Link>
</Button>
</div>
</div>
);
}
if (teamEmailVerification.completed) {
return (
<div>
<h1 className="text-4xl font-semibold">
<Trans>Team email already verified!</Trans>
</h1>
<p className="text-muted-foreground mb-4 mt-2 text-sm">
<Trans>
You have already verified your email address for{' '}
<strong>{teamEmailVerification.team.name}</strong>.
</Trans>
</p>
<Button asChild>
<Link href="/">
<Trans>Continue</Trans>
</Link>
</Button>
</div>
);
}
const { team } = teamEmailVerification;
let isTeamEmailVerificationError = false;
try {
await prisma.$transaction([
prisma.teamEmailVerification.updateMany({
where: {
teamId: team.id,
email: teamEmailVerification.email,
},
data: {
completed: true,
},
}),
prisma.teamEmailVerification.deleteMany({
where: {
teamId: team.id,
expiresAt: {
lt: new Date(),
},
},
}),
prisma.teamEmail.create({
data: {
teamId: team.id,
email: teamEmailVerification.email,
name: teamEmailVerification.name,
},
}),
]);
} catch (e) {
console.error(e);
isTeamEmailVerificationError = true;
}
if (isTeamEmailVerificationError) {
return (
<div>
<h1 className="text-4xl font-semibold">
<Trans>Team email verification</Trans>
</h1>
<p className="text-muted-foreground mt-2 text-sm">
<Trans>
Something went wrong while attempting to verify your email address for{' '}
<strong>{team.name}</strong>. Please try again later.
</Trans>
</p>
</div>
);
}
return (
<div>
<h1 className="text-4xl font-semibold">
<Trans>Team email verified!</Trans>
</h1>
<p className="text-muted-foreground mb-4 mt-2 text-sm">
<Trans>
You have verified your email address for <strong>{team.name}</strong>.
</Trans>
</p>
<Button asChild>
<Link href="/">
<Trans>Continue</Trans>
</Link>
</Button>
</div>
);
}

View File

@ -0,0 +1,127 @@
import Link from 'next/link';
import { Trans } from '@lingui/macro';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { transferTeamOwnership } from '@documenso/lib/server-only/team/transfer-team-ownership';
import { isTokenExpired } from '@documenso/lib/utils/token-verification';
import { prisma } from '@documenso/prisma';
import { Button } from '@documenso/ui/primitives/button';
type VerifyTeamTransferPage = {
params: {
token: string;
};
};
export default async function VerifyTeamTransferPage({
params: { token },
}: VerifyTeamTransferPage) {
await setupI18nSSR();
const teamTransferVerification = await prisma.teamTransferVerification.findUnique({
where: {
token,
},
include: {
team: true,
},
});
if (!teamTransferVerification || isTokenExpired(teamTransferVerification.expiresAt)) {
return (
<div className="w-screen max-w-lg px-4">
<div className="w-full">
<h1 className="text-4xl font-semibold">
<Trans>Invalid link</Trans>
</h1>
<p className="text-muted-foreground mb-4 mt-2 text-sm">
<Trans>
This link is invalid or has expired. Please contact your team to resend a transfer
request.
</Trans>
</p>
<Button asChild>
<Link href="/">
<Trans>Return</Trans>
</Link>
</Button>
</div>
</div>
);
}
if (teamTransferVerification.completed) {
return (
<div>
<h1 className="text-4xl font-semibold">
<Trans>Team ownership transfer already completed!</Trans>
</h1>
<p className="text-muted-foreground mb-4 mt-2 text-sm">
<Trans>
You have already completed the ownership transfer for{' '}
<strong>{teamTransferVerification.team.name}</strong>.
</Trans>
</p>
<Button asChild>
<Link href="/">
<Trans>Continue</Trans>
</Link>
</Button>
</div>
);
}
const { team } = teamTransferVerification;
let isTransferError = false;
try {
await transferTeamOwnership({ token });
} catch (e) {
console.error(e);
isTransferError = true;
}
if (isTransferError) {
return (
<div>
<h1 className="text-4xl font-semibold">
<Trans>Team ownership transfer</Trans>
</h1>
<p className="text-muted-foreground mt-2 text-sm">
<Trans>
Something went wrong while attempting to transfer the ownership of team{' '}
<strong>{team.name}</strong> to your. Please try again later or contact support.
</Trans>
</p>
</div>
);
}
return (
<div>
<h1 className="text-4xl font-semibold">
<Trans>Team ownership transferred!</Trans>
</h1>
<p className="text-muted-foreground mb-4 mt-2 text-sm">
<Trans>
The ownership of team <strong>{team.name}</strong> has been successfully transferred to
you.
</Trans>
</p>
<Button asChild>
<Link href={`/t/${team.url}/settings`}>
<Trans>Continue</Trans>
</Link>
</Button>
</div>
);
}

View File

@ -0,0 +1,56 @@
'use client';
import { useEffect } from 'react';
import Link from 'next/link';
import { Trans } from '@lingui/macro';
import { CheckCircle2 } from 'lucide-react';
import { signIn } from 'next-auth/react';
import { Button } from '@documenso/ui/primitives/button';
export type VerifyEmailPageClientProps = {
signInData?: string;
};
export const VerifyEmailPageClient = ({ signInData }: VerifyEmailPageClientProps) => {
useEffect(() => {
if (signInData) {
void signIn('manual', {
credential: signInData,
callbackUrl: '/documents',
});
}
}, [signInData]);
return (
<div className="w-screen max-w-lg px-4">
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<CheckCircle2 className="h-10 w-10 text-green-500" strokeWidth={2} />
</div>
<div>
<h2 className="text-2xl font-bold md:text-4xl">
<Trans>Email Confirmed!</Trans>
</h2>
<p className="text-muted-foreground mt-4">
<Trans>
Your email has been successfully confirmed! You can now use all features of Documenso.
</Trans>
</p>
{!signInData && (
<Button className="mt-4" asChild>
<Link href="/">
<Trans>Go back home</Trans>
</Link>
</Button>
)}
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,130 @@
import Link from 'next/link';
import { Trans } from '@lingui/macro';
import { AlertTriangle, XCircle, XOctagon } from 'lucide-react';
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt';
import {
EMAIL_VERIFICATION_STATE,
verifyEmail,
} from '@documenso/lib/server-only/user/verify-email';
import { prisma } from '@documenso/prisma';
import { Button } from '@documenso/ui/primitives/button';
import { VerifyEmailPageClient } from './client';
export type PageProps = {
params: {
token: string;
};
};
export default async function VerifyEmailPage({ params: { token } }: PageProps) {
await setupI18nSSR();
if (!token) {
return (
<div className="w-screen max-w-lg px-4">
<div className="w-full">
<div className="mb-4 text-red-300">
<XOctagon />
</div>
<h2 className="text-4xl font-semibold">
<Trans>No token provided</Trans>
</h2>
<p className="text-muted-foreground mt-2 text-base">
<Trans>
It seems that there is no token provided. Please check your email and try again.
</Trans>
</p>
</div>
</div>
);
}
const verified = await verifyEmail({ token });
return await match(verified)
.with(EMAIL_VERIFICATION_STATE.NOT_FOUND, () => (
<div className="w-screen max-w-lg px-4">
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<AlertTriangle className="h-10 w-10 text-yellow-500" strokeWidth={2} />
</div>
<div>
<h2 className="text-2xl font-bold md:text-4xl">
<Trans>Something went wrong</Trans>
</h2>
<p className="text-muted-foreground mt-4">
<Trans>
We were unable to verify your email. If your email is not verified already, please
try again.
</Trans>
</p>
<Button className="mt-4" asChild>
<Link href="/">
<Trans>Go back home</Trans>
</Link>
</Button>
</div>
</div>
</div>
))
.with(EMAIL_VERIFICATION_STATE.EXPIRED, () => (
<div className="w-screen max-w-lg px-4">
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<XCircle className="text-destructive h-10 w-10" strokeWidth={2} />
</div>
<div>
<h2 className="text-2xl font-bold md:text-4xl">
<Trans>Your token has expired!</Trans>
</h2>
<p className="text-muted-foreground mt-4">
<Trans>
It seems that the provided token has expired. We've just sent you another token,
please check your email and try again.
</Trans>
</p>
<Button className="mt-4" asChild>
<Link href="/">
<Trans>Go back home</Trans>
</Link>
</Button>
</div>
</div>
</div>
))
.with(EMAIL_VERIFICATION_STATE.VERIFIED, async () => {
const { user } = await prisma.verificationToken.findFirstOrThrow({
where: {
token,
},
include: {
user: true,
},
});
const data = encryptSecondaryData({
data: JSON.stringify({
userId: user.id,
email: user.email,
}),
expiresAt: DateTime.now().plus({ minutes: 5 }).toMillis(),
});
return <VerifyEmailPageClient signInData={data} />;
})
.with(EMAIL_VERIFICATION_STATE.ALREADY_VERIFIED, () => <VerifyEmailPageClient />)
.exhaustive();
}

View File

@ -0,0 +1,45 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { Trans } from '@lingui/macro';
import { XCircle } from 'lucide-react';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { Button } from '@documenso/ui/primitives/button';
export const metadata: Metadata = {
title: 'Verify Email',
};
export default async function EmailVerificationWithoutTokenPage() {
await setupI18nSSR();
return (
<div className="w-screen max-w-lg px-4">
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<XCircle className="text-destructive h-10 w-10" strokeWidth={2} />
</div>
<div>
<h2 className="text-2xl font-bold md:text-4xl">
<Trans>Uh oh! Looks like you're missing a token</Trans>
</h2>
<p className="text-muted-foreground mt-4">
<Trans>
It seems that there is no token provided, if you are trying to verify your email
please follow the link in your email.
</Trans>
</p>
<Button className="mt-4" asChild>
<Link href="/">
<Trans>Go back home</Trans>
</Link>
</Button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,123 @@
import { cookies } from 'next/headers';
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { getToken } from 'next-auth/jwt';
import { TEAM_URL_ROOT_REGEX } from '@documenso/lib/constants/teams';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
async function middleware(req: NextRequest): Promise<NextResponse> {
const preferredTeamUrl = cookies().get('preferred-team-url');
const referrer = req.headers.get('referer');
const referrerUrl = referrer ? new URL(referrer) : null;
const referrerPathname = referrerUrl ? referrerUrl.pathname : null;
// Whether to reset the preferred team url cookie if the user accesses a non team page from a team page.
const resetPreferredTeamUrl =
referrerPathname &&
referrerPathname.startsWith('/t/') &&
(!req.nextUrl.pathname.startsWith('/t/') || req.nextUrl.pathname === '/');
// Redirect root page to `/documents` or `/t/{preferredTeamUrl}/documents`.
if (req.nextUrl.pathname === '/') {
const redirectUrlPath = formatDocumentsPath(
resetPreferredTeamUrl ? undefined : preferredTeamUrl?.value,
);
const redirectUrl = new URL(redirectUrlPath, req.url);
const response = NextResponse.redirect(redirectUrl);
return response;
}
// Redirect `/t` to `/settings/teams`.
if (req.nextUrl.pathname === '/t') {
const redirectUrl = new URL('/settings/teams', req.url);
return NextResponse.redirect(redirectUrl);
}
// Redirect `/t/<team_url>` to `/t/<team_url>/documents`.
if (TEAM_URL_ROOT_REGEX.test(req.nextUrl.pathname)) {
const redirectUrl = new URL(`${req.nextUrl.pathname}/documents`, req.url);
const response = NextResponse.redirect(redirectUrl);
response.cookies.set('preferred-team-url', req.nextUrl.pathname.replace('/t/', ''));
return response;
}
// Set the preferred team url cookie if user accesses a team page.
if (req.nextUrl.pathname.startsWith('/t/')) {
const response = NextResponse.next();
response.cookies.set('preferred-team-url', req.nextUrl.pathname.split('/')[2]);
return response;
}
if (req.nextUrl.pathname.startsWith('/signin')) {
const token = await getToken({ req });
if (token) {
const redirectUrl = new URL('/documents', req.url);
return NextResponse.redirect(redirectUrl);
}
}
// Clear preferred team url cookie if user accesses a non team page from a team page.
if (resetPreferredTeamUrl || req.nextUrl.pathname === '/documents') {
const response = NextResponse.next();
response.cookies.set('preferred-team-url', '');
return response;
}
if (req.nextUrl.pathname.startsWith('/embed')) {
const res = NextResponse.next();
const origin = req.headers.get('Origin') ?? '*';
// Allow third parties to iframe the document.
res.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.headers.set('Access-Control-Allow-Origin', origin);
res.headers.set('Content-Security-Policy', `frame-ancestors ${origin}`);
res.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
res.headers.set('X-Content-Type-Options', 'nosniff');
return res;
}
return NextResponse.next();
}
export default async function middlewareWrapper(req: NextRequest) {
const response = await middleware(req);
// Can place anything that needs to be set on the response here.
return response;
}
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - api (API routes)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* - ingest (analytics)
* - site.webmanifest
*/
{
source: '/((?!api|_next/static|_next/image|ingest|favicon|site.webmanifest).*)',
missing: [
{ type: 'header', key: 'next-router-prefetch' },
{ type: 'header', key: 'purpose', value: 'prefetch' },
],
},
],
};

View File

@ -0,0 +1,28 @@
import { type TSiteSettingsBannerSchema } from '@documenso/lib/server-only/site-settings/schemas/banner';
export type AppBannerProps = {
banner: TSiteSettingsBannerSchema;
};
export const AppBanner = ({ banner }: AppBannerProps) => {
if (!banner.enabled) {
return null;
}
return (
<div className="mb-2" style={{ background: banner.data.bgColor }}>
<div
className="mx-auto flex h-auto max-w-screen-xl items-center justify-center px-4 py-3 text-sm font-medium"
style={{ color: banner.data.textColor }}
>
<div className="flex items-center">
<span dangerouslySetInnerHTML={{ __html: banner.data.content }} />
</div>
</div>
</div>
);
};
// Banner
// Custom Text
// Custom Text with Custom Icon

View File

@ -1,29 +0,0 @@
import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
import { SITE_SETTINGS_BANNER_ID } from '@documenso/lib/server-only/site-settings/schemas/banner';
export const Banner = async () => {
const banner = await getSiteSettings().then((settings) =>
settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
);
return (
<>
{banner && banner.enabled && (
<div className="mb-2" style={{ background: banner.data.bgColor }}>
<div
className="mx-auto flex h-auto max-w-screen-xl items-center justify-center px-4 py-3 text-sm font-medium"
style={{ color: banner.data.textColor }}
>
<div className="flex items-center">
<span dangerouslySetInnerHTML={{ __html: banner.data.content }} />
</div>
</div>
</div>
)}
</>
);
};
// Banner
// Custom Text
// Custom Text with Custom Icon

View File

@ -13,6 +13,7 @@ import { z } from 'zod';
import communityCardsImage from '@documenso/assets/images/community-cards.png';
import { authClient } from '@documenso/auth/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
@ -93,7 +94,7 @@ export const SignUpForm = ({
const { _ } = useLingui();
const { toast } = useToast();
// const analytics = useAnalytics(); // Todo
const analytics = useAnalytics();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
@ -134,11 +135,11 @@ export const SignUpForm = ({
duration: 5000,
});
// analytics.capture('App: User Sign Up', {
// email,
// timestamp: new Date().toISOString(),
// custom_campaign_params: { src: utmSrc },
// });
analytics.capture('App: User Sign Up', {
email,
timestamp: new Date().toISOString(),
custom_campaign_params: { src: utmSrc },
});
} catch (err) {
const error = AppError.parseError(err);

View File

@ -0,0 +1,138 @@
import { useState } from 'react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { AlertTriangle } from 'lucide-react';
import { match } from 'ts-pattern';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import type { TeamMemberRole } from '@documenso/prisma/client';
import { type Subscription, SubscriptionStatus } 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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type TeamLayoutBillingBannerProps = {
subscription: Subscription;
teamId: number;
userRole: TeamMemberRole;
};
export const TeamLayoutBillingBanner = ({
subscription,
teamId,
userRole,
}: TeamLayoutBillingBannerProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync: createBillingPortal, isPending } =
trpc.team.createBillingPortal.useMutation();
const handleCreatePortal = async () => {
try {
const sessionUrl = await createBillingPortal({ teamId });
window.open(sessionUrl, '_blank');
setIsOpen(false);
} catch (err) {
toast({
title: _(msg`Something went wrong`),
description: _(
msg`We are unable to proceed to the billing portal at this time. Please try again, or contact support.`,
),
variant: 'destructive',
duration: 10000,
});
}
};
if (subscription.status === SubscriptionStatus.ACTIVE) {
return null;
}
return (
<>
<div
className={cn({
'bg-yellow-200 text-yellow-900 dark:bg-yellow-400':
subscription.status === SubscriptionStatus.PAST_DUE,
'bg-destructive text-destructive-foreground':
subscription.status === SubscriptionStatus.INACTIVE,
})}
>
<div className="mx-auto flex max-w-screen-xl items-center justify-center gap-x-4 px-4 py-2 text-sm font-medium">
<div className="flex items-center">
<AlertTriangle className="mr-2.5 h-5 w-5" />
{match(subscription.status)
.with(SubscriptionStatus.PAST_DUE, () => <Trans>Payment overdue</Trans>)
.with(SubscriptionStatus.INACTIVE, () => <Trans>Teams restricted</Trans>)
.exhaustive()}
</div>
<Button
variant="ghost"
className={cn({
'text-yellow-900 hover:bg-yellow-100 hover:text-yellow-900 dark:hover:bg-yellow-500':
subscription.status === SubscriptionStatus.PAST_DUE,
'text-destructive-foreground hover:bg-destructive-foreground hover:text-white':
subscription.status === SubscriptionStatus.INACTIVE,
})}
disabled={isPending}
onClick={() => setIsOpen(true)}
size="sm"
>
<Trans>Resolve</Trans>
</Button>
</div>
</div>
<Dialog open={isOpen} onOpenChange={(value) => !isPending && setIsOpen(value)}>
<DialogContent>
<DialogTitle>
<Trans>Payment overdue</Trans>
</DialogTitle>
{match(subscription.status)
.with(SubscriptionStatus.PAST_DUE, () => (
<DialogDescription>
<Trans>
Your payment for teams is overdue. Please settle the payment to avoid any service
disruptions.
</Trans>
</DialogDescription>
))
.with(SubscriptionStatus.INACTIVE, () => (
<DialogDescription>
<Trans>
Due to an unpaid invoice, your team has been restricted. Please settle the payment
to restore full access to your team.
</Trans>
</DialogDescription>
))
.otherwise(() => null)}
{canExecuteTeamAction('MANAGE_BILLING', userRole) && (
<DialogFooter>
<Button loading={isPending} onClick={handleCreatePortal}>
<Trans>Resolve payment</Trans>
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
</>
);
};

View File

@ -1,3 +1,5 @@
import { Suspense } from 'react';
import {
Links,
Meta,
@ -20,6 +22,7 @@ import { TooltipProvider } from '@documenso/ui/primitives/tooltip';
import type { Route } from './+types/root';
import stylesheet from './app.css?url';
import { GenericErrorLayout } from './components/general/generic-error-layout';
import { PostHogPageview } from './providers/posthog';
import { langCookie } from './storage/lang-cookie.server';
import { themeSessionResolver } from './storage/theme-session.server';
@ -41,6 +44,37 @@ export const links: Route.LinksFunction = () => [
{ rel: 'stylesheet', href: stylesheet },
];
// Todo: Meta data.
// export function generateMetadata() {
// return {
// title: {
// template: '%s - Documenso',
// default: 'Documenso',
// },
// description:
// 'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
// keywords:
// 'Documenso, open source, DocuSign alternative, document signing, open signing infrastructure, open-source community, fast signing, beautiful signing, smart templates',
// authors: { name: 'Documenso, Inc.' },
// robots: 'index, follow',
// metadataBase: new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000'),
// openGraph: {
// title: 'Documenso - The Open Source DocuSign Alternative',
// description:
// 'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
// type: 'website',
// images: ['/opengraph-image.jpg'],
// },
// twitter: {
// site: '@documenso',
// card: 'summary_large_image',
// images: ['/opengraph-image.jpg'],
// description:
// 'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
// },
// };
// }
export async function loader({ request, context }: Route.LoaderArgs) {
const { getTheme } = await themeSessionResolver(request);
@ -81,10 +115,15 @@ export function Layout({ children }: { children: React.ReactNode }) {
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="google" content="notranslate" />
<Meta />
<Links />
<meta name="google" content="notranslate" />
{/* <PreventFlashOnWrongTheme ssrTheme={Boolean(theme)} /> */}
<Suspense>
<PostHogPageview />
</Suspense>
</head>
<body>
{children}

View File

@ -1,15 +1,22 @@
import { Outlet, redirect } from 'react-router';
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client';
import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
import { SITE_SETTINGS_BANNER_ID } from '@documenso/lib/server-only/site-settings/schemas/banner';
import { AppBanner } from '~/components/(dashboard)/layout/app-banner';
import { Header } from '~/components/(dashboard)/layout/header';
import { VerifyEmailBanner } from '~/components/(dashboard)/layout/verify-email-banner';
import type { Route } from './+types/_layout';
export const loader = ({ context }: Route.LoaderArgs) => {
export const loader = async ({ context }: Route.LoaderArgs) => {
const { session } = context;
const banner = await getSiteSettings().then((settings) =>
settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
);
if (!session) {
throw redirect('/signin');
}
@ -17,18 +24,18 @@ export const loader = ({ context }: Route.LoaderArgs) => {
return {
user: session.user,
teams: session.teams,
banner,
};
};
export default function Layout({ loaderData }: Route.ComponentProps) {
const { user, teams } = loaderData;
const { user, teams, banner } = loaderData;
return (
<LimitsProvider>
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
{/* // Todo: Banner */}
{/* <Banner /> */}
{banner && <AppBanner banner={banner} />}
<Header user={user} teams={teams} />

View File

@ -1,7 +1,7 @@
import { Trans } from '@lingui/macro';
import { BarChart3, FileStack, Settings, Trophy, Users, Wallet2 } from 'lucide-react';
import { Link, Outlet, redirect, useLocation } from 'react-router';
import { getRequiredSessionContext } from 'server/utils/get-required-session-context';
import { getRequiredLoaderSession } from 'server/utils/get-required-session-context';
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
import { cn } from '@documenso/ui/lib/utils';
@ -10,7 +10,7 @@ import { Button } from '@documenso/ui/primitives/button';
import type { Route } from './+types/_layout';
export function loader({ context }: Route.LoaderArgs) {
const { user } = getRequiredSessionContext(context);
const { user } = getRequiredLoaderSession(context);
if (!user || !isAdmin(user)) {
throw redirect('/documents');

View File

@ -3,7 +3,7 @@ import { useLingui } from '@lingui/react';
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
import { ChevronLeft, Clock9, Users2 } from 'lucide-react';
import { Link, redirect } from 'react-router';
import { getRequiredSessionContext } from 'server/utils/get-required-session-context';
import { getRequiredLoaderSession } from 'server/utils/get-required-session-context';
import { match } from 'ts-pattern';
import { useSession } from '@documenso/lib/client-only/providers/session';
@ -34,7 +34,7 @@ import { DocumentPageViewRecipients } from '~/components/general/document/docume
import type { Route } from './+types/$id._index';
export async function loader({ params, context }: Route.LoaderArgs) {
const { user, currentTeam: team } = getRequiredSessionContext(context);
const { user, currentTeam: team } = getRequiredLoaderSession(context);
const { id } = params;

View File

@ -2,7 +2,7 @@ import { Plural, Trans } from '@lingui/macro';
import { DocumentStatus as InternalDocumentStatus, TeamMemberRole } from '@prisma/client';
import { ChevronLeft, Users2 } from 'lucide-react';
import { Link, redirect } from 'react-router';
import { getRequiredSessionContext } from 'server/utils/get-required-session-context';
import { getRequiredLoaderSession } from 'server/utils/get-required-session-context';
import { match } from 'ts-pattern';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
@ -18,7 +18,7 @@ import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/$id.edit';
export async function loader({ params, context }: Route.LoaderArgs) {
const { user, currentTeam: team } = getRequiredSessionContext(context);
const { user, currentTeam: team } = getRequiredLoaderSession(context);
const { id } = params;

View File

@ -5,7 +5,7 @@ import type { Recipient } from '@prisma/client';
import { ChevronLeft } from 'lucide-react';
import { DateTime } from 'luxon';
import { Link, redirect } from 'react-router';
import { getRequiredSessionContext } from 'server/utils/get-required-session-context';
import { getRequiredLoaderSession } from 'server/utils/get-required-session-context';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
@ -25,7 +25,7 @@ import type { Route } from './+types/$id.logs';
export async function loader({ params, context }: Route.LoaderArgs) {
const { id } = params;
const { user, currentTeam: team } = getRequiredSessionContext(context);
const { user, currentTeam: team } = getRequiredLoaderSession(context);
const documentId = Number(id);

View File

@ -4,7 +4,7 @@ import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import type { TemplateDirectLink } from '@prisma/client';
import { TemplateType } from '@prisma/client';
import { getRequiredSessionContext } from 'server/utils/get-required-session-context';
import { getRequiredLoaderSession } from 'server/utils/get-required-session-context';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { getUserPublicProfile } from '@documenso/lib/server-only/user/get-user-public-profile';
@ -44,7 +44,7 @@ const teamProfileText = {
};
export async function loader({ context }: Route.LoaderArgs) {
const { user } = getRequiredSessionContext(context);
const { user } = getRequiredLoaderSession(context);
const { profile } = await getUserPublicProfile({
userId: user.id,

View File

@ -1,7 +1,7 @@
import { Trans } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { DateTime } from 'luxon';
import { getRequiredSessionContext } from 'server/utils/get-required-session-context';
import { getRequiredLoaderSession } from 'server/utils/get-required-session-context';
import { getUserTokens } from '@documenso/lib/server-only/public-api/get-all-user-tokens';
import { Button } from '@documenso/ui/primitives/button';
@ -12,7 +12,7 @@ import { ApiTokenForm } from '~/components/forms/token';
import type { Route } from './+types/index';
export async function loader({ context }: Route.LoaderArgs) {
const { user } = getRequiredSessionContext(context);
const { user } = getRequiredLoaderSession(context);
// Todo: Use TRPC & use table instead
const tokens = await getUserTokens({ userId: user.id });

View File

@ -3,7 +3,7 @@ import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { ChevronLeft } from 'lucide-react';
import { Link, Outlet, isRouteErrorResponse, redirect, useNavigate } from 'react-router';
import { getRequiredSessionContext } from 'server/utils/get-required-session-context';
import { getRequiredLoaderSession } from 'server/utils/get-required-session-context';
import { match } from 'ts-pattern';
import { AppErrorCode } from '@documenso/lib/errors/app-error';
@ -15,7 +15,7 @@ import { TeamProvider } from '~/providers/team';
import type { Route } from './+types/_layout';
export const loader = ({ context }: Route.LoaderArgs) => {
const { currentTeam } = getRequiredSessionContext(context);
const { currentTeam } = getRequiredLoaderSession(context);
if (!currentTeam) {
throw redirect('/documents');

View File

@ -1,6 +1,6 @@
import { Trans } from '@lingui/macro';
import { Outlet } from 'react-router';
import { getRequiredTeamSessionContext } from 'server/utils/get-required-session-context';
import { getRequiredLoaderTeamSession } from 'server/utils/get-required-session-context';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
@ -9,10 +9,9 @@ import { TeamSettingsMobileNav } from '~/components/general/teams/team-settings-
import type { Route } from '../+types/_layout';
export async function loader({ context }: Route.LoaderArgs) {
const { currentTeam: team } = getRequiredTeamSessionContext(context);
export function loader({ context }: Route.LoaderArgs) {
const { currentTeam: team } = getRequiredLoaderTeamSession(context);
// Todo: Test that 404 page shows up from error.
if (!team || !canExecuteTeamAction('MANAGE_TEAM', team.currentTeamMember.role)) {
throw new Response(null, { status: 401 }); // Unauthorized.
}

View File

@ -1,7 +1,7 @@
import { Plural, Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { DateTime } from 'luxon';
import { getRequiredTeamSessionContext } from 'server/utils/get-required-session-context';
import { getRequiredLoaderTeamSession } from 'server/utils/get-required-session-context';
import type Stripe from 'stripe';
import { match } from 'ts-pattern';
@ -16,7 +16,7 @@ import { TeamBillingPortalButton } from '~/components/general/teams/team-billing
import type { Route } from './+types/billing';
export async function loader({ context }: Route.LoaderArgs) {
const { currentTeam: team } = getRequiredTeamSessionContext(context);
const { currentTeam: team } = getRequiredLoaderTeamSession(context);
let teamSubscription: Stripe.Subscription | null = null;

View File

@ -1,4 +1,4 @@
import { getRequiredTeamSessionContext } from 'server/utils/get-required-session-context';
import { getRequiredLoaderTeamSession } from 'server/utils/get-required-session-context';
import { getTeamPublicProfile } from '@documenso/lib/server-only/team/get-team-public-profile';
@ -7,7 +7,7 @@ import PublicProfilePage from '~/routes/_authenticated+/settings+/public-profile
import type { Route } from './+types/public-profile';
export async function loader({ context }: Route.LoaderArgs) {
const { user, currentTeam: team } = getRequiredTeamSessionContext(context);
const { user, currentTeam: team } = getRequiredLoaderTeamSession(context);
const { profile } = await getTeamPublicProfile({
userId: user.id,

View File

@ -1,7 +1,7 @@
import { Trans } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { DateTime } from 'luxon';
import { getRequiredTeamSessionContext } from 'server/utils/get-required-session-context';
import { getRequiredLoaderTeamSession } from 'server/utils/get-required-session-context';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getTeamTokens } from '@documenso/lib/server-only/public-api/get-all-team-tokens';
@ -13,7 +13,7 @@ import { ApiTokenForm } from '~/components/forms/token';
import type { Route } from './+types/tokens';
export async function loader({ context }: Route.LoaderArgs) {
const { user, currentTeam: team } = getRequiredTeamSessionContext(context);
const { user, currentTeam: team } = getRequiredLoaderTeamSession(context);
const tokens = await getTeamTokens({ userId: user.id, teamId: team.id }).catch(() => null);

View File

@ -2,7 +2,7 @@ import { Trans } from '@lingui/macro';
import { DocumentSigningOrder, SigningStatus } from '@prisma/client';
import { ChevronLeft, LucideEdit } from 'lucide-react';
import { Link, redirect } from 'react-router';
import { getRequiredSessionContext } from 'server/utils/get-required-session-context';
import { getRequiredLoaderSession } from 'server/utils/get-required-session-context';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
@ -25,7 +25,7 @@ import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/$id._index';
export async function loader({ params, context }: Route.LoaderArgs) {
const { user, currentTeam: team } = getRequiredSessionContext(context);
const { user, currentTeam: team } = getRequiredLoaderSession(context);
const { id } = params;

View File

@ -1,7 +1,7 @@
import { Trans } from '@lingui/macro';
import { ChevronLeft } from 'lucide-react';
import { Link, redirect } from 'react-router';
import { getRequiredSessionContext } from 'server/utils/get-required-session-context';
import { getRequiredLoaderSession } from 'server/utils/get-required-session-context';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
@ -16,7 +16,7 @@ import { TemplateDirectLinkDialogWrapper } from '../../../components/dialogs/tem
import type { Route } from './+types/$id.edit';
export async function loader({ context, params }: Route.LoaderArgs) {
const { user, currentTeam: team } = getRequiredSessionContext(context);
const { user, currentTeam: team } = getRequiredLoaderSession(context);
const { id } = params;

View File

@ -16,12 +16,6 @@ import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/_index';
export type TemplatesDirectPageProps = {
params: {
token: string;
};
};
export async function loader({ params, context }: Route.LoaderArgs) {
const { token } = params;

View File

@ -2,12 +2,6 @@ import { Outlet } from 'react-router';
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
import type { Route } from './+types/_layout';
export const loader = async (args: Route.LoaderArgs) => {
//
};
export default function Layout() {
return (
<main className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-24">

View File

@ -17,7 +17,7 @@ export async function loader({ params }: Route.LoaderArgs) {
const isValid = await getResetTokenValidity({ token });
if (!isValid) {
redirect('/reset-password');
throw redirect('/reset-password');
}
return {

View File

@ -0,0 +1,152 @@
import { ImageResponse } from 'next/og';
import { P, match } from 'ts-pattern';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import type { ShareHandlerAPIResponse } from '../api+/share';
import type { Route } from './+types/share.$slug.opengraph';
export const runtime = 'edge';
const CARD_OFFSET_TOP = 173;
const CARD_OFFSET_LEFT = 307;
const CARD_WIDTH = 590;
const CARD_HEIGHT = 337;
const IMAGE_SIZE = {
width: 1200,
height: 630,
};
export const loader = async ({ params }: Route.LoaderArgs) => {
const { slug } = params;
const baseUrl = NEXT_PUBLIC_WEBAPP_URL();
const [interSemiBold, interRegular, caveatRegular, shareFrameImage] = await Promise.all([
fetch(new URL(`${baseUrl}/fonts/inter-semibold.ttf`, import.meta.url)).then(async (res) =>
res.arrayBuffer(),
),
fetch(new URL(`${baseUrl}/fonts/inter-regular.ttf`, import.meta.url)).then(async (res) =>
res.arrayBuffer(),
),
fetch(new URL(`${baseUrl}/fonts/caveat-regular.ttf`, import.meta.url)).then(async (res) =>
res.arrayBuffer(),
),
fetch(new URL(`${baseUrl}/static/og-share-frame2.png`, import.meta.url)).then(async (res) =>
res.arrayBuffer(),
),
]);
const recipientOrSender: ShareHandlerAPIResponse = await fetch(
new URL(`/api/share?slug=${slug}`, baseUrl),
).then(async (res) => res.json());
if ('error' in recipientOrSender) {
return Response.json({ error: 'Not found' }, { status: 404 });
}
const isRecipient = 'Signature' in recipientOrSender;
const signatureImage = match(recipientOrSender)
.with({ signatures: P.array(P._) }, (recipient) => {
return recipient.signatures?.[0]?.signatureImageAsBase64 || null;
})
.otherwise((sender) => {
return sender.signature || null;
});
const signatureName = match(recipientOrSender)
.with({ signatures: P.array(P._) }, (recipient) => {
return recipient.name || recipient.email;
})
.otherwise((sender) => {
return sender.name || sender.email;
});
return new ImageResponse(
(
<div tw="relative flex h-full w-full bg-white">
{/* @ts-expect-error Lack of typing from ImageResponse */}
<img src={shareFrameImage} alt="og-share-frame" tw="absolute inset-0 w-full h-full" />
{signatureImage ? (
<div
tw="absolute py-6 px-12 flex items-center justify-center text-center"
style={{
top: `${CARD_OFFSET_TOP}px`,
left: `${CARD_OFFSET_LEFT}px`,
width: `${CARD_WIDTH}px`,
height: `${CARD_HEIGHT}px`,
}}
>
<img src={signatureImage} alt="signature" tw="opacity-60 h-full max-w-[100%]" />
</div>
) : (
<p
tw="absolute py-6 px-12 -mt-2 flex items-center justify-center text-center text-slate-500"
style={{
fontFamily: 'Caveat',
fontSize: `${Math.max(
Math.min((CARD_WIDTH * 1.5) / signatureName.length, 80),
36,
)}px`,
top: `${CARD_OFFSET_TOP}px`,
left: `${CARD_OFFSET_LEFT}px`,
width: `${CARD_WIDTH}px`,
height: `${CARD_HEIGHT}px`,
}}
>
{signatureName}
</p>
)}
<div
tw="absolute flex w-full"
style={{
top: `${CARD_OFFSET_TOP - 78}px`,
left: `${CARD_OFFSET_LEFT}px`,
}}
>
<h2
tw="text-xl"
style={{
color: '#828282',
fontFamily: 'Inter',
fontWeight: 700,
}}
>
{isRecipient ? 'Document Signed!' : 'Document Sent!'}
</h2>
</div>
</div>
),
{
...IMAGE_SIZE,
fonts: [
{
name: 'Caveat',
data: caveatRegular,
style: 'italic',
},
{
name: 'Inter',
data: interRegular,
style: 'normal',
weight: 400,
},
{
name: 'Inter',
data: interSemiBold,
style: 'normal',
weight: 600,
},
],
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
},
},
);
};

View File

@ -0,0 +1,39 @@
import { redirect } from 'react-router';
import { NEXT_PUBLIC_MARKETING_URL } from '@documenso/lib/constants/app';
import type { Route } from './+types/share.$slug';
// Todo: Test meta.
export function meta({ params: { slug } }: Route.MetaArgs) {
return [
{ title: 'Documenso - Share' },
{ description: 'I just signed a document in style with Documenso!' },
{
openGraph: {
title: 'Documenso - Join the open source signing revolution',
description: 'I just signed with Documenso!',
type: 'website',
images: [`/share/${slug}/opengraph`],
},
},
{
twitter: {
site: '@documenso',
card: 'summary_large_image',
images: [`/share/${slug}/opengraph`],
description: 'I just signed with Documenso!',
},
},
];
}
export const loader = ({ request }: Route.LoaderArgs) => {
const userAgent = request.headers.get('User-Agent') ?? '';
if (/bot|facebookexternalhit|WhatsApp|google|bing|duckduckbot|MetaInspector/i.test(userAgent)) {
return null;
}
return redirect(NEXT_PUBLIC_MARKETING_URL());
};

View File

@ -0,0 +1,73 @@
import sharp from 'sharp';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { prisma } from '@documenso/prisma';
import type { Route } from './+types/branding.logo.team.$teamId';
export async function loader({ params }: Route.LoaderArgs) {
const teamId = Number(params.teamId);
if (teamId === 0 || Number.isNaN(teamId)) {
return Response.json(
{
status: 'error',
message: 'Invalid team ID',
},
{ status: 400 },
);
}
const settings = await prisma.teamGlobalSettings.findFirst({
where: {
teamId,
},
});
if (!settings || !settings.brandingEnabled) {
return Response.json(
{
status: 'error',
message: 'Not found',
},
{ status: 404 },
);
}
if (!settings.brandingLogo) {
return Response.json(
{
status: 'error',
message: 'Not found',
},
{ status: 404 },
);
}
const file = await getFile(JSON.parse(settings.brandingLogo)).catch(() => null);
if (!file) {
return Response.json(
{
status: 'error',
message: 'Not found',
},
{ status: 404 },
);
}
const img = await sharp(file)
.toFormat('png', {
quality: 80,
})
.toBuffer();
return new Response(img, {
headers: {
'Content-Type': 'image/png',
'Content-Length': img.length.toString(),
// Stale while revalidate for 1 hours to 24 hours
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
},
});
}

View File

@ -0,0 +1,22 @@
import { prisma } from '@documenso/prisma';
export async function loader() {
try {
await prisma.$queryRaw`SELECT 1`;
return Response.json({
status: 'ok',
message: 'All systems operational',
});
} catch (err) {
console.error(err);
return Response.json(
{
status: 'error',
message: err instanceof Error ? err.message : 'Unknown error',
},
{ status: 500 },
);
}
}

View File

@ -0,0 +1,27 @@
import { getRecipientOrSenderByShareLinkSlug } from '@documenso/lib/server-only/share/get-recipient-or-sender-by-share-link-slug';
import type { Route } from './+types/share';
export type ShareHandlerAPIResponse =
| Awaited<ReturnType<typeof getRecipientOrSenderByShareLinkSlug>>
| { error: string };
// Todo: Test
export async function loader({ request }: Route.LoaderArgs) {
try {
const url = new URL(request.url);
const slug = url.searchParams.get('slug');
if (typeof slug !== 'string') {
throw new Error('Invalid slug');
}
const data = await getRecipientOrSenderByShareLinkSlug({
slug,
});
return Response.json(data);
} catch (error) {
return Response.json({ error: 'Not found' }, { status: 404 });
}
}

View File

@ -0,0 +1,11 @@
import { stripeWebhookHandler } from '@documenso/ee/server-only/stripe/webhook/handler';
// Todo
// export const config = {
// api: { bodyParser: false },
// };
import type { Route } from './+types/webhook.trigger';
export async function loader({ request }: Route.LoaderArgs) {
return stripeWebhookHandler(request);
}

View File

@ -0,0 +1,17 @@
import { handlerTriggerWebhooks } from '@documenso/lib/server-only/webhooks/trigger/handler';
import type { Route } from './+types/webhook.trigger';
// Todo
// export const config = {
// maxDuration: 300,
// api: {
// bodyParser: {
// sizeLimit: '50mb',
// },
// },
// };
export async function loader({ request }: Route.LoaderArgs) {
return handlerTriggerWebhooks(request);
}

View File

@ -1,5 +1,5 @@
import { data } from 'react-router';
import { getRequiredSessionContext } from 'server/utils/get-required-session-context';
import { getRequiredLoaderSession } from 'server/utils/get-required-session-context';
import { match } from 'ts-pattern';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
@ -48,7 +48,7 @@ export async function loader({ params, context }: Route.LoaderArgs) {
);
}
const { user } = getRequiredSessionContext(context);
const { user } = getRequiredLoaderSession(context);
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: template.authOptions,

View File

@ -1,5 +1,5 @@
import { data } from 'react-router';
import { getRequiredSessionContext } from 'server/utils/get-required-session-context';
import { getRequiredLoaderSession } from 'server/utils/get-required-session-context';
import { match } from 'ts-pattern';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
@ -27,7 +27,7 @@ export async function loader({ params, context }: Route.LoaderArgs) {
const token = params.url;
const { user } = getRequiredSessionContext(context);
const { user } = getRequiredLoaderSession(context);
const [document, fields, recipient] = await Promise.all([
getDocumentAndSenderByToken({