mirror of
https://github.com/documenso/documenso.git
synced 2025-11-24 21:51:40 +10:00
Compare commits
29 Commits
wip/rr7-ne
...
feat/dicta
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a6942f9da | |||
| 8b82d22f9f | |||
| 00e402f4cb | |||
| 1e90ca45a6 | |||
| 4189a34de0 | |||
| 2ff330f9d4 | |||
| ce1c93b2a6 | |||
| 82337e4e3a | |||
| 7d9a3f9776 | |||
| cbad065dac | |||
| 25a3861c91 | |||
| b9ae277041 | |||
| 7fad826d06 | |||
| eb8ba2036a | |||
| 339759166c | |||
| 637e06f9c0 | |||
| 332e0657e0 | |||
| 4017b250fb | |||
| 41373a7c6f | |||
| 7cc85ca6bc | |||
| bc19fa0cbd | |||
| a60f58e20b | |||
| aca902b5ff | |||
| 2f866c41b4 | |||
| 7e4faef95f | |||
| bcef84787d | |||
| 70a3ac0525 | |||
| c6fb101a99 | |||
| 2984af769c |
@ -5,7 +5,6 @@ module.exports = {
|
|||||||
rules: {
|
rules: {
|
||||||
'@next/next/no-img-element': 'off',
|
'@next/next/no-img-element': 'off',
|
||||||
'no-unreachable': 'error',
|
'no-unreachable': 'error',
|
||||||
'react-hooks/exhaustive-deps': 'off',
|
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
next: {
|
next: {
|
||||||
|
|||||||
@ -111,6 +111,83 @@ The colors will be automatically converted to the appropriate format internally.
|
|||||||
|
|
||||||
4. **Consistent Radius**: Use a consistent border radius value that matches your application's design system.
|
4. **Consistent Radius**: Use a consistent border radius value that matches your application's design system.
|
||||||
|
|
||||||
|
## CSS Class Targets
|
||||||
|
|
||||||
|
In addition to CSS variables, specific components in the embedded experience can be targeted using CSS classes for more granular styling:
|
||||||
|
|
||||||
|
### Component Classes
|
||||||
|
|
||||||
|
| Class Name | Description |
|
||||||
|
| --------------------------------- | ----------------------------------------------------------------------- |
|
||||||
|
| `.embed--Root` | Main container for the embedded signing experience |
|
||||||
|
| `.embed--DocumentContainer` | Container for the document and signing widget |
|
||||||
|
| `.embed--DocumentViewer` | Container for the document viewer |
|
||||||
|
| `.embed--DocumentWidget` | The signing widget container |
|
||||||
|
| `.embed--DocumentWidgetContainer` | Outer container for the signing widget, handles positioning |
|
||||||
|
| `.embed--DocumentWidgetHeader` | Header section of the signing widget |
|
||||||
|
| `.embed--DocumentWidgetContent` | Main content area of the signing widget |
|
||||||
|
| `.embed--DocumentWidgetForm` | Form section within the signing widget |
|
||||||
|
| `.embed--DocumentWidgetFooter` | Footer section of the signing widget |
|
||||||
|
| `.embed--WaitingForTurn` | Container for the waiting screen when it's not the user's turn to sign |
|
||||||
|
| `.embed--DocumentCompleted` | Container for the completion screen after signing |
|
||||||
|
| `.field--FieldRootContainer` | Base container for document fields (signatures, text, checkboxes, etc.) |
|
||||||
|
|
||||||
|
Field components also expose several data attributes that can be used for styling different states:
|
||||||
|
|
||||||
|
| Data Attribute | Values | Description |
|
||||||
|
| ------------------- | ---------------------------------------------- | ------------------------------------ |
|
||||||
|
| `[data-field-type]` | `SIGNATURE`, `TEXT`, `CHECKBOX`, `RADIO`, etc. | The type of field |
|
||||||
|
| `[data-inserted]` | `true`, `false` | Whether the field has been filled |
|
||||||
|
| `[data-validate]` | `true`, `false` | Whether the field is being validated |
|
||||||
|
|
||||||
|
### Field Styling Example
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Style all field containers */
|
||||||
|
.field--FieldRootContainer {
|
||||||
|
transition: all 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Style specific field types */
|
||||||
|
.field--FieldRootContainer[data-field-type='SIGNATURE'] {
|
||||||
|
background-color: rgba(0, 0, 0, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Style inserted fields */
|
||||||
|
.field--FieldRootContainer[data-inserted='true'] {
|
||||||
|
background-color: var(--primary);
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Style fields being validated */
|
||||||
|
.field--FieldRootContainer[data-validate='true'] {
|
||||||
|
border-color: orange;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example Usage
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Custom styles for the document widget */
|
||||||
|
.embed--DocumentWidget {
|
||||||
|
background-color: #ffffff;
|
||||||
|
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom styles for the waiting screen */
|
||||||
|
.embed--WaitingForTurn {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments for the document container */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.embed--DocumentContainer {
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
- [React Integration](/developers/embedding/react)
|
- [React Integration](/developers/embedding/react)
|
||||||
|
|||||||
@ -3,6 +3,8 @@ title: Public API
|
|||||||
description: Learn how to interact with your documents programmatically using the Documenso public API.
|
description: Learn how to interact with your documents programmatically using the Documenso public API.
|
||||||
---
|
---
|
||||||
|
|
||||||
|
import { Callout, Steps } from 'nextra/components';
|
||||||
|
|
||||||
# Public API
|
# Public API
|
||||||
|
|
||||||
Documenso provides a public REST API enabling you to interact with your documents programmatically. The API exposes various HTTP endpoints that allow you to perform operations such as:
|
Documenso provides a public REST API enabling you to interact with your documents programmatically. The API exposes various HTTP endpoints that allow you to perform operations such as:
|
||||||
@ -13,10 +15,24 @@ Documenso provides a public REST API enabling you to interact with your document
|
|||||||
|
|
||||||
The documentation walks you through creating API keys and using them to authenticate your API requests. You'll also learn about the available endpoints, request and response formats, and how to use the API.
|
The documentation walks you through creating API keys and using them to authenticate your API requests. You'll also learn about the available endpoints, request and response formats, and how to use the API.
|
||||||
|
|
||||||
## Swagger Documentation
|
## API V1 - Stable
|
||||||
|
|
||||||
The [Swagger documentation](https://app.documenso.com/api/v1/openapi) also provides information about the API endpoints, request parameters, response formats, and authentication methods.
|
Check out the [API V1 documentation](https://app.documenso.com/api/v1/openapi) for details about the API endpoints, request parameters, response formats, and authentication methods.
|
||||||
|
|
||||||
|
## API V2 - Beta
|
||||||
|
|
||||||
|
Our new API V2 is currently in Beta. The new API features typed SDKs for TypeScript, Python and Go and example code for many more.
|
||||||
|
|
||||||
|
<Callout type="warning">
|
||||||
|
NOW IN BETA: [API V2 Documentation](https://documen.so/api-v2-docs)
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
🚀 [V2 Announcement](https://documen.so/sdk-blog)
|
||||||
|
|
||||||
|
💬 [Leave Feedback](https://documen.so/sdk-feedback)
|
||||||
|
|
||||||
|
🔔 [Breaking Changes](https://documen.so/sdk-breaking)
|
||||||
|
|
||||||
## Availability
|
## Availability
|
||||||
|
|
||||||
The API is available to individual users and teams.
|
The API is available to individual users, teams and higher plans. [Fair Use](https://documen.so/fair) applies.
|
||||||
|
|||||||
@ -21,6 +21,7 @@ Documenso supports Webhooks and allows you to subscribe to the following events:
|
|||||||
- `document.signed`
|
- `document.signed`
|
||||||
- `document.completed`
|
- `document.completed`
|
||||||
- `document.rejected`
|
- `document.rejected`
|
||||||
|
- `document.cancelled`
|
||||||
|
|
||||||
## Create a webhook subscription
|
## Create a webhook subscription
|
||||||
|
|
||||||
@ -37,7 +38,7 @@ Clicking on the "**Create Webhook**" button opens a modal to create a new webhoo
|
|||||||
To create a new webhook subscription, you need to provide the following information:
|
To create a new webhook subscription, you need to provide the following information:
|
||||||
|
|
||||||
- Enter the webhook URL that will receive the event payload.
|
- Enter the webhook URL that will receive the event payload.
|
||||||
- Select the event(s) you want to subscribe to: `document.created`, `document.sent`, `document.opened`, `document.signed`, `document.completed`, `document.rejected`.
|
- Select the event(s) you want to subscribe to: `document.created`, `document.sent`, `document.opened`, `document.signed`, `document.completed`, `document.rejected`, `document.cancelled`.
|
||||||
- Optionally, you can provide a secret key that will be used to sign the payload. This key will be included in the `X-Documenso-Secret` header of the request.
|
- Optionally, you can provide a secret key that will be used to sign the payload. This key will be included in the `X-Documenso-Secret` header of the request.
|
||||||
|
|
||||||

|

|
||||||
@ -528,6 +529,96 @@ Example payload for the `document.rejected` event:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Example payload for the `document.rejected` event:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "DOCUMENT_CANCELLED",
|
||||||
|
"payload": {
|
||||||
|
"id": 7,
|
||||||
|
"externalId": null,
|
||||||
|
"userId": 3,
|
||||||
|
"authOptions": null,
|
||||||
|
"formValues": null,
|
||||||
|
"visibility": "EVERYONE",
|
||||||
|
"title": "documenso.pdf",
|
||||||
|
"status": "PENDING",
|
||||||
|
"documentDataId": "cm6exvn93006hi02ru90a265a",
|
||||||
|
"createdAt": "2025-01-27T11:02:14.393Z",
|
||||||
|
"updatedAt": "2025-01-27T11:03:16.387Z",
|
||||||
|
"completedAt": null,
|
||||||
|
"deletedAt": null,
|
||||||
|
"teamId": null,
|
||||||
|
"templateId": null,
|
||||||
|
"source": "DOCUMENT",
|
||||||
|
"documentMeta": {
|
||||||
|
"id": "cm6exvn96006ji02rqvzjvwoy",
|
||||||
|
"subject": "",
|
||||||
|
"message": "",
|
||||||
|
"timezone": "Etc/UTC",
|
||||||
|
"password": null,
|
||||||
|
"dateFormat": "yyyy-MM-dd hh:mm a",
|
||||||
|
"redirectUrl": "",
|
||||||
|
"signingOrder": "PARALLEL",
|
||||||
|
"typedSignatureEnabled": true,
|
||||||
|
"language": "en",
|
||||||
|
"distributionMethod": "EMAIL",
|
||||||
|
"emailSettings": {
|
||||||
|
"documentDeleted": true,
|
||||||
|
"documentPending": true,
|
||||||
|
"recipientSigned": true,
|
||||||
|
"recipientRemoved": true,
|
||||||
|
"documentCompleted": true,
|
||||||
|
"ownerDocumentCompleted": true,
|
||||||
|
"recipientSigningRequest": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"recipients": [
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"documentId": 7,
|
||||||
|
"templateId": null,
|
||||||
|
"email": "mybirihix@mailinator.com",
|
||||||
|
"name": "Zorita Baird",
|
||||||
|
"token": "XkKx1HCs6Znm2UBJA2j6o",
|
||||||
|
"documentDeletedAt": null,
|
||||||
|
"expired": null,
|
||||||
|
"signedAt": null,
|
||||||
|
"authOptions": { "accessAuth": null, "actionAuth": null },
|
||||||
|
"signingOrder": 1,
|
||||||
|
"rejectionReason": null,
|
||||||
|
"role": "SIGNER",
|
||||||
|
"readStatus": "NOT_OPENED",
|
||||||
|
"signingStatus": "NOT_SIGNED",
|
||||||
|
"sendStatus": "SENT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Recipient": [
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"documentId": 7,
|
||||||
|
"templateId": null,
|
||||||
|
"email": "signer@documenso.com",
|
||||||
|
"name": "Signer",
|
||||||
|
"token": "XkKx1HCs6Znm2UBJA2j6o",
|
||||||
|
"documentDeletedAt": null,
|
||||||
|
"expired": null,
|
||||||
|
"signedAt": null,
|
||||||
|
"authOptions": { "accessAuth": null, "actionAuth": null },
|
||||||
|
"signingOrder": 1,
|
||||||
|
"rejectionReason": null,
|
||||||
|
"role": "SIGNER",
|
||||||
|
"readStatus": "NOT_OPENED",
|
||||||
|
"signingStatus": "NOT_SIGNED",
|
||||||
|
"sendStatus": "SENT"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"createdAt": "2025-01-27T11:03:27.730Z",
|
||||||
|
"webhookEndpoint": "https://mywebhooksite.com/mywebhook"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Availability
|
## Availability
|
||||||
|
|
||||||
Webhooks are available to individual users and teams.
|
Webhooks are available to individual users and teams.
|
||||||
|
|||||||
@ -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"
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
.react-router
|
|
||||||
build
|
|
||||||
node_modules
|
|
||||||
README.md
|
|
||||||
9
apps/remix/.gitignore
vendored
9
apps/remix/.gitignore
vendored
@ -1,9 +0,0 @@
|
|||||||
.DS_Store
|
|
||||||
/node_modules/
|
|
||||||
|
|
||||||
# React Router
|
|
||||||
/.react-router/
|
|
||||||
/build/
|
|
||||||
|
|
||||||
# Vite
|
|
||||||
vite.config.*.timestamp*
|
|
||||||
@ -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"]
|
|
||||||
@ -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"]
|
|
||||||
@ -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"]
|
|
||||||
@ -1,100 +0,0 @@
|
|||||||
# Welcome to React Router!
|
|
||||||
|
|
||||||
A modern, production-ready template for building full-stack React applications using React Router.
|
|
||||||
|
|
||||||
[](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.
|
|
||||||
@ -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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,376 +0,0 @@
|
|||||||
import { useEffect, useLayoutEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import type { DocumentMeta, Recipient, TemplateMeta } from '@prisma/client';
|
|
||||||
import { type DocumentData, type Field, FieldType } from '@prisma/client';
|
|
||||||
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
|
||||||
|
|
||||||
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
|
||||||
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
|
||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
|
||||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { BrandingLogo } from '~/components/general/branding-logo';
|
|
||||||
import { injectCss } from '~/utils/css-vars';
|
|
||||||
|
|
||||||
import { ZSignDocumentEmbedDataSchema } from '../../types/embed-document-sign-schema';
|
|
||||||
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
|
|
||||||
import { EmbedClientLoading } from './embed-client-loading';
|
|
||||||
import { EmbedDocumentCompleted } from './embed-document-completed';
|
|
||||||
import { EmbedDocumentFields } from './embed-document-fields';
|
|
||||||
|
|
||||||
export type EmbedSignDocumentClientPageProps = {
|
|
||||||
token: string;
|
|
||||||
documentId: number;
|
|
||||||
documentData: DocumentData;
|
|
||||||
recipient: Recipient;
|
|
||||||
fields: Field[];
|
|
||||||
metadata?: DocumentMeta | TemplateMeta | null;
|
|
||||||
isCompleted?: boolean;
|
|
||||||
hidePoweredBy?: boolean;
|
|
||||||
isPlatformOrEnterprise?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const EmbedSignDocumentClientPage = ({
|
|
||||||
token,
|
|
||||||
documentId,
|
|
||||||
documentData,
|
|
||||||
recipient,
|
|
||||||
fields,
|
|
||||||
metadata,
|
|
||||||
isCompleted,
|
|
||||||
hidePoweredBy = false,
|
|
||||||
isPlatformOrEnterprise = false,
|
|
||||||
}: EmbedSignDocumentClientPageProps) => {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const {
|
|
||||||
fullName,
|
|
||||||
email,
|
|
||||||
signature,
|
|
||||||
signatureValid,
|
|
||||||
setFullName,
|
|
||||||
setSignature,
|
|
||||||
setSignatureValid,
|
|
||||||
} = useRequiredDocumentSigningContext();
|
|
||||||
|
|
||||||
const [hasFinishedInit, setHasFinishedInit] = useState(false);
|
|
||||||
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
|
|
||||||
const [hasCompletedDocument, setHasCompletedDocument] = useState(isCompleted);
|
|
||||||
|
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
|
||||||
|
|
||||||
const [isNameLocked, setIsNameLocked] = useState(false);
|
|
||||||
|
|
||||||
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
|
||||||
|
|
||||||
const [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500);
|
|
||||||
|
|
||||||
const [pendingFields, _completedFields] = [
|
|
||||||
fields.filter((field) => !field.inserted),
|
|
||||||
fields.filter((field) => field.inserted),
|
|
||||||
];
|
|
||||||
|
|
||||||
const { mutateAsync: completeDocumentWithToken, isPending: isSubmitting } =
|
|
||||||
trpc.recipient.completeDocumentWithToken.useMutation();
|
|
||||||
|
|
||||||
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
|
|
||||||
|
|
||||||
const onNextFieldClick = () => {
|
|
||||||
validateFieldsInserted(fields);
|
|
||||||
|
|
||||||
setShowPendingFieldTooltip(true);
|
|
||||||
setIsExpanded(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onCompleteClick = async () => {
|
|
||||||
try {
|
|
||||||
if (hasSignatureField && !signatureValid) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const valid = validateFieldsInserted(fields);
|
|
||||||
|
|
||||||
if (!valid) {
|
|
||||||
setShowPendingFieldTooltip(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await completeDocumentWithToken({
|
|
||||||
documentId,
|
|
||||||
token,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (window.parent) {
|
|
||||||
window.parent.postMessage(
|
|
||||||
{
|
|
||||||
action: 'document-completed',
|
|
||||||
data: {
|
|
||||||
token,
|
|
||||||
documentId,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'*',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
setHasCompletedDocument(true);
|
|
||||||
} catch (err) {
|
|
||||||
if (window.parent) {
|
|
||||||
window.parent.postMessage(
|
|
||||||
{
|
|
||||||
action: 'document-error',
|
|
||||||
data: null,
|
|
||||||
},
|
|
||||||
'*',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`Something went wrong`),
|
|
||||||
description: _(
|
|
||||||
msg`We were unable to submit this document at this time. Please try again later.`,
|
|
||||||
),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const hash = window.location.hash.slice(1);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = ZSignDocumentEmbedDataSchema.parse(JSON.parse(decodeURIComponent(atob(hash))));
|
|
||||||
|
|
||||||
if (!isCompleted && data.name) {
|
|
||||||
setFullName(data.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Since a recipient can be provided a name we can lock it without requiring
|
|
||||||
// a to be provided by the parent application, unlike direct templates.
|
|
||||||
setIsNameLocked(!!data.lockName);
|
|
||||||
|
|
||||||
if (data.darkModeDisabled) {
|
|
||||||
document.documentElement.classList.add('dark-mode-disabled');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPlatformOrEnterprise) {
|
|
||||||
injectCss({
|
|
||||||
css: data.css,
|
|
||||||
cssVars: data.cssVars,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
setHasFinishedInit(true);
|
|
||||||
|
|
||||||
// !: While the two setters are stable we still want to ensure we're avoiding
|
|
||||||
// !: re-renders.
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasFinishedInit && hasDocumentLoaded && window.parent) {
|
|
||||||
window.parent.postMessage(
|
|
||||||
{
|
|
||||||
action: 'document-ready',
|
|
||||||
data: null,
|
|
||||||
},
|
|
||||||
'*',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [hasFinishedInit, hasDocumentLoaded]);
|
|
||||||
|
|
||||||
if (hasCompletedDocument) {
|
|
||||||
return (
|
|
||||||
<EmbedDocumentCompleted
|
|
||||||
name={fullName}
|
|
||||||
signature={{
|
|
||||||
id: 1,
|
|
||||||
fieldId: 1,
|
|
||||||
recipientId: 1,
|
|
||||||
created: new Date(),
|
|
||||||
signatureImageAsBase64: signature?.startsWith('data:') ? signature : null,
|
|
||||||
typedSignature: signature?.startsWith('data:') ? null : signature,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
|
||||||
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
|
|
||||||
|
|
||||||
<div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
|
||||||
{/* Viewer */}
|
|
||||||
<div className="flex-1">
|
|
||||||
<LazyPDFViewer
|
|
||||||
documentData={documentData}
|
|
||||||
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Widget */}
|
|
||||||
<div
|
|
||||||
key={isExpanded ? 'expanded' : 'collapsed'}
|
|
||||||
className="group/document-widget fixed bottom-8 left-0 z-50 h-fit w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
|
|
||||||
data-expanded={isExpanded || undefined}
|
|
||||||
>
|
|
||||||
<div className="border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between gap-x-2">
|
|
||||||
<h3 className="text-foreground text-xl font-semibold md:text-2xl">
|
|
||||||
<Trans>Sign document</Trans>
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
|
|
||||||
{isExpanded ? (
|
|
||||||
<LucideChevronDown
|
|
||||||
className="text-muted-foreground h-5 w-5"
|
|
||||||
onClick={() => setIsExpanded(false)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<LucideChevronUp
|
|
||||||
className="text-muted-foreground h-5 w-5"
|
|
||||||
onClick={() => setIsExpanded(true)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="hidden group-data-[expanded]/document-widget:block md:block">
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
|
||||||
<Trans>Sign the document to complete the process.</Trans>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<hr className="border-border mb-8 mt-4" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Form */}
|
|
||||||
<div className="-mx-2 hidden px-2 group-data-[expanded]/document-widget:block md:block">
|
|
||||||
<div className="flex flex-1 flex-col gap-y-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="full-name">
|
|
||||||
<Trans>Full Name</Trans>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
id="full-name"
|
|
||||||
className="bg-background mt-2"
|
|
||||||
disabled={isNameLocked}
|
|
||||||
value={fullName}
|
|
||||||
onChange={(e) => !isNameLocked && setFullName(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="email">
|
|
||||||
<Trans>Email</Trans>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
id="email"
|
|
||||||
className="bg-background mt-2"
|
|
||||||
value={email}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="Signature">
|
|
||||||
<Trans>Signature</Trans>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Card className="mt-2" gradient degrees={-120}>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<SignaturePad
|
|
||||||
className="h-44 w-full"
|
|
||||||
disabled={isThrottled || isSubmitting}
|
|
||||||
defaultValue={signature ?? undefined}
|
|
||||||
onChange={(value) => {
|
|
||||||
setSignature(value);
|
|
||||||
}}
|
|
||||||
onValidityChange={(isValid) => {
|
|
||||||
setSignatureValid(isValid);
|
|
||||||
}}
|
|
||||||
allowTypedSignature={Boolean(
|
|
||||||
metadata &&
|
|
||||||
'typedSignatureEnabled' in metadata &&
|
|
||||||
metadata.typedSignatureEnabled,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{hasSignatureField && !signatureValid && (
|
|
||||||
<div className="text-destructive mt-2 text-sm">
|
|
||||||
<Trans>
|
|
||||||
Signature is too small. Please provide a more complete signature.
|
|
||||||
</Trans>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="hidden flex-1 group-data-[expanded]/document-widget:block md:block" />
|
|
||||||
|
|
||||||
<div className="mt-4 hidden w-full grid-cols-2 items-center group-data-[expanded]/document-widget:grid md:grid">
|
|
||||||
{pendingFields.length > 0 ? (
|
|
||||||
<Button className="col-start-2" onClick={() => onNextFieldClick()}>
|
|
||||||
<Trans>Next</Trans>
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
className="col-start-2"
|
|
||||||
disabled={isThrottled || (hasSignatureField && !signatureValid)}
|
|
||||||
loading={isSubmitting}
|
|
||||||
onClick={() => throttledOnCompleteClick()}
|
|
||||||
>
|
|
||||||
<Trans>Complete</Trans>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
|
||||||
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
|
||||||
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
|
|
||||||
<Trans>Click to insert field</Trans>
|
|
||||||
</FieldToolTip>
|
|
||||||
)}
|
|
||||||
</ElementVisible>
|
|
||||||
|
|
||||||
{/* Fields */}
|
|
||||||
<EmbedDocumentFields recipient={recipient} fields={fields} metadata={metadata} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!hidePoweredBy && (
|
|
||||||
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
|
|
||||||
<span>Powered by</span>
|
|
||||||
<BrandingLogo className="ml-2 inline-block h-[14px]" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -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
|
|
||||||
@ -1,150 +0,0 @@
|
|||||||
import { useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import type { Field } from '@prisma/client';
|
|
||||||
import { RecipientRole } from '@prisma/client';
|
|
||||||
|
|
||||||
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
|
|
||||||
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
|
|
||||||
|
|
||||||
export type DocumentSigningCompleteDialogProps = {
|
|
||||||
isSubmitting: boolean;
|
|
||||||
documentTitle: string;
|
|
||||||
fields: Field[];
|
|
||||||
fieldsValidated: () => void | Promise<void>;
|
|
||||||
onSignatureComplete: () => void | Promise<void>;
|
|
||||||
role: RecipientRole;
|
|
||||||
disabled?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DocumentSigningCompleteDialog = ({
|
|
||||||
isSubmitting,
|
|
||||||
documentTitle,
|
|
||||||
fields,
|
|
||||||
fieldsValidated,
|
|
||||||
onSignatureComplete,
|
|
||||||
role,
|
|
||||||
disabled = false,
|
|
||||||
}: DocumentSigningCompleteDialogProps) => {
|
|
||||||
const [showDialog, setShowDialog] = useState(false);
|
|
||||||
|
|
||||||
const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]);
|
|
||||||
|
|
||||||
const handleOpenChange = (open: boolean) => {
|
|
||||||
if (isSubmitting || !isComplete) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setShowDialog(open);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={showDialog} onOpenChange={handleOpenChange}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button
|
|
||||||
className="w-full"
|
|
||||||
type="button"
|
|
||||||
size="lg"
|
|
||||||
onClick={fieldsValidated}
|
|
||||||
loading={isSubmitting}
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
{isComplete ? <Trans>Complete</Trans> : <Trans>Next field</Trans>}
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent>
|
|
||||||
<DialogTitle>
|
|
||||||
<div className="text-foreground text-xl font-semibold">
|
|
||||||
{role === RecipientRole.VIEWER && <Trans>Complete Viewing</Trans>}
|
|
||||||
{role === RecipientRole.SIGNER && <Trans>Complete Signing</Trans>}
|
|
||||||
{role === RecipientRole.APPROVER && <Trans>Complete Approval</Trans>}
|
|
||||||
</div>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<div className="text-muted-foreground max-w-[50ch]">
|
|
||||||
{role === RecipientRole.VIEWER && (
|
|
||||||
<span>
|
|
||||||
<Trans>
|
|
||||||
<span className="inline-flex flex-wrap">
|
|
||||||
You are about to complete viewing "
|
|
||||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
|
||||||
{documentTitle}
|
|
||||||
</span>
|
|
||||||
".
|
|
||||||
</span>
|
|
||||||
<br /> Are you sure?
|
|
||||||
</Trans>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{role === RecipientRole.SIGNER && (
|
|
||||||
<span>
|
|
||||||
<Trans>
|
|
||||||
<span className="inline-flex flex-wrap">
|
|
||||||
You are about to complete signing "
|
|
||||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
|
||||||
{documentTitle}
|
|
||||||
</span>
|
|
||||||
".
|
|
||||||
</span>
|
|
||||||
<br /> Are you sure?
|
|
||||||
</Trans>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{role === RecipientRole.APPROVER && (
|
|
||||||
<span>
|
|
||||||
<Trans>
|
|
||||||
<span className="inline-flex flex-wrap">
|
|
||||||
You are about to complete approving{' '}
|
|
||||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
|
||||||
"{documentTitle}"
|
|
||||||
</span>
|
|
||||||
.
|
|
||||||
</span>
|
|
||||||
<br /> Are you sure?
|
|
||||||
</Trans>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DocumentSigningDisclosure className="mt-4" />
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => {
|
|
||||||
setShowDialog(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="flex-1"
|
|
||||||
disabled={!isComplete}
|
|
||||||
loading={isSubmitting}
|
|
||||||
onClick={onSignatureComplete}
|
|
||||||
>
|
|
||||||
{role === RecipientRole.VIEWER && <Trans>Mark as Viewed</Trans>}
|
|
||||||
{role === RecipientRole.SIGNER && <Trans>Sign</Trans>}
|
|
||||||
{role === RecipientRole.APPROVER && <Trans>Approve</Trans>}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,268 +0,0 @@
|
|||||||
import { useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { type Field, FieldType, type Recipient, RecipientRole } from '@prisma/client';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { useNavigate } from 'react-router';
|
|
||||||
|
|
||||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
|
||||||
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
|
||||||
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
|
||||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
|
||||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
|
||||||
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
|
||||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
|
||||||
|
|
||||||
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
|
|
||||||
import { useRequiredDocumentSigningContext } from './document-signing-provider';
|
|
||||||
|
|
||||||
export type DocumentSigningFormProps = {
|
|
||||||
document: DocumentAndSender;
|
|
||||||
recipient: Recipient;
|
|
||||||
fields: Field[];
|
|
||||||
redirectUrl?: string | null;
|
|
||||||
isRecipientsTurn: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DocumentSigningForm = ({
|
|
||||||
document,
|
|
||||||
recipient,
|
|
||||||
fields,
|
|
||||||
redirectUrl,
|
|
||||||
isRecipientsTurn,
|
|
||||||
}: DocumentSigningFormProps) => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const analytics = useAnalytics();
|
|
||||||
|
|
||||||
const { user } = useOptionalSession();
|
|
||||||
|
|
||||||
const { fullName, signature, setFullName, setSignature, signatureValid, setSignatureValid } =
|
|
||||||
useRequiredDocumentSigningContext();
|
|
||||||
|
|
||||||
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
|
||||||
|
|
||||||
const { mutateAsync: completeDocumentWithToken } =
|
|
||||||
trpc.recipient.completeDocumentWithToken.useMutation();
|
|
||||||
|
|
||||||
const { handleSubmit, formState } = useForm();
|
|
||||||
|
|
||||||
// Keep the loading state going if successful since the redirect may take some time.
|
|
||||||
const isSubmitting = formState.isSubmitting || formState.isSubmitSuccessful;
|
|
||||||
|
|
||||||
const fieldsRequiringValidation = useMemo(
|
|
||||||
() => fields.filter(isFieldUnsignedAndRequired),
|
|
||||||
[fields],
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
|
|
||||||
|
|
||||||
const uninsertedFields = useMemo(() => {
|
|
||||||
return sortFieldsByPosition(fieldsRequiringValidation.filter((field) => !field.inserted));
|
|
||||||
}, [fields]);
|
|
||||||
|
|
||||||
const fieldsValidated = () => {
|
|
||||||
setValidateUninsertedFields(true);
|
|
||||||
validateFieldsInserted(fieldsRequiringValidation);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onFormSubmit = async () => {
|
|
||||||
setValidateUninsertedFields(true);
|
|
||||||
|
|
||||||
const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation);
|
|
||||||
|
|
||||||
if (hasSignatureField && !signatureValid) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isFieldsValid) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await completeDocument();
|
|
||||||
|
|
||||||
// Reauth is currently not required for completing the document.
|
|
||||||
// await executeActionAuthProcedure({
|
|
||||||
// onReauthFormSubmit: completeDocument,
|
|
||||||
// actionTarget: 'DOCUMENT',
|
|
||||||
// });
|
|
||||||
};
|
|
||||||
|
|
||||||
const completeDocument = async (authOptions?: TRecipientActionAuth) => {
|
|
||||||
await completeDocumentWithToken({
|
|
||||||
token: recipient.token,
|
|
||||||
documentId: document.id,
|
|
||||||
authOptions,
|
|
||||||
});
|
|
||||||
|
|
||||||
analytics.capture('App: Recipient has completed signing', {
|
|
||||||
signerId: recipient.id,
|
|
||||||
documentId: document.id,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (redirectUrl) {
|
|
||||||
window.location.href = redirectUrl;
|
|
||||||
} else {
|
|
||||||
await navigate(`/sign/${recipient.token}/complete`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
className={cn(
|
|
||||||
'dark:bg-background border-border bg-widget sticky flex h-full flex-col rounded-xl border px-4 py-6',
|
|
||||||
{
|
|
||||||
'top-20 max-h-[min(68rem,calc(100vh-6rem))]': user,
|
|
||||||
'top-4 max-h-[min(68rem,calc(100vh-2rem))]': !user,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
onSubmit={handleSubmit(onFormSubmit)}
|
|
||||||
>
|
|
||||||
{validateUninsertedFields && uninsertedFields[0] && (
|
|
||||||
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
|
|
||||||
<Trans>Click to insert field</Trans>
|
|
||||||
</FieldToolTip>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<fieldset
|
|
||||||
disabled={isSubmitting}
|
|
||||||
className={cn(
|
|
||||||
'custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className={cn('flex flex-1 flex-col')}>
|
|
||||||
<h3 className="text-foreground text-2xl font-semibold">
|
|
||||||
{recipient.role === RecipientRole.VIEWER && <Trans>View Document</Trans>}
|
|
||||||
{recipient.role === RecipientRole.SIGNER && <Trans>Sign Document</Trans>}
|
|
||||||
{recipient.role === RecipientRole.APPROVER && <Trans>Approve Document</Trans>}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{recipient.role === RecipientRole.VIEWER ? (
|
|
||||||
<>
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
|
||||||
<Trans>Please mark as viewed to complete</Trans>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<hr className="border-border mb-8 mt-4" />
|
|
||||||
|
|
||||||
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
|
|
||||||
<div className="flex flex-1 flex-col gap-y-4" />
|
|
||||||
<div className="flex flex-col gap-4 md:flex-row">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
|
||||||
variant="secondary"
|
|
||||||
size="lg"
|
|
||||||
disabled={typeof window !== 'undefined' && window.history.length <= 1}
|
|
||||||
onClick={async () => navigate(-1)}
|
|
||||||
>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<DocumentSigningCompleteDialog
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
onSignatureComplete={handleSubmit(onFormSubmit)}
|
|
||||||
documentTitle={document.title}
|
|
||||||
fields={fields}
|
|
||||||
fieldsValidated={fieldsValidated}
|
|
||||||
role={recipient.role}
|
|
||||||
disabled={!isRecipientsTurn}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
|
||||||
<Trans>Please review the document before signing.</Trans>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<hr className="border-border mb-8 mt-4" />
|
|
||||||
|
|
||||||
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
|
|
||||||
<div className="flex flex-1 flex-col gap-y-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="full-name">
|
|
||||||
<Trans>Full Name</Trans>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
id="full-name"
|
|
||||||
className="bg-background mt-2"
|
|
||||||
value={fullName}
|
|
||||||
onChange={(e) => setFullName(e.target.value.trimStart())}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="Signature">
|
|
||||||
<Trans>Signature</Trans>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Card className="mt-2" gradient degrees={-120}>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<SignaturePad
|
|
||||||
className="h-44 w-full"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
defaultValue={signature ?? undefined}
|
|
||||||
onValidityChange={(isValid) => {
|
|
||||||
setSignatureValid(isValid);
|
|
||||||
}}
|
|
||||||
onChange={(value) => {
|
|
||||||
if (signatureValid) {
|
|
||||||
setSignature(value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
allowTypedSignature={document.documentMeta?.typedSignatureEnabled}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{hasSignatureField && !signatureValid && (
|
|
||||||
<div className="text-destructive mt-2 text-sm">
|
|
||||||
<Trans>
|
|
||||||
Signature is too small. Please provide a more complete signature.
|
|
||||||
</Trans>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 md:flex-row">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
|
||||||
variant="secondary"
|
|
||||||
size="lg"
|
|
||||||
disabled={typeof window !== 'undefined' && window.history.length <= 1}
|
|
||||||
onClick={async () => navigate(-1)}
|
|
||||||
>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<DocumentSigningCompleteDialog
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
onSignatureComplete={handleSubmit(onFormSubmit)}
|
|
||||||
documentTitle={document.title}
|
|
||||||
fields={fields}
|
|
||||||
fieldsValidated={fieldsValidated}
|
|
||||||
role={recipient.role}
|
|
||||||
disabled={!isRecipientsTurn}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,245 +0,0 @@
|
|||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import type { Field, Recipient } from '@prisma/client';
|
|
||||||
import { FieldType, RecipientRole } from '@prisma/client';
|
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
|
|
||||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
|
||||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
|
||||||
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
|
||||||
import {
|
|
||||||
ZCheckboxFieldMeta,
|
|
||||||
ZDropdownFieldMeta,
|
|
||||||
ZNumberFieldMeta,
|
|
||||||
ZRadioFieldMeta,
|
|
||||||
ZTextFieldMeta,
|
|
||||||
} from '@documenso/lib/types/field-meta';
|
|
||||||
import type { CompletedField } from '@documenso/lib/types/fields';
|
|
||||||
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
|
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
|
||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
|
||||||
|
|
||||||
import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign';
|
|
||||||
import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field';
|
|
||||||
import { DocumentSigningDateField } from '~/components/general/document-signing/document-signing-date-field';
|
|
||||||
import { DocumentSigningDropdownField } from '~/components/general/document-signing/document-signing-dropdown-field';
|
|
||||||
import { DocumentSigningEmailField } from '~/components/general/document-signing/document-signing-email-field';
|
|
||||||
import { DocumentSigningForm } from '~/components/general/document-signing/document-signing-form';
|
|
||||||
import { DocumentSigningInitialsField } from '~/components/general/document-signing/document-signing-initials-field';
|
|
||||||
import { DocumentSigningNameField } from '~/components/general/document-signing/document-signing-name-field';
|
|
||||||
import { DocumentSigningNumberField } from '~/components/general/document-signing/document-signing-number-field';
|
|
||||||
import { DocumentSigningRadioField } from '~/components/general/document-signing/document-signing-radio-field';
|
|
||||||
import { DocumentSigningRejectDialog } from '~/components/general/document-signing/document-signing-reject-dialog';
|
|
||||||
import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field';
|
|
||||||
import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field';
|
|
||||||
import { DocumentReadOnlyFields } from '~/components/general/document/document-read-only-fields';
|
|
||||||
|
|
||||||
export type SigningPageViewProps = {
|
|
||||||
document: DocumentAndSender;
|
|
||||||
recipient: Recipient;
|
|
||||||
fields: Field[];
|
|
||||||
completedFields: CompletedField[];
|
|
||||||
isRecipientsTurn: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DocumentSigningPageView = ({
|
|
||||||
document,
|
|
||||||
recipient,
|
|
||||||
fields,
|
|
||||||
completedFields,
|
|
||||||
isRecipientsTurn,
|
|
||||||
}: SigningPageViewProps) => {
|
|
||||||
const { documentData, documentMeta } = document;
|
|
||||||
|
|
||||||
const shouldUseTeamDetails =
|
|
||||||
document.teamId && document.team?.teamGlobalSettings?.includeSenderDetails === false;
|
|
||||||
|
|
||||||
let senderName = document.user.name ?? '';
|
|
||||||
let senderEmail = `(${document.user.email})`;
|
|
||||||
|
|
||||||
if (shouldUseTeamDetails) {
|
|
||||||
senderName = document.team?.name ?? '';
|
|
||||||
senderEmail = document.team?.teamEmail?.email ? `(${document.team.teamEmail.email})` : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto w-full max-w-screen-xl">
|
|
||||||
<h1
|
|
||||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
|
||||||
title={document.title}
|
|
||||||
>
|
|
||||||
{document.title}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div className="mt-2.5 flex flex-wrap items-center justify-between gap-x-6">
|
|
||||||
<div className="max-w-[50ch]">
|
|
||||||
<span className="text-muted-foreground truncate" title={senderName}>
|
|
||||||
{senderName} {senderEmail}
|
|
||||||
</span>{' '}
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{match(recipient.role)
|
|
||||||
.with(RecipientRole.VIEWER, () =>
|
|
||||||
document.teamId && !shouldUseTeamDetails ? (
|
|
||||||
<Trans>
|
|
||||||
on behalf of "{document.team?.name}" has invited you to view this document
|
|
||||||
</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>has invited you to view this document</Trans>
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.with(RecipientRole.SIGNER, () =>
|
|
||||||
document.teamId && !shouldUseTeamDetails ? (
|
|
||||||
<Trans>
|
|
||||||
on behalf of "{document.team?.name}" has invited you to sign this document
|
|
||||||
</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>has invited you to sign this document</Trans>
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.with(RecipientRole.APPROVER, () =>
|
|
||||||
document.teamId && !shouldUseTeamDetails ? (
|
|
||||||
<Trans>
|
|
||||||
on behalf of "{document.team?.name}" has invited you to approve this document
|
|
||||||
</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>has invited you to approve this document</Trans>
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.otherwise(() => null)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DocumentSigningRejectDialog document={document} token={recipient.token} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 grid grid-cols-12 gap-y-8 lg:gap-x-8 lg:gap-y-0">
|
|
||||||
<Card
|
|
||||||
className="col-span-12 rounded-xl before:rounded-xl lg:col-span-7 xl:col-span-8"
|
|
||||||
gradient
|
|
||||||
>
|
|
||||||
<CardContent className="p-2">
|
|
||||||
<LazyPDFViewer
|
|
||||||
key={documentData.id}
|
|
||||||
documentData={documentData}
|
|
||||||
document={document}
|
|
||||||
password={documentMeta?.password}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="col-span-12 lg:col-span-5 xl:col-span-4">
|
|
||||||
<DocumentSigningForm
|
|
||||||
document={document}
|
|
||||||
recipient={recipient}
|
|
||||||
fields={fields}
|
|
||||||
redirectUrl={documentMeta?.redirectUrl}
|
|
||||||
isRecipientsTurn={isRecipientsTurn}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DocumentReadOnlyFields fields={completedFields} />
|
|
||||||
|
|
||||||
<DocumentSigningAutoSign recipient={recipient} fields={fields} />
|
|
||||||
|
|
||||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
|
||||||
{fields.map((field) =>
|
|
||||||
match(field.type)
|
|
||||||
.with(FieldType.SIGNATURE, () => (
|
|
||||||
<DocumentSigningSignatureField
|
|
||||||
key={field.id}
|
|
||||||
field={field}
|
|
||||||
recipient={recipient}
|
|
||||||
typedSignatureEnabled={documentMeta?.typedSignatureEnabled}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
.with(FieldType.INITIALS, () => (
|
|
||||||
<DocumentSigningInitialsField key={field.id} field={field} recipient={recipient} />
|
|
||||||
))
|
|
||||||
.with(FieldType.NAME, () => (
|
|
||||||
<DocumentSigningNameField key={field.id} field={field} recipient={recipient} />
|
|
||||||
))
|
|
||||||
.with(FieldType.DATE, () => (
|
|
||||||
<DocumentSigningDateField
|
|
||||||
key={field.id}
|
|
||||||
field={field}
|
|
||||||
recipient={recipient}
|
|
||||||
dateFormat={documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
|
|
||||||
timezone={documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
.with(FieldType.EMAIL, () => (
|
|
||||||
<DocumentSigningEmailField key={field.id} field={field} recipient={recipient} />
|
|
||||||
))
|
|
||||||
.with(FieldType.TEXT, () => {
|
|
||||||
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
|
||||||
...field,
|
|
||||||
fieldMeta: field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : null,
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<DocumentSigningTextField
|
|
||||||
key={field.id}
|
|
||||||
field={fieldWithMeta}
|
|
||||||
recipient={recipient}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.with(FieldType.NUMBER, () => {
|
|
||||||
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
|
||||||
...field,
|
|
||||||
fieldMeta: field.fieldMeta ? ZNumberFieldMeta.parse(field.fieldMeta) : null,
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<DocumentSigningNumberField
|
|
||||||
key={field.id}
|
|
||||||
field={fieldWithMeta}
|
|
||||||
recipient={recipient}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.with(FieldType.RADIO, () => {
|
|
||||||
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
|
||||||
...field,
|
|
||||||
fieldMeta: field.fieldMeta ? ZRadioFieldMeta.parse(field.fieldMeta) : null,
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<DocumentSigningRadioField
|
|
||||||
key={field.id}
|
|
||||||
field={fieldWithMeta}
|
|
||||||
recipient={recipient}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.with(FieldType.CHECKBOX, () => {
|
|
||||||
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
|
||||||
...field,
|
|
||||||
fieldMeta: field.fieldMeta ? ZCheckboxFieldMeta.parse(field.fieldMeta) : null,
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<DocumentSigningCheckboxField
|
|
||||||
key={field.id}
|
|
||||||
field={fieldWithMeta}
|
|
||||||
recipient={recipient}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.with(FieldType.DROPDOWN, () => {
|
|
||||||
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
|
||||||
...field,
|
|
||||||
fieldMeta: field.fieldMeta ? ZDropdownFieldMeta.parse(field.fieldMeta) : null,
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<DocumentSigningDropdownField
|
|
||||||
key={field.id}
|
|
||||||
field={fieldWithMeta}
|
|
||||||
recipient={recipient}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.otherwise(() => null),
|
|
||||||
)}
|
|
||||||
</ElementVisible>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -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>
|
|
||||||
));
|
|
||||||
};
|
|
||||||
@ -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();
|
|
||||||
@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -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} />;
|
|
||||||
}
|
|
||||||
@ -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)$$/,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
import { redirect } from 'react-router';
|
|
||||||
|
|
||||||
export function loader() {
|
|
||||||
throw redirect('/admin/stats');
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AdminPage() {
|
|
||||||
// Redirect page.
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
import { redirect } from 'react-router';
|
|
||||||
|
|
||||||
export function loader() {
|
|
||||||
throw redirect('/settings/profile');
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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));
|
|
||||||
}
|
|
||||||
@ -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>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
import DocumentPage, { loader } from '~/routes/_authenticated+/documents+/$id._index';
|
|
||||||
|
|
||||||
export { loader };
|
|
||||||
|
|
||||||
export default DocumentPage;
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
import DocumentEditPage, { loader } from '~/routes/_authenticated+/documents+/$id.edit';
|
|
||||||
|
|
||||||
export { loader };
|
|
||||||
|
|
||||||
export default DocumentEditPage;
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
import DocumentLogsPage, { loader } from '~/routes/_authenticated+/documents+/$id.logs';
|
|
||||||
|
|
||||||
export { loader };
|
|
||||||
|
|
||||||
export default DocumentLogsPage;
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
import DocumentsPage, { meta } from '~/routes/_authenticated+/documents+/_index';
|
|
||||||
|
|
||||||
export { meta };
|
|
||||||
|
|
||||||
export default DocumentsPage;
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
import TemplatePage, { loader } from '~/routes/_authenticated+/templates+/$id._index';
|
|
||||||
|
|
||||||
export { loader };
|
|
||||||
|
|
||||||
export default TemplatePage;
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
import TemplateEditPage, { loader } from '~/routes/_authenticated+/templates+/$id.edit';
|
|
||||||
|
|
||||||
export { loader };
|
|
||||||
|
|
||||||
export default TemplateEditPage;
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
import TemplatesPage, { meta } from '~/routes/_authenticated+/templates+/_index';
|
|
||||||
|
|
||||||
export { meta };
|
|
||||||
|
|
||||||
export default TemplatesPage;
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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');
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
@ -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',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -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());
|
|
||||||
};
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
@ -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',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -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',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -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 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
@ -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) },
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
import { createThemeAction } from 'remix-themes';
|
|
||||||
|
|
||||||
import { themeSessionResolver } from '~/storage/theme-session.server';
|
|
||||||
|
|
||||||
export const action = createThemeAction(themeSessionResolver);
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
@ -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>;
|
|
||||||
}
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
import { createCookie } from 'react-router';
|
|
||||||
|
|
||||||
export const langCookie = createCookie('lang', {
|
|
||||||
path: '/',
|
|
||||||
maxAge: 60 * 60 * 24 * 365 * 2,
|
|
||||||
});
|
|
||||||
@ -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);
|
|
||||||
@ -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`,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 458 KiB |
@ -1,6 +0,0 @@
|
|||||||
import type { Config } from '@react-router/dev/config';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
appDirectory: 'app',
|
|
||||||
ssr: true,
|
|
||||||
} satisfies Config;
|
|
||||||
@ -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;
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@ -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>;
|
|
||||||
@ -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/|^/__');
|
|
||||||
@ -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 });
|
|
||||||
@ -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();
|
|
||||||
// }
|
|
||||||
};
|
|
||||||
@ -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;
|
|
||||||
@ -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 {};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -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'),
|
|
||||||
});
|
|
||||||
@ -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,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
9
apps/remix/vite-env.d.ts
vendored
9
apps/remix/vite-env.d.ts
vendored
@ -1,9 +0,0 @@
|
|||||||
/// <reference types="vite/client" />
|
|
||||||
|
|
||||||
interface ImportMetaEnv {
|
|
||||||
// more env variables...
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ImportMeta {
|
|
||||||
readonly env: ImportMetaEnv;
|
|
||||||
}
|
|
||||||
@ -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
1
apps/web/README.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
# @documenso/web
|
||||||
1
apps/web/ambient.d.ts
vendored
Normal file
1
apps/web/ambient.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
declare module '@documenso/tailwind-config';
|
||||||
6
apps/web/next-env.d.ts
vendored
Normal file
6
apps/web/next-env.d.ts
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
/// <reference types="next/navigation-types/compat/navigation" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user