Compare commits

..

5 Commits

Author SHA1 Message Date
Mythie
4e197ac24c v1.9.0-rc.7 2025-01-09 15:07:11 +11:00
Ephraim Duncan
f707e5fb10 fix: update template field schema (#1575)
Co-authored-by: Lucas Smith <me@lucasjamessmith.me>
2025-01-09 12:06:17 +11:00
Catalin Pit
6fc5e565d0 fix: add document visibility to template (#1566)
Adds the visibility property to templates
2025-01-09 10:14:24 +11:00
Mythie
07c852744b v1.9.0-rc.6 2025-01-08 20:18:09 +11:00
Lucas Smith
4fab98c633 feat: allow switching document when using templates (#1571)
Adds the ability to upload a custom document when using a template.

This is useful when you have a given fixed template with placeholder
values that you want to decorate with Documenso fields but will then
create a final specialised document when sending it out to a given
recipient.
2025-01-07 16:13:35 +11:00
51 changed files with 1921 additions and 4568 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +0,0 @@
@import '@documenso/ui/styles/theme.css';

View File

@@ -1,13 +0,0 @@
import { twoFactor } from 'better-auth/plugins';
import { createAuthClient } from 'better-auth/react';
import { passkeyClientPlugin } from './auth/passkey-plugin/client';
// make sure to import from better-auth/react
export const authClient = createAuthClient({
baseURL: 'http://localhost:3000',
plugins: [twoFactor(), passkeyClientPlugin()],
});
export const { signIn, signOut, useSession } = authClient;

View File

@@ -1,112 +0,0 @@
import { compare, hash } from '@node-rs/bcrypt';
import { betterAuth } from 'better-auth';
import { prismaAdapter } from 'better-auth/adapters/prisma';
import { twoFactor } from 'better-auth/plugins';
import { getAuthenticatorOptions } from '@documenso/lib/utils/authenticator';
import { prisma } from '@documenso/prisma';
import { passkeyPlugin } from './auth/passkey-plugin';
// todo: import from @documenso/lib/constants/auth
export const SALT_ROUNDS = 12;
const passkeyOptions = getAuthenticatorOptions();
export const auth = betterAuth({
appName: 'Documenso',
plugins: [
twoFactor({
issuer: 'Documenso',
skipVerificationOnEnable: true,
// totpOptions: {
// },
schema: {
twoFactor: {
modelName: 'TwoFactor',
fields: {
userId: 'userId',
secret: 'secret',
backupCodes: 'backupCodes',
},
},
},
// todo: add options
}),
passkeyPlugin(),
// passkey({
// rpID: passkeyOptions.rpId,
// rpName: passkeyOptions.rpName,
// origin: passkeyOptions.origin,
// schema: {
// passkey: {
// fields: {
// publicKey: 'credentialPublicKey',
// credentialID: 'credentialId',
// deviceType: 'credentialDeviceType',
// backedUp: 'credentialBackedUp',
// // transports: '',
// },
// },
// },
// }),
],
secret: 'secret', // todo
database: prismaAdapter(prisma, {
provider: 'postgresql',
}),
databaseHooks: {
account: {
create: {
before: (session) => {
return {
data: {
...session,
accountId: session.accountId.toString(),
},
};
},
},
},
},
session: {
fields: {
token: 'sessionToken',
expiresAt: 'expires',
},
},
user: {
fields: {
emailVerified: 'isEmailVerified',
},
},
account: {
fields: {
providerId: 'provider',
accountId: 'providerAccountId',
refreshToken: 'refresh_token',
accessToken: 'access_token',
idToken: 'id_token',
},
},
advanced: {
generateId: false,
},
socialProviders: {
google: {
clientId: '',
clientSecret: '',
},
},
emailAndPassword: {
enabled: true,
requireEmailVerification: false,
// maxPasswordLength: 128,
// minPasswordLength: 8,
password: {
hash: async (password) => hash(password, SALT_ROUNDS),
verify: async ({ hash, password }) => compare(password, hash),
},
},
});

View File

@@ -1,24 +0,0 @@
import type { BetterAuthClientPlugin } from 'better-auth';
import type { passkeyPlugin } from './index';
type PasskeyPlugin = typeof passkeyPlugin;
export const passkeyClientPlugin = () => {
const passkeySignin = () => {
//
// credential: JSON.stringify(credential),
// callbackUrl,
};
return {
id: 'passkeyPlugin',
getActions: () => ({
signIn: {
passkey: () => passkeySignin,
},
}),
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
$InferServerPlugin: {} as ReturnType<PasskeyPlugin>,
} satisfies BetterAuthClientPlugin;
};

View File

@@ -1,165 +0,0 @@
import type { BetterAuthPlugin } from 'better-auth';
import { createAuthEndpoint, createAuthMiddleware } from 'better-auth/plugins';
export const passkeyPlugin = () =>
({
id: 'passkeyPlugin',
schema: {
user: {
fields: {
// twoFactorEnabled: {
// type: 'boolean',
// required: false,
// },
// twoFactorBackupCodes: {
// type: 'string',
// required: false,
// },
// twoFactorSecret: {
// type: 'string',
// required: false,
// },
// birthday: {
// type: 'date', // string, number, boolean, date
// required: true, // if the field should be required on a new record. (default: false)
// unique: false, // if the field should be unique. (default: false)
// reference: null, // if the field is a reference to another table. (default: null)
// },
},
},
},
endpoints: {
authorize: createAuthEndpoint(
'/passkey/authorize',
{
method: 'POST',
// use: [],
},
async (ctx) => {
const csrfToken = credentials?.csrfToken;
if (typeof csrfToken !== 'string' || csrfToken.length === 0) {
throw new AppError(AppErrorCode.INVALID_REQUEST);
}
let requestBodyCrediential: TAuthenticationResponseJSONSchema | null = null;
try {
const parsedBodyCredential = JSON.parse(req.body?.credential);
requestBodyCrediential = ZAuthenticationResponseJSONSchema.parse(parsedBodyCredential);
} catch {
throw new AppError(AppErrorCode.INVALID_REQUEST);
}
const challengeToken = await prisma.anonymousVerificationToken
.delete({
where: {
id: csrfToken,
},
})
.catch(() => null);
if (!challengeToken) {
return null;
}
if (challengeToken.expiresAt < new Date()) {
throw new AppError(AppErrorCode.EXPIRED_CODE);
}
const passkey = await prisma.passkey.findFirst({
where: {
credentialId: Buffer.from(requestBodyCrediential.id, 'base64'),
},
include: {
User: {
select: {
id: true,
email: true,
name: true,
emailVerified: true,
},
},
},
});
if (!passkey) {
throw new AppError(AppErrorCode.NOT_SETUP);
}
const user = passkey.User;
const { rpId, origin } = getAuthenticatorOptions();
const verification = await verifyAuthenticationResponse({
response: requestBodyCrediential,
expectedChallenge: challengeToken.token,
expectedOrigin: origin,
expectedRPID: rpId,
authenticator: {
credentialID: new Uint8Array(Array.from(passkey.credentialId)),
credentialPublicKey: new Uint8Array(passkey.credentialPublicKey),
counter: Number(passkey.counter),
},
}).catch(() => null);
const requestMetadata = extractNextAuthRequestMetadata(req);
if (!verification?.verified) {
await prisma.userSecurityAuditLog.create({
data: {
userId: user.id,
ipAddress: requestMetadata.ipAddress,
userAgent: requestMetadata.userAgent,
type: UserSecurityAuditLogType.SIGN_IN_PASSKEY_FAIL,
},
});
return null;
}
await prisma.passkey.update({
where: {
id: passkey.id,
},
data: {
lastUsedAt: new Date(),
counter: verification.authenticationInfo.newCounter,
},
});
return {
id: Number(user.id),
email: user.email,
name: user.name,
emailVerified: user.emailVerified?.toISOString() ?? null,
} satisfies User;
},
),
},
hooks: {
before: [
{
matcher: (context) => context.path.startsWith('/sign-in/email'),
handler: createAuthMiddleware(async (ctx) => {
console.log('here...');
const { birthday } = ctx.body;
if ((!birthday) instanceof Date) {
throw new APIError('BAD_REQUEST', { message: 'Birthday must be of type Date.' });
}
const today = new Date();
const fiveYearsAgo = new Date(today.setFullYear(today.getFullYear() - 5));
if (birthday >= fiveYearsAgo) {
throw new APIError('BAD_REQUEST', { message: 'User must be above 5 years old.' });
}
return { context: ctx };
}),
},
],
},
}) satisfies BetterAuthPlugin;

View File

@@ -1,74 +0,0 @@
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
isRouteErrorResponse,
} from 'react-router';
import type { Route } from './+types/root';
import stylesheet from './app.css?url';
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=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap',
},
{ rel: 'stylesheet', href: stylesheet },
];
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
return <Outlet />;
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = 'Oops!';
let details = 'An unexpected error occurred.';
let stack: string | undefined;
if (isRouteErrorResponse(error)) {
message = error.status === 404 ? '404' : 'Error';
details =
error.status === 404 ? 'The requested page could not be found.' : error.statusText || details;
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message;
stack = error.stack;
}
return (
<main className="container mx-auto p-4 pt-16">
<h1>{message}</h1>
<p>{details}</p>
{stack && (
<pre className="w-full overflow-x-auto p-4">
<code>{stack}</code>
</pre>
)}
</main>
);
}

View File

@@ -1,4 +0,0 @@
import { type RouteConfig } from '@react-router/dev/routes';
import { flatRoutes } from '@react-router/fs-routes';
export default flatRoutes() satisfies RouteConfig;

View File

@@ -1,193 +0,0 @@
import { useState } from 'react';
import { Button } from '@documenso/ui/primitives/button';
import { authClient, signOut, useSession } from '~/lib/auth-client';
import { auth } from '~/lib/auth.server';
import type { Route } from '../+types/root';
export function meta({}: Route.MetaArgs) {
return [
{ title: 'New React Router App' },
{ name: 'description', content: 'Welcome to React Router!' },
];
}
export async function loader({ params, request, context }: Route.LoaderArgs) {
const session = await auth.api.getSession({
query: {
disableCookieCache: true,
},
headers: request.headers, // pass the headers
});
return {
session,
};
}
export function clientLoader({ params }: Route.ClientLoaderArgs) {
return {
session: authClient.getSession(),
};
}
export default function Home({ loaderData }: Route.ComponentProps) {
const { data } = useSession();
const [email, setEmail] = useState('deepfriedcoconut@gmail.com');
const [password, setPassword] = useState('password');
const signIn = async () => {
await authClient.signIn.email(
{
email,
password,
},
{
onRequest: (ctx) => {
// show loading state
},
onSuccess: (ctx) => {
console.log('success');
// redirect to home
},
onError: (ctx) => {
console.log(ctx.error);
alert(ctx.error);
},
},
);
};
const signUp = async () => {
await authClient.signUp.email(
{
email,
password,
name: '',
},
{
onRequest: (ctx) => {
// show loading state
},
onSuccess: (ctx) => {
console.log(ctx);
// redirect to home
},
onError: (ctx) => {
console.log(ctx.error);
alert(ctx.error);
},
},
);
};
return (
<main className="flex flex-col items-center justify-center pb-4 pt-16">
<h1>Status: {data ? 'Authenticated' : 'Not Authenticated'}</h1>
{data ? (
<>
<div>
<p>Session data</p>
<p className="mt-2 max-w-2xl text-xs">{JSON.stringify(data, null, 2)}</p>
</div>
<div className="space-x-2">
<Button
onClick={() => {
authClient.twoFactor
.enable({
password: 'password', // user password required
})
.catch((e) => {
console.log(e);
});
}}
>
Enable 2FA
</Button>
<Button
variant="destructive"
onClick={() => {
authClient.twoFactor.disable({
password: 'password',
});
}}
>
Disable 2FA
</Button>
</div>
<button onClick={() => signOut()}>signout</button>
</>
) : (
<>
<div className="">
<h2>Sign In</h2>
<input
className="border border-blue-500"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<input
className="border border-blue-500"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button type="submit" onClick={signIn}>
Sign In
</button>
</div>
<div className="mt-8">
<h2>Sign Up</h2>
<input
type="email"
className="border border-blue-500"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
/>
<input
type="password"
className="border border-blue-500"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
/>
<button type="submit" onClick={signUp}>
Sign Up
</button>
</div>
<button
onClick={() => {
authClient.signIn.social({
provider: 'google',
});
}}
>
google
</button>
<button
onClick={async () => {
const response = await authClient.signIn.passkey();
console.log(response);
}}
>
passkey
</button>
</>
)}
</main>
);
}

View File

@@ -1,12 +0,0 @@
// Adjust the path as necessary
import type { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node';
import { auth } from '~/lib/auth.server';
export function loader({ request }: LoaderFunctionArgs) {
return auth.handler(request);
}
export function action({ request }: ActionFunctionArgs) {
return auth.handler(request);
}

View File

@@ -1,40 +0,0 @@
import { useState } from 'react';
import { authClient } from '~/lib/auth-client';
export default function SignIn() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const signIn = async () => {
await authClient.signIn.email(
{
email,
password,
},
{
onRequest: (ctx) => {
// show loading state
},
onSuccess: (ctx) => {
// redirect to home
},
onError: (ctx) => {
alert(ctx.error);
},
},
);
};
return (
<div>
<h2>Sign In</h2>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
<button type="submit" onClick={signIn}>
Sign In
</button>
</div>
);
}

View File

@@ -1,43 +0,0 @@
{
"name": "@documenso/remix",
"type": "module",
"private": true,
"scripts": {
"build": "cross-env NODE_ENV=production react-router build",
"dev": "tsx watch --ignore \"vite.config.ts*\" server/main.ts",
"start": "cross-env NODE_ENV=production node dist/server/index.js",
"typecheck": "react-router typegen && tsc"
},
"dependencies": {
"@documenso/api": "*",
"@documenso/assets": "*",
"@documenso/ee": "*",
"@documenso/lib": "*",
"@documenso/prisma": "*",
"@documenso/trpc": "*",
"@documenso/ui": "*",
"@epic-web/remember": "^1.1.0",
"@hono/node-server": "^1.13.7",
"@react-router/fs-routes": "^7.1.1",
"@react-router/node": "^7.1.1",
"@react-router/serve": "^7.1.1",
"better-auth": "^1.1.9",
"hono": "^4.6.15",
"isbot": "^5.1.17",
"react": "^18",
"react-dom": "^18",
"react-router": "^7.1.1",
"remix-hono": "^0.0.18"
},
"devDependencies": {
"@react-router/dev": "^7.1.1",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"cross-env": "^7.0.3",
"tsx": "^4.11.0",
"typescript": "5.7.2",
"vite": "^5.4.11",
"vite-tsconfig-paths": "^5.1.4"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,7 +0,0 @@
import type { Config } from '@react-router/dev/config';
export default {
appDirectory: 'app',
// Server-side render by default, to enable SPA mode set this to `false`
ssr: true,
} satisfies Config;

View File

@@ -1,45 +0,0 @@
import { remember } from '@epic-web/remember';
import { type HttpBindings } from '@hono/node-server';
import { Hono } from 'hono';
import { reactRouter } from 'remix-hono/handler';
type Bindings = HttpBindings;
const app = new Hono<{ Bindings: Bindings }>();
const isProduction = process.env.NODE_ENV === 'production';
const viteDevServer = isProduction
? undefined
: await import('vite').then(async (vite) =>
vite.createServer({
server: { middlewareMode: true },
}),
);
const reactRouterMiddleware = remember('reactRouterMiddleware', async () =>
reactRouter({
mode: isProduction ? 'production' : 'development',
build: isProduction
? // @ts-expect-error build/server/index.js is a build artifact
await import('../build/server/index.js')
: async () => viteDevServer!.ssrLoadModule('virtual:react-router/server-build'),
}),
);
// app.get('/', (c) => c.text('Hello, world!'));
if (viteDevServer) {
app.use('*', async (c, next) => {
return new Promise((resolve) => {
viteDevServer.middlewares(c.env.incoming, c.env.outgoing, () => resolve(next()));
});
});
}
app.use('*', async (c, next) => {
const middleware = await reactRouterMiddleware;
return middleware(c, next);
});
export default app;

View File

@@ -1,7 +0,0 @@
import { serve } from '@hono/node-server';
import app from './app';
serve(app, (info) => {
console.log(`Server is running on http://localhost:${info.port}`);
});

View File

@@ -1,18 +0,0 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const baseConfig = require('@documenso/tailwind-config');
const path = require('path');
module.exports = {
...baseConfig,
content: [
...baseConfig.content,
'./app/**/*.{ts,tsx}',
`${path.join(require.resolve('@documenso/ui'), '..')}/components/**/*.{ts,tsx}`,
`${path.join(require.resolve('@documenso/ui'), '..')}/icons/**/*.{ts,tsx}`,
`${path.join(require.resolve('@documenso/ui'), '..')}/lib/**/*.{ts,tsx}`,
`${path.join(require.resolve('@documenso/ui'), '..')}/primitives/**/*.{ts,tsx}`,
`${path.join(require.resolve('@documenso/email'), '..')}/templates/**/*.{ts,tsx}`,
`${path.join(require.resolve('@documenso/email'), '..')}/template-components/**/*.{ts,tsx}`,
`${path.join(require.resolve('@documenso/email'), '..')}/providers/**/*.{ts,tsx}`,
],
};

View File

@@ -1,27 +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/*"]
},
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true
}
}

View File

@@ -1,17 +0,0 @@
import { reactRouter } from '@react-router/dev/vite';
import autoprefixer from 'autoprefixer';
import tailwindcss from 'tailwindcss';
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
css: {
postcss: {
plugins: [tailwindcss, autoprefixer],
},
},
plugins: [reactRouter(), tsconfigPaths()],
optimizeDeps: {
exclude: ['@node-rs/bcrypt'],
},
});

View File

@@ -1,6 +1,6 @@
{
"name": "@documenso/web",
"version": "1.9.0-rc.5",
"version": "1.9.0-rc.7",
"private": true,
"license": "AGPL-3.0",
"scripts": {
@@ -73,6 +73,6 @@
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/ua-parser-js": "^0.7.39",
"typescript": "5.7.2"
"typescript": "5.2.2"
}
}

View File

@@ -166,6 +166,7 @@ export const EditTemplateForm = ({
data: {
title: data.title,
externalId: data.externalId || null,
visibility: data.visibility,
globalAccessAuth: data.globalAccessAuth ?? null,
globalActionAuth: data.globalActionAuth ?? null,
},
@@ -296,6 +297,7 @@ export const EditTemplateForm = ({
<AddTemplateSettingsFormPartial
key={recipients.length}
template={template}
currentTeamMemberRole={team?.currentTeamMember?.role}
documentFlow={documentFlow.settings}
recipients={recipients}
fields={fields}

View File

@@ -7,15 +7,17 @@ import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { InfoIcon, Plus } from 'lucide-react';
import { InfoIcon, Plus, Upload, X } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import * as z from 'zod';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import {
TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
} from '@documenso/lib/constants/template';
import { AppError } from '@documenso/lib/errors/app-error';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import type { Recipient } from '@documenso/prisma/client';
import { DocumentDistributionMethod, DocumentSigningOrder } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
@@ -50,6 +52,11 @@ import { useOptionalCurrentTeam } from '~/providers/team';
const ZAddRecipientsForNewDocumentSchema = z
.object({
distributeDocument: z.boolean(),
useCustomDocument: z.boolean().default(false),
customDocumentData: z
.any()
.refine((data) => data instanceof File || data === undefined)
.optional(),
recipients: z.array(
z.object({
id: z.number(),
@@ -119,6 +126,8 @@ export function UseTemplateDialog({
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
defaultValues: {
distributeDocument: false,
useCustomDocument: false,
customDocumentData: undefined,
recipients: recipients
.sort((a, b) => (a.signingOrder || 0) - (b.signingOrder || 0))
.map((recipient) => {
@@ -145,11 +154,19 @@ export function UseTemplateDialog({
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
try {
let customDocumentDataId: string | undefined = undefined;
if (data.useCustomDocument && data.customDocumentData) {
const customDocumentData = await putPdfFile(data.customDocumentData);
customDocumentDataId = customDocumentData.id;
}
const { id } = await createDocumentFromTemplate({
templateId,
teamId: team?.id,
recipients: data.recipients,
distributeDocument: data.distributeDocument,
customDocumentDataId,
});
toast({
@@ -300,7 +317,6 @@ export function UseTemplateDialog({
/>
</div>
))}
</div>
{recipients.length > 0 && (
<div className="mt-4 flex flex-row items-center">
@@ -331,8 +347,8 @@ export function UseTemplateDialog({
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
<p>
<Trans>
The document will be immediately sent to recipients if this is
checked.
The document will be immediately sent to recipients if this
is checked.
</Trans>
</p>
@@ -358,7 +374,9 @@ export function UseTemplateDialog({
</TooltipTrigger>
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
<p>
<Trans>Create the document as pending and ready to sign.</Trans>
<Trans>
Create the document as pending and ready to sign.
</Trans>
</p>
<p>
@@ -367,8 +385,8 @@ export function UseTemplateDialog({
<p className="mt-2">
<Trans>
We will generate signing links for you, which you can send to
the recipients through your method of choice.
We will generate signing links for you, which you can send
to the recipients through your method of choice.
</Trans>
</p>
</TooltipContent>
@@ -382,7 +400,162 @@ export function UseTemplateDialog({
</div>
)}
<DialogFooter>
<FormField
control={form.control}
name="useCustomDocument"
render={({ field }) => (
<FormItem>
<div className="flex flex-row items-center">
<Checkbox
id="useCustomDocument"
className="h-5 w-5"
checked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked);
if (!checked) {
form.setValue('customDocumentData', undefined);
}
}}
/>
<label
className="text-muted-foreground ml-2 flex items-center text-sm"
htmlFor="useCustomDocument"
>
<Trans>Upload custom document</Trans>
<Tooltip>
<TooltipTrigger type="button">
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
<p>
<Trans>
Upload a custom document to use instead of the template's default
document
</Trans>
</p>
</TooltipContent>
</Tooltip>
</label>
</div>
</FormItem>
)}
/>
{form.watch('useCustomDocument') && (
<div className="my-4">
<FormField
control={form.control}
name="customDocumentData"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="w-full space-y-4">
<label
className={cn(
'text-muted-foreground hover:border-muted-foreground/50 group relative flex min-h-[150px] cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-gray-300 px-6 py-10 transition-colors',
{
'border-destructive hover:border-destructive':
form.formState.errors.customDocumentData,
},
)}
>
<div className="text-center">
{!field.value && (
<>
<Upload className="text-muted-foreground/50 mx-auto h-10 w-10" />
<div className="mt-4 flex text-sm leading-6">
<span className="text-muted-foreground relative">
<Trans>
<span className="text-primary font-semibold">
Click to upload
</span>{' '}
or drag and drop
</Trans>
</span>
</div>
<p className="text-muted-foreground/80 text-xs">
PDF files only
</p>
</>
)}
{field.value && (
<div className="text-muted-foreground space-y-1">
<p className="text-sm font-medium">{field.value.name}</p>
<p className="text-muted-foreground/60 text-xs">
{(field.value.size / (1024 * 1024)).toFixed(2)} MB
</p>
</div>
)}
</div>
<input
type="file"
className="absolute h-full w-full opacity-0"
accept=".pdf,application/pdf"
onChange={(e) => {
const file = e.target.files?.[0];
if (!file) {
field.onChange(undefined);
return;
}
if (file.type !== 'application/pdf') {
form.setError('customDocumentData', {
type: 'manual',
message: _(msg`Please select a PDF file`),
});
return;
}
if (file.size > APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024) {
form.setError('customDocumentData', {
type: 'manual',
message: _(
msg`File size exceeds the limit of ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT} MB`,
),
});
return;
}
field.onChange(file);
}}
/>
{field.value && (
<div className="absolute right-2 top-2">
<Button
type="button"
variant="destructive"
className="h-6 w-6 p-0"
onClick={(e) => {
e.preventDefault();
field.onChange(undefined);
}}
>
<X className="h-4 w-4" />
<div className="sr-only">
<Trans>Clear file</Trans>
</div>
</Button>
</div>
)}
</label>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
<DialogFooter className="mt-4">
<DialogClose asChild>
<Button type="button" variant="secondary">
<Trans>Close</Trans>

4317
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,10 @@
{
"private": true,
"version": "1.9.0-rc.5",
"version": "1.9.0-rc.7",
"scripts": {
"build": "turbo run build",
"build:web": "turbo run build --filter=@documenso/web",
"dev": "turbo run dev --filter=@documenso/web",
"dev:remix": "turbo run dev --filter=@documenso/remix",
"dev:web": "turbo run dev --filter=@documenso/web",
"dev:docs": "turbo run dev --filter=@documenso/documentation",
"dev:openpage-api": "turbo run dev --filter=@documenso/openpage-api",
@@ -72,7 +71,6 @@
"mupdf": "^1.0.0",
"next-runtime-env": "^3.2.0",
"react": "^18",
"typescript": "5.7.2",
"zod": "3.24.1"
},
"overrides": {

View File

@@ -1,5 +1,6 @@
import { createNextRoute } from '@ts-rest/next';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
@@ -36,10 +37,10 @@ import { deleteTemplate } from '@documenso/lib/server-only/template/delete-templ
import { findTemplates } from '@documenso/lib/server-only/template/find-templates';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { extractDerivedDocumentEmailSettings } from '@documenso/lib/types/document-email';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import {
ZCheckboxFieldMeta,
ZDropdownFieldMeta,
ZFieldMetaSchema,
ZNumberFieldMeta,
ZRadioFieldMeta,
ZTextFieldMeta,
@@ -62,6 +63,7 @@ import {
import { ApiContractV1 } from './contract';
import { authenticatedMiddleware } from './middleware/authenticated';
import { ZTemplateWithDataSchema } from './schema';
export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
getDocuments: authenticatedMiddleware(async (args, user, team) => {
@@ -414,9 +416,11 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
teamId: team?.id,
});
const parsed = ZTemplateWithDataSchema.parse(template);
return {
status: 200,
body: template,
body: parsed,
};
} catch (err) {
return AppError.toRestAPIError(err);
@@ -435,10 +439,12 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
teamId: team?.id,
});
const parsed = z.array(ZTemplateWithDataSchema).parse(templates);
return {
status: 200,
body: {
templates,
templates: parsed,
totalPages,
},
};

View File

@@ -61,6 +61,7 @@ export const ZSuccessfulGetDocumentResponseSchema = ZSuccessfulDocumentResponseS
fields: z.lazy(() =>
ZFieldSchema.pick({
id: true,
documentId: true,
recipientId: true,
type: true,
page: true,
@@ -68,6 +69,8 @@ export const ZSuccessfulGetDocumentResponseSchema = ZSuccessfulDocumentResponseS
positionY: true,
width: true,
height: true,
customText: true,
fieldMeta: true,
})
.extend({
fieldMeta: ZFieldMetaSchema.nullish(),
@@ -524,6 +527,7 @@ export const ZFieldSchema = z.object({
height: z.unknown(),
customText: z.string(),
inserted: z.boolean(),
fieldMeta: ZFieldMetaSchema.nullish().openapi({}),
});
export const ZTemplateWithDataSchema = ZTemplateSchema.extend({
@@ -541,6 +545,8 @@ export const ZTemplateWithDataSchema = ZTemplateSchema.extend({
}),
Field: ZFieldSchema.pick({
id: true,
documentId: true,
templateId: true,
recipientId: true,
type: true,
page: true,
@@ -548,6 +554,8 @@ export const ZTemplateWithDataSchema = ZTemplateSchema.extend({
positionY: true,
width: true,
height: true,
customText: true,
fieldMeta: true,
}).array(),
Recipient: ZRecipientSchema.pick({
id: true,

View File

@@ -1,5 +1,7 @@
import { expect, test } from '@playwright/test';
import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client';
import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions';
import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
@@ -157,3 +159,109 @@ test('[TEMPLATE_FLOW]: add settings', async ({ page }) => {
await expect(page.getByLabel('Title')).toHaveValue('New Title');
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
});
test('[TEMPLATE_FLOW] add document visibility settings', async ({ page }) => {
const { owner, ...team } = await seedTeam({
createTeamMembers: 1,
});
const template = await seedBlankTemplate(owner, {
createTemplateOptions: {
teamId: team.id,
},
});
await apiSignin({
page,
email: owner.email,
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
});
// Set document visibility.
await page.getByTestId('documentVisibilitySelectValue').click();
await page.getByLabel('Managers and above').click();
await expect(page.getByTestId('documentVisibilitySelectValue')).toContainText(
'Managers and above',
);
// Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
// Navigate back to the edit page to check that the settings are saved correctly.
await page.goto(`/t/${team.url}/templates/${template.id}/edit`);
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await expect(page.getByTestId('documentVisibilitySelectValue')).toContainText(
'Managers and above',
);
});
test('[TEMPLATE_FLOW] team member visibility permissions', async ({ page }) => {
const team = await seedTeam({
createTeamMembers: 2, // Create an additional member to test different roles
});
await prisma.teamMember.update({
where: {
id: team.members[1].id,
},
data: {
role: TeamMemberRole.MANAGER,
},
});
const owner = team.owner;
const managerUser = team.members[1].user;
const memberUser = team.members[2].user;
const template = await seedBlankTemplate(owner, {
createTemplateOptions: {
teamId: team.id,
},
});
// Test as manager
await apiSignin({
page,
email: managerUser.email,
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
});
// Manager should be able to set visibility to managers and above
await page.getByTestId('documentVisibilitySelectValue').click();
await page.getByLabel('Managers and above').click();
await expect(page.getByTestId('documentVisibilitySelectValue')).toContainText(
'Managers and above',
);
await expect(page.getByText('Admins only')).toBeDisabled();
// Save and verify
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
// Test as regular member
await apiSignin({
page,
email: memberUser.email,
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
});
// Regular member should not be able to modify visibility when set to managers and above
await expect(page.getByTestId('documentVisibilitySelectValue')).toBeDisabled();
// Create a new template with 'everyone' visibility
const everyoneTemplate = await seedBlankTemplate(owner, {
createTemplateOptions: {
teamId: team.id,
visibility: 'EVERYONE',
},
});
// Navigate to the new template
await page.goto(`/t/${team.url}/templates/${everyoneTemplate.id}/edit`);
// Regular member should be able to see but not modify visibility
await expect(page.getByTestId('documentVisibilitySelectValue')).toBeDisabled();
await expect(page.getByTestId('documentVisibilitySelectValue')).toContainText('Everyone');
});

View File

@@ -1,7 +1,11 @@
import { expect, test } from '@playwright/test';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { DocumentDataType, TeamMemberRole } from '@documenso/prisma/client';
import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions';
import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
@@ -13,6 +17,20 @@ test.describe.configure({ mode: 'parallel' });
const enterprisePriceId = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || '';
// Create a temporary PDF file for testing
function createTempPdfFile() {
const tempDir = os.tmpdir();
const tempFilePath = path.join(tempDir, 'test.pdf');
// Create a simple PDF file with some content
const pdfContent = Buffer.from(
'%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj 2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj 3 0 obj<</Type/Page/MediaBox[0 0 612 792]/Parent 2 0 R>>endobj\nxref\n0 4\n0000000000 65535 f\n0000000009 00000 n\n0000000052 00000 n\n0000000101 00000 n\ntrailer<</Size 4/Root 1 0 R>>\nstartxref\n178\n%%EOF',
);
fs.writeFileSync(tempFilePath, pdfContent);
return tempFilePath;
}
/**
* 1. Create a template with all settings filled out
* 2. Create a document from the template
@@ -283,3 +301,318 @@ test('[TEMPLATE]: should create a team document from a team template', async ({
expect(recipientOneAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
expect(recipientTwoAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
});
/**
* This test verifies that we can create a document from a template using a custom document
* instead of the template's default document.
*/
test('[TEMPLATE]: should create a document from a template with custom document', async ({
page,
}) => {
const user = await seedUser();
const template = await seedBlankTemplate(user);
// Create a temporary PDF file for upload
const testPdfPath = createTempPdfFile();
const pdfContent = fs.readFileSync(testPdfPath).toString('base64');
try {
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}/edit`,
});
// Set template title
await page.getByLabel('Title').fill('TEMPLATE_WITH_CUSTOM_DOC');
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
// Add a signer
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient');
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Save template' }).click();
// Use template with custom document
await page.waitForURL('/templates');
await page.getByRole('button', { name: 'Use Template' }).click();
// Enable custom document upload and upload file
await page.getByLabel('Upload custom document').check();
await page.locator('input[type="file"]').setInputFiles(testPdfPath);
// Wait for upload to complete
await expect(page.getByText(path.basename(testPdfPath))).toBeVisible();
// Create document with custom document data
await page.getByRole('button', { name: 'Create as draft' }).click();
// Review that the document was created with the custom document data
await page.waitForURL(/documents/);
const documentId = Number(page.url().split('/').pop());
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
},
include: {
documentData: true,
},
});
expect(document.title).toEqual('TEMPLATE_WITH_CUSTOM_DOC');
expect(document.documentData.type).toEqual(DocumentDataType.BYTES_64);
expect(document.documentData.data).toEqual(pdfContent);
expect(document.documentData.initialData).toEqual(pdfContent);
} finally {
// Clean up the temporary file
fs.unlinkSync(testPdfPath);
}
});
/**
* This test verifies that we can create a team document from a template using a custom document
* instead of the template's default document.
*/
test('[TEMPLATE]: should create a team document from a template with custom document', async ({
page,
}) => {
const { owner, ...team } = await seedTeam({
createTeamMembers: 2,
});
const template = await seedBlankTemplate(owner, {
createTemplateOptions: {
teamId: team.id,
},
});
// Create a temporary PDF file for upload
const testPdfPath = createTempPdfFile();
const pdfContent = fs.readFileSync(testPdfPath).toString('base64');
try {
await apiSignin({
page,
email: owner.email,
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
});
// Set template title
await page.getByLabel('Title').fill('TEAM_TEMPLATE_WITH_CUSTOM_DOC');
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
// Add a signer
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient');
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Save template' }).click();
// Use template with custom document
await page.waitForURL(`/t/${team.url}/templates`);
await page.getByRole('button', { name: 'Use Template' }).click();
// Enable custom document upload and upload file
await page.getByLabel('Upload custom document').check();
await page.locator('input[type="file"]').setInputFiles(testPdfPath);
// Wait for upload to complete
await expect(page.getByText(path.basename(testPdfPath))).toBeVisible();
// Create document with custom document data
await page.getByRole('button', { name: 'Create as draft' }).click();
// Review that the document was created with the custom document data
await page.waitForURL(/documents/);
const documentId = Number(page.url().split('/').pop());
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
},
include: {
documentData: true,
},
});
expect(document.teamId).toEqual(team.id);
expect(document.title).toEqual('TEAM_TEMPLATE_WITH_CUSTOM_DOC');
expect(document.documentData.type).toEqual(DocumentDataType.BYTES_64);
expect(document.documentData.data).toEqual(pdfContent);
expect(document.documentData.initialData).toEqual(pdfContent);
} finally {
// Clean up the temporary file
fs.unlinkSync(testPdfPath);
}
});
/**
* This test verifies that when custom document upload is not enabled,
* the document uses the template's original document data.
*/
test('[TEMPLATE]: should create a document from a template using template document when custom document is not enabled', async ({
page,
}) => {
const user = await seedUser();
const template = await seedBlankTemplate(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}/edit`,
});
// Set template title
await page.getByLabel('Title').fill('TEMPLATE_WITH_ORIGINAL_DOC');
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
// Add a signer
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient');
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Save template' }).click();
// Use template without custom document
await page.waitForURL('/templates');
await page.getByRole('button', { name: 'Use Template' }).click();
// Verify custom document upload is not checked by default
await expect(page.getByLabel('Upload custom document')).not.toBeChecked();
// Create document without custom document data
await page.getByRole('button', { name: 'Create as draft' }).click();
// Review that the document was created with the template's document data
await page.waitForURL(/documents/);
const documentId = Number(page.url().split('/').pop());
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
},
include: {
documentData: true,
},
});
const templateWithData = await prisma.template.findFirstOrThrow({
where: {
id: template.id,
},
include: {
templateDocumentData: true,
},
});
expect(document.title).toEqual('TEMPLATE_WITH_ORIGINAL_DOC');
expect(document.documentData.data).toEqual(templateWithData.templateDocumentData.data);
expect(document.documentData.initialData).toEqual(
templateWithData.templateDocumentData.initialData,
);
expect(document.documentData.type).toEqual(templateWithData.templateDocumentData.type);
});
test('[TEMPLATE]: should persist document visibility when creating from template', async ({
page,
}) => {
const { owner, ...team } = await seedTeam({
createTeamMembers: 2,
});
const template = await seedBlankTemplate(owner, {
createTemplateOptions: {
teamId: team.id,
},
});
await apiSignin({
page,
email: owner.email,
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
});
// Set template title and visibility
await page.getByLabel('Title').fill('TEMPLATE_WITH_VISIBILITY');
await page.getByTestId('documentVisibilitySelectValue').click();
await page.getByLabel('Managers and above').click();
await expect(page.getByTestId('documentVisibilitySelectValue')).toContainText(
'Managers and above',
);
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
// Add a signer
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient');
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Save template' }).click();
// Test creating document as team manager
await prisma.teamMember.update({
where: {
id: team.members[1].id,
},
data: {
role: TeamMemberRole.MANAGER,
},
});
const managerUser = team.members[1].user;
await apiSignin({
page,
email: managerUser.email,
redirectPath: `/t/${team.url}/templates`,
});
await page.getByRole('button', { name: 'Use Template' }).click();
await page.getByRole('button', { name: 'Create as draft' }).click();
// Review that the document was created with the correct visibility
await page.waitForURL(/documents/);
const documentId = Number(page.url().split('/').pop());
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
},
});
expect(document.title).toEqual('TEMPLATE_WITH_VISIBILITY');
expect(document.visibility).toEqual('MANAGER_AND_ABOVE');
expect(document.teamId).toEqual(team.id);
// Test that regular member cannot create document from restricted template
const memberUser = team.members[2].user;
await apiSignin({
page,
email: memberUser.email,
redirectPath: `/t/${team.url}/templates`,
});
// Template should not be visible to regular member
await expect(page.getByRole('button', { name: 'Use Template' })).not.toBeVisible();
});

View File

@@ -67,6 +67,8 @@ test('[DIRECT_TEMPLATES]: create direct link for template', async ({ page }) =>
await page.getByRole('button', { name: 'Enable direct link signing' }).click();
await page.getByRole('button', { name: 'Create one automatically' }).click();
await expect(page.getByRole('heading', { name: 'Direct Link Signing' })).toBeVisible();
await page.waitForTimeout(1000);
await page.getByTestId('btn-dialog-close').click();
// Expect badge to appear.

View File

@@ -15,6 +15,6 @@
"eslint-plugin-package-json": "^0.10.4",
"eslint-plugin-react": "^7.34.0",
"eslint-plugin-unused-imports": "^3.1.0",
"typescript": "5.7.2"
"typescript": "5.2.2"
}
}

View File

@@ -54,6 +54,7 @@ export type CreateDocumentFromTemplateOptions = {
email: string;
signingOrder?: number | null;
}[];
customDocumentDataId?: string;
/**
* Values that will override the predefined values in the template.
@@ -90,6 +91,7 @@ export const createDocumentFromTemplate = async ({
userId,
teamId,
recipients,
customDocumentDataId,
override,
requestMetadata,
}: CreateDocumentFromTemplateOptions): Promise<TCreateDocumentFromTemplateResponse> => {
@@ -171,11 +173,29 @@ export const createDocumentFromTemplate = async ({
};
});
let parentDocumentData = template.templateDocumentData;
if (customDocumentDataId) {
const customDocumentData = await prisma.documentData.findFirst({
where: {
id: customDocumentDataId,
},
});
if (!customDocumentData) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Custom document data not found',
});
}
parentDocumentData = customDocumentData;
}
const documentData = await prisma.documentData.create({
data: {
type: template.templateDocumentData.type,
data: template.templateDocumentData.data,
initialData: template.templateDocumentData.initialData,
type: parentDocumentData.type,
data: parentDocumentData.data,
initialData: parentDocumentData.initialData,
},
});
@@ -193,7 +213,7 @@ export const createDocumentFromTemplate = async ({
globalAccessAuth: templateAuthOptions.globalAccessAuth,
globalActionAuth: templateAuthOptions.globalActionAuth,
}),
visibility: template.team?.teamGlobalSettings?.documentVisibility,
visibility: template.visibility || template.team?.teamGlobalSettings?.documentVisibility,
documentMeta: {
create: {
subject: override?.subject || template.templateMeta?.subject,

View File

@@ -1,7 +1,13 @@
import { match } from 'ts-pattern';
import type { z } from 'zod';
import { prisma } from '@documenso/prisma';
import type { Prisma, Template } from '@documenso/prisma/client';
import {
DocumentVisibility,
type Prisma,
TeamMemberRole,
type Template,
} from '@documenso/prisma/client';
import {
DocumentDataSchema,
FieldSchema,
@@ -12,6 +18,7 @@ import {
TemplateSchema,
} from '@documenso/prisma/generated/zod';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { type FindResultResponse, ZFindResultResponse } from '../../types/search-params';
export type FindTemplatesOptions = {
@@ -52,28 +59,58 @@ export const findTemplates = async ({
page = 1,
perPage = 10,
}: FindTemplatesOptions): Promise<TFindTemplatesResponse> => {
let whereFilter: Prisma.TemplateWhereInput = {
userId,
teamId: null,
type,
};
const whereFilter: Prisma.TemplateWhereInput[] = [];
if (teamId === undefined) {
whereFilter.push({ userId, teamId: null });
}
if (teamId !== undefined) {
whereFilter = {
team: {
id: teamId,
members: {
some: {
const teamMember = await prisma.teamMember.findFirst({
where: {
userId,
teamId,
},
});
if (!teamMember) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You are not a member of this team.',
});
}
whereFilter.push(
{ teamId },
{
OR: [
match(teamMember.role)
.with(TeamMemberRole.ADMIN, () => ({
visibility: {
in: [
DocumentVisibility.EVERYONE,
DocumentVisibility.MANAGER_AND_ABOVE,
DocumentVisibility.ADMIN,
],
},
}))
.with(TeamMemberRole.MANAGER, () => ({
visibility: {
in: [DocumentVisibility.EVERYONE, DocumentVisibility.MANAGER_AND_ABOVE],
},
};
}))
.otherwise(() => ({ visibility: DocumentVisibility.EVERYONE })),
{ userId, teamId },
],
},
);
}
const [data, count] = await Promise.all([
prisma.template.findMany({
where: whereFilter,
where: {
type,
AND: whereFilter,
},
include: {
templateDocumentData: true,
team: {
@@ -103,7 +140,9 @@ export const findTemplates = async ({
},
}),
prisma.template.count({
where: whereFilter,
where: {
AND: whereFilter,
},
}),
]);

View File

@@ -5,7 +5,7 @@ import type { z } from 'zod';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma';
import type { Template, TemplateMeta } from '@documenso/prisma/client';
import type { DocumentVisibility, Template, TemplateMeta } from '@documenso/prisma/client';
import { TemplateSchema } from '@documenso/prisma/generated/zod';
import { AppError, AppErrorCode } from '../../errors/app-error';
@@ -19,6 +19,7 @@ export type UpdateTemplateSettingsOptions = {
data: {
title?: string;
externalId?: string | null;
visibility?: DocumentVisibility;
globalAccessAuth?: TDocumentAccessAuthTypes | null;
globalActionAuth?: TDocumentActionAuthTypes | null;
publicTitle?: string;
@@ -110,6 +111,7 @@ export const updateTemplateSettings = async ({
title: data.title,
externalId: data.externalId,
type: data.type,
visibility: data.visibility,
publicDescription: data.publicDescription,
publicTitle: data.publicTitle,
authOptions,

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Template" ADD COLUMN "visibility" "DocumentVisibility" NOT NULL DEFAULT 'EVERYONE';

View File

@@ -1,11 +0,0 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "secondaryId" TEXT;
-- Set all null secondaryId fields to a uuid
UPDATE "User" SET "secondaryId" = gen_random_uuid()::text WHERE "secondaryId" IS NULL;
-- Restrict the User to required
ALTER TABLE "User" ALTER COLUMN "secondaryId" SET NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "User_secondaryId_key" ON "User"("secondaryId");

View File

@@ -1,36 +0,0 @@
/*
Warnings:
- Added the required column `updatedAt` to the `Account` table without a default value. This is not possible if the table is not empty.
- Added the required column `updatedAt` to the `Session` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Account" ADD COLUMN "accessTokenExpiresAt" TIMESTAMP(3),
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "password" TEXT,
ADD COLUMN "refreshTokenExpiresAt" TIMESTAMP(3),
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL,
ALTER COLUMN "type" SET DEFAULT 'legacy';
-- AlterTable
ALTER TABLE "Session" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "ipAddress" TEXT,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL,
ADD COLUMN "userAgent" TEXT;
-- AlterTable
ALTER TABLE "User" ADD COLUMN "image" TEXT,
ADD COLUMN "isEmailVerified" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "verification" (
"id" TEXT NOT NULL,
"identifier" TEXT NOT NULL,
"value" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3),
"updatedAt" TIMESTAMP(3),
CONSTRAINT "verification_pkey" PRIMARY KEY ("id")
);

View File

@@ -1,28 +0,0 @@
-- Migrate DOCUMENSO users to have proper Account records
DO $$
BEGIN
INSERT INTO "Account" (
"id",
"userId",
"type",
"provider",
"providerAccountId",
"password",
"createdAt",
"updatedAt"
)
SELECT
gen_random_uuid()::text,
u.id,
'legacy',
'credential',
u.email,
u.password,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM "User" u
LEFT JOIN "Account" a ON a."userId" = u.id AND a."provider" = 'documenso'
WHERE
u."identityProvider" = 'DOCUMENSO'
AND a.id IS NULL;
END $$;

View File

@@ -1,32 +0,0 @@
-- CreateTable
CREATE TABLE "TwoFactor" (
"id" TEXT NOT NULL,
"secret" TEXT NOT NULL,
"backupCodes" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
CONSTRAINT "TwoFactor_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "TwoFactor" ADD CONSTRAINT "TwoFactor_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
DO $$
BEGIN
-- Then migrate two factor data
INSERT INTO "TwoFactor" (
"secret",
"backupCodes",
"userId"
)
SELECT
u."twoFactorSecret",
COALESCE(u."twoFactorBackupCodes", ''),
u.id
FROM "User" u
LEFT JOIN "TwoFactor" tf ON tf."userId" = u.id
WHERE
u."twoFactorSecret" IS NOT NULL
AND u."twoFactorEnabled" = true
AND tf.id IS NULL;
END $$;

View File

@@ -32,7 +32,7 @@
"dotenv-cli": "^7.3.0",
"prisma-kysely": "^1.8.0",
"tsx": "^4.11.0",
"typescript": "5.7.2",
"typescript": "5.2.2",
"zod-prisma-types": "^3.1.8"
}
}

View File

@@ -29,12 +29,10 @@ enum Role {
model User {
id Int @id @default(autoincrement())
secondaryId String @unique @default(cuid())
name String?
customerId String? @unique
email String @unique
emailVerified DateTime?
isEmailVerified Boolean @default(false)
password String?
source String?
signature String?
@@ -54,13 +52,9 @@ model User {
ownedTeams Team[]
ownedPendingTeams TeamPending[]
teamMembers TeamMember[]
twoFactorEnabled Boolean @default(false)
// Todo: Delete these after full auth migration.
twoFactorBackupCodes String?
twoFactorSecret String?
// End of Todo.
twoFactorEnabled Boolean @default(false)
twoFactorBackupCodes String?
url String? @unique
profile UserProfile?
@@ -73,21 +67,9 @@ model User {
passkeys Passkey[]
avatarImage AvatarImage? @relation(fields: [avatarImageId], references: [id], onDelete: SetNull)
image String?
twofactors TwoFactor[]
@@index([email])
}
model TwoFactor {
id String @id @default(cuid())
secret String
backupCodes String
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model UserProfile {
id String @id @default(cuid())
enabled Boolean @default(false)
@@ -266,6 +248,7 @@ model Subscription {
model Account {
id String @id @default(cuid())
userId Int
type String
provider String
providerAccountId String
refresh_token String? @db.Text
@@ -273,24 +256,12 @@ model Account {
expires_at Int?
// Some providers return created_at so we need to make it optional
created_at Int?
scope String?
id_token String? @db.Text
// Betterauth
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
password String?
// Stops next-auth from crashing when dealing with AzureAD
ext_expires_in Int?
// Todo: Remove these fields after auth migration.
type String @default("legacy")
token_type String?
scope String?
id_token String? @db.Text
session_state String?
// End of Todo.
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -303,23 +274,6 @@ model Session {
userId Int
expires DateTime
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
// Better auth fields.
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
ipAddress String?
userAgent String?
}
model Verification {
id String @id @default(cuid())
identifier String
value String
expiresAt DateTime
createdAt DateTime?
updatedAt DateTime?
@@map("verification")
}
enum DocumentStatus {
@@ -706,6 +660,7 @@ model Template {
title String
userId Int
teamId Int?
visibility DocumentVisibility @default(EVERYONE)
authOptions Json?
templateMeta TemplateMeta?
templateDocumentDataId String

View File

@@ -18,6 +18,6 @@
"ts-pattern": "^5.0.5"
},
"devDependencies": {
"vitest": "^2.1.8"
"vitest": "^1.3.1"
}
}

View File

@@ -237,8 +237,8 @@ export const templateRouter = router({
})
.input(ZCreateDocumentFromTemplateMutationSchema)
.output(ZGetDocumentWithDetailsByIdResponseSchema)
.mutation(async ({ input, ctx }) => {
const { templateId, teamId, recipients, distributeDocument } = input;
.mutation(async ({ ctx, input }) => {
const { templateId, teamId, recipients, distributeDocument, customDocumentDataId } = input;
const limits = await getServerLimits({ email: ctx.user.email, teamId });
@@ -253,6 +253,7 @@ export const templateRouter = router({
teamId,
userId: ctx.user.id,
recipients,
customDocumentDataId,
requestMetadata,
});

View File

@@ -11,6 +11,7 @@ import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url';
import {
DocumentDistributionMethod,
DocumentSigningOrder,
DocumentVisibility,
TemplateType,
} from '@documenso/prisma/client';
@@ -47,6 +48,7 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({
return new Set(emails).size === emails.length;
}, 'Recipients must have unique emails'),
distributeDocument: z.boolean().optional(),
customDocumentDataId: z.string().optional(),
});
export const ZDuplicateTemplateMutationSchema = z.object({
@@ -83,6 +85,7 @@ export const ZUpdateTemplateSettingsMutationSchema = z.object({
data: z.object({
title: z.string().min(1).optional(),
externalId: z.string().nullish(),
visibility: z.nativeEnum(DocumentVisibility).optional(),
globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullable().optional(),
globalActionAuth: ZDocumentActionAuthTypesSchema.nullable().optional(),
publicTitle: z.string().trim().min(1).max(MAX_TEMPLATE_PUBLIC_TITLE_LENGTH).optional(),

View File

@@ -23,7 +23,7 @@
"@types/react": "^18",
"@types/react-dom": "^18",
"react": "^18",
"typescript": "5.7.2"
"typescript": "5.2.2"
},
"dependencies": {
"@documenso/lib": "*",

View File

@@ -7,6 +7,7 @@ import { Trans } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { InfoIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DOCUMENT_DISTRIBUTION_METHODS } from '@documenso/lib/constants/document';
@@ -14,6 +15,7 @@ import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
import { DocumentDistributionMethod, type Field, type Recipient } from '@documenso/prisma/client';
import type { TemplateWithData } from '@documenso/prisma/types/template';
import {
@@ -25,6 +27,10 @@ import {
DocumentGlobalAuthActionTooltip,
} from '@documenso/ui/components/document/document-global-auth-action-select';
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
import {
DocumentVisibilitySelect,
DocumentVisibilityTooltip,
} from '@documenso/ui/components/document/document-visibility-select';
import {
Accordion,
AccordionContent,
@@ -66,6 +72,7 @@ export type AddTemplateSettingsFormProps = {
isEnterprise: boolean;
isDocumentPdfLoaded: boolean;
template: TemplateWithData;
currentTeamMemberRole?: TeamMemberRole;
onSubmit: (_data: TAddTemplateSettingsFormSchema) => void;
};
@@ -76,6 +83,7 @@ export const AddTemplateSettingsFormPartial = ({
isEnterprise,
isDocumentPdfLoaded,
template,
currentTeamMemberRole,
onSubmit,
}: AddTemplateSettingsFormProps) => {
const { _ } = useLingui();
@@ -89,6 +97,7 @@ export const AddTemplateSettingsFormPartial = ({
defaultValues: {
title: template.title,
externalId: template.externalId || undefined,
visibility: template.visibility || '',
globalAccessAuth: documentAuthOption?.globalAccessAuth || undefined,
globalActionAuth: documentAuthOption?.globalActionAuth || undefined,
meta: {
@@ -110,6 +119,16 @@ export const AddTemplateSettingsFormPartial = ({
const distributionMethod = form.watch('meta.distributionMethod');
const emailSettings = form.watch('meta.emailSettings');
const canUpdateVisibility = match(currentTeamMemberRole)
.with(TeamMemberRole.ADMIN, () => true)
.with(
TeamMemberRole.MANAGER,
() =>
template.visibility === DocumentVisibility.EVERYONE ||
template.visibility === DocumentVisibility.MANAGER_AND_ABOVE,
)
.otherwise(() => false);
// We almost always want to set the timezone to the user's local timezone to avoid confusion
// when the document is signed.
useEffect(() => {
@@ -210,6 +229,30 @@ export const AddTemplateSettingsFormPartial = ({
)}
/>
{currentTeamMemberRole && (
<FormField
control={form.control}
name="visibility"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
Document visibility
<DocumentVisibilityTooltip />
</FormLabel>
<FormControl>
<DocumentVisibilitySelect
canUpdateVisibility={canUpdateVisibility}
currentTeamMemberRole={currentTeamMemberRole}
{...field}
onValueChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="meta.distributionMethod"

View File

@@ -9,6 +9,7 @@ import {
} from '@documenso/lib/types/document-auth';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url';
import { DocumentVisibility } from '@documenso/prisma/client';
import { ZMapNegativeOneToUndefinedSchema } from '../document-flow/add-settings.types';
import { DocumentDistributionMethod } from '.prisma/client';
@@ -16,6 +17,7 @@ import { DocumentDistributionMethod } from '.prisma/client';
export const ZAddTemplateSettingsFormSchema = z.object({
title: z.string().trim().min(1, { message: "Title can't be empty" }),
externalId: z.string().optional(),
visibility: z.nativeEnum(DocumentVisibility).optional(),
globalAccessAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZDocumentAccessAuthTypesSchema.optional(),
),