diff --git a/apps/remix/app/_todo/app/(unauthenticated)/team/decline/[token]/page.tsx b/apps/remix/app/_todo/app/(unauthenticated)/team/decline/[token]/page.tsx
new file mode 100644
index 000000000..06c7dadc9
--- /dev/null
+++ b/apps/remix/app/_todo/app/(unauthenticated)/team/decline/[token]/page.tsx
@@ -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 (
+
+
+
+ Invalid token
+
+
+
+ This token is invalid or has expired. No action is needed.
+
+
+
+
+ Return
+
+
+
+
+ );
+ }
+
+ 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 (
+
+
+ Team invitation
+
+
+
+
+ You have been invited by {team.name} to join their team.
+
+
+
+
+ To decline this invitation you must create an account.
+
+
+
+
+ Create account
+
+
+
+ );
+ }
+
+ const isSessionUserTheInvitedUser = user?.id === session.user?.id;
+
+ return (
+
+
+ Invitation declined
+
+
+
+
+ You have declined the invitation from {team.name} to join their team.
+
+
+
+ {isSessionUserTheInvitedUser ? (
+
+
+ Return to Dashboard
+
+
+ ) : (
+
+
+ Return to Home
+
+
+ )}
+
+ );
+}
diff --git a/apps/remix/app/_todo/app/(unauthenticated)/team/invite/[token]/page.tsx b/apps/remix/app/_todo/app/(unauthenticated)/team/invite/[token]/page.tsx
new file mode 100644
index 000000000..3441dbed7
--- /dev/null
+++ b/apps/remix/app/_todo/app/(unauthenticated)/team/invite/[token]/page.tsx
@@ -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 (
+
+
+
+ Invalid token
+
+
+
+
+ This token is invalid or has expired. Please contact your team for a new invitation.
+
+
+
+
+
+ Return
+
+
+
+
+ );
+ }
+
+ 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 (
+
+
+ Team invitation
+
+
+
+
+ You have been invited by {team.name} to join their team.
+
+
+
+
+ To accept this invitation you must create an account.
+
+
+
+
+ Create account
+
+
+
+ );
+ }
+
+ const isSessionUserTheInvitedUser = user.id === session.user?.id;
+
+ return (
+
+
+ Invitation accepted!
+
+
+
+
+ You have accepted an invitation from {team.name} to join their team.
+
+
+
+ {isSessionUserTheInvitedUser ? (
+
+
+ Continue
+
+
+ ) : (
+
+
+ Continue to login
+
+
+ )}
+
+ );
+}
diff --git a/apps/remix/app/_todo/app/(unauthenticated)/team/verify/email/[token]/page.tsx b/apps/remix/app/_todo/app/(unauthenticated)/team/verify/email/[token]/page.tsx
new file mode 100644
index 000000000..b53fb5f71
--- /dev/null
+++ b/apps/remix/app/_todo/app/(unauthenticated)/team/verify/email/[token]/page.tsx
@@ -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 (
+
+
+
+ Invalid link
+
+
+
+
+ This link is invalid or has expired. Please contact your team to resend a
+ verification.
+
+
+
+
+
+ Return
+
+
+
+
+ );
+ }
+
+ if (teamEmailVerification.completed) {
+ return (
+
+
+ Team email already verified!
+
+
+
+
+ You have already verified your email address for{' '}
+ {teamEmailVerification.team.name} .
+
+
+
+
+
+ Continue
+
+
+
+ );
+ }
+
+ 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 (
+
+
+ Team email verification
+
+
+
+
+ Something went wrong while attempting to verify your email address for{' '}
+ {team.name} . Please try again later.
+
+
+
+ );
+ }
+
+ return (
+
+
+ Team email verified!
+
+
+
+
+ You have verified your email address for {team.name} .
+
+
+
+
+
+ Continue
+
+
+
+ );
+}
diff --git a/apps/remix/app/_todo/app/(unauthenticated)/team/verify/transfer/[token]/page.tsx b/apps/remix/app/_todo/app/(unauthenticated)/team/verify/transfer/[token]/page.tsx
new file mode 100644
index 000000000..8713aeecd
--- /dev/null
+++ b/apps/remix/app/_todo/app/(unauthenticated)/team/verify/transfer/[token]/page.tsx
@@ -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 (
+
+
+
+ Invalid link
+
+
+
+
+ This link is invalid or has expired. Please contact your team to resend a transfer
+ request.
+
+
+
+
+
+ Return
+
+
+
+
+ );
+ }
+
+ if (teamTransferVerification.completed) {
+ return (
+
+
+ Team ownership transfer already completed!
+
+
+
+
+ You have already completed the ownership transfer for{' '}
+ {teamTransferVerification.team.name} .
+
+
+
+
+
+ Continue
+
+
+
+ );
+ }
+
+ const { team } = teamTransferVerification;
+
+ let isTransferError = false;
+
+ try {
+ await transferTeamOwnership({ token });
+ } catch (e) {
+ console.error(e);
+ isTransferError = true;
+ }
+
+ if (isTransferError) {
+ return (
+
+
+ Team ownership transfer
+
+
+
+
+ Something went wrong while attempting to transfer the ownership of team{' '}
+ {team.name} to your. Please try again later or contact support.
+
+
+
+ );
+ }
+
+ return (
+
+
+ Team ownership transferred!
+
+
+
+
+ The ownership of team {team.name} has been successfully transferred to
+ you.
+
+
+
+
+
+ Continue
+
+
+
+ );
+}
diff --git a/apps/remix/app/_todo/app/(unauthenticated)/verify-email/[token]/client.tsx b/apps/remix/app/_todo/app/(unauthenticated)/verify-email/[token]/client.tsx
new file mode 100644
index 000000000..d7c2a936a
--- /dev/null
+++ b/apps/remix/app/_todo/app/(unauthenticated)/verify-email/[token]/client.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+ Email Confirmed!
+
+
+
+
+ Your email has been successfully confirmed! You can now use all features of Documenso.
+
+
+
+ {!signInData && (
+
+
+ Go back home
+
+
+ )}
+
+
+
+ );
+};
diff --git a/apps/remix/app/_todo/app/(unauthenticated)/verify-email/[token]/page.tsx b/apps/remix/app/_todo/app/(unauthenticated)/verify-email/[token]/page.tsx
new file mode 100644
index 000000000..eb88538c4
--- /dev/null
+++ b/apps/remix/app/_todo/app/(unauthenticated)/verify-email/[token]/page.tsx
@@ -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 (
+
+
+
+
+
+
+
+ No token provided
+
+
+
+ It seems that there is no token provided. Please check your email and try again.
+
+
+
+
+ );
+ }
+
+ const verified = await verifyEmail({ token });
+
+ return await match(verified)
+ .with(EMAIL_VERIFICATION_STATE.NOT_FOUND, () => (
+
+
+
+
+
+
+ Something went wrong
+
+
+
+
+ We were unable to verify your email. If your email is not verified already, please
+ try again.
+
+
+
+
+
+ Go back home
+
+
+
+
+
+ ))
+ .with(EMAIL_VERIFICATION_STATE.EXPIRED, () => (
+
+
+
+
+
+
+
+
+ Your token has expired!
+
+
+
+
+ It seems that the provided token has expired. We've just sent you another token,
+ please check your email and try again.
+
+
+
+
+
+ Go back home
+
+
+
+
+
+ ))
+ .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 ;
+ })
+ .with(EMAIL_VERIFICATION_STATE.ALREADY_VERIFIED, () => )
+ .exhaustive();
+}
diff --git a/apps/remix/app/_todo/app/(unauthenticated)/verify-email/page.tsx b/apps/remix/app/_todo/app/(unauthenticated)/verify-email/page.tsx
new file mode 100644
index 000000000..cd518a913
--- /dev/null
+++ b/apps/remix/app/_todo/app/(unauthenticated)/verify-email/page.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+ Uh oh! Looks like you're missing a token
+
+
+
+
+ It seems that there is no token provided, if you are trying to verify your email
+ please follow the link in your email.
+
+
+
+
+
+ Go back home
+
+
+
+
+
+ );
+}
diff --git a/apps/remix/app/_todo/middleware.ts b/apps/remix/app/_todo/middleware.ts
new file mode 100644
index 000000000..25de9debb
--- /dev/null
+++ b/apps/remix/app/_todo/middleware.ts
@@ -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 {
+ 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/` to `/t//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' },
+ ],
+ },
+ ],
+};
diff --git a/apps/remix/app/components/(dashboard)/layout/app-banner.tsx b/apps/remix/app/components/(dashboard)/layout/app-banner.tsx
new file mode 100644
index 000000000..336aa3718
--- /dev/null
+++ b/apps/remix/app/components/(dashboard)/layout/app-banner.tsx
@@ -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 (
+
+ );
+};
+
+// Banner
+// Custom Text
+// Custom Text with Custom Icon
diff --git a/apps/remix/app/components/(dashboard)/layout/banner.tsx b/apps/remix/app/components/(dashboard)/layout/banner.tsx
deleted file mode 100644
index 95a0de3dd..000000000
--- a/apps/remix/app/components/(dashboard)/layout/banner.tsx
+++ /dev/null
@@ -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 && (
-
- )}
- >
- );
-};
-
-// Banner
-// Custom Text
-// Custom Text with Custom Icon
diff --git a/apps/remix/app/components/forms/signup.tsx b/apps/remix/app/components/forms/signup.tsx
index 15233af69..9e328278c 100644
--- a/apps/remix/app/components/forms/signup.tsx
+++ b/apps/remix/app/components/forms/signup.tsx
@@ -13,6 +13,7 @@ import { z } from 'zod';
import communityCardsImage from '@documenso/assets/images/community-cards.png';
import { authClient } from '@documenso/auth/client';
+import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
@@ -93,7 +94,7 @@ export const SignUpForm = ({
const { _ } = useLingui();
const { toast } = useToast();
- // const analytics = useAnalytics(); // Todo
+ const analytics = useAnalytics();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
@@ -134,11 +135,11 @@ export const SignUpForm = ({
duration: 5000,
});
- // analytics.capture('App: User Sign Up', {
- // email,
- // timestamp: new Date().toISOString(),
- // custom_campaign_params: { src: utmSrc },
- // });
+ analytics.capture('App: User Sign Up', {
+ email,
+ timestamp: new Date().toISOString(),
+ custom_campaign_params: { src: utmSrc },
+ });
} catch (err) {
const error = AppError.parseError(err);
diff --git a/apps/remix/app/components/general/teams/team-layout-billing-banner.tsx b/apps/remix/app/components/general/teams/team-layout-billing-banner.tsx
new file mode 100644
index 000000000..becea0d22
--- /dev/null
+++ b/apps/remix/app/components/general/teams/team-layout-billing-banner.tsx
@@ -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 (
+ <>
+
+
+
+
+
+ {match(subscription.status)
+ .with(SubscriptionStatus.PAST_DUE, () =>
Payment overdue )
+ .with(SubscriptionStatus.INACTIVE, () =>
Teams restricted )
+ .exhaustive()}
+
+
+
setIsOpen(true)}
+ size="sm"
+ >
+ Resolve
+
+
+
+
+ !isPending && setIsOpen(value)}>
+
+
+ Payment overdue
+
+
+ {match(subscription.status)
+ .with(SubscriptionStatus.PAST_DUE, () => (
+
+
+ Your payment for teams is overdue. Please settle the payment to avoid any service
+ disruptions.
+
+
+ ))
+ .with(SubscriptionStatus.INACTIVE, () => (
+
+
+ Due to an unpaid invoice, your team has been restricted. Please settle the payment
+ to restore full access to your team.
+
+
+ ))
+ .otherwise(() => null)}
+
+ {canExecuteTeamAction('MANAGE_BILLING', userRole) && (
+
+
+ Resolve payment
+
+
+ )}
+
+
+ >
+ );
+};
diff --git a/apps/remix/app/root.tsx b/apps/remix/app/root.tsx
index 546fe4f48..e4330366a 100644
--- a/apps/remix/app/root.tsx
+++ b/apps/remix/app/root.tsx
@@ -1,3 +1,5 @@
+import { Suspense } from 'react';
+
import {
Links,
Meta,
@@ -20,6 +22,7 @@ import { TooltipProvider } from '@documenso/ui/primitives/tooltip';
import type { Route } from './+types/root';
import stylesheet from './app.css?url';
import { GenericErrorLayout } from './components/general/generic-error-layout';
+import { PostHogPageview } from './providers/posthog';
import { langCookie } from './storage/lang-cookie.server';
import { themeSessionResolver } from './storage/theme-session.server';
@@ -41,6 +44,37 @@ export const links: Route.LinksFunction = () => [
{ rel: 'stylesheet', href: stylesheet },
];
+// Todo: Meta data.
+// export function generateMetadata() {
+// return {
+// title: {
+// template: '%s - Documenso',
+// default: 'Documenso',
+// },
+// description:
+// 'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
+// keywords:
+// 'Documenso, open source, DocuSign alternative, document signing, open signing infrastructure, open-source community, fast signing, beautiful signing, smart templates',
+// authors: { name: 'Documenso, Inc.' },
+// robots: 'index, follow',
+// metadataBase: new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000'),
+// openGraph: {
+// title: 'Documenso - The Open Source DocuSign Alternative',
+// description:
+// 'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
+// type: 'website',
+// images: ['/opengraph-image.jpg'],
+// },
+// twitter: {
+// site: '@documenso',
+// card: 'summary_large_image',
+// images: ['/opengraph-image.jpg'],
+// description:
+// 'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
+// },
+// };
+// }
+
export async function loader({ request, context }: Route.LoaderArgs) {
const { getTheme } = await themeSessionResolver(request);
@@ -81,10 +115,15 @@ export function Layout({ children }: { children: React.ReactNode }) {
+
{/* */}
+
+
+
+
{children}
diff --git a/apps/remix/app/routes/_authenticated+/_layout.tsx b/apps/remix/app/routes/_authenticated+/_layout.tsx
index 69984425b..22cef02ef 100644
--- a/apps/remix/app/routes/_authenticated+/_layout.tsx
+++ b/apps/remix/app/routes/_authenticated+/_layout.tsx
@@ -1,15 +1,22 @@
import { Outlet, redirect } from 'react-router';
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client';
+import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
+import { SITE_SETTINGS_BANNER_ID } from '@documenso/lib/server-only/site-settings/schemas/banner';
+import { AppBanner } from '~/components/(dashboard)/layout/app-banner';
import { Header } from '~/components/(dashboard)/layout/header';
import { VerifyEmailBanner } from '~/components/(dashboard)/layout/verify-email-banner';
import type { Route } from './+types/_layout';
-export const loader = ({ context }: Route.LoaderArgs) => {
+export const loader = async ({ context }: Route.LoaderArgs) => {
const { session } = context;
+ const banner = await getSiteSettings().then((settings) =>
+ settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
+ );
+
if (!session) {
throw redirect('/signin');
}
@@ -17,18 +24,18 @@ export const loader = ({ context }: Route.LoaderArgs) => {
return {
user: session.user,
teams: session.teams,
+ banner,
};
};
export default function Layout({ loaderData }: Route.ComponentProps) {
- const { user, teams } = loaderData;
+ const { user, teams, banner } = loaderData;
return (
{!user.emailVerified && }
- {/* // Todo: Banner */}
- {/* */}
+ {banner && }
diff --git a/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx b/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
index 14b0c2eb7..5566213d2 100644
--- a/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
+++ b/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
@@ -1,7 +1,7 @@
import { Trans } from '@lingui/macro';
import { BarChart3, FileStack, Settings, Trophy, Users, Wallet2 } from 'lucide-react';
import { Link, Outlet, redirect, useLocation } from 'react-router';
-import { getRequiredSessionContext } from 'server/utils/get-required-session-context';
+import { getRequiredLoaderSession } from 'server/utils/get-required-session-context';
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
import { cn } from '@documenso/ui/lib/utils';
@@ -10,7 +10,7 @@ import { Button } from '@documenso/ui/primitives/button';
import type { Route } from './+types/_layout';
export function loader({ context }: Route.LoaderArgs) {
- const { user } = getRequiredSessionContext(context);
+ const { user } = getRequiredLoaderSession(context);
if (!user || !isAdmin(user)) {
throw redirect('/documents');
diff --git a/apps/remix/app/routes/_authenticated+/documents+/$id._index.tsx b/apps/remix/app/routes/_authenticated+/documents+/$id._index.tsx
index 9ab2abcc5..dc653b0c9 100644
--- a/apps/remix/app/routes/_authenticated+/documents+/$id._index.tsx
+++ b/apps/remix/app/routes/_authenticated+/documents+/$id._index.tsx
@@ -3,7 +3,7 @@ import { useLingui } from '@lingui/react';
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
import { ChevronLeft, Clock9, Users2 } from 'lucide-react';
import { Link, redirect } from 'react-router';
-import { getRequiredSessionContext } from 'server/utils/get-required-session-context';
+import { getRequiredLoaderSession } from 'server/utils/get-required-session-context';
import { match } from 'ts-pattern';
import { useSession } from '@documenso/lib/client-only/providers/session';
@@ -34,7 +34,7 @@ import { DocumentPageViewRecipients } from '~/components/general/document/docume
import type { Route } from './+types/$id._index';
export async function loader({ params, context }: Route.LoaderArgs) {
- const { user, currentTeam: team } = getRequiredSessionContext(context);
+ const { user, currentTeam: team } = getRequiredLoaderSession(context);
const { id } = params;
diff --git a/apps/remix/app/routes/_authenticated+/documents+/$id.edit.tsx b/apps/remix/app/routes/_authenticated+/documents+/$id.edit.tsx
index a500de06e..11af2eef7 100644
--- a/apps/remix/app/routes/_authenticated+/documents+/$id.edit.tsx
+++ b/apps/remix/app/routes/_authenticated+/documents+/$id.edit.tsx
@@ -2,7 +2,7 @@ import { Plural, Trans } from '@lingui/macro';
import { DocumentStatus as InternalDocumentStatus, TeamMemberRole } from '@prisma/client';
import { ChevronLeft, Users2 } from 'lucide-react';
import { Link, redirect } from 'react-router';
-import { getRequiredSessionContext } from 'server/utils/get-required-session-context';
+import { getRequiredLoaderSession } from 'server/utils/get-required-session-context';
import { match } from 'ts-pattern';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
@@ -18,7 +18,7 @@ import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/$id.edit';
export async function loader({ params, context }: Route.LoaderArgs) {
- const { user, currentTeam: team } = getRequiredSessionContext(context);
+ const { user, currentTeam: team } = getRequiredLoaderSession(context);
const { id } = params;
diff --git a/apps/remix/app/routes/_authenticated+/documents+/$id.logs.tsx b/apps/remix/app/routes/_authenticated+/documents+/$id.logs.tsx
index c36ea713c..f9c88a780 100644
--- a/apps/remix/app/routes/_authenticated+/documents+/$id.logs.tsx
+++ b/apps/remix/app/routes/_authenticated+/documents+/$id.logs.tsx
@@ -5,7 +5,7 @@ import type { Recipient } from '@prisma/client';
import { ChevronLeft } from 'lucide-react';
import { DateTime } from 'luxon';
import { Link, redirect } from 'react-router';
-import { getRequiredSessionContext } from 'server/utils/get-required-session-context';
+import { getRequiredLoaderSession } from 'server/utils/get-required-session-context';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
@@ -25,7 +25,7 @@ import type { Route } from './+types/$id.logs';
export async function loader({ params, context }: Route.LoaderArgs) {
const { id } = params;
- const { user, currentTeam: team } = getRequiredSessionContext(context);
+ const { user, currentTeam: team } = getRequiredLoaderSession(context);
const documentId = Number(id);
diff --git a/apps/remix/app/routes/_authenticated+/settings+/public-profile+/index.tsx b/apps/remix/app/routes/_authenticated+/settings+/public-profile+/index.tsx
index b9443d6cb..9c33ff07c 100644
--- a/apps/remix/app/routes/_authenticated+/settings+/public-profile+/index.tsx
+++ b/apps/remix/app/routes/_authenticated+/settings+/public-profile+/index.tsx
@@ -4,7 +4,7 @@ import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import type { TemplateDirectLink } from '@prisma/client';
import { TemplateType } from '@prisma/client';
-import { getRequiredSessionContext } from 'server/utils/get-required-session-context';
+import { getRequiredLoaderSession } from 'server/utils/get-required-session-context';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { getUserPublicProfile } from '@documenso/lib/server-only/user/get-user-public-profile';
@@ -44,7 +44,7 @@ const teamProfileText = {
};
export async function loader({ context }: Route.LoaderArgs) {
- const { user } = getRequiredSessionContext(context);
+ const { user } = getRequiredLoaderSession(context);
const { profile } = await getUserPublicProfile({
userId: user.id,
diff --git a/apps/remix/app/routes/_authenticated+/settings+/tokens+/index.tsx b/apps/remix/app/routes/_authenticated+/settings+/tokens+/index.tsx
index 1407c5286..c0b5133dc 100644
--- a/apps/remix/app/routes/_authenticated+/settings+/tokens+/index.tsx
+++ b/apps/remix/app/routes/_authenticated+/settings+/tokens+/index.tsx
@@ -1,7 +1,7 @@
import { Trans } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { DateTime } from 'luxon';
-import { getRequiredSessionContext } from 'server/utils/get-required-session-context';
+import { getRequiredLoaderSession } from 'server/utils/get-required-session-context';
import { getUserTokens } from '@documenso/lib/server-only/public-api/get-all-user-tokens';
import { Button } from '@documenso/ui/primitives/button';
@@ -12,7 +12,7 @@ import { ApiTokenForm } from '~/components/forms/token';
import type { Route } from './+types/index';
export async function loader({ context }: Route.LoaderArgs) {
- const { user } = getRequiredSessionContext(context);
+ const { user } = getRequiredLoaderSession(context);
// Todo: Use TRPC & use table instead
const tokens = await getUserTokens({ userId: user.id });
diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/_layout.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/_layout.tsx
index 1eaf48878..16e2ca388 100644
--- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/_layout.tsx
+++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/_layout.tsx
@@ -3,7 +3,7 @@ import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { ChevronLeft } from 'lucide-react';
import { Link, Outlet, isRouteErrorResponse, redirect, useNavigate } from 'react-router';
-import { getRequiredSessionContext } from 'server/utils/get-required-session-context';
+import { getRequiredLoaderSession } from 'server/utils/get-required-session-context';
import { match } from 'ts-pattern';
import { AppErrorCode } from '@documenso/lib/errors/app-error';
@@ -15,7 +15,7 @@ import { TeamProvider } from '~/providers/team';
import type { Route } from './+types/_layout';
export const loader = ({ context }: Route.LoaderArgs) => {
- const { currentTeam } = getRequiredSessionContext(context);
+ const { currentTeam } = getRequiredLoaderSession(context);
if (!currentTeam) {
throw redirect('/documents');
diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings+/_layout.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings+/_layout.tsx
index 786933067..a42c13d0e 100644
--- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings+/_layout.tsx
+++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings+/_layout.tsx
@@ -1,6 +1,6 @@
import { Trans } from '@lingui/macro';
import { Outlet } from 'react-router';
-import { getRequiredTeamSessionContext } from 'server/utils/get-required-session-context';
+import { getRequiredLoaderTeamSession } from 'server/utils/get-required-session-context';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
@@ -9,10 +9,9 @@ import { TeamSettingsMobileNav } from '~/components/general/teams/team-settings-
import type { Route } from '../+types/_layout';
-export async function loader({ context }: Route.LoaderArgs) {
- const { currentTeam: team } = getRequiredTeamSessionContext(context);
+export function loader({ context }: Route.LoaderArgs) {
+ const { currentTeam: team } = getRequiredLoaderTeamSession(context);
- // Todo: Test that 404 page shows up from error.
if (!team || !canExecuteTeamAction('MANAGE_TEAM', team.currentTeamMember.role)) {
throw new Response(null, { status: 401 }); // Unauthorized.
}
diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings+/billing.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings+/billing.tsx
index f48e33b51..796e5d636 100644
--- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings+/billing.tsx
+++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings+/billing.tsx
@@ -1,7 +1,7 @@
import { Plural, Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { DateTime } from 'luxon';
-import { getRequiredTeamSessionContext } from 'server/utils/get-required-session-context';
+import { getRequiredLoaderTeamSession } from 'server/utils/get-required-session-context';
import type Stripe from 'stripe';
import { match } from 'ts-pattern';
@@ -16,7 +16,7 @@ import { TeamBillingPortalButton } from '~/components/general/teams/team-billing
import type { Route } from './+types/billing';
export async function loader({ context }: Route.LoaderArgs) {
- const { currentTeam: team } = getRequiredTeamSessionContext(context);
+ const { currentTeam: team } = getRequiredLoaderTeamSession(context);
let teamSubscription: Stripe.Subscription | null = null;
diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings+/public-profile.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings+/public-profile.tsx
index 3dfc1f0bc..14d1a2c80 100644
--- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings+/public-profile.tsx
+++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings+/public-profile.tsx
@@ -1,4 +1,4 @@
-import { getRequiredTeamSessionContext } from 'server/utils/get-required-session-context';
+import { getRequiredLoaderTeamSession } from 'server/utils/get-required-session-context';
import { getTeamPublicProfile } from '@documenso/lib/server-only/team/get-team-public-profile';
@@ -7,7 +7,7 @@ import PublicProfilePage from '~/routes/_authenticated+/settings+/public-profile
import type { Route } from './+types/public-profile';
export async function loader({ context }: Route.LoaderArgs) {
- const { user, currentTeam: team } = getRequiredTeamSessionContext(context);
+ const { user, currentTeam: team } = getRequiredLoaderTeamSession(context);
const { profile } = await getTeamPublicProfile({
userId: user.id,
diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings+/tokens.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings+/tokens.tsx
index 1aed36c9e..1b6994a3f 100644
--- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings+/tokens.tsx
+++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings+/tokens.tsx
@@ -1,7 +1,7 @@
import { Trans } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { DateTime } from 'luxon';
-import { getRequiredTeamSessionContext } from 'server/utils/get-required-session-context';
+import { getRequiredLoaderTeamSession } from 'server/utils/get-required-session-context';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getTeamTokens } from '@documenso/lib/server-only/public-api/get-all-team-tokens';
@@ -13,7 +13,7 @@ import { ApiTokenForm } from '~/components/forms/token';
import type { Route } from './+types/tokens';
export async function loader({ context }: Route.LoaderArgs) {
- const { user, currentTeam: team } = getRequiredTeamSessionContext(context);
+ const { user, currentTeam: team } = getRequiredLoaderTeamSession(context);
const tokens = await getTeamTokens({ userId: user.id, teamId: team.id }).catch(() => null);
diff --git a/apps/remix/app/routes/_authenticated+/templates+/$id._index.tsx b/apps/remix/app/routes/_authenticated+/templates+/$id._index.tsx
index 06598ba4a..e663a4a2c 100644
--- a/apps/remix/app/routes/_authenticated+/templates+/$id._index.tsx
+++ b/apps/remix/app/routes/_authenticated+/templates+/$id._index.tsx
@@ -2,7 +2,7 @@ import { Trans } from '@lingui/macro';
import { DocumentSigningOrder, SigningStatus } from '@prisma/client';
import { ChevronLeft, LucideEdit } from 'lucide-react';
import { Link, redirect } from 'react-router';
-import { getRequiredSessionContext } from 'server/utils/get-required-session-context';
+import { getRequiredLoaderSession } from 'server/utils/get-required-session-context';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
@@ -25,7 +25,7 @@ import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/$id._index';
export async function loader({ params, context }: Route.LoaderArgs) {
- const { user, currentTeam: team } = getRequiredSessionContext(context);
+ const { user, currentTeam: team } = getRequiredLoaderSession(context);
const { id } = params;
diff --git a/apps/remix/app/routes/_authenticated+/templates+/$id.edit.tsx b/apps/remix/app/routes/_authenticated+/templates+/$id.edit.tsx
index f8f1a3d61..398add891 100644
--- a/apps/remix/app/routes/_authenticated+/templates+/$id.edit.tsx
+++ b/apps/remix/app/routes/_authenticated+/templates+/$id.edit.tsx
@@ -1,7 +1,7 @@
import { Trans } from '@lingui/macro';
import { ChevronLeft } from 'lucide-react';
import { Link, redirect } from 'react-router';
-import { getRequiredSessionContext } from 'server/utils/get-required-session-context';
+import { getRequiredLoaderSession } from 'server/utils/get-required-session-context';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
@@ -16,7 +16,7 @@ import { TemplateDirectLinkDialogWrapper } from '../../../components/dialogs/tem
import type { Route } from './+types/$id.edit';
export async function loader({ context, params }: Route.LoaderArgs) {
- const { user, currentTeam: team } = getRequiredSessionContext(context);
+ const { user, currentTeam: team } = getRequiredLoaderSession(context);
const { id } = params;
diff --git a/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx b/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx
index 80ed27c9d..828845e79 100644
--- a/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx
+++ b/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx
@@ -16,12 +16,6 @@ import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/_index';
-export type TemplatesDirectPageProps = {
- params: {
- token: string;
- };
-};
-
export async function loader({ params, context }: Route.LoaderArgs) {
const { token } = params;
diff --git a/apps/remix/app/routes/_unauthenticated+/_layout.tsx b/apps/remix/app/routes/_unauthenticated+/_layout.tsx
index 5d9121e17..d66c67b8c 100644
--- a/apps/remix/app/routes/_unauthenticated+/_layout.tsx
+++ b/apps/remix/app/routes/_unauthenticated+/_layout.tsx
@@ -2,12 +2,6 @@ import { Outlet } from 'react-router';
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
-import type { Route } from './+types/_layout';
-
-export const loader = async (args: Route.LoaderArgs) => {
- //
-};
-
export default function Layout() {
return (
diff --git a/apps/remix/app/routes/_unauthenticated+/signature-disclosure.tsx b/apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
similarity index 100%
rename from apps/remix/app/routes/_unauthenticated+/signature-disclosure.tsx
rename to apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
diff --git a/apps/remix/app/routes/_unauthenticated+/reset-password.$token.tsx b/apps/remix/app/routes/_unauthenticated+/reset-password.$token.tsx
index 920c8b6ab..e837c6f34 100644
--- a/apps/remix/app/routes/_unauthenticated+/reset-password.$token.tsx
+++ b/apps/remix/app/routes/_unauthenticated+/reset-password.$token.tsx
@@ -17,7 +17,7 @@ export async function loader({ params }: Route.LoaderArgs) {
const isValid = await getResetTokenValidity({ token });
if (!isValid) {
- redirect('/reset-password');
+ throw redirect('/reset-password');
}
return {
diff --git a/apps/remix/app/routes/_unauthenticated+/share.$slug.opengraph.tsx b/apps/remix/app/routes/_unauthenticated+/share.$slug.opengraph.tsx
new file mode 100644
index 000000000..606d85fbc
--- /dev/null
+++ b/apps/remix/app/routes/_unauthenticated+/share.$slug.opengraph.tsx
@@ -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(
+ (
+
+ {/* @ts-expect-error Lack of typing from ImageResponse */}
+
+
+ {signatureImage ? (
+
+
+
+ ) : (
+
+ {signatureName}
+
+ )}
+
+
+
+ {isRecipient ? 'Document Signed!' : 'Document Sent!'}
+
+
+
+ ),
+ {
+ ...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',
+ },
+ },
+ );
+};
diff --git a/apps/remix/app/routes/_unauthenticated+/share.$slug.tsx b/apps/remix/app/routes/_unauthenticated+/share.$slug.tsx
new file mode 100644
index 000000000..8e86de76f
--- /dev/null
+++ b/apps/remix/app/routes/_unauthenticated+/share.$slug.tsx
@@ -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());
+};
diff --git a/apps/remix/app/routes/api+/branding.logo.team.$teamId.ts b/apps/remix/app/routes/api+/branding.logo.team.$teamId.ts
new file mode 100644
index 000000000..d6bd0e738
--- /dev/null
+++ b/apps/remix/app/routes/api+/branding.logo.team.$teamId.ts
@@ -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',
+ },
+ });
+}
diff --git a/apps/remix/app/routes/api+/health.ts b/apps/remix/app/routes/api+/health.ts
new file mode 100644
index 000000000..9783157e9
--- /dev/null
+++ b/apps/remix/app/routes/api+/health.ts
@@ -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 },
+ );
+ }
+}
diff --git a/apps/remix/app/routes/api+/share.ts b/apps/remix/app/routes/api+/share.ts
new file mode 100644
index 000000000..fa768526e
--- /dev/null
+++ b/apps/remix/app/routes/api+/share.ts
@@ -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>
+ | { 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 });
+ }
+}
diff --git a/apps/remix/app/routes/api+/stripe.webhook.ts b/apps/remix/app/routes/api+/stripe.webhook.ts
new file mode 100644
index 000000000..f3c78cd44
--- /dev/null
+++ b/apps/remix/app/routes/api+/stripe.webhook.ts
@@ -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);
+}
diff --git a/apps/remix/app/routes/api+/webhook.trigger.ts b/apps/remix/app/routes/api+/webhook.trigger.ts
new file mode 100644
index 000000000..ea95bd836
--- /dev/null
+++ b/apps/remix/app/routes/api+/webhook.trigger.ts
@@ -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);
+}
diff --git a/apps/remix/app/routes/embed+/direct.$url.tsx b/apps/remix/app/routes/embed+/direct.$url.tsx
index f3cea512c..25863e779 100644
--- a/apps/remix/app/routes/embed+/direct.$url.tsx
+++ b/apps/remix/app/routes/embed+/direct.$url.tsx
@@ -1,5 +1,5 @@
import { data } from 'react-router';
-import { getRequiredSessionContext } from 'server/utils/get-required-session-context';
+import { getRequiredLoaderSession } from 'server/utils/get-required-session-context';
import { match } from 'ts-pattern';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
@@ -48,7 +48,7 @@ export async function loader({ params, context }: Route.LoaderArgs) {
);
}
- const { user } = getRequiredSessionContext(context);
+ const { user } = getRequiredLoaderSession(context);
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: template.authOptions,
diff --git a/apps/remix/app/routes/embed+/sign.$url.tsx b/apps/remix/app/routes/embed+/sign.$url.tsx
index c46507b90..aa2f412ef 100644
--- a/apps/remix/app/routes/embed+/sign.$url.tsx
+++ b/apps/remix/app/routes/embed+/sign.$url.tsx
@@ -1,5 +1,5 @@
import { data } from 'react-router';
-import { getRequiredSessionContext } from 'server/utils/get-required-session-context';
+import { getRequiredLoaderSession } from 'server/utils/get-required-session-context';
import { match } from 'ts-pattern';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
@@ -27,7 +27,7 @@ export async function loader({ params, context }: Route.LoaderArgs) {
const token = params.url;
- const { user } = getRequiredSessionContext(context);
+ const { user } = getRequiredLoaderSession(context);
const [document, fields, recipient] = await Promise.all([
getDocumentAndSenderByToken({
diff --git a/apps/remix/example/cert.p12 b/apps/remix/example/cert.p12
new file mode 100644
index 000000000..532ee19ab
Binary files /dev/null and b/apps/remix/example/cert.p12 differ
diff --git a/apps/remix/package.json b/apps/remix/package.json
index c086a99ff..b68c58181 100644
--- a/apps/remix/package.json
+++ b/apps/remix/package.json
@@ -30,6 +30,8 @@
"hono-react-router-adapter": "^0.6.2",
"isbot": "^5.1.17",
"jsonwebtoken": "^9.0.2",
+ "posthog-js": "^1.75.3",
+ "posthog-node": "^3.1.1",
"react": "^18",
"react-dom": "^18",
"react-router": "^7.1.3",
diff --git a/apps/remix/public/static/og-share-frame2.png b/apps/remix/public/static/og-share-frame2.png
new file mode 100644
index 000000000..b9a65657d
Binary files /dev/null and b/apps/remix/public/static/og-share-frame2.png differ
diff --git a/apps/remix/server/index.ts b/apps/remix/server/index.ts
index c759a4db2..0e3dab546 100644
--- a/apps/remix/server/index.ts
+++ b/apps/remix/server/index.ts
@@ -2,12 +2,15 @@
import { Hono } from 'hono';
import { PDFDocument } from 'pdf-lib';
+import { tsRestHonoApp } from '@documenso/api/hono';
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 { jobsClient } from '@documenso/lib/jobs/client';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { putFile } from '@documenso/lib/universal/upload/put-file';
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 { reactRouterTrpcServer } from './trpc/hono-trpc-remix';
@@ -18,11 +21,14 @@ const app = new Hono();
app.route('/api/auth', auth);
// API servers. Todo: Configure max durations, etc?
+app.route('/api/v1', tsRestHonoApp);
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);
+// 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.
app
.post('/api/file', async (c) => {
diff --git a/apps/remix/server/trpc/hono-trpc-open-api.ts b/apps/remix/server/trpc/hono-trpc-open-api.ts
index 1b12d7c13..16077df0f 100644
--- a/apps/remix/server/trpc/hono-trpc-open-api.ts
+++ b/apps/remix/server/trpc/hono-trpc-open-api.ts
@@ -1,6 +1,7 @@
import type { Context } from 'hono';
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 { appRouter } from '@documenso/trpc/server/router';
import { handleTrpcRouterError } from '@documenso/trpc/utils/trpc-error-handler';
@@ -9,7 +10,7 @@ import { createHonoTrpcContext } from './trpc-context';
export const openApiTrpcServerHandler = async (c: Context) => {
return createOpenApiFetchHandler({
- endpoint: '/v2/api',
+ endpoint: API_V2_BETA_URL,
router: appRouter,
// Todo: Test this, since it's not using the createContext params.
createContext: async () => createHonoTrpcContext({ c, requestSource: 'apiV2' }),
diff --git a/apps/remix/server/trpc/hono-trpc-remix.ts b/apps/remix/server/trpc/hono-trpc-remix.ts
index 24cae0458..658a30102 100644
--- a/apps/remix/server/trpc/hono-trpc-remix.ts
+++ b/apps/remix/server/trpc/hono-trpc-remix.ts
@@ -5,6 +5,7 @@ import { handleTrpcRouterError } from '@documenso/trpc/utils/trpc-error-handler'
import { createHonoTrpcContext } from './trpc-context';
+// Todo
// export const config = {
// maxDuration: 120,
// api: {
diff --git a/apps/remix/server/utils/get-required-session-context.ts b/apps/remix/server/utils/get-required-session-context.ts
index bf363d6ec..ad4345f8e 100644
--- a/apps/remix/server/utils/get-required-session-context.ts
+++ b/apps/remix/server/utils/get-required-session-context.ts
@@ -4,7 +4,7 @@ import { redirect } from 'react-router';
/**
* 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) {
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.
*/
-export const getRequiredTeamSessionContext = (context: AppLoadContext) => {
+export const getRequiredLoaderTeamSession = (context: AppLoadContext) => {
if (!context.session) {
throw redirect('/signin'); // Todo: Maybe add a redirect cookie to come back?
}
diff --git a/package-lock.json b/package-lock.json
index bf8759d6c..5db6ec70b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -126,6 +126,8 @@
"hono-react-router-adapter": "^0.6.2",
"isbot": "^5.1.17",
"jsonwebtoken": "^9.0.2",
+ "posthog-js": "^1.75.3",
+ "posthog-node": "^3.1.1",
"react": "^18",
"react-dom": "^18",
"react-router": "^7.1.3",
@@ -22490,6 +22492,12 @@
"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": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz",
@@ -37703,8 +37711,8 @@
"@documenso/lib": "*",
"@documenso/prisma": "*",
"@ts-rest/core": "^3.30.5",
- "@ts-rest/next": "^3.30.5",
"@ts-rest/open-api": "^3.33.0",
+ "@ts-rest/serverless": "^3.51.0",
"@types/swagger-ui-react": "^4.18.3",
"luxon": "^3.4.0",
"superjson": "^1.13.1",
@@ -37713,21 +37721,73 @@
"zod": "3.24.1"
}
},
- "packages/api/node_modules/@ts-rest/next": {
- "version": "3.30.5",
- "resolved": "https://registry.npmjs.org/@ts-rest/next/-/next-3.30.5.tgz",
- "integrity": "sha512-NasfUN7SnwcjJNbxvvcemC4fOv4f4IF5I14wVqQODN0HWPokkrta6XLuv0eKQJYdB32AS7VINQhls8Sj1AIN0g==",
+ "packages/api/node_modules/@ts-rest/core": {
+ "version": "3.51.0",
+ "resolved": "https://registry.npmjs.org/@ts-rest/core/-/core-3.51.0.tgz",
+ "integrity": "sha512-v6lnWEcpZj1UgN9wb84XQ+EORP1QEtncFumoXMJjno5ZUV6vdjKze3MYcQN0C6vjBpIJPQEaI/gab2jr4/0KzQ==",
+ "license": "MIT",
"peerDependencies": {
- "@ts-rest/core": "3.30.5",
- "next": "^12.0.0 || ^13.0.0",
+ "@types/node": "^18.18.7 || >=20.8.4",
"zod": "^3.22.3"
},
"peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
"zod": {
"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": {
"name": "@documenso/app-tests",
"version": "0.0.0",
diff --git a/packages/api/hono.ts b/packages/api/hono.ts
new file mode 100644
index 000000000..bc4de971a
--- /dev/null
+++ b/packages/api/hono.ts
@@ -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: {},
+ });
+});
diff --git a/packages/api/package.json b/packages/api/package.json
index 0097d000b..cf5ca07bc 100644
--- a/packages/api/package.json
+++ b/packages/api/package.json
@@ -18,8 +18,8 @@
"@documenso/lib": "*",
"@documenso/prisma": "*",
"@ts-rest/core": "^3.30.5",
- "@ts-rest/next": "^3.30.5",
"@ts-rest/open-api": "^3.33.0",
+ "@ts-rest/serverless": "^3.51.0",
"@types/swagger-ui-react": "^4.18.3",
"luxon": "^3.4.0",
"superjson": "^1.13.1",
@@ -27,4 +27,4 @@
"ts-pattern": "^5.0.5",
"zod": "3.24.1"
}
-}
\ No newline at end of file
+}
diff --git a/packages/api/v1/implementation.ts b/packages/api/v1/implementation.ts
index 0f13150c7..ac2851946 100644
--- a/packages/api/v1/implementation.ts
+++ b/packages/api/v1/implementation.ts
@@ -1,4 +1,4 @@
-import { createNextRoute } from '@ts-rest/next';
+import { tsr } from '@ts-rest/serverless/fetch';
import { match } from 'ts-pattern';
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
@@ -42,7 +42,6 @@ import {
ZRadioFieldMeta,
ZTextFieldMeta,
} 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 { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import {
@@ -62,7 +61,7 @@ import {
import { ApiContractV1 } from './contract';
import { authenticatedMiddleware } from './middleware/authenticated';
-export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
+export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
getDocuments: authenticatedMiddleware(async (args, user, team) => {
const page = Number(args.query.page) || 1;
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 { name, email, role, authOptions, signingOrder } = args.body;
@@ -887,7 +886,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
role,
signingOrder,
actionAuth: authOptions?.actionAuth,
- requestMetadata: extractNextApiRequestMetadata(args.req),
+ requestMetadata: metadata.requestMetadata,
}).catch(() => null);
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 document = await getDocumentById({
@@ -941,7 +940,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
recipientId: Number(recipientId),
userId: user.id,
teamId: team?.id,
- requestMetadata: extractNextApiRequestMetadata(args.req),
+ requestMetadata: metadata.requestMetadata,
}).catch(() => null);
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 fields = Array.isArray(args.body) ? args.body : [args.body];
@@ -1100,7 +1099,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
fieldRecipientId: recipientId,
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 { recipientId, type, pageNumber, pageWidth, pageHeight, pageX, pageY, fieldMeta } =
args.body;
@@ -1198,7 +1197,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
pageY,
pageWidth,
pageHeight,
- requestMetadata: extractNextApiRequestMetadata(args.req),
+ requestMetadata: metadata.requestMetadata,
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 document = await getDocumentById({
@@ -1286,7 +1285,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
fieldId: Number(fieldId),
userId: user.id,
teamId: team?.id,
- requestMetadata: extractNextApiRequestMetadata(args.req),
+ requestMetadata: metadata.requestMetadata,
}).catch(() => null);
if (!deletedField) {
diff --git a/packages/api/v1/middleware/authenticated.ts b/packages/api/v1/middleware/authenticated.ts
index b2ad28d2f..a09e946df 100644
--- a/packages/api/v1/middleware/authenticated.ts
+++ b/packages/api/v1/middleware/authenticated.ts
@@ -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 { getApiTokenByToken } from '@documenso/lib/server-only/public-api/get-api-token-by-token';
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';
+type B = {
+ // appRoute: any;
+ request: TsRestRequest;
+ responseHeaders: Headers;
+};
+
export const authenticatedMiddleware = <
T extends {
- req: NextApiRequest;
+ headers: {
+ authorization: string;
+ };
},
R extends {
status: number;
@@ -16,15 +24,15 @@ export const authenticatedMiddleware = <
},
>(
handler: (
- args: T,
+ args: T & { req: TsRestRequest },
user: User,
team: Team | null | undefined,
options: { metadata: ApiRequestMetadata },
) => Promise,
) => {
- return async (args: T) => {
+ return async (args: T, { request }: B) => {
try {
- const { authorization } = args.req.headers;
+ const { authorization } = args.headers;
// Support for both "Authorization: Bearer api_xxx" and "Authorization: api_xxx"
const [token] = (authorization || '').split('Bearer ').filter((s) => s.length > 0);
@@ -44,7 +52,7 @@ export const authenticatedMiddleware = <
}
const metadata: ApiRequestMetadata = {
- requestMetadata: extractNextApiRequestMetadata(args.req),
+ requestMetadata: extractRequestMetadata(request), // Todo: Test
source: 'apiV1',
auth: 'api',
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) {
console.log({ err: err });
diff --git a/packages/ee/server-only/stripe/webhook/handler.ts b/packages/ee/server-only/stripe/webhook/handler.ts
index 440be6e34..3039b97ad 100644
--- a/packages/ee/server-only/stripe/webhook/handler.ts
+++ b/packages/ee/server-only/stripe/webhook/handler.ts
@@ -1,13 +1,9 @@
-import type { NextApiRequest, NextApiResponse } from 'next';
-
-import { buffer } from 'micro';
import { match } from 'ts-pattern';
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import type { 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 { getFlag } from '@documenso/lib/universal/get-feature-flag';
import { env } from '@documenso/lib/utils/env';
import { prisma } from '@documenso/prisma';
@@ -19,37 +15,52 @@ type StripeWebhookResponse = {
message: string;
};
-export const stripeWebhookHandler = async (
- req: NextApiRequest,
- res: NextApiResponse,
-) => {
+export const stripeWebhookHandler = async (req: Request) => {
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) {
- return res.status(500).json({
- success: false,
- message: 'Billing is disabled',
- });
+ return Response.json(
+ {
+ success: false,
+ message: 'Billing is disabled',
+ } satisfies StripeWebhookResponse,
+ { status: 500 },
+ );
}
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) {
- return res.status(400).json({
- success: false,
- message: 'No signature found in request',
- });
+ return Response.json(
+ {
+ 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(
- body,
- signature,
- env('NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET'), // Todo: Test
- );
+ // It was this:
+ // const body = await buffer(req);
+
+ const event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
await match(event.type)
.with('checkout.session.completed', async () => {
@@ -93,10 +104,10 @@ export const stripeWebhookHandler = async (
: session.subscription?.id;
if (!subscriptionId) {
- return res.status(500).json({
- success: false,
- message: 'Invalid session',
- });
+ return Response.json(
+ { success: false, message: 'Invalid session' } satisfies StripeWebhookResponse,
+ { status: 500 },
+ );
}
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) {
await handleTeamSeatCheckout({ subscription });
- return res.status(200).json({
- success: true,
- message: 'Webhook received',
- });
+ return Response.json(
+ { success: true, message: 'Webhook received' } satisfies StripeWebhookResponse,
+ { status: 200 },
+ );
}
// Validate user ID.
if (!userId || Number.isNaN(userId)) {
- return res.status(500).json({
- success: false,
- message: 'Invalid session or missing user ID',
- });
+ return Response.json(
+ {
+ success: false,
+ message: 'Invalid session or missing user ID',
+ } satisfies StripeWebhookResponse,
+ { status: 500 },
+ );
}
await onSubscriptionUpdated({ userId, subscription });
- return res.status(200).json({
- success: true,
- message: 'Webhook received',
- });
+ return Response.json(
+ { success: true, message: 'Webhook received' } satisfies StripeWebhookResponse,
+ { status: 200 },
+ );
})
.with('customer.subscription.updated', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
@@ -143,18 +157,21 @@ export const stripeWebhookHandler = async (
});
if (!team) {
- return res.status(500).json({
- success: false,
- message: 'No team associated with subscription found',
- });
+ return Response.json(
+ {
+ success: false,
+ message: 'No team associated with subscription found',
+ } satisfies StripeWebhookResponse,
+ { status: 500 },
+ );
}
await onSubscriptionUpdated({ teamId: team.id, subscription });
- return res.status(200).json({
- success: true,
- message: 'Webhook received',
- });
+ return Response.json(
+ { success: true, message: 'Webhook received' } satisfies StripeWebhookResponse,
+ { status: 200 },
+ );
}
const result = await prisma.user.findFirst({
@@ -167,28 +184,37 @@ export const stripeWebhookHandler = async (
});
if (!result?.id) {
- return res.status(500).json({
- success: false,
- message: 'User not found',
- });
+ return Response.json(
+ {
+ success: false,
+ message: 'User not found',
+ } satisfies StripeWebhookResponse,
+ { status: 500 },
+ );
}
await onSubscriptionUpdated({ userId: result.id, subscription });
- return res.status(200).json({
- success: true,
- message: 'Webhook received',
- });
+ return Response.json(
+ {
+ success: true,
+ message: 'Webhook received',
+ } satisfies StripeWebhookResponse,
+ { status: 200 },
+ );
})
.with('invoice.payment_succeeded', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const invoice = event.data.object as Stripe.Invoice;
if (invoice.billing_reason !== 'subscription_cycle') {
- return res.status(200).json({
- success: true,
- message: 'Webhook received',
- });
+ return Response.json(
+ {
+ success: true,
+ message: 'Webhook received',
+ } satisfies StripeWebhookResponse,
+ { status: 200 },
+ );
}
const customerId =
@@ -200,19 +226,25 @@ export const stripeWebhookHandler = async (
: invoice.subscription?.id;
if (!customerId || !subscriptionId) {
- return res.status(500).json({
- success: false,
- message: 'Invalid invoice',
- });
+ return Response.json(
+ {
+ success: false,
+ message: 'Invalid invoice',
+ } satisfies StripeWebhookResponse,
+ { status: 500 },
+ );
}
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
if (subscription.status === 'incomplete_expired') {
- return res.status(200).json({
- success: true,
- message: 'Webhook received',
- });
+ return Response.json(
+ {
+ success: true,
+ message: 'Webhook received',
+ } satisfies StripeWebhookResponse,
+ { status: 200 },
+ );
}
if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) {
@@ -223,18 +255,24 @@ export const stripeWebhookHandler = async (
});
if (!team) {
- return res.status(500).json({
- success: false,
- message: 'No team associated with subscription found',
- });
+ return Response.json(
+ {
+ success: false,
+ message: 'No team associated with subscription found',
+ } satisfies StripeWebhookResponse,
+ { status: 500 },
+ );
}
await onSubscriptionUpdated({ teamId: team.id, subscription });
- return res.status(200).json({
- success: true,
- message: 'Webhook received',
- });
+ return Response.json(
+ {
+ success: true,
+ message: 'Webhook received',
+ } satisfies StripeWebhookResponse,
+ { status: 200 },
+ );
}
const result = await prisma.user.findFirst({
@@ -247,18 +285,24 @@ export const stripeWebhookHandler = async (
});
if (!result?.id) {
- return res.status(500).json({
- success: false,
- message: 'User not found',
- });
+ return Response.json(
+ {
+ success: false,
+ message: 'User not found',
+ } satisfies StripeWebhookResponse,
+ { status: 500 },
+ );
}
await onSubscriptionUpdated({ userId: result.id, subscription });
- return res.status(200).json({
- success: true,
- message: 'Webhook received',
- });
+ return Response.json(
+ {
+ success: true,
+ message: 'Webhook received',
+ } satisfies StripeWebhookResponse,
+ { status: 200 },
+ );
})
.with('invoice.payment_failed', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
@@ -273,19 +317,25 @@ export const stripeWebhookHandler = async (
: invoice.subscription?.id;
if (!customerId || !subscriptionId) {
- return res.status(500).json({
- success: false,
- message: 'Invalid invoice',
- });
+ return Response.json(
+ {
+ success: false,
+ message: 'Invalid invoice',
+ } satisfies StripeWebhookResponse,
+ { status: 500 },
+ );
}
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
if (subscription.status === 'incomplete_expired') {
- return res.status(200).json({
- success: true,
- message: 'Webhook received',
- });
+ return Response.json(
+ {
+ success: true,
+ message: 'Webhook received',
+ } satisfies StripeWebhookResponse,
+ { status: 200 },
+ );
}
if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) {
@@ -296,18 +346,24 @@ export const stripeWebhookHandler = async (
});
if (!team) {
- return res.status(500).json({
- success: false,
- message: 'No team associated with subscription found',
- });
+ return Response.json(
+ {
+ success: false,
+ message: 'No team associated with subscription found',
+ } satisfies StripeWebhookResponse,
+ { status: 500 },
+ );
}
await onSubscriptionUpdated({ teamId: team.id, subscription });
- return res.status(200).json({
- success: true,
- message: 'Webhook received',
- });
+ return Response.json(
+ {
+ success: true,
+ message: 'Webhook received',
+ } satisfies StripeWebhookResponse,
+ { status: 200 },
+ );
}
const result = await prisma.user.findFirst({
@@ -320,18 +376,24 @@ export const stripeWebhookHandler = async (
});
if (!result?.id) {
- return res.status(500).json({
- success: false,
- message: 'User not found',
- });
+ return Response.json(
+ {
+ success: false,
+ message: 'User not found',
+ } satisfies StripeWebhookResponse,
+ { status: 500 },
+ );
}
await onSubscriptionUpdated({ userId: result.id, subscription });
- return res.status(200).json({
- success: true,
- message: 'Webhook received',
- });
+ return Response.json(
+ {
+ success: true,
+ message: 'Webhook received',
+ } satisfies StripeWebhookResponse,
+ { status: 200 },
+ );
})
.with('customer.subscription.deleted', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
@@ -339,24 +401,33 @@ export const stripeWebhookHandler = async (
await onSubscriptionDeleted({ subscription });
- return res.status(200).json({
- success: true,
- message: 'Webhook received',
- });
+ return Response.json(
+ {
+ success: true,
+ message: 'Webhook received',
+ } satisfies StripeWebhookResponse,
+ { status: 200 },
+ );
})
.otherwise(() => {
- return res.status(200).json({
- success: true,
- message: 'Webhook received',
- });
+ return Response.json(
+ {
+ success: true,
+ message: 'Webhook received',
+ } satisfies StripeWebhookResponse,
+ { status: 200 },
+ );
});
} catch (err) {
console.error(err);
- res.status(500).json({
- success: false,
- message: 'Unknown error',
- });
+ return Response.json(
+ {
+ success: false,
+ message: 'Unknown error',
+ } satisfies StripeWebhookResponse,
+ { status: 500 },
+ );
}
};
diff --git a/packages/lib/client-only/hooks/use-analytics.ts b/packages/lib/client-only/hooks/use-analytics.ts
index e5939dddf..e6516a075 100644
--- a/packages/lib/client-only/hooks/use-analytics.ts
+++ b/packages/lib/client-only/hooks/use-analytics.ts
@@ -1,10 +1,6 @@
import { posthog } from 'posthog-js';
-import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
-import {
- FEATURE_FLAG_GLOBAL_SESSION_RECORDING,
- extractPostHogConfig,
-} from '@documenso/lib/constants/feature-flags';
+import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
export function useAnalytics() {
// const featureFlags = useFeatureFlags();
diff --git a/packages/lib/constants/app.ts b/packages/lib/constants/app.ts
index 2077df7ef..d137aea33 100644
--- a/packages/lib/constants/app.ts
+++ b/packages/lib/constants/app.ts
@@ -5,8 +5,13 @@ export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT =
export const NEXT_PUBLIC_WEBAPP_URL = () =>
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 =
env('NEXT_PRIVATE_INTERNAL_WEBAPP_URL') ?? NEXT_PUBLIC_WEBAPP_URL();
export const IS_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED') === 'true';
+
+export const API_V2_BETA_URL = '/api/v2-beta';
diff --git a/packages/lib/server-only/public-api/test-credentials.ts b/packages/lib/server-only/public-api/test-credentials.ts
index 2e20d79c4..76b5a84a1 100644
--- a/packages/lib/server-only/public-api/test-credentials.ts
+++ b/packages/lib/server-only/public-api/test-credentials.ts
@@ -1,19 +1,24 @@
-import type { NextApiRequest, NextApiResponse } from 'next';
-
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 {
- const { authorization } = req.headers;
+ const authorization = req.headers.get('authorization');
+
+ if (!authorization) {
+ throw new Error('Missing authorization header');
+ }
const result = await validateApiToken({ authorization });
- return res.status(200).json({
+ return Response.json({
name: result.team?.name ?? result.user.name,
});
} catch (err) {
- return res.status(500).json({
- message: 'Internal Server Error',
- });
+ return Response.json(
+ {
+ message: 'Internal Server Error',
+ },
+ { status: 500 },
+ );
}
};
diff --git a/packages/lib/server-only/webhooks/trigger/handler.ts b/packages/lib/server-only/webhooks/trigger/handler.ts
index 4e705efea..968f7ce5b 100644
--- a/packages/lib/server-only/webhooks/trigger/handler.ts
+++ b/packages/lib/server-only/webhooks/trigger/handler.ts
@@ -1,5 +1,3 @@
-import type { NextApiRequest, NextApiResponse } from 'next';
-
import { verify } from '../../crypto/verify';
import { getAllWebhooksByEventTrigger } from '../get-all-webhooks-by-event-trigger';
import { executeWebhook } from './execute-webhook';
@@ -15,29 +13,26 @@ export type HandlerTriggerWebhooksResponse =
error: string;
};
-export const handlerTriggerWebhooks = async (
- req: NextApiRequest,
- res: NextApiResponse,
-) => {
- const signature = req.headers['x-webhook-signature'];
+export const handlerTriggerWebhooks = async (req: Request) => {
+ const signature = req.headers.get('x-webhook-signature');
if (typeof signature !== 'string') {
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);
if (!valid) {
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);
if (!result.success) {
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;
@@ -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 },
+ );
};
diff --git a/packages/lib/server-only/webhooks/zapier/list-documents.ts b/packages/lib/server-only/webhooks/zapier/list-documents.ts
index 97f8608aa..f0c4380da 100644
--- a/packages/lib/server-only/webhooks/zapier/list-documents.ts
+++ b/packages/lib/server-only/webhooks/zapier/list-documents.ts
@@ -1,5 +1,3 @@
-import type { NextApiRequest, NextApiResponse } from 'next';
-
import type { Webhook } from '@prisma/client';
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 { validateApiToken } from './validateApiToken';
-export const listDocumentsHandler = async (req: NextApiRequest, res: NextApiResponse) => {
+export const listDocumentsHandler = async (req: Request) => {
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 });
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) {
- return res.status(500).json({
- message: 'Internal Server Error',
- });
+ return Response.json(
+ {
+ message: 'Internal Server Error',
+ },
+ { status: 500 },
+ );
}
};
diff --git a/packages/lib/server-only/webhooks/zapier/subscribe.ts b/packages/lib/server-only/webhooks/zapier/subscribe.ts
index 90c68e063..6629b5f8c 100644
--- a/packages/lib/server-only/webhooks/zapier/subscribe.ts
+++ b/packages/lib/server-only/webhooks/zapier/subscribe.ts
@@ -1,14 +1,16 @@
-import type { NextApiRequest, NextApiResponse } from 'next';
-
import { prisma } from '@documenso/prisma';
import { validateApiToken } from './validateApiToken';
-export const subscribeHandler = async (req: NextApiRequest, res: NextApiResponse) => {
+export const subscribeHandler = async (req: Request) => {
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 });
@@ -23,10 +25,13 @@ export const subscribeHandler = async (req: NextApiRequest, res: NextApiResponse
},
});
- return res.status(200).json(createdWebhook);
+ return Response.json(createdWebhook);
} catch (err) {
- return res.status(500).json({
- message: 'Internal Server Error',
- });
+ return Response.json(
+ {
+ message: 'Internal Server Error',
+ },
+ { status: 500 },
+ );
}
};
diff --git a/packages/lib/server-only/webhooks/zapier/unsubscribe.ts b/packages/lib/server-only/webhooks/zapier/unsubscribe.ts
index 07fa75e11..6e26a2c15 100644
--- a/packages/lib/server-only/webhooks/zapier/unsubscribe.ts
+++ b/packages/lib/server-only/webhooks/zapier/unsubscribe.ts
@@ -1,14 +1,16 @@
-import type { NextApiRequest, NextApiResponse } from 'next';
-
import { prisma } from '@documenso/prisma';
import { validateApiToken } from './validateApiToken';
-export const unsubscribeHandler = async (req: NextApiRequest, res: NextApiResponse) => {
+export const unsubscribeHandler = async (req: Request) => {
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 });
@@ -20,10 +22,13 @@ export const unsubscribeHandler = async (req: NextApiRequest, res: NextApiRespon
},
});
- return res.status(200).json(deletedWebhook);
+ return Response.json(deletedWebhook);
} catch (err) {
- return res.status(500).json({
- message: 'Internal Server Error',
- });
+ return Response.json(
+ {
+ message: 'Internal Server Error',
+ },
+ { status: 500 },
+ );
}
};
diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts
index 1e016590a..8221cff01 100644
--- a/packages/trpc/server/document-router/router.ts
+++ b/packages/trpc/server/document-router/router.ts
@@ -135,15 +135,6 @@ export const documentRouter = router({
* @private
*/
findDocumentsInternal: authenticatedProcedure
- .meta({
- openapi: {
- method: 'GET',
- path: '/document',
- summary: 'Find documents',
- description: 'Find documents based on a search criteria',
- tags: ['Document'],
- },
- })
.input(ZFindDocumentsInternalRequestSchema)
.output(ZFindDocumentsInternalResponseSchema)
.query(async ({ input, ctx }) => {