feat: migrate nextjs to rr7

This commit is contained in:
David Nguyen
2025-01-02 15:33:37 +11:00
parent 9183f668d3
commit 383b5f78f0
898 changed files with 31175 additions and 24615 deletions

View File

@ -0,0 +1,27 @@
import { Outlet } from 'react-router';
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
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">
<div>
<div className="absolute -inset-[min(600px,max(400px,60vw))] -z-[1] flex items-center justify-center opacity-70">
<img
src={backgroundPattern}
alt="background pattern"
className="dark:brightness-95 dark:contrast-[70%] dark:invert dark:sepia"
style={{
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
}}
/>
</div>
<div className="relative w-full">
<Outlet />
</div>
</div>
</main>
);
}

View File

@ -0,0 +1,166 @@
import { Trans } from '@lingui/react/macro';
import { Link } from 'react-router';
import { Button } from '@documenso/ui/primitives/button';
const SUPPORT_EMAIL = 'support@documenso.com';
export default function SignatureDisclosure() {
return (
<div>
<article className="prose dark:prose-invert">
<h1>
<Trans>Electronic Signature Disclosure</Trans>
</h1>
<h2>
<Trans>Welcome</Trans>
</h2>
<p>
<Trans>
Thank you for using Documenso to perform your electronic document signing. The purpose
of this disclosure is to inform you about the process, legality, and your rights
regarding the use of electronic signatures on our platform. By opting to use an
electronic signature, you are agreeing to the terms and conditions outlined below.
</Trans>
</p>
<h2>
<Trans>Acceptance and Consent</Trans>
</h2>
<p>
<Trans>
When you use our platform to affix your electronic signature to documents, you are
consenting to do so under the Electronic Signatures in Global and National Commerce Act
(E-Sign Act) and other applicable laws. This action indicates your agreement to use
electronic means to sign documents and receive notifications.
</Trans>
</p>
<h2>
<Trans>Legality of Electronic Signatures</Trans>
</h2>
<p>
<Trans>
An electronic signature provided by you on our platform, achieved through clicking
through to a document and entering your name, or any other electronic signing method we
provide, is legally binding. It carries the same weight and enforceability as a manual
signature written with ink on paper.
</Trans>
</p>
<h2>
<Trans>System Requirements</Trans>
</h2>
<p>
<Trans>To use our electronic signature service, you must have access to:</Trans>
</p>
<ul>
<li>
<Trans>A stable internet connection</Trans>
</li>
<li>
<Trans>An email account</Trans>
</li>
<li>
<Trans>A device capable of accessing, opening, and reading documents</Trans>
</li>
<li>
<Trans>A means to print or download documents for your records</Trans>
</li>
</ul>
<h2>
<Trans>Electronic Delivery of Documents</Trans>
</h2>
<p>
<Trans>
All documents related to the electronic signing process will be provided to you
electronically through our platform or via email. It is your responsibility to ensure
that your email address is current and that you can receive and open our emails.
</Trans>
</p>
<h2>
<Trans>Consent to Electronic Transactions</Trans>
</h2>
<p>
<Trans>
By using the electronic signature feature, you are consenting to conduct transactions
and receive disclosures electronically. You acknowledge that your electronic signature
on documents is binding and that you accept the terms outlined in the documents you are
signing.
</Trans>
</p>
<h2>
<Trans>Withdrawing Consent</Trans>
</h2>
<p>
<Trans>
You have the right to withdraw your consent to use electronic signatures at any time
before completing the signing process. To withdraw your consent, please contact the
sender of the document. In failing to contact the sender you may reach out to{' '}
<a href={`mailto:${SUPPORT_EMAIL}`}>{SUPPORT_EMAIL}</a> for assistance. Be aware that
withdrawing consent may delay or halt the completion of the related transaction or
service.
</Trans>
</p>
<h2>
<Trans>Updating Your Information</Trans>
</h2>
<p>
<Trans>
It is crucial to keep your contact information, especially your email address, up to
date with us. Please notify us immediately of any changes to ensure that you continue to
receive all necessary communications.
</Trans>
</p>
<h2>
<Trans>Retention of Documents</Trans>
</h2>
<p>
<Trans>
After signing a document electronically, you will be provided the opportunity to view,
download, and print the document for your records. It is highly recommended that you
retain a copy of all electronically signed documents for your personal records. We will
also retain a copy of the signed document for our records however we may not be able to
provide you with a copy of the signed document after a certain period of time.
</Trans>
</p>
<h2>
<Trans>Acknowledgment</Trans>
</h2>
<p>
<Trans>
By proceeding to use the electronic signature service provided by Documenso, you affirm
that you have read and understood this disclosure. You agree to all terms and conditions
related to the use of electronic signatures and electronic transactions as outlined
herein.
</Trans>
</p>
<h2>
<Trans>Contact Information</Trans>
</h2>
<p>
<Trans>
For any questions regarding this disclosure, electronic signatures, or any related
process, please contact us at: <a href={`mailto:${SUPPORT_EMAIL}`}>{SUPPORT_EMAIL}</a>
</Trans>
</p>
</article>
<div className="mt-8">
<Button asChild>
<Link to="/documents">
<Trans>Back to Documents</Trans>
</Link>
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,35 @@
import { Trans } from '@lingui/react/macro';
import { Link } from 'react-router';
import { Button } from '@documenso/ui/primitives/button';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Forgot password');
}
export default function ForgotPasswordPage() {
return (
<div className="w-screen max-w-lg px-4">
<div className="w-full">
<h1 className="text-4xl font-semibold">
<Trans>Email sent!</Trans>
</h1>
<p className="text-muted-foreground mb-4 mt-2 text-sm">
<Trans>
A password reset email has been sent, if you have an account you should see it in your
inbox shortly.
</Trans>
</p>
<Button asChild>
<Link to="/signin">
<Trans>Return to sign in</Trans>
</Link>
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,39 @@
import { Trans } from '@lingui/react/macro';
import { Link } from 'react-router';
import { ForgotPasswordForm } from '~/components/forms/forgot-password';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Forgot Password');
}
export default function ForgotPasswordPage() {
return (
<div className="w-screen max-w-lg px-4">
<div className="w-full">
<h1 className="text-3xl font-semibold">
<Trans>Forgot your password?</Trans>
</h1>
<p className="text-muted-foreground mt-2 text-sm">
<Trans>
No worries, it happens! Enter your email and we'll email you a special link to reset
your password.
</Trans>
</p>
<ForgotPasswordForm className="mt-4" />
<p className="text-muted-foreground mt-6 text-center text-sm">
<Trans>
Remembered your password?{' '}
<Link to="/signin" className="text-primary duration-200 hover:opacity-70">
Sign In
</Link>
</Trans>
</p>
</div>
</div>
);
}

View File

@ -0,0 +1,56 @@
import { Trans } from '@lingui/react/macro';
import { Link, redirect } from 'react-router';
import { getResetTokenValidity } from '@documenso/lib/server-only/user/get-reset-token-validity';
import { ResetPasswordForm } from '~/components/forms/reset-password';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/reset-password.$token';
export function meta() {
return appMetaTags('Reset Password');
}
export async function loader({ params }: Route.LoaderArgs) {
const { token } = params;
const isValid = await getResetTokenValidity({ token });
if (!isValid) {
throw redirect('/reset-password');
}
return {
token,
};
}
export default function ResetPasswordPage({ loaderData }: Route.ComponentProps) {
const { token } = loaderData;
return (
<div className="w-screen max-w-lg px-4">
<div className="w-full">
<h1 className="text-4xl font-semibold">
<Trans>Reset Password</Trans>
</h1>
<p className="text-muted-foreground mt-2 text-sm">
<Trans>Please choose your new password</Trans>
</p>
<ResetPasswordForm token={token} className="mt-4" />
<p className="text-muted-foreground mt-6 text-center text-sm">
<Trans>
Don't have an account?{' '}
<Link to="/signup" className="text-primary duration-200 hover:opacity-70">
Sign up
</Link>
</Trans>
</p>
</div>
</div>
);
}

View File

@ -0,0 +1,35 @@
import { Trans } from '@lingui/react/macro';
import { Link } from 'react-router';
import { Button } from '@documenso/ui/primitives/button';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Reset Password');
}
export default function ResetPasswordPage() {
return (
<div className="w-screen max-w-lg px-4">
<div className="w-full">
<h1 className="text-3xl font-semibold">
<Trans>Unable to reset password</Trans>
</h1>
<p className="text-muted-foreground mt-2 text-sm">
<Trans>
The token you have used to reset your password is either expired or it never existed. If
you have still forgotten your password, please request a new reset link.
</Trans>
</p>
<Button className="mt-4" asChild>
<Link to="/signin">
<Trans>Return to sign in</Trans>
</Link>
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,192 @@
// Todo: Test, used AI to migrate this component from NextJS to Remix.
import satori from 'satori';
import sharp from 'sharp';
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] = 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(),
),
]);
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;
});
// Generate SVG using Satori
const svg = await satori(
<div
style={{
display: 'flex',
height: '100%',
width: '100%',
backgroundColor: 'white',
position: 'relative',
}}
>
<img
src={`${baseUrl}/static/og-share-frame2.png`}
alt="og-share-frame"
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
}}
/>
{signatureImage ? (
<div
style={{
position: 'absolute',
padding: '24px 48px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
top: CARD_OFFSET_TOP,
left: CARD_OFFSET_LEFT,
width: CARD_WIDTH,
height: CARD_HEIGHT,
}}
>
<img
src={signatureImage}
alt="signature"
style={{
opacity: 0.6,
height: '100%',
maxWidth: '100%',
}}
/>
</div>
) : (
<p
style={{
position: 'absolute',
padding: '24px 48px',
marginTop: '-8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
color: '#64748b',
fontFamily: 'Caveat',
fontSize: Math.max(Math.min((CARD_WIDTH * 1.5) / signatureName.length, 80), 36),
top: CARD_OFFSET_TOP,
left: CARD_OFFSET_LEFT,
width: CARD_WIDTH,
height: CARD_HEIGHT,
}}
>
{signatureName}
</p>
)}
<div
style={{
position: 'absolute',
display: 'flex',
width: '100%',
top: CARD_OFFSET_TOP - 78,
left: CARD_OFFSET_LEFT,
}}
>
<h2
style={{
fontSize: '20px',
color: '#828282',
fontFamily: 'Inter',
fontWeight: 700,
}}
>
{isRecipient ? 'Document Signed!' : 'Document Sent!'}
</h2>
</div>
</div>,
{
width: IMAGE_SIZE.width,
height: IMAGE_SIZE.height,
fonts: [
{
name: 'Caveat',
data: caveatRegular,
style: 'italic',
},
{
name: 'Inter',
data: interRegular,
weight: 400,
},
{
name: 'Inter',
data: interSemiBold,
weight: 600,
},
],
},
);
// Convert SVG to PNG using sharp
const pngBuffer = await sharp(Buffer.from(svg)).toFormat('png').toBuffer();
return new Response(pngBuffer, {
headers: {
'Content-Type': 'image/png',
'Content-Length': pngBuffer.length.toString(),
'Cache-Control': 'public, max-age=31536000, immutable',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
},
});
};

View File

@ -0,0 +1,55 @@
import { redirect } from 'react-router';
import { NEXT_PUBLIC_MARKETING_URL, NEXT_PUBLIC_WEBAPP_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!' },
{
property: 'og:title',
title: 'Documenso - Join the open source signing revolution',
},
{
property: 'og:description',
description: 'I just signed with Documenso!',
},
{
property: 'og:type',
type: 'website',
},
{
property: 'og:images',
images: `${NEXT_PUBLIC_WEBAPP_URL()}/share/${slug}/opengraph`,
},
{
name: 'twitter:site',
site: '@documenso',
},
{
name: 'twitter:card',
card: 'summary_large_image',
},
{
name: 'twitter:images',
images: `${NEXT_PUBLIC_WEBAPP_URL()}/share/${slug}/opengraph`,
},
{
name: 'twitter:description',
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;
}
throw redirect(NEXT_PUBLIC_MARKETING_URL());
};

View File

@ -0,0 +1,74 @@
import { Trans } from '@lingui/react/macro';
import { Link, redirect } from 'react-router';
import { getOptionalLoaderSession } from 'server/utils/get-loader-session';
import {
IS_GOOGLE_SSO_ENABLED,
IS_OIDC_SSO_ENABLED,
OIDC_PROVIDER_LABEL,
} from '@documenso/lib/constants/auth';
import { env } from '@documenso/lib/utils/env';
import { SignInForm } from '~/components/forms/signin';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/signin';
export function meta() {
return appMetaTags('Sign In');
}
export function loader() {
const session = getOptionalLoaderSession();
// SSR env variables.
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED;
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
const oidcProviderLabel = OIDC_PROVIDER_LABEL;
if (session) {
throw redirect('/documents');
}
return {
isGoogleSSOEnabled,
isOIDCSSOEnabled,
oidcProviderLabel,
};
}
export default function SignIn({ loaderData }: Route.ComponentProps) {
const { isGoogleSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } = loaderData;
return (
<div className="w-screen max-w-lg px-4">
<div className="border-border dark:bg-background z-10 rounded-xl border bg-neutral-100 p-6">
<h1 className="text-2xl font-semibold">
<Trans>Sign in to your account</Trans>
</h1>
<p className="text-muted-foreground mt-2 text-sm">
<Trans>Welcome back, we are lucky to have you.</Trans>
</p>
<hr className="-mx-6 my-4" />
<SignInForm
isGoogleSSOEnabled={isGoogleSSOEnabled}
isOIDCSSOEnabled={isOIDCSSOEnabled}
oidcProviderLabel={oidcProviderLabel}
/>
{env('NEXT_PUBLIC_DISABLE_SIGNUP') !== 'true' && (
<p className="text-muted-foreground mt-6 text-center text-sm">
<Trans>
Don't have an account?{' '}
<Link to="/signup" className="text-documenso-700 duration-200 hover:opacity-70">
Sign up
</Link>
</Trans>
</p>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,42 @@
import { redirect } from 'react-router';
import { IS_GOOGLE_SSO_ENABLED, IS_OIDC_SSO_ENABLED } from '@documenso/lib/constants/auth';
import { env } from '@documenso/lib/utils/env';
import { SignUpForm } from '~/components/forms/signup';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/signup';
export function meta() {
return appMetaTags('Sign Up');
}
export function loader() {
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
// SSR env variables.
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED;
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
if (NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
throw redirect('/signin');
}
return {
isGoogleSSOEnabled,
isOIDCSSOEnabled,
};
}
export default function SignUp({ loaderData }: Route.ComponentProps) {
const { isGoogleSSOEnabled, isOIDCSSOEnabled } = loaderData;
return (
<SignUpForm
className="w-screen max-w-screen-2xl px-4 md:px-16 lg:-my-16"
isGoogleSSOEnabled={isGoogleSSOEnabled}
isOIDCSSOEnabled={isOIDCSSOEnabled}
/>
);
}

View File

@ -0,0 +1,165 @@
import { Trans } from '@lingui/react/macro';
import { TeamMemberInviteStatus } from '@prisma/client';
import { DateTime } from 'luxon';
import { Link } from 'react-router';
import { getOptionalLoaderSession } from 'server/utils/get-loader-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 { Button } from '@documenso/ui/primitives/button';
import type { Route } from './+types/team.decline.$token';
export async function loader({ params }: Route.LoaderArgs) {
const session = getOptionalLoaderSession();
const { token } = params;
if (!token) {
return {
state: 'InvalidLink',
} as const;
}
const teamMemberInvite = await prisma.teamMemberInvite.findUnique({
where: {
token,
},
});
if (!teamMemberInvite) {
return {
state: 'InvalidLink',
} as const;
}
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 {
state: 'LoginRequired',
email,
teamName: team.name,
} as const;
}
const isSessionUserTheInvitedUser = user.id === session?.user.id;
return {
state: 'Success',
email,
teamName: team.name,
isSessionUserTheInvitedUser,
} as const;
}
export default function DeclineInvitationPage({ loaderData }: Route.ComponentProps) {
const data = loaderData;
if (data.state === 'InvalidLink') {
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 to="/">
<Trans>Return</Trans>
</Link>
</Button>
</div>
</div>
);
}
if (data.state === 'LoginRequired') {
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>{data.teamName}</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 to={`/signup?email=${encodeURIComponent(data.email)}`}>
<Trans>Create account</Trans>
</Link>
</Button>
</div>
);
}
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>{data.teamName}</strong> to join their team.
</Trans>
</p>
{data.isSessionUserTheInvitedUser ? (
<Button asChild>
<Link to="/">
<Trans>Return to Dashboard</Trans>
</Link>
</Button>
) : (
<Button asChild>
<Link to="/">
<Trans>Return to Home</Trans>
</Link>
</Button>
)}
</div>
);
}

View File

@ -0,0 +1,170 @@
import { Trans } from '@lingui/react/macro';
import { TeamMemberInviteStatus } from '@prisma/client';
import { DateTime } from 'luxon';
import { Link } from 'react-router';
import { getOptionalLoaderSession } from 'server/utils/get-loader-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 { Button } from '@documenso/ui/primitives/button';
import type { Route } from './+types/team.invite.$token';
export async function loader({ params }: Route.LoaderArgs) {
const session = getOptionalLoaderSession();
const { token } = params;
if (!token) {
return {
state: 'InvalidLink',
} as const;
}
const teamMemberInvite = await prisma.teamMemberInvite.findUnique({
where: {
token,
},
});
if (!teamMemberInvite) {
return {
state: 'InvalidLink',
} as const;
}
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 {
state: 'LoginRequired',
email,
teamName: team.name,
} as const;
}
const isSessionUserTheInvitedUser = user.id === session?.user.id;
return {
state: 'Success',
email,
teamName: team.name,
isSessionUserTheInvitedUser,
} as const;
}
export default function AcceptInvitationPage({ loaderData }: Route.ComponentProps) {
const data = loaderData;
if (data.state === 'InvalidLink') {
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 to="/">
<Trans>Return</Trans>
</Link>
</Button>
</div>
</div>
);
}
if (data.state === 'LoginRequired') {
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>{data.teamName}</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 to={`/signup?email=${encodeURIComponent(data.email)}`}>
<Trans>Create account</Trans>
</Link>
</Button>
</div>
);
}
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>{data.teamName}</strong> to join their team.
</Trans>
</p>
{data.isSessionUserTheInvitedUser ? (
<Button asChild>
<Link to="/">
<Trans>Continue</Trans>
</Link>
</Button>
) : (
<Button asChild>
<Link to={`/signin?email=${encodeURIComponent(data.email)}`}>
<Trans>Continue to login</Trans>
</Link>
</Button>
)}
</div>
);
}

View File

@ -0,0 +1,176 @@
import { Trans } from '@lingui/react/macro';
import { Link } from 'react-router';
import { isTokenExpired } from '@documenso/lib/utils/token-verification';
import { prisma } from '@documenso/prisma';
import { Button } from '@documenso/ui/primitives/button';
import type { Route } from './+types/team.verify.email.$token';
export async function loader({ params }: Route.LoaderArgs) {
const { token } = params;
if (!token) {
return {
state: 'InvalidLink',
} as const;
}
const teamEmailVerification = await prisma.teamEmailVerification.findUnique({
where: {
token,
},
include: {
team: true,
},
});
if (!teamEmailVerification || isTokenExpired(teamEmailVerification.expiresAt)) {
return {
state: 'InvalidLink',
} as const;
}
if (teamEmailVerification.completed) {
return {
state: 'AlreadyCompleted',
teamName: teamEmailVerification.team.name,
} as const;
}
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 {
state: 'VerificationError',
teamName: team.name,
} as const;
}
return {
state: 'Success',
teamName: team.name,
} as const;
}
export default function VerifyTeamEmailPage({ loaderData }: Route.ComponentProps) {
const data = loaderData;
if (data.state === 'InvalidLink') {
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 to="/">
<Trans>Return</Trans>
</Link>
</Button>
</div>
</div>
);
}
if (data.state === 'AlreadyCompleted') {
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>{data.teamName}</strong>.
</Trans>
</p>
<Button asChild>
<Link to="/">
<Trans>Continue</Trans>
</Link>
</Button>
</div>
);
}
if (data.state === 'VerificationError') {
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>{data.teamName}</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>{data.teamName}</strong>.
</Trans>
</p>
<Button asChild>
<Link to="/">
<Trans>Continue</Trans>
</Link>
</Button>
</div>
);
}

View File

@ -0,0 +1,154 @@
import { Trans } from '@lingui/react/macro';
import { Link } from 'react-router';
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';
import type { Route } from './+types/team.verify.transfer.token';
export async function loader({ params }: Route.LoaderArgs) {
const { token } = params;
if (!token) {
return {
state: 'InvalidLink',
} as const;
}
const teamTransferVerification = await prisma.teamTransferVerification.findUnique({
where: {
token,
},
include: {
team: true,
},
});
if (!teamTransferVerification || isTokenExpired(teamTransferVerification.expiresAt)) {
return {
state: 'InvalidLink',
} as const;
}
if (teamTransferVerification.completed) {
return {
state: 'AlreadyCompleted',
teamName: teamTransferVerification.team.name,
} as const;
}
const { team } = teamTransferVerification;
let isTransferError = false;
try {
await transferTeamOwnership({ token });
} catch (e) {
console.error(e);
isTransferError = true;
}
if (isTransferError) {
return {
state: 'TransferError',
teamName: team.name,
} as const;
}
return {
state: 'Success',
teamName: team.name,
teamUrl: team.url,
} as const;
}
export default function VerifyTeamTransferPage({ loaderData }: Route.ComponentProps) {
const data = loaderData;
if (data.state === 'InvalidLink') {
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 to="/">
<Trans>Return</Trans>
</Link>
</Button>
</div>
</div>
);
}
if (data.state === 'AlreadyCompleted') {
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>{data.teamName}</strong>.
</Trans>
</p>
<Button asChild>
<Link to="/">
<Trans>Continue</Trans>
</Link>
</Button>
</div>
);
}
if (data.state === 'TransferError') {
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>{data.teamName}</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>{data.teamName}</strong> has been successfully transferred
to you.
</Trans>
</p>
<Button asChild>
<Link to={`/t/${data.teamUrl}/settings`}>
<Trans>Continue</Trans>
</Link>
</Button>
</div>
);
}

View File

@ -0,0 +1,37 @@
import { Trans } from '@lingui/react/macro';
import { Mails } from 'lucide-react';
import { SendConfirmationEmailForm } from '~/components/forms/send-confirmation-email';
export default function UnverifiedAccount() {
return (
<div className="w-screen max-w-lg px-4">
<div className="flex items-start">
<div className="mr-4 mt-1 hidden md:block">
<Mails className="text-primary h-10 w-10" strokeWidth={2} />
</div>
<div className="">
<h2 className="text-2xl font-bold md:text-4xl">
<Trans>Confirm email</Trans>
</h2>
<p className="text-muted-foreground mt-4">
<Trans>
To gain access to your account, please confirm your email address by clicking on the
confirmation link from your inbox.
</Trans>
</p>
<p className="text-muted-foreground mt-4">
<Trans>
If you don't find the confirmation link in your inbox, you can request a new one
below.
</Trans>
</p>
<SendConfirmationEmailForm />
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,187 @@
import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { AlertTriangle, CheckCircle2, Loader, XCircle } from 'lucide-react';
import { Link, redirect, useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import { authClient } from '@documenso/auth/client';
import { EMAIL_VERIFICATION_STATE } from '@documenso/lib/constants/email';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import type { Route } from './+types/verify-email.$token';
export const loader = ({ params }: Route.LoaderArgs) => {
const { token } = params;
if (!token) {
throw redirect('/verify-email');
}
return {
token,
};
};
export default function VerifyEmailPage({ loaderData }: Route.ComponentProps) {
const { token } = loaderData;
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const [state, setState] = useState<keyof typeof EMAIL_VERIFICATION_STATE | null>(null);
const [isLoading, setIsLoading] = useState(false);
const verifyToken = async () => {
setIsLoading(true);
try {
const response = await authClient.emailPassword.verifyEmail({
token,
});
setState(response.state);
} catch (err) {
console.error(err);
toast({
title: _(msg`Something went wrong`),
description: _(msg`We were unable to verify your email at this time.`),
});
await navigate('/verify-email');
}
setIsLoading(false);
};
useEffect(() => {
void verifyToken();
}, []);
if (isLoading || state === null) {
return (
<div className="relative">
<Loader className="text-documenso h-8 w-8 animate-spin" />
</div>
);
}
return match(state)
.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 to="/">
<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 to="/">
<Trans>Go back home</Trans>
</Link>
</Button>
</div>
</div>
</div>
))
.with(EMAIL_VERIFICATION_STATE.VERIFIED, () => (
<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>
<Button className="mt-4" asChild>
<Link to="/">
<Trans>Continue</Trans>
</Link>
</Button>
</div>
</div>
</div>
))
.with(EMAIL_VERIFICATION_STATE.ALREADY_VERIFIED, () => (
<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 already confirmed</Trans>
</h2>
<p className="text-muted-foreground mt-4">
<Trans>
Your email has already been confirmed. You can now use all features of Documenso.
</Trans>
</p>
<Button className="mt-4" asChild>
<Link to="/">
<Trans>Go back home</Trans>
</Link>
</Button>
</div>
</div>
</div>
))
.exhaustive();
}

View File

@ -0,0 +1,42 @@
import { Trans } from '@lingui/react/macro';
import { XCircle } from 'lucide-react';
import { Link } from 'react-router';
import { Button } from '@documenso/ui/primitives/button';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Verify Email');
}
export default function EmailVerificationWithoutTokenPage() {
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 to="/">
<Trans>Go back home</Trans>
</Link>
</Button>
</div>
</div>
</div>
);
}