Compare commits

..

1 Commits

Author SHA1 Message Date
5dbbed9ba8 chore: update deps 2025-01-12 23:15:06 +11:00
961 changed files with 25727 additions and 38196 deletions

View File

@ -5,7 +5,6 @@ module.exports = {
rules: {
'@next/next/no-img-element': 'off',
'no-unreachable': 'error',
'react-hooks/exhaustive-deps': 'off',
},
settings: {
next: {

View File

@ -10,7 +10,7 @@ on:
jobs:
analyze:
name: Analyze
runs-on: ubuntu-22.04
runs-on: ubuntu-latest
permissions:
actions: read
contents: read

View File

@ -8,7 +8,7 @@ jobs:
e2e_tests:
name: 'E2E Tests'
timeout-minutes: 60
runs-on: warp-ubuntu-2204-x64-16x
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4

View File

@ -16,7 +16,7 @@
"@documenso/tailwind-config": "*",
"@documenso/trpc": "*",
"@documenso/ui": "*",
"next": "14.2.6",
"next": "14.2.23",
"next-plausible": "^3.12.0",
"nextra": "^2.13.4",
"nextra-theme-docs": "^2.13.4",
@ -27,6 +27,6 @@
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"typescript": "5.6.2"
"typescript": "5.2.2"
}
}

View File

@ -5,6 +5,5 @@
"svelte": "Svelte Integration",
"solid": "Solid Integration",
"preact": "Preact Integration",
"angular": "Angular Integration",
"css-variables": "CSS Variables"
}
}

View File

@ -1,90 +0,0 @@
---
title: Angular Integration
description: Learn how to use our embedding SDK within your Angular application.
---
# Angular Integration
Our Angular SDK provides a simple way to embed a signing experience within your Angular application. It supports both direct link templates and signing tokens.
## Installation
To install the SDK, run the following command:
```bash
npm install @documenso/embed-angular
```
## Usage
To embed a signing experience, you'll need to provide the token for the document you want to embed. This can be done in a few different ways, depending on your use case.
### Direct Link Template
If you have a direct link template, you can simply provide the token for the template to the `EmbedDirectTemplate` component.
```typescript
import { Component } from '@angular/core';
import { EmbedDirectTemplate } from '@documenso/embed-angular';
@Component({
selector: 'app-embedding',
template: `
<embed-direct-template [token]="token" />
`,
standalone: true,
imports: [EmbedDirectTemplate],
})
export class EmbeddingComponent {
token = 'YOUR_TOKEN_HERE'; // Replace with the actual token
}
```
#### Props
| Prop | Type | Description |
| ------------------- | ------------------- | ------------------------------------------------------------------------------------------ |
| token | string | The token for the document you want to embed |
| host | string (optional) | The host to be used for the signing experience, relevant for self-hosters |
| name | string (optional) | The name the signer that will be used by default for signing |
| lockName | boolean (optional) | Whether or not the name field should be locked disallowing modifications |
| email | string (optional) | The email the signer that will be used by default for signing |
| lockEmail | boolean (optional) | Whether or not the email field should be locked disallowing modifications |
| onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed |
| onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed |
| onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document |
| onFieldSigned | function (optional) | A callback function that will be called when a field is signed |
| onFieldUnsigned | function (optional) | A callback function that will be called when a field is unsigned |
### Signing Token
If you have a signing token, you can provide it to the `EmbedSignDocument` component.
```typescript
import { Component } from '@angular/core';
import { EmbedSignDocument } from '@documenso/embed-angular';
@Component({
selector: 'app-embedding',
template: `
<embed-sign-document [token]="token" />
`,
standalone: true,
imports: [EmbedSignDocument],
})
export class EmbeddingComponent {
token = 'YOUR_TOKEN_HERE'; // Replace with the actual token
}
```
#### Props
| Prop | Type | Description |
| ------------------- | ------------------- | ------------------------------------------------------------------------------------------ |
| token | string | The token for the document you want to embed |
| host | string (optional) | The host to be used for the signing experience, relevant for self-hosters |
| name | string (optional) | The name the signer that will be used by default for signing |
| lockName | boolean (optional) | Whether or not the name field should be locked disallowing modifications |
| onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed |
| onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed |
| onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document |

View File

@ -5,7 +5,7 @@ description: Learn how to use embedding to bring signing to your own website or
# Embedding
Our embedding feature lets you integrate our document signing experience into your own application or website. Whether you're building with React, Preact, Vue, Svelte, Solid, Angular, or using generalized web components, this guide will help you get started with embedding Documenso.
Our embedding feature lets you integrate our document signing experience into your own application or website. Whether you're building with React, Preact, Vue, Svelte, Solid, or using generalized web components, this guide will help you get started with embedding Documenso.
## Availability
@ -73,14 +73,13 @@ These customization options are available for both Direct Templates and Signing
We support embedding across a range of popular JavaScript frameworks, including:
| Framework | Package |
| --------- | ---------------------------------------------------------------------------------- |
| React | [@documenso/embed-react](https://www.npmjs.com/package/@documenso/embed-react) |
| Preact | [@documenso/embed-preact](https://www.npmjs.com/package/@documenso/embed-preact) |
| Vue | [@documenso/embed-vue](https://www.npmjs.com/package/@documenso/embed-vue) |
| Svelte | [@documenso/embed-svelte](https://www.npmjs.com/package/@documenso/embed-svelte) |
| Solid | [@documenso/embed-solid](https://www.npmjs.com/package/@documenso/embed-solid) |
| Angular | [@documenso/embed-angular](https://www.npmjs.com/package/@documenso/embed-angular) |
| Framework | Package |
| --------- | -------------------------------------------------------------------------------- |
| React | [@documenso/embed-react](https://www.npmjs.com/package/@documenso/embed-react) |
| Preact | [@documenso/embed-preact](https://www.npmjs.com/package/@documenso/embed-preact) |
| Vue | [@documenso/embed-vue](https://www.npmjs.com/package/@documenso/embed-vue) |
| Svelte | [@documenso/embed-svelte](https://www.npmjs.com/package/@documenso/embed-svelte) |
| Solid | [@documenso/embed-solid](https://www.npmjs.com/package/@documenso/embed-solid) |
Additionally, we provide **web components** for more generalized use. However, please note that web components are still in their early stages and haven't been extensively tested.
@ -128,7 +127,7 @@ This will show a dialog which will ask you to configure which recipient should b
## Embedding with Signing Tokens
To embed the signing process for an ordinary document, you'll need a **document signing token** for the recipient. This token provides the necessary access to load the document and facilitate the signing process securely.
To embed the signing process for an ordinary document, youll need a **document signing token** for the recipient. This token provides the necessary access to load the document and facilitate the signing process securely.
#### Instructions
@ -165,7 +164,6 @@ Once you've obtained the appropriate tokens, you can integrate the signing exper
- [Vue](/developers/embedding/vue)
- [Svelte](/developers/embedding/svelte)
- [Solid](/developers/embedding/solid)
- [Angular](/developers/embedding/angular)
If you're using **web components**, the integration process is slightly different. Keep in mind that web components are currently less tested but can still provide flexibility for general use cases.
@ -176,5 +174,4 @@ If you're using **web components**, the integration process is slightly differen
- [Svelte Integration](/developers/embedding/svelte)
- [Solid Integration](/developers/embedding/solid)
- [Preact Integration](/developers/embedding/preact)
- [Angular Integration](/developers/embedding/angular)
- [CSS Variables](/developers/embedding/css-variables)

View File

@ -13,11 +13,11 @@
"dependencies": {
"@documenso/prisma": "*",
"luxon": "^3.5.0",
"next": "14.2.6"
"next": "14.2.23"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "18.3.5",
"typescript": "5.6.2"
"@types/react": "^18",
"typescript": "5.2.2"
}
}

View File

@ -1,28 +0,0 @@
#!/usr/bin/env bash
# Exit on error.
set -eo pipefail
cd "$(dirname "$0")/.."
start_time=$(date +%s)
echo "[Build]: Extracting and compiling translations"
npm run translate --prefix ../../
echo "[Build]: Building app"
npm run build:app
echo "[Build]: Building server"
npm run build:server
# Copy over the entry point for the server.
cp server/main.js build/server/main.js
# Copy over all web.js translations
cp -r ../../packages/lib/translations build/server/hono/packages/lib/translations
# Time taken
end_time=$(date +%s)
echo "[Build]: Done in $((end_time - start_time)) seconds"

View File

@ -1,4 +0,0 @@
.react-router
build
node_modules
README.md

View File

@ -1,9 +0,0 @@
.DS_Store
/node_modules/
# React Router
/.react-router/
/build/
# Vite
vite.config.*.timestamp*

View File

@ -1,22 +0,0 @@
FROM node:20-alpine AS development-dependencies-env
COPY . /app
WORKDIR /app
RUN npm ci
FROM node:20-alpine AS production-dependencies-env
COPY ./package.json package-lock.json /app/
WORKDIR /app
RUN npm ci --omit=dev
FROM node:20-alpine AS build-env
COPY . /app/
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
WORKDIR /app
RUN npm run build
FROM node:20-alpine
COPY ./package.json package-lock.json /app/
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
COPY --from=build-env /app/build /app/build
WORKDIR /app
CMD ["npm", "run", "start"]

View File

@ -1,25 +0,0 @@
FROM oven/bun:1 AS dependencies-env
COPY . /app
FROM dependencies-env AS development-dependencies-env
COPY ./package.json bun.lockb /app/
WORKDIR /app
RUN bun i --frozen-lockfile
FROM dependencies-env AS production-dependencies-env
COPY ./package.json bun.lockb /app/
WORKDIR /app
RUN bun i --production
FROM dependencies-env AS build-env
COPY ./package.json bun.lockb /app/
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
WORKDIR /app
RUN bun run build
FROM dependencies-env
COPY ./package.json bun.lockb /app/
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
COPY --from=build-env /app/build /app/build
WORKDIR /app
CMD ["bun", "run", "start"]

View File

@ -1,26 +0,0 @@
FROM node:20-alpine AS dependencies-env
RUN npm i -g pnpm
COPY . /app
FROM dependencies-env AS development-dependencies-env
COPY ./package.json pnpm-lock.yaml /app/
WORKDIR /app
RUN pnpm i --frozen-lockfile
FROM dependencies-env AS production-dependencies-env
COPY ./package.json pnpm-lock.yaml /app/
WORKDIR /app
RUN pnpm i --prod --frozen-lockfile
FROM dependencies-env AS build-env
COPY ./package.json pnpm-lock.yaml /app/
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
WORKDIR /app
RUN pnpm build
FROM dependencies-env
COPY ./package.json pnpm-lock.yaml /app/
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
COPY --from=build-env /app/build /app/build
WORKDIR /app
CMD ["pnpm", "start"]

View File

@ -1,100 +0,0 @@
# Welcome to React Router!
A modern, production-ready template for building full-stack React applications using React Router.
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default)
## Features
- 🚀 Server-side rendering
- ⚡️ Hot Module Replacement (HMR)
- 📦 Asset bundling and optimization
- 🔄 Data loading and mutations
- 🔒 TypeScript by default
- 🎉 TailwindCSS for styling
- 📖 [React Router docs](https://reactrouter.com/)
## Getting Started
### Installation
Install the dependencies:
```bash
npm install
```
### Development
Start the development server with HMR:
```bash
npm run dev
```
Your application will be available at `http://localhost:5173`.
## Building for Production
Create a production build:
```bash
npm run build
```
## Deployment
### Docker Deployment
This template includes three Dockerfiles optimized for different package managers:
- `Dockerfile` - for npm
- `Dockerfile.pnpm` - for pnpm
- `Dockerfile.bun` - for bun
To build and run using Docker:
```bash
# For npm
docker build -t my-app .
# For pnpm
docker build -f Dockerfile.pnpm -t my-app .
# For bun
docker build -f Dockerfile.bun -t my-app .
# Run the container
docker run -p 3000:3000 my-app
```
The containerized application can be deployed to any platform that supports Docker, including:
- AWS ECS
- Google Cloud Run
- Azure Container Apps
- Digital Ocean App Platform
- Fly.io
- Railway
### DIY Deployment
If you're familiar with deploying Node applications, the built-in app server is production-ready.
Make sure to deploy the output of `npm run build`
```
├── package.json
├── package-lock.json (or pnpm-lock.yaml, or bun.lockb)
├── build/
│ ├── client/ # Static assets
│ └── server/ # Server-side code
```
## Styling
This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer.
---
Built with ❤️ using React Router.

View File

@ -1,24 +0,0 @@
@import '@documenso/ui/styles/theme.css';
@font-face {
font-family: 'Inter';
src: url('/public/fonts/inter-regular.ttf') format('ttf');
/* font-weight: 400;
font-style: normal;
font-display: swap; */
}
@font-face {
font-family: 'Caveat';
src: url('/public/fonts/caveat.ttf') format('ttf');
/* font-weight: 400;
font-style: normal;
font-display: swap; */
}
@layer base {
:root {
--font-sans: 'Inter';
--font-signature: 'Caveat';
}
}

View File

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

View File

@ -1,98 +0,0 @@
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { motion } from 'framer-motion';
import { ChevronLeft } from 'lucide-react';
import { Link, useNavigate } from 'react-router';
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { useOptionalCurrentTeam } from '~/providers/team';
export type GenericErrorLayoutProps = {
children?: React.ReactNode;
errorCode?: number;
};
export const ErrorLayoutCodes: Record<
number,
{ subHeading: MessageDescriptor; heading: MessageDescriptor; message: MessageDescriptor }
> = {
404: {
subHeading: msg`404 Page not found`,
heading: msg`Oops! Something went wrong.`,
message: msg`The page you are looking for was moved, removed, renamed or might never have existed.`,
},
500: {
subHeading: msg`500 Internal Server Error`,
heading: msg`Oops! Something went wrong.`,
message: msg`An unexpected error occurred.`,
},
};
export const GenericErrorLayout = ({ children, errorCode }: GenericErrorLayoutProps) => {
const navigate = useNavigate();
const { _ } = useLingui();
const team = useOptionalCurrentTeam();
const { subHeading, heading, message } =
ErrorLayoutCodes[errorCode || 404] ?? ErrorLayoutCodes[404];
return (
<div className={cn('relative max-w-[100vw] overflow-hidden')}>
<div className="absolute -inset-24 -z-10">
<motion.div
className="flex h-full w-full items-center justify-center"
initial={{ opacity: 0 }}
animate={{ opacity: 0.8, transition: { duration: 0.5, delay: 0.5 } }}
>
<img
src={backgroundPattern}
alt="background pattern"
className="-ml-[50vw] -mt-[15vh] h-full scale-100 object-cover md:scale-100 lg:scale-[100%] dark:contrast-[70%] dark:invert dark:sepia"
style={{
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
}}
/>
</motion.div>
</div>
<div className="container mx-auto flex h-full min-h-screen items-center justify-center px-6 py-32">
<div>
<p className="text-muted-foreground font-semibold">{_(subHeading)}</p>
<h1 className="mt-3 text-2xl font-bold md:text-3xl">{_(heading)}</h1>
<p className="text-muted-foreground mt-4 text-sm">{_(message)}</p>
<div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center">
<Button
variant="ghost"
className="w-32"
onClick={() => {
void navigate(-1);
}}
>
<ChevronLeft className="mr-2 h-4 w-4" />
<Trans>Go Back</Trans>
</Button>
<Button asChild>
<Link to={formatDocumentsPath(team?.url)}>
<Trans>Documents</Trans>
</Link>
</Button>
{children}
</div>
</div>
</div>
</div>
);
};

View File

@ -1,203 +0,0 @@
import { useMemo, useTransition } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Loader } from 'lucide-react';
import { DateTime } from 'luxon';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/schema';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
import { DocumentStatus } from '~/components/general/document/document-status';
import { useOptionalCurrentTeam } from '~/providers/team';
import { StackAvatarsWithTooltip } from '../general/stack-avatars-with-tooltip';
import { DocumentsTableActionButton } from './documents-table-action-button';
import { DocumentsTableActionDropdown } from './documents-table-action-dropdown';
export type DocumentsTableProps = {
data?: TFindDocumentsResponse;
isLoading?: boolean;
isLoadingError?: boolean;
};
type DocumentsTableRow = TFindDocumentsResponse['data'][number];
export const DocumentsTable = ({ data, isLoading, isLoadingError }: DocumentsTableProps) => {
const { _, i18n } = useLingui();
const team = useOptionalCurrentTeam();
const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams();
const columns = useMemo(() => {
return [
{
header: _(msg`Created`),
accessorKey: 'createdAt',
cell: ({ row }) =>
i18n.date(row.original.createdAt, { ...DateTime.DATETIME_SHORT, hourCycle: 'h12' }),
},
{
header: _(msg`Title`),
cell: ({ row }) => <DataTableTitle row={row.original} teamUrl={team?.url} />,
},
{
id: 'sender',
header: _(msg`Sender`),
cell: ({ row }) => row.original.user.name ?? row.original.user.email,
},
{
header: _(msg`Recipient`),
accessorKey: 'recipient',
cell: ({ row }) => (
<StackAvatarsWithTooltip
recipients={row.original.recipients}
documentStatus={row.original.status}
/>
),
},
{
header: _(msg`Status`),
accessorKey: 'status',
cell: ({ row }) => <DocumentStatus status={row.original.status} />,
size: 140,
},
{
header: _(msg`Actions`),
cell: ({ row }) =>
(!row.original.deletedAt || row.original.status === ExtendedDocumentStatus.COMPLETED) && (
<div className="flex items-center gap-x-4">
<DocumentsTableActionButton row={row.original} />
<DocumentsTableActionDropdown row={row.original} />
</div>
),
},
] satisfies DataTableColumnDef<DocumentsTableRow>[];
}, [team]);
const onPaginationChange = (page: number, perPage: number) => {
startTransition(() => {
updateSearchParams({
page,
perPage,
});
});
};
const results = data ?? {
data: [],
perPage: 10,
currentPage: 1,
totalPages: 1,
};
return (
<div className="relative">
<DataTable
columns={columns}
data={results.data}
perPage={results.perPage}
currentPage={results.currentPage}
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
columnVisibility={{
sender: team !== undefined,
}}
error={{
enable: isLoadingError || false,
}}
skeleton={{
enable: isLoading || false,
rows: 5,
component: (
<>
<TableCell>
<Skeleton className="h-4 w-40 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-20 rounded-full" />
</TableCell>
<TableCell className="py-4">
<div className="flex w-full flex-row items-center">
<Skeleton className="h-10 w-10 flex-shrink-0 rounded-full" />
</div>
</TableCell>
<TableCell>
<Skeleton className="h-4 w-20 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-10 w-24 rounded" />
</TableCell>
</>
),
}}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>
{isPending && (
<div className="bg-background/50 absolute inset-0 flex items-center justify-center">
<Loader className="text-muted-foreground h-8 w-8 animate-spin" />
</div>
)}
</div>
);
};
type DataTableTitleProps = {
row: DocumentsTableRow;
teamUrl?: string;
};
const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => {
const { user } = useSession();
const recipient = row.recipients.find((recipient) => recipient.email === user.email);
const isOwner = row.user.id === user.id;
const isRecipient = !!recipient;
const isCurrentTeamDocument = teamUrl && row.team?.url === teamUrl;
const documentsPath = formatDocumentsPath(isCurrentTeamDocument ? teamUrl : undefined);
return match({
isOwner,
isRecipient,
isCurrentTeamDocument,
})
.with({ isOwner: true }, { isCurrentTeamDocument: true }, () => (
<Link
to={`${documentsPath}/${row.id}`}
title={row.title}
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
>
{row.title}
</Link>
))
.with({ isRecipient: true }, () => (
<Link
to={`/sign/${recipient?.token}`}
title={row.title}
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
>
{row.title}
</Link>
))
.otherwise(() => (
<span className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]">
{row.title}
</span>
));
};

View File

@ -1,29 +0,0 @@
import { StrictMode, startTransition } from 'react';
import { i18n } from '@lingui/core';
import { detect, fromHtmlTag } from '@lingui/detect-locale';
import { I18nProvider } from '@lingui/react';
import { hydrateRoot } from 'react-dom/client';
import { HydratedRouter } from 'react-router/dom';
import { dynamicActivate } from '@documenso/lib/utils/i18n';
async function main() {
const locale = detect(fromHtmlTag('lang')) || 'en';
await dynamicActivate(locale);
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<I18nProvider i18n={i18n}>
<HydratedRouter />
</I18nProvider>
</StrictMode>,
);
});
}
// eslint-disable-next-line @typescript-eslint/no-floating-promises
main();

View File

@ -1,82 +0,0 @@
import { i18n } from '@lingui/core';
import { I18nProvider } from '@lingui/react';
import { createReadableStreamFromReadable } from '@react-router/node';
import { isbot } from 'isbot';
import { PassThrough } from 'node:stream';
import type { RenderToPipeableStreamOptions } from 'react-dom/server';
import { renderToPipeableStream } from 'react-dom/server';
import type { AppLoadContext, EntryContext } from 'react-router';
import { ServerRouter } from 'react-router';
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
import { dynamicActivate, extractLocaleData } from '@documenso/lib/utils/i18n';
import { langCookie } from './storage/lang-cookie.server';
export const streamTimeout = 5_000;
export default async function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
_loadContext: AppLoadContext,
) {
let language = await langCookie.parse(request.headers.get('cookie') ?? '');
if (!APP_I18N_OPTIONS.supportedLangs.includes(language)) {
language = extractLocaleData({ headers: request.headers }).lang;
}
await dynamicActivate(language);
return new Promise((resolve, reject) => {
let shellRendered = false;
const userAgent = request.headers.get('user-agent');
// Ensure requests from bots and SPA Mode renders wait for all content to load before responding
// https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
const readyOption: keyof RenderToPipeableStreamOptions =
(userAgent && isbot(userAgent)) || routerContext.isSpaMode ? 'onAllReady' : 'onShellReady';
const { pipe, abort } = renderToPipeableStream(
<I18nProvider i18n={i18n}>
<ServerRouter context={routerContext} url={request.url} />
</I18nProvider>,
{
[readyOption]() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set('Content-Type', 'text/html');
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
}),
);
pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
}
},
},
);
// Abort the rendering stream after the `streamTimeout` so it has time to
// flush down the rejected boundaries
setTimeout(abort, streamTimeout + 1000);
});
}

View File

@ -1,164 +0,0 @@
import { Suspense, useEffect } from 'react';
import Plausible from 'plausible-tracker';
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
data,
isRouteErrorResponse,
useLoaderData,
useLocation,
} from 'react-router';
import { ThemeProvider } from 'remix-themes';
import { getOptionalLoaderSession } from 'server/utils/get-loader-session';
import { SessionProvider } from '@documenso/lib/client-only/providers/session';
import { APP_I18N_OPTIONS, type SupportedLanguageCodes } from '@documenso/lib/constants/i18n';
import { createPublicEnv } from '@documenso/lib/utils/env';
import { extractLocaleData } from '@documenso/lib/utils/i18n';
import { TrpcProvider } from '@documenso/trpc/react';
import { Toaster } from '@documenso/ui/primitives/toaster';
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 { RefreshOnFocus } from './components/general/refresh-on-focus';
import { PostHogPageview } from './providers/posthog';
import { langCookie } from './storage/lang-cookie.server';
import { themeSessionResolver } from './storage/theme-session.server';
import { appMetaTags } from './utils/meta';
const { trackPageview } = Plausible({
domain: 'documenso.com',
});
export const links: Route.LinksFunction = () => [
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
{
rel: 'preconnect',
href: 'https://fonts.gstatic.com',
crossOrigin: 'anonymous',
},
{
rel: 'stylesheet',
href: 'https://fonts.googleapis.com/css2?family=Caveat:wght@400..600&display=swap',
},
{
rel: 'stylesheet',
href: 'https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap',
},
{ rel: 'stylesheet', href: stylesheet },
];
export function meta() {
return appMetaTags();
}
export async function loader({ request }: Route.LoaderArgs) {
const session = getOptionalLoaderSession();
const { getTheme } = await themeSessionResolver(request);
let lang: SupportedLanguageCodes = await langCookie.parse(request.headers.get('cookie') ?? '');
if (!APP_I18N_OPTIONS.supportedLangs.includes(lang)) {
lang = extractLocaleData({ headers: request.headers }).lang;
}
return data(
{
lang,
theme: getTheme(),
session,
publicEnv: createPublicEnv(),
},
{
headers: {
'Set-Cookie': await langCookie.serialize(lang),
},
},
);
}
export function Layout({ children }: { children: React.ReactNode }) {
const { publicEnv, theme, lang } = useLoaderData<typeof loader>() || {};
// const [theme] = useTheme();
return (
<html translate="no" lang={lang} data-theme={theme ?? ''}>
<head>
<meta charSet="utf-8" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="google" content="notranslate" />
<Meta />
<Links />
<meta name="google" content="notranslate" />
{/* <PreventFlashOnWrongTheme ssrTheme={Boolean(theme)} /> */}
<Suspense>
<PostHogPageview />
</Suspense>
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
{/* Todo: Do we want this here? */}
<RefreshOnFocus />
<script
dangerouslySetInnerHTML={{
__html: `window.__ENV__ = ${JSON.stringify(publicEnv)}`,
}}
/>
</body>
</html>
);
}
export default function App({ loaderData }: Route.ComponentProps) {
const location = useLocation();
useEffect(() => {
trackPageview();
}, [location.pathname]);
return (
<SessionProvider session={loaderData.session}>
{/* Todo: Themes (this won't work for now) */}
<ThemeProvider specifiedTheme={loaderData.theme} themeAction="/api/theme">
<TooltipProvider>
<TrpcProvider>
<Outlet />
<Toaster />
</TrpcProvider>
</TooltipProvider>
</ThemeProvider>
</SessionProvider>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
console.error('[RootErrorBoundary]', error);
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
if (isRouteErrorResponse(error)) {
console.log(error.data);
console.log(error.status);
console.log(error.statusText);
}
return <GenericErrorLayout errorCode={errorCode} />;
}

View File

@ -1,13 +0,0 @@
import { remixRoutesOptionAdapter } from '@react-router/remix-routes-option-adapter';
import { flatRoutes } from 'remix-flat-routes';
export default remixRoutesOptionAdapter((defineRoutes) => {
return flatRoutes('routes', defineRoutes, {
ignoredRouteFiles: ['**/.*'], // Ignore dot files (like .DS_Store)
//appDir: 'app',
//routeDir: 'routes',
//basePath: '/',
//paramPrefixChar: '$',
//routeRegex: /(([+][\/\\][^\/\\:?*]+)|[\/\\]((index|route|layout|page)|(_[^\/\\:?*]+)|([^\/\\:?*]+\.route)))\.(ts|tsx|js|jsx|md|mdx)$$/,
});
});

View File

@ -1,53 +0,0 @@
import { Outlet } from 'react-router';
import { getLoaderSession } from 'server/utils/get-loader-session';
import { getLimits } from '@documenso/ee/server-only/limits/client';
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client';
import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
import { SITE_SETTINGS_BANNER_ID } from '@documenso/lib/server-only/site-settings/schemas/banner';
import { AppBanner } from '~/components/general/app-banner';
import { Header } from '~/components/general/app-header';
import { VerifyEmailBanner } from '~/components/general/verify-email-banner';
import type { Route } from './+types/_layout';
export const loader = async ({ request }: Route.LoaderArgs) => {
const { user, teams, currentTeam } = getLoaderSession();
const requestHeaders = Object.fromEntries(request.headers.entries());
// Todo: Should only load this on first render.
const [limits, banner] = await Promise.all([
getLimits({ headers: requestHeaders, teamId: currentTeam?.id }),
getSiteSettings().then((settings) =>
settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
),
]);
return {
user,
teams,
banner,
limits,
teamId: currentTeam?.id,
};
};
export default function Layout({ loaderData }: Route.ComponentProps) {
const { user, teams, banner, limits, teamId } = loaderData;
return (
<LimitsProvider initialValue={limits} teamId={teamId}>
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
{banner && <AppBanner banner={banner} />}
<Header user={user} teams={teams} />
<main className="mt-8 pb-8 md:mt-12 md:pb-12">
<Outlet />
</main>
</LimitsProvider>
);
}

View File

@ -1,9 +0,0 @@
import { redirect } from 'react-router';
export function loader() {
throw redirect('/admin/stats');
}
export default function AdminPage() {
// Redirect page.
}

View File

@ -1,120 +0,0 @@
import { Trans } from '@lingui/react/macro';
import { BarChart3, FileStack, Settings, Trophy, Users, Wallet2 } from 'lucide-react';
import { Link, Outlet, redirect, useLocation } from 'react-router';
import { getLoaderSession } from 'server/utils/get-loader-session';
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
export function loader() {
const { user } = getLoaderSession();
if (!user || !isAdmin(user)) {
throw redirect('/documents');
}
}
export default function AdminLayout() {
const { pathname } = useLocation();
return (
<div className="mx-auto mt-16 w-full max-w-screen-xl px-4 md:px-8">
<div className="grid grid-cols-12 md:mt-8 md:gap-8">
<div
className={cn(
'col-span-12 flex gap-x-2.5 gap-y-2 overflow-hidden overflow-x-auto md:col-span-3 md:flex md:flex-col',
)}
>
<Button
variant="ghost"
className={cn(
'justify-start md:w-full',
pathname?.startsWith('/admin/stats') && 'bg-secondary',
)}
asChild
>
<Link to="/admin/stats">
<BarChart3 className="mr-2 h-5 w-5" />
<Trans>Stats</Trans>
</Link>
</Button>
<Button
variant="ghost"
className={cn(
'justify-start md:w-full',
pathname?.startsWith('/admin/users') && 'bg-secondary',
)}
asChild
>
<Link to="/admin/users">
<Users className="mr-2 h-5 w-5" />
<Trans>Users</Trans>
</Link>
</Button>
<Button
variant="ghost"
className={cn(
'justify-start md:w-full',
pathname?.startsWith('/admin/documents') && 'bg-secondary',
)}
asChild
>
<Link to="/admin/documents">
<FileStack className="mr-2 h-5 w-5" />
<Trans>Documents</Trans>
</Link>
</Button>
<Button
variant="ghost"
className={cn(
'justify-start md:w-full',
pathname?.startsWith('/admin/subscriptions') && 'bg-secondary',
)}
asChild
>
<Link to="/admin/subscriptions">
<Wallet2 className="mr-2 h-5 w-5" />
<Trans>Subscriptions</Trans>
</Link>
</Button>
<Button
variant="ghost"
className={cn(
'justify-start md:w-full',
pathname?.startsWith('/admin/leaderboard') && 'bg-secondary',
)}
asChild
>
<Link to="/admin/leaderboard">
<Trophy className="mr-2 h-5 w-5" />
<Trans>Leaderboard</Trans>
</Link>
</Button>
<Button
variant="ghost"
className={cn(
'justify-start md:w-full',
pathname?.startsWith('/admin/banner') && 'bg-secondary',
)}
asChild
>
<Link to="/admin/site-settings">
<Settings className="mr-2 h-5 w-5" />
<Trans>Site Settings</Trans>
</Link>
</Button>
</div>
<div className="col-span-12 mt-12 md:col-span-9 md:mt-0">
<Outlet />
</div>
</div>
</div>
);
}

View File

@ -1,168 +0,0 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { SigningStatus } from '@prisma/client';
import { DateTime } from 'luxon';
import { Link, redirect } from 'react-router';
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
import { trpc } from '@documenso/trpc/react';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@documenso/ui/primitives/accordion';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { AdminDocumentDeleteDialog } from '~/components/dialogs/admin-document-delete-dialog';
import { DocumentStatus } from '~/components/general/document/document-status';
import { AdminDocumentRecipientItemTable } from '~/components/tables/admin-document-recipient-item-table';
import type { Route } from './+types/documents.$id';
export async function loader({ params }: Route.LoaderArgs) {
const id = Number(params.id);
// Todo: Is it possible for this to return data to the frontend w/out auth layout due to race condition?
// Todo: Is it possible for this to return data to the frontend w/out auth layout due to race condition?
// Todo: Is it possible for this to return data to the frontend w/out auth layout due to race condition?
if (isNaN(id)) {
throw redirect('/admin/documents');
}
const document = await getEntireDocument({ id });
return { document };
}
export default function AdminDocumentDetailsPage({ loaderData }: Route.ComponentProps) {
const { document } = loaderData;
const { _, i18n } = useLingui();
const { toast } = useToast();
const { mutate: resealDocument, isPending: isResealDocumentLoading } =
trpc.admin.resealDocument.useMutation({
onSuccess: () => {
toast({
title: _(msg`Success`),
description: _(msg`Document resealed`),
});
},
onError: () => {
toast({
title: _(msg`Error`),
description: _(msg`Failed to reseal document`),
variant: 'destructive',
});
},
});
return (
<div>
<div className="flex items-start justify-between">
<div className="flex items-center gap-x-4">
<h1 className="text-2xl font-semibold">{document.title}</h1>
<DocumentStatus status={document.status} />
</div>
{document.deletedAt && (
<Badge size="large" variant="destructive">
<Trans>Deleted</Trans>
</Badge>
)}
</div>
<div className="text-muted-foreground mt-4 text-sm">
<div>
<Trans>Created on</Trans>: {i18n.date(document.createdAt, DateTime.DATETIME_MED)}
</div>
<div>
<Trans>Last updated at</Trans>: {i18n.date(document.updatedAt, DateTime.DATETIME_MED)}
</div>
</div>
<hr className="my-4" />
<h2 className="text-lg font-semibold">
<Trans>Admin Actions</Trans>
</h2>
<div className="mt-2 flex gap-x-4">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
loading={isResealDocumentLoading}
disabled={document.recipients.some(
(recipient) => recipient.signingStatus !== SigningStatus.SIGNED,
)}
onClick={() => resealDocument({ id: document.id })}
>
<Trans>Reseal document</Trans>
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-[40ch]">
<Trans>
Attempts sealing the document again, useful for after a code change has occurred to
resolve an erroneous document.
</Trans>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button variant="outline" asChild>
<Link to={`/admin/users/${document.userId}`}>
<Trans>Go to owner</Trans>
</Link>
</Button>
</div>
<hr className="my-4" />
<h2 className="text-lg font-semibold">
<Trans>Recipients</Trans>
</h2>
<div className="mt-4">
<Accordion type="multiple" className="space-y-4">
{document.recipients.map((recipient) => (
<AccordionItem
key={recipient.id}
value={recipient.id.toString()}
className="rounded-lg border"
>
<AccordionTrigger className="px-4">
<div className="flex items-center gap-x-4">
<h4 className="font-semibold">{recipient.name}</h4>
<Badge size="small" variant="neutral">
{recipient.email}
</Badge>
</div>
</AccordionTrigger>
<AccordionContent className="border-t px-4 pt-4">
<AdminDocumentRecipientItemTable recipient={recipient} />
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
<hr className="my-4" />
{document && <AdminDocumentDeleteDialog document={document} />}
</div>
);
}

View File

@ -1,66 +0,0 @@
import { Trans } from '@lingui/react/macro';
import { getSigningVolume } from '@documenso/lib/server-only/admin/get-signing-volume';
import { AdminLeaderboardTable } from '~/components/tables/admin-leaderboard-table';
import type { Route } from './+types/leaderboard';
export async function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url);
const rawSortBy = url.searchParams.get('sortBy') || 'signingVolume';
const rawSortOrder = url.searchParams.get('sortOrder') || 'desc';
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const sortOrder = (['asc', 'desc'].includes(rawSortOrder) ? rawSortOrder : 'desc') as
| 'asc'
| 'desc';
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const sortBy = (
['name', 'createdAt', 'signingVolume'].includes(rawSortBy) ? rawSortBy : 'signingVolume'
) as 'name' | 'createdAt' | 'signingVolume';
const page = Number(url.searchParams.get('page')) || 1;
const perPage = Number(url.searchParams.get('perPage')) || 10;
const search = url.searchParams.get('search') || '';
const { leaderboard: signingVolume, totalPages } = await getSigningVolume({
search,
page,
perPage,
sortBy,
sortOrder,
});
return {
signingVolume,
totalPages,
page,
perPage,
sortBy,
sortOrder,
};
}
export default function Leaderboard({ loaderData }: Route.ComponentProps) {
const { signingVolume, totalPages, page, perPage, sortBy, sortOrder } = loaderData;
return (
<div>
<h2 className="text-4xl font-semibold">
<Trans>Signing Volume</Trans>
</h2>
<div className="mt-8">
<AdminLeaderboardTable
signingVolume={signingVolume}
totalPages={totalPages}
page={page}
perPage={perPage}
sortBy={sortBy}
sortOrder={sortOrder}
/>
</div>
</div>
);
}

View File

@ -1,224 +0,0 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import type { z } from 'zod';
import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
import {
SITE_SETTINGS_BANNER_ID,
ZSiteSettingsBannerSchema,
} from '@documenso/lib/server-only/site-settings/schemas/banner';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { ColorPicker } from '@documenso/ui/primitives/color-picker';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Switch } from '@documenso/ui/primitives/switch';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { SettingsHeader } from '~/components/general/settings-header';
import type { Route } from './+types/site-settings';
const ZBannerFormSchema = ZSiteSettingsBannerSchema;
type TBannerFormSchema = z.infer<typeof ZBannerFormSchema>;
export async function loader() {
const banner = await getSiteSettings().then((settings) =>
settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
);
return { banner };
}
export default function AdminBannerPage({ loaderData }: Route.ComponentProps) {
const { banner } = loaderData;
const { toast } = useToast();
const { _ } = useLingui();
const { revalidate } = useRevalidator();
const form = useForm<TBannerFormSchema>({
resolver: zodResolver(ZBannerFormSchema),
defaultValues: {
id: SITE_SETTINGS_BANNER_ID,
enabled: banner?.enabled ?? false,
data: {
content: banner?.data?.content ?? '',
bgColor: banner?.data?.bgColor ?? '#000000',
textColor: banner?.data?.textColor ?? '#FFFFFF',
},
},
});
const enabled = form.watch('enabled');
const { mutateAsync: updateSiteSetting, isPending: isUpdateSiteSettingLoading } =
trpcReact.admin.updateSiteSetting.useMutation();
const onBannerUpdate = async ({ id, enabled, data }: TBannerFormSchema) => {
try {
await updateSiteSetting({
id,
enabled,
data,
});
toast({
title: _(msg`Banner Updated`),
description: _(msg`Your banner has been updated successfully.`),
duration: 5000,
});
await revalidate();
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),
variant: 'destructive',
description: _(
msg`We encountered an unknown error while attempting to update the banner. Please try again later.`,
),
});
}
};
return (
<div>
<SettingsHeader
title={_(msg`Site Settings`)}
subtitle={_(msg`Manage your site settings here`)}
/>
<div className="mt-8">
<div>
<h2 className="font-semibold">
<Trans>Site Banner</Trans>
</h2>
<p className="text-muted-foreground mt-2 text-sm">
<Trans>
The site banner is a message that is shown at the top of the site. It can be used to
display important information to your users.
</Trans>
</p>
<Form {...form}>
<form
className="mt-4 flex flex-col rounded-md"
onSubmit={form.handleSubmit(onBannerUpdate)}
>
<div className="mt-4 flex flex-col gap-4 md:flex-row">
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Enabled</Trans>
</FormLabel>
<FormControl>
<div>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</div>
</FormControl>
</FormItem>
)}
/>
<fieldset
className="flex flex-col gap-4 md:flex-row"
disabled={!enabled}
aria-disabled={!enabled}
>
<FormField
control={form.control}
name="data.bgColor"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Background Color</Trans>
</FormLabel>
<FormControl>
<div>
<ColorPicker {...field} />
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="data.textColor"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Text Color</Trans>
</FormLabel>
<FormControl>
<div>
<ColorPicker {...field} />
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
</div>
<fieldset disabled={!enabled} aria-disabled={!enabled}>
<FormField
control={form.control}
name="data.content"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Content</Trans>
</FormLabel>
<FormControl>
<Textarea className="h-32 resize-none" {...field} />
</FormControl>
<FormDescription>
<Trans>The content to show in the banner, HTML is allowed</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
<Button
type="submit"
loading={isUpdateSiteSettingLoading}
className="mt-4 justify-end self-end"
>
<Trans>Update Banner</Trans>
</Button>
</form>
</Form>
</div>
</div>
</div>
);
}

View File

@ -1,52 +0,0 @@
import { Trans } from '@lingui/react/macro';
import { getPricesByPlan } from '@documenso/ee/server-only/stripe/get-prices-by-plan';
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { findUsers } from '@documenso/lib/server-only/user/get-all-users';
import { AdminDashboardUsersTable } from '~/components/tables/admin-dashboard-users-table';
import type { Route } from './+types/users._index';
export async function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url);
const page = Number(url.searchParams.get('page')) || 1;
const perPage = Number(url.searchParams.get('perPage')) || 10;
const search = url.searchParams.get('search') || '';
const [{ users, totalPages }, individualPrices] = await Promise.all([
findUsers({ username: search, email: search, page, perPage }),
getPricesByPlan([STRIPE_PLAN_TYPE.REGULAR, STRIPE_PLAN_TYPE.COMMUNITY]).catch(() => []),
]);
const individualPriceIds = individualPrices.map((price) => price.id);
return {
users,
totalPages,
individualPriceIds,
page,
perPage,
};
}
export default function AdminManageUsersPage({ loaderData }: Route.ComponentProps) {
const { users, totalPages, individualPriceIds, page, perPage } = loaderData;
return (
<div>
<h2 className="text-4xl font-semibold">
<Trans>Manage users</Trans>
</h2>
<AdminDashboardUsersTable
users={users}
individualPriceIds={individualPriceIds}
totalPages={totalPages}
page={page}
perPage={perPage}
/>
</div>
);
}

View File

@ -1,156 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { useSearchParams } from 'react-router';
import { Link } from 'react-router';
import { z } from 'zod';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { parseToIntegerArray } from '@documenso/lib/utils/params';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { trpc } from '@documenso/trpc/react';
import {
type TFindDocumentsInternalResponse,
ZFindDocumentsInternalRequestSchema,
} from '@documenso/trpc/server/document-router/schema';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { DocumentSearch } from '~/components/general/document/document-search';
import { DocumentStatus } from '~/components/general/document/document-status';
import { DocumentUploadDropzone } from '~/components/general/document/document-upload';
import { PeriodSelector } from '~/components/general/period-selector';
import { DocumentsTable } from '~/components/tables/documents-table';
import { DocumentsTableEmptyState } from '~/components/tables/documents-table-empty-state';
import { DocumentsTableSenderFilter } from '~/components/tables/documents-table-sender-filter';
import { useOptionalCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Documents');
}
const ZSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({
status: true,
period: true,
page: true,
perPage: true,
query: true,
}).extend({
senderIds: z.string().transform(parseToIntegerArray).optional().catch([]),
});
export default function DocumentsPage() {
const [searchParams] = useSearchParams();
const team = useOptionalCurrentTeam();
const [stats, setStats] = useState<TFindDocumentsInternalResponse['stats']>({
[ExtendedDocumentStatus.DRAFT]: 0,
[ExtendedDocumentStatus.PENDING]: 0,
[ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.INBOX]: 0,
[ExtendedDocumentStatus.ALL]: 0,
});
const findDocumentSearchParams = useMemo(
() => ZSearchParamsSchema.safeParse(Object.fromEntries(searchParams.entries())).data || {},
[searchParams],
);
const { data, isLoading, isLoadingError } = trpc.document.findDocumentsInternal.useQuery({
...findDocumentSearchParams,
});
const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => {
const params = new URLSearchParams(searchParams);
params.set('status', value);
if (params.has('page')) {
params.delete('page');
}
return `${formatDocumentsPath(team?.url)}?${params.toString()}`;
};
useEffect(() => {
if (data?.stats) {
setStats(data.stats);
}
}, [data?.stats]);
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<DocumentUploadDropzone />
<div className="mt-12 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
<div className="flex flex-row items-center">
{team && (
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
<AvatarFallback className="text-xs text-gray-400">
{team.name.slice(0, 1)}
</AvatarFallback>
</Avatar>
)}
<h1 className="text-4xl font-semibold">
<Trans>Documents</Trans>
</h1>
</div>
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
<Tabs value={findDocumentSearchParams.status || 'ALL'} className="overflow-x-auto">
<TabsList>
{[
ExtendedDocumentStatus.INBOX,
ExtendedDocumentStatus.PENDING,
ExtendedDocumentStatus.COMPLETED,
ExtendedDocumentStatus.DRAFT,
ExtendedDocumentStatus.ALL,
].map((value) => (
<TabsTrigger
key={value}
className="hover:text-foreground min-w-[60px]"
value={value}
asChild
>
<Link to={getTabHref(value)} preventScrollReset>
<DocumentStatus status={value} />
{value !== ExtendedDocumentStatus.ALL && (
<span className="ml-1 inline-block opacity-50">{stats[value]}</span>
)}
</Link>
</TabsTrigger>
))}
</TabsList>
</Tabs>
{team && <DocumentsTableSenderFilter teamId={team.id} />}
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
<PeriodSelector />
</div>
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
<DocumentSearch initialValue={findDocumentSearchParams.query} />
</div>
</div>
</div>
<div className="mt-8">
<div>
{data && data.count === 0 ? (
<DocumentsTableEmptyState
status={findDocumentSearchParams.status || ExtendedDocumentStatus.ALL}
/>
) : (
<DocumentsTable data={data} isLoading={isLoading} isLoadingError={isLoadingError} />
)}
</div>
</div>
</div>
);
}

View File

@ -1,5 +0,0 @@
import { redirect } from 'react-router';
export function loader() {
throw redirect('/settings/profile');
}

View File

@ -1,29 +0,0 @@
import { Trans } from '@lingui/react/macro';
import { Outlet } from 'react-router';
import { SettingsDesktopNav } from '~/components/general/settings-nav-desktop';
import { SettingsMobileNav } from '~/components/general/settings-nav-mobile';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Settings');
}
export default function SettingsLayout() {
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<h1 className="text-4xl font-semibold">
<Trans>Settings</Trans>
</h1>
<div className="mt-4 grid grid-cols-12 gap-x-8 md:mt-8">
<SettingsDesktopNav className="hidden md:col-span-3 md:flex" />
<SettingsMobileNav className="col-span-12 mb-8 md:hidden" />
<div className="col-span-12 md:col-span-9">
<Outlet />
</div>
</div>
</div>
);
}

View File

@ -1,32 +0,0 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { AccountDeleteDialog } from '~/components/dialogs/account-delete-dialog';
import { AvatarImageForm } from '~/components/forms/avatar-image';
import { ProfileForm } from '~/components/forms/profile';
import { SettingsHeader } from '~/components/general/settings-header';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Profile');
}
export default function SettingsProfile() {
const { _ } = useLingui();
return (
<div>
<SettingsHeader
title={_(msg`Profile`)}
subtitle={_(msg`Here you can edit your personal details.`)}
/>
<AvatarImageForm className="mb-8 max-w-xl" />
<ProfileForm className="mb-8 max-w-xl" />
<hr className="my-4 max-w-xl" />
<AccountDeleteDialog className="max-w-xl" />
</div>
);
}

View File

@ -1,31 +0,0 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { SettingsHeader } from '~/components/general/settings-header';
import { SettingsSecurityActivityTable } from '~/components/tables/settings-security-activity-table';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Security activity');
}
export default function SettingsSecurityActivity() {
const { _ } = useLingui();
return (
<div>
<SettingsHeader
title={_(msg`Security activity`)}
subtitle={_(msg`View all security activity related to your account.`)}
hideDivider={true}
>
{/* Todo */}
{/* <ActivityPageBackButton /> */}
</SettingsHeader>
<div className="mt-4">
<SettingsSecurityActivityTable />
</div>
</div>
);
}

View File

@ -1,31 +0,0 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { PasskeyCreateDialog } from '~/components/dialogs/passkey-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
import { SettingsSecurityPasskeyTable } from '~/components/tables/settings-security-passkey-table';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Manage passkeys');
}
export default function SettingsPasskeys() {
const { _ } = useLingui();
return (
<div>
<SettingsHeader
title={_(msg`Passkeys`)}
subtitle={_(msg`Manage your passkeys.`)}
hideDivider={true}
>
<PasskeyCreateDialog />
</SettingsHeader>
<div className="mt-4">
<SettingsSecurityPasskeyTable />
</div>
</div>
);
}

View File

@ -1,14 +0,0 @@
import { redirect } from 'react-router';
import { getLoaderSession } from 'server/utils/get-loader-session';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
export function loader() {
const { currentTeam } = getLoaderSession();
if (!currentTeam) {
throw redirect('/settings/teams');
}
throw redirect(formatDocumentsPath(currentTeam.url));
}

View File

@ -1,157 +0,0 @@
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { ChevronLeft } from 'lucide-react';
import { Link, Outlet, isRouteErrorResponse, redirect, useNavigate } from 'react-router';
import { getLoaderSession } from 'server/utils/get-loader-session';
import { match } from 'ts-pattern';
import { AppErrorCode } from '@documenso/lib/errors/app-error';
import { TrpcProvider } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { TeamProvider } from '~/providers/team';
import type { Route } from './+types/_layout';
export const loader = () => {
const { currentTeam } = getLoaderSession();
if (!currentTeam) {
throw redirect('/settings/teams');
}
const trpcHeaders = {
'x-team-Id': currentTeam.id.toString(),
};
return {
currentTeam,
trpcHeaders,
};
};
export default function Layout({ loaderData }: Route.ComponentProps) {
const { currentTeam, trpcHeaders } = loaderData;
return (
<TeamProvider team={currentTeam}>
<TrpcProvider headers={trpcHeaders}>
{/* Todo: Do this. */}
{/* {team.subscription && team.subscription.status !== SubscriptionStatus.ACTIVE && (
<LayoutBillingBanner
subscription={team.subscription}
teamId={team.id}
userRole={team.currentTeamMember.role}
/>
)} */}
<main className="mt-8 pb-8 md:mt-12 md:pb-12">
<Outlet />
</main>
</TrpcProvider>
</TeamProvider>
);
}
// Todo: Handle this.
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
const { _ } = useLingui();
const navigate = useNavigate();
let errorMessage = msg`Unknown error`;
let errorDetails: MessageDescriptor | null = null;
if (error instanceof Error && error.message === AppErrorCode.UNAUTHORIZED) {
errorMessage = msg`Unauthorized`;
errorDetails = msg`You are not authorized to view this page.`;
}
if (isRouteErrorResponse(error)) {
return match(error.status)
.with(404, () => (
<div className="mx-auto flex min-h-[80vh] w-full items-center justify-center py-32">
<div>
<p className="text-muted-foreground font-semibold">
<Trans>404 Team not found</Trans>
</p>
<h1 className="mt-3 text-2xl font-bold md:text-3xl">
<Trans>Oops! Something went wrong.</Trans>
</h1>
<p className="text-muted-foreground mt-4 text-sm">
<Trans>
The team you are looking for may have been removed, renamed or may have never
existed.
</Trans>
</p>
<div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center">
<Button asChild className="w-32">
<Link to="/settings/teams">
<ChevronLeft className="mr-2 h-4 w-4" />
<Trans>Go Back</Trans>
</Link>
</Button>
</div>
</div>
</div>
))
.with(500, () => (
<div className="mx-auto flex min-h-[80vh] w-full items-center justify-center py-32">
<div>
<p className="text-muted-foreground font-semibold">{_(errorMessage)}</p>
<h1 className="mt-3 text-2xl font-bold md:text-3xl">
<Trans>Oops! Something went wrong.</Trans>
</h1>
<p className="text-muted-foreground mt-4 text-sm">
{errorDetails ? _(errorDetails) : ''}
</p>
<div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center">
<Button
variant="ghost"
className="w-32"
onClick={() => {
void navigate(-1);
}}
>
<ChevronLeft className="mr-2 h-4 w-4" />
<Trans>Go Back</Trans>
</Button>
<Button asChild>
<Link to="/settings/teams">
<Trans>View teams</Trans>
</Link>
</Button>
</div>
</div>
</div>
))
.otherwise(() => (
<>
<h1>
{error.status} {error.statusText}
</h1>
<p>{error.data}</p>
</>
));
} else if (error instanceof Error) {
return (
<div>
<h1>Error</h1>
<p>{error.message}</p>
<p>The stack trace is:</p>
<pre>{error.stack}</pre>
</div>
);
} else {
return <h1>Unknown Error</h1>;
}
}

View File

@ -1,5 +0,0 @@
import DocumentPage, { loader } from '~/routes/_authenticated+/documents+/$id._index';
export { loader };
export default DocumentPage;

View File

@ -1,5 +0,0 @@
import DocumentEditPage, { loader } from '~/routes/_authenticated+/documents+/$id.edit';
export { loader };
export default DocumentEditPage;

View File

@ -1,5 +0,0 @@
import DocumentLogsPage, { loader } from '~/routes/_authenticated+/documents+/$id.logs';
export { loader };
export default DocumentLogsPage;

View File

@ -1,5 +0,0 @@
import DocumentsPage, { meta } from '~/routes/_authenticated+/documents+/_index';
export { meta };
export default DocumentsPage;

View File

@ -1,40 +0,0 @@
import { Trans } from '@lingui/react/macro';
import { Outlet } from 'react-router';
import { getLoaderTeamSession } from 'server/utils/get-loader-session';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { TeamSettingsNavDesktop } from '~/components/general/teams/team-settings-nav-desktop';
import { TeamSettingsNavMobile } from '~/components/general/teams/team-settings-nav-mobile';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Team Settings');
}
export function loader() {
const { currentTeam: team } = getLoaderTeamSession();
if (!team || !canExecuteTeamAction('MANAGE_TEAM', team.currentTeamMember.role)) {
throw new Response(null, { status: 401 }); // Unauthorized.
}
}
export default function TeamsSettingsLayout() {
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<h1 className="text-4xl font-semibold">
<Trans>Team Settings</Trans>
</h1>
<div className="mt-4 grid grid-cols-12 gap-x-8 md:mt-8">
<TeamSettingsNavDesktop className="hidden md:col-span-3 md:flex" />
<TeamSettingsNavMobile className="col-span-12 mb-8 md:hidden" />
<div className="col-span-12 md:col-span-9">
<Outlet />
</div>
</div>
</div>
);
}

View File

@ -1,91 +0,0 @@
import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Link, useLocation, useSearchParams } from 'react-router';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { Input } from '@documenso/ui/primitives/input';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { TeamMemberInviteDialog } from '~/components/dialogs/team-member-invite-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
import { TeamSettingsMemberInvitesTable } from '~/components/tables/team-settings-member-invites-table';
import { TeamSettingsMembersDataTable } from '~/components/tables/team-settings-members-table';
export default function TeamsSettingsMembersPage() {
const { _ } = useLingui();
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
const currentTab = searchParams?.get('tab') === 'invites' ? 'invites' : 'members';
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
return (
<div>
<SettingsHeader
title={_(msg`Members`)}
subtitle={_(msg`Manage the members or invite new members.`)}
>
<TeamMemberInviteDialog />
</SettingsHeader>
<div>
<div className="my-4 flex flex-row items-center justify-between space-x-4">
<Input
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={_(msg`Search`)}
/>
<Tabs value={currentTab} className="flex-shrink-0 overflow-x-auto">
<TabsList>
<TabsTrigger className="min-w-[60px]" value="members" asChild>
<Link to={pathname ?? '/'}>
<Trans>Active</Trans>
</Link>
</TabsTrigger>
<TabsTrigger className="min-w-[60px]" value="invites" asChild>
<Link to={`${pathname}?tab=invites`}>
<Trans>Pending</Trans>
</Link>
</TabsTrigger>
</TabsList>
</Tabs>
</div>
{currentTab === 'invites' ? (
<TeamSettingsMemberInvitesTable key="invites" />
) : (
<TeamSettingsMembersDataTable key="members" />
)}
</div>
</div>
);
}

View File

@ -1,36 +0,0 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { TeamBrandingPreferencesForm } from '~/components/forms/team-branding-preferences-form';
import { TeamDocumentPreferencesForm } from '~/components/forms/team-document-preferences-form';
import { SettingsHeader } from '~/components/general/settings-header';
import { useCurrentTeam } from '~/providers/team';
export default function TeamsSettingsPage() {
const { _ } = useLingui();
const team = useCurrentTeam();
return (
<div>
<SettingsHeader
title={_(msg`Team Preferences`)}
subtitle={_(msg`Here you can set preferences and defaults for your team.`)}
/>
<section>
<TeamDocumentPreferencesForm team={team} settings={team.teamGlobalSettings} />
</section>
<SettingsHeader
title={_(msg`Branding Preferences`)}
subtitle={_(msg`Here you can set preferences and defaults for branding.`)}
className="mt-8"
/>
<section>
<TeamBrandingPreferencesForm team={team} settings={team.teamGlobalSettings} />
</section>
</div>
);
}

View File

@ -1,21 +0,0 @@
import { getLoaderTeamSession } from 'server/utils/get-loader-session';
import { getTeamPublicProfile } from '@documenso/lib/server-only/team/get-team-public-profile';
import PublicProfilePage from '~/routes/_authenticated+/settings+/public-profile+/index';
export async function loader() {
const { user, currentTeam: team } = getLoaderTeamSession();
const { profile } = await getTeamPublicProfile({
userId: user.id,
teamId: team.id,
});
return {
profile,
};
}
// Todo: Test that the profile shows up correctly for teams.
export default PublicProfilePage;

View File

@ -1,5 +0,0 @@
import TemplatePage, { loader } from '~/routes/_authenticated+/templates+/$id._index';
export { loader };
export default TemplatePage;

View File

@ -1,5 +0,0 @@
import TemplateEditPage, { loader } from '~/routes/_authenticated+/templates+/$id.edit';
export { loader };
export default TemplateEditPage;

View File

@ -1,5 +0,0 @@
import TemplatesPage, { meta } from '~/routes/_authenticated+/templates+/_index';
export { meta };
export default TemplatesPage;

View File

@ -1,87 +0,0 @@
import { Trans } from '@lingui/react/macro';
import { Bird } from 'lucide-react';
import { useSearchParams } from 'react-router';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { TemplateCreateDialog } from '~/components/dialogs/template-create-dialog';
import { TemplatesTable } from '~/components/tables/templates-table';
import { useOptionalCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Templates');
}
export default function TemplatesPage() {
const [searchParams] = useSearchParams();
const team = useOptionalCurrentTeam();
const page = Number(searchParams.get('page')) || 1;
const perPage = Number(searchParams.get('perPage')) || 10;
const documentRootPath = formatDocumentsPath(team?.url);
const templateRootPath = formatTemplatesPath(team?.url);
const { data, isLoading, isLoadingError } = trpc.template.findTemplates.useQuery({
page: page,
perPage: perPage,
});
return (
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
<div className="flex items-baseline justify-between">
<div className="flex flex-row items-center">
{team && (
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
<AvatarFallback className="text-xs text-gray-400">
{team.name.slice(0, 1)}
</AvatarFallback>
</Avatar>
)}
<h1 className="truncate text-2xl font-semibold md:text-3xl">
<Trans>Templates</Trans>
</h1>
</div>
<div>
<TemplateCreateDialog templateRootPath={templateRootPath} teamId={team?.id} />
</div>
</div>
<div className="relative mt-5">
{data && data.count === 0 ? (
<div className="text-muted-foreground/60 flex h-96 flex-col items-center justify-center gap-y-4">
<Bird className="h-12 w-12" strokeWidth={1.5} />
<div className="text-center">
<h3 className="text-lg font-semibold">
<Trans>We're all empty</Trans>
</h3>
<p className="mt-2 max-w-[50ch]">
<Trans>
You have not yet created any templates. To create a template please upload one.
</Trans>
</p>
</div>
</div>
) : (
<TemplatesTable
data={data}
isLoading={isLoading}
isLoadingError={isLoadingError}
documentRootPath={documentRootPath}
templateRootPath={templateRootPath}
/>
)}
</div>
</div>
);
}

View File

@ -1,12 +0,0 @@
import { redirect } from 'react-router';
import { getOptionalLoaderSession } from 'server/utils/get-loader-session';
export function loader() {
const session = getOptionalLoaderSession();
if (session) {
throw redirect('/documents');
}
throw redirect('/signin');
}

View File

@ -1,120 +0,0 @@
import { useEffect, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { PlusIcon } from 'lucide-react';
import { ChevronLeft } from 'lucide-react';
import { Link, Outlet } from 'react-router';
import LogoIcon from '@documenso/assets/logo_icon.png';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Header as AuthenticatedHeader } from '~/components/general/app-header';
import { BrandingLogo } from '~/components/general/branding-logo';
export default function PublicProfileLayout() {
const session = useSession();
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
const onScroll = () => {
setScrollY(window.scrollY);
};
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, []);
return (
<div className="min-h-screen">
{session ? (
<AuthenticatedHeader user={session.user} teams={session.teams} />
) : (
<header
className={cn(
'supports-backdrop-blur:bg-background/60 bg-background/95 sticky top-0 z-[60] flex h-16 w-full items-center border-b border-b-transparent backdrop-blur duration-200',
scrollY > 5 && 'border-b-border',
)}
>
<div className="mx-auto flex w-full max-w-screen-xl items-center justify-between gap-x-4 px-4 md:px-8">
<Link
to="/"
className="focus-visible:ring-ring ring-offset-background rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 md:inline"
>
<BrandingLogo className="hidden h-6 w-auto sm:block" />
<img
src={LogoIcon}
alt="Documenso Logo"
width={48}
height={48}
className="h-10 w-auto sm:hidden dark:invert"
/>
</Link>
<div className="flex flex-row items-center justify-center">
<p className="text-muted-foreground mr-4">
<span className="text-sm sm:hidden">
<Trans>Want your own public profile?</Trans>
</span>
<span className="hidden text-sm sm:block">
<Trans>Like to have your own public profile with agreements?</Trans>
</span>
</p>
<Button asChild variant="secondary">
<Link to="/signup">
<div className="hidden flex-row items-center sm:flex">
<PlusIcon className="mr-1 h-5 w-5" />
<Trans>Create now</Trans>
</div>
<span className="sm:hidden">
<Trans>Create</Trans>
</span>
</Link>
</Button>
</div>
</div>
</header>
)}
<main className="my-8 px-4 md:my-12 md:px-8">
<Outlet />
</main>
</div>
);
}
// Todo: Test
export function ErrorBoundary() {
return (
<div className="mx-auto flex min-h-[80vh] w-full items-center justify-center py-32">
<div>
<p className="text-muted-foreground font-semibold">
<Trans>404 Profile not found</Trans>
</p>
<h1 className="mt-3 text-2xl font-bold md:text-3xl">
<Trans>Oops! Something went wrong.</Trans>
</h1>
<p className="text-muted-foreground mt-4 text-sm">
<Trans>The profile you are looking for could not be found.</Trans>
</p>
<div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center">
<Button asChild className="w-32">
<Link to="/">
<ChevronLeft className="mr-2 h-4 w-4" />
<Trans>Go Back</Trans>
</Link>
</Button>
</div>
</div>
</div>
);
}

View File

@ -1,71 +0,0 @@
import { Trans } from '@lingui/react/macro';
import { ChevronLeft } from 'lucide-react';
import { Link, Outlet } from 'react-router';
import { getOptionalLoaderSession } from 'server/utils/get-loader-session';
import { Button } from '@documenso/ui/primitives/button';
import { Header as AuthenticatedHeader } from '~/components/general/app-header';
import type { Route } from './+types/_layout';
export function loader() {
const session = getOptionalLoaderSession();
return {
user: session?.user,
teams: session?.teams || [],
};
}
/**
* A layout to handle scenarios where the user is a recipient of a given resource
* where we do not care whether they are authenticated or not.
*
* Such as direct template access, or signing.
*/
export default function RecipientLayout({ loaderData }: Route.ComponentProps) {
const { user, teams } = loaderData;
return (
<div className="min-h-screen">
{user && <AuthenticatedHeader user={user} teams={teams} />}
<main className="mb-8 mt-8 px-4 md:mb-12 md:mt-12 md:px-8">
<Outlet />
</main>
</div>
);
}
export function ErrorBoundary() {
return (
<div className="mx-auto flex min-h-[80vh] w-full items-center justify-center py-32">
<div>
<p className="text-muted-foreground font-semibold">
<Trans>404 Not found</Trans>
</p>
<h1 className="mt-3 text-2xl font-bold md:text-3xl">
<Trans>Oops! Something went wrong.</Trans>
</h1>
<p className="text-muted-foreground mt-4 text-sm">
<Trans>
The resource you are looking for may have been disabled, deleted or may have never
existed.
</Trans>
</p>
<div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center">
<Button asChild className="w-32">
<Link to="/">
<ChevronLeft className="mr-2 h-4 w-4" />
<Trans>Go Back</Trans>
</Link>
</Button>
</div>
</div>
</div>
);
}

View File

@ -1,111 +0,0 @@
import { Plural } from '@lingui/react/macro';
import { UsersIcon } from 'lucide-react';
import { redirect } from 'react-router';
import { getOptionalLoaderSession } from 'server/utils/get-loader-session';
import { match } from 'ts-pattern';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { DirectTemplatePageView } from '~/components/general/direct-template/direct-template-page';
import { DirectTemplateAuthPageView } from '~/components/general/direct-template/direct-template-signing-auth-page';
import { DocumentSigningAuthProvider } from '~/components/general/document-signing/document-signing-auth-provider';
import { DocumentSigningProvider } from '~/components/general/document-signing/document-signing-provider';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/_index';
export async function loader({ params }: Route.LoaderArgs) {
const session = getOptionalLoaderSession();
const { token } = params;
if (!token) {
throw redirect('/');
}
const template = await getTemplateByDirectLinkToken({
token,
}).catch(() => null);
if (!template || !template.directLink?.enabled) {
throw new Response('Not Found', { status: 404 });
}
const directTemplateRecipient = template.recipients.find(
(recipient) => recipient.id === template.directLink?.directTemplateRecipientId,
);
if (!directTemplateRecipient) {
throw new Response('Not Found', { status: 404 });
}
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: template.authOptions,
});
// Ensure typesafety when we add more options.
const isAccessAuthValid = match(derivedRecipientAccessAuth)
.with(DocumentAccessAuth.ACCOUNT, () => Boolean(session?.user))
.with(null, () => true)
.exhaustive();
if (!isAccessAuthValid) {
return superLoaderJson({
isAccessAuthValid: false as const,
});
}
return superLoaderJson({
isAccessAuthValid: true,
template,
directTemplateRecipient,
} as const);
}
export default function DirectTemplatePage() {
const { user } = useOptionalSession();
const data = useSuperLoaderData<typeof loader>();
// Should not be possible for directLink to be null.
if (!data.isAccessAuthValid) {
return <DirectTemplateAuthPageView />;
}
const { template, directTemplateRecipient } = data;
return (
<DocumentSigningProvider email={user?.email} fullName={user?.name} signature={user?.signature}>
<DocumentSigningAuthProvider
documentAuthOptions={template.authOptions}
recipient={directTemplateRecipient}
user={user}
>
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
<h1
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
title={template.title}
>
{template.title}
</h1>
<div className="text-muted-foreground mb-8 mt-2.5 flex items-center gap-x-2">
<UsersIcon className="h-4 w-4" />
<p className="text-muted-foreground/80">
<Plural value={template.recipients.length} one="# recipient" other="# recipients" />
</p>
</div>
<DirectTemplatePageView
directTemplateRecipient={directTemplateRecipient}
directTemplateToken={template.directLink.token}
template={template}
/>
</div>
</DocumentSigningAuthProvider>
</DocumentSigningProvider>
);
}

View File

@ -1,230 +0,0 @@
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, SigningStatus } from '@prisma/client';
import { Clock8 } from 'lucide-react';
import { Link, redirect } from 'react-router';
import { getOptionalLoaderContext } from 'server/utils/get-loader-session';
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { SigningCard3D } from '@documenso/ui/components/signing-card';
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
import { DocumentSigningAuthProvider } from '~/components/general/document-signing/document-signing-auth-provider';
import { DocumentSigningPageView } from '~/components/general/document-signing/document-signing-page-view';
import { DocumentSigningProvider } from '~/components/general/document-signing/document-signing-provider';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/_index';
export async function loader({ params }: Route.LoaderArgs) {
const { session, requestMetadata } = getOptionalLoaderContext();
const { token } = params;
if (!token) {
throw new Response('Not Found', { status: 404 });
}
const user = session?.user;
const [document, fields, recipient, completedFields] = await Promise.all([
getDocumentAndSenderByToken({
token,
userId: user?.id,
requireAccessAuth: false,
}).catch(() => null),
getFieldsForToken({ token }),
getRecipientByToken({ token }).catch(() => null),
getCompletedFieldsForToken({ token }),
]);
if (
!document ||
!document.documentData ||
!recipient ||
document.status === DocumentStatus.DRAFT
) {
throw new Response('Not Found', { status: 404 });
}
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token });
if (!isRecipientsTurn) {
throw redirect(`/sign/${token}/waiting`);
}
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipient.authOptions,
});
const isDocumentAccessValid = await isRecipientAuthorized({
type: 'ACCESS',
documentAuthOptions: document.authOptions,
recipient,
userId: user?.id,
});
let recipientHasAccount: boolean | null = null;
if (!isDocumentAccessValid) {
recipientHasAccount = await getUserByEmail({ email: recipient.email })
.then((user) => !!user)
.catch(() => false);
return superLoaderJson({
isDocumentAccessValid: false,
recipientEmail: recipient.email,
recipientHasAccount,
} as const);
}
await viewedDocument({
token,
requestMetadata,
recipientAccessAuth: derivedRecipientAccessAuth,
}).catch(() => null);
const { documentMeta } = document;
if (recipient.signingStatus === SigningStatus.REJECTED) {
throw redirect(`/sign/${token}/rejected`);
}
if (
document.status === DocumentStatus.COMPLETED ||
recipient.signingStatus === SigningStatus.SIGNED
) {
throw redirect(documentMeta?.redirectUrl || `/sign/${token}/complete`);
}
// Todo: We don't handle encrypted files right.
// if (documentMeta?.password) {
// const key = DOCUMENSO_ENCRYPTION_KEY;
// if (!key) {
// throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY');
// }
// const securePassword = Buffer.from(
// symmetricDecrypt({
// key,
// data: documentMeta.password,
// }),
// ).toString('utf-8');
// documentMeta.password = securePassword;
// }
const [recipientSignature] = await getRecipientSignatures({ recipientId: recipient.id });
return superLoaderJson({
isDocumentAccessValid: true,
document,
fields,
recipient,
completedFields,
recipientSignature,
isRecipientsTurn,
} as const);
}
export default function SigningPage() {
const data = useSuperLoaderData<typeof loader>();
const { user } = useOptionalSession();
if (!data.isDocumentAccessValid) {
return (
<DocumentSigningAuthPageView
email={data.recipientEmail}
emailHasAccount={!!data.recipientHasAccount}
/>
);
}
const { document, fields, recipient, completedFields, recipientSignature, isRecipientsTurn } =
data;
if (document.deletedAt) {
return (
<div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-16 md:-mx-8 md:px-8 lg:pt-16 xl:pt-24">
<SigningCard3D
name={recipient.name}
signature={recipientSignature}
signingCelebrationImage={signingCelebration}
/>
<div className="relative mt-2 flex w-full flex-col items-center">
<div className="mt-8 flex items-center text-center text-red-600">
<Clock8 className="mr-2 h-5 w-5" />
<span className="text-sm">
<Trans>Document Cancelled</Trans>
</span>
</div>
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
<Trans>
<span className="mt-1.5 block">"{document.title}"</span>
is no longer available to sign
</Trans>
</h2>
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
<Trans>This document has been cancelled by the owner.</Trans>
</p>
{user ? (
<Link to="/documents" className="text-documenso-700 hover:text-documenso-600 mt-36">
<Trans>Go Back Home</Trans>
</Link>
) : (
<p className="text-muted-foreground/60 mt-36 text-sm">
<Trans>
Want to send slick signing links like this one?{' '}
<Link
to="https://documenso.com"
className="text-documenso-700 hover:text-documenso-600"
>
Check out Documenso.
</Link>
</Trans>
</p>
)}
</div>
</div>
);
}
return (
<DocumentSigningProvider
email={recipient.email}
fullName={user?.email === recipient.email ? user?.name : recipient.name}
signature={user?.email === recipient.email ? user?.signature : undefined}
>
<DocumentSigningAuthProvider
documentAuthOptions={document.authOptions}
recipient={recipient}
user={user}
>
<DocumentSigningPageView
recipient={recipient}
document={document}
fields={fields}
completedFields={completedFields}
isRecipientsTurn={isRecipientsTurn}
/>
</DocumentSigningAuthProvider>
</DocumentSigningProvider>
);
}

View File

@ -1,41 +0,0 @@
/**
* https://posthog.com/docs/advanced/proxy/remix
*/
import type { Route } from './+types/ingest.$';
const API_HOST = 'eu.i.posthog.com';
const ASSET_HOST = 'eu-assets.i.posthog.com';
const posthogProxy = async (request: Request) => {
const url = new URL(request.url);
const hostname = url.pathname.startsWith('/ingest/static/') ? ASSET_HOST : API_HOST;
const newUrl = new URL(url);
newUrl.protocol = 'https';
newUrl.hostname = hostname;
newUrl.port = '443';
newUrl.pathname = newUrl.pathname.replace(/^\/ingest/, '');
const headers = new Headers(request.headers);
headers.set('host', hostname);
const response = await fetch(newUrl, {
method: request.method,
headers,
body: request.body,
});
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
};
export async function loader({ request }: Route.LoaderArgs) {
return posthogProxy(request);
}
export async function action({ request }: Route.ActionArgs) {
return posthogProxy(request);
}

View File

@ -1,192 +0,0 @@
// Todo: Test, used AI to migrate this component from NextJS to Remix.
import satori from 'satori';
import sharp from 'sharp';
import { P, match } from 'ts-pattern';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import type { ShareHandlerAPIResponse } from '../api+/share';
import type { Route } from './+types/share.$slug.opengraph';
export const runtime = 'edge';
const CARD_OFFSET_TOP = 173;
const CARD_OFFSET_LEFT = 307;
const CARD_WIDTH = 590;
const CARD_HEIGHT = 337;
const IMAGE_SIZE = {
width: 1200,
height: 630,
};
export const loader = async ({ params }: Route.LoaderArgs) => {
const { slug } = params;
const baseUrl = NEXT_PUBLIC_WEBAPP_URL();
const [interSemiBold, interRegular, caveatRegular] = await Promise.all([
fetch(new URL(`${baseUrl}/fonts/inter-semibold.ttf`, import.meta.url)).then(async (res) =>
res.arrayBuffer(),
),
fetch(new URL(`${baseUrl}/fonts/inter-regular.ttf`, import.meta.url)).then(async (res) =>
res.arrayBuffer(),
),
fetch(new URL(`${baseUrl}/fonts/caveat-regular.ttf`, import.meta.url)).then(async (res) =>
res.arrayBuffer(),
),
]);
const recipientOrSender: ShareHandlerAPIResponse = await fetch(
new URL(`/api/share?slug=${slug}`, baseUrl),
).then(async (res) => res.json());
if ('error' in recipientOrSender) {
return Response.json({ error: 'Not found' }, { status: 404 });
}
const isRecipient = 'Signature' in recipientOrSender;
const signatureImage = match(recipientOrSender)
.with({ signatures: P.array(P._) }, (recipient) => {
return recipient.signatures?.[0]?.signatureImageAsBase64 || null;
})
.otherwise((sender) => {
return sender.signature || null;
});
const signatureName = match(recipientOrSender)
.with({ signatures: P.array(P._) }, (recipient) => {
return recipient.name || recipient.email;
})
.otherwise((sender) => {
return sender.name || sender.email;
});
// Generate SVG using Satori
const svg = await satori(
<div
style={{
display: 'flex',
height: '100%',
width: '100%',
backgroundColor: 'white',
position: 'relative',
}}
>
<img
src={`${baseUrl}/static/og-share-frame2.png`}
alt="og-share-frame"
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
}}
/>
{signatureImage ? (
<div
style={{
position: 'absolute',
padding: '24px 48px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
top: CARD_OFFSET_TOP,
left: CARD_OFFSET_LEFT,
width: CARD_WIDTH,
height: CARD_HEIGHT,
}}
>
<img
src={signatureImage}
alt="signature"
style={{
opacity: 0.6,
height: '100%',
maxWidth: '100%',
}}
/>
</div>
) : (
<p
style={{
position: 'absolute',
padding: '24px 48px',
marginTop: '-8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
color: '#64748b',
fontFamily: 'Caveat',
fontSize: Math.max(Math.min((CARD_WIDTH * 1.5) / signatureName.length, 80), 36),
top: CARD_OFFSET_TOP,
left: CARD_OFFSET_LEFT,
width: CARD_WIDTH,
height: CARD_HEIGHT,
}}
>
{signatureName}
</p>
)}
<div
style={{
position: 'absolute',
display: 'flex',
width: '100%',
top: CARD_OFFSET_TOP - 78,
left: CARD_OFFSET_LEFT,
}}
>
<h2
style={{
fontSize: '20px',
color: '#828282',
fontFamily: 'Inter',
fontWeight: 700,
}}
>
{isRecipient ? 'Document Signed!' : 'Document Sent!'}
</h2>
</div>
</div>,
{
width: IMAGE_SIZE.width,
height: IMAGE_SIZE.height,
fonts: [
{
name: 'Caveat',
data: caveatRegular,
style: 'italic',
},
{
name: 'Inter',
data: interRegular,
weight: 400,
},
{
name: 'Inter',
data: interSemiBold,
weight: 600,
},
],
},
);
// Convert SVG to PNG using sharp
const pngBuffer = await sharp(Buffer.from(svg)).toFormat('png').toBuffer();
return new Response(pngBuffer, {
headers: {
'Content-Type': 'image/png',
'Content-Length': pngBuffer.length.toString(),
'Cache-Control': 'public, max-age=31536000, immutable',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
},
});
};

View File

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

View File

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

View File

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

View File

@ -1,187 +0,0 @@
import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { AlertTriangle, CheckCircle2, Loader, XCircle } from 'lucide-react';
import { Link, redirect, useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import { authClient } from '@documenso/auth/client';
import { EMAIL_VERIFICATION_STATE } from '@documenso/lib/constants/email';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import type { Route } from './+types/verify-email.$token';
export const loader = ({ params }: Route.LoaderArgs) => {
const { token } = params;
if (!token) {
throw redirect('/verify-email');
}
return {
token,
};
};
export default function VerifyEmailPage({ loaderData }: Route.ComponentProps) {
const { token } = loaderData;
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const [state, setState] = useState<keyof typeof EMAIL_VERIFICATION_STATE | null>(null);
const [isLoading, setIsLoading] = useState(false);
const verifyToken = async () => {
setIsLoading(true);
try {
const response = await authClient.emailPassword.verifyEmail({
token,
});
setState(response.state);
} catch (err) {
console.error(err);
toast({
title: _(msg`Something went wrong`),
description: _(msg`We were unable to verify your email at this time.`),
});
await navigate('/verify-email');
}
setIsLoading(false);
};
useEffect(() => {
void verifyToken();
}, []);
if (isLoading || state === null) {
return (
<div className="relative">
<Loader className="text-documenso h-8 w-8 animate-spin" />
</div>
);
}
return match(state)
.with(EMAIL_VERIFICATION_STATE.NOT_FOUND, () => (
<div className="w-screen max-w-lg px-4">
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<AlertTriangle className="h-10 w-10 text-yellow-500" strokeWidth={2} />
</div>
<div>
<h2 className="text-2xl font-bold md:text-4xl">
<Trans>Something went wrong</Trans>
</h2>
<p className="text-muted-foreground mt-4">
<Trans>
We were unable to verify your email. If your email is not verified already, please
try again.
</Trans>
</p>
<Button className="mt-4" asChild>
<Link to="/">
<Trans>Go back home</Trans>
</Link>
</Button>
</div>
</div>
</div>
))
.with(EMAIL_VERIFICATION_STATE.EXPIRED, () => (
<div className="w-screen max-w-lg px-4">
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<XCircle className="text-destructive h-10 w-10" strokeWidth={2} />
</div>
<div>
<h2 className="text-2xl font-bold md:text-4xl">
<Trans>Your token has expired!</Trans>
</h2>
<p className="text-muted-foreground mt-4">
<Trans>
It seems that the provided token has expired. We've just sent you another token,
please check your email and try again.
</Trans>
</p>
<Button className="mt-4" asChild>
<Link to="/">
<Trans>Go back home</Trans>
</Link>
</Button>
</div>
</div>
</div>
))
.with(EMAIL_VERIFICATION_STATE.VERIFIED, () => (
<div className="w-screen max-w-lg px-4">
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<CheckCircle2 className="h-10 w-10 text-green-500" strokeWidth={2} />
</div>
<div>
<h2 className="text-2xl font-bold md:text-4xl">
<Trans>Email Confirmed!</Trans>
</h2>
<p className="text-muted-foreground mt-4">
<Trans>
Your email has been successfully confirmed! You can now use all features of
Documenso.
</Trans>
</p>
<Button className="mt-4" asChild>
<Link to="/">
<Trans>Continue</Trans>
</Link>
</Button>
</div>
</div>
</div>
))
.with(EMAIL_VERIFICATION_STATE.ALREADY_VERIFIED, () => (
<div className="w-screen max-w-lg px-4">
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<CheckCircle2 className="h-10 w-10 text-green-500" strokeWidth={2} />
</div>
<div>
<h2 className="text-2xl font-bold md:text-4xl">
<Trans>Email already confirmed</Trans>
</h2>
<p className="text-muted-foreground mt-4">
<Trans>
Your email has already been confirmed. You can now use all features of Documenso.
</Trans>
</p>
<Button className="mt-4" asChild>
<Link to="/">
<Trans>Go back home</Trans>
</Link>
</Button>
</div>
</div>
</div>
))
.exhaustive();
}

View File

@ -1,40 +0,0 @@
import { getAvatarImage } from '@documenso/lib/server-only/profile/get-avatar-image';
import type { Route } from './+types/avatar.$id';
export async function loader({ params }: Route.LoaderArgs) {
const { id } = params;
if (typeof id !== 'string') {
return Response.json(
{
status: 'error',
message: 'Missing id',
},
{ status: 400 },
);
}
const result = await getAvatarImage({ id });
if (!result) {
return Response.json(
{
status: 'error',
message: 'Not found',
},
{ status: 404 },
);
}
// res.setHeader('Content-Type', result.contentType);
// res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
// res.send(result.content);
return new Response(result.content, {
headers: {
'Content-Type': result.contentType,
'Cache-Control': 'public, max-age=31536000, immutable',
},
});
}

View File

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

View File

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

View File

@ -1,7 +0,0 @@
import { limitsHandler } from '@documenso/ee/server-only/limits/handler';
import type { Route } from './+types/limits';
export async function loader({ request }: Route.LoaderArgs) {
return limitsHandler(request);
}

View File

@ -1,19 +0,0 @@
import type { ActionFunctionArgs } from 'react-router';
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
import { langCookie } from '~/storage/lang-cookie.server';
export const action = async ({ request }: ActionFunctionArgs) => {
const formData = await request.formData();
const lang = formData.get('lang') || '';
if (!APP_I18N_OPTIONS.supportedLangs.find((l) => l === lang)) {
throw new Response('Unsupported language', { status: 400 });
}
return new Response('OK', {
status: 200,
headers: { 'Set-Cookie': await langCookie.serialize(lang) },
});
};

View File

@ -1,11 +0,0 @@
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 action({ request }: Route.ActionArgs) {
return stripeWebhookHandler(request);
}

View File

@ -1,5 +0,0 @@
import { createThemeAction } from 'remix-themes';
import { themeSessionResolver } from '~/storage/theme-session.server';
export const action = createThemeAction(themeSessionResolver);

View File

@ -1,17 +0,0 @@
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 action({ request }: Route.ActionArgs) {
return handlerTriggerWebhooks(request);
}

View File

@ -1,42 +0,0 @@
import { Outlet, isRouteErrorResponse, useRouteError } from 'react-router';
import { EmbedAuthenticationRequired } from '~/components/embed/embed-authentication-required';
import { EmbedPaywall } from '~/components/embed/embed-paywall';
import type { Route } from './+types/_layout';
// Todo: Test
export function headers({ loaderHeaders }: Route.HeadersArgs) {
const origin = loaderHeaders.get('Origin') ?? '*';
// Allow third parties to iframe the document.
return {
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Origin': origin,
'Content-Security-Policy': `frame-ancestors ${origin}`,
'Referrer-Policy': 'strict-origin-when-cross-origin',
'X-Content-Type-Options': 'nosniff',
};
}
export default function Layout() {
return <Outlet />;
}
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
if (error.status === 401 && error.data.type === 'embed-authentication-required') {
return (
<EmbedAuthenticationRequired email={error.data.email} returnTo={error.data.returnTo} />
);
}
if (error.status === 403 && error.data.type === 'embed-paywall') {
return <EmbedPaywall />;
}
}
return <div>Not Found</div>;
}

View File

@ -1,6 +0,0 @@
import { createCookie } from 'react-router';
export const langCookie = createCookie('lang', {
path: '/',
maxAge: 60 * 60 * 24 * 365 * 2,
});

View File

@ -1,20 +0,0 @@
import { createCookieSessionStorage } from 'react-router';
import { createThemeSessionResolver } from 'remix-themes';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { env } from '@documenso/lib/utils/env';
const themeSessionStorage = createCookieSessionStorage({
cookie: {
name: 'theme',
path: '/',
httpOnly: true,
sameSite: 'lax',
secrets: ['insecure-secret'], // Todo: Don't need secret
// Todo: Check this works on production.
// Set domain and secure only if in production
...(env('NODE_ENV') === 'production' ? { domain: NEXT_PUBLIC_WEBAPP_URL(), secure: true } : {}),
},
});
export const themeSessionResolver = createThemeSessionResolver(themeSessionStorage);

View File

@ -1,61 +0,0 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
export const appMetaTags = (title?: string) => {
const 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.';
return [
{
title: title ? `${title} - Documenso` : 'Documenso',
},
{
name: 'description',
content: description,
},
{
name: 'keywords',
content:
'Documenso, open source, DocuSign alternative, document signing, open signing infrastructure, open-source community, fast signing, beautiful signing, smart templates',
},
{
name: 'author',
content: 'Documenso, Inc.',
},
{
name: 'robots',
content: 'index, follow',
},
{
property: 'og:title',
content: 'Documenso - The Open Source DocuSign Alternative',
},
{
property: 'og:description',
content: description,
},
{
property: 'og:image',
content: `${NEXT_PUBLIC_WEBAPP_URL()}/opengraph-image.jpg`,
},
{
property: 'og:type',
content: 'website',
},
{
name: 'twitter:card',
content: 'summary_large_image',
},
{
name: 'twitter:site',
content: '@documenso',
},
{
name: 'twitter:description',
content: description,
},
{
name: 'twitter:image',
content: `${NEXT_PUBLIC_WEBAPP_URL()}/opengraph-image.jpg`,
},
];
};

View File

@ -1,57 +0,0 @@
/* eslint-disable @typescript-eslint/consistent-type-assertions */
/* eslint-disable @typescript-eslint/no-unnecessary-type-constraint */
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* https://github.com/kiliman/remix-superjson/
*/
import { useActionData, useLoaderData } from 'react-router';
import * as _superjson from 'superjson';
export type SuperJsonFunction = <Data extends unknown>(
data: Data,
init?: number | ResponseInit,
) => SuperTypedResponse<Data>;
export declare type SuperTypedResponse<T extends unknown = unknown> = Response & {
superjson(): Promise<T>;
};
type AppData = any;
type DataFunction = (...args: any[]) => unknown; // matches any function
type DataOrFunction = AppData | DataFunction;
export type UseDataFunctionReturn<T extends DataOrFunction> = T extends (
...args: any[]
) => infer Output
? Awaited<Output> extends SuperTypedResponse<infer U>
? U
: Awaited<ReturnType<T>>
: Awaited<T>;
export const superLoaderJson: SuperJsonFunction = (data, init = {}) => {
const responseInit = typeof init === 'number' ? { status: init } : init;
const headers = new Headers(responseInit.headers);
if (!headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json; charset=utf-8');
}
return new Response(_superjson.stringify(data), {
...responseInit,
headers,
}) as SuperTypedResponse<typeof data>;
};
export function useSuperLoaderData<T = AppData>(): UseDataFunctionReturn<T> {
const data = useLoaderData();
return _superjson.deserialize(data);
}
export function useSuperActionData<T = AppData>(): UseDataFunctionReturn<T> | null {
const data = useActionData();
return data ? _superjson.deserialize(data) : null;
}

View File

@ -1,103 +0,0 @@
{
"name": "@documenso/remix",
"private": true,
"type": "module",
"scripts": {
"build": "sh .bin/build.sh",
"build:app": "npm run typecheck && cross-env NODE_ENV=production react-router build",
"build:server": "cross-env NODE_ENV=production rollup -c rollup.config.mjs",
"dev": "npm run with:env -- react-router dev",
"start": "npm run with:env -- cross-env NODE_ENV=production node build/server/main.js",
"clean": "rimraf .react-router && rimraf node_modules",
"typecheck": "react-router typegen && tsc",
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs",
"with:env": "dotenv -e ../../.env -e ../../.env.local --"
},
"dependencies": {
"@documenso/api": "*",
"@documenso/assets": "*",
"@documenso/auth": "*",
"@documenso/ee": "*",
"@documenso/lib": "*",
"@documenso/prisma": "*",
"@documenso/tailwind-config": "*",
"@documenso/trpc": "*",
"@documenso/ui": "*",
"@epic-web/remember": "^1.1.0",
"@hono/node-server": "^1.13.7",
"@hono/trpc-server": "^0.3.4",
"@hookform/resolvers": "^3.1.0",
"@lingui/core": "^5.2.0",
"@lingui/detect-locale": "^5.2.0",
"@lingui/macro": "^5.2.0",
"@lingui/react": "^5.2.0",
"@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0",
"@react-router/node": "^7.1.5",
"@react-router/serve": "^7.1.5",
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.3",
"autoprefixer": "^10.4.13",
"colord": "^2.9.3",
"framer-motion": "^10.12.8",
"hono": "4.7.0",
"hono-react-router-adapter": "^0.6.2",
"input-otp": "^1.2.4",
"isbot": "^5.1.17",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.279.0",
"luxon": "^3.4.0",
"papaparse": "^5.4.1",
"plausible-tracker": "^0.3.9",
"posthog-js": "^1.75.3",
"posthog-node": "^3.1.1",
"react": "^18",
"react-call": "^1.3.0",
"react-dom": "^18",
"react-dropzone": "^14.2.3",
"react-hook-form": "^7.43.9",
"react-hotkeys-hook": "^4.4.1",
"react-icons": "^5.4.0",
"react-rnd": "^10.4.1",
"react-router": "^7.1.5",
"recharts": "^2.7.2",
"remeda": "^2.17.3",
"remix-themes": "^2.0.4",
"satori": "^0.12.1",
"sharp": "0.32.6",
"tailwindcss": "^3.4.15",
"ts-pattern": "^5.0.5",
"ua-parser-js": "^1.0.37",
"uqr": "^0.1.2"
},
"devDependencies": {
"@babel/core": "^7.26.7",
"@babel/preset-react": "^7.26.3",
"@babel/preset-typescript": "^7.26.0",
"@lingui/babel-plugin-lingui-macro": "^5.2.0",
"@lingui/vite-plugin": "^5.2.0",
"@react-router/dev": "^7.1.1",
"@react-router/remix-routes-option-adapter": "^7.1.5",
"@rollup/plugin-babel": "^6.0.4",
"@rollup/plugin-commonjs": "^28.0.2",
"@rollup/plugin-node-resolve": "^16.0.0",
"@rollup/plugin-typescript": "^12.1.2",
"@simplewebauthn/types": "^9.0.1",
"@types/formidable": "^2.0.6",
"@types/luxon": "^3.3.1",
"@types/node": "^20",
"@types/papaparse": "^5.3.15",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/ua-parser-js": "^0.7.39",
"cross-env": "^7.0.3",
"esbuild": "0.24.2",
"remix-flat-routes": "^0.8.4",
"rollup": "^4.34.5",
"tsx": "^4.19.2",
"typescript": "5.6.2",
"vite": "^6.1.0",
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
}
}

View File

@ -1,7 +0,0 @@
# General Issues
Contact: https://github.com/documenso/documenso/issues/new?assignees=&labels=bug&projects=&template=bug-report.yml
# Report critical issues privately to let us take appropriate action before publishing.
Contact: mailto:security@documenso.com
Preferred-Languages: en
Canonical: https://documenso.com/.well-known/security.txt

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 458 KiB

View File

@ -1,6 +0,0 @@
import type { Config } from '@react-router/dev/config';
export default {
appDirectory: 'app',
ssr: true,
} satisfies Config;

View File

@ -1,52 +0,0 @@
import linguiMacro from '@lingui/babel-plugin-lingui-macro';
import babel from '@rollup/plugin-babel';
import commonjs from '@rollup/plugin-commonjs';
import resolve from '@rollup/plugin-node-resolve';
import typescript from '@rollup/plugin-typescript';
import path from 'node:path';
/** @type {import('rollup').RollupOptions} */
const config = {
/**
* We specifically target the router.ts instead of the entry point so the rollup doesn't go through the
* already prebuilt RR7 server files.
*/
input: 'server/router.ts',
output: {
dir: 'build/server/hono',
format: 'esm',
sourcemap: true,
preserveModules: true,
preserveModulesRoot: '.',
},
external: [/node_modules/],
plugins: [
typescript({
noEmitOnError: true,
moduleResolution: 'bundler',
include: ['server/**/*', '../../packages/**/*', '../../packages/lib/translations/**/*'],
jsx: 'preserve',
}),
resolve({
rootDir: path.join(process.cwd(), '../..'),
preferBuiltins: true,
resolveOnly: [
'@documenso/api/*',
'@documenso/auth/*',
'@documenso/lib/*',
'@documenso/trpc/*',
'@documenso/email/*',
],
extensions: ['.ts', '.tsx', '.js', '.jsx', '.json'],
}),
commonjs(),
babel({
babelHelpers: 'bundled',
extensions: ['.ts', '.tsx'],
presets: ['@babel/preset-typescript', ['@babel/preset-react', { runtime: 'automatic' }]],
plugins: [linguiMacro],
}),
],
};
export default config;

View File

@ -1,110 +0,0 @@
import { sValidator } from '@hono/standard-validator';
import { Hono } from 'hono';
import { PDFDocument } from 'pdf-lib';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { putFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import {
getPresignGetUrl,
getPresignPostUrl,
} from '@documenso/lib/universal/upload/server-actions';
import type { HonoEnv } from '../router';
import {
type TGetPresignedGetUrlResponse,
type TGetPresignedPostUrlResponse,
ZGetPresignedGetUrlRequestSchema,
ZGetPresignedPostUrlRequestSchema,
ZUploadPdfRequestSchema,
} from './files.types';
export const filesRoute = new Hono<HonoEnv>()
/**
* Uploads a document file to the appropriate storage location and creates
* a document data record.
*/
.post('/upload-pdf', sValidator('form', ZUploadPdfRequestSchema), async (c) => {
try {
const { file } = c.req.valid('form');
if (!file) {
return c.json({ error: 'No file provided' }, 400);
}
// Todo: Do we want to validate the file type?
// if (file.type !== 'application/pdf') {
// return c.json({ error: 'File must be a PDF' }, 400);
// }
// Todo: This is new.
// Add file size validation.
// Convert MB to bytes (1 MB = 1024 * 1024 bytes)
const MAX_FILE_SIZE = APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024;
if (file.size > MAX_FILE_SIZE) {
return c.json({ error: 'File too large' }, 400);
}
const arrayBuffer = await file.arrayBuffer();
const pdf = await PDFDocument.load(arrayBuffer).catch((e) => {
console.error(`PDF upload parse error: ${e.message}`);
throw new AppError('INVALID_DOCUMENT_FILE');
});
if (pdf.isEncrypted) {
throw new AppError('INVALID_DOCUMENT_FILE');
}
// Todo: Test this.
if (!file.name.endsWith('.pdf')) {
Object.defineProperty(file, 'name', {
writable: true,
value: `${file.name}.pdf`,
});
}
const { type, data } = await putFileServerSide(file);
const result = await createDocumentData({ type, data });
return c.json(result);
} catch (error) {
console.error('Upload failed:', error);
return c.json({ error: 'Upload failed' }, 500);
}
})
.post('/presigned-get-url', sValidator('json', ZGetPresignedGetUrlRequestSchema), async (c) => {
const { key } = await c.req.json();
console.log(key);
try {
const { url } = await getPresignGetUrl(key || '');
return c.json({ url } satisfies TGetPresignedGetUrlResponse);
} catch (err) {
console.error(err);
throw new AppError(AppErrorCode.UNKNOWN_ERROR);
}
})
.post('/presigned-post-url', sValidator('json', ZGetPresignedPostUrlRequestSchema), async (c) => {
const { fileName, contentType } = c.req.valid('json');
try {
console.log({
fileName,
});
const { key, url } = await getPresignPostUrl(fileName, contentType);
console.log(key);
return c.json({ key, url } satisfies TGetPresignedPostUrlResponse);
} catch (err) {
console.error(err);
throw new AppError(AppErrorCode.UNKNOWN_ERROR);
}
});

View File

@ -1,38 +0,0 @@
import { z } from 'zod';
import DocumentDataSchema from '@documenso/prisma/generated/zod/modelSchema/DocumentDataSchema';
export const ZUploadPdfRequestSchema = z.object({
file: z.instanceof(File),
});
export const ZUploadPdfResponseSchema = DocumentDataSchema.pick({
type: true,
id: true,
});
export type TUploadPdfRequest = z.infer<typeof ZUploadPdfRequestSchema>;
export type TUploadPdfResponse = z.infer<typeof ZUploadPdfResponseSchema>;
export const ZGetPresignedPostUrlRequestSchema = z.object({
fileName: z.string().min(1),
contentType: z.string().min(1),
});
export const ZGetPresignedPostUrlResponseSchema = z.object({
key: z.string().min(1),
url: z.string().min(1),
});
export const ZGetPresignedGetUrlRequestSchema = z.object({
key: z.string().min(1),
});
export const ZGetPresignedGetUrlResponseSchema = z.object({
url: z.string().min(1),
});
export type TGetPresignedPostUrlRequest = z.infer<typeof ZGetPresignedPostUrlRequestSchema>;
export type TGetPresignedPostUrlResponse = z.infer<typeof ZGetPresignedPostUrlResponseSchema>;
export type TGetPresignedGetUrlRequest = z.infer<typeof ZGetPresignedGetUrlRequestSchema>;
export type TGetPresignedGetUrlResponse = z.infer<typeof ZGetPresignedGetUrlResponseSchema>;

View File

@ -1,109 +0,0 @@
import type { Context, Next } from 'hono';
import { extractSessionCookieFromHeaders } from '@documenso/auth/server/lib/session/session-cookies';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import type { AppSession } from '@documenso/lib/client-only/providers/session';
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { type TGetTeamsResponse, getTeams } from '@documenso/lib/server-only/team/get-teams';
import {
type RequestMetadata,
extractRequestMetadata,
} from '@documenso/lib/universal/extract-request-metadata';
import { AppLogger } from '@documenso/lib/utils/debugger';
const logger = new AppLogger('Middleware');
export type AppContext = {
requestMetadata: RequestMetadata;
session: AppSession | null;
};
export const appContext = async (c: Context, next: Next) => {
const initTime = Date.now();
const request = c.req.raw;
const url = new URL(request.url);
const noSessionCookie = extractSessionCookieFromHeaders(request.headers) === null;
if (!isPageRequest(request) || noSessionCookie || blacklistedPathsRegex.test(url.pathname)) {
// logger.log('Pathname ignored', url.pathname);
setAppContext(c, {
requestMetadata: extractRequestMetadata(request),
session: null,
});
return next();
}
const splitUrl = url.pathname.replace('.data', '').split('/');
let team: TGetTeamByUrlResponse | null = null;
let teams: TGetTeamsResponse = [];
const session = await getSession(c);
if (session.isAuthenticated) {
let teamUrl = null;
if (splitUrl[1] === 't' && splitUrl[2]) {
teamUrl = splitUrl[2];
}
const result = await Promise.all([
getTeams({ userId: session.user.id }),
teamUrl ? getTeamByUrl({ userId: session.user.id, teamUrl }).catch(() => null) : null,
]);
teams = result[0];
team = result[1];
}
const endTime = Date.now();
logger.log(`Pathname accepted in ${endTime - initTime}ms`, url.pathname);
setAppContext(c, {
requestMetadata: extractRequestMetadata(request),
session: session.isAuthenticated
? {
session: session.session,
user: session.user,
currentTeam: team,
teams,
}
: null,
});
return next();
};
const setAppContext = (c: Context, context: AppContext) => {
c.set('context', context);
};
const isPageRequest = (request: Request) => {
const url = new URL(request.url);
if (request.method !== 'GET') {
return false;
}
// If it ends with .data it's the loader which we need to pass context for.
if (url.pathname.endsWith('.data')) {
return true;
}
if (request.headers.get('Accept')?.includes('text/html')) {
return true;
}
return false;
};
/**
* List of paths to reject
* - Urls that start with /api
* - Urls that start with _
*/
const blacklistedPathsRegex = new RegExp('^/api/|^/__');

View File

@ -1,24 +0,0 @@
/**
* This is the main entry point for the server which will launch the RR7 application
* and spin up auth, api, etc.
*
* Note:
* This file will be copied to the build folder during build time.
* Running this file will not work without a build.
*/
import { serve } from '@hono/node-server';
import { serveStatic } from '@hono/node-server/serve-static';
import handle from 'hono-react-router-adapter/node';
import server from './hono/server/router.js';
import * as build from './index.js';
server.use(
serveStatic({
root: 'build/client',
}),
);
const handler = handle(build, server);
serve({ fetch: handler.fetch, port: 3000 });

View File

@ -1,66 +0,0 @@
import type { Context, Next } from 'hono';
import { getCookie } from 'hono/cookie';
import { AppLogger } from '@documenso/lib/utils/debugger';
const logger = new AppLogger('Middleware');
/**
* Middleware for initial page loads.
*
* You won't be able to easily handle sequential page loads because they will be
* called under `path.data`
*
* Example an initial page load would be `/documents` then if the user click templates
* the path here would be `/templates.data`.
*/
export const appMiddleware = async (c: Context, next: Next) => {
const { req } = c;
const { path } = req;
// Basic paths to ignore.
if (path.startsWith('/api') || path.endsWith('.data') || path.startsWith('/__manifest')) {
return next();
}
logger.log('Path', path);
const preferredTeamUrl = getCookie(c, 'preferred-team-url');
const referrer = c.req.header('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/') &&
// (!path.startsWith('/t/') || path === '/');
// // Redirect root page to `/documents` or `/t/{preferredTeamUrl}/documents`.
// if (path === '/') {
// logger.log('Redirecting from root to documents');
// const redirectUrlPath = formatDocumentsPath(
// resetPreferredTeamUrl ? undefined : preferredTeamUrl,
// );
// const redirectUrl = new URL(redirectUrlPath, req.url);
// return c.redirect(redirectUrl);
// }
// // Set the preferred team url cookie if user accesses a team page.
// if (path.startsWith('/t/')) {
// setCookie(c, 'preferred-team-url', path.split('/')[2]);
// return next();
// }
// // Clear preferred team url cookie if user accesses a non team page from a team page.
// if (resetPreferredTeamUrl || path === '/documents') {
// logger.log('Resetting preferred team url');
// deleteCookie(c, 'preferred-team-url');
// return next();
// }
};

View File

@ -1,49 +0,0 @@
import { Hono } from 'hono';
import { contextStorage } from 'hono/context-storage';
import { tsRestHonoApp } from '@documenso/api/hono';
import { auth } from '@documenso/auth/server';
import { API_V2_BETA_URL } from '@documenso/lib/constants/app';
import { jobsClient } from '@documenso/lib/jobs/client';
import { openApiDocument } from '@documenso/trpc/server/open-api';
import { filesRoute } from './api/files';
import { type AppContext, appContext } from './context';
import { openApiTrpcServerHandler } from './trpc/hono-trpc-open-api';
import { reactRouterTrpcServer } from './trpc/hono-trpc-remix';
export interface HonoEnv {
Variables: {
context: AppContext;
};
}
const app = new Hono<HonoEnv>();
/**
* Attach session and context to requests.
*/
app.use(contextStorage());
app.use(appContext);
/**
* Middleware for initial page loads.
*/
// app.use('*', appMiddleware);
// Auth server.
app.route('/api/auth', auth);
// Files route.
app.route('/api/files', filesRoute);
// API servers. Todo: Configure max durations, etc?
app.route('/api/v1', tsRestHonoApp);
app.use('/api/jobs/*', jobsClient.getApiHandler());
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)); // Todo: Add next()?
export default app;

View File

@ -1,34 +0,0 @@
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 { createTrpcContext } from '@documenso/trpc/server/context';
import { appRouter } from '@documenso/trpc/server/router';
import { handleTrpcRouterError } from '@documenso/trpc/utils/trpc-error-handler';
export const openApiTrpcServerHandler = async (c: Context) => {
return createOpenApiFetchHandler<typeof appRouter>({
endpoint: API_V2_BETA_URL,
router: appRouter,
// Todo: Test this, since it's not using the createContext params.
// Todo: Reduce calls since we fetch on most request? maybe
createContext: async () => createTrpcContext({ c, requestSource: 'apiV2' }),
req: c.req.raw,
onError: (opts) => handleTrpcRouterError(opts, 'apiV2'),
// Not sure why we need to do this since we handle it in errorFormatter which runs after this.
responseMeta: (opts) => {
if (opts.errors[0]?.cause instanceof AppError) {
const appError = AppError.parseError(opts.errors[0].cause);
const httpStatus = genericErrorCodeToTrpcErrorCodeMap[appError.code]?.status ?? 400;
return {
status: httpStatus,
};
}
return {};
},
});
};

View File

@ -1,25 +0,0 @@
import { trpcServer } from '@hono/trpc-server';
import { createTrpcContext } from '@documenso/trpc/server/context';
import { appRouter } from '@documenso/trpc/server/router';
import { handleTrpcRouterError } from '@documenso/trpc/utils/trpc-error-handler';
// Todo
// export const config = {
// maxDuration: 120,
// api: {
// bodyParser: {
// sizeLimit: '50mb',
// },
// },
// };
/**
* Trpc server for internal routes like /api/trpc/*
*/
export const reactRouterTrpcServer = trpcServer({
router: appRouter,
endpoint: '/api/trpc',
createContext: async (_, c) => createTrpcContext({ c, requestSource: 'app' }),
onError: (opts) => handleTrpcRouterError(opts, 'trpc'),
});

View File

@ -1,59 +0,0 @@
import { getContext } from 'hono/context-storage';
import { redirect } from 'react-router';
import type { AppContext } from 'server/context';
import type { HonoEnv } from 'server/router';
import type { AppSession } from '@documenso/lib/client-only/providers/session';
/**
* Get the full context passed to the loader.
*
* @returns The full app context.
*/
export const getOptionalLoaderContext = (): AppContext => {
const { context } = getContext<HonoEnv>().var;
return context;
};
/**
* Returns the session extracted from the app context.
*
* @returns The session, or null if not authenticated.
*/
export const getOptionalLoaderSession = (): AppSession | null => {
const { context } = getContext<HonoEnv>().var;
return context.session;
};
/**
* Returns the session context or throws a redirect to signin if it is not present.
*/
export const getLoaderSession = (): AppSession => {
const session = getOptionalLoaderSession();
if (!session) {
throw redirect('/signin'); // Todo: Maybe add a redirect cookie to come back?
}
return session;
};
/**
* Returns the team session context or throws a redirect to signin if it is not present.
*/
export const getLoaderTeamSession = () => {
const session = getOptionalLoaderSession();
if (!session) {
throw redirect('/signin'); // Todo: Maybe add a redirect cookie to come back?
}
if (!session.currentTeam) {
throw new Response(null, { status: 404 }); // Todo: Test that 404 page shows up.
}
return {
...session,
currentTeam: session.currentTeam,
};
};

View File

@ -1,34 +0,0 @@
{
"include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*"],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"types": ["node", "vite/client"],
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"rootDirs": [".", "./.react-router/types"],
"baseUrl": ".",
"paths": {
"~/*": ["./app/*"],
"@documenso/api": ["../../packages/api"],
"@documenso/assets": ["../../packages/assets"],
"@documenso/auth": ["../../packages/auth"],
"@documenso/ee": ["../../packages/ee"],
"@documenso/lib": ["../../packages/lib"],
"@documenso/prisma": ["../../packages/prisma"],
"@documenso/trpc": ["../../packages/trpc"],
"@documenso/ui": ["../../packages/ui"],
"@documenso/tailwind-config": ["../../packages/tailwind-config"]
},
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"moduleDetection": "force",
"resolveJsonModule": true,
"isolatedModules": true,
"skipLibCheck": true,
"strict": true,
"useUnknownInCatchVariables": false
}
}

View File

@ -1,9 +0,0 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
// more env variables...
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@ -1,67 +0,0 @@
import { lingui } from '@lingui/vite-plugin';
import { reactRouter } from '@react-router/dev/vite';
import autoprefixer from 'autoprefixer';
import serverAdapter from 'hono-react-router-adapter/vite';
import tailwindcss from 'tailwindcss';
import { defineConfig } from 'vite';
import macrosPlugin from 'vite-plugin-babel-macros';
import tsconfigPaths from 'vite-tsconfig-paths';
/**
* Note: We load the env variables externally so we can have runtime enviroment variables
* for docker.
*
* Do not configure any envs here.
*/
export default defineConfig({
css: {
postcss: {
plugins: [tailwindcss, autoprefixer],
},
},
server: {
port: 3000,
strictPort: true,
},
plugins: [
reactRouter(),
macrosPlugin(),
lingui(),
tsconfigPaths(),
serverAdapter({
entry: 'server/router.ts',
}),
],
ssr: {
noExternal: ['react-dropzone', 'plausible-tracker', 'pdfjs-dist'],
external: ['@node-rs/bcrypt', '@prisma/client', '@documenso/tailwind-config'],
},
optimizeDeps: {
entries: ['./app/**/*', '../../packages/ui/**/*', '../../packages/lib/**/*'],
include: ['prop-types', 'file-selector', 'attr-accept'],
exclude: ['node_modules', '@node-rs/bcrypt', '@documenso/pdf-sign', 'sharp'],
},
resolve: {
alias: {
https: 'node:https',
'.prisma/client/default': '../../node_modules/.prisma/client/default.js',
'.prisma/client/index-browser': '../../node_modules/.prisma/client/index-browser.js',
},
},
/**
* Note: Re run rollup again to build the server afterwards.
*
* See rollup.config.mjs which is used for that.
*/
build: {
rollupOptions: {
external: [
'@node-rs/bcrypt',
'@documenso/pdf-sign',
'@aws-sdk/cloudfront-signer',
'nodemailer',
'playwright',
],
},
},
});

1
apps/web/README.md Normal file
View File

@ -0,0 +1 @@
# @documenso/web

1
apps/web/ambient.d.ts vendored Normal file
View File

@ -0,0 +1 @@
declare module '@documenso/tailwind-config';

Some files were not shown because too many files have changed in this diff Show More