This commit is contained in:
David Nguyen
2025-02-04 22:25:11 +11:00
parent 381a9d3fb8
commit 540cc5bfc1
35 changed files with 529 additions and 890 deletions

View File

@ -1,3 +1,4 @@
// Todo: This relies on NextJS
import { ImageResponse } from 'next/og';
import { P, match } from 'ts-pattern';

View File

@ -0,0 +1,162 @@
import { Trans } from '@lingui/macro';
import { DateTime } from 'luxon';
import { Link } from 'react-router';
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';
import type { Route } from './+types/team.decline.$token';
export async function loader({ params, context }: Route.LoaderArgs) {
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 === context.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,167 @@
import { Trans } from '@lingui/macro';
import { DateTime } from 'luxon';
import { Link } from 'react-router';
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';
import type { Route } from './+types/team.invite.$token';
export async function loader({ params, context }: Route.LoaderArgs) {
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 === context.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/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/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,189 @@
import { useEffect, useState } from 'react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
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/server-only/user/verify-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) {
console.log('hello world');
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 {
// Todo: Types and check.
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,40 @@
import { Trans } from '@lingui/macro';
import { XCircle } from 'lucide-react';
import { Link } from 'react-router';
import { Button } from '@documenso/ui/primitives/button';
export function meta() {
return [{ title: '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>
);
}