diff --git a/.env.example b/.env.example index 7b8872b69..980792eb6 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,10 @@ NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="DEADBEEF" # https://docs.documenso.com/developers/self-hosting/setting-up-oauth-providers#google-oauth-gmail NEXT_PRIVATE_GOOGLE_CLIENT_ID="" NEXT_PRIVATE_GOOGLE_CLIENT_SECRET="" +# Find documentation on setting up Microsoft OAuth here: +# https://docs.documenso.com/developers/self-hosting/setting-up-oauth-providers#microsoft-oauth-azure-ad +NEXT_PRIVATE_MICROSOFT_CLIENT_ID="" +NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET="" NEXT_PRIVATE_OIDC_WELL_KNOWN="" NEXT_PRIVATE_OIDC_CLIENT_ID="" @@ -25,6 +29,10 @@ NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000" # URL used by the web app to request itself (e.g. local background jobs) NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000" +# [[SERVER]] +# OPTIONAL: The port the server will listen on. Defaults to 3000. +PORT=3000 + # [[DATABASE]] NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso" # Defines the URL to use for the database when running migrations and other commands that won't work with a connection pool. diff --git a/.gitignore b/.gitignore index f31f951a7..9e622a76f 100644 --- a/.gitignore +++ b/.gitignore @@ -56,4 +56,7 @@ logs.json # claude .claude -CLAUDE.md \ No newline at end of file +CLAUDE.md + +# agents +.specs diff --git a/apps/documentation/pages/developers/self-hosting/setting-up-oauth-providers.mdx b/apps/documentation/pages/developers/self-hosting/setting-up-oauth-providers.mdx index 0ba359142..efebb4092 100644 --- a/apps/documentation/pages/developers/self-hosting/setting-up-oauth-providers.mdx +++ b/apps/documentation/pages/developers/self-hosting/setting-up-oauth-providers.mdx @@ -27,3 +27,33 @@ NEXT_PRIVATE_GOOGLE_CLIENT_SECRET= ``` Finally verify the signing in with Google works by signing in with your Google account and checking the email address in your profile. + +## Microsoft OAuth (Azure AD) + +To use Microsoft OAuth, you will need to create an Azure AD application registration in the Microsoft Azure portal. This will allow users to sign in with their Microsoft accounts. + +### Create and configure a new Azure AD application + +1. Go to the [Azure Portal](https://portal.azure.com/) +2. Navigate to **Azure Active Directory** (or **Microsoft Entra ID** in newer Azure portals) +3. In the left sidebar, click **App registrations** +4. Click **New registration** +5. Enter a name for your application (e.g., "Documenso") +6. Under **Supported account types**, select **Accounts in any organizational directory (Any Azure AD directory - Multitenant) and personal Microsoft accounts (e.g. Skype, Xbox)** to allow any Microsoft account to sign in +7. Under **Redirect URI**, select **Web** and enter: `https:///api/auth/callback/microsoft` +8. Click **Register** + +### Configure the application + +1. After registration, you'll be taken to the app's overview page +2. Copy the **Application (client) ID** - this will be your `NEXT_PRIVATE_MICROSOFT_CLIENT_ID` +3. In the left sidebar, click **Certificates & secrets** +4. Under **Client secrets**, click **New client secret** +5. Add a description and select an expiration period +6. Click **Add** and copy the **Value** (not the Secret ID) - this will be your `NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET` +7. In the Documenso environment variables, set the following: + +``` +NEXT_PRIVATE_MICROSOFT_CLIENT_ID= +NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET= +``` diff --git a/apps/documentation/pages/users/documents/sending-documents.mdx b/apps/documentation/pages/users/documents/sending-documents.mdx index 19d012bc3..bf4d7c394 100644 --- a/apps/documentation/pages/users/documents/sending-documents.mdx +++ b/apps/documentation/pages/users/documents/sending-documents.mdx @@ -18,6 +18,11 @@ The guide assumes you have a Documenso account. If you don't, you can create a f Navigate to the [Documenso dashboard](https://app.documenso.com/documents) and click on the "Add a document" button. Select the document you want to upload and wait for the upload to complete. + + The maximum file size for uploaded documents is 150MB in production. In staging, the limit is + 50MB. + + ![Documenso dashboard](/document-signing/documenso-documents-dashboard.webp) After the upload is complete, you will be redirected to the document's page. You can configure the document's settings and add recipients and fields here. 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 f429b0a54..808d7259d 100644 --- a/apps/openpage-api/lib/growth/get-monthly-completed-document.ts +++ b/apps/openpage-api/lib/growth/get-monthly-completed-document.ts @@ -1,4 +1,4 @@ -import { DocumentStatus } from '@prisma/client'; +import { DocumentStatus, EnvelopeType } from '@prisma/client'; import { DateTime } from 'luxon'; import { kyselyPrisma, sql } from '@documenso/prisma'; @@ -7,18 +7,19 @@ import { addZeroMonth } from '../add-zero-month'; export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative' = 'count') => { const qb = kyselyPrisma.$kysely - .selectFrom('Document') + .selectFrom('Envelope') .select(({ fn }) => [ - fn('DATE_TRUNC', [sql.lit('MONTH'), 'Document.updatedAt']).as('month'), + fn('DATE_TRUNC', [sql.lit('MONTH'), 'Envelope.updatedAt']).as('month'), fn.count('id').as('count'), fn .sum(fn.count('id')) // Feels like a bug in the Kysely extension but I just can not do this orderBy in a type-safe manner // eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any - .over((ob) => ob.orderBy(fn('DATE_TRUNC', [sql.lit('MONTH'), 'Document.updatedAt']) as any)) + .over((ob) => ob.orderBy(fn('DATE_TRUNC', [sql.lit('MONTH'), 'Envelope.updatedAt']) as any)) .as('cume_count'), ]) - .where(() => sql`"Document"."status" = ${DocumentStatus.COMPLETED}::"DocumentStatus"`) + .where(() => sql`"Envelope"."status" = ${DocumentStatus.COMPLETED}::"DocumentStatus"`) + .where(() => sql`"Envelope"."type" = ${EnvelopeType.DOCUMENT}::"EnvelopeType"`) .groupBy('month') .orderBy('month', 'desc') .limit(12); diff --git a/apps/openpage-api/package.json b/apps/openpage-api/package.json index 8e5447cca..3cb30a01d 100644 --- a/apps/openpage-api/package.json +++ b/apps/openpage-api/package.json @@ -5,7 +5,7 @@ "scripts": { "dev": "next dev -p 3003", "build": "next build", - "start": "next start", + "start": "next start -p 3003", "lint:fix": "next lint --fix", "clean": "rimraf .next && rimraf node_modules" }, diff --git a/apps/remix/app/app.css b/apps/remix/app/app.css index e45e2ae35..9255cdb6a 100644 --- a/apps/remix/app/app.css +++ b/apps/remix/app/app.css @@ -27,9 +27,45 @@ font-display: swap; } +@font-face { + font-family: 'Noto Sans'; + src: url('/fonts/noto-sans.ttf') format('truetype-variations'); + font-weight: 100 900; + font-style: normal; + font-display: swap; +} + +/* Korean noto sans */ +@font-face { + font-family: 'Noto Sans Korean'; + src: url('/fonts/noto-sans-korean.ttf') format('truetype-variations'); + font-weight: 100 900; + font-style: normal; + font-display: swap; +} + +/* Japanese noto sans */ +@font-face { + font-family: 'Noto Sans Japanese'; + src: url('/fonts/noto-sans-japanese.ttf') format('truetype-variations'); + font-weight: 100 900; + font-style: normal; + font-display: swap; +} + +/* Chinese noto sans */ +@font-face { + font-family: 'Noto Sans Chinese'; + src: url('/fonts/noto-sans-chinese.ttf') format('truetype-variations'); + font-weight: 100 900; + font-style: normal; + font-display: swap; +} + @layer base { :root { --font-sans: 'Inter'; --font-signature: 'Caveat'; + --font-noto: 'Noto Sans', 'Noto Sans Korean', 'Noto Sans Japanese', 'Noto Sans Chinese'; } } diff --git a/apps/remix/app/components/dialogs/admin-document-delete-dialog.tsx b/apps/remix/app/components/dialogs/admin-document-delete-dialog.tsx index 9f82d8551..aee9167cc 100644 --- a/apps/remix/app/components/dialogs/admin-document-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/admin-document-delete-dialog.tsx @@ -3,7 +3,6 @@ import { useState } from 'react'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import type { Document } from '@prisma/client'; import { useNavigate } from 'react-router'; import { trpc } from '@documenso/trpc/react'; @@ -22,10 +21,10 @@ import { Input } from '@documenso/ui/primitives/input'; import { useToast } from '@documenso/ui/primitives/use-toast'; export type AdminDocumentDeleteDialogProps = { - document: Document; + envelopeId: string; }; -export const AdminDocumentDeleteDialog = ({ document }: AdminDocumentDeleteDialogProps) => { +export const AdminDocumentDeleteDialog = ({ envelopeId }: AdminDocumentDeleteDialogProps) => { const { _ } = useLingui(); const { toast } = useToast(); @@ -42,7 +41,7 @@ export const AdminDocumentDeleteDialog = ({ document }: AdminDocumentDeleteDialo return; } - await deleteDocument({ id: document.id, reason }); + await deleteDocument({ id: envelopeId, reason }); toast({ title: _(msg`Document deleted`), diff --git a/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx b/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx index 57146ed9f..81ec5bdf4 100644 --- a/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx @@ -57,14 +57,14 @@ export const DocumentDuplicateDialog = ({ const { mutateAsync: duplicateDocument, isPending: isDuplicateLoading } = trpcReact.document.duplicate.useMutation({ - onSuccess: async ({ documentId }) => { + onSuccess: async ({ id }) => { toast({ title: _(msg`Document Duplicated`), description: _(msg`Your document has been successfully duplicated.`), duration: 5000, }); - await navigate(`${documentsPath}/${documentId}/edit`); + await navigate(`${documentsPath}/${id}/edit`); onOpenChange(false); }, }); diff --git a/apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx b/apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx index 401fb3529..45ec1465a 100644 --- a/apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx @@ -71,7 +71,7 @@ export const DocumentMoveToFolderDialog = ({ }, }); - const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFolders.useQuery( + const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery( { parentId: currentFolderId, type: FolderType.DOCUMENT, @@ -81,7 +81,7 @@ export const DocumentMoveToFolderDialog = ({ }, ); - const { mutateAsync: moveDocumentToFolder } = trpc.folder.moveDocumentToFolder.useMutation(); + const { mutateAsync: updateDocument } = trpc.document.update.useMutation(); useEffect(() => { if (!open) { @@ -94,9 +94,11 @@ export const DocumentMoveToFolderDialog = ({ const onSubmit = async (data: TMoveDocumentFormSchema) => { try { - await moveDocumentToFolder({ + await updateDocument({ documentId, - folderId: data.folderId ?? null, + data: { + folderId: data.folderId ?? null, + }, }); const documentsPath = formatDocumentsPath(team.url); diff --git a/apps/remix/app/components/dialogs/document-resend-dialog.tsx b/apps/remix/app/components/dialogs/document-resend-dialog.tsx index e1a97ecc1..d8c0a73ee 100644 --- a/apps/remix/app/components/dialogs/document-resend-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-resend-dialog.tsx @@ -4,15 +4,15 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import { type Recipient, SigningStatus } from '@prisma/client'; +import { type Recipient, SigningStatus, type Team, type User } from '@prisma/client'; import { History } from 'lucide-react'; -import { useForm } from 'react-hook-form'; +import { useForm, useWatch } from 'react-hook-form'; import * as z from 'zod'; import { useSession } from '@documenso/lib/client-only/providers/session'; import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; -import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/document'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; +import type { Document } from '@documenso/prisma/types/document-legacy-schema'; import { trpc as trpcReact } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; @@ -43,7 +43,11 @@ import { StackAvatar } from '../general/stack-avatar'; const FORM_ID = 'resend-email'; export type DocumentResendDialogProps = { - document: TDocumentRow; + document: Pick & { + user: Pick; + recipients: Recipient[]; + team: Pick | null; + }; recipients: Recipient[]; }; @@ -85,6 +89,11 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia formState: { isSubmitting }, } = form; + const selectedRecipients = useWatch({ + control: form.control, + name: 'recipients', + }); + const onFormSubmit = async ({ recipients }: TResendDocumentFormSchema) => { try { await resendDocument({ documentId: document.id, recipients }); @@ -151,7 +160,7 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia @@ -182,7 +191,13 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia - diff --git a/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx b/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx new file mode 100644 index 000000000..f9ca72f5e --- /dev/null +++ b/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx @@ -0,0 +1,442 @@ +import { useMemo, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useLingui } from '@lingui/react/macro'; +import { Trans } from '@lingui/react/macro'; +import { + DocumentDistributionMethod, + DocumentStatus, + EnvelopeType, + type Field, + FieldType, + type Recipient, + RecipientRole, +} from '@prisma/client'; +import { AnimatePresence, motion } from 'framer-motion'; +import { InfoIcon } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router'; +import { match } from 'ts-pattern'; +import * as z from 'zod'; + +import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +import type { TEnvelope } from '@documenso/lib/types/envelope'; +import { trpc, trpc as trpcReact } from '@documenso/trpc/react'; +import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper'; +import { cn } from '@documenso/ui/lib/utils'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} 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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; +import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; +import { Textarea } from '@documenso/ui/primitives/textarea'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type EnvelopeDistributeDialogProps = { + envelope: Pick & { + recipients: Recipient[]; + fields: Pick[]; + }; + onDistribute?: () => Promise; + documentRootPath: string; + trigger?: React.ReactNode; +}; + +export const ZEnvelopeDistributeFormSchema = z.object({ + meta: z.object({ + emailId: z.string().nullable(), + emailReplyTo: z.preprocess( + (val) => (val === '' ? undefined : val), + z.string().email().optional(), + ), + subject: z.string(), + message: z.string(), + distributionMethod: z + .nativeEnum(DocumentDistributionMethod) + .optional() + .default(DocumentDistributionMethod.EMAIL), + }), +}); + +export type TEnvelopeDistributeFormSchema = z.infer; + +export const EnvelopeDistributeDialog = ({ + envelope, + trigger, + documentRootPath, + onDistribute, +}: EnvelopeDistributeDialogProps) => { + const organisation = useCurrentOrganisation(); + + const recipients = envelope.recipients; + + const { toast } = useToast(); + const { t } = useLingui(); + const navigate = useNavigate(); + + const [isOpen, setIsOpen] = useState(false); + + const { mutateAsync: distributeEnvelope } = trpcReact.envelope.distribute.useMutation(); + + const form = useForm({ + defaultValues: { + meta: { + emailId: envelope.documentMeta?.emailId ?? null, + emailReplyTo: envelope.documentMeta?.emailReplyTo || undefined, + subject: envelope.documentMeta?.subject ?? '', + message: envelope.documentMeta?.message ?? '', + distributionMethod: + envelope.documentMeta?.distributionMethod || DocumentDistributionMethod.EMAIL, + }, + }, + resolver: zodResolver(ZEnvelopeDistributeFormSchema), + }); + + const { + handleSubmit, + setValue, + watch, + formState: { isSubmitting }, + } = form; + + const { data: emailData, isLoading: isLoadingEmails } = + trpc.enterprise.organisation.email.find.useQuery({ + organisationId: organisation.id, + perPage: 100, + }); + + const emails = emailData?.data || []; + + const distributionMethod = watch('meta.distributionMethod'); + + const recipientsMissingSignatureFields = useMemo( + () => + envelope.recipients.filter( + (recipient) => + recipient.role === RecipientRole.SIGNER && + !envelope.fields.some( + (field) => field.type === FieldType.SIGNATURE && field.recipientId === recipient.id, + ), + ), + [envelope.recipients, envelope.fields], + ); + + const invalidEnvelopeCode = useMemo(() => { + if (recipientsMissingSignatureFields.length > 0) { + return 'MISSING_SIGNATURES'; + } + + if (envelope.recipients.length === 0) { + return 'MISSING_RECIPIENTS'; + } + + return null; + }, [envelope.recipients, envelope.fields, recipientsMissingSignatureFields]); + + const onFormSubmit = async ({ meta }: TEnvelopeDistributeFormSchema) => { + try { + await distributeEnvelope({ envelopeId: envelope.id, meta }); + + await onDistribute?.(); + + let redirectPath = `${documentRootPath}/${envelope.id}`; + + if (meta.distributionMethod === DocumentDistributionMethod.NONE) { + redirectPath += '?action=copy-links'; + } + + await navigate(redirectPath); + + toast({ + title: t`Envelope distributed`, + description: t`Your envelope has been distributed successfully.`, + duration: 5000, + }); + + setIsOpen(false); + } catch (err) { + toast({ + title: t`Something went wrong`, + description: t`This envelope could not be distributed at this time. Please try again.`, + variant: 'destructive', + duration: 7500, + }); + } + }; + + if (envelope.status !== DocumentStatus.DRAFT || envelope.type !== EnvelopeType.DOCUMENT) { + return null; + } + + return ( + + {trigger} + + + + + Send Document + + + + Recipients will be able to sign the document once sent + + + + {!invalidEnvelopeCode ? ( +
+ +
+ + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + setValue('meta.distributionMethod', value as DocumentDistributionMethod) + } + value={distributionMethod} + className="mb-2" + > + + + Email + + + None + + + + +
+ + {distributionMethod === DocumentDistributionMethod.EMAIL && ( + + +
+ {organisation.organisationClaim.flags.emailDomains && ( + ( + + + Email Sender + + + + + + + + )} + /> + )} + + ( + + + Reply To Email{' '} + (Optional) + + + + + + + + + )} + /> + + ( + + + Subject{' '} + (Optional) + + + + + + + + )} + /> + + ( + + + Message{' '} + (Optional) + + + + + + + + + + + +