mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 08:13:56 +10:00
fix: wip
This commit is contained in:
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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();
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
123
apps/remix/app/_todo/middleware.ts
Normal file
123
apps/remix/app/_todo/middleware.ts
Normal 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' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
28
apps/remix/app/components/(dashboard)/layout/app-banner.tsx
Normal file
28
apps/remix/app/components/(dashboard)/layout/app-banner.tsx
Normal 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
|
||||||
@ -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
|
|
||||||
@ -13,6 +13,7 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
import communityCardsImage from '@documenso/assets/images/community-cards.png';
|
import communityCardsImage from '@documenso/assets/images/community-cards.png';
|
||||||
import { authClient } from '@documenso/auth/client';
|
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 { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
||||||
@ -93,7 +94,7 @@ export const SignUpForm = ({
|
|||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
// const analytics = useAnalytics(); // Todo
|
const analytics = useAnalytics();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
@ -134,11 +135,11 @@ export const SignUpForm = ({
|
|||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// analytics.capture('App: User Sign Up', {
|
analytics.capture('App: User Sign Up', {
|
||||||
// email,
|
email,
|
||||||
// timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
// custom_campaign_params: { src: utmSrc },
|
custom_campaign_params: { src: utmSrc },
|
||||||
// });
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = AppError.parseError(err);
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Links,
|
Links,
|
||||||
Meta,
|
Meta,
|
||||||
@ -20,6 +22,7 @@ import { TooltipProvider } from '@documenso/ui/primitives/tooltip';
|
|||||||
import type { Route } from './+types/root';
|
import type { Route } from './+types/root';
|
||||||
import stylesheet from './app.css?url';
|
import stylesheet from './app.css?url';
|
||||||
import { GenericErrorLayout } from './components/general/generic-error-layout';
|
import { GenericErrorLayout } from './components/general/generic-error-layout';
|
||||||
|
import { PostHogPageview } from './providers/posthog';
|
||||||
import { langCookie } from './storage/lang-cookie.server';
|
import { langCookie } from './storage/lang-cookie.server';
|
||||||
import { themeSessionResolver } from './storage/theme-session.server';
|
import { themeSessionResolver } from './storage/theme-session.server';
|
||||||
|
|
||||||
@ -41,6 +44,37 @@ export const links: Route.LinksFunction = () => [
|
|||||||
{ rel: 'stylesheet', href: stylesheet },
|
{ 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) {
|
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||||
const { getTheme } = await themeSessionResolver(request);
|
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" />
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="manifest" href="/site.webmanifest" />
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
|
<meta name="google" content="notranslate" />
|
||||||
<Meta />
|
<Meta />
|
||||||
<Links />
|
<Links />
|
||||||
<meta name="google" content="notranslate" />
|
<meta name="google" content="notranslate" />
|
||||||
{/* <PreventFlashOnWrongTheme ssrTheme={Boolean(theme)} /> */}
|
{/* <PreventFlashOnWrongTheme ssrTheme={Boolean(theme)} /> */}
|
||||||
|
|
||||||
|
<Suspense>
|
||||||
|
<PostHogPageview />
|
||||||
|
</Suspense>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@ -1,15 +1,22 @@
|
|||||||
import { Outlet, redirect } from 'react-router';
|
import { Outlet, redirect } from 'react-router';
|
||||||
|
|
||||||
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client';
|
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 { Header } from '~/components/(dashboard)/layout/header';
|
||||||
import { VerifyEmailBanner } from '~/components/(dashboard)/layout/verify-email-banner';
|
import { VerifyEmailBanner } from '~/components/(dashboard)/layout/verify-email-banner';
|
||||||
|
|
||||||
import type { Route } from './+types/_layout';
|
import type { Route } from './+types/_layout';
|
||||||
|
|
||||||
export const loader = ({ context }: Route.LoaderArgs) => {
|
export const loader = async ({ context }: Route.LoaderArgs) => {
|
||||||
const { session } = context;
|
const { session } = context;
|
||||||
|
|
||||||
|
const banner = await getSiteSettings().then((settings) =>
|
||||||
|
settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
|
||||||
|
);
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw redirect('/signin');
|
throw redirect('/signin');
|
||||||
}
|
}
|
||||||
@ -17,18 +24,18 @@ export const loader = ({ context }: Route.LoaderArgs) => {
|
|||||||
return {
|
return {
|
||||||
user: session.user,
|
user: session.user,
|
||||||
teams: session.teams,
|
teams: session.teams,
|
||||||
|
banner,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Layout({ loaderData }: Route.ComponentProps) {
|
export default function Layout({ loaderData }: Route.ComponentProps) {
|
||||||
const { user, teams } = loaderData;
|
const { user, teams, banner } = loaderData;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LimitsProvider>
|
<LimitsProvider>
|
||||||
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
|
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
|
||||||
|
|
||||||
{/* // Todo: Banner */}
|
{banner && <AppBanner banner={banner} />}
|
||||||
{/* <Banner /> */}
|
|
||||||
|
|
||||||
<Header user={user} teams={teams} />
|
<Header user={user} teams={teams} />
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Trans } from '@lingui/macro';
|
import { Trans } from '@lingui/macro';
|
||||||
import { BarChart3, FileStack, Settings, Trophy, Users, Wallet2 } from 'lucide-react';
|
import { BarChart3, FileStack, Settings, Trophy, Users, Wallet2 } from 'lucide-react';
|
||||||
import { Link, Outlet, redirect, useLocation } from 'react-router';
|
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 { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -10,7 +10,7 @@ import { Button } from '@documenso/ui/primitives/button';
|
|||||||
import type { Route } from './+types/_layout';
|
import type { Route } from './+types/_layout';
|
||||||
|
|
||||||
export function loader({ context }: Route.LoaderArgs) {
|
export function loader({ context }: Route.LoaderArgs) {
|
||||||
const { user } = getRequiredSessionContext(context);
|
const { user } = getRequiredLoaderSession(context);
|
||||||
|
|
||||||
if (!user || !isAdmin(user)) {
|
if (!user || !isAdmin(user)) {
|
||||||
throw redirect('/documents');
|
throw redirect('/documents');
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { useLingui } from '@lingui/react';
|
|||||||
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
|
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
|
||||||
import { ChevronLeft, Clock9, Users2 } from 'lucide-react';
|
import { ChevronLeft, Clock9, Users2 } from 'lucide-react';
|
||||||
import { Link, redirect } from 'react-router';
|
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 { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
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';
|
import type { Route } from './+types/$id._index';
|
||||||
|
|
||||||
export async function loader({ params, context }: Route.LoaderArgs) {
|
export async function loader({ params, context }: Route.LoaderArgs) {
|
||||||
const { user, currentTeam: team } = getRequiredSessionContext(context);
|
const { user, currentTeam: team } = getRequiredLoaderSession(context);
|
||||||
|
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { Plural, Trans } from '@lingui/macro';
|
|||||||
import { DocumentStatus as InternalDocumentStatus, TeamMemberRole } from '@prisma/client';
|
import { DocumentStatus as InternalDocumentStatus, TeamMemberRole } from '@prisma/client';
|
||||||
import { ChevronLeft, Users2 } from 'lucide-react';
|
import { ChevronLeft, Users2 } from 'lucide-react';
|
||||||
import { Link, redirect } from 'react-router';
|
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 { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
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';
|
import type { Route } from './+types/$id.edit';
|
||||||
|
|
||||||
export async function loader({ params, context }: Route.LoaderArgs) {
|
export async function loader({ params, context }: Route.LoaderArgs) {
|
||||||
const { user, currentTeam: team } = getRequiredSessionContext(context);
|
const { user, currentTeam: team } = getRequiredLoaderSession(context);
|
||||||
|
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import type { Recipient } from '@prisma/client';
|
|||||||
import { ChevronLeft } from 'lucide-react';
|
import { ChevronLeft } from 'lucide-react';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { Link, redirect } from 'react-router';
|
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 { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
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) {
|
export async function loader({ params, context }: Route.LoaderArgs) {
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
|
|
||||||
const { user, currentTeam: team } = getRequiredSessionContext(context);
|
const { user, currentTeam: team } = getRequiredLoaderSession(context);
|
||||||
|
|
||||||
const documentId = Number(id);
|
const documentId = Number(id);
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { Trans, msg } from '@lingui/macro';
|
|||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import type { TemplateDirectLink } from '@prisma/client';
|
import type { TemplateDirectLink } from '@prisma/client';
|
||||||
import { TemplateType } 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 { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { getUserPublicProfile } from '@documenso/lib/server-only/user/get-user-public-profile';
|
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) {
|
export async function loader({ context }: Route.LoaderArgs) {
|
||||||
const { user } = getRequiredSessionContext(context);
|
const { user } = getRequiredLoaderSession(context);
|
||||||
|
|
||||||
const { profile } = await getUserPublicProfile({
|
const { profile } = await getUserPublicProfile({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Trans } from '@lingui/macro';
|
import { Trans } from '@lingui/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { DateTime } from 'luxon';
|
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 { getUserTokens } from '@documenso/lib/server-only/public-api/get-all-user-tokens';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -12,7 +12,7 @@ import { ApiTokenForm } from '~/components/forms/token';
|
|||||||
import type { Route } from './+types/index';
|
import type { Route } from './+types/index';
|
||||||
|
|
||||||
export async function loader({ context }: Route.LoaderArgs) {
|
export async function loader({ context }: Route.LoaderArgs) {
|
||||||
const { user } = getRequiredSessionContext(context);
|
const { user } = getRequiredLoaderSession(context);
|
||||||
|
|
||||||
// Todo: Use TRPC & use table instead
|
// Todo: Use TRPC & use table instead
|
||||||
const tokens = await getUserTokens({ userId: user.id });
|
const tokens = await getUserTokens({ userId: user.id });
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { Trans, msg } from '@lingui/macro';
|
|||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { ChevronLeft } from 'lucide-react';
|
import { ChevronLeft } from 'lucide-react';
|
||||||
import { Link, Outlet, isRouteErrorResponse, redirect, useNavigate } from 'react-router';
|
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 { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
@ -15,7 +15,7 @@ import { TeamProvider } from '~/providers/team';
|
|||||||
import type { Route } from './+types/_layout';
|
import type { Route } from './+types/_layout';
|
||||||
|
|
||||||
export const loader = ({ context }: Route.LoaderArgs) => {
|
export const loader = ({ context }: Route.LoaderArgs) => {
|
||||||
const { currentTeam } = getRequiredSessionContext(context);
|
const { currentTeam } = getRequiredLoaderSession(context);
|
||||||
|
|
||||||
if (!currentTeam) {
|
if (!currentTeam) {
|
||||||
throw redirect('/documents');
|
throw redirect('/documents');
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { Trans } from '@lingui/macro';
|
import { Trans } from '@lingui/macro';
|
||||||
import { Outlet } from 'react-router';
|
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';
|
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';
|
import type { Route } from '../+types/_layout';
|
||||||
|
|
||||||
export async function loader({ context }: Route.LoaderArgs) {
|
export function loader({ context }: Route.LoaderArgs) {
|
||||||
const { currentTeam: team } = getRequiredTeamSessionContext(context);
|
const { currentTeam: team } = getRequiredLoaderTeamSession(context);
|
||||||
|
|
||||||
// Todo: Test that 404 page shows up from error.
|
|
||||||
if (!team || !canExecuteTeamAction('MANAGE_TEAM', team.currentTeamMember.role)) {
|
if (!team || !canExecuteTeamAction('MANAGE_TEAM', team.currentTeamMember.role)) {
|
||||||
throw new Response(null, { status: 401 }); // Unauthorized.
|
throw new Response(null, { status: 401 }); // Unauthorized.
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Plural, Trans, msg } from '@lingui/macro';
|
import { Plural, Trans, msg } from '@lingui/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { DateTime } from 'luxon';
|
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 type Stripe from 'stripe';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
@ -16,7 +16,7 @@ import { TeamBillingPortalButton } from '~/components/general/teams/team-billing
|
|||||||
import type { Route } from './+types/billing';
|
import type { Route } from './+types/billing';
|
||||||
|
|
||||||
export async function loader({ context }: Route.LoaderArgs) {
|
export async function loader({ context }: Route.LoaderArgs) {
|
||||||
const { currentTeam: team } = getRequiredTeamSessionContext(context);
|
const { currentTeam: team } = getRequiredLoaderTeamSession(context);
|
||||||
|
|
||||||
let teamSubscription: Stripe.Subscription | null = null;
|
let teamSubscription: Stripe.Subscription | null = null;
|
||||||
|
|
||||||
|
|||||||
@ -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';
|
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';
|
import type { Route } from './+types/public-profile';
|
||||||
|
|
||||||
export async function loader({ context }: Route.LoaderArgs) {
|
export async function loader({ context }: Route.LoaderArgs) {
|
||||||
const { user, currentTeam: team } = getRequiredTeamSessionContext(context);
|
const { user, currentTeam: team } = getRequiredLoaderTeamSession(context);
|
||||||
|
|
||||||
const { profile } = await getTeamPublicProfile({
|
const { profile } = await getTeamPublicProfile({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Trans } from '@lingui/macro';
|
import { Trans } from '@lingui/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { DateTime } from 'luxon';
|
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 { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
import { getTeamTokens } from '@documenso/lib/server-only/public-api/get-all-team-tokens';
|
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';
|
import type { Route } from './+types/tokens';
|
||||||
|
|
||||||
export async function loader({ context }: Route.LoaderArgs) {
|
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);
|
const tokens = await getTeamTokens({ userId: user.id, teamId: team.id }).catch(() => null);
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { Trans } from '@lingui/macro';
|
|||||||
import { DocumentSigningOrder, SigningStatus } from '@prisma/client';
|
import { DocumentSigningOrder, SigningStatus } from '@prisma/client';
|
||||||
import { ChevronLeft, LucideEdit } from 'lucide-react';
|
import { ChevronLeft, LucideEdit } from 'lucide-react';
|
||||||
import { Link, redirect } from 'react-router';
|
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 { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
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';
|
import type { Route } from './+types/$id._index';
|
||||||
|
|
||||||
export async function loader({ params, context }: Route.LoaderArgs) {
|
export async function loader({ params, context }: Route.LoaderArgs) {
|
||||||
const { user, currentTeam: team } = getRequiredSessionContext(context);
|
const { user, currentTeam: team } = getRequiredLoaderSession(context);
|
||||||
|
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Trans } from '@lingui/macro';
|
import { Trans } from '@lingui/macro';
|
||||||
import { ChevronLeft } from 'lucide-react';
|
import { ChevronLeft } from 'lucide-react';
|
||||||
import { Link, redirect } from 'react-router';
|
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 { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||||
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
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';
|
import type { Route } from './+types/$id.edit';
|
||||||
|
|
||||||
export async function loader({ context, params }: Route.LoaderArgs) {
|
export async function loader({ context, params }: Route.LoaderArgs) {
|
||||||
const { user, currentTeam: team } = getRequiredSessionContext(context);
|
const { user, currentTeam: team } = getRequiredLoaderSession(context);
|
||||||
|
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
|
|
||||||
|
|||||||
@ -16,12 +16,6 @@ import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
|||||||
|
|
||||||
import type { Route } from './+types/_index';
|
import type { Route } from './+types/_index';
|
||||||
|
|
||||||
export type TemplatesDirectPageProps = {
|
|
||||||
params: {
|
|
||||||
token: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function loader({ params, context }: Route.LoaderArgs) {
|
export async function loader({ params, context }: Route.LoaderArgs) {
|
||||||
const { token } = params;
|
const { token } = params;
|
||||||
|
|
||||||
|
|||||||
@ -2,12 +2,6 @@ import { Outlet } from 'react-router';
|
|||||||
|
|
||||||
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
|
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() {
|
export default function Layout() {
|
||||||
return (
|
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">
|
<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">
|
||||||
|
|||||||
@ -17,7 +17,7 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||||||
const isValid = await getResetTokenValidity({ token });
|
const isValid = await getResetTokenValidity({ token });
|
||||||
|
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
redirect('/reset-password');
|
throw redirect('/reset-password');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
39
apps/remix/app/routes/_unauthenticated+/share.$slug.tsx
Normal file
39
apps/remix/app/routes/_unauthenticated+/share.$slug.tsx
Normal 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());
|
||||||
|
};
|
||||||
73
apps/remix/app/routes/api+/branding.logo.team.$teamId.ts
Normal file
73
apps/remix/app/routes/api+/branding.logo.team.$teamId.ts
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
22
apps/remix/app/routes/api+/health.ts
Normal file
22
apps/remix/app/routes/api+/health.ts
Normal 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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
apps/remix/app/routes/api+/share.ts
Normal file
27
apps/remix/app/routes/api+/share.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
11
apps/remix/app/routes/api+/stripe.webhook.ts
Normal file
11
apps/remix/app/routes/api+/stripe.webhook.ts
Normal 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);
|
||||||
|
}
|
||||||
17
apps/remix/app/routes/api+/webhook.trigger.ts
Normal file
17
apps/remix/app/routes/api+/webhook.trigger.ts
Normal 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);
|
||||||
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { data } from 'react-router';
|
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 { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
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({
|
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||||
documentAuth: template.authOptions,
|
documentAuth: template.authOptions,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { data } from 'react-router';
|
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 { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
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 token = params.url;
|
||||||
|
|
||||||
const { user } = getRequiredSessionContext(context);
|
const { user } = getRequiredLoaderSession(context);
|
||||||
|
|
||||||
const [document, fields, recipient] = await Promise.all([
|
const [document, fields, recipient] = await Promise.all([
|
||||||
getDocumentAndSenderByToken({
|
getDocumentAndSenderByToken({
|
||||||
|
|||||||
BIN
apps/remix/example/cert.p12
Normal file
BIN
apps/remix/example/cert.p12
Normal file
Binary file not shown.
@ -30,6 +30,8 @@
|
|||||||
"hono-react-router-adapter": "^0.6.2",
|
"hono-react-router-adapter": "^0.6.2",
|
||||||
"isbot": "^5.1.17",
|
"isbot": "^5.1.17",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"posthog-js": "^1.75.3",
|
||||||
|
"posthog-node": "^3.1.1",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
"react-router": "^7.1.3",
|
"react-router": "^7.1.3",
|
||||||
|
|||||||
BIN
apps/remix/public/static/og-share-frame2.png
Normal file
BIN
apps/remix/public/static/og-share-frame2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 458 KiB |
@ -2,12 +2,15 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { PDFDocument } from 'pdf-lib';
|
import { PDFDocument } from 'pdf-lib';
|
||||||
|
|
||||||
|
import { tsRestHonoApp } from '@documenso/api/hono';
|
||||||
import { auth } from '@documenso/auth/server';
|
import { auth } from '@documenso/auth/server';
|
||||||
|
import { API_V2_BETA_URL } from '@documenso/lib/constants/app';
|
||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
import { jobsClient } from '@documenso/lib/jobs/client';
|
import { jobsClient } from '@documenso/lib/jobs/client';
|
||||||
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
||||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||||
import { getPresignGetUrl } from '@documenso/lib/universal/upload/server-actions';
|
import { getPresignGetUrl } from '@documenso/lib/universal/upload/server-actions';
|
||||||
|
import { openApiDocument } from '@documenso/trpc/server/open-api';
|
||||||
|
|
||||||
import { openApiTrpcServerHandler } from './trpc/hono-trpc-open-api';
|
import { openApiTrpcServerHandler } from './trpc/hono-trpc-open-api';
|
||||||
import { reactRouterTrpcServer } from './trpc/hono-trpc-remix';
|
import { reactRouterTrpcServer } from './trpc/hono-trpc-remix';
|
||||||
@ -18,11 +21,14 @@ const app = new Hono();
|
|||||||
app.route('/api/auth', auth);
|
app.route('/api/auth', auth);
|
||||||
|
|
||||||
// API servers. Todo: Configure max durations, etc?
|
// API servers. Todo: Configure max durations, etc?
|
||||||
|
app.route('/api/v1', tsRestHonoApp);
|
||||||
app.use('/api/jobs/*', jobsClient.getHonoApiHandler());
|
app.use('/api/jobs/*', jobsClient.getHonoApiHandler());
|
||||||
app.use('/api/v1/*', reactRouterTrpcServer); // Todo: ts-rest
|
|
||||||
app.use('/api/v2/*', async (c) => openApiTrpcServerHandler(c));
|
|
||||||
app.use('/api/trpc/*', reactRouterTrpcServer);
|
app.use('/api/trpc/*', reactRouterTrpcServer);
|
||||||
|
|
||||||
|
// Unstable API server routes. Order matters for these two.
|
||||||
|
app.get(`${API_V2_BETA_URL}/openapi.json`, (c) => c.json(openApiDocument));
|
||||||
|
app.use(`${API_V2_BETA_URL}/*`, async (c) => openApiTrpcServerHandler(c));
|
||||||
|
|
||||||
// Temp uploader.
|
// Temp uploader.
|
||||||
app
|
app
|
||||||
.post('/api/file', async (c) => {
|
.post('/api/file', async (c) => {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import type { Context } from 'hono';
|
import type { Context } from 'hono';
|
||||||
import { createOpenApiFetchHandler } from 'trpc-to-openapi';
|
import { createOpenApiFetchHandler } from 'trpc-to-openapi';
|
||||||
|
|
||||||
|
import { API_V2_BETA_URL } from '@documenso/lib/constants/app';
|
||||||
import { AppError, genericErrorCodeToTrpcErrorCodeMap } from '@documenso/lib/errors/app-error';
|
import { AppError, genericErrorCodeToTrpcErrorCodeMap } from '@documenso/lib/errors/app-error';
|
||||||
import { appRouter } from '@documenso/trpc/server/router';
|
import { appRouter } from '@documenso/trpc/server/router';
|
||||||
import { handleTrpcRouterError } from '@documenso/trpc/utils/trpc-error-handler';
|
import { handleTrpcRouterError } from '@documenso/trpc/utils/trpc-error-handler';
|
||||||
@ -9,7 +10,7 @@ import { createHonoTrpcContext } from './trpc-context';
|
|||||||
|
|
||||||
export const openApiTrpcServerHandler = async (c: Context) => {
|
export const openApiTrpcServerHandler = async (c: Context) => {
|
||||||
return createOpenApiFetchHandler<typeof appRouter>({
|
return createOpenApiFetchHandler<typeof appRouter>({
|
||||||
endpoint: '/v2/api',
|
endpoint: API_V2_BETA_URL,
|
||||||
router: appRouter,
|
router: appRouter,
|
||||||
// Todo: Test this, since it's not using the createContext params.
|
// Todo: Test this, since it's not using the createContext params.
|
||||||
createContext: async () => createHonoTrpcContext({ c, requestSource: 'apiV2' }),
|
createContext: async () => createHonoTrpcContext({ c, requestSource: 'apiV2' }),
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { handleTrpcRouterError } from '@documenso/trpc/utils/trpc-error-handler'
|
|||||||
|
|
||||||
import { createHonoTrpcContext } from './trpc-context';
|
import { createHonoTrpcContext } from './trpc-context';
|
||||||
|
|
||||||
|
// Todo
|
||||||
// export const config = {
|
// export const config = {
|
||||||
// maxDuration: 120,
|
// maxDuration: 120,
|
||||||
// api: {
|
// api: {
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { redirect } from 'react-router';
|
|||||||
/**
|
/**
|
||||||
* Returns the session context or throws a redirect to signin if it is not present.
|
* Returns the session context or throws a redirect to signin if it is not present.
|
||||||
*/
|
*/
|
||||||
export const getRequiredSessionContext = (context: AppLoadContext) => {
|
export const getRequiredLoaderSession = (context: AppLoadContext) => {
|
||||||
if (!context.session) {
|
if (!context.session) {
|
||||||
throw redirect('/signin'); // Todo: Maybe add a redirect cookie to come back?
|
throw redirect('/signin'); // Todo: Maybe add a redirect cookie to come back?
|
||||||
}
|
}
|
||||||
@ -15,7 +15,7 @@ export const getRequiredSessionContext = (context: AppLoadContext) => {
|
|||||||
/**
|
/**
|
||||||
* Returns the team session context or throws a redirect to signin if it is not present.
|
* Returns the team session context or throws a redirect to signin if it is not present.
|
||||||
*/
|
*/
|
||||||
export const getRequiredTeamSessionContext = (context: AppLoadContext) => {
|
export const getRequiredLoaderTeamSession = (context: AppLoadContext) => {
|
||||||
if (!context.session) {
|
if (!context.session) {
|
||||||
throw redirect('/signin'); // Todo: Maybe add a redirect cookie to come back?
|
throw redirect('/signin'); // Todo: Maybe add a redirect cookie to come back?
|
||||||
}
|
}
|
||||||
|
|||||||
74
package-lock.json
generated
74
package-lock.json
generated
@ -126,6 +126,8 @@
|
|||||||
"hono-react-router-adapter": "^0.6.2",
|
"hono-react-router-adapter": "^0.6.2",
|
||||||
"isbot": "^5.1.17",
|
"isbot": "^5.1.17",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"posthog-js": "^1.75.3",
|
||||||
|
"posthog-node": "^3.1.1",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
"react-router": "^7.1.3",
|
"react-router": "^7.1.3",
|
||||||
@ -22490,6 +22492,12 @@
|
|||||||
"set-function-name": "^2.0.1"
|
"set-function-name": "^2.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/itty-router": {
|
||||||
|
"version": "5.0.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/itty-router/-/itty-router-5.0.18.tgz",
|
||||||
|
"integrity": "sha512-mK3ReOt4ARAGy0V0J7uHmArG2USN2x0zprZ+u+YgmeRjXTDbaowDy3kPcsmQY6tH+uHhDgpWit9Vqmv/4rTXwA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/jackspeak": {
|
"node_modules/jackspeak": {
|
||||||
"version": "2.3.6",
|
"version": "2.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz",
|
||||||
@ -37703,8 +37711,8 @@
|
|||||||
"@documenso/lib": "*",
|
"@documenso/lib": "*",
|
||||||
"@documenso/prisma": "*",
|
"@documenso/prisma": "*",
|
||||||
"@ts-rest/core": "^3.30.5",
|
"@ts-rest/core": "^3.30.5",
|
||||||
"@ts-rest/next": "^3.30.5",
|
|
||||||
"@ts-rest/open-api": "^3.33.0",
|
"@ts-rest/open-api": "^3.33.0",
|
||||||
|
"@ts-rest/serverless": "^3.51.0",
|
||||||
"@types/swagger-ui-react": "^4.18.3",
|
"@types/swagger-ui-react": "^4.18.3",
|
||||||
"luxon": "^3.4.0",
|
"luxon": "^3.4.0",
|
||||||
"superjson": "^1.13.1",
|
"superjson": "^1.13.1",
|
||||||
@ -37713,21 +37721,73 @@
|
|||||||
"zod": "3.24.1"
|
"zod": "3.24.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/api/node_modules/@ts-rest/next": {
|
"packages/api/node_modules/@ts-rest/core": {
|
||||||
"version": "3.30.5",
|
"version": "3.51.0",
|
||||||
"resolved": "https://registry.npmjs.org/@ts-rest/next/-/next-3.30.5.tgz",
|
"resolved": "https://registry.npmjs.org/@ts-rest/core/-/core-3.51.0.tgz",
|
||||||
"integrity": "sha512-NasfUN7SnwcjJNbxvvcemC4fOv4f4IF5I14wVqQODN0HWPokkrta6XLuv0eKQJYdB32AS7VINQhls8Sj1AIN0g==",
|
"integrity": "sha512-v6lnWEcpZj1UgN9wb84XQ+EORP1QEtncFumoXMJjno5ZUV6vdjKze3MYcQN0C6vjBpIJPQEaI/gab2jr4/0KzQ==",
|
||||||
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@ts-rest/core": "3.30.5",
|
"@types/node": "^18.18.7 || >=20.8.4",
|
||||||
"next": "^12.0.0 || ^13.0.0",
|
|
||||||
"zod": "^3.22.3"
|
"zod": "^3.22.3"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"zod": {
|
"zod": {
|
||||||
"optional": true
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packages/api/node_modules/@ts-rest/serverless": {
|
||||||
|
"version": "3.51.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ts-rest/serverless/-/serverless-3.51.0.tgz",
|
||||||
|
"integrity": "sha512-BjwmLPgnYifdDjSpSvhZk+v1P+3CiM/jpxKNUgdw8RfgnDy/+aaOPmAcSkjhBCOIu6ASChuv/sNpiuWx3YyPUw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"itty-router": "^5.0.9"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@azure/functions": "^4.0.0",
|
||||||
|
"@ts-rest/core": "~3.51.0",
|
||||||
|
"@types/aws-lambda": "^8.10.115",
|
||||||
|
"next": "^12.0.0 || ^13.0.0 || ^14.0.0",
|
||||||
|
"zod": "^3.22.3"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@azure/functions": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/aws-lambda": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"next": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"zod": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"packages/api/node_modules/@types/node": {
|
||||||
|
"version": "22.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.0.tgz",
|
||||||
|
"integrity": "sha512-ClIbNe36lawluuvq3+YYhnIN2CELi+6q8NpnM7PYp4hBn/TatfboPgVSm2rwKRfnV2M+Ty9GWDFI64KEe+kysA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~6.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"packages/api/node_modules/undici-types": {
|
||||||
|
"version": "6.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
|
||||||
|
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"packages/app-tests": {
|
"packages/app-tests": {
|
||||||
"name": "@documenso/app-tests",
|
"name": "@documenso/app-tests",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
|
|||||||
35
packages/api/hono.ts
Normal file
35
packages/api/hono.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { fetchRequestHandler } from '@ts-rest/serverless/fetch';
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
|
||||||
|
import { ApiContractV1 } from '@documenso/api/v1/contract';
|
||||||
|
import { ApiContractV1Implementation } from '@documenso/api/v1/implementation';
|
||||||
|
import { OpenAPIV1 } from '@documenso/api/v1/openapi';
|
||||||
|
import { testCredentialsHandler } from '@documenso/lib/server-only/public-api/test-credentials';
|
||||||
|
import { listDocumentsHandler } from '@documenso/lib/server-only/webhooks/zapier/list-documents';
|
||||||
|
import { subscribeHandler } from '@documenso/lib/server-only/webhooks/zapier/subscribe';
|
||||||
|
import { unsubscribeHandler } from '@documenso/lib/server-only/webhooks/zapier/unsubscribe';
|
||||||
|
|
||||||
|
// This is bad, ts-router will be created on each request.
|
||||||
|
// But don't really have a choice here.
|
||||||
|
export const tsRestHonoApp = new Hono();
|
||||||
|
|
||||||
|
tsRestHonoApp
|
||||||
|
.get('/openapi', (c) => c.redirect('https://openapi-v1.documenso.com'))
|
||||||
|
.get('/openapi.json', (c) => c.json(OpenAPIV1))
|
||||||
|
.get('/me', async (c) => testCredentialsHandler(c.req.raw));
|
||||||
|
|
||||||
|
// Zapier. Todo: Check methods. Are these get/post/update requests?
|
||||||
|
// Todo: Is there really no validations?
|
||||||
|
tsRestHonoApp
|
||||||
|
.all('/zapier/list-documents', async (c) => listDocumentsHandler(c.req.raw))
|
||||||
|
.all('/zapier/subscribe', async (c) => subscribeHandler(c.req.raw))
|
||||||
|
.all('/zapier/unsubscribe', async (c) => unsubscribeHandler(c.req.raw));
|
||||||
|
|
||||||
|
tsRestHonoApp.mount('/', async (request) => {
|
||||||
|
return fetchRequestHandler({
|
||||||
|
request,
|
||||||
|
contract: ApiContractV1,
|
||||||
|
router: ApiContractV1Implementation,
|
||||||
|
options: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -18,8 +18,8 @@
|
|||||||
"@documenso/lib": "*",
|
"@documenso/lib": "*",
|
||||||
"@documenso/prisma": "*",
|
"@documenso/prisma": "*",
|
||||||
"@ts-rest/core": "^3.30.5",
|
"@ts-rest/core": "^3.30.5",
|
||||||
"@ts-rest/next": "^3.30.5",
|
|
||||||
"@ts-rest/open-api": "^3.33.0",
|
"@ts-rest/open-api": "^3.33.0",
|
||||||
|
"@ts-rest/serverless": "^3.51.0",
|
||||||
"@types/swagger-ui-react": "^4.18.3",
|
"@types/swagger-ui-react": "^4.18.3",
|
||||||
"luxon": "^3.4.0",
|
"luxon": "^3.4.0",
|
||||||
"superjson": "^1.13.1",
|
"superjson": "^1.13.1",
|
||||||
@ -27,4 +27,4 @@
|
|||||||
"ts-pattern": "^5.0.5",
|
"ts-pattern": "^5.0.5",
|
||||||
"zod": "3.24.1"
|
"zod": "3.24.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { createNextRoute } from '@ts-rest/next';
|
import { tsr } from '@ts-rest/serverless/fetch';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
|
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
|
||||||
@ -42,7 +42,6 @@ import {
|
|||||||
ZRadioFieldMeta,
|
ZRadioFieldMeta,
|
||||||
ZTextFieldMeta,
|
ZTextFieldMeta,
|
||||||
} from '@documenso/lib/types/field-meta';
|
} from '@documenso/lib/types/field-meta';
|
||||||
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
|
||||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||||
import {
|
import {
|
||||||
@ -62,7 +61,7 @@ import {
|
|||||||
import { ApiContractV1 } from './contract';
|
import { ApiContractV1 } from './contract';
|
||||||
import { authenticatedMiddleware } from './middleware/authenticated';
|
import { authenticatedMiddleware } from './middleware/authenticated';
|
||||||
|
|
||||||
export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
||||||
getDocuments: authenticatedMiddleware(async (args, user, team) => {
|
getDocuments: authenticatedMiddleware(async (args, user, team) => {
|
||||||
const page = Number(args.query.page) || 1;
|
const page = Number(args.query.page) || 1;
|
||||||
const perPage = Number(args.query.perPage) || 10;
|
const perPage = Number(args.query.perPage) || 10;
|
||||||
@ -849,7 +848,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
updateRecipient: authenticatedMiddleware(async (args, user, team) => {
|
updateRecipient: authenticatedMiddleware(async (args, user, team, { metadata }) => {
|
||||||
const { id: documentId, recipientId } = args.params;
|
const { id: documentId, recipientId } = args.params;
|
||||||
const { name, email, role, authOptions, signingOrder } = args.body;
|
const { name, email, role, authOptions, signingOrder } = args.body;
|
||||||
|
|
||||||
@ -887,7 +886,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
|||||||
role,
|
role,
|
||||||
signingOrder,
|
signingOrder,
|
||||||
actionAuth: authOptions?.actionAuth,
|
actionAuth: authOptions?.actionAuth,
|
||||||
requestMetadata: extractNextApiRequestMetadata(args.req),
|
requestMetadata: metadata.requestMetadata,
|
||||||
}).catch(() => null);
|
}).catch(() => null);
|
||||||
|
|
||||||
if (!updatedRecipient) {
|
if (!updatedRecipient) {
|
||||||
@ -909,7 +908,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
deleteRecipient: authenticatedMiddleware(async (args, user, team) => {
|
deleteRecipient: authenticatedMiddleware(async (args, user, team, { metadata }) => {
|
||||||
const { id: documentId, recipientId } = args.params;
|
const { id: documentId, recipientId } = args.params;
|
||||||
|
|
||||||
const document = await getDocumentById({
|
const document = await getDocumentById({
|
||||||
@ -941,7 +940,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
|||||||
recipientId: Number(recipientId),
|
recipientId: Number(recipientId),
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
teamId: team?.id,
|
teamId: team?.id,
|
||||||
requestMetadata: extractNextApiRequestMetadata(args.req),
|
requestMetadata: metadata.requestMetadata,
|
||||||
}).catch(() => null);
|
}).catch(() => null);
|
||||||
|
|
||||||
if (!deletedRecipient) {
|
if (!deletedRecipient) {
|
||||||
@ -963,7 +962,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
createField: authenticatedMiddleware(async (args, user, team) => {
|
createField: authenticatedMiddleware(async (args, user, team, { metadata }) => {
|
||||||
const { id: documentId } = args.params;
|
const { id: documentId } = args.params;
|
||||||
const fields = Array.isArray(args.body) ? args.body : [args.body];
|
const fields = Array.isArray(args.body) ? args.body : [args.body];
|
||||||
|
|
||||||
@ -1100,7 +1099,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
|||||||
fieldRecipientId: recipientId,
|
fieldRecipientId: recipientId,
|
||||||
fieldType: field.type,
|
fieldType: field.type,
|
||||||
},
|
},
|
||||||
requestMetadata: extractNextApiRequestMetadata(args.req),
|
requestMetadata: metadata.requestMetadata,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1134,7 +1133,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
updateField: authenticatedMiddleware(async (args, user, team) => {
|
updateField: authenticatedMiddleware(async (args, user, team, { metadata }) => {
|
||||||
const { id: documentId, fieldId } = args.params;
|
const { id: documentId, fieldId } = args.params;
|
||||||
const { recipientId, type, pageNumber, pageWidth, pageHeight, pageX, pageY, fieldMeta } =
|
const { recipientId, type, pageNumber, pageWidth, pageHeight, pageX, pageY, fieldMeta } =
|
||||||
args.body;
|
args.body;
|
||||||
@ -1198,7 +1197,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
|||||||
pageY,
|
pageY,
|
||||||
pageWidth,
|
pageWidth,
|
||||||
pageHeight,
|
pageHeight,
|
||||||
requestMetadata: extractNextApiRequestMetadata(args.req),
|
requestMetadata: metadata.requestMetadata,
|
||||||
fieldMeta: fieldMeta ? ZFieldMetaSchema.parse(fieldMeta) : undefined,
|
fieldMeta: fieldMeta ? ZFieldMetaSchema.parse(fieldMeta) : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1225,7 +1224,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
deleteField: authenticatedMiddleware(async (args, user, team) => {
|
deleteField: authenticatedMiddleware(async (args, user, team, { metadata }) => {
|
||||||
const { id: documentId, fieldId } = args.params;
|
const { id: documentId, fieldId } = args.params;
|
||||||
|
|
||||||
const document = await getDocumentById({
|
const document = await getDocumentById({
|
||||||
@ -1286,7 +1285,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
|||||||
fieldId: Number(fieldId),
|
fieldId: Number(fieldId),
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
teamId: team?.id,
|
teamId: team?.id,
|
||||||
requestMetadata: extractNextApiRequestMetadata(args.req),
|
requestMetadata: metadata.requestMetadata,
|
||||||
}).catch(() => null);
|
}).catch(() => null);
|
||||||
|
|
||||||
if (!deletedField) {
|
if (!deletedField) {
|
||||||
|
|||||||
@ -1,14 +1,22 @@
|
|||||||
import type { NextApiRequest } from 'next';
|
import type { TsRestRequest } from '@ts-rest/serverless';
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { getApiTokenByToken } from '@documenso/lib/server-only/public-api/get-api-token-by-token';
|
import { getApiTokenByToken } from '@documenso/lib/server-only/public-api/get-api-token-by-token';
|
||||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
import { extractRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
import type { Team, User } from '@documenso/prisma/client';
|
import type { Team, User } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
type B = {
|
||||||
|
// appRoute: any;
|
||||||
|
request: TsRestRequest;
|
||||||
|
responseHeaders: Headers;
|
||||||
|
};
|
||||||
|
|
||||||
export const authenticatedMiddleware = <
|
export const authenticatedMiddleware = <
|
||||||
T extends {
|
T extends {
|
||||||
req: NextApiRequest;
|
headers: {
|
||||||
|
authorization: string;
|
||||||
|
};
|
||||||
},
|
},
|
||||||
R extends {
|
R extends {
|
||||||
status: number;
|
status: number;
|
||||||
@ -16,15 +24,15 @@ export const authenticatedMiddleware = <
|
|||||||
},
|
},
|
||||||
>(
|
>(
|
||||||
handler: (
|
handler: (
|
||||||
args: T,
|
args: T & { req: TsRestRequest },
|
||||||
user: User,
|
user: User,
|
||||||
team: Team | null | undefined,
|
team: Team | null | undefined,
|
||||||
options: { metadata: ApiRequestMetadata },
|
options: { metadata: ApiRequestMetadata },
|
||||||
) => Promise<R>,
|
) => Promise<R>,
|
||||||
) => {
|
) => {
|
||||||
return async (args: T) => {
|
return async (args: T, { request }: B) => {
|
||||||
try {
|
try {
|
||||||
const { authorization } = args.req.headers;
|
const { authorization } = args.headers;
|
||||||
|
|
||||||
// Support for both "Authorization: Bearer api_xxx" and "Authorization: api_xxx"
|
// Support for both "Authorization: Bearer api_xxx" and "Authorization: api_xxx"
|
||||||
const [token] = (authorization || '').split('Bearer ').filter((s) => s.length > 0);
|
const [token] = (authorization || '').split('Bearer ').filter((s) => s.length > 0);
|
||||||
@ -44,7 +52,7 @@ export const authenticatedMiddleware = <
|
|||||||
}
|
}
|
||||||
|
|
||||||
const metadata: ApiRequestMetadata = {
|
const metadata: ApiRequestMetadata = {
|
||||||
requestMetadata: extractNextApiRequestMetadata(args.req),
|
requestMetadata: extractRequestMetadata(request), // Todo: Test
|
||||||
source: 'apiV1',
|
source: 'apiV1',
|
||||||
auth: 'api',
|
auth: 'api',
|
||||||
auditUser: {
|
auditUser: {
|
||||||
@ -54,7 +62,15 @@ export const authenticatedMiddleware = <
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return await handler(args, apiToken.user, apiToken.team, { metadata });
|
return await handler(
|
||||||
|
{
|
||||||
|
...args,
|
||||||
|
req: request,
|
||||||
|
},
|
||||||
|
apiToken.user,
|
||||||
|
apiToken.team,
|
||||||
|
{ metadata },
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log({ err: err });
|
console.log({ err: err });
|
||||||
|
|
||||||
|
|||||||
@ -1,13 +1,9 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
|
||||||
|
|
||||||
import { buffer } from 'micro';
|
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||||
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
||||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||||
import { createTeamFromPendingTeam } from '@documenso/lib/server-only/team/create-team';
|
import { createTeamFromPendingTeam } from '@documenso/lib/server-only/team/create-team';
|
||||||
import { getFlag } from '@documenso/lib/universal/get-feature-flag';
|
|
||||||
import { env } from '@documenso/lib/utils/env';
|
import { env } from '@documenso/lib/utils/env';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
@ -19,37 +15,52 @@ type StripeWebhookResponse = {
|
|||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const stripeWebhookHandler = async (
|
export const stripeWebhookHandler = async (req: Request) => {
|
||||||
req: NextApiRequest,
|
|
||||||
res: NextApiResponse<StripeWebhookResponse>,
|
|
||||||
) => {
|
|
||||||
try {
|
try {
|
||||||
const isBillingEnabled = await getFlag('app_billing');
|
// Todo
|
||||||
|
// const isBillingEnabled = await getFlag('app_billing');
|
||||||
|
const isBillingEnabled = true;
|
||||||
|
|
||||||
|
const webhookSecret = env('NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET');
|
||||||
|
|
||||||
|
if (!webhookSecret) {
|
||||||
|
throw new Error('Missing Stripe webhook secret');
|
||||||
|
}
|
||||||
|
|
||||||
if (!isBillingEnabled) {
|
if (!isBillingEnabled) {
|
||||||
return res.status(500).json({
|
return Response.json(
|
||||||
success: false,
|
{
|
||||||
message: 'Billing is disabled',
|
success: false,
|
||||||
});
|
message: 'Billing is disabled',
|
||||||
|
} satisfies StripeWebhookResponse,
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const signature =
|
const signature =
|
||||||
typeof req.headers['stripe-signature'] === 'string' ? req.headers['stripe-signature'] : '';
|
typeof req.headers.get('stripe-signature') === 'string'
|
||||||
|
? req.headers.get('stripe-signature')
|
||||||
|
: '';
|
||||||
|
|
||||||
if (!signature) {
|
if (!signature) {
|
||||||
return res.status(400).json({
|
return Response.json(
|
||||||
success: false,
|
{
|
||||||
message: 'No signature found in request',
|
success: false,
|
||||||
});
|
message: 'No signature found in request',
|
||||||
|
} satisfies StripeWebhookResponse,
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await buffer(req);
|
// Todo: I'm not sure about this.
|
||||||
|
const clonedReq = req.clone();
|
||||||
|
const rawBody = await clonedReq.arrayBuffer();
|
||||||
|
const body = Buffer.from(rawBody);
|
||||||
|
|
||||||
const event = stripe.webhooks.constructEvent(
|
// It was this:
|
||||||
body,
|
// const body = await buffer(req);
|
||||||
signature,
|
|
||||||
env('NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET'), // Todo: Test
|
const event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
|
||||||
);
|
|
||||||
|
|
||||||
await match(event.type)
|
await match(event.type)
|
||||||
.with('checkout.session.completed', async () => {
|
.with('checkout.session.completed', async () => {
|
||||||
@ -93,10 +104,10 @@ export const stripeWebhookHandler = async (
|
|||||||
: session.subscription?.id;
|
: session.subscription?.id;
|
||||||
|
|
||||||
if (!subscriptionId) {
|
if (!subscriptionId) {
|
||||||
return res.status(500).json({
|
return Response.json(
|
||||||
success: false,
|
{ success: false, message: 'Invalid session' } satisfies StripeWebhookResponse,
|
||||||
message: 'Invalid session',
|
{ status: 500 },
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||||
@ -105,26 +116,29 @@ export const stripeWebhookHandler = async (
|
|||||||
if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) {
|
if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) {
|
||||||
await handleTeamSeatCheckout({ subscription });
|
await handleTeamSeatCheckout({ subscription });
|
||||||
|
|
||||||
return res.status(200).json({
|
return Response.json(
|
||||||
success: true,
|
{ success: true, message: 'Webhook received' } satisfies StripeWebhookResponse,
|
||||||
message: 'Webhook received',
|
{ status: 200 },
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate user ID.
|
// Validate user ID.
|
||||||
if (!userId || Number.isNaN(userId)) {
|
if (!userId || Number.isNaN(userId)) {
|
||||||
return res.status(500).json({
|
return Response.json(
|
||||||
success: false,
|
{
|
||||||
message: 'Invalid session or missing user ID',
|
success: false,
|
||||||
});
|
message: 'Invalid session or missing user ID',
|
||||||
|
} satisfies StripeWebhookResponse,
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await onSubscriptionUpdated({ userId, subscription });
|
await onSubscriptionUpdated({ userId, subscription });
|
||||||
|
|
||||||
return res.status(200).json({
|
return Response.json(
|
||||||
success: true,
|
{ success: true, message: 'Webhook received' } satisfies StripeWebhookResponse,
|
||||||
message: 'Webhook received',
|
{ status: 200 },
|
||||||
});
|
);
|
||||||
})
|
})
|
||||||
.with('customer.subscription.updated', async () => {
|
.with('customer.subscription.updated', async () => {
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
@ -143,18 +157,21 @@ export const stripeWebhookHandler = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!team) {
|
if (!team) {
|
||||||
return res.status(500).json({
|
return Response.json(
|
||||||
success: false,
|
{
|
||||||
message: 'No team associated with subscription found',
|
success: false,
|
||||||
});
|
message: 'No team associated with subscription found',
|
||||||
|
} satisfies StripeWebhookResponse,
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await onSubscriptionUpdated({ teamId: team.id, subscription });
|
await onSubscriptionUpdated({ teamId: team.id, subscription });
|
||||||
|
|
||||||
return res.status(200).json({
|
return Response.json(
|
||||||
success: true,
|
{ success: true, message: 'Webhook received' } satisfies StripeWebhookResponse,
|
||||||
message: 'Webhook received',
|
{ status: 200 },
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await prisma.user.findFirst({
|
const result = await prisma.user.findFirst({
|
||||||
@ -167,28 +184,37 @@ export const stripeWebhookHandler = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!result?.id) {
|
if (!result?.id) {
|
||||||
return res.status(500).json({
|
return Response.json(
|
||||||
success: false,
|
{
|
||||||
message: 'User not found',
|
success: false,
|
||||||
});
|
message: 'User not found',
|
||||||
|
} satisfies StripeWebhookResponse,
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await onSubscriptionUpdated({ userId: result.id, subscription });
|
await onSubscriptionUpdated({ userId: result.id, subscription });
|
||||||
|
|
||||||
return res.status(200).json({
|
return Response.json(
|
||||||
success: true,
|
{
|
||||||
message: 'Webhook received',
|
success: true,
|
||||||
});
|
message: 'Webhook received',
|
||||||
|
} satisfies StripeWebhookResponse,
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.with('invoice.payment_succeeded', async () => {
|
.with('invoice.payment_succeeded', async () => {
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
const invoice = event.data.object as Stripe.Invoice;
|
const invoice = event.data.object as Stripe.Invoice;
|
||||||
|
|
||||||
if (invoice.billing_reason !== 'subscription_cycle') {
|
if (invoice.billing_reason !== 'subscription_cycle') {
|
||||||
return res.status(200).json({
|
return Response.json(
|
||||||
success: true,
|
{
|
||||||
message: 'Webhook received',
|
success: true,
|
||||||
});
|
message: 'Webhook received',
|
||||||
|
} satisfies StripeWebhookResponse,
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const customerId =
|
const customerId =
|
||||||
@ -200,19 +226,25 @@ export const stripeWebhookHandler = async (
|
|||||||
: invoice.subscription?.id;
|
: invoice.subscription?.id;
|
||||||
|
|
||||||
if (!customerId || !subscriptionId) {
|
if (!customerId || !subscriptionId) {
|
||||||
return res.status(500).json({
|
return Response.json(
|
||||||
success: false,
|
{
|
||||||
message: 'Invalid invoice',
|
success: false,
|
||||||
});
|
message: 'Invalid invoice',
|
||||||
|
} satisfies StripeWebhookResponse,
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||||
|
|
||||||
if (subscription.status === 'incomplete_expired') {
|
if (subscription.status === 'incomplete_expired') {
|
||||||
return res.status(200).json({
|
return Response.json(
|
||||||
success: true,
|
{
|
||||||
message: 'Webhook received',
|
success: true,
|
||||||
});
|
message: 'Webhook received',
|
||||||
|
} satisfies StripeWebhookResponse,
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) {
|
if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) {
|
||||||
@ -223,18 +255,24 @@ export const stripeWebhookHandler = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!team) {
|
if (!team) {
|
||||||
return res.status(500).json({
|
return Response.json(
|
||||||
success: false,
|
{
|
||||||
message: 'No team associated with subscription found',
|
success: false,
|
||||||
});
|
message: 'No team associated with subscription found',
|
||||||
|
} satisfies StripeWebhookResponse,
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await onSubscriptionUpdated({ teamId: team.id, subscription });
|
await onSubscriptionUpdated({ teamId: team.id, subscription });
|
||||||
|
|
||||||
return res.status(200).json({
|
return Response.json(
|
||||||
success: true,
|
{
|
||||||
message: 'Webhook received',
|
success: true,
|
||||||
});
|
message: 'Webhook received',
|
||||||
|
} satisfies StripeWebhookResponse,
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await prisma.user.findFirst({
|
const result = await prisma.user.findFirst({
|
||||||
@ -247,18 +285,24 @@ export const stripeWebhookHandler = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!result?.id) {
|
if (!result?.id) {
|
||||||
return res.status(500).json({
|
return Response.json(
|
||||||
success: false,
|
{
|
||||||
message: 'User not found',
|
success: false,
|
||||||
});
|
message: 'User not found',
|
||||||
|
} satisfies StripeWebhookResponse,
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await onSubscriptionUpdated({ userId: result.id, subscription });
|
await onSubscriptionUpdated({ userId: result.id, subscription });
|
||||||
|
|
||||||
return res.status(200).json({
|
return Response.json(
|
||||||
success: true,
|
{
|
||||||
message: 'Webhook received',
|
success: true,
|
||||||
});
|
message: 'Webhook received',
|
||||||
|
} satisfies StripeWebhookResponse,
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.with('invoice.payment_failed', async () => {
|
.with('invoice.payment_failed', async () => {
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
@ -273,19 +317,25 @@ export const stripeWebhookHandler = async (
|
|||||||
: invoice.subscription?.id;
|
: invoice.subscription?.id;
|
||||||
|
|
||||||
if (!customerId || !subscriptionId) {
|
if (!customerId || !subscriptionId) {
|
||||||
return res.status(500).json({
|
return Response.json(
|
||||||
success: false,
|
{
|
||||||
message: 'Invalid invoice',
|
success: false,
|
||||||
});
|
message: 'Invalid invoice',
|
||||||
|
} satisfies StripeWebhookResponse,
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||||
|
|
||||||
if (subscription.status === 'incomplete_expired') {
|
if (subscription.status === 'incomplete_expired') {
|
||||||
return res.status(200).json({
|
return Response.json(
|
||||||
success: true,
|
{
|
||||||
message: 'Webhook received',
|
success: true,
|
||||||
});
|
message: 'Webhook received',
|
||||||
|
} satisfies StripeWebhookResponse,
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) {
|
if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) {
|
||||||
@ -296,18 +346,24 @@ export const stripeWebhookHandler = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!team) {
|
if (!team) {
|
||||||
return res.status(500).json({
|
return Response.json(
|
||||||
success: false,
|
{
|
||||||
message: 'No team associated with subscription found',
|
success: false,
|
||||||
});
|
message: 'No team associated with subscription found',
|
||||||
|
} satisfies StripeWebhookResponse,
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await onSubscriptionUpdated({ teamId: team.id, subscription });
|
await onSubscriptionUpdated({ teamId: team.id, subscription });
|
||||||
|
|
||||||
return res.status(200).json({
|
return Response.json(
|
||||||
success: true,
|
{
|
||||||
message: 'Webhook received',
|
success: true,
|
||||||
});
|
message: 'Webhook received',
|
||||||
|
} satisfies StripeWebhookResponse,
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await prisma.user.findFirst({
|
const result = await prisma.user.findFirst({
|
||||||
@ -320,18 +376,24 @@ export const stripeWebhookHandler = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!result?.id) {
|
if (!result?.id) {
|
||||||
return res.status(500).json({
|
return Response.json(
|
||||||
success: false,
|
{
|
||||||
message: 'User not found',
|
success: false,
|
||||||
});
|
message: 'User not found',
|
||||||
|
} satisfies StripeWebhookResponse,
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await onSubscriptionUpdated({ userId: result.id, subscription });
|
await onSubscriptionUpdated({ userId: result.id, subscription });
|
||||||
|
|
||||||
return res.status(200).json({
|
return Response.json(
|
||||||
success: true,
|
{
|
||||||
message: 'Webhook received',
|
success: true,
|
||||||
});
|
message: 'Webhook received',
|
||||||
|
} satisfies StripeWebhookResponse,
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.with('customer.subscription.deleted', async () => {
|
.with('customer.subscription.deleted', async () => {
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
@ -339,24 +401,33 @@ export const stripeWebhookHandler = async (
|
|||||||
|
|
||||||
await onSubscriptionDeleted({ subscription });
|
await onSubscriptionDeleted({ subscription });
|
||||||
|
|
||||||
return res.status(200).json({
|
return Response.json(
|
||||||
success: true,
|
{
|
||||||
message: 'Webhook received',
|
success: true,
|
||||||
});
|
message: 'Webhook received',
|
||||||
|
} satisfies StripeWebhookResponse,
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.otherwise(() => {
|
.otherwise(() => {
|
||||||
return res.status(200).json({
|
return Response.json(
|
||||||
success: true,
|
{
|
||||||
message: 'Webhook received',
|
success: true,
|
||||||
});
|
message: 'Webhook received',
|
||||||
|
} satisfies StripeWebhookResponse,
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
res.status(500).json({
|
return Response.json(
|
||||||
success: false,
|
{
|
||||||
message: 'Unknown error',
|
success: false,
|
||||||
});
|
message: 'Unknown error',
|
||||||
|
} satisfies StripeWebhookResponse,
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,6 @@
|
|||||||
import { posthog } from 'posthog-js';
|
import { posthog } from 'posthog-js';
|
||||||
|
|
||||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
|
||||||
import {
|
|
||||||
FEATURE_FLAG_GLOBAL_SESSION_RECORDING,
|
|
||||||
extractPostHogConfig,
|
|
||||||
} from '@documenso/lib/constants/feature-flags';
|
|
||||||
|
|
||||||
export function useAnalytics() {
|
export function useAnalytics() {
|
||||||
// const featureFlags = useFeatureFlags();
|
// const featureFlags = useFeatureFlags();
|
||||||
|
|||||||
@ -5,8 +5,13 @@ export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT =
|
|||||||
|
|
||||||
export const NEXT_PUBLIC_WEBAPP_URL = () =>
|
export const NEXT_PUBLIC_WEBAPP_URL = () =>
|
||||||
env('NEXT_PUBLIC_WEBAPP_URL') ?? 'http://localhost:3000';
|
env('NEXT_PUBLIC_WEBAPP_URL') ?? 'http://localhost:3000';
|
||||||
export const NEXT_PUBLIC_MARKETING_URL = () => env('NEXT_PUBLIC_MARKETING_URL');
|
|
||||||
|
export const NEXT_PUBLIC_MARKETING_URL = () =>
|
||||||
|
env('NEXT_PUBLIC_MARKETING_URL') ?? 'http://localhost:3001';
|
||||||
|
|
||||||
export const NEXT_PRIVATE_INTERNAL_WEBAPP_URL =
|
export const NEXT_PRIVATE_INTERNAL_WEBAPP_URL =
|
||||||
env('NEXT_PRIVATE_INTERNAL_WEBAPP_URL') ?? NEXT_PUBLIC_WEBAPP_URL();
|
env('NEXT_PRIVATE_INTERNAL_WEBAPP_URL') ?? NEXT_PUBLIC_WEBAPP_URL();
|
||||||
|
|
||||||
export const IS_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED') === 'true';
|
export const IS_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED') === 'true';
|
||||||
|
|
||||||
|
export const API_V2_BETA_URL = '/api/v2-beta';
|
||||||
|
|||||||
@ -1,19 +1,24 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
|
||||||
|
|
||||||
import { validateApiToken } from '@documenso/lib/server-only/webhooks/zapier/validateApiToken';
|
import { validateApiToken } from '@documenso/lib/server-only/webhooks/zapier/validateApiToken';
|
||||||
|
|
||||||
export const testCredentialsHandler = async (req: NextApiRequest, res: NextApiResponse) => {
|
export const testCredentialsHandler = async (req: Request) => {
|
||||||
try {
|
try {
|
||||||
const { authorization } = req.headers;
|
const authorization = req.headers.get('authorization');
|
||||||
|
|
||||||
|
if (!authorization) {
|
||||||
|
throw new Error('Missing authorization header');
|
||||||
|
}
|
||||||
|
|
||||||
const result = await validateApiToken({ authorization });
|
const result = await validateApiToken({ authorization });
|
||||||
|
|
||||||
return res.status(200).json({
|
return Response.json({
|
||||||
name: result.team?.name ?? result.user.name,
|
name: result.team?.name ?? result.user.name,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return res.status(500).json({
|
return Response.json(
|
||||||
message: 'Internal Server Error',
|
{
|
||||||
});
|
message: 'Internal Server Error',
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
|
||||||
|
|
||||||
import { verify } from '../../crypto/verify';
|
import { verify } from '../../crypto/verify';
|
||||||
import { getAllWebhooksByEventTrigger } from '../get-all-webhooks-by-event-trigger';
|
import { getAllWebhooksByEventTrigger } from '../get-all-webhooks-by-event-trigger';
|
||||||
import { executeWebhook } from './execute-webhook';
|
import { executeWebhook } from './execute-webhook';
|
||||||
@ -15,29 +13,26 @@ export type HandlerTriggerWebhooksResponse =
|
|||||||
error: string;
|
error: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handlerTriggerWebhooks = async (
|
export const handlerTriggerWebhooks = async (req: Request) => {
|
||||||
req: NextApiRequest,
|
const signature = req.headers.get('x-webhook-signature');
|
||||||
res: NextApiResponse<HandlerTriggerWebhooksResponse>,
|
|
||||||
) => {
|
|
||||||
const signature = req.headers['x-webhook-signature'];
|
|
||||||
|
|
||||||
if (typeof signature !== 'string') {
|
if (typeof signature !== 'string') {
|
||||||
console.log('Missing signature');
|
console.log('Missing signature');
|
||||||
return res.status(400).json({ success: false, error: 'Missing signature' });
|
return Response.json({ success: false, error: 'Missing signature' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const valid = verify(req.body, signature);
|
const valid = verify(req.body, signature);
|
||||||
|
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
console.log('Invalid signature');
|
console.log('Invalid signature');
|
||||||
return res.status(400).json({ success: false, error: 'Invalid signature' });
|
return Response.json({ success: false, error: 'Invalid signature' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = ZTriggerWebhookBodySchema.safeParse(req.body);
|
const result = ZTriggerWebhookBodySchema.safeParse(req.body);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
console.log('Invalid request body');
|
console.log('Invalid request body');
|
||||||
return res.status(400).json({ success: false, error: 'Invalid request body' });
|
return Response.json({ success: false, error: 'Invalid request body' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { event, data, userId, teamId } = result.data;
|
const { event, data, userId, teamId } = result.data;
|
||||||
@ -54,5 +49,8 @@ export const handlerTriggerWebhooks = async (
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return res.status(200).json({ success: true, message: 'Webhooks executed successfully' });
|
return Response.json(
|
||||||
|
{ success: true, message: 'Webhooks executed successfully' },
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
|
||||||
|
|
||||||
import type { Webhook } from '@prisma/client';
|
import type { Webhook } from '@prisma/client';
|
||||||
|
|
||||||
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
||||||
@ -9,9 +7,14 @@ import { getWebhooksByTeamId } from '../get-webhooks-by-team-id';
|
|||||||
import { getWebhooksByUserId } from '../get-webhooks-by-user-id';
|
import { getWebhooksByUserId } from '../get-webhooks-by-user-id';
|
||||||
import { validateApiToken } from './validateApiToken';
|
import { validateApiToken } from './validateApiToken';
|
||||||
|
|
||||||
export const listDocumentsHandler = async (req: NextApiRequest, res: NextApiResponse) => {
|
export const listDocumentsHandler = async (req: Request) => {
|
||||||
try {
|
try {
|
||||||
const { authorization } = req.headers;
|
const authorization = req.headers.get('authorization');
|
||||||
|
|
||||||
|
if (!authorization) {
|
||||||
|
return new Response('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
const { user, userId, teamId } = await validateApiToken({ authorization });
|
const { user, userId, teamId } = await validateApiToken({ authorization });
|
||||||
|
|
||||||
let allWebhooks: Webhook[] = [];
|
let allWebhooks: Webhook[] = [];
|
||||||
@ -56,13 +59,16 @@ export const listDocumentsHandler = async (req: NextApiRequest, res: NextApiResp
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return res.status(200).json([testWebhook]);
|
return Response.json([testWebhook]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.status(200).json([]);
|
return Response.json([]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return res.status(500).json({
|
return Response.json(
|
||||||
message: 'Internal Server Error',
|
{
|
||||||
});
|
message: 'Internal Server Error',
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,14 +1,16 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
|
||||||
|
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
import { validateApiToken } from './validateApiToken';
|
import { validateApiToken } from './validateApiToken';
|
||||||
|
|
||||||
export const subscribeHandler = async (req: NextApiRequest, res: NextApiResponse) => {
|
export const subscribeHandler = async (req: Request) => {
|
||||||
try {
|
try {
|
||||||
const { authorization } = req.headers;
|
const authorization = req.headers.get('authorization');
|
||||||
|
|
||||||
const { webhookUrl, eventTrigger } = req.body;
|
if (!authorization) {
|
||||||
|
return new Response('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { webhookUrl, eventTrigger } = await req.json();
|
||||||
|
|
||||||
const result = await validateApiToken({ authorization });
|
const result = await validateApiToken({ authorization });
|
||||||
|
|
||||||
@ -23,10 +25,13 @@ export const subscribeHandler = async (req: NextApiRequest, res: NextApiResponse
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.status(200).json(createdWebhook);
|
return Response.json(createdWebhook);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return res.status(500).json({
|
return Response.json(
|
||||||
message: 'Internal Server Error',
|
{
|
||||||
});
|
message: 'Internal Server Error',
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,14 +1,16 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
|
||||||
|
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
import { validateApiToken } from './validateApiToken';
|
import { validateApiToken } from './validateApiToken';
|
||||||
|
|
||||||
export const unsubscribeHandler = async (req: NextApiRequest, res: NextApiResponse) => {
|
export const unsubscribeHandler = async (req: Request) => {
|
||||||
try {
|
try {
|
||||||
const { authorization } = req.headers;
|
const authorization = req.headers.get('authorization');
|
||||||
|
|
||||||
const { webhookId } = req.body;
|
if (!authorization) {
|
||||||
|
return new Response('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { webhookId } = await req.json();
|
||||||
|
|
||||||
const result = await validateApiToken({ authorization });
|
const result = await validateApiToken({ authorization });
|
||||||
|
|
||||||
@ -20,10 +22,13 @@ export const unsubscribeHandler = async (req: NextApiRequest, res: NextApiRespon
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.status(200).json(deletedWebhook);
|
return Response.json(deletedWebhook);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return res.status(500).json({
|
return Response.json(
|
||||||
message: 'Internal Server Error',
|
{
|
||||||
});
|
message: 'Internal Server Error',
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -135,15 +135,6 @@ export const documentRouter = router({
|
|||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
findDocumentsInternal: authenticatedProcedure
|
findDocumentsInternal: authenticatedProcedure
|
||||||
.meta({
|
|
||||||
openapi: {
|
|
||||||
method: 'GET',
|
|
||||||
path: '/document',
|
|
||||||
summary: 'Find documents',
|
|
||||||
description: 'Find documents based on a search criteria',
|
|
||||||
tags: ['Document'],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.input(ZFindDocumentsInternalRequestSchema)
|
.input(ZFindDocumentsInternalRequestSchema)
|
||||||
.output(ZFindDocumentsInternalResponseSchema)
|
.output(ZFindDocumentsInternalResponseSchema)
|
||||||
.query(async ({ input, ctx }) => {
|
.query(async ({ input, ctx }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user