diff --git a/.env.example b/.env.example index 41145424c..15b0b3f5c 100644 --- a/.env.example +++ b/.env.example @@ -22,7 +22,6 @@ NEXT_PRIVATE_OIDC_SKIP_VERIFY="" # [[URLS]] NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000" -NEXT_PUBLIC_MARKETING_URL="http://localhost:3001" # URL used by the web app to request itself (e.g. local background jobs) NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000" diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 4673dfca1..9d935d83f 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -1,7 +1,7 @@ name: Playwright Tests on: push: - branches: ['main'] + branches: ['main', 'feat/rr7'] pull_request: branches: ['main'] jobs: diff --git a/.gitpod.yml b/.gitpod.yml index 6976c2239..261f8c96b 100644 --- a/.gitpod.yml +++ b/.gitpod.yml @@ -5,7 +5,6 @@ tasks: cp .env.example .env && set -a; source .env && export NEXT_PUBLIC_WEBAPP_URL="$(gp url 3000)" && - export NEXT_PUBLIC_MARKETING_URL="$(gp url 3001)" command: npm run d ports: diff --git a/.husky/pre-commit b/.husky/pre-commit index d4d3c418f..52007e38b 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -4,9 +4,6 @@ SCRIPT_DIR="$(readlink -f "$(dirname "$0")")" MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")" -echo "Copying pdf.js" -npm run copy:pdfjs --workspace apps/** - echo "Copying .well-known/ contents" node "$MONOREPO_ROOT/scripts/copy-wellknown.cjs" diff --git a/README.md b/README.md index 3afaafeec..d7f79adda 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,6 @@ git clone https://github.com//documenso - NEXTAUTH_SECRET - NEXT_PUBLIC_WEBAPP_URL - - NEXT_PUBLIC_MARKETING_URL - NEXT_PRIVATE_DATABASE_URL - NEXT_PRIVATE_DIRECT_DATABASE_URL - NEXT_PRIVATE_SMTP_FROM_NAME @@ -237,7 +236,6 @@ The following environment variables must be set: - `NEXTAUTH_SECRET` - `NEXT_PUBLIC_WEBAPP_URL` -- `NEXT_PUBLIC_MARKETING_URL` - `NEXT_PRIVATE_DATABASE_URL` - `NEXT_PRIVATE_DIRECT_DATABASE_URL` - `NEXT_PRIVATE_SMTP_FROM_NAME` diff --git a/apps/documentation/package.json b/apps/documentation/package.json index fc06f6547..76def45e1 100644 --- a/apps/documentation/package.json +++ b/apps/documentation/package.json @@ -7,8 +7,7 @@ "build": "next build", "start": "next start -p 3002", "lint:fix": "next lint --fix", - "clean": "rimraf .next && rimraf node_modules", - "copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs" + "clean": "rimraf .next && rimraf node_modules" }, "dependencies": { "@documenso/assets": "*", diff --git a/apps/documentation/pages/developers/embedding/index.mdx b/apps/documentation/pages/developers/embedding/index.mdx index 27d6f6f8f..3a5535f89 100644 --- a/apps/documentation/pages/developers/embedding/index.mdx +++ b/apps/documentation/pages/developers/embedding/index.mdx @@ -52,9 +52,9 @@ Platform customers have access to advanced styling options to customize the embe ``` diff --git a/apps/documentation/pages/developers/embedding/preact.mdx b/apps/documentation/pages/developers/embedding/preact.mdx index 91176723f..3f6f579f5 100644 --- a/apps/documentation/pages/developers/embedding/preact.mdx +++ b/apps/documentation/pages/developers/embedding/preact.mdx @@ -95,9 +95,9 @@ const MyEmbeddingComponent = () => { } `; const cssVars = { - colorPrimary: '#0000FF', - colorBackground: '#F5F5F5', - borderRadius: '8px', + primary: '#0000FF', + background: '#F5F5F5', + radius: '8px', }; return ( diff --git a/apps/documentation/pages/developers/embedding/react.mdx b/apps/documentation/pages/developers/embedding/react.mdx index 05dc3a8aa..2c259ddb8 100644 --- a/apps/documentation/pages/developers/embedding/react.mdx +++ b/apps/documentation/pages/developers/embedding/react.mdx @@ -99,9 +99,9 @@ const MyEmbeddingComponent = () => { `} // CSS Variables cssVars={{ - colorPrimary: '#0000FF', - colorBackground: '#F5F5F5', - borderRadius: '8px', + primary: '#0000FF', + background: '#F5F5F5', + radius: '8px', }} // Dark Mode Control darkModeDisabled={true} diff --git a/apps/documentation/pages/developers/embedding/solid.mdx b/apps/documentation/pages/developers/embedding/solid.mdx index e19007a43..135e93ff6 100644 --- a/apps/documentation/pages/developers/embedding/solid.mdx +++ b/apps/documentation/pages/developers/embedding/solid.mdx @@ -95,9 +95,9 @@ const MyEmbeddingComponent = () => { } `; const cssVars = { - colorPrimary: '#0000FF', - colorBackground: '#F5F5F5', - borderRadius: '8px', + primary: '#0000FF', + background: '#F5F5F5', + radius: '8px', }; return ( diff --git a/apps/documentation/pages/developers/embedding/svelte.mdx b/apps/documentation/pages/developers/embedding/svelte.mdx index 46ec69c63..126b43e10 100644 --- a/apps/documentation/pages/developers/embedding/svelte.mdx +++ b/apps/documentation/pages/developers/embedding/svelte.mdx @@ -97,9 +97,9 @@ Platform customers have access to advanced styling options: } `; const cssVars = { - colorPrimary: '#0000FF', - colorBackground: '#F5F5F5', - borderRadius: '8px', + primary: '#0000FF', + background: '#F5F5F5', + radius: '8px', }; diff --git a/apps/documentation/pages/developers/embedding/vue.mdx b/apps/documentation/pages/developers/embedding/vue.mdx index 8051dbe35..dc68f979d 100644 --- a/apps/documentation/pages/developers/embedding/vue.mdx +++ b/apps/documentation/pages/developers/embedding/vue.mdx @@ -97,9 +97,9 @@ Platform customers have access to advanced styling options: } `; const cssVars = { - colorPrimary: '#0000FF', - colorBackground: '#F5F5F5', - borderRadius: '8px', + primary: '#0000FF', + background: '#F5F5F5', + radius: '8px', }; diff --git a/apps/documentation/pages/developers/local-development/index.mdx b/apps/documentation/pages/developers/local-development/index.mdx index c92f9b385..bf9f92723 100644 --- a/apps/documentation/pages/developers/local-development/index.mdx +++ b/apps/documentation/pages/developers/local-development/index.mdx @@ -16,7 +16,7 @@ Pick the one that fits your needs the best. ## Tech Stack - [Typescript](https://www.typescriptlang.org/) - Language -- [ReactRouter](https://reactrouter.com/) - Framework +- [React Router](https://reactrouter.com/) - Framework - [Prisma](https://www.prisma.io/) - ORM - [Tailwind](https://tailwindcss.com/) - CSS - [shadcn/ui](https://ui.shadcn.com/) - Component Library diff --git a/apps/documentation/pages/developers/local-development/manual.mdx b/apps/documentation/pages/developers/local-development/manual.mdx index 9f2b5c4fc..ed98338cf 100644 --- a/apps/documentation/pages/developers/local-development/manual.mdx +++ b/apps/documentation/pages/developers/local-development/manual.mdx @@ -34,7 +34,6 @@ Set up the following environment variables in the `.env` file: ```bash NEXTAUTH_SECRET NEXT_PUBLIC_WEBAPP_URL -NEXT_PUBLIC_MARKETING_URL NEXT_PRIVATE_DATABASE_URL NEXT_PRIVATE_DIRECT_DATABASE_URL NEXT_PRIVATE_SMTP_FROM_NAME diff --git a/apps/documentation/pages/developers/local-development/translations.mdx b/apps/documentation/pages/developers/local-development/translations.mdx index a776dc50c..2fbb6eb96 100644 --- a/apps/documentation/pages/developers/local-development/translations.mdx +++ b/apps/documentation/pages/developers/local-development/translations.mdx @@ -13,35 +13,13 @@ Documenso uses the following stack to handle translations: Additional reading can be found in the [Lingui documentation](https://lingui.dev/introduction). -## Requirements - -You **must** insert **`setupI18nSSR()`** when creating any of the following files: - -- Server layout.tsx -- Server page.tsx -- Server loading.tsx - -Server meaning it does not have `'use client'` in it. - -```tsx -import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server'; - -export default function SomePage() { - setupI18nSSR(); // Required if there are translations within the page, or nested in components. - - // Rest of code... -} -``` - -Additional information can be found [here.](https://lingui.dev/tutorials/react-rsc#pages-layouts-and-lingui) - ## Quick guide If you require more in-depth information, please see the [Lingui documentation](https://lingui.dev/introduction). ### HTML -Wrap all text to translate in **``** tags exported from **@lingui/macro** (not @lingui/react). +Wrap all text to translate in **``** tags exported from **@lingui/react/macro**. ```html

@@ -64,8 +42,9 @@ For text that is broken into elements, but represent a whole sentence, you must ### Constants outside of react components ```tsx -import { Trans, msg } from '@lingui/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; // Wrap text in msg`text to translate` when it's in a constant here, or another file/package. export const CONSTANT_WITH_MSG = { @@ -98,31 +77,13 @@ Lingui provides a Plural component to make it easy. See full documentation [here Lingui provides a [DateTime instance](https://lingui.dev/ref/core#i18n.date) with the configured locale. -#### Server components - -Note that the i18n instance is coming from **setupI18nSSR**. - ```tsx import { Trans } from '@lingui/macro'; import { useLingui } from '@lingui/react'; export const SomeComponent = () => { - const { i18n } = setupI18nSSR(); + const { i18n } = useLingui(); return The current date is {i18n.date(new Date(), { dateStyle: 'short' })}; }; ``` - -#### Client components - -Note that the i18n instance is coming from the **import**. - -```tsx -import { i18n } from '@lingui/core'; -import { Trans } from '@lingui/macro'; -import { useLingui } from '@lingui/react'; - -export const SomeComponent = () => { - return The current date is {i18n.date(new Date(), { dateStyle: 'short' })}; -}; -``` diff --git a/apps/documentation/pages/developers/public-api/index.mdx b/apps/documentation/pages/developers/public-api/index.mdx index f2745ee82..050810863 100644 --- a/apps/documentation/pages/developers/public-api/index.mdx +++ b/apps/documentation/pages/developers/public-api/index.mdx @@ -21,14 +21,25 @@ Check out the [API V1 documentation](https://app.documenso.com/api/v1/openapi) f ## 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. +API V2 is currently beta, and will be subject to breaking changes - - NOW IN BETA: [API V2 Documentation](https://documen.so/api-v2-docs) +Check out the [API V2 documentation](https://documen.so/api-v2-docs) for details about the API endpoints, request parameters, response formats, and authentication methods. + +Our new API V2 supports the following typed SDKs: + +- [TypeScript](https://github.com/documenso/sdk-typescript) +- [Python](https://github.com/documenso/sdk-python) +- [Go](https://github.com/documenso/sdk-go) + + + For the staging API, please use the following base URL: + `https://stg-app.documenso.dev/api/v2-beta/` 🚀 [V2 Announcement](https://documen.so/sdk-blog) +📖 [Documentation](https://documen.so/api-v2-docs) + 💬 [Leave Feedback](https://documen.so/sdk-feedback) 🔔 [Breaking Changes](https://documen.so/sdk-breaking) diff --git a/apps/documentation/pages/developers/self-hosting/how-to.mdx b/apps/documentation/pages/developers/self-hosting/how-to.mdx index f327a55a5..4025ce6d0 100644 --- a/apps/documentation/pages/developers/self-hosting/how-to.mdx +++ b/apps/documentation/pages/developers/self-hosting/how-to.mdx @@ -37,7 +37,6 @@ Open the `.env` file and fill in the following variables: ```bash - NEXTAUTH_SECRET - NEXT_PUBLIC_WEBAPP_URL -- NEXT_PUBLIC_MARKETING_URL - NEXT_PRIVATE_DATABASE_URL - NEXT_PRIVATE_DIRECT_DATABASE_URL - NEXT_PRIVATE_SMTP_FROM_NAME diff --git a/apps/openpage-api/lib/growth/get-monthly-completed-document.ts b/apps/openpage-api/lib/growth/get-monthly-completed-document.ts index 1b4c83650..885842101 100644 --- a/apps/openpage-api/lib/growth/get-monthly-completed-document.ts +++ b/apps/openpage-api/lib/growth/get-monthly-completed-document.ts @@ -1,7 +1,7 @@ +import { DocumentStatus } from '@prisma/client'; import { DateTime } from 'luxon'; import { kyselyPrisma, sql } from '@documenso/prisma'; -import { DocumentStatus } from '@documenso/prisma/client'; export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative' = 'count') => { const qb = kyselyPrisma.$kysely diff --git a/apps/openpage-api/package.json b/apps/openpage-api/package.json index 1a8816acb..3ceaba3e4 100644 --- a/apps/openpage-api/package.json +++ b/apps/openpage-api/package.json @@ -7,8 +7,7 @@ "build": "next build", "start": "next start", "lint:fix": "next lint --fix", - "clean": "rimraf .next && rimraf node_modules", - "copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs" + "clean": "rimraf .next && rimraf node_modules" }, "dependencies": { "@documenso/prisma": "*", diff --git a/apps/remix/.bin/build.sh b/apps/remix/.bin/build.sh index 83a617e75..d7e4c6134 100755 --- a/apps/remix/.bin/build.sh +++ b/apps/remix/.bin/build.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/usr/bin/env sh # Exit on error. set -eo pipefail diff --git a/apps/remix/.bin/stripe-dev.sh b/apps/remix/.bin/stripe-dev.sh new file mode 100755 index 000000000..67d349ded --- /dev/null +++ b/apps/remix/.bin/stripe-dev.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash + +# Set Error handling +set -eu + +SCRIPT_DIR="$(readlink -f "$(dirname "$0")")" +WEB_APP_DIR="$SCRIPT_DIR/.." + +# Store the original directory +ORIGINAL_DIR=$(pwd) + +# Set up trap to ensure we return to original directory +trap 'cd "$ORIGINAL_DIR"' EXIT + +cd "$WEB_APP_DIR" + +# Define env file paths +ENV_LOCAL_FILE="../../.env.local" + +# Function to load environment variable from env files +load_env_var() { + local var_name=$1 + local var_value="" + + if [ -f "$ENV_LOCAL_FILE" ]; then + var_value=$(grep "^$var_name=" "$ENV_LOCAL_FILE" | cut -d '=' -f2) + fi + + # Remove quotes if present + var_value=$(echo "$var_value" | sed 's/^"\(.*\)"$/\1/' | sed "s/^'\(.*\)'$/\1/") + + echo "$var_value" +} + +NEXT_PUBLIC_FEATURE_BILLING_ENABLED=$(load_env_var "NEXT_PUBLIC_FEATURE_BILLING_ENABLED") + +# Check if NEXT_PUBLIC_FEATURE_BILLING_ENABLED is equal to true +if [ "$NEXT_PUBLIC_FEATURE_BILLING_ENABLED" != "true" ]; then + echo "[ERROR]: NEXT_PUBLIC_FEATURE_BILLING_ENABLED must be enabled." + exit 1 +fi + +# 1. Load NEXT_PRIVATE_STRIPE_API_KEY from env files +NEXT_PRIVATE_STRIPE_API_KEY=$(load_env_var "NEXT_PRIVATE_STRIPE_API_KEY") + +# Check if NEXT_PRIVATE_STRIPE_API_KEY exists +if [ -z "$NEXT_PRIVATE_STRIPE_API_KEY" ]; then + echo "[ERROR]: NEXT_PRIVATE_STRIPE_API_KEY not found in environment files." + echo "[ERROR]: Please make sure it's set in $ENV_LOCAL_FILE" + exit 1 +fi + +# 2. Check if stripe CLI is installed +if ! command -v stripe &> /dev/null; then + echo "[ERROR]: Stripe CLI is not installed or not in PATH." + echo "[ERROR]: Please install the Stripe CLI: https://stripe.com/docs/stripe-cli" + exit 1 +fi + +# 3. Check if NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET env key exists +NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=$(load_env_var "NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET") + +if [ -z "$NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET" ]; then + echo "╔═════════════════════════════════════════════════════════════════════╗" + echo "║ ║" + echo "║ ! WARNING: NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET MISSING ! ║" + echo "║ ║" + echo "║ Copy the webhook signing secret which will appear in the terminal ║" + echo "║ soon into the env file. ║" + echo "║ ║" + echo "║ The webhook secret will start with whsec_... ║" + echo "║ ║" + echo "╚═════════════════════════════════════════════════════════════════════╝" +fi + +echo "[INFO]: Starting Stripe webhook listener..." +stripe listen --api-key "$NEXT_PRIVATE_STRIPE_API_KEY" --forward-to http://localhost:3000/api/stripe/webhook diff --git a/apps/remix/app/components/dialogs/document-delete-dialog.tsx b/apps/remix/app/components/dialogs/document-delete-dialog.tsx index a169686a6..c89e346a0 100644 --- a/apps/remix/app/components/dialogs/document-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-delete-dialog.tsx @@ -4,7 +4,7 @@ import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; import { DocumentStatus } from '@prisma/client'; -import { match } from 'ts-pattern'; +import { P, match } from 'ts-pattern'; import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; import { trpc as trpcReact } from '@documenso/trpc/react'; @@ -146,7 +146,7 @@ export const DocumentDeleteDialog = ({ )) - .with(DocumentStatus.COMPLETED, () => ( + .with(P.union(DocumentStatus.COMPLETED, DocumentStatus.REJECTED), () => (

By deleting this document, the following will occur: diff --git a/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx b/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx index a6c2d8f03..754fa2596 100644 --- a/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx @@ -13,7 +13,7 @@ import { DialogHeader, DialogTitle, } from '@documenso/ui/primitives/dialog'; -import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; +import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { useOptionalCurrentTeam } from '~/providers/team'; @@ -97,7 +97,7 @@ export const DocumentDuplicateDialog = ({ ) : (

- +
)} diff --git a/apps/remix/app/components/dialogs/token-delete-dialog.tsx b/apps/remix/app/components/dialogs/token-delete-dialog.tsx index 8b373d370..511ce04db 100644 --- a/apps/remix/app/components/dialogs/token-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/token-delete-dialog.tsx @@ -30,22 +30,20 @@ import { import { Input } from '@documenso/ui/primitives/input'; import { useToast } from '@documenso/ui/primitives/use-toast'; +import { useOptionalCurrentTeam } from '~/providers/team'; + export type TokenDeleteDialogProps = { - teamId?: number; token: Pick; onDelete?: () => void; children?: React.ReactNode; }; -export default function TokenDeleteDialog({ - teamId, - token, - onDelete, - children, -}: TokenDeleteDialogProps) { +export default function TokenDeleteDialog({ token, onDelete, children }: TokenDeleteDialogProps) { const { _ } = useLingui(); const { toast } = useToast(); + const team = useOptionalCurrentTeam(); + const [isOpen, setIsOpen] = useState(false); const deleteMessage = _(msg`delete ${token.name}`); @@ -75,7 +73,7 @@ export default function TokenDeleteDialog({ try { await deleteTokenMutation({ id: token.id, - teamId, + teamId: team?.id, }); toast({ diff --git a/apps/remix/app/components/dialogs/webhook-create-dialog.tsx b/apps/remix/app/components/dialogs/webhook-create-dialog.tsx index 32f16045c..f8c5c94d2 100644 --- a/apps/remix/app/components/dialogs/webhook-create-dialog.tsx +++ b/apps/remix/app/components/dialogs/webhook-create-dialog.tsx @@ -230,14 +230,13 @@ export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogPr /> -
- - -
+ + +
diff --git a/apps/remix/app/components/embed/embed-authentication-required.tsx b/apps/remix/app/components/embed/embed-authentication-required.tsx index 11d672cb4..db65e7a2f 100644 --- a/apps/remix/app/components/embed/embed-authentication-required.tsx +++ b/apps/remix/app/components/embed/embed-authentication-required.tsx @@ -8,11 +8,17 @@ import { BrandingLogo } from '~/components/general/branding-logo'; export type EmbedAuthenticationRequiredProps = { email?: string; returnTo: string; + isGoogleSSOEnabled?: boolean; + isOIDCSSOEnabled?: boolean; + oidcProviderLabel?: string; }; export const EmbedAuthenticationRequired = ({ email, returnTo, + // isGoogleSSOEnabled, + // isOIDCSSOEnabled, + // oidcProviderLabel, }: EmbedAuthenticationRequiredProps) => { return (
@@ -28,7 +34,15 @@ export const EmbedAuthenticationRequired = ({ - +
); diff --git a/apps/remix/app/components/embed/embed-direct-template-client-page.tsx b/apps/remix/app/components/embed/embed-direct-template-client-page.tsx index 47832c7af..aa780385c 100644 --- a/apps/remix/app/components/embed/embed-direct-template-client-page.tsx +++ b/apps/remix/app/components/embed/embed-direct-template-client-page.tsx @@ -3,8 +3,8 @@ 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 DocumentData, type Field, FieldType } from '@prisma/client'; import type { DocumentMeta, Recipient, Signature, TemplateMeta } from '@prisma/client'; +import { type DocumentData, type Field, FieldType } from '@prisma/client'; import { LucideChevronDown, LucideChevronUp } from 'lucide-react'; import { DateTime } from 'luxon'; import { useSearchParams } from 'react-router'; @@ -13,6 +13,10 @@ import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn' 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 { + isFieldUnsignedAndRequired, + isRequiredField, +} from '@documenso/lib/utils/advanced-fields-helpers'; import { validateFieldsInserted } from '@documenso/lib/utils/fields'; import { trpc } from '@documenso/trpc/react'; import type { @@ -21,12 +25,11 @@ import type { } from '@documenso/trpc/server/field-router/schema'; 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 { PDFViewer } from '@documenso/ui/primitives/pdf-viewer'; +import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { BrandingLogo } from '~/components/general/branding-logo'; @@ -47,7 +50,7 @@ export type EmbedDirectTemplateClientPageProps = { fields: Field[]; metadata?: DocumentMeta | TemplateMeta | null; hidePoweredBy?: boolean; - isPlatformOrEnterprise?: boolean; + allowWhiteLabelling?: boolean; }; export const EmbedDirectTemplateClientPage = ({ @@ -58,23 +61,15 @@ export const EmbedDirectTemplateClientPage = ({ fields, metadata, hidePoweredBy = false, - isPlatformOrEnterprise = false, + allowWhiteLabelling = false, }: EmbedDirectTemplateClientPageProps) => { const { _ } = useLingui(); const { toast } = useToast(); const [searchParams] = useSearchParams(); - const { - fullName, - email, - signature, - signatureValid, - setFullName, - setEmail, - setSignature, - setSignatureValid, - } = useRequiredDocumentSigningContext(); + const { fullName, email, signature, setFullName, setEmail, setSignature } = + useRequiredDocumentSigningContext(); const [hasFinishedInit, setHasFinishedInit] = useState(false); const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false); @@ -92,7 +87,7 @@ export const EmbedDirectTemplateClientPage = ({ const [localFields, setLocalFields] = useState(() => fields); const [pendingFields, _completedFields] = [ - localFields.filter((field) => !field.inserted), + localFields.filter((field) => isFieldUnsignedAndRequired(field)), localFields.filter((field) => field.inserted), ]; @@ -110,7 +105,7 @@ export const EmbedDirectTemplateClientPage = ({ const newField: DirectTemplateLocalField = structuredClone({ ...field, - customText: payload.value, + customText: payload.value ?? '', inserted: true, signedValue: payload, }); @@ -121,8 +116,10 @@ export const EmbedDirectTemplateClientPage = ({ created: new Date(), recipientId: 1, fieldId: 1, - signatureImageAsBase64: payload.value.startsWith('data:') ? payload.value : null, - typedSignature: payload.value.startsWith('data:') ? null : payload.value, + signatureImageAsBase64: + payload.value && payload.value.startsWith('data:') ? payload.value : null, + typedSignature: + payload.value && !payload.value.startsWith('data:') ? payload.value : null, } satisfies Signature; } @@ -180,7 +177,7 @@ export const EmbedDirectTemplateClientPage = ({ }; const onNextFieldClick = () => { - validateFieldsInserted(localFields); + validateFieldsInserted(pendingFields); setShowPendingFieldTooltip(true); setIsExpanded(false); @@ -188,11 +185,7 @@ export const EmbedDirectTemplateClientPage = ({ const onCompleteClick = async () => { try { - if (hasSignatureField && !signatureValid) { - return; - } - - const valid = validateFieldsInserted(localFields); + const valid = validateFieldsInserted(pendingFields); if (!valid) { setShowPendingFieldTooltip(true); @@ -205,12 +198,6 @@ export const EmbedDirectTemplateClientPage = ({ directTemplateExternalId = decodeURIComponent(directTemplateExternalId); } - localFields.forEach((field) => { - if (!field.signedValue) { - throw new Error('Invalid configuration'); - } - }); - const { documentId, token: documentToken, @@ -221,13 +208,11 @@ export const EmbedDirectTemplateClientPage = ({ directRecipientName: fullName, directRecipientEmail: email, templateUpdatedAt: updatedAt, - signedFieldValues: localFields.map((field) => { - if (!field.signedValue) { - throw new Error('Invalid configuration'); - } - - return field.signedValue; - }), + signedFieldValues: localFields + .filter((field) => { + return field.signedValue && (isRequiredField(field) || field.inserted); + }) + .map((field) => field.signedValue!), }); if (window.parent) { @@ -286,7 +271,7 @@ export const EmbedDirectTemplateClientPage = ({ document.documentElement.classList.add('dark-mode-disabled'); } - if (isPlatformOrEnterprise) { + if (allowWhiteLabelling) { injectCss({ css: data.css, cssVars: data.cssVars, @@ -338,7 +323,7 @@ export const EmbedDirectTemplateClientPage = ({
{/* Viewer */}
- setHasDocumentLoaded(true)} /> @@ -347,7 +332,7 @@ export const EmbedDirectTemplateClientPage = ({ {/* Widget */}
@@ -415,40 +400,24 @@ export const EmbedDirectTemplateClientPage = ({ />
-
- + {hasSignatureField && ( +
+ - - - { - setSignature(value); - }} - onValidityChange={(isValid) => { - setSignatureValid(isValid); - }} - allowTypedSignature={Boolean( - metadata && - 'typedSignatureEnabled' in metadata && - metadata.typedSignatureEnabled, - )} - /> - - - - {hasSignatureField && !signatureValid && ( -
- - Signature is too small. Please provide a more complete signature. - -
- )} -
+ setSignature(v ?? '')} + typedSignatureEnabled={metadata?.typedSignatureEnabled} + uploadSignatureEnabled={metadata?.uploadSignatureEnabled} + drawSignatureEnabled={metadata?.drawSignatureEnabled} + /> +
+ )}
diff --git a/apps/remix/app/components/embed/embed-document-fields.tsx b/apps/remix/app/components/embed/embed-document-fields.tsx index 99c8e4600..0cb53f17c 100644 --- a/apps/remix/app/components/embed/embed-document-fields.tsx +++ b/apps/remix/app/components/embed/embed-document-fields.tsx @@ -54,6 +54,8 @@ export const EmbedDocumentFields = ({ onSignField={onSignField} onUnsignField={onUnsignField} typedSignatureEnabled={metadata?.typedSignatureEnabled} + uploadSignatureEnabled={metadata?.uploadSignatureEnabled} + drawSignatureEnabled={metadata?.drawSignatureEnabled} /> )) .with(FieldType.INITIALS, () => ( diff --git a/apps/remix/app/components/embed/embed-document-rejected.tsx b/apps/remix/app/components/embed/embed-document-rejected.tsx new file mode 100644 index 000000000..911df8729 --- /dev/null +++ b/apps/remix/app/components/embed/embed-document-rejected.tsx @@ -0,0 +1,33 @@ +import { Trans } from '@lingui/react/macro'; +import { XCircle } from 'lucide-react'; + +export const EmbedDocumentRejected = () => { + return ( +
+
+
+ + +

+ Document Rejected +

+
+ +
+ You have rejected this document +
+ +

+ + The document owner has been notified of your decision. They may contact you with further + instructions if necessary. + +

+ +

+ No further action is required from you at this time. +

+
+
+ ); +}; diff --git a/apps/remix/app/components/embed/embed-document-signing-page.tsx b/apps/remix/app/components/embed/embed-document-signing-page.tsx index 980e80f7a..79d87e6aa 100644 --- a/apps/remix/app/components/embed/embed-document-signing-page.tsx +++ b/apps/remix/app/components/embed/embed-document-signing-page.tsx @@ -1,26 +1,32 @@ -import { useEffect, useId, useLayoutEffect, useState } from 'react'; +import { useEffect, useId, useLayoutEffect, useMemo, useState } from 'react'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; import type { DocumentMeta, TemplateMeta } from '@prisma/client'; -import { type DocumentData, type Field, FieldType, RecipientRole } from '@prisma/client'; +import { + type DocumentData, + type Field, + FieldType, + RecipientRole, + SigningStatus, +} 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 { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers'; import { validateFieldsInserted } from '@documenso/lib/utils/fields'; import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-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 { PDFViewer } from '@documenso/ui/primitives/pdf-viewer'; import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group'; -import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; +import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { BrandingLogo } from '~/components/general/branding-logo'; @@ -29,9 +35,11 @@ import { injectCss } from '~/utils/css-vars'; import { ZSignDocumentEmbedDataSchema } from '../../types/embed-document-sign-schema'; import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider'; import { DocumentSigningRecipientProvider } from '../general/document-signing/document-signing-recipient-provider'; +import { DocumentSigningRejectDialog } from '../general/document-signing/document-signing-reject-dialog'; import { EmbedClientLoading } from './embed-client-loading'; import { EmbedDocumentCompleted } from './embed-document-completed'; import { EmbedDocumentFields } from './embed-document-fields'; +import { EmbedDocumentRejected } from './embed-document-rejected'; export type EmbedSignDocumentClientPageProps = { token: string; @@ -42,7 +50,7 @@ export type EmbedSignDocumentClientPageProps = { metadata?: DocumentMeta | TemplateMeta | null; isCompleted?: boolean; hidePoweredBy?: boolean; - isPlatformOrEnterprise?: boolean; + allowWhitelabelling?: boolean; allRecipients?: RecipientWithFields[]; }; @@ -55,25 +63,21 @@ export const EmbedSignDocumentClientPage = ({ metadata, isCompleted, hidePoweredBy = false, - isPlatformOrEnterprise = false, + allowWhitelabelling = false, allRecipients = [], }: EmbedSignDocumentClientPageProps) => { const { _ } = useLingui(); const { toast } = useToast(); - const { - fullName, - email, - signature, - signatureValid, - setFullName, - setSignature, - setSignatureValid, - } = useRequiredDocumentSigningContext(); + const { fullName, email, signature, setFullName, setSignature } = + useRequiredDocumentSigningContext(); const [hasFinishedInit, setHasFinishedInit] = useState(false); const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false); const [hasCompletedDocument, setHasCompletedDocument] = useState(isCompleted); + const [hasRejectedDocument, setHasRejectedDocument] = useState( + recipient.signingStatus === SigningStatus.REJECTED, + ); const [selectedSignerId, setSelectedSignerId] = useState( allRecipients.length > 0 ? allRecipients[0].id : null, ); @@ -82,25 +86,34 @@ export const EmbedSignDocumentClientPage = ({ const [isNameLocked, setIsNameLocked] = useState(false); const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false); + const [allowDocumentRejection, setAllowDocumentRejection] = useState(false); + const selectedSigner = allRecipients.find((r) => r.id === selectedSignerId); const isAssistantMode = recipient.role === RecipientRole.ASSISTANT; const [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500); const [pendingFields, _completedFields] = [ - fields.filter((field) => field.recipientId === recipient.id && !field.inserted), + fields.filter( + (field) => field.recipientId === recipient.id && isFieldUnsignedAndRequired(field), + ), fields.filter((field) => field.inserted), ]; const { mutateAsync: completeDocumentWithToken, isPending: isSubmitting } = trpc.recipient.completeDocumentWithToken.useMutation(); + const fieldsRequiringValidation = useMemo( + () => fields.filter(isFieldUnsignedAndRequired), + [fields], + ); + const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE); const assistantSignersId = useId(); const onNextFieldClick = () => { - validateFieldsInserted(fields); + validateFieldsInserted(fieldsRequiringValidation); setShowPendingFieldTooltip(true); setIsExpanded(false); @@ -108,11 +121,7 @@ export const EmbedSignDocumentClientPage = ({ const onCompleteClick = async () => { try { - if (hasSignatureField && !signatureValid) { - return; - } - - const valid = validateFieldsInserted(fields); + const valid = validateFieldsInserted(fieldsRequiringValidation); if (!valid) { setShowPendingFieldTooltip(true); @@ -160,6 +169,25 @@ export const EmbedSignDocumentClientPage = ({ } }; + const onDocumentRejected = (reason: string) => { + if (window.parent) { + window.parent.postMessage( + { + action: 'document-rejected', + data: { + token, + documentId, + recipientId: recipient.id, + reason, + }, + }, + '*', + ); + } + + setHasRejectedDocument(true); + }; + useLayoutEffect(() => { const hash = window.location.hash.slice(1); @@ -173,12 +201,13 @@ export const EmbedSignDocumentClientPage = ({ // 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); + setAllowDocumentRejection(!!data.allowDocumentRejection); if (data.darkModeDisabled) { document.documentElement.classList.add('dark-mode-disabled'); } - if (isPlatformOrEnterprise) { + if (allowWhitelabelling) { injectCss({ css: data.css, cssVars: data.cssVars, @@ -207,6 +236,10 @@ export const EmbedSignDocumentClientPage = ({ } }, [hasFinishedInit, hasDocumentLoaded]); + if (hasRejectedDocument) { + return ; + } + if (hasCompletedDocument) { return ( {(!hasFinishedInit || !hasDocumentLoaded) && } + {allowDocumentRejection && ( +
+ +
+ )} +
{/* Viewer */}
- setHasDocumentLoaded(true)} /> @@ -240,7 +283,7 @@ export const EmbedSignDocumentClientPage = ({ {/* Widget */}
@@ -371,40 +414,24 @@ export const EmbedSignDocumentClientPage = ({ />
-
- + {hasSignatureField && ( +
+ - - - { - setSignature(value); - }} - onValidityChange={(isValid) => { - setSignatureValid(isValid); - }} - allowTypedSignature={Boolean( - metadata && - 'typedSignatureEnabled' in metadata && - metadata.typedSignatureEnabled, - )} - /> - - - - {hasSignatureField && !signatureValid && ( -
- - Signature is too small. Please provide a more complete signature. - -
- )} -
+ setSignature(v ?? '')} + typedSignatureEnabled={metadata?.typedSignatureEnabled} + uploadSignatureEnabled={metadata?.uploadSignatureEnabled} + drawSignatureEnabled={metadata?.drawSignatureEnabled} + /> +
+ )} )}
@@ -419,10 +446,8 @@ export const EmbedSignDocumentClientPage = ({ ) : (
+ ( + render={({ field: { onChange, value } }) => ( Signature - onChange(v ?? '')} - allowTypedSignature={true} /> @@ -136,7 +135,7 @@ export const ProfileForm = ({ className }: ProfileFormProps) => { diff --git a/apps/remix/app/components/forms/public-profile-claim-dialog.tsx b/apps/remix/app/components/forms/public-profile-claim-dialog.tsx deleted file mode 100644 index f5e55e2ef..000000000 --- a/apps/remix/app/components/forms/public-profile-claim-dialog.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import React, { useState } from 'react'; - -import { zodResolver } from '@hookform/resolvers/zod'; -import { msg } from '@lingui/core/macro'; -import { useLingui } from '@lingui/react'; -import type { User } from '@prisma/client'; -import { useForm } from 'react-hook-form'; -import { z } from 'zod'; - -import profileClaimTeaserImage from '@documenso/assets/images/profile-claim-teaser.png'; -import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; -import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; -import { trpc } from '@documenso/trpc/react'; -import { cn } from '@documenso/ui/lib/utils'; -import { Button } from '@documenso/ui/primitives/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@documenso/ui/primitives/dialog'; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from '@documenso/ui/primitives/form/form'; -import { Input } from '@documenso/ui/primitives/input'; -import { useToast } from '@documenso/ui/primitives/use-toast'; - -import { UserProfileSkeleton } from '../general/user-profile-skeleton'; - -export const ZClaimPublicProfileFormSchema = z.object({ - url: z - .string() - .trim() - .toLowerCase() - .min(1, { message: 'Please enter a valid username.' }) - .regex(/^[a-z0-9-]+$/, { - message: 'Username can only container alphanumeric characters and dashes.', - }), -}); - -export type TClaimPublicProfileFormSchema = z.infer; - -export type ClaimPublicProfileDialogFormProps = { - open: boolean; - onOpenChange?: (open: boolean) => void; - onClaimed?: () => void; - user: User; -}; - -export const ClaimPublicProfileDialogForm = ({ - open, - onOpenChange, - onClaimed, - user, -}: ClaimPublicProfileDialogFormProps) => { - const { _ } = useLingui(); - const { toast } = useToast(); - - const [claimed, setClaimed] = useState(false); - - const baseUrl = new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000'); - - const form = useForm({ - values: { - url: user.url || '', - }, - resolver: zodResolver(ZClaimPublicProfileFormSchema), - }); - - const { mutateAsync: updatePublicProfile } = trpc.profile.updatePublicProfile.useMutation(); - - const isSubmitting = form.formState.isSubmitting; - - const onFormSubmit = async ({ url }: TClaimPublicProfileFormSchema) => { - try { - await updatePublicProfile({ - url, - }); - - setClaimed(true); - onClaimed?.(); - } catch (err) { - const error = AppError.parseError(err); - - if (error.code === 'PROFILE_URL_TAKEN') { - form.setError('url', { - type: 'manual', - message: _(msg`This username is already taken`), - }); - } else if (error.code === 'PREMIUM_PROFILE_URL') { - form.setError('url', { - type: 'manual', - message: error.message, - }); - } else if (error.code !== AppErrorCode.UNKNOWN_ERROR) { - toast({ - title: 'An error occurred', - description: error.userMessage ?? error.message, - variant: 'destructive', - }); - } else { - toast({ - title: _(msg`An unknown error occurred`), - description: _( - msg`We encountered an unknown error while attempting to save your details. Please try again later.`, - ), - variant: 'destructive', - }); - } - } - }; - - return ( - - - {!claimed && ( - <> - - - Introducing public profiles! - - - - Reserve your Documenso public profile username - - - - profile claim teaser - -
- -
- ( - - Public profile username - - - - - - - -
- {baseUrl.host}/u/{field.value || ''} -
-
- )} - /> -
- -
- -
-
- - - )} - - {claimed && ( - <> - - All set! - - - We will let you know as soon as this features is launched - - - - - -
- -
- - )} -
-
- ); -}; diff --git a/apps/remix/app/components/forms/signup.tsx b/apps/remix/app/components/forms/signup.tsx index 6f9388a0f..b26f56742 100644 --- a/apps/remix/app/components/forms/signup.tsx +++ b/apps/remix/app/components/forms/signup.tsx @@ -30,7 +30,7 @@ import { } from '@documenso/ui/primitives/form/form'; import { Input } from '@documenso/ui/primitives/input'; import { PasswordInput } from '@documenso/ui/primitives/password-input'; -import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; +import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { UserProfileSkeleton } from '~/components/general/user-profile-skeleton'; @@ -353,16 +353,15 @@ export const SignUpForm = ({ ( + render={({ field: { onChange, value } }) => ( Sign Here - onChange(v ?? '')} /> @@ -531,6 +530,27 @@ export const SignUpForm = ({
+

+ + By proceeding, you agree to our{' '} + + Terms of Service + {' '} + and{' '} + + Privacy Policy + + . + +

); diff --git a/apps/remix/app/components/forms/team-branding-preferences-form.tsx b/apps/remix/app/components/forms/team-branding-preferences-form.tsx index f33345d3b..5cc519960 100644 --- a/apps/remix/app/components/forms/team-branding-preferences-form.tsx +++ b/apps/remix/app/components/forms/team-branding-preferences-form.tsx @@ -308,7 +308,7 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref
diff --git a/apps/remix/app/components/forms/team-document-preferences-form.tsx b/apps/remix/app/components/forms/team-document-preferences-form.tsx index 98701b36b..2b4846116 100644 --- a/apps/remix/app/components/forms/team-document-preferences-form.tsx +++ b/apps/remix/app/components/forms/team-document-preferences-form.tsx @@ -8,12 +8,15 @@ import { useForm } from 'react-hook-form'; import { z } from 'zod'; import { useSession } from '@documenso/lib/client-only/providers/session'; +import { DOCUMENT_SIGNATURE_TYPES, DocumentSignatureType } from '@documenso/lib/constants/document'; import { SUPPORTED_LANGUAGES, SUPPORTED_LANGUAGE_CODES, isValidLanguageCode, } from '@documenso/lib/constants/i18n'; +import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams'; import { trpc } from '@documenso/trpc/react'; +import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip'; import { Alert } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -23,7 +26,9 @@ import { FormField, FormItem, FormLabel, + FormMessage, } from '@documenso/ui/primitives/form/form'; +import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox'; import { Select, SelectContent, @@ -38,8 +43,10 @@ const ZTeamDocumentPreferencesFormSchema = z.object({ documentVisibility: z.nativeEnum(DocumentVisibility), documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES), includeSenderDetails: z.boolean(), - typedSignatureEnabled: z.boolean(), includeSigningCertificate: z.boolean(), + signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, { + message: msg`At least one signature type must be enabled`.id, + }), }); type TTeamDocumentPreferencesFormSchema = z.infer; @@ -69,8 +76,8 @@ export const TeamDocumentPreferencesForm = ({ ? settings?.documentLanguage : 'en', includeSenderDetails: settings?.includeSenderDetails ?? false, - typedSignatureEnabled: settings?.typedSignatureEnabled ?? true, includeSigningCertificate: settings?.includeSigningCertificate ?? true, + signatureTypes: extractTeamSignatureSettings(settings), }, resolver: zodResolver(ZTeamDocumentPreferencesFormSchema), }); @@ -84,7 +91,7 @@ export const TeamDocumentPreferencesForm = ({ documentLanguage, includeSenderDetails, includeSigningCertificate, - typedSignatureEnabled, + signatureTypes, } = data; await updateTeamDocumentPreferences({ @@ -93,8 +100,10 @@ export const TeamDocumentPreferencesForm = ({ documentVisibility, documentLanguage, includeSenderDetails, - typedSignatureEnabled, includeSigningCertificate, + typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE), + uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD), + drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW), }, }); @@ -190,6 +199,44 @@ export const TeamDocumentPreferencesForm = ({ )} /> + ( + + + Default Signature Settings + + + + + ({ + label: _(option.label), + value: option.value, + }))} + selectedValues={field.value} + onChange={field.onChange} + className="bg-background w-full" + enableSearch={false} + emptySelectionPlaceholder="Select signature types" + testId="signature-types-combobox" + /> + + + {form.formState.errors.signatureTypes ? ( + + ) : ( + + + Controls which signatures are allowed to be used when signing a document. + + + )} + + )} + /> + - ( - - - Enable Typed Signature - - -
- - - -
- - - - Controls whether the recipients can sign the documents using a typed signature. - Enable or disable the typed signature globally. - - -
- )} - /> - diff --git a/apps/remix/app/components/forms/token.tsx b/apps/remix/app/components/forms/token.tsx index 41ce41bf3..d7bec0cba 100644 --- a/apps/remix/app/components/forms/token.tsx +++ b/apps/remix/app/components/forms/token.tsx @@ -1,4 +1,4 @@ -import { useState, useTransition } from 'react'; +import { useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import { msg } from '@lingui/core/macro'; @@ -38,6 +38,8 @@ import { import { Switch } from '@documenso/ui/primitives/switch'; import { useToast } from '@documenso/ui/primitives/use-toast'; +import { useOptionalCurrentTeam } from '~/providers/team'; + export const EXPIRATION_DATES = { ONE_WEEK: msg`7 days`, ONE_MONTH: msg`1 month`, @@ -59,15 +61,14 @@ type NewlyCreatedToken = { export type ApiTokenFormProps = { className?: string; - teamId?: number; tokens?: Pick[]; }; -export const ApiTokenForm = ({ className, teamId, tokens }: ApiTokenFormProps) => { - const [isTransitionPending, startTransition] = useTransition(); - +export const ApiTokenForm = ({ className, tokens }: ApiTokenFormProps) => { const [, copy] = useCopyToClipboard(); + const team = useOptionalCurrentTeam(); + const { _ } = useLingui(); const { toast } = useToast(); @@ -113,7 +114,7 @@ export const ApiTokenForm = ({ className, teamId, tokens }: ApiTokenFormProps) = const onSubmit = async ({ tokenName, expirationDate }: TCreateTokenMutationSchema) => { try { await createTokenMutation({ - teamId, + teamId: team?.id, tokenName, expirationDate: noExpirationDate ? null : expirationDate, }); @@ -238,7 +239,7 @@ export const ApiTokenForm = ({ className, teamId, tokens }: ApiTokenFormProps) = type="submit" className="hidden md:inline-flex" disabled={!form.formState.isDirty} - loading={form.formState.isSubmitting || isTransitionPending} + loading={form.formState.isSubmitting} > Create token @@ -247,7 +248,7 @@ export const ApiTokenForm = ({ className, teamId, tokens }: ApiTokenFormProps) = diff --git a/apps/remix/app/components/general/app-nav-desktop.tsx b/apps/remix/app/components/general/app-nav-desktop.tsx index eaa0c0bf5..631fff212 100644 --- a/apps/remix/app/components/general/app-nav-desktop.tsx +++ b/apps/remix/app/components/general/app-nav-desktop.tsx @@ -76,7 +76,7 @@ export const AppNavDesktop = ({ + + + ))} + + + + ); +}; diff --git a/apps/remix/app/components/general/billing-portal-button.tsx b/apps/remix/app/components/general/billing-portal-button.tsx new file mode 100644 index 000000000..ea8735954 --- /dev/null +++ b/apps/remix/app/components/general/billing-portal-button.tsx @@ -0,0 +1,48 @@ +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; + +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type BillingPortalButtonProps = { + buttonProps?: React.ComponentProps; + children?: React.ReactNode; +}; + +export const BillingPortalButton = ({ buttonProps, children }: BillingPortalButtonProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const { mutateAsync: createBillingPortal, isPending } = + trpc.profile.createBillingPortal.useMutation({ + onSuccess: (sessionUrl) => { + window.open(sessionUrl, '_blank'); + }, + onError: (err) => { + let description = _( + msg`We are unable to proceed to the billing portal at this time. Please try again, or contact support.`, + ); + + if (err.message === 'CUSTOMER_NOT_FOUND') { + description = _( + msg`You do not currently have a customer record, this should not happen. Please contact support for assistance.`, + ); + } + + toast({ + title: _(msg`Something went wrong`), + description, + variant: 'destructive', + duration: 10000, + }); + }, + }); + + return ( + + ); +}; diff --git a/apps/remix/app/components/general/direct-template/direct-template-page.tsx b/apps/remix/app/components/general/direct-template/direct-template-page.tsx index 4751cb780..a567f0cda 100644 --- a/apps/remix/app/components/general/direct-template/direct-template-page.tsx +++ b/apps/remix/app/components/general/direct-template/direct-template-page.tsx @@ -12,7 +12,7 @@ import { trpc } from '@documenso/trpc/react'; import { Card, CardContent } from '@documenso/ui/primitives/card'; import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root'; import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; -import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; +import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer'; import { Stepper } from '@documenso/ui/primitives/stepper'; import { useToast } from '@documenso/ui/primitives/use-toast'; @@ -136,7 +136,7 @@ export const DirectTemplatePageView = ({ gradient > - setIsDocumentPdfLoaded(true)} diff --git a/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx b/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx index 5483e051d..943932c27 100644 --- a/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx +++ b/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Trans } from '@lingui/react/macro'; import type { Field, Recipient, Signature } from '@prisma/client'; @@ -24,7 +24,6 @@ import type { } from '@documenso/trpc/server/field-router/schema'; 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 { DocumentFlowFormContainerContent, DocumentFlowFormContainerFooter, @@ -35,7 +34,7 @@ import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/ty import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { Input } from '@documenso/ui/primitives/input'; import { Label } from '@documenso/ui/primitives/label'; -import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; +import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog'; import { useStep } from '@documenso/ui/primitives/stepper'; import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field'; @@ -73,8 +72,7 @@ export const DirectTemplateSigningForm = ({ template, onSubmit, }: DirectTemplateSigningFormProps) => { - const { fullName, signature, signatureValid, setFullName, setSignature } = - useRequiredDocumentSigningContext(); + const { fullName, signature, setFullName, setSignature } = useRequiredDocumentSigningContext(); const [localFields, setLocalFields] = useState(directRecipientFields); const [validateUninsertedFields, setValidateUninsertedFields] = useState(false); @@ -91,7 +89,7 @@ export const DirectTemplateSigningForm = ({ const tempField: DirectTemplateLocalField = { ...field, - customText: value.value, + customText: value.value ?? '', inserted: true, signedValue: value, }; @@ -102,8 +100,8 @@ export const DirectTemplateSigningForm = ({ created: new Date(), recipientId: 1, fieldId: 1, - signatureImageAsBase64: value.value.startsWith('data:') ? value.value : null, - typedSignature: value.value.startsWith('data:') ? null : value.value, + signatureImageAsBase64: value.value?.startsWith('data:') ? value.value : null, + typedSignature: value.value && !value.value.startsWith('data:') ? value.value : null, } satisfies Signature; } @@ -135,8 +133,6 @@ export const DirectTemplateSigningForm = ({ ); }; - const hasSignatureField = localFields.some((field) => field.type === FieldType.SIGNATURE); - const uninsertedFields = useMemo(() => { return sortFieldsByPosition(localFields.filter((field) => !field.inserted)); }, [localFields]); @@ -149,10 +145,6 @@ export const DirectTemplateSigningForm = ({ const handleSubmit = async () => { setValidateUninsertedFields(true); - if (hasSignatureField && !signatureValid) { - return; - } - const isFieldsValid = validateFieldsInserted(localFields); if (!isFieldsValid) { @@ -170,6 +162,55 @@ export const DirectTemplateSigningForm = ({ // Do not reset to false since we do a redirect. }; + useEffect(() => { + const updatedFields = [...localFields]; + + localFields.forEach((field) => { + const index = updatedFields.findIndex((f) => f.id === field.id); + let value = ''; + + match(field.type) + .with(FieldType.TEXT, () => { + const meta = field.fieldMeta ? ZTextFieldMeta.safeParse(field.fieldMeta) : null; + + if (meta?.success) { + value = meta.data.text ?? ''; + } + }) + .with(FieldType.NUMBER, () => { + const meta = field.fieldMeta ? ZNumberFieldMeta.safeParse(field.fieldMeta) : null; + + if (meta?.success) { + value = meta.data.value ?? ''; + } + }) + .with(FieldType.DROPDOWN, () => { + const meta = field.fieldMeta ? ZDropdownFieldMeta.safeParse(field.fieldMeta) : null; + + if (meta?.success) { + value = meta.data.defaultValue ?? ''; + } + }); + + if (value) { + const signedValue = { + token: directRecipient.token, + fieldId: field.id, + value, + }; + + updatedFields[index] = { + ...field, + customText: value, + inserted: true, + signedValue, + }; + } + }); + + setLocalFields(updatedFields); + }, []); + return ( @@ -191,6 +232,8 @@ export const DirectTemplateSigningForm = ({ onSignField={onSignField} onUnsignField={onUnsignField} typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled} + uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled} + drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled} /> )) .with(FieldType.INITIALS, () => ( @@ -335,19 +378,15 @@ export const DirectTemplateSigningForm = ({ Signature - - - { - setSignature(value); - }} - allowTypedSignature={template.templateMeta?.typedSignatureEnabled} - /> - - + setSignature(value)} + typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled} + uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled} + drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled} + /> diff --git a/apps/remix/app/components/general/document-signing/document-signing-checkbox-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-checkbox-field.tsx index 6ba6b8fef..dcd8edc1c 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-checkbox-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-checkbox-field.tsx @@ -45,7 +45,12 @@ export const DocumentSigningCheckboxField = ({ const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext(); - const parsedFieldMeta = ZCheckboxFieldMeta.parse(field.fieldMeta); + const parsedFieldMeta = ZCheckboxFieldMeta.parse( + field.fieldMeta ?? { + type: 'checkbox', + values: [{ id: 1, checked: false, value: '' }], + }, + ); const values = parsedFieldMeta.values?.map((item) => ({ ...item, @@ -92,6 +97,10 @@ export const DocumentSigningCheckboxField = ({ const onSign = async (authOptions?: TRecipientActionAuth) => { try { + if (!isLengthConditionMet) { + return; + } + const payload: TSignFieldWithTokenMutationSchema = { token: recipient.token, fieldId: field.id, @@ -189,18 +198,30 @@ export const DocumentSigningCheckboxField = ({ setCheckedValues(updatedValues); - await removeSignedFieldWithToken({ + const removePayload: TRemovedSignedFieldWithTokenMutationSchema = { token: recipient.token, fieldId: field.id, - }); + }; - if (updatedValues.length > 0) { - await signFieldWithToken({ + if (onUnsignField) { + await onUnsignField(removePayload); + } else { + await removeSignedFieldWithToken(removePayload); + } + + if (updatedValues.length > 0 && shouldAutoSignField) { + const signPayload: TSignFieldWithTokenMutationSchema = { token: recipient.token, fieldId: field.id, value: toCheckboxValue(updatedValues), isBase64: true, - }); + }; + + if (onSignField) { + await onSignField(signPayload); + } else { + await signFieldWithToken(signPayload); + } } } catch (err) { console.error(err); diff --git a/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx b/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx index dbc655a0d..ee0350576 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx @@ -3,6 +3,7 @@ import { useMemo, useState } from 'react'; import { Trans } from '@lingui/react/macro'; import type { Field } from '@prisma/client'; import { RecipientRole } from '@prisma/client'; +import { match } from 'ts-pattern'; import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers'; import { Button } from '@documenso/ui/primitives/button'; @@ -58,62 +59,88 @@ export const DocumentSigningCompleteDialog = ({ loading={isSubmitting} disabled={disabled} > - {isComplete ? Complete : Next field} + {match({ isComplete, role }) + .with({ isComplete: false }, () => Next field) + .with({ isComplete: true, role: RecipientRole.APPROVER }, () => Approve) + .with({ isComplete: true, role: RecipientRole.VIEWER }, () => ( + Mark as viewed + )) + .with({ isComplete: true }, () => Complete) + .exhaustive()}
- {role === RecipientRole.VIEWER && Complete Viewing} - {role === RecipientRole.SIGNER && Complete Signing} - {role === RecipientRole.APPROVER && Complete Approval} + {match(role) + .with(RecipientRole.VIEWER, () => Complete Viewing) + .with(RecipientRole.SIGNER, () => Complete Signing) + .with(RecipientRole.APPROVER, () => Complete Approval) + .with(RecipientRole.CC, () => Complete Viewing) + .with(RecipientRole.ASSISTANT, () => Complete Assisting) + .exhaustive()}
- {role === RecipientRole.VIEWER && ( - - - - You are about to complete viewing " - - {documentTitle} + {match(role) + .with(RecipientRole.VIEWER, () => ( + + + + You are about to complete viewing " + + {documentTitle} + + ". - ". - -
Are you sure? -
-
- )} - {role === RecipientRole.SIGNER && ( - - - - You are about to complete signing " - - {documentTitle} +
Are you sure? +
+
+ )) + .with(RecipientRole.SIGNER, () => ( + + + + You are about to complete signing " + + {documentTitle} + + ". - ". - -
Are you sure? - - - )} - {role === RecipientRole.APPROVER && ( - - - - You are about to complete approving{' '} - - "{documentTitle}" +
Are you sure? +
+
+ )) + .with(RecipientRole.APPROVER, () => ( + + + + You are about to complete approving{' '} + + "{documentTitle}" + + . - . - -
Are you sure? - - - )} +
Are you sure? + + + )) + .otherwise(() => ( + + + + You are about to complete viewing " + + {documentTitle} + + ". + +
Are you sure? +
+
+ ))}
@@ -138,9 +165,13 @@ export const DocumentSigningCompleteDialog = ({ loading={isSubmitting} onClick={onSignatureComplete} > - {role === RecipientRole.VIEWER && Mark as Viewed} - {role === RecipientRole.SIGNER && Sign} - {role === RecipientRole.APPROVER && Approve} + {match(role) + .with(RecipientRole.VIEWER, () => Mark as Viewed) + .with(RecipientRole.SIGNER, () => Sign) + .with(RecipientRole.APPROVER, () => Approve) + .with(RecipientRole.CC, () => Mark as Viewed) + .with(RecipientRole.ASSISTANT, () => Complete) + .exhaustive()} diff --git a/apps/remix/app/components/general/document-signing/document-signing-field-container.tsx b/apps/remix/app/components/general/document-signing/document-signing-field-container.tsx index 148117b55..14fe95c44 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-field-container.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-field-container.tsx @@ -181,6 +181,23 @@ export const DocumentSigningFieldContainer = ({ )} + {(field.type === FieldType.RADIO || field.type === FieldType.CHECKBOX) && + field.fieldMeta?.label && ( +
+ {field.fieldMeta.label} +
+ )} + {children} diff --git a/apps/remix/app/components/general/document-signing/document-signing-form.tsx b/apps/remix/app/components/general/document-signing/document-signing-form.tsx index ef6847c34..81c089399 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-form.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-form.tsx @@ -18,11 +18,10 @@ 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 { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group'; -import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; +import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { AssistantConfirmationDialog } from '../../dialogs/assistant-confirmation-dialog'; @@ -59,8 +58,7 @@ export const DocumentSigningForm = ({ const assistantSignersId = useId(); - const { fullName, signature, setFullName, setSignature, signatureValid, setSignatureValid } = - useRequiredDocumentSigningContext(); + const { fullName, signature, setFullName, setSignature } = useRequiredDocumentSigningContext(); const [validateUninsertedFields, setValidateUninsertedFields] = useState(false); const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false); @@ -105,10 +103,6 @@ export const DocumentSigningForm = ({ const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation); - if (hasSignatureField && !signatureValid) { - return; - } - if (!isFieldsValid) { return; } @@ -313,7 +307,11 @@ export const DocumentSigningForm = ({ <>

- Please review the document before signing. + {recipient.role === RecipientRole.APPROVER && !hasSignatureField ? ( + Please review the document before approving. + ) : ( + Please review the document before signing. + )}


@@ -337,38 +335,23 @@ export const DocumentSigningForm = ({ /> -
- + {hasSignatureField && ( +
+ - - - { - setSignatureValid(isValid); - }} - onChange={(value) => { - if (signatureValid) { - setSignature(value); - } - }} - allowTypedSignature={document.documentMeta?.typedSignatureEnabled} - /> - - - - {hasSignatureField && !signatureValid && ( -
- - Signature is too small. Please provide a more complete signature. - -
- )} -
+ setSignature(v ?? '')} + typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled} + uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled} + drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled} + /> +
+ )}
diff --git a/apps/remix/app/components/general/document-signing/document-signing-page-view.tsx b/apps/remix/app/components/general/document-signing/document-signing-page-view.tsx index 22787592d..9d8fb1b2c 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-page-view.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-page-view.tsx @@ -21,7 +21,7 @@ import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/fie import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields'; 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 { PDFViewer } from '@documenso/ui/primitives/pdf-viewer'; import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign'; import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field'; @@ -140,12 +140,7 @@ export const DocumentSigningPageView = ({ gradient > - + @@ -182,6 +177,8 @@ export const DocumentSigningPageView = ({ key={field.id} field={field} typedSignatureEnabled={documentMeta?.typedSignatureEnabled} + uploadSignatureEnabled={documentMeta?.uploadSignatureEnabled} + drawSignatureEnabled={documentMeta?.drawSignatureEnabled} /> )) .with(FieldType.INITIALS, () => ( diff --git a/apps/remix/app/components/general/document-signing/document-signing-provider.tsx b/apps/remix/app/components/general/document-signing/document-signing-provider.tsx index ca231949d..9d704f591 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-provider.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-provider.tsx @@ -1,4 +1,6 @@ -import { createContext, useContext, useEffect, useState } from 'react'; +import { createContext, useContext, useState } from 'react'; + +import { isBase64Image } from '@documenso/lib/constants/signatures'; export type DocumentSigningContextValue = { fullName: string; @@ -7,8 +9,6 @@ export type DocumentSigningContextValue = { setEmail: (_value: string) => void; signature: string | null; setSignature: (_value: string | null) => void; - signatureValid: boolean; - setSignatureValid: (_valid: boolean) => void; }; const DocumentSigningContext = createContext(null); @@ -31,6 +31,9 @@ export interface DocumentSigningProviderProps { fullName?: string | null; email?: string | null; signature?: string | null; + typedSignatureEnabled?: boolean; + uploadSignatureEnabled?: boolean; + drawSignatureEnabled?: boolean; children: React.ReactNode; } @@ -38,18 +41,31 @@ export const DocumentSigningProvider = ({ fullName: initialFullName, email: initialEmail, signature: initialSignature, + typedSignatureEnabled = true, + uploadSignatureEnabled = true, + drawSignatureEnabled = true, children, }: DocumentSigningProviderProps) => { const [fullName, setFullName] = useState(initialFullName || ''); const [email, setEmail] = useState(initialEmail || ''); - const [signature, setSignature] = useState(initialSignature || null); - const [signatureValid, setSignatureValid] = useState(true); - useEffect(() => { - if (initialSignature) { - setSignature(initialSignature); - } - }, [initialSignature]); + // Ensure the user signature doesn't show up if it's not allowed. + const [signature, setSignature] = useState( + (() => { + const sig = initialSignature || ''; + const isBase64 = isBase64Image(sig); + + if (isBase64 && (uploadSignatureEnabled || drawSignatureEnabled)) { + return sig; + } + + if (!isBase64 && typedSignatureEnabled) { + return sig; + } + + return null; + })(), + ); return ( {children} diff --git a/apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx b/apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx index 1160f89b8..6fd88824a 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx @@ -42,9 +42,14 @@ type TRejectDocumentFormSchema = z.infer; export interface DocumentSigningRejectDialogProps { document: Pick; token: string; + onRejected?: (reason: string) => void | Promise; } -export function DocumentSigningRejectDialog({ document, token }: DocumentSigningRejectDialogProps) { +export function DocumentSigningRejectDialog({ + document, + token, + onRejected, +}: DocumentSigningRejectDialogProps) { const { toast } = useToast(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); @@ -75,9 +80,13 @@ export function DocumentSigningRejectDialog({ document, token }: DocumentSigning duration: 5000, }); - await navigate(`/sign/${token}/rejected`); - setIsOpen(false); + + if (onRejected) { + await onRejected(reason); + } else { + await navigate(`/sign/${token}/rejected`); + } } catch (err) { toast({ title: 'Error', diff --git a/apps/remix/app/components/general/document-signing/document-signing-signature-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-signature-field.tsx index 1b1f92dbd..381658ab3 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-signature-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-signature-field.tsx @@ -17,7 +17,6 @@ import type { } from '@documenso/trpc/server/field-router/schema'; import { Button } from '@documenso/ui/primitives/button'; import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog'; -import { Label } from '@documenso/ui/primitives/label'; import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; import { useToast } from '@documenso/ui/primitives/use-toast'; @@ -29,11 +28,14 @@ import { useRequiredDocumentSigningContext } from './document-signing-provider'; import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider'; type SignatureFieldState = 'empty' | 'signed-image' | 'signed-text'; + export type DocumentSigningSignatureFieldProps = { field: FieldWithSignature; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; typedSignatureEnabled?: boolean; + uploadSignatureEnabled?: boolean; + drawSignatureEnabled?: boolean; }; export const DocumentSigningSignatureField = ({ @@ -41,6 +43,8 @@ export const DocumentSigningSignatureField = ({ onSignField, onUnsignField, typedSignatureEnabled, + uploadSignatureEnabled, + drawSignatureEnabled, }: DocumentSigningSignatureFieldProps) => { const { _ } = useLingui(); const { toast } = useToast(); @@ -52,12 +56,8 @@ export const DocumentSigningSignatureField = ({ const containerRef = useRef(null); const [fontSize, setFontSize] = useState(2); - const { - signature: providedSignature, - setSignature: setProvidedSignature, - signatureValid, - setSignatureValid, - } = useRequiredDocumentSigningContext(); + const { signature: providedSignature, setSignature: setProvidedSignature } = + useRequiredDocumentSigningContext(); const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext(); @@ -89,7 +89,7 @@ export const DocumentSigningSignatureField = ({ }, [field.inserted, signature?.signatureImageAsBase64]); const onPreSign = () => { - if (!providedSignature || !signatureValid) { + if (!providedSignature) { setShowSignatureModal(true); return false; } @@ -102,6 +102,7 @@ export const DocumentSigningSignatureField = ({ const onDialogSignClick = () => { setShowSignatureModal(false); setProvidedSignature(localSignature); + if (!localSignature) { return; } @@ -116,14 +117,14 @@ export const DocumentSigningSignatureField = ({ try { const value = signature || providedSignature; - if (!value || (signature && !signatureValid)) { + if (!value) { setShowSignatureModal(true); return; } const isTypedSignature = !value.startsWith('data:image'); - if (isTypedSignature && !typedSignatureEnabled) { + if (isTypedSignature && typedSignatureEnabled === false) { toast({ title: _(msg`Error`), description: _(msg`Typed signatures are not allowed. Please draw your signature.`), @@ -275,29 +276,14 @@ export const DocumentSigningSignatureField = ({ -
- - -
- setLocalSignature(value)} - allowTypedSignature={typedSignatureEnabled} - onValidityChange={(isValid) => { - setSignatureValid(isValid); - }} - /> -
- - {!signatureValid && ( -
- Signature is too small. Please provide a more complete signature. -
- )} -
+ setLocalSignature(value)} + typedSignatureEnabled={typedSignatureEnabled} + uploadSignatureEnabled={uploadSignatureEnabled} + drawSignatureEnabled={drawSignatureEnabled} + /> @@ -317,7 +303,7 @@ export const DocumentSigningSignatureField = ({

- {document.status !== DocumentStatus.COMPLETED && ( + {!isDocumentCompleted(document.status) && ( icon: File, color: 'text-yellow-500 dark:text-yellow-200', }, + REJECTED: { + label: msg`Rejected`, + labelExtended: msg`Document rejected`, + icon: XCircle, + color: 'text-red-500 dark:text-red-300', + }, INBOX: { label: msg`Inbox`, labelExtended: msg`Document inbox`, diff --git a/apps/remix/app/components/general/document/document-upload.tsx b/apps/remix/app/components/general/document/document-upload.tsx index d54c05c8c..741b6da02 100644 --- a/apps/remix/app/components/general/document/document-upload.tsx +++ b/apps/remix/app/components/general/document/document-upload.tsx @@ -85,7 +85,7 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp timestamp: new Date().toISOString(), }); - void navigate(`${formatDocumentsPath(team?.url)}/${id}/edit`); + await navigate(`${formatDocumentsPath(team?.url)}/${id}/edit`); } catch (err) { const error = AppError.parseError(err); diff --git a/apps/remix/app/components/general/generic-error-layout.tsx b/apps/remix/app/components/general/generic-error-layout.tsx index 792c8f2f9..77b1eb04f 100644 --- a/apps/remix/app/components/general/generic-error-layout.tsx +++ b/apps/remix/app/components/general/generic-error-layout.tsx @@ -38,7 +38,7 @@ export type GenericErrorLayoutProps = { export const defaultErrorCodeMap: ErrorCodeMap = { 404: { - subHeading: msg`404 Page not found`, + subHeading: msg`404 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.`, }, @@ -62,7 +62,7 @@ export const GenericErrorLayout = ({ const team = useOptionalCurrentTeam(); const { subHeading, heading, message } = - errorCodeMap[errorCode || 404] ?? defaultErrorCodeMap[500]; + errorCodeMap[errorCode || 500] ?? defaultErrorCodeMap[500]; return (
diff --git a/apps/remix/app/components/general/refresh-on-focus.tsx b/apps/remix/app/components/general/refresh-on-focus.tsx index 775b722f3..bf8f7a68a 100644 --- a/apps/remix/app/components/general/refresh-on-focus.tsx +++ b/apps/remix/app/components/general/refresh-on-focus.tsx @@ -2,11 +2,16 @@ import { useCallback, useEffect } from 'react'; import { useRevalidator } from 'react-router'; +/** + * Not really used anymore, this causes random 500s when the user refreshes while this occurs. + */ export const RefreshOnFocus = () => { - const { revalidate } = useRevalidator(); + const { revalidate, state } = useRevalidator(); const onFocus = useCallback(() => { - void revalidate(); + if (state === 'idle') { + void revalidate(); + } }, [revalidate]); useEffect(() => { diff --git a/apps/remix/app/components/general/settings-header.tsx b/apps/remix/app/components/general/settings-header.tsx index 6f5ae28bc..06de2ff24 100644 --- a/apps/remix/app/components/general/settings-header.tsx +++ b/apps/remix/app/components/general/settings-header.tsx @@ -3,8 +3,8 @@ import React from 'react'; import { cn } from '@documenso/ui/lib/utils'; export type SettingsHeaderProps = { - title: string; - subtitle: string; + title: string | React.ReactNode; + subtitle: string | React.ReactNode; hideDivider?: boolean; children?: React.ReactNode; className?: string; diff --git a/apps/remix/app/routes/_authenticated+/settings+/teams+/team-email-usage.tsx b/apps/remix/app/components/general/teams/team-email-usage.tsx similarity index 100% rename from apps/remix/app/routes/_authenticated+/settings+/teams+/team-email-usage.tsx rename to apps/remix/app/components/general/teams/team-email-usage.tsx diff --git a/apps/remix/app/routes/_authenticated+/settings+/teams+/team-invitations.tsx b/apps/remix/app/components/general/teams/team-invitations.tsx similarity index 100% rename from apps/remix/app/routes/_authenticated+/settings+/teams+/team-invitations.tsx rename to apps/remix/app/components/general/teams/team-invitations.tsx diff --git a/apps/remix/app/components/general/template/template-edit-form.tsx b/apps/remix/app/components/general/template/template-edit-form.tsx index 972114435..9e508d915 100644 --- a/apps/remix/app/components/general/template/template-edit-form.tsx +++ b/apps/remix/app/components/general/template/template-edit-form.tsx @@ -4,6 +4,7 @@ import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { useNavigate } from 'react-router'; +import { DocumentSignatureType } from '@documenso/lib/constants/document'; import { isValidLanguageCode } from '@documenso/lib/constants/i18n'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, @@ -15,7 +16,7 @@ import { cn } from '@documenso/ui/lib/utils'; import { Card, CardContent } from '@documenso/ui/primitives/card'; import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root'; import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; -import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; +import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer'; import { Stepper } from '@documenso/ui/primitives/stepper'; import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-fields'; import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types'; @@ -124,6 +125,8 @@ export const TemplateEditForm = ({ }); const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => { + const { signatureTypes } = data.meta; + try { await updateTemplateSettings({ templateId: template.id, @@ -136,6 +139,9 @@ export const TemplateEditForm = ({ }, meta: { ...data.meta, + typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE), + uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD), + drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW), language: isValidLanguageCode(data.meta.language) ? data.meta.language : undefined, }, }); @@ -187,13 +193,6 @@ export const TemplateEditForm = ({ fields: data.fields, }); - await updateTemplateSettings({ - templateId: template.id, - meta: { - typedSignatureEnabled: data.typedSignatureEnabled, - }, - }); - // Clear all field data from localStorage for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); @@ -236,7 +235,7 @@ export const TemplateEditForm = ({ gradient > - setIsDocumentPdfLoaded(true)} @@ -284,7 +283,6 @@ export const TemplateEditForm = ({ fields={fields} onSubmit={onAddFieldsFormSubmit} teamId={team?.id} - typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled} /> diff --git a/apps/remix/app/components/tables/documents-table-action-button.tsx b/apps/remix/app/components/tables/documents-table-action-button.tsx index c2fd7cfb4..97230c359 100644 --- a/apps/remix/app/components/tables/documents-table-action-button.tsx +++ b/apps/remix/app/components/tables/documents-table-action-button.tsx @@ -9,6 +9,7 @@ import { match } from 'ts-pattern'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import { useSession } from '@documenso/lib/client-only/providers/session'; +import { isDocumentCompleted } from '@documenso/lib/utils/document'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { trpc as trpcClient } from '@documenso/trpc/client'; import { Button } from '@documenso/ui/primitives/button'; @@ -37,7 +38,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr const isRecipient = !!recipient; const isDraft = row.status === DocumentStatus.DRAFT; const isPending = row.status === DocumentStatus.PENDING; - const isComplete = row.status === DocumentStatus.COMPLETED; + const isComplete = isDocumentCompleted(row.status); const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; const role = recipient?.role; const isCurrentTeamDocument = team && row.team?.url === team.url; diff --git a/apps/remix/app/components/tables/documents-table-action-dropdown.tsx b/apps/remix/app/components/tables/documents-table-action-dropdown.tsx index c4a6ccd3b..ea0f11fc1 100644 --- a/apps/remix/app/components/tables/documents-table-action-dropdown.tsx +++ b/apps/remix/app/components/tables/documents-table-action-dropdown.tsx @@ -22,6 +22,7 @@ import { Link } from 'react-router'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import { useSession } from '@documenso/lib/client-only/providers/session'; +import { isDocumentCompleted } from '@documenso/lib/utils/document'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { trpc as trpcClient } from '@documenso/trpc/client'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; @@ -66,7 +67,7 @@ export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdo // const isRecipient = !!recipient; const isDraft = row.status === DocumentStatus.DRAFT; const isPending = row.status === DocumentStatus.PENDING; - const isComplete = row.status === DocumentStatus.COMPLETED; + const isComplete = isDocumentCompleted(row.status); // const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; const isCurrentTeamDocument = team && row.team?.url === team.url; const canManageDocument = Boolean(isOwner || isCurrentTeamDocument); diff --git a/apps/remix/app/components/tables/documents-table.tsx b/apps/remix/app/components/tables/documents-table.tsx index f4f5b3cb5..4f6dc0050 100644 --- a/apps/remix/app/components/tables/documents-table.tsx +++ b/apps/remix/app/components/tables/documents-table.tsx @@ -9,8 +9,8 @@ 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 { isDocumentCompleted } from '@documenso/lib/utils/document'; 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'; @@ -77,7 +77,7 @@ export const DocumentsTable = ({ data, isLoading, isLoadingError }: DocumentsTab { header: _(msg`Actions`), cell: ({ row }) => - (!row.original.deletedAt || row.original.status === ExtendedDocumentStatus.COMPLETED) && ( + (!row.original.deletedAt || isDocumentCompleted(row.original.status)) && (
diff --git a/apps/remix/app/entry.client.tsx b/apps/remix/app/entry.client.tsx index 42996b5ec..4b5769ff5 100644 --- a/apps/remix/app/entry.client.tsx +++ b/apps/remix/app/entry.client.tsx @@ -1,21 +1,31 @@ -import { StrictMode, startTransition } from 'react'; +import { StrictMode, startTransition, useEffect } from 'react'; import { i18n } from '@lingui/core'; import { detect, fromHtmlTag } from '@lingui/detect-locale'; import { I18nProvider } from '@lingui/react'; +import posthog from 'posthog-js'; import { hydrateRoot } from 'react-dom/client'; import { HydratedRouter } from 'react-router/dom'; -import { Theme, ThemeProvider } from 'remix-themes'; -import { match } from 'ts-pattern'; +import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags'; import { dynamicActivate } from '@documenso/lib/utils/i18n'; -async function main() { - const theme = match(document.documentElement.getAttribute('data-theme')) - .with('dark', () => Theme.DARK) - .with('light', () => Theme.LIGHT) - .otherwise(() => null); +function PosthogInit() { + const postHogConfig = extractPostHogConfig(); + useEffect(() => { + if (postHogConfig) { + posthog.init(postHogConfig.key, { + api_host: postHogConfig.host, + capture_exceptions: true, + }); + } + }, []); + + return null; +} + +async function main() { const locale = detect(fromHtmlTag('lang')) || 'en'; await dynamicActivate(locale); @@ -25,10 +35,10 @@ async function main() { document, - - - + + + , ); }); diff --git a/apps/remix/app/entry.server.tsx b/apps/remix/app/entry.server.tsx index 6250bff85..6caeba746 100644 --- a/apps/remix/app/entry.server.tsx +++ b/apps/remix/app/entry.server.tsx @@ -7,13 +7,11 @@ 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 { ThemeProvider } from 'remix-themes'; import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n'; import { dynamicActivate, extractLocaleData } from '@documenso/lib/utils/i18n'; import { langCookie } from './storage/lang-cookie.server'; -import { themeSessionResolver } from './storage/theme-session.server'; export const streamTimeout = 5_000; @@ -32,10 +30,6 @@ export default async function handleRequest( await dynamicActivate(language); - const { getTheme } = await themeSessionResolver(request); - - const theme = getTheme(); - return new Promise((resolve, reject) => { let shellRendered = false; const userAgent = request.headers.get('user-agent'); @@ -47,9 +41,7 @@ export default async function handleRequest( const { pipe, abort } = renderToPipeableStream( - - - + , { [readyOption]() { diff --git a/apps/remix/app/providers/posthog.tsx b/apps/remix/app/providers/posthog.tsx deleted file mode 100644 index 1da9dbf57..000000000 --- a/apps/remix/app/providers/posthog.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { useEffect } from 'react'; - -import posthog from 'posthog-js'; -import { useLocation, useSearchParams } from 'react-router'; - -import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags'; - -export function PostHogPageview() { - const postHogConfig = extractPostHogConfig(); - - const { pathname } = useLocation(); - const [searchParams] = useSearchParams(); - - // const { sessionData } = useOptionalSession(); - // const user = sessionData?.user; - - if (typeof window !== 'undefined' && postHogConfig) { - posthog.init(postHogConfig.key, { - api_host: postHogConfig.host, - disable_session_recording: true, - // loaded: () => { - // if (user) { - // posthog.identify(user.email ?? user.id.toString()); - // } else { - // posthog.reset(); - // } - // }, - custom_campaign_params: ['src'], - }); - } - - useEffect(() => { - if (!postHogConfig || !pathname) { - return; - } - - let url = window.origin + pathname; - if (searchParams && searchParams.toString()) { - url = url + `?${searchParams.toString()}`; - } - posthog.capture('$pageview', { - $current_url: url, - }); - }, [pathname, searchParams, postHogConfig]); - - return null; -} diff --git a/apps/remix/app/root.tsx b/apps/remix/app/root.tsx index a741ee00a..c9a453622 100644 --- a/apps/remix/app/root.tsx +++ b/apps/remix/app/root.tsx @@ -1,4 +1,4 @@ -import { Suspense, useEffect } from 'react'; +import { useEffect } from 'react'; import Plausible from 'plausible-tracker'; import { @@ -12,7 +12,7 @@ import { useLoaderData, useLocation, } from 'react-router'; -import { PreventFlashOnWrongTheme, useTheme } from 'remix-themes'; +import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from 'remix-themes'; import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session'; import { SessionProvider } from '@documenso/lib/client-only/providers/session'; @@ -27,8 +27,6 @@ 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'; @@ -106,9 +104,7 @@ export async function loader({ request }: Route.LoaderArgs) { } export function Layout({ children }: { children: React.ReactNode }) { - const { publicEnv, lang, session, ...data } = useLoaderData() || {}; - - const [theme] = useTheme(); + const { theme } = useLoaderData() || {}; const location = useLocation(); @@ -118,6 +114,18 @@ export function Layout({ children }: { children: React.ReactNode }) { } }, [location.pathname]); + return ( + + {children} + + ); +} + +export function LayoutContent({ children }: { children: React.ReactNode }) { + const { publicEnv, session, lang, ...data } = useLoaderData() || {}; + + const [theme] = useTheme(); + return ( @@ -133,10 +141,6 @@ export function Layout({ children }: { children: React.ReactNode }) { - - - - {/* Fix: https://stackoverflow.com/questions/21147149/flash-of-unstyled-content-fouc-in-firefox-only-is-ff-slow-renderer */} @@ -154,8 +158,6 @@ export function Layout({ children }: { children: React.ReactNode }) { - -