;
-export const SigningDisclosure = ({ className, ...props }: SigningDisclosureProps) => {
+export const DocumentSigningDisclosure = ({
+ className,
+ ...props
+}: DocumentSigningDisclosureProps) => {
return (
@@ -21,7 +24,7 @@ export const SigningDisclosure = ({ className, ...props }: SigningDisclosureProp
Read the full{' '}
signature disclosure
diff --git a/apps/remix/app/components/general/document-signing/document-signing-dropdown-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-dropdown-field.tsx
new file mode 100644
index 000000000..f839224f7
--- /dev/null
+++ b/apps/remix/app/components/general/document-signing/document-signing-dropdown-field.tsx
@@ -0,0 +1,215 @@
+import { useEffect, useState, useTransition } from 'react';
+
+import { msg } from '@lingui/macro';
+import { useLingui } from '@lingui/react';
+import type { Recipient } from '@prisma/client';
+import { Loader } from 'lucide-react';
+
+import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
+import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
+import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
+import { ZDropdownFieldMeta } from '@documenso/lib/types/field-meta';
+import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
+import { trpc } from '@documenso/trpc/react';
+import type {
+ TRemovedSignedFieldWithTokenMutationSchema,
+ TSignFieldWithTokenMutationSchema,
+} from '@documenso/trpc/server/field-router/schema';
+import { cn } from '@documenso/ui/lib/utils';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@documenso/ui/primitives/select';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
+import { DocumentSigningFieldContainer } from './document-signing-field-container';
+
+export type DocumentSigningDropdownFieldProps = {
+ field: FieldWithSignatureAndFieldMeta;
+ recipient: Recipient;
+ onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void;
+ onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void;
+};
+
+export const DocumentSigningDropdownField = ({
+ field,
+ recipient,
+ onSignField,
+ onUnsignField,
+}: DocumentSigningDropdownFieldProps) => {
+ const { _ } = useLingui();
+ const { toast } = useToast();
+
+ const [isPending, startTransition] = useTransition();
+
+ const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
+
+ const parsedFieldMeta = ZDropdownFieldMeta.parse(field.fieldMeta);
+ const isReadOnly = parsedFieldMeta?.readOnly;
+ const defaultValue = parsedFieldMeta?.defaultValue;
+ const [localChoice, setLocalChoice] = useState(parsedFieldMeta.defaultValue ?? '');
+
+ const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } =
+ trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
+
+ const {
+ mutateAsync: removeSignedFieldWithToken,
+ isPending: isRemoveSignedFieldWithTokenLoading,
+ } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
+
+ const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
+ const shouldAutoSignField =
+ (!field.inserted && localChoice) || (!field.inserted && isReadOnly && defaultValue);
+
+ const onSign = async (authOptions?: TRecipientActionAuth) => {
+ try {
+ if (!localChoice) {
+ return;
+ }
+
+ const payload: TSignFieldWithTokenMutationSchema = {
+ token: recipient.token,
+ fieldId: field.id,
+ value: localChoice,
+ isBase64: true,
+ authOptions,
+ };
+
+ if (onSignField) {
+ await onSignField(payload);
+ } else {
+ await signFieldWithToken(payload);
+ }
+
+ setLocalChoice('');
+
+ // Todo
+ // startTransition(() => router.refresh());
+ } catch (err) {
+ const error = AppError.parseError(err);
+
+ if (error.code === AppErrorCode.UNAUTHORIZED) {
+ throw error;
+ }
+
+ console.error(err);
+
+ toast({
+ title: _(msg`Error`),
+ description: _(msg`An error occurred while signing the document.`),
+ variant: 'destructive',
+ });
+ }
+ };
+
+ const onPreSign = () => {
+ return true;
+ };
+
+ const onRemove = async () => {
+ try {
+ const payload: TRemovedSignedFieldWithTokenMutationSchema = {
+ token: recipient.token,
+ fieldId: field.id,
+ };
+
+ if (onUnsignField) {
+ await onUnsignField(payload);
+ return;
+ } else {
+ await removeSignedFieldWithToken(payload);
+ }
+
+ setLocalChoice('');
+
+ // Todo
+ // startTransition(() => router.refresh());
+ } catch (err) {
+ console.error(err);
+
+ toast({
+ title: _(msg`Error`),
+ description: _(msg`An error occurred while removing the signature.`),
+ variant: 'destructive',
+ });
+ }
+ };
+
+ const handleSelectItem = (val: string) => {
+ setLocalChoice(val);
+ };
+
+ useEffect(() => {
+ if (!field.inserted && localChoice) {
+ void executeActionAuthProcedure({
+ onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
+ actionTarget: field.type,
+ });
+ }
+ }, [localChoice]);
+
+ useEffect(() => {
+ if (shouldAutoSignField) {
+ void executeActionAuthProcedure({
+ onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
+ actionTarget: field.type,
+ });
+ }
+ }, []);
+
+ return (
+
+
+ {isLoading && (
+
+
+
+ )}
+
+ {!field.inserted && (
+
+
+
+
+
+
+ {parsedFieldMeta?.values?.map((item, index) => (
+
+ {item.value}
+
+ ))}
+
+
+
+ )}
+
+ {field.inserted && (
+
+ {field.customText}
+
+ )}
+
+
+ );
+};
diff --git a/apps/remix/app/components/general/document-signing/document-signing-email-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-email-field.tsx
new file mode 100644
index 000000000..2e5f20bba
--- /dev/null
+++ b/apps/remix/app/components/general/document-signing/document-signing-email-field.tsx
@@ -0,0 +1,138 @@
+import { useTransition } from 'react';
+
+import { Trans, msg } from '@lingui/macro';
+import { useLingui } from '@lingui/react';
+import type { Recipient } from '@prisma/client';
+import { Loader } from 'lucide-react';
+
+import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
+import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
+import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
+import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
+import { trpc } from '@documenso/trpc/react';
+import type {
+ TRemovedSignedFieldWithTokenMutationSchema,
+ TSignFieldWithTokenMutationSchema,
+} from '@documenso/trpc/server/field-router/schema';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+import { DocumentSigningFieldContainer } from './document-signing-field-container';
+import { useRequiredDocumentSigningContext } from './document-signing-provider';
+
+export type DocumentSigningEmailFieldProps = {
+ field: FieldWithSignature;
+ recipient: Recipient;
+ onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void;
+ onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void;
+};
+
+export const DocumentSigningEmailField = ({
+ field,
+ recipient,
+ onSignField,
+ onUnsignField,
+}: DocumentSigningEmailFieldProps) => {
+ const { _ } = useLingui();
+ const { toast } = useToast();
+
+ const { email: providedEmail } = useRequiredDocumentSigningContext();
+
+ const [isPending, startTransition] = useTransition();
+
+ const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } =
+ trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
+
+ const {
+ mutateAsync: removeSignedFieldWithToken,
+ isPending: isRemoveSignedFieldWithTokenLoading,
+ } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
+
+ const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
+
+ const onSign = async (authOptions?: TRecipientActionAuth) => {
+ try {
+ const value = providedEmail ?? '';
+
+ const payload: TSignFieldWithTokenMutationSchema = {
+ token: recipient.token,
+ fieldId: field.id,
+ value,
+ isBase64: false,
+ authOptions,
+ };
+
+ if (onSignField) {
+ await onSignField(payload);
+ return;
+ }
+
+ await signFieldWithToken(payload);
+
+ // Todo
+ // startTransition(() => router.refresh());
+ } catch (err) {
+ const error = AppError.parseError(err);
+
+ if (error.code === AppErrorCode.UNAUTHORIZED) {
+ throw error;
+ }
+
+ console.error(err);
+
+ toast({
+ title: _(msg`Error`),
+ description: _(msg`An error occurred while signing the document.`),
+ variant: 'destructive',
+ });
+ }
+ };
+
+ const onRemove = async () => {
+ try {
+ const payload: TRemovedSignedFieldWithTokenMutationSchema = {
+ token: recipient.token,
+ fieldId: field.id,
+ };
+
+ if (onUnsignField) {
+ await onUnsignField(payload);
+ return;
+ }
+
+ await removeSignedFieldWithToken(payload);
+
+ // Todo
+ // startTransition(() => router.refresh());
+ } catch (err) {
+ console.error(err);
+
+ toast({
+ title: _(msg`Error`),
+ description: _(msg`An error occurred while removing the signature.`),
+ variant: 'destructive',
+ });
+ }
+ };
+
+ return (
+
+ {isLoading && (
+
+
+
+ )}
+
+ {!field.inserted && (
+
+ Email
+
+ )}
+
+ {field.inserted && (
+
+ {field.customText}
+
+ )}
+
+ );
+};
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
new file mode 100644
index 000000000..801ec2ad0
--- /dev/null
+++ b/apps/remix/app/components/general/document-signing/document-signing-field-container.tsx
@@ -0,0 +1,187 @@
+import React from 'react';
+
+import { Trans } from '@lingui/macro';
+import { FieldType } from '@prisma/client';
+import { X } from 'lucide-react';
+
+import { type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
+import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
+import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
+import { FieldRootContainer } from '@documenso/ui/components/field/field';
+import { cn } from '@documenso/ui/lib/utils';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
+
+import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
+
+export type DocumentSigningFieldContainerProps = {
+ field: FieldWithSignature;
+ loading?: boolean;
+ children: React.ReactNode;
+
+ /**
+ * A function that is called before the field requires to be signed, or reauthed.
+ *
+ * Example, you may want to show a dialog prior to signing where they can enter a value.
+ *
+ * Once that action is complete, you will need to call `executeActionAuthProcedure` to proceed
+ * regardless if it requires reauth or not.
+ *
+ * If the function returns true, we will proceed with the signing process. Otherwise if
+ * false is returned we will not proceed.
+ */
+ onPreSign?: () => Promise | boolean;
+
+ /**
+ * The function required to be executed to insert the field.
+ *
+ * The auth values will be passed in if available.
+ */
+ onSign?: (documentAuthValue?: TRecipientActionAuth) => Promise | void;
+ onRemove?: (fieldType?: string) => Promise | void;
+ type?:
+ | 'Date'
+ | 'Initials'
+ | 'Email'
+ | 'Name'
+ | 'Signature'
+ | 'Radio'
+ | 'Dropdown'
+ | 'Number'
+ | 'Checkbox';
+ tooltipText?: string | null;
+};
+
+export const DocumentSigningFieldContainer = ({
+ field,
+ loading,
+ onPreSign,
+ onSign,
+ onRemove,
+ children,
+ type,
+ tooltipText,
+}: DocumentSigningFieldContainerProps) => {
+ const { executeActionAuthProcedure, isAuthRedirectRequired } =
+ useRequiredDocumentSigningAuthContext();
+
+ const parsedFieldMeta = field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined;
+ const readOnlyField = parsedFieldMeta?.readOnly || false;
+
+ const handleInsertField = async () => {
+ if (field.inserted || !onSign) {
+ return;
+ }
+
+ // Bypass reauth for non signature fields.
+ if (field.type !== FieldType.SIGNATURE) {
+ const presignResult = await onPreSign?.();
+
+ if (presignResult === false) {
+ return;
+ }
+
+ await onSign();
+ return;
+ }
+
+ if (isAuthRedirectRequired) {
+ await executeActionAuthProcedure({
+ onReauthFormSubmit: () => {
+ // Do nothing since the user should be redirected.
+ },
+ actionTarget: field.type,
+ });
+
+ return;
+ }
+
+ // Handle any presign requirements, and halt if required.
+ if (onPreSign) {
+ const preSignResult = await onPreSign();
+
+ if (preSignResult === false) {
+ return;
+ }
+ }
+
+ await executeActionAuthProcedure({
+ onReauthFormSubmit: onSign,
+ actionTarget: field.type,
+ });
+ };
+
+ const onRemoveSignedFieldClick = async () => {
+ if (!field.inserted) {
+ return;
+ }
+
+ await onRemove?.();
+ };
+
+ const onClearCheckBoxValues = async (fieldType?: string) => {
+ if (!field.inserted) {
+ return;
+ }
+
+ await onRemove?.(fieldType);
+ };
+
+ return (
+
+
+ {!field.inserted && !loading && !readOnlyField && (
+ handleInsertField()}
+ />
+ )}
+
+ {readOnlyField && (
+
+
+ Read only field
+
+
+ )}
+
+ {type === 'Date' && field.inserted && !loading && !readOnlyField && (
+
+
+
+ Remove
+
+
+
+ {tooltipText && {tooltipText} }
+
+ )}
+
+ {type === 'Checkbox' && field.inserted && !loading && !readOnlyField && (
+ void onClearCheckBoxValues(type)}
+ >
+
+
+
+
+ )}
+
+ {type !== 'Date' && type !== 'Checkbox' && field.inserted && !loading && !readOnlyField && (
+
+ Remove
+
+ )}
+
+ {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
new file mode 100644
index 000000000..3c53e3e36
--- /dev/null
+++ b/apps/remix/app/components/general/document-signing/document-signing-form.tsx
@@ -0,0 +1,264 @@
+import { useMemo, useState } from 'react';
+
+import { Trans } from '@lingui/macro';
+import { type Field, FieldType, type Recipient, RecipientRole } from '@prisma/client';
+import { useForm } from 'react-hook-form';
+import { useNavigate } from 'react-router';
+
+import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
+import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
+import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
+import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
+import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
+import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
+import { trpc } from '@documenso/trpc/react';
+import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
+import { cn } from '@documenso/ui/lib/utils';
+import { Button } from '@documenso/ui/primitives/button';
+import { Card, CardContent } from '@documenso/ui/primitives/card';
+import { Input } from '@documenso/ui/primitives/input';
+import { Label } from '@documenso/ui/primitives/label';
+import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
+
+import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
+import { useRequiredDocumentSigningContext } from './document-signing-provider';
+
+export type DocumentSigningFormProps = {
+ document: DocumentAndSender;
+ recipient: Recipient;
+ fields: Field[];
+ redirectUrl?: string | null;
+ isRecipientsTurn: boolean;
+};
+
+export const DocumentSigningForm = ({
+ document,
+ recipient,
+ fields,
+ redirectUrl,
+ isRecipientsTurn,
+}: DocumentSigningFormProps) => {
+ const navigate = useNavigate();
+ const analytics = useAnalytics();
+
+ const { user } = useOptionalSession();
+
+ const { fullName, signature, setFullName, setSignature, signatureValid, setSignatureValid } =
+ useRequiredDocumentSigningContext();
+
+ const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
+
+ const { mutateAsync: completeDocumentWithToken } =
+ trpc.recipient.completeDocumentWithToken.useMutation();
+
+ const { handleSubmit, formState } = useForm();
+
+ // Keep the loading state going if successful since the redirect may take some time.
+ const isSubmitting = formState.isSubmitting || formState.isSubmitSuccessful;
+
+ const fieldsRequiringValidation = useMemo(
+ () => fields.filter(isFieldUnsignedAndRequired),
+ [fields],
+ );
+
+ const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
+
+ const uninsertedFields = useMemo(() => {
+ return sortFieldsByPosition(fieldsRequiringValidation.filter((field) => !field.inserted));
+ }, [fields]);
+
+ const fieldsValidated = () => {
+ setValidateUninsertedFields(true);
+ validateFieldsInserted(fieldsRequiringValidation);
+ };
+
+ const onFormSubmit = async () => {
+ setValidateUninsertedFields(true);
+
+ const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation);
+
+ if (hasSignatureField && !signatureValid) {
+ return;
+ }
+
+ if (!isFieldsValid) {
+ return;
+ }
+
+ await completeDocument();
+
+ // Reauth is currently not required for completing the document.
+ // await executeActionAuthProcedure({
+ // onReauthFormSubmit: completeDocument,
+ // actionTarget: 'DOCUMENT',
+ // });
+ };
+
+ const completeDocument = async (authOptions?: TRecipientActionAuth) => {
+ await completeDocumentWithToken({
+ token: recipient.token,
+ documentId: document.id,
+ authOptions,
+ });
+
+ analytics.capture('App: Recipient has completed signing', {
+ signerId: recipient.id,
+ documentId: document.id,
+ timestamp: new Date().toISOString(),
+ });
+
+ redirectUrl ? navigate(redirectUrl) : navigate(`/sign/${recipient.token}/complete`);
+ };
+
+ return (
+
+ );
+};
diff --git a/apps/remix/app/components/general/document-signing/document-signing-initials-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-initials-field.tsx
new file mode 100644
index 000000000..85471b86f
--- /dev/null
+++ b/apps/remix/app/components/general/document-signing/document-signing-initials-field.tsx
@@ -0,0 +1,144 @@
+import { useTransition } from 'react';
+
+import { Trans, msg } from '@lingui/macro';
+import { useLingui } from '@lingui/react';
+import type { Recipient } from '@prisma/client';
+import { Loader } from 'lucide-react';
+
+import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
+import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
+import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
+import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
+import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
+import { trpc } from '@documenso/trpc/react';
+import type {
+ TRemovedSignedFieldWithTokenMutationSchema,
+ TSignFieldWithTokenMutationSchema,
+} from '@documenso/trpc/server/field-router/schema';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+import { DocumentSigningFieldContainer } from './document-signing-field-container';
+import { useRequiredDocumentSigningContext } from './document-signing-provider';
+
+export type DocumentSigningInitialsFieldProps = {
+ field: FieldWithSignature;
+ recipient: Recipient;
+ onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void;
+ onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void;
+};
+
+export const DocumentSigningInitialsField = ({
+ field,
+ recipient,
+ onSignField,
+ onUnsignField,
+}: DocumentSigningInitialsFieldProps) => {
+ const { toast } = useToast();
+ const { _ } = useLingui();
+
+ const { fullName } = useRequiredDocumentSigningContext();
+ const initials = extractInitials(fullName);
+
+ const [isPending, startTransition] = useTransition();
+
+ const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } =
+ trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
+
+ const {
+ mutateAsync: removeSignedFieldWithToken,
+ isPending: isRemoveSignedFieldWithTokenLoading,
+ } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
+
+ const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
+
+ const onSign = async (authOptions?: TRecipientActionAuth) => {
+ try {
+ const value = initials ?? '';
+
+ const payload: TSignFieldWithTokenMutationSchema = {
+ token: recipient.token,
+ fieldId: field.id,
+ value,
+ isBase64: false,
+ authOptions,
+ };
+
+ if (onSignField) {
+ await onSignField(payload);
+ return;
+ }
+
+ await signFieldWithToken(payload);
+
+ // Tod
+ // startTransition(() => router.refresh());
+ } catch (err) {
+ const error = AppError.parseError(err);
+
+ if (error.code === AppErrorCode.UNAUTHORIZED) {
+ throw error;
+ }
+
+ console.error(err);
+
+ toast({
+ title: _(msg`Error`),
+ description: _(msg`An error occurred while signing the document.`),
+ variant: 'destructive',
+ });
+ }
+ };
+
+ const onRemove = async () => {
+ try {
+ const payload: TRemovedSignedFieldWithTokenMutationSchema = {
+ token: recipient.token,
+ fieldId: field.id,
+ };
+
+ if (onUnsignField) {
+ await onUnsignField(payload);
+ return;
+ }
+
+ await removeSignedFieldWithToken(payload);
+
+ // startTransition(() => router.refresh());
+ } catch (err) {
+ console.error(err);
+
+ toast({
+ title: _(msg`Error`),
+ description: _(msg`An error occurred while removing the field.`),
+ variant: 'destructive',
+ });
+ }
+ };
+
+ return (
+
+ {isLoading && (
+
+
+
+ )}
+
+ {!field.inserted && (
+
+ Initials
+
+ )}
+
+ {field.inserted && (
+
+ {field.customText}
+
+ )}
+
+ );
+};
diff --git a/apps/remix/app/components/general/document-signing/document-signing-name-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-name-field.tsx
new file mode 100644
index 000000000..bc35c3234
--- /dev/null
+++ b/apps/remix/app/components/general/document-signing/document-signing-name-field.tsx
@@ -0,0 +1,231 @@
+import { useState, useTransition } from 'react';
+
+import { Trans, msg } from '@lingui/macro';
+import { useLingui } from '@lingui/react';
+import { type Recipient } from '@prisma/client';
+import { Loader } from 'lucide-react';
+
+import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
+import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
+import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
+import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
+import { trpc } from '@documenso/trpc/react';
+import type {
+ TRemovedSignedFieldWithTokenMutationSchema,
+ TSignFieldWithTokenMutationSchema,
+} 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 { Input } from '@documenso/ui/primitives/input';
+import { Label } from '@documenso/ui/primitives/label';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
+import { DocumentSigningFieldContainer } from './document-signing-field-container';
+import { useRequiredDocumentSigningContext } from './document-signing-provider';
+
+export type DocumentSigningNameFieldProps = {
+ field: FieldWithSignature;
+ recipient: Recipient;
+ onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void;
+ onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void;
+};
+
+export const DocumentSigningNameField = ({
+ field,
+ recipient,
+ onSignField,
+ onUnsignField,
+}: DocumentSigningNameFieldProps) => {
+ const { _ } = useLingui();
+ const { toast } = useToast();
+
+ const { fullName: providedFullName, setFullName: setProvidedFullName } =
+ useRequiredDocumentSigningContext();
+
+ const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
+
+ const [isPending, startTransition] = useTransition();
+
+ const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } =
+ trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
+
+ const {
+ mutateAsync: removeSignedFieldWithToken,
+ isPending: isRemoveSignedFieldWithTokenLoading,
+ } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
+
+ const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
+
+ const [showFullNameModal, setShowFullNameModal] = useState(false);
+ const [localFullName, setLocalFullName] = useState('');
+
+ const onPreSign = () => {
+ if (!providedFullName) {
+ setShowFullNameModal(true);
+ return false;
+ }
+
+ return true;
+ };
+
+ /**
+ * When the user clicks the sign button in the dialog where they enter their full name.
+ */
+ const onDialogSignClick = () => {
+ setShowFullNameModal(false);
+ setProvidedFullName(localFullName);
+
+ void executeActionAuthProcedure({
+ onReauthFormSubmit: async (authOptions) => await onSign(authOptions, localFullName),
+ actionTarget: field.type,
+ });
+ };
+
+ const onSign = async (authOptions?: TRecipientActionAuth, name?: string) => {
+ try {
+ const value = name || providedFullName;
+
+ if (!value) {
+ setShowFullNameModal(true);
+ return;
+ }
+
+ const payload: TSignFieldWithTokenMutationSchema = {
+ token: recipient.token,
+ fieldId: field.id,
+ value,
+ isBase64: false,
+ authOptions,
+ };
+
+ if (onSignField) {
+ await onSignField(payload);
+ return;
+ }
+
+ await signFieldWithToken(payload);
+
+ // startTransition(() => router.refresh());
+ } catch (err) {
+ const error = AppError.parseError(err);
+
+ if (error.code === AppErrorCode.UNAUTHORIZED) {
+ throw error;
+ }
+
+ console.error(err);
+
+ toast({
+ title: _(msg`Error`),
+ description: _(msg`An error occurred while signing the document.`),
+ variant: 'destructive',
+ });
+ }
+ };
+
+ const onRemove = async () => {
+ try {
+ const payload: TRemovedSignedFieldWithTokenMutationSchema = {
+ token: recipient.token,
+ fieldId: field.id,
+ };
+
+ if (onUnsignField) {
+ await onUnsignField(payload);
+ return;
+ }
+
+ await removeSignedFieldWithToken(payload);
+
+ // startTransition(() => router.refresh());
+ } catch (err) {
+ console.error(err);
+
+ toast({
+ title: _(msg`Error`),
+ description: _(msg`An error occurred while removing the signature.`),
+ variant: 'destructive',
+ });
+ }
+ };
+
+ return (
+
+ {isLoading && (
+
+
+
+ )}
+
+ {!field.inserted && (
+
+ Name
+
+ )}
+
+ {field.inserted && (
+
+ {field.customText}
+
+ )}
+
+
+
+
+
+ Sign as
+
+ {recipient.name}
({recipient.email})
+
+
+
+
+
+
+ Full Name
+
+
+ setLocalFullName(e.target.value.trimStart())}
+ />
+
+
+
+
+ {
+ setShowFullNameModal(false);
+ setLocalFullName('');
+ }}
+ >
+ Cancel
+
+
+ onDialogSignClick()}
+ >
+ Sign
+
+
+
+
+
+
+ );
+};
diff --git a/apps/remix/app/components/general/document-signing/document-signing-number-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-number-field.tsx
new file mode 100644
index 000000000..421f4253f
--- /dev/null
+++ b/apps/remix/app/components/general/document-signing/document-signing-number-field.tsx
@@ -0,0 +1,347 @@
+import { useEffect, useState, useTransition } from 'react';
+
+import { Trans, msg } from '@lingui/macro';
+import { useLingui } from '@lingui/react';
+import type { Recipient } from '@prisma/client';
+import { Hash, Loader } from 'lucide-react';
+
+import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
+import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
+import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
+import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
+import { ZNumberFieldMeta } from '@documenso/lib/types/field-meta';
+import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
+import { trpc } from '@documenso/trpc/react';
+import type {
+ TRemovedSignedFieldWithTokenMutationSchema,
+ TSignFieldWithTokenMutationSchema,
+} from '@documenso/trpc/server/field-router/schema';
+import { cn } from '@documenso/ui/lib/utils';
+import { Button } from '@documenso/ui/primitives/button';
+import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
+import { Input } from '@documenso/ui/primitives/input';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
+import { DocumentSigningFieldContainer } from './document-signing-field-container';
+
+type ValidationErrors = {
+ isNumber: string[];
+ required: string[];
+ minValue: string[];
+ maxValue: string[];
+ numberFormat: string[];
+};
+
+export type DocumentSigningNumberFieldProps = {
+ field: FieldWithSignature;
+ recipient: Recipient;
+ onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void;
+ onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void;
+};
+
+export const DocumentSigningNumberField = ({
+ field,
+ recipient,
+ onSignField,
+ onUnsignField,
+}: DocumentSigningNumberFieldProps) => {
+ const { _ } = useLingui();
+ const { toast } = useToast();
+
+ const [isPending, startTransition] = useTransition();
+ const [showRadioModal, setShowRadioModal] = useState(false);
+
+ const parsedFieldMeta = field.fieldMeta ? ZNumberFieldMeta.parse(field.fieldMeta) : null;
+ const isReadOnly = parsedFieldMeta?.readOnly;
+ const defaultValue = parsedFieldMeta?.value;
+ const [localNumber, setLocalNumber] = useState(
+ parsedFieldMeta?.value ? String(parsedFieldMeta.value) : '0',
+ );
+
+ const initialErrors: ValidationErrors = {
+ isNumber: [],
+ required: [],
+ minValue: [],
+ maxValue: [],
+ numberFormat: [],
+ };
+
+ const [errors, setErrors] = useState(initialErrors);
+
+ const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
+
+ const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } =
+ trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
+
+ const {
+ mutateAsync: removeSignedFieldWithToken,
+ isPending: isRemoveSignedFieldWithTokenLoading,
+ } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
+
+ const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
+
+ const handleNumberChange = (e: React.ChangeEvent) => {
+ const text = e.target.value;
+ setLocalNumber(text);
+
+ if (parsedFieldMeta) {
+ const validationErrors = validateNumberField(text, parsedFieldMeta, true);
+ setErrors({
+ isNumber: validationErrors.filter((error) => error.includes('valid number')),
+ required: validationErrors.filter((error) => error.includes('required')),
+ minValue: validationErrors.filter((error) => error.includes('minimum value')),
+ maxValue: validationErrors.filter((error) => error.includes('maximum value')),
+ numberFormat: validationErrors.filter((error) => error.includes('number format')),
+ });
+ } else {
+ const validationErrors = validateNumberField(text);
+ setErrors((prevErrors) => ({
+ ...prevErrors,
+ isNumber: validationErrors.filter((error) => error.includes('valid number')),
+ }));
+ }
+ };
+
+ const onDialogSignClick = () => {
+ setShowRadioModal(false);
+
+ void executeActionAuthProcedure({
+ onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
+ actionTarget: field.type,
+ });
+ };
+
+ const onSign = async (authOptions?: TRecipientActionAuth) => {
+ try {
+ if (!localNumber || Object.values(errors).some((error) => error.length > 0)) {
+ return;
+ }
+
+ const payload: TSignFieldWithTokenMutationSchema = {
+ token: recipient.token,
+ fieldId: field.id,
+ value: localNumber,
+ isBase64: true,
+ authOptions,
+ };
+
+ if (onSignField) {
+ await onSignField(payload);
+ return;
+ }
+
+ await signFieldWithToken(payload);
+
+ setLocalNumber('');
+
+ // Todo
+ // startTransition(() => router.refresh());
+ } catch (err) {
+ const error = AppError.parseError(err);
+
+ if (error.code === AppErrorCode.UNAUTHORIZED) {
+ throw error;
+ }
+
+ console.error(err);
+
+ toast({
+ title: _(msg`Error`),
+ description: _(msg`An error occurred while signing the document.`),
+ variant: 'destructive',
+ });
+ }
+ };
+
+ const onPreSign = () => {
+ setShowRadioModal(true);
+
+ if (localNumber && parsedFieldMeta) {
+ const validationErrors = validateNumberField(localNumber, parsedFieldMeta, true);
+ setErrors({
+ isNumber: validationErrors.filter((error) => error.includes('valid number')),
+ required: validationErrors.filter((error) => error.includes('required')),
+ minValue: validationErrors.filter((error) => error.includes('minimum value')),
+ maxValue: validationErrors.filter((error) => error.includes('maximum value')),
+ numberFormat: validationErrors.filter((error) => error.includes('number format')),
+ });
+ }
+
+ return false;
+ };
+
+ const onRemove = async () => {
+ try {
+ const payload: TRemovedSignedFieldWithTokenMutationSchema = {
+ token: recipient.token,
+ fieldId: field.id,
+ };
+
+ if (onUnsignField) {
+ await onUnsignField(payload);
+ return;
+ }
+
+ await removeSignedFieldWithToken(payload);
+
+ setLocalNumber(parsedFieldMeta?.value ? String(parsedFieldMeta?.value) : '');
+
+ // Todo
+ // startTransition(() => router.refresh());
+ } catch (err) {
+ console.error(err);
+
+ toast({
+ title: _(msg`Error`),
+ description: _(msg`An error occurred while removing the signature.`),
+ variant: 'destructive',
+ });
+ }
+ };
+
+ useEffect(() => {
+ if (!showRadioModal) {
+ setLocalNumber(parsedFieldMeta?.value ? String(parsedFieldMeta.value) : '0');
+ setErrors(initialErrors);
+ }
+ }, [showRadioModal]);
+
+ useEffect(() => {
+ if (
+ (!field.inserted && defaultValue && localNumber) ||
+ (!field.inserted && isReadOnly && defaultValue)
+ ) {
+ void executeActionAuthProcedure({
+ onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
+ actionTarget: field.type,
+ });
+ }
+ }, []);
+
+ let fieldDisplayName = 'Number';
+
+ if (parsedFieldMeta?.label) {
+ fieldDisplayName =
+ parsedFieldMeta.label.length > 10
+ ? parsedFieldMeta.label.substring(0, 10) + '...'
+ : parsedFieldMeta.label;
+ }
+
+ const userInputHasErrors = Object.values(errors).some((error) => error.length > 0);
+
+ return (
+
+ {isLoading && (
+
+
+
+ )}
+
+ {!field.inserted && (
+
+
+ {' '}
+ {fieldDisplayName}
+
+
+ )}
+
+ {field.inserted && (
+
+ {field.customText}
+
+ )}
+
+
+
+
+ {parsedFieldMeta?.label ? parsedFieldMeta?.label : Number }
+
+
+
+
+
+
+ {userInputHasErrors && (
+
+ {errors.isNumber?.map((error, index) => (
+
+ {error}
+
+ ))}
+ {errors.required?.map((error, index) => (
+
+ {error}
+
+ ))}
+ {errors.minValue?.map((error, index) => (
+
+ {error}
+
+ ))}
+ {errors.maxValue?.map((error, index) => (
+
+ {error}
+
+ ))}
+ {errors.numberFormat?.map((error, index) => (
+
+ {error}
+
+ ))}
+
+ )}
+
+
+
+ {
+ setShowRadioModal(false);
+ setLocalNumber('');
+ }}
+ >
+ Cancel
+
+
+ onDialogSignClick()}
+ >
+ Save
+
+
+
+
+
+
+ );
+};
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
new file mode 100644
index 000000000..1d4807f69
--- /dev/null
+++ b/apps/remix/app/components/general/document-signing/document-signing-page-view.tsx
@@ -0,0 +1,245 @@
+import { Trans } from '@lingui/macro';
+import type { Field, Recipient } from '@prisma/client';
+import { FieldType, RecipientRole } from '@prisma/client';
+import { match } from 'ts-pattern';
+
+import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
+import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
+import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
+import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
+import {
+ ZCheckboxFieldMeta,
+ ZDropdownFieldMeta,
+ ZNumberFieldMeta,
+ ZRadioFieldMeta,
+ ZTextFieldMeta,
+} from '@documenso/lib/types/field-meta';
+import type { CompletedField } from '@documenso/lib/types/fields';
+import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
+import { Card, CardContent } from '@documenso/ui/primitives/card';
+import { ElementVisible } from '@documenso/ui/primitives/element-visible';
+import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
+
+import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields';
+import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign';
+import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field';
+import { DocumentSigningDateField } from '~/components/general/document-signing/document-signing-date-field';
+import { DocumentSigningDropdownField } from '~/components/general/document-signing/document-signing-dropdown-field';
+import { DocumentSigningEmailField } from '~/components/general/document-signing/document-signing-email-field';
+import { DocumentSigningForm } from '~/components/general/document-signing/document-signing-form';
+import { DocumentSigningInitialsField } from '~/components/general/document-signing/document-signing-initials-field';
+import { DocumentSigningNameField } from '~/components/general/document-signing/document-signing-name-field';
+import { DocumentSigningNumberField } from '~/components/general/document-signing/document-signing-number-field';
+import { DocumentSigningRadioField } from '~/components/general/document-signing/document-signing-radio-field';
+import { DocumentSigningRejectDialog } from '~/components/general/document-signing/document-signing-reject-dialog';
+import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field';
+import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field';
+
+export type SigningPageViewProps = {
+ document: DocumentAndSender;
+ recipient: Recipient;
+ fields: Field[];
+ completedFields: CompletedField[];
+ isRecipientsTurn: boolean;
+};
+
+export const DocumentSigningPageView = ({
+ document,
+ recipient,
+ fields,
+ completedFields,
+ isRecipientsTurn,
+}: SigningPageViewProps) => {
+ const { documentData, documentMeta } = document;
+
+ const shouldUseTeamDetails =
+ document.teamId && document.team?.teamGlobalSettings?.includeSenderDetails === false;
+
+ let senderName = document.user.name ?? '';
+ let senderEmail = `(${document.user.email})`;
+
+ if (shouldUseTeamDetails) {
+ senderName = document.team?.name ?? '';
+ senderEmail = document.team?.teamEmail?.email ? `(${document.team.teamEmail.email})` : '';
+ }
+
+ return (
+
+
+ {document.title}
+
+
+
+
+
+ {senderName} {senderEmail}
+ {' '}
+
+ {match(recipient.role)
+ .with(RecipientRole.VIEWER, () =>
+ document.teamId && !shouldUseTeamDetails ? (
+
+ on behalf of "{document.team?.name}" has invited you to view this document
+
+ ) : (
+ has invited you to view this document
+ ),
+ )
+ .with(RecipientRole.SIGNER, () =>
+ document.teamId && !shouldUseTeamDetails ? (
+
+ on behalf of "{document.team?.name}" has invited you to sign this document
+
+ ) : (
+ has invited you to sign this document
+ ),
+ )
+ .with(RecipientRole.APPROVER, () =>
+ document.teamId && !shouldUseTeamDetails ? (
+
+ on behalf of "{document.team?.name}" has invited you to approve this document
+
+ ) : (
+ has invited you to approve this document
+ ),
+ )
+ .otherwise(() => null)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {fields.map((field) =>
+ match(field.type)
+ .with(FieldType.SIGNATURE, () => (
+
+ ))
+ .with(FieldType.INITIALS, () => (
+
+ ))
+ .with(FieldType.NAME, () => (
+
+ ))
+ .with(FieldType.DATE, () => (
+
+ ))
+ .with(FieldType.EMAIL, () => (
+
+ ))
+ .with(FieldType.TEXT, () => {
+ const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
+ ...field,
+ fieldMeta: field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : null,
+ };
+ return (
+
+ );
+ })
+ .with(FieldType.NUMBER, () => {
+ const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
+ ...field,
+ fieldMeta: field.fieldMeta ? ZNumberFieldMeta.parse(field.fieldMeta) : null,
+ };
+ return (
+
+ );
+ })
+ .with(FieldType.RADIO, () => {
+ const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
+ ...field,
+ fieldMeta: field.fieldMeta ? ZRadioFieldMeta.parse(field.fieldMeta) : null,
+ };
+ return (
+
+ );
+ })
+ .with(FieldType.CHECKBOX, () => {
+ const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
+ ...field,
+ fieldMeta: field.fieldMeta ? ZCheckboxFieldMeta.parse(field.fieldMeta) : null,
+ };
+ return (
+
+ );
+ })
+ .with(FieldType.DROPDOWN, () => {
+ const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
+ ...field,
+ fieldMeta: field.fieldMeta ? ZDropdownFieldMeta.parse(field.fieldMeta) : null,
+ };
+ return (
+
+ );
+ })
+ .otherwise(() => null),
+ )}
+
+
+ );
+};
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
new file mode 100644
index 000000000..ca231949d
--- /dev/null
+++ b/apps/remix/app/components/general/document-signing/document-signing-provider.tsx
@@ -0,0 +1,72 @@
+import { createContext, useContext, useEffect, useState } from 'react';
+
+export type DocumentSigningContextValue = {
+ fullName: string;
+ setFullName: (_value: string) => void;
+ email: string;
+ setEmail: (_value: string) => void;
+ signature: string | null;
+ setSignature: (_value: string | null) => void;
+ signatureValid: boolean;
+ setSignatureValid: (_valid: boolean) => void;
+};
+
+const DocumentSigningContext = createContext(null);
+
+export const useDocumentSigningContext = () => {
+ return useContext(DocumentSigningContext);
+};
+
+export const useRequiredDocumentSigningContext = () => {
+ const context = useDocumentSigningContext();
+
+ if (!context) {
+ throw new Error('Signing context is required');
+ }
+
+ return context;
+};
+
+export interface DocumentSigningProviderProps {
+ fullName?: string | null;
+ email?: string | null;
+ signature?: string | null;
+ children: React.ReactNode;
+}
+
+export const DocumentSigningProvider = ({
+ fullName: initialFullName,
+ email: initialEmail,
+ signature: initialSignature,
+ 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]);
+
+ return (
+
+ {children}
+
+ );
+};
+
+DocumentSigningProvider.displayName = 'DocumentSigningProvider';
diff --git a/apps/remix/app/components/general/document-signing/document-signing-radio-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-radio-field.tsx
new file mode 100644
index 000000000..777fe61a0
--- /dev/null
+++ b/apps/remix/app/components/general/document-signing/document-signing-radio-field.tsx
@@ -0,0 +1,197 @@
+import { useEffect, useState, useTransition } from 'react';
+
+import { msg } from '@lingui/macro';
+import { useLingui } from '@lingui/react';
+import type { Recipient } from '@prisma/client';
+import { Loader } from 'lucide-react';
+
+import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
+import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
+import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
+import { ZRadioFieldMeta } from '@documenso/lib/types/field-meta';
+import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
+import { trpc } from '@documenso/trpc/react';
+import type {
+ TRemovedSignedFieldWithTokenMutationSchema,
+ TSignFieldWithTokenMutationSchema,
+} from '@documenso/trpc/server/field-router/schema';
+import { Label } from '@documenso/ui/primitives/label';
+import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
+import { DocumentSigningFieldContainer } from './document-signing-field-container';
+
+export type DocumentSigningRadioFieldProps = {
+ field: FieldWithSignatureAndFieldMeta;
+ recipient: Recipient;
+ onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void;
+ onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void;
+};
+
+export const DocumentSigningRadioField = ({
+ field,
+ recipient,
+ onSignField,
+ onUnsignField,
+}: DocumentSigningRadioFieldProps) => {
+ const { _ } = useLingui();
+ const { toast } = useToast();
+
+ const [isPending, startTransition] = useTransition();
+
+ const parsedFieldMeta = ZRadioFieldMeta.parse(field.fieldMeta);
+ const values = parsedFieldMeta.values?.map((item) => ({
+ ...item,
+ value: item.value.length > 0 ? item.value : `empty-value-${item.id}`,
+ }));
+ const checkedItem = values?.find((item) => item.checked);
+ const defaultValue = !field.inserted && !!checkedItem ? checkedItem.value : '';
+
+ const [selectedOption, setSelectedOption] = useState(defaultValue);
+
+ const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
+
+ const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } =
+ trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
+
+ const {
+ mutateAsync: removeSignedFieldWithToken,
+ isPending: isRemoveSignedFieldWithTokenLoading,
+ } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
+
+ const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
+ const shouldAutoSignField =
+ (!field.inserted && selectedOption) ||
+ (!field.inserted && defaultValue) ||
+ (!field.inserted && parsedFieldMeta.readOnly && defaultValue);
+
+ const onSign = async (authOptions?: TRecipientActionAuth) => {
+ try {
+ if (!selectedOption) {
+ return;
+ }
+
+ const payload: TSignFieldWithTokenMutationSchema = {
+ token: recipient.token,
+ fieldId: field.id,
+ value: selectedOption,
+ isBase64: true,
+ authOptions,
+ };
+
+ if (onSignField) {
+ await onSignField(payload);
+ } else {
+ await signFieldWithToken(payload);
+ }
+
+ setSelectedOption('');
+
+ // Todo
+ // startTransition(() => router.refresh());
+ } catch (err) {
+ const error = AppError.parseError(err);
+
+ if (error.code === AppErrorCode.UNAUTHORIZED) {
+ throw error;
+ }
+
+ console.error(err);
+
+ toast({
+ title: _(msg`Error`),
+ description: _(msg`An error occurred while signing the document.`),
+ variant: 'destructive',
+ });
+ }
+ };
+
+ const onRemove = async () => {
+ try {
+ const payload: TRemovedSignedFieldWithTokenMutationSchema = {
+ token: recipient.token,
+ fieldId: field.id,
+ };
+
+ if (onUnsignField) {
+ await onUnsignField(payload);
+ } else {
+ await removeSignedFieldWithToken(payload);
+ }
+
+ setSelectedOption('');
+
+ // Todo
+ // startTransition(() => router.refresh());
+ } catch (err) {
+ console.error(err);
+
+ toast({
+ title: _(msg`Error`),
+ description: _(msg`An error occurred while removing the signature.`),
+ variant: 'destructive',
+ });
+ }
+ };
+
+ const handleSelectItem = (selectedOption: string) => {
+ setSelectedOption(selectedOption);
+ };
+
+ useEffect(() => {
+ if (shouldAutoSignField) {
+ void executeActionAuthProcedure({
+ onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
+ actionTarget: field.type,
+ });
+ }
+ }, [selectedOption, field]);
+
+ return (
+
+ {isLoading && (
+
+
+
+ )}
+
+ {!field.inserted && (
+ handleSelectItem(value)} className="z-10">
+ {values?.map((item, index) => (
+
+
+
+
+ {item.value.includes('empty-value-') ? '' : item.value}
+
+
+ ))}
+
+ )}
+
+ {field.inserted && (
+
+ {values?.map((item, index) => (
+
+
+
+ {item.value.includes('empty-value-') ? '' : item.value}
+
+
+ ))}
+
+ )}
+
+ );
+};
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
new file mode 100644
index 000000000..fedf8f71f
--- /dev/null
+++ b/apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx
@@ -0,0 +1,167 @@
+import { useEffect, useState } from 'react';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import { Trans, msg } from '@lingui/macro';
+import type { Document } from '@prisma/client';
+import { useForm } from 'react-hook-form';
+import { useNavigate } from 'react-router';
+import { useSearchParams } from 'react-router';
+import { z } from 'zod';
+
+import { trpc } from '@documenso/trpc/react';
+import { Button } from '@documenso/ui/primitives/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@documenso/ui/primitives/dialog';
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormMessage,
+} from '@documenso/ui/primitives/form/form';
+import { Textarea } from '@documenso/ui/primitives/textarea';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+const ZRejectDocumentFormSchema = z.object({
+ reason: z
+ .string()
+ .min(5, msg`Please provide a reason`)
+ .max(500, msg`Reason must be less than 500 characters`),
+});
+
+type TRejectDocumentFormSchema = z.infer;
+
+export interface DocumentSigningRejectDialogProps {
+ document: Pick;
+ token: string;
+}
+
+export function DocumentSigningRejectDialog({ document, token }: DocumentSigningRejectDialogProps) {
+ const { toast } = useToast();
+ const navigate = useNavigate();
+ const [searchParams] = useSearchParams();
+
+ const [isOpen, setIsOpen] = useState(false);
+
+ const { mutateAsync: rejectDocumentWithToken } =
+ trpc.recipient.rejectDocumentWithToken.useMutation();
+
+ const form = useForm({
+ resolver: zodResolver(ZRejectDocumentFormSchema),
+ defaultValues: {
+ reason: '',
+ },
+ });
+
+ const onRejectDocument = async ({ reason }: TRejectDocumentFormSchema) => {
+ try {
+ await rejectDocumentWithToken({
+ documentId: document.id,
+ token,
+ reason,
+ });
+
+ toast({
+ title: 'Document rejected',
+ description: 'The document has been successfully rejected.',
+ duration: 5000,
+ });
+
+ await navigate(`/sign/${token}/rejected`);
+
+ setIsOpen(false);
+ } catch (err) {
+ toast({
+ title: 'Error',
+ description: 'An error occurred while rejecting the document. Please try again.',
+ variant: 'destructive',
+ duration: 5000,
+ });
+ }
+ };
+
+ useEffect(() => {
+ if (searchParams?.get('reject') === 'true') {
+ setIsOpen(true);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (!isOpen) {
+ form.reset();
+ }
+ }, [isOpen]);
+
+ return (
+
+
+
+ Reject Document
+
+
+
+
+
+
+ Reject Document
+
+
+
+
+ Are you sure you want to reject this document? This action cannot be undone.
+
+
+
+
+
+
+
+
+ );
+}
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
new file mode 100644
index 000000000..11d044704
--- /dev/null
+++ b/apps/remix/app/components/general/document-signing/document-signing-signature-field.tsx
@@ -0,0 +1,333 @@
+import { useLayoutEffect, useMemo, useRef, useState, useTransition } from 'react';
+
+import { Trans, msg } from '@lingui/macro';
+import { useLingui } from '@lingui/react';
+import { type Recipient } from '@prisma/client';
+import { Loader } from 'lucide-react';
+
+import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
+import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
+import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
+import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
+import { trpc } from '@documenso/trpc/react';
+import type {
+ TRemovedSignedFieldWithTokenMutationSchema,
+ TSignFieldWithTokenMutationSchema,
+} 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';
+
+import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
+
+import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
+import { DocumentSigningFieldContainer } from './document-signing-field-container';
+import { useRequiredDocumentSigningContext } from './document-signing-provider';
+
+type SignatureFieldState = 'empty' | 'signed-image' | 'signed-text';
+export type DocumentSigningSignatureFieldProps = {
+ field: FieldWithSignature;
+ recipient: Recipient;
+ onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void;
+ onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void;
+ typedSignatureEnabled?: boolean;
+};
+
+export const DocumentSigningSignatureField = ({
+ field,
+ recipient,
+ onSignField,
+ onUnsignField,
+ typedSignatureEnabled,
+}: DocumentSigningSignatureFieldProps) => {
+ const { _ } = useLingui();
+ const { toast } = useToast();
+
+ const signatureRef = useRef(null);
+ const containerRef = useRef(null);
+ const [fontSize, setFontSize] = useState(2);
+
+ const {
+ signature: providedSignature,
+ setSignature: setProvidedSignature,
+ signatureValid,
+ setSignatureValid,
+ } = useRequiredDocumentSigningContext();
+
+ const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
+
+ const [isPending, startTransition] = useTransition();
+
+ const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } =
+ trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
+
+ const {
+ mutateAsync: removeSignedFieldWithToken,
+ isPending: isRemoveSignedFieldWithTokenLoading,
+ } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
+
+ const { signature } = field;
+
+ const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
+
+ const [showSignatureModal, setShowSignatureModal] = useState(false);
+ const [localSignature, setLocalSignature] = useState(null);
+
+ const state = useMemo(() => {
+ if (!field.inserted) {
+ return 'empty';
+ }
+
+ if (signature?.signatureImageAsBase64) {
+ return 'signed-image';
+ }
+
+ return 'signed-text';
+ }, [field.inserted, signature?.signatureImageAsBase64]);
+
+ const onPreSign = () => {
+ if (!providedSignature || !signatureValid) {
+ setShowSignatureModal(true);
+ return false;
+ }
+
+ return true;
+ };
+ /**
+ * When the user clicks the sign button in the dialog where they enter their signature.
+ */
+ const onDialogSignClick = () => {
+ setShowSignatureModal(false);
+ setProvidedSignature(localSignature);
+ if (!localSignature) {
+ return;
+ }
+
+ void executeActionAuthProcedure({
+ onReauthFormSubmit: async (authOptions) => await onSign(authOptions, localSignature),
+ actionTarget: field.type,
+ });
+ };
+
+ const onSign = async (authOptions?: TRecipientActionAuth, signature?: string) => {
+ try {
+ const value = signature || providedSignature;
+
+ if (!value || (signature && !signatureValid)) {
+ setShowSignatureModal(true);
+ return;
+ }
+
+ const isTypedSignature = !value.startsWith('data:image');
+
+ if (isTypedSignature && !typedSignatureEnabled) {
+ toast({
+ title: _(msg`Error`),
+ description: _(msg`Typed signatures are not allowed. Please draw your signature.`),
+ variant: 'destructive',
+ });
+
+ return;
+ }
+
+ const payload: TSignFieldWithTokenMutationSchema = {
+ token: recipient.token,
+ fieldId: field.id,
+ value,
+ isBase64: !isTypedSignature,
+ authOptions,
+ };
+
+ if (onSignField) {
+ await onSignField(payload);
+ return;
+ }
+
+ await signFieldWithToken(payload);
+
+ // Todo
+ // startTransition(() => router.refresh());
+ } catch (err) {
+ const error = AppError.parseError(err);
+
+ if (error.code === AppErrorCode.UNAUTHORIZED) {
+ throw error;
+ }
+
+ console.error(err);
+
+ toast({
+ title: _(msg`Error`),
+ description: _(msg`An error occurred while signing the document.`),
+ variant: 'destructive',
+ });
+ }
+ };
+
+ const onRemove = async () => {
+ try {
+ const payload: TRemovedSignedFieldWithTokenMutationSchema = {
+ token: recipient.token,
+ fieldId: field.id,
+ };
+
+ if (onUnsignField) {
+ await onUnsignField(payload);
+ return;
+ }
+
+ await removeSignedFieldWithToken(payload);
+
+ // Todo
+ // startTransition(() => router.refresh());
+ } catch (err) {
+ console.error(err);
+
+ toast({
+ title: _(msg`Error`),
+ description: _(msg`An error occurred while removing the signature.`),
+ variant: 'destructive',
+ });
+ }
+ };
+
+ useLayoutEffect(() => {
+ if (!signatureRef.current || !containerRef.current || !signature?.typedSignature) {
+ return;
+ }
+
+ const adjustTextSize = () => {
+ const container = containerRef.current;
+ const text = signatureRef.current;
+
+ if (!container || !text) {
+ return;
+ }
+
+ let size = 2;
+ text.style.fontSize = `${size}rem`;
+
+ while (
+ (text.scrollWidth > container.clientWidth || text.scrollHeight > container.clientHeight) &&
+ size > 0.8
+ ) {
+ size -= 0.1;
+ text.style.fontSize = `${size}rem`;
+ }
+
+ setFontSize(size);
+ };
+
+ const resizeObserver = new ResizeObserver(adjustTextSize);
+ resizeObserver.observe(containerRef.current);
+
+ adjustTextSize();
+
+ return () => resizeObserver.disconnect();
+ }, [signature?.typedSignature]);
+
+ return (
+
+ {isLoading && (
+
+
+
+ )}
+
+ {state === 'empty' && (
+
+ Signature
+
+ )}
+
+ {state === 'signed-image' && signature?.signatureImageAsBase64 && (
+
+ )}
+
+ {state === 'signed-text' && (
+
+
+ {signature?.typedSignature}
+
+
+ )}
+
+
+
+
+
+ Sign as {recipient.name}{' '}
+ ({recipient.email})
+
+
+
+
+
+ Signature
+
+
+
+ setLocalSignature(value)}
+ allowTypedSignature={typedSignatureEnabled}
+ onValidityChange={(isValid) => {
+ setSignatureValid(isValid);
+ }}
+ />
+
+
+ {!signatureValid && (
+
+ Signature is too small. Please provide a more complete signature.
+
+ )}
+
+
+
+
+
+
+ {
+ setShowSignatureModal(false);
+ setLocalSignature(null);
+ }}
+ >
+ Cancel
+
+ onDialogSignClick()}
+ >
+ Sign
+
+
+
+
+
+
+ );
+};
diff --git a/apps/remix/app/components/general/document-signing/document-signing-text-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-text-field.tsx
new file mode 100644
index 000000000..289c12c30
--- /dev/null
+++ b/apps/remix/app/components/general/document-signing/document-signing-text-field.tsx
@@ -0,0 +1,353 @@
+import { useEffect, useState, useTransition } from 'react';
+
+import { Plural, Trans, msg } from '@lingui/macro';
+import { useLingui } from '@lingui/react';
+import type { Recipient } from '@prisma/client';
+import { Loader, Type } from 'lucide-react';
+
+import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
+import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
+import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
+import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
+import { ZTextFieldMeta } from '@documenso/lib/types/field-meta';
+import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
+import { trpc } from '@documenso/trpc/react';
+import type {
+ TRemovedSignedFieldWithTokenMutationSchema,
+ TSignFieldWithTokenMutationSchema,
+} from '@documenso/trpc/server/field-router/schema';
+import { cn } from '@documenso/ui/lib/utils';
+import { Button } from '@documenso/ui/primitives/button';
+import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
+import { Textarea } from '@documenso/ui/primitives/textarea';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
+import { DocumentSigningFieldContainer } from './document-signing-field-container';
+
+export type DocumentSigningTextFieldProps = {
+ field: FieldWithSignatureAndFieldMeta;
+ recipient: Recipient;
+ onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void;
+ onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void;
+};
+
+export const DocumentSigningTextField = ({
+ field,
+ recipient,
+ onSignField,
+ onUnsignField,
+}: DocumentSigningTextFieldProps) => {
+ const { _ } = useLingui();
+ const { toast } = useToast();
+
+ const initialErrors: Record = {
+ required: [],
+ characterLimit: [],
+ };
+
+ const [errors, setErrors] = useState(initialErrors);
+ const userInputHasErrors = Object.values(errors).some((error) => error.length > 0);
+
+ const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
+
+ const [isPending, startTransition] = useTransition();
+
+ const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } =
+ trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
+
+ const {
+ mutateAsync: removeSignedFieldWithToken,
+ isPending: isRemoveSignedFieldWithTokenLoading,
+ } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
+
+ const parsedFieldMeta = field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : null;
+
+ const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
+ const shouldAutoSignField =
+ (!field.inserted && parsedFieldMeta?.text) ||
+ (!field.inserted && parsedFieldMeta?.text && parsedFieldMeta?.readOnly);
+
+ const [showCustomTextModal, setShowCustomTextModal] = useState(false);
+ const [localText, setLocalCustomText] = useState(parsedFieldMeta?.text ?? '');
+
+ useEffect(() => {
+ if (!showCustomTextModal) {
+ setLocalCustomText(parsedFieldMeta?.text ?? '');
+ setErrors(initialErrors);
+ }
+ }, [showCustomTextModal]);
+
+ const handleTextChange = (e: React.ChangeEvent) => {
+ const text = e.target.value;
+ setLocalCustomText(text);
+
+ if (parsedFieldMeta) {
+ const validationErrors = validateTextField(text, parsedFieldMeta, true);
+ setErrors({
+ required: validationErrors.filter((error) => error.includes('required')),
+ characterLimit: validationErrors.filter((error) => error.includes('character limit')),
+ });
+ }
+ };
+
+ /**
+ * When the user clicks the sign button in the dialog where they enter the text field.
+ */
+ const onDialogSignClick = () => {
+ if (parsedFieldMeta) {
+ const validationErrors = validateTextField(localText, parsedFieldMeta, true);
+
+ if (validationErrors.length > 0) {
+ setErrors({
+ required: validationErrors.filter((error) => error.includes('required')),
+ characterLimit: validationErrors.filter((error) => error.includes('character limit')),
+ });
+ return;
+ }
+ }
+
+ setShowCustomTextModal(false);
+
+ void executeActionAuthProcedure({
+ onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
+ actionTarget: field.type,
+ });
+ };
+
+ const onPreSign = () => {
+ setShowCustomTextModal(true);
+
+ if (localText && parsedFieldMeta) {
+ const validationErrors = validateTextField(localText, parsedFieldMeta, true);
+ setErrors({
+ required: validationErrors.filter((error) => error.includes('required')),
+ characterLimit: validationErrors.filter((error) => error.includes('character limit')),
+ });
+ }
+
+ return false;
+ };
+
+ const onSign = async (authOptions?: TRecipientActionAuth) => {
+ try {
+ if (!localText || userInputHasErrors) {
+ return;
+ }
+
+ const payload: TSignFieldWithTokenMutationSchema = {
+ token: recipient.token,
+ fieldId: field.id,
+ value: localText,
+ isBase64: true,
+ authOptions,
+ };
+
+ if (onSignField) {
+ await onSignField(payload);
+ return;
+ }
+
+ await signFieldWithToken(payload);
+
+ setLocalCustomText('');
+
+ // Todo
+ // startTransition(() => router.refresh());
+ } catch (err) {
+ const error = AppError.parseError(err);
+
+ if (error.code === AppErrorCode.UNAUTHORIZED) {
+ throw error;
+ }
+
+ console.error(err);
+
+ toast({
+ title: _(msg`Error`),
+ description: _(msg`An error occurred while signing the document.`),
+ variant: 'destructive',
+ });
+ }
+ };
+
+ const onRemove = async () => {
+ try {
+ const payload: TRemovedSignedFieldWithTokenMutationSchema = {
+ token: recipient.token,
+ fieldId: field.id,
+ };
+
+ if (onUnsignField) {
+ await onUnsignField(payload);
+ return;
+ }
+
+ await removeSignedFieldWithToken(payload);
+
+ setLocalCustomText(parsedFieldMeta?.text ?? '');
+
+ // Todo
+ // startTransition(() => router.refresh());
+ } catch (err) {
+ console.error(err);
+
+ toast({
+ title: _(msg`Error`),
+ description: _(msg`An error occurred while removing the text.`),
+ variant: 'destructive',
+ });
+ }
+ };
+
+ useEffect(() => {
+ if (shouldAutoSignField) {
+ void executeActionAuthProcedure({
+ onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
+ actionTarget: field.type,
+ });
+ }
+ }, []);
+
+ const parsedField = field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : undefined;
+
+ const labelDisplay =
+ parsedField?.label && parsedField.label.length < 20
+ ? parsedField.label
+ : parsedField?.label
+ ? parsedField?.label.substring(0, 20) + '...'
+ : undefined;
+
+ const textDisplay =
+ parsedField?.text && parsedField.text.length < 20
+ ? parsedField.text
+ : parsedField?.text
+ ? parsedField?.text.substring(0, 20) + '...'
+ : undefined;
+
+ const fieldDisplayName = labelDisplay ? labelDisplay : textDisplay;
+ const charactersRemaining = (parsedFieldMeta?.characterLimit ?? 0) - (localText.length ?? 0);
+
+ return (
+
+ {isLoading && (
+
+
+
+ )}
+
+ {!field.inserted && (
+
+
+
+
+ {fieldDisplayName || Text }
+
+
+
+ )}
+
+ {field.inserted && (
+
+ {field.customText.length < 20
+ ? field.customText
+ : field.customText.substring(0, 15) + '...'}
+
+ )}
+
+
+
+
+ {parsedFieldMeta?.label ? parsedFieldMeta?.label : Text }
+
+
+
+
+
+
+ {parsedFieldMeta?.characterLimit !== undefined &&
+ parsedFieldMeta?.characterLimit > 0 &&
+ !userInputHasErrors && (
+
+ )}
+
+ {userInputHasErrors && (
+
+ {errors.required.map((error, index) => (
+
+ {error}
+
+ ))}
+ {errors.characterLimit.map((error, index) => (
+
+ {error}{' '}
+ {charactersRemaining < 0 && (
+
+ )}
+
+ ))}
+
+ )}
+
+
+
+ {
+ setShowCustomTextModal(false);
+ setLocalCustomText('');
+ }}
+ >
+ Cancel
+
+
+ onDialogSignClick()}
+ >
+ Save
+
+
+
+
+
+
+ );
+};
diff --git a/apps/remix/app/components/pages/document/document-audit-log-download-button.tsx b/apps/remix/app/components/general/document/document-audit-log-download-button.tsx
similarity index 100%
rename from apps/remix/app/components/pages/document/document-audit-log-download-button.tsx
rename to apps/remix/app/components/general/document/document-audit-log-download-button.tsx
diff --git a/apps/remix/app/components/pages/document/document-certificate-download-button.tsx b/apps/remix/app/components/general/document/document-certificate-download-button.tsx
similarity index 100%
rename from apps/remix/app/components/pages/document/document-certificate-download-button.tsx
rename to apps/remix/app/components/general/document/document-certificate-download-button.tsx
diff --git a/apps/remix/app/components/pages/document/document-edit-form.tsx b/apps/remix/app/components/general/document/document-edit-form.tsx
similarity index 100%
rename from apps/remix/app/components/pages/document/document-edit-form.tsx
rename to apps/remix/app/components/general/document/document-edit-form.tsx
diff --git a/apps/remix/app/components/pages/document/document-page-view-button.tsx b/apps/remix/app/components/general/document/document-page-view-button.tsx
similarity index 100%
rename from apps/remix/app/components/pages/document/document-page-view-button.tsx
rename to apps/remix/app/components/general/document/document-page-view-button.tsx
diff --git a/apps/remix/app/components/pages/document/document-page-view-dropdown.tsx b/apps/remix/app/components/general/document/document-page-view-dropdown.tsx
similarity index 100%
rename from apps/remix/app/components/pages/document/document-page-view-dropdown.tsx
rename to apps/remix/app/components/general/document/document-page-view-dropdown.tsx
diff --git a/apps/remix/app/components/pages/document/document-page-view-information.tsx b/apps/remix/app/components/general/document/document-page-view-information.tsx
similarity index 100%
rename from apps/remix/app/components/pages/document/document-page-view-information.tsx
rename to apps/remix/app/components/general/document/document-page-view-information.tsx
diff --git a/apps/remix/app/components/pages/document/document-page-view-recent-activity.tsx b/apps/remix/app/components/general/document/document-page-view-recent-activity.tsx
similarity index 100%
rename from apps/remix/app/components/pages/document/document-page-view-recent-activity.tsx
rename to apps/remix/app/components/general/document/document-page-view-recent-activity.tsx
diff --git a/apps/remix/app/components/pages/document/document-page-view-recipients.tsx b/apps/remix/app/components/general/document/document-page-view-recipients.tsx
similarity index 100%
rename from apps/remix/app/components/pages/document/document-page-view-recipients.tsx
rename to apps/remix/app/components/general/document/document-page-view-recipients.tsx
diff --git a/apps/remix/app/components/pages/teams/team-billing-portal-button.tsx b/apps/remix/app/components/general/teams/team-billing-portal-button.tsx
similarity index 100%
rename from apps/remix/app/components/pages/teams/team-billing-portal-button.tsx
rename to apps/remix/app/components/general/teams/team-billing-portal-button.tsx
diff --git a/apps/remix/app/components/pages/teams/team-email-dropdown.tsx b/apps/remix/app/components/general/teams/team-email-dropdown.tsx
similarity index 100%
rename from apps/remix/app/components/pages/teams/team-email-dropdown.tsx
rename to apps/remix/app/components/general/teams/team-email-dropdown.tsx
diff --git a/apps/remix/app/components/pages/teams/team-settings-desktop-nav.tsx b/apps/remix/app/components/general/teams/team-settings-desktop-nav.tsx
similarity index 100%
rename from apps/remix/app/components/pages/teams/team-settings-desktop-nav.tsx
rename to apps/remix/app/components/general/teams/team-settings-desktop-nav.tsx
diff --git a/apps/remix/app/components/pages/teams/team-settings-mobile-nav.tsx b/apps/remix/app/components/general/teams/team-settings-mobile-nav.tsx
similarity index 100%
rename from apps/remix/app/components/pages/teams/team-settings-mobile-nav.tsx
rename to apps/remix/app/components/general/teams/team-settings-mobile-nav.tsx
diff --git a/apps/remix/app/components/pages/teams/team-transfer-status.tsx b/apps/remix/app/components/general/teams/team-transfer-status.tsx
similarity index 100%
rename from apps/remix/app/components/pages/teams/team-transfer-status.tsx
rename to apps/remix/app/components/general/teams/team-transfer-status.tsx
diff --git a/apps/remix/app/components/pages/template/template-direct-link-badge.tsx b/apps/remix/app/components/general/template/template-direct-link-badge.tsx
similarity index 100%
rename from apps/remix/app/components/pages/template/template-direct-link-badge.tsx
rename to apps/remix/app/components/general/template/template-direct-link-badge.tsx
diff --git a/apps/remix/app/components/pages/template/template-edit-form.tsx b/apps/remix/app/components/general/template/template-edit-form.tsx
similarity index 100%
rename from apps/remix/app/components/pages/template/template-edit-form.tsx
rename to apps/remix/app/components/general/template/template-edit-form.tsx
diff --git a/apps/remix/app/components/pages/template/template-page-view-documents-table.tsx b/apps/remix/app/components/general/template/template-page-view-documents-table.tsx
similarity index 100%
rename from apps/remix/app/components/pages/template/template-page-view-documents-table.tsx
rename to apps/remix/app/components/general/template/template-page-view-documents-table.tsx
diff --git a/apps/remix/app/components/pages/template/template-page-view-information.tsx b/apps/remix/app/components/general/template/template-page-view-information.tsx
similarity index 100%
rename from apps/remix/app/components/pages/template/template-page-view-information.tsx
rename to apps/remix/app/components/general/template/template-page-view-information.tsx
diff --git a/apps/remix/app/components/pages/template/template-page-view-recent-activity.tsx b/apps/remix/app/components/general/template/template-page-view-recent-activity.tsx
similarity index 100%
rename from apps/remix/app/components/pages/template/template-page-view-recent-activity.tsx
rename to apps/remix/app/components/general/template/template-page-view-recent-activity.tsx
diff --git a/apps/remix/app/components/pages/template/template-page-view-recipients.tsx b/apps/remix/app/components/general/template/template-page-view-recipients.tsx
similarity index 100%
rename from apps/remix/app/components/pages/template/template-page-view-recipients.tsx
rename to apps/remix/app/components/general/template/template-page-view-recipients.tsx
diff --git a/apps/remix/app/components/general/webhook-multiselect-combobox.tsx b/apps/remix/app/components/general/webhook-multiselect-combobox.tsx
index ba6eb1ac9..abcadb156 100644
--- a/apps/remix/app/components/general/webhook-multiselect-combobox.tsx
+++ b/apps/remix/app/components/general/webhook-multiselect-combobox.tsx
@@ -16,7 +16,7 @@ import {
} from '@documenso/ui/primitives/command';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
-import { truncateTitle } from '~/helpers/truncate-title';
+import { truncateTitle } from '~/utils/truncate-title';
type WebhookMultiSelectComboboxProps = {
listValues: string[];
diff --git a/apps/remix/app/components/tables/templates-table.tsx b/apps/remix/app/components/tables/templates-table.tsx
index d3641a224..43fc68809 100644
--- a/apps/remix/app/components/tables/templates-table.tsx
+++ b/apps/remix/app/components/tables/templates-table.tsx
@@ -21,7 +21,7 @@ import { TemplateType } from '~/components/formatter/template-type';
import { useOptionalCurrentTeam } from '~/providers/team';
import { TemplateUseDialog } from '../dialogs/template-use-dialog';
-import { TemplateDirectLinkBadge } from '../pages/template/template-direct-link-badge';
+import { TemplateDirectLinkBadge } from '../general/template/template-direct-link-badge';
import { TemplatesTableActionDropdown } from './templates-table-action-dropdown';
type TemplatesTableProps = {
diff --git a/apps/remix/app/providers/next-theme.tsx b/apps/remix/app/providers/next-theme.tsx
deleted file mode 100644
index d15114606..000000000
--- a/apps/remix/app/providers/next-theme.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-'use client';
-
-import * as React from 'react';
-
-import { ThemeProvider as NextThemesProvider } from 'next-themes';
-import type { ThemeProviderProps } from 'next-themes/dist/types';
-
-export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
- return {children} ;
-}
diff --git a/apps/remix/app/providers/plausible.tsx b/apps/remix/app/providers/plausible.tsx
index dceaa4d93..ca8f3c691 100644
--- a/apps/remix/app/providers/plausible.tsx
+++ b/apps/remix/app/providers/plausible.tsx
@@ -1,5 +1,3 @@
-'use client';
-
import React from 'react';
import NextPlausibleProvider from 'next-plausible';
diff --git a/apps/remix/app/providers/posthog.tsx b/apps/remix/app/providers/posthog.tsx
index e386dd9ec..eb3e7dd23 100644
--- a/apps/remix/app/providers/posthog.tsx
+++ b/apps/remix/app/providers/posthog.tsx
@@ -1,9 +1,9 @@
import { useEffect } from 'react';
-import { getSession } from 'next-auth/react';
import posthog from 'posthog-js';
import { useLocation, useSearchParams } from 'react-router';
+import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
export function PostHogPageview() {
@@ -12,22 +12,18 @@ export function PostHogPageview() {
const { pathname } = useLocation();
const [searchParams] = useSearchParams();
+ const { user } = useOptionalSession();
+
if (typeof window !== 'undefined' && postHogConfig) {
posthog.init(postHogConfig.key, {
api_host: postHogConfig.host,
disable_session_recording: true,
loaded: () => {
- getSession()
- .then((session) => {
- if (session) {
- posthog.identify(session.user.email ?? session.user.id.toString());
- } else {
- posthog.reset();
- }
- })
- .catch(() => {
- // Do nothing.
- });
+ if (user) {
+ posthog.identify(user.email ?? user.id.toString());
+ } else {
+ posthog.reset();
+ }
},
custom_campaign_params: ['src'],
});
diff --git a/apps/remix/app/root.tsx b/apps/remix/app/root.tsx
index 13216183b..6a7047ea9 100644
--- a/apps/remix/app/root.tsx
+++ b/apps/remix/app/root.tsx
@@ -7,13 +7,16 @@ import {
isRouteErrorResponse,
useLoaderData,
} from 'react-router';
+import { ThemeProvider } from 'remix-themes';
+import { SessionProvider } from '@documenso/lib/client-only/providers/session';
import { TrpcProvider } from '@documenso/trpc/react';
import { Toaster } from '@documenso/ui/primitives/toaster';
import { TooltipProvider } from '@documenso/ui/primitives/tooltip';
import type { Route } from './+types/root';
import stylesheet from './app.css?url';
+import { themeSessionResolver } from './storage/theme-session.server';
export const links: Route.LinksFunction = () => [
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
@@ -33,8 +36,12 @@ export const links: Route.LinksFunction = () => [
{ rel: 'stylesheet', href: stylesheet },
];
-export function loader() {
+export async function loader({ request, context }: Route.LoaderArgs) {
+ const { getTheme } = await themeSessionResolver(request);
+
return {
+ theme: getTheme(),
+ session: context.session,
__ENV__: Object.fromEntries(
Object.entries(process.env).filter(([key]) => key.startsWith('NEXT_')),
),
@@ -42,15 +49,18 @@ export function loader() {
}
export function Layout({ children }: { children: React.ReactNode }) {
- const { __ENV__ } = useLoaderData() || {};
+ const { __ENV__, theme } = useLoaderData() || {};
+
+ // const [theme] = useTheme();
return (
-
+
+ {/* */}
{children}
@@ -67,15 +77,20 @@ export function Layout({ children }: { children: React.ReactNode }) {
);
}
-export default function App() {
+export default function App({ loaderData }: Route.ComponentProps) {
return (
-
-
-
+
+ {/* Todo: Themes (this won't work for now) */}
+
+
+
+
-
-
-
+
+
+
+
+
);
}
diff --git a/apps/remix/app/routes/_authenticated+/_layout.tsx b/apps/remix/app/routes/_authenticated+/_layout.tsx
index 93c4b174a..69984425b 100644
--- a/apps/remix/app/routes/_authenticated+/_layout.tsx
+++ b/apps/remix/app/routes/_authenticated+/_layout.tsx
@@ -1,8 +1,6 @@
-import { Outlet } from 'react-router';
-import { redirect } from 'react-router';
+import { Outlet, redirect } from 'react-router';
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client';
-import { SessionProvider } from '@documenso/lib/client-only/providers/session';
import { Header } from '~/components/(dashboard)/layout/header';
import { VerifyEmailBanner } from '~/components/(dashboard)/layout/verify-email-banner';
@@ -13,33 +11,30 @@ export const loader = ({ context }: Route.LoaderArgs) => {
const { session } = context;
if (!session) {
- return redirect('/signin');
+ throw redirect('/signin');
}
return {
user: session.user,
- session: session.session,
teams: session.teams,
};
};
export default function Layout({ loaderData }: Route.ComponentProps) {
- const { user, session, teams } = loaderData;
+ const { user, teams } = loaderData;
return (
-
-
- {!user.emailVerified && }
+
+ {!user.emailVerified && }
- {/* // Todo: Banner */}
- {/* */}
+ {/* // Todo: Banner */}
+ {/* */}
-
+
-
-
-
-
-
+
+
+
+
);
}
diff --git a/apps/remix/app/routes/_authenticated+/admin+/_index.tsx b/apps/remix/app/routes/_authenticated+/admin+/_index.tsx
index 84361792e..d17a970f5 100644
--- a/apps/remix/app/routes/_authenticated+/admin+/_index.tsx
+++ b/apps/remix/app/routes/_authenticated+/admin+/_index.tsx
@@ -1,7 +1,7 @@
import { redirect } from 'react-router';
export function loader() {
- return redirect('/admin/stats');
+ throw redirect('/admin/stats');
}
export default function AdminPage() {
diff --git a/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx b/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
index 0aa71db4b..14b0c2eb7 100644
--- a/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
+++ b/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
@@ -13,7 +13,7 @@ export function loader({ context }: Route.LoaderArgs) {
const { user } = getRequiredSessionContext(context);
if (!user || !isAdmin(user)) {
- return redirect('/documents');
+ throw redirect('/documents');
}
}
diff --git a/apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx b/apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
index 51e339698..f2b1bf765 100644
--- a/apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
+++ b/apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
@@ -35,7 +35,7 @@ export async function loader({ params }: Route.LoaderArgs) {
// Todo: Is it possible for this to return data to the frontend w/out auth layout due to race condition?
if (isNaN(id)) {
- return redirect('/admin/documents');
+ throw redirect('/admin/documents');
}
const document = await getEntireDocument({ id });
diff --git a/apps/remix/app/routes/_authenticated+/documents+/$id._index.tsx b/apps/remix/app/routes/_authenticated+/documents+/$id._index.tsx
index a6fa4a749..9ab2abcc5 100644
--- a/apps/remix/app/routes/_authenticated+/documents+/$id._index.tsx
+++ b/apps/remix/app/routes/_authenticated+/documents+/$id._index.tsx
@@ -1,19 +1,16 @@
import { Plural, Trans } from '@lingui/macro';
import { useLingui } from '@lingui/react';
-import { DocumentStatus } from '@prisma/client';
-import { TeamMemberRole } from '@prisma/client';
+import { DocumentStatus, TeamMemberRole } from '@prisma/client';
import { ChevronLeft, Clock9, Users2 } from 'lucide-react';
import { Link, redirect } from 'react-router';
import { getRequiredSessionContext } from 'server/utils/get-required-session-context';
import { match } from 'ts-pattern';
import { useSession } from '@documenso/lib/client-only/providers/session';
-import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
-import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
@@ -28,11 +25,11 @@ import {
DocumentStatus as DocumentStatusComponent,
FRIENDLY_STATUS_MAP,
} from '~/components/formatter/document-status';
-import { DocumentPageViewButton } from '~/components/pages/document/document-page-view-button';
-import { DocumentPageViewDropdown } from '~/components/pages/document/document-page-view-dropdown';
-import { DocumentPageViewInformation } from '~/components/pages/document/document-page-view-information';
-import { DocumentPageViewRecentActivity } from '~/components/pages/document/document-page-view-recent-activity';
-import { DocumentPageViewRecipients } from '~/components/pages/document/document-page-view-recipients';
+import { DocumentPageViewButton } from '~/components/general/document/document-page-view-button';
+import { DocumentPageViewDropdown } from '~/components/general/document/document-page-view-dropdown';
+import { DocumentPageViewInformation } from '~/components/general/document/document-page-view-information';
+import { DocumentPageViewRecentActivity } from '~/components/general/document/document-page-view-recent-activity';
+import { DocumentPageViewRecipients } from '~/components/general/document/document-page-view-recipients';
import type { Route } from './+types/$id._index';
@@ -46,7 +43,7 @@ export async function loader({ params, context }: Route.LoaderArgs) {
const documentRootPath = formatDocumentsPath(team?.url);
if (!documentId || Number.isNaN(documentId)) {
- return redirect(documentRootPath);
+ throw redirect(documentRootPath);
}
const document = await getDocumentById({
@@ -56,7 +53,7 @@ export async function loader({ params, context }: Route.LoaderArgs) {
}).catch(() => null);
if (document?.teamId && !team?.url) {
- return redirect(documentRootPath);
+ throw redirect(documentRootPath);
}
const documentVisibility = document?.visibility;
@@ -76,31 +73,32 @@ export async function loader({ params, context }: Route.LoaderArgs) {
}
if (!document || !document.documentData || (team && !canAccessDocument)) {
- return redirect(documentRootPath);
+ throw redirect(documentRootPath);
}
if (team && !canAccessDocument) {
- return redirect(documentRootPath);
+ throw redirect(documentRootPath);
}
const { documentMeta } = document;
- if (documentMeta?.password) {
- const key = DOCUMENSO_ENCRYPTION_KEY;
+ // Todo: We don't handle encrypted files right.
+ // if (documentMeta?.password) {
+ // const key = DOCUMENSO_ENCRYPTION_KEY;
- if (!key) {
- throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY');
- }
+ // if (!key) {
+ // throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY');
+ // }
- const securePassword = Buffer.from(
- symmetricDecrypt({
- key,
- data: documentMeta.password,
- }),
- ).toString('utf-8');
+ // const securePassword = Buffer.from(
+ // symmetricDecrypt({
+ // key,
+ // data: documentMeta.password,
+ // }),
+ // ).toString('utf-8');
- documentMeta.password = securePassword;
- }
+ // documentMeta.password = securePassword;
+ // }
// Todo: Get full document instead???
const [recipients, fields] = await Promise.all([
diff --git a/apps/remix/app/routes/_authenticated+/documents+/$id.edit.tsx b/apps/remix/app/routes/_authenticated+/documents+/$id.edit.tsx
index f9415fa60..a500de06e 100644
--- a/apps/remix/app/routes/_authenticated+/documents+/$id.edit.tsx
+++ b/apps/remix/app/routes/_authenticated+/documents+/$id.edit.tsx
@@ -1,21 +1,19 @@
import { Plural, Trans } from '@lingui/macro';
-import { TeamMemberRole } from '@prisma/client';
-import { DocumentStatus as InternalDocumentStatus } from '@prisma/client';
+import { DocumentStatus as InternalDocumentStatus, TeamMemberRole } from '@prisma/client';
import { ChevronLeft, Users2 } from 'lucide-react';
import { Link, redirect } from 'react-router';
import { getRequiredSessionContext } from 'server/utils/get-required-session-context';
import { match } from 'ts-pattern';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
-import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
-import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
import { DocumentStatus } from '~/components/formatter/document-status';
-import { DocumentEditForm } from '~/components/pages/document/document-edit-form';
+import { DocumentEditForm } from '~/components/general/document/document-edit-form';
+import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/$id.edit';
@@ -29,7 +27,7 @@ export async function loader({ params, context }: Route.LoaderArgs) {
const documentRootPath = formatDocumentsPath(team?.url);
if (!documentId || Number.isNaN(documentId)) {
- return redirect(documentRootPath);
+ throw redirect(documentRootPath);
}
const document = await getDocumentWithDetailsById({
@@ -39,7 +37,7 @@ export async function loader({ params, context }: Route.LoaderArgs) {
}).catch(() => null);
if (document?.teamId && !team?.url) {
- return redirect(documentRootPath);
+ throw redirect(documentRootPath);
}
const documentVisibility = document?.visibility;
@@ -59,50 +57,49 @@ export async function loader({ params, context }: Route.LoaderArgs) {
}
if (!document) {
- return redirect(documentRootPath);
+ throw redirect(documentRootPath);
}
if (team && !canAccessDocument) {
- return redirect(documentRootPath);
+ throw redirect(documentRootPath);
}
if (document.status === InternalDocumentStatus.COMPLETED) {
- return redirect(`${documentRootPath}/${documentId}`);
+ throw redirect(`${documentRootPath}/${documentId}`);
}
- const { documentMeta, recipients } = document;
+ // Todo: We don't handle encrypted files right.
+ // if (documentMeta?.password) {
+ // const key = DOCUMENSO_ENCRYPTION_KEY;
- if (documentMeta?.password) {
- const key = DOCUMENSO_ENCRYPTION_KEY;
+ // if (!key) {
+ // throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY');
+ // }
- if (!key) {
- throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY');
- }
+ // const securePassword = Buffer.from(
+ // symmetricDecrypt({
+ // key,
+ // data: documentMeta.password,
+ // }),
+ // ).toString('utf-8');
- const securePassword = Buffer.from(
- symmetricDecrypt({
- key,
- data: documentMeta.password,
- }),
- ).toString('utf-8');
-
- documentMeta.password = securePassword;
- }
+ // documentMeta.password = securePassword;
+ // }
const isDocumentEnterprise = await isUserEnterprise({
userId: user.id,
teamId: team?.id,
});
- return {
+ return superLoaderJson({
document,
documentRootPath,
isDocumentEnterprise,
- };
+ });
}
-export default function DocumentEditPage({ loaderData }: Route.ComponentProps) {
- const { document, documentRootPath, isDocumentEnterprise } = loaderData;
+export default function DocumentEditPage() {
+ const { document, documentRootPath, isDocumentEnterprise } = useSuperLoaderData();
const { recipients } = document;
diff --git a/apps/remix/app/routes/_authenticated+/documents+/$id.logs.tsx b/apps/remix/app/routes/_authenticated+/documents+/$id.logs.tsx
index 9c37bed7c..c36ea713c 100644
--- a/apps/remix/app/routes/_authenticated+/documents+/$id.logs.tsx
+++ b/apps/remix/app/routes/_authenticated+/documents+/$id.logs.tsx
@@ -7,19 +7,17 @@ import { DateTime } from 'luxon';
import { Link, redirect } from 'react-router';
import { getRequiredSessionContext } from 'server/utils/get-required-session-context';
-import { getRequiredSession } from '@documenso/auth/server/lib/utils/get-session';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
-import { prisma } from '@documenso/prisma';
import { Card } from '@documenso/ui/primitives/card';
import {
DocumentStatus as DocumentStatusComponent,
FRIENDLY_STATUS_MAP,
} from '~/components/formatter/document-status';
-import { DocumentAuditLogDownloadButton } from '~/components/pages/document/document-audit-log-download-button';
-import { DocumentCertificateDownloadButton } from '~/components/pages/document/document-certificate-download-button';
+import { DocumentAuditLogDownloadButton } from '~/components/general/document/document-audit-log-download-button';
+import { DocumentCertificateDownloadButton } from '~/components/general/document/document-certificate-download-button';
import { DocumentLogsTable } from '~/components/tables/document-logs-table';
import type { Route } from './+types/$id.logs';
@@ -34,7 +32,7 @@ export async function loader({ params, context }: Route.LoaderArgs) {
const documentRootPath = formatDocumentsPath(team?.url);
if (!documentId || Number.isNaN(documentId)) {
- return redirect(documentRootPath);
+ throw redirect(documentRootPath);
}
// Todo: Get detailed?
@@ -52,7 +50,7 @@ export async function loader({ params, context }: Route.LoaderArgs) {
]);
if (!document || !document.documentData) {
- return redirect(documentRootPath);
+ throw redirect(documentRootPath);
}
return {
diff --git a/apps/remix/app/routes/_authenticated+/settings+/_index.tsx b/apps/remix/app/routes/_authenticated+/settings+/_index.tsx
index 300bacd9f..f66113db0 100644
--- a/apps/remix/app/routes/_authenticated+/settings+/_index.tsx
+++ b/apps/remix/app/routes/_authenticated+/settings+/_index.tsx
@@ -1,5 +1,5 @@
import { redirect } from 'react-router';
export function loader() {
- return redirect('/settings/profile');
+ throw redirect('/settings/profile');
}
diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/_layout.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/_layout.tsx
index 32678841d..1eaf48878 100644
--- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/_layout.tsx
+++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/_layout.tsx
@@ -18,7 +18,7 @@ export const loader = ({ context }: Route.LoaderArgs) => {
const { currentTeam } = getRequiredSessionContext(context);
if (!currentTeam) {
- return redirect('/documents');
+ throw redirect('/documents');
}
const trpcHeaders = {
diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings+/_index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings+/_index.tsx
index be509e16e..bd7be3ff4 100644
--- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings+/_index.tsx
+++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings+/_index.tsx
@@ -15,8 +15,8 @@ import { TeamDeleteDialog } from '~/components/dialogs/team-delete-dialog';
import { TeamEmailAddDialog } from '~/components/dialogs/team-email-add-dialog';
import { TeamTransferDialog } from '~/components/dialogs/team-transfer-dialog';
import { AvatarImageForm } from '~/components/forms/avatar-image';
-import { TeamEmailDropdown } from '~/components/pages/teams/team-email-dropdown';
-import { TeamTransferStatus } from '~/components/pages/teams/team-transfer-status';
+import { TeamEmailDropdown } from '~/components/general/teams/team-email-dropdown';
+import { TeamTransferStatus } from '~/components/general/teams/team-transfer-status';
import { useCurrentTeam } from '~/providers/team';
export default function TeamsSettingsPage() {
diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings+/_layout.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings+/_layout.tsx
index 15d0b5cbc..786933067 100644
--- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings+/_layout.tsx
+++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings+/_layout.tsx
@@ -4,8 +4,8 @@ import { getRequiredTeamSessionContext } from 'server/utils/get-required-session
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
-import { TeamSettingsDesktopNav } from '~/components/pages/teams/team-settings-desktop-nav';
-import { TeamSettingsMobileNav } from '~/components/pages/teams/team-settings-mobile-nav';
+import { TeamSettingsDesktopNav } from '~/components/general/teams/team-settings-desktop-nav';
+import { TeamSettingsMobileNav } from '~/components/general/teams/team-settings-mobile-nav';
import type { Route } from '../+types/_layout';
diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings+/billing.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings+/billing.tsx
index 620576ea9..f48e33b51 100644
--- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings+/billing.tsx
+++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings+/billing.tsx
@@ -11,7 +11,7 @@ import { Card, CardContent } from '@documenso/ui/primitives/card';
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
import { TeamBillingInvoicesDataTable } from '~/components/(teams)/tables/team-billing-invoices-data-table';
-import { TeamBillingPortalButton } from '~/components/pages/teams/team-billing-portal-button';
+import { TeamBillingPortalButton } from '~/components/general/teams/team-billing-portal-button';
import type { Route } from './+types/billing';
diff --git a/apps/remix/app/routes/_authenticated+/templates+/$id._index.tsx b/apps/remix/app/routes/_authenticated+/templates+/$id._index.tsx
index 2944adaa0..06598ba4a 100644
--- a/apps/remix/app/routes/_authenticated+/templates+/$id._index.tsx
+++ b/apps/remix/app/routes/_authenticated+/templates+/$id._index.tsx
@@ -1,10 +1,9 @@
import { Trans } from '@lingui/macro';
-import { DocumentSigningOrder, SigningStatus, type Team } from '@prisma/client';
+import { DocumentSigningOrder, SigningStatus } from '@prisma/client';
import { ChevronLeft, LucideEdit } from 'lucide-react';
import { Link, redirect } from 'react-router';
import { getRequiredSessionContext } from 'server/utils/get-required-session-context';
-import { useSession } from '@documenso/lib/client-only/providers/session';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { Button } from '@documenso/ui/primitives/button';
@@ -15,13 +14,13 @@ import { TemplateDirectLinkDialogWrapper } from '~/components/dialogs/template-d
import { TemplateUseDialog } from '~/components/dialogs/template-use-dialog';
import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields';
import { TemplateType } from '~/components/formatter/template-type';
-import { TemplateDirectLinkBadge } from '~/components/pages/template/template-direct-link-badge';
-import { TemplatePageViewDocumentsTable } from '~/components/pages/template/template-page-view-documents-table';
-import { TemplatePageViewInformation } from '~/components/pages/template/template-page-view-information';
-import { TemplatePageViewRecentActivity } from '~/components/pages/template/template-page-view-recent-activity';
-import { TemplatePageViewRecipients } from '~/components/pages/template/template-page-view-recipients';
+import { TemplateDirectLinkBadge } from '~/components/general/template/template-direct-link-badge';
+import { TemplatePageViewDocumentsTable } from '~/components/general/template/template-page-view-documents-table';
+import { TemplatePageViewInformation } from '~/components/general/template/template-page-view-information';
+import { TemplatePageViewRecentActivity } from '~/components/general/template/template-page-view-recent-activity';
+import { TemplatePageViewRecipients } from '~/components/general/template/template-page-view-recipients';
import { TemplatesTableActionDropdown } from '~/components/tables/templates-table-action-dropdown';
-import { useOptionalCurrentTeam } from '~/providers/team';
+import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/$id._index';
@@ -35,7 +34,7 @@ export async function loader({ params, context }: Route.LoaderArgs) {
const documentRootPath = formatDocumentsPath(team?.url);
if (!templateId || Number.isNaN(templateId)) {
- return redirect(templateRootPath);
+ throw redirect(templateRootPath);
}
const template = await getTemplateById({
@@ -45,20 +44,21 @@ export async function loader({ params, context }: Route.LoaderArgs) {
}).catch(() => null);
if (!template || !template.templateDocumentData || (template?.teamId && !team?.url)) {
- return redirect(templateRootPath);
+ throw redirect(templateRootPath);
}
- return {
+ return superLoaderJson({
user,
team,
template,
templateRootPath,
documentRootPath,
- };
+ });
}
-export default function TemplatePage({ loaderData }: Route.ComponentProps) {
- const { user, team, template, templateRootPath, documentRootPath } = loaderData;
+export default function TemplatePage() {
+ const { user, team, template, templateRootPath, documentRootPath } =
+ useSuperLoaderData();
const { templateDocumentData, fields, recipients, templateMeta } = template;
diff --git a/apps/remix/app/routes/_authenticated+/templates+/$id.edit.tsx b/apps/remix/app/routes/_authenticated+/templates+/$id.edit.tsx
index ad5cf370b..f8f1a3d61 100644
--- a/apps/remix/app/routes/_authenticated+/templates+/$id.edit.tsx
+++ b/apps/remix/app/routes/_authenticated+/templates+/$id.edit.tsx
@@ -8,8 +8,9 @@ import { getTemplateById } from '@documenso/lib/server-only/template/get-templat
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { TemplateType } from '~/components/formatter/template-type';
-import { TemplateDirectLinkBadge } from '~/components/pages/template/template-direct-link-badge';
-import { TemplateEditForm } from '~/components/pages/template/template-edit-form';
+import { TemplateDirectLinkBadge } from '~/components/general/template/template-direct-link-badge';
+import { TemplateEditForm } from '~/components/general/template/template-edit-form';
+import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import { TemplateDirectLinkDialogWrapper } from '../../../components/dialogs/template-direct-link-dialog-wrapper';
import type { Route } from './+types/$id.edit';
@@ -23,7 +24,7 @@ export async function loader({ context, params }: Route.LoaderArgs) {
const templateRootPath = formatTemplatesPath(team?.url);
if (!templateId || Number.isNaN(templateId)) {
- return redirect(templateRootPath);
+ throw redirect(templateRootPath);
}
const template = await getTemplateById({
@@ -33,7 +34,7 @@ export async function loader({ context, params }: Route.LoaderArgs) {
}).catch(() => null);
if (!template || !template.templateDocumentData) {
- return redirect(templateRootPath);
+ throw redirect(templateRootPath);
}
const isTemplateEnterprise = await isUserEnterprise({
@@ -41,15 +42,15 @@ export async function loader({ context, params }: Route.LoaderArgs) {
teamId: team?.id,
});
- return {
+ return superLoaderJson({
template,
isTemplateEnterprise,
templateRootPath,
- };
+ });
}
-export default function TemplateEditPage({ loaderData }: Route.ComponentProps) {
- const { template, isTemplateEnterprise, templateRootPath } = loaderData;
+export default function TemplateEditPage() {
+ const { template, isTemplateEnterprise, templateRootPath } = useSuperLoaderData();
return (
diff --git a/apps/remix/app/routes/_index.tsx b/apps/remix/app/routes/_index.tsx
index 78020bd50..6bc005462 100644
--- a/apps/remix/app/routes/_index.tsx
+++ b/apps/remix/app/routes/_index.tsx
@@ -2,10 +2,10 @@ import { redirect } from 'react-router';
import type { Route } from './+types/_index';
-export async function loader({ context }: Route.LoaderArgs) {
+export function loader({ context }: Route.LoaderArgs) {
if (context.session) {
- return redirect('/documents');
+ throw redirect('/documents');
}
- return redirect('/signin');
+ throw redirect('/signin');
}
diff --git a/apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx b/apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
index 37d0d9d84..ddeeba5fd 100644
--- a/apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
+++ b/apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
@@ -1,7 +1,6 @@
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { DateTime } from 'luxon';
-import { useSearchParams } from 'react-router';
import { redirect } from 'react-router';
import { DOCUMENT_STATUS } from '@documenso/lib/constants/document';
@@ -22,13 +21,13 @@ export async function loader({ request }: Route.LoaderArgs) {
const d = new URL(request.url).searchParams.get('d');
if (typeof d !== 'string' || !d) {
- return redirect('/');
+ throw redirect('/');
}
const rawDocumentId = decryptSecondaryData(d);
if (!rawDocumentId || isNaN(Number(rawDocumentId))) {
- return redirect('/');
+ throw redirect('/');
}
const documentId = Number(rawDocumentId);
@@ -38,7 +37,7 @@ export async function loader({ request }: Route.LoaderArgs) {
}).catch(() => null);
if (!document) {
- return redirect('/');
+ throw redirect('/');
}
const documentLanguage = ZSupportedLanguageCodeSchema.parse(document.documentMeta?.language);
@@ -68,7 +67,8 @@ export default function AuditLog({ loaderData }: Route.ComponentProps) {
const { i18n } = useLingui();
- dynamicActivate(i18n, documentLanguage);
+ // Todo
+ void dynamicActivate(i18n, documentLanguage);
const { _ } = useLingui();
diff --git a/apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx b/apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
index 06880d80e..28d5ce96c 100644
--- a/apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
+++ b/apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
@@ -1,7 +1,8 @@
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
+import { FieldType } from '@prisma/client';
import { DateTime } from 'luxon';
-import { redirect, useSearchParams } from 'react-router';
+import { redirect } from 'react-router';
import { match } from 'ts-pattern';
import { UAParser } from 'ua-parser-js';
@@ -15,8 +16,6 @@ import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt'
import { getDocumentCertificateAuditLogs } from '@documenso/lib/server-only/document/get-document-certificate-audit-logs';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
-import { dynamicActivate } from '@documenso/lib/utils/i18n';
-import { FieldType } from '@documenso/prisma/client';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import {
Table,
@@ -40,13 +39,13 @@ export async function loader({ request }: Route.LoaderArgs) {
const d = new URL(request.url).searchParams.get('d');
if (typeof d !== 'string' || !d) {
- return redirect('/');
+ throw redirect('/');
}
const rawDocumentId = decryptSecondaryData(d);
if (!rawDocumentId || isNaN(Number(rawDocumentId))) {
- return redirect('/');
+ throw redirect('/');
}
const documentId = Number(rawDocumentId);
@@ -56,7 +55,7 @@ export async function loader({ request }: Route.LoaderArgs) {
}).catch(() => null);
if (!document) {
- return redirect('/');
+ throw redirect('/');
}
const documentLanguage = ZSupportedLanguageCodeSchema.parse(document.documentMeta?.language);
@@ -86,7 +85,8 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
const { _ } = useLingui();
- dynamicActivate(i18n, documentLanguage);
+ // Todo
+ // dynamicActivate(i18n, documentLanguage);
const isOwner = (email: string) => {
return email.toLowerCase() === document.user.email.toLowerCase();
diff --git a/apps/remix/app/routes/_profile+/_layout.tsx b/apps/remix/app/routes/_profile+/_layout.tsx
new file mode 100644
index 000000000..a47915c73
--- /dev/null
+++ b/apps/remix/app/routes/_profile+/_layout.tsx
@@ -0,0 +1,129 @@
+import { useEffect, useState } from 'react';
+
+import { Trans } from '@lingui/macro';
+import { PlusIcon } from 'lucide-react';
+import { ChevronLeft } from 'lucide-react';
+import { Link, Outlet } from 'react-router';
+
+import LogoIcon from '@documenso/assets/logo_icon.png';
+import { cn } from '@documenso/ui/lib/utils';
+import { Button } from '@documenso/ui/primitives/button';
+
+import { Header as AuthenticatedHeader } from '~/components/(dashboard)/layout/header';
+import { Logo } from '~/components/branding/logo';
+
+import type { Route } from './+types/_layout';
+
+export function loader({ context }: Route.LoaderArgs) {
+ const { session } = context;
+
+ return {
+ session,
+ };
+}
+
+export default function PublicProfileLayout({ loaderData }: Route.ComponentProps) {
+ const { session } = loaderData;
+
+ const [scrollY, setScrollY] = useState(0);
+
+ useEffect(() => {
+ const onScroll = () => {
+ setScrollY(window.scrollY);
+ };
+
+ window.addEventListener('scroll', onScroll);
+
+ return () => window.removeEventListener('scroll', onScroll);
+ }, []);
+
+ return (
+
+ {session ? (
+
+ ) : (
+
5 && 'border-b-border',
+ )}
+ >
+
+
+
+
+
+
+
+
+
+
+ Want your own public profile?
+
+
+ Like to have your own public profile with agreements?
+
+
+
+
+
+
+
+
+ Create
+
+
+
+
+
+
+ )}
+
+
+
+
+
+ );
+}
+
+// Todo: Test
+export function ErrorBoundary() {
+ return (
+
+
+
+ 404 Profile not found
+
+
+
+ Oops! Something went wrong.
+
+
+
+ The profile you are looking for could not be found.
+
+
+
+
+
+
+ Go Back
+
+
+
+
+
+ );
+}
diff --git a/apps/remix/app/routes/_profile+/p.$url.tsx b/apps/remix/app/routes/_profile+/p.$url.tsx
new file mode 100644
index 000000000..2903440ea
--- /dev/null
+++ b/apps/remix/app/routes/_profile+/p.$url.tsx
@@ -0,0 +1,205 @@
+import { Trans } from '@lingui/macro';
+import { FileIcon } from 'lucide-react';
+import { DateTime } from 'luxon';
+import { Link, redirect } from 'react-router';
+
+import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
+import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
+import { getPublicProfileByUrl } from '@documenso/lib/server-only/profile/get-public-profile-by-url';
+import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
+import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
+import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
+import { Button } from '@documenso/ui/primitives/button';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@documenso/ui/primitives/table';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
+
+import type { Route } from './+types/p.$url';
+
+const BADGE_DATA = {
+ Premium: {
+ imageSrc: '/static/premium-user-badge.svg',
+ name: 'Premium',
+ },
+ EarlySupporter: {
+ imageSrc: '/static/early-supporter-badge.svg',
+ name: 'Early supporter',
+ },
+};
+
+export async function loader({ params }: Route.LoaderArgs) {
+ const { url: profileUrl } = params;
+
+ if (!profileUrl) {
+ throw redirect('/');
+ }
+
+ const publicProfile = await getPublicProfileByUrl({
+ profileUrl,
+ }).catch(() => null);
+
+ // Todo: Test
+ if (!publicProfile || !publicProfile.profile.enabled) {
+ throw new Response('Not Found', { status: 404 });
+ }
+
+ return {
+ publicProfile,
+ };
+}
+
+export default function PublicProfilePage({ loaderData }: Route.ComponentProps) {
+ const { publicProfile } = loaderData;
+
+ const { profile, templates } = publicProfile;
+
+ const { user } = useOptionalSession();
+
+ return (
+
+
+
+ {publicProfile.avatarImageId && (
+
+ )}
+
+
+ {extractInitials(publicProfile.name)}
+
+
+
+
+
{publicProfile.name}
+
+ {publicProfile.badge && (
+
+
+
+
+
+
+
+
+
+
+ {BADGE_DATA[publicProfile.badge.type].name}
+
+
+
+ Since {DateTime.fromJSDate(publicProfile.badge.since).toFormat('LLL ‘yy')}
+
+
+
+
+
+ )}
+
+
+
+ {(profile.bio ?? '').split('\n').map((line, index) => (
+
+ {line}
+
+ ))}
+
+
+
+ {templates.length === 0 && (
+
+
+
+ It looks like {publicProfile.name} hasn't added any documents to their profile yet.
+ {' '}
+ {!user?.id && (
+
+
+ While waiting for them to do so you can create your own Documenso account and get
+ started with document signing right away.
+
+
+ )}
+ {'userId' in profile && user?.id === profile.userId && (
+
+
+ Go to your{' '}
+
+ public profile settings
+ {' '}
+ to add documents.
+
+
+ )}
+
+
+ )}
+
+ {templates.length > 0 && (
+
+
+
+
+
+ Documents
+
+
+
+
+ {templates.map((template) => (
+
+
+
+
+
+
+
+
+ {template.publicTitle}
+
+
+ {template.publicDescription}
+
+
+
+
+
+ Sign
+
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+ );
+}
diff --git a/apps/remix/app/routes/_recipient+/_layout.tsx b/apps/remix/app/routes/_recipient+/_layout.tsx
new file mode 100644
index 000000000..7a6259920
--- /dev/null
+++ b/apps/remix/app/routes/_recipient+/_layout.tsx
@@ -0,0 +1,68 @@
+import { Trans } from '@lingui/macro';
+import { ChevronLeft } from 'lucide-react';
+import { Link, Outlet } from 'react-router';
+
+import { Button } from '@documenso/ui/primitives/button';
+
+import { Header as AuthenticatedHeader } from '~/components/(dashboard)/layout/header';
+
+import type { Route } from './+types/_layout';
+
+export async function loader({ context }: Route.LoaderArgs) {
+ return {
+ user: context.session?.user,
+ teams: context.session?.teams || [],
+ };
+}
+
+/**
+ * A layout to handle scenarios where the user is a recipient of a given resource
+ * where we do not care whether they are authenticated or not.
+ *
+ * Such as direct template access, or signing.
+ */
+export default function RecipientLayout({ loaderData }: Route.ComponentProps) {
+ const { user, teams } = loaderData;
+
+ return (
+
+ );
+}
+
+export function ErrorBoundary() {
+ return (
+
+
+
+ 404 Not found
+
+
+
+ Oops! Something went wrong.
+
+
+
+
+ The resource you are looking for may have been disabled, deleted or may have never
+ existed.
+
+
+
+
+
+
+
+ Go Back
+
+
+
+
+
+ );
+}
diff --git a/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx b/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx
new file mode 100644
index 000000000..80ed27c9d
--- /dev/null
+++ b/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx
@@ -0,0 +1,113 @@
+import { Plural } from '@lingui/macro';
+import { UsersIcon } from 'lucide-react';
+import { redirect } from 'react-router';
+import { match } from 'ts-pattern';
+
+import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
+import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
+import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
+import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
+
+import { DirectTemplatePageView } from '~/components/general/direct-template/direct-template-page';
+import { DirectTemplateAuthPageView } from '~/components/general/direct-template/direct-template-signing-auth-page';
+import { DocumentSigningAuthProvider } from '~/components/general/document-signing/document-signing-auth-provider';
+import { DocumentSigningProvider } from '~/components/general/document-signing/document-signing-provider';
+import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
+
+import type { Route } from './+types/_index';
+
+export type TemplatesDirectPageProps = {
+ params: {
+ token: string;
+ };
+};
+
+export async function loader({ params, context }: Route.LoaderArgs) {
+ const { token } = params;
+
+ if (!token) {
+ throw redirect('/');
+ }
+
+ const template = await getTemplateByDirectLinkToken({
+ token,
+ }).catch(() => null);
+
+ if (!template || !template.directLink?.enabled) {
+ throw new Response('Not Found', { status: 404 });
+ }
+
+ const directTemplateRecipient = template.recipients.find(
+ (recipient) => recipient.id === template.directLink?.directTemplateRecipientId,
+ );
+
+ if (!directTemplateRecipient) {
+ throw new Response('Not Found', { status: 404 });
+ }
+
+ const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
+ documentAuth: template.authOptions,
+ });
+
+ // Ensure typesafety when we add more options.
+ const isAccessAuthValid = match(derivedRecipientAccessAuth)
+ .with(DocumentAccessAuth.ACCOUNT, () => context.session?.user !== null)
+ .with(null, () => true)
+ .exhaustive();
+
+ if (!isAccessAuthValid) {
+ return superLoaderJson({
+ isAccessAuthValid: false as const,
+ });
+ }
+
+ return superLoaderJson({
+ isAccessAuthValid: true as const,
+ template,
+ directTemplateRecipient,
+ });
+}
+
+export default function DirectTemplatePage() {
+ const { user } = useOptionalSession();
+
+ const data = useSuperLoaderData
();
+
+ if (!data.isAccessAuthValid) {
+ return ;
+ }
+
+ const { template, directTemplateRecipient } = data;
+
+ return (
+
+
+
+
+ {template.title}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx b/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
new file mode 100644
index 000000000..8a21da5b4
--- /dev/null
+++ b/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
@@ -0,0 +1,227 @@
+import { Trans } from '@lingui/macro';
+import { DocumentStatus, SigningStatus } from '@prisma/client';
+import { Clock8 } from 'lucide-react';
+import { Link, redirect } from 'react-router';
+
+import signingCelebration from '@documenso/assets/images/signing-celebration.png';
+import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
+import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
+import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
+import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
+import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
+import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
+import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
+import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
+import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
+import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
+import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
+import { SigningCard3D } from '@documenso/ui/components/signing-card';
+
+import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
+import { DocumentSigningAuthProvider } from '~/components/general/document-signing/document-signing-auth-provider';
+import { DocumentSigningPageView } from '~/components/general/document-signing/document-signing-page-view';
+import { DocumentSigningProvider } from '~/components/general/document-signing/document-signing-provider';
+import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
+
+import type { Route } from './+types/_index';
+
+export async function loader({ params, context }: Route.LoaderArgs) {
+ const { token } = params;
+
+ if (!token) {
+ throw new Response('Not Found', { status: 404 });
+ }
+
+ const user = context.session?.user;
+
+ const [document, fields, recipient, completedFields] = await Promise.all([
+ getDocumentAndSenderByToken({
+ token,
+ userId: user?.id,
+ requireAccessAuth: false,
+ }).catch(() => null),
+ getFieldsForToken({ token }),
+ getRecipientByToken({ token }).catch(() => null),
+ getCompletedFieldsForToken({ token }),
+ ]);
+
+ if (
+ !document ||
+ !document.documentData ||
+ !recipient ||
+ document.status === DocumentStatus.DRAFT
+ ) {
+ throw new Response('Not Found', { status: 404 });
+ }
+
+ const isRecipientsTurn = await getIsRecipientsTurnToSign({ token });
+
+ if (!isRecipientsTurn) {
+ throw redirect(`/sign/${token}/waiting`);
+ }
+
+ const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
+ documentAuth: document.authOptions,
+ recipientAuth: recipient.authOptions,
+ });
+
+ const isDocumentAccessValid = await isRecipientAuthorized({
+ type: 'ACCESS',
+ documentAuthOptions: document.authOptions,
+ recipient,
+ userId: user?.id,
+ });
+
+ let recipientHasAccount: boolean | null = null;
+
+ if (!isDocumentAccessValid) {
+ recipientHasAccount = await getUserByEmail({ email: recipient.email })
+ .then((user) => !!user)
+ .catch(() => false);
+
+ return superLoaderJson({
+ isDocumentAccessValid: false,
+ recipientEmail: recipient.email,
+ recipientHasAccount,
+ } as const);
+ }
+
+ await viewedDocument({
+ token,
+ requestMetadata: context.requestMetadata,
+ recipientAccessAuth: derivedRecipientAccessAuth,
+ }).catch(() => null);
+
+ const { documentMeta } = document;
+
+ if (recipient.signingStatus === SigningStatus.REJECTED) {
+ throw redirect(`/sign/${token}/rejected`);
+ }
+
+ if (
+ document.status === DocumentStatus.COMPLETED ||
+ recipient.signingStatus === SigningStatus.SIGNED
+ ) {
+ throw redirect(documentMeta?.redirectUrl || `/sign/${token}/complete`);
+ }
+
+ // Todo: We don't handle encrypted files right.
+ // if (documentMeta?.password) {
+ // const key = DOCUMENSO_ENCRYPTION_KEY;
+
+ // if (!key) {
+ // throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY');
+ // }
+
+ // const securePassword = Buffer.from(
+ // symmetricDecrypt({
+ // key,
+ // data: documentMeta.password,
+ // }),
+ // ).toString('utf-8');
+
+ // documentMeta.password = securePassword;
+ // }
+
+ const [recipientSignature] = await getRecipientSignatures({ recipientId: recipient.id });
+
+ return superLoaderJson({
+ isDocumentAccessValid: true,
+ document,
+ fields,
+ recipient,
+ completedFields,
+ recipientSignature,
+ isRecipientsTurn,
+ } as const);
+}
+
+export default function SigningPage() {
+ const data = useSuperLoaderData();
+
+ const { user } = useOptionalSession();
+
+ if (!data.isDocumentAccessValid) {
+ return (
+
+ );
+ }
+
+ const { document, fields, recipient, completedFields, recipientSignature, isRecipientsTurn } =
+ data;
+
+ if (document.deletedAt) {
+ return (
+
+
+
+
+
+
+
+ Document Cancelled
+
+
+
+
+
+ "{document.title}"
+ is no longer available to sign
+
+
+
+
+ This document has been cancelled by the owner.
+
+
+ {user ? (
+
+
Go Back Home
+
+ ) : (
+
+
+ Want to send slick signing links like this one?{' '}
+
+ Check out Documenso.
+
+
+
+ )}
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx b/apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
new file mode 100644
index 000000000..f5e0b5db3
--- /dev/null
+++ b/apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
@@ -0,0 +1,284 @@
+import { Trans, msg } from '@lingui/macro';
+import { useLingui } from '@lingui/react';
+import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
+import { CheckCircle2, Clock8, FileSearch } from 'lucide-react';
+import { Link } from 'react-router';
+import { match } from 'ts-pattern';
+
+import signingCelebration from '@documenso/assets/images/signing-celebration.png';
+import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
+import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
+import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
+import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
+import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
+import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
+import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
+import { env } from '@documenso/lib/utils/env';
+import DocumentDialog from '@documenso/ui/components/document/document-dialog';
+import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
+import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
+import { SigningCard3D } from '@documenso/ui/components/signing-card';
+import { cn } from '@documenso/ui/lib/utils';
+import { Badge } from '@documenso/ui/primitives/badge';
+import { Button } from '@documenso/ui/primitives/button';
+
+import { ClaimAccount } from '~/components/general/claim-account';
+import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
+
+import type { Route } from './+types/complete';
+
+export async function loader({ params, context }: Route.LoaderArgs) {
+ const { token } = params;
+
+ if (!token) {
+ throw new Response('Not Found', { status: 404 });
+ }
+
+ const user = context.session?.user;
+
+ const document = await getDocumentAndSenderByToken({
+ token,
+ requireAccessAuth: false,
+ }).catch(() => null);
+
+ if (!document || !document.documentData) {
+ throw new Response('Not Found', { status: 404 });
+ }
+
+ const [fields, recipient] = await Promise.all([
+ getFieldsForToken({ token }),
+ getRecipientByToken({ token }).catch(() => null),
+ ]);
+
+ if (!recipient) {
+ throw new Response('Not Found', { status: 404 });
+ }
+
+ const isDocumentAccessValid = await isRecipientAuthorized({
+ type: 'ACCESS',
+ documentAuthOptions: document.authOptions,
+ recipient,
+ userId: user?.id,
+ });
+
+ if (!isDocumentAccessValid) {
+ return {
+ isDocumentAccessValid: false,
+ recipientEmail: recipient.email,
+ } as const;
+ }
+
+ const signatures = await getRecipientSignatures({ recipientId: recipient.id });
+ const isExistingUser = await getUserByEmail({ email: recipient.email })
+ .then((u) => !!u)
+ .catch(() => false);
+
+ const recipientName =
+ recipient.name ||
+ fields.find((field) => field.type === FieldType.NAME)?.customText ||
+ recipient.email;
+
+ const canSignUp = !isExistingUser && env('NEXT_PUBLIC_DISABLE_SIGNUP') !== 'true';
+
+ return {
+ isDocumentAccessValid: true,
+ canSignUp,
+ recipientName,
+ recipientEmail: recipient.email,
+ signatures,
+ document,
+ recipient,
+ };
+}
+
+export default function CompletedSigningPage({ loaderData }: Route.ComponentProps) {
+ const { _ } = useLingui();
+
+ const { user } = useOptionalSession();
+
+ const {
+ isDocumentAccessValid,
+ canSignUp,
+ recipientName,
+ signatures,
+ document,
+ recipient,
+ recipientEmail,
+ } = loaderData;
+
+ if (!isDocumentAccessValid) {
+ return ;
+ }
+
+ return (
+
+
+
+
+
+ {document.title}
+
+
+
+ {/* Card with recipient */}
+
+
+
+ {recipient.role === RecipientRole.SIGNER && Document Signed }
+ {recipient.role === RecipientRole.VIEWER && Document Viewed }
+ {recipient.role === RecipientRole.APPROVER && Document Approved }
+
+
+ {match({ status: document.status, deletedAt: document.deletedAt })
+ .with({ status: DocumentStatus.COMPLETED }, () => (
+
+
+
+ Everyone has signed
+
+
+ ))
+ .with({ deletedAt: null }, () => (
+
+
+
+ Waiting for others to sign
+
+
+ ))
+ .otherwise(() => (
+
+
+
+ Document no longer available to sign
+
+
+ ))}
+
+ {match({ status: document.status, deletedAt: document.deletedAt })
+ .with({ status: DocumentStatus.COMPLETED }, () => (
+
+
+ Everyone has signed! You will receive an Email copy of the signed document.
+
+
+ ))
+ .with({ deletedAt: null }, () => (
+
+
+ You will receive an Email copy of the signed document once everyone has signed.
+
+
+ ))
+ .otherwise(() => (
+
+
+ This document has been cancelled by the owner and is no longer available for
+ others to sign.
+
+
+ ))}
+
+
+
+
+ {document.status === DocumentStatus.COMPLETED ? (
+
+ ) : (
+
+
+ View Original Document
+
+ }
+ />
+ )}
+
+
+
+
+ {canSignUp && (
+
+
+ Need to sign documents?
+
+
+
+
+ Create your account and start using state-of-the-art document signing.
+
+
+
+
+
+ )}
+
+ {user && (
+
+
Go Back Home
+
+ )}
+
+
+
+ {/* Todo */}
+ {/* Todo */}
+ {/*
*/}
+
+ );
+}
+
+// Todo: Refresh on focus? Was in a layout w it before.
+// Todo:
+// export type PollUntilDocumentCompletedProps = {
+// document: Pick;
+// };
+
+// export const PollUntilDocumentCompleted = ({ document }: PollUntilDocumentCompletedProps) => {
+// const router = useRouter();
+
+// useEffect(() => {
+// if (document.status === DocumentStatus.COMPLETED) {
+// return;
+// }
+
+// const interval = setInterval(() => {
+// if (window.document.hasFocus()) {
+// router.refresh();
+// }
+// }, 5000);
+
+// return () => clearInterval(interval);
+// }, [router, document.status]);
+
+// return <>>;
+// };
diff --git a/apps/remix/app/routes/_recipient+/sign.$token+/rejected.tsx b/apps/remix/app/routes/_recipient+/sign.$token+/rejected.tsx
new file mode 100644
index 000000000..ff5b853e8
--- /dev/null
+++ b/apps/remix/app/routes/_recipient+/sign.$token+/rejected.tsx
@@ -0,0 +1,122 @@
+import { Trans } from '@lingui/macro';
+import { FieldType } from '@prisma/client';
+import { XCircle } from 'lucide-react';
+import { Link } from 'react-router';
+
+import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
+import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
+import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
+import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
+import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
+import { Badge } from '@documenso/ui/primitives/badge';
+import { Button } from '@documenso/ui/primitives/button';
+
+import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
+import { truncateTitle } from '~/utils/truncate-title';
+
+import type { Route } from './+types/rejected';
+
+export async function loader({ params, context }: Route.LoaderArgs) {
+ const { token } = params;
+
+ if (!token) {
+ throw new Response('Not Found', { status: 404 });
+ }
+
+ const user = context.session?.user;
+
+ const document = await getDocumentAndSenderByToken({
+ token,
+ requireAccessAuth: false,
+ }).catch(() => null);
+
+ if (!document) {
+ throw new Response('Not Found', { status: 404 });
+ }
+
+ const truncatedTitle = truncateTitle(document.title);
+
+ const [fields, recipient] = await Promise.all([
+ getFieldsForToken({ token }),
+ getRecipientByToken({ token }).catch(() => null),
+ ]);
+
+ if (!recipient) {
+ throw new Response('Not Found', { status: 404 });
+ }
+
+ const isDocumentAccessValid = await isRecipientAuthorized({
+ type: 'ACCESS',
+ documentAuthOptions: document.authOptions,
+ recipient,
+ userId: user?.id,
+ });
+
+ const recipientReference =
+ recipient.name ||
+ fields.find((field) => field.type === FieldType.NAME)?.customText ||
+ recipient.email;
+
+ if (isDocumentAccessValid) {
+ return {
+ isDocumentAccessValid: true,
+ recipientReference,
+ truncatedTitle,
+ };
+ }
+
+ // Don't leak data if access is denied.
+ return {
+ isDocumentAccessValid: false,
+ recipientReference,
+ };
+}
+
+export default function RejectedSigningPage({ loaderData }: Route.ComponentProps) {
+ const { user } = useOptionalSession();
+
+ const { isDocumentAccessValid, recipientReference, truncatedTitle } = loaderData;
+
+ if (!isDocumentAccessValid) {
+ return ;
+ }
+
+ return (
+
+
+ {truncatedTitle}
+
+
+
+
+
+
+
+ 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.
+
+
+ {user && (
+
+ Return Home
+
+ )}
+
+
+ );
+}
diff --git a/apps/remix/app/routes/_recipient+/sign.$token+/waiting.tsx b/apps/remix/app/routes/_recipient+/sign.$token+/waiting.tsx
new file mode 100644
index 000000000..c29011238
--- /dev/null
+++ b/apps/remix/app/routes/_recipient+/sign.$token+/waiting.tsx
@@ -0,0 +1,103 @@
+import { Trans } from '@lingui/macro';
+import type { Team } from '@prisma/client';
+import { DocumentStatus } from '@prisma/client';
+import { Link, redirect } from 'react-router';
+
+import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
+import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
+import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
+import { getTeamById } from '@documenso/lib/server-only/team/get-team';
+import { formatDocumentsPath } from '@documenso/lib/utils/teams';
+import { Button } from '@documenso/ui/primitives/button';
+
+import type { Route } from './+types/waiting';
+
+export async function loader({ params, context }: Route.LoaderArgs) {
+ const { token } = params;
+
+ if (!token) {
+ throw new Response('Not Found', { status: 404 });
+ }
+
+ const [document, recipient] = await Promise.all([
+ getDocumentAndSenderByToken({ token }).catch(() => null),
+ getRecipientByToken({ token }).catch(() => null),
+ ]);
+
+ if (!document || !recipient) {
+ throw new Response('Not Found', { status: 404 });
+ }
+
+ if (document.status === DocumentStatus.COMPLETED) {
+ throw redirect(`/sign/${token}/complete`);
+ }
+
+ let isOwnerOrTeamMember = false;
+
+ const user = context.session?.user;
+ let team: Team | null = null;
+
+ if (user) {
+ isOwnerOrTeamMember = await getDocumentById({
+ documentId: document.id,
+ userId: user.id,
+ teamId: document.teamId ?? undefined,
+ })
+ .then((document) => !!document)
+ .catch(() => false);
+
+ if (document.teamId) {
+ team = await getTeamById({
+ userId: user.id,
+ teamId: document.teamId,
+ });
+ }
+ }
+
+ const documentPathForEditing = isOwnerOrTeamMember
+ ? formatDocumentsPath(team?.url) + '/' + document.id
+ : null;
+
+ return {
+ documentPathForEditing,
+ };
+}
+
+export default function WaitingForTurnToSignPage({ loaderData }: Route.ComponentProps) {
+ const { documentPathForEditing } = loaderData;
+
+ return (
+
+
+
+ Waiting for Your Turn
+
+
+
+
+ It's currently not your turn to sign. You will receive an email with instructions once
+ it's your turn to sign the document.
+
+
+
+
+ Please check your email for updates.
+
+
+
+ {documentPathForEditing ? (
+
+
+ Were you trying to edit this document instead?
+
+
+ ) : (
+
+ Return Home
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/remix/app/routes/_unauthenticated+/signin.tsx b/apps/remix/app/routes/_unauthenticated+/signin.tsx
index dafd12090..202718dd2 100644
--- a/apps/remix/app/routes/_unauthenticated+/signin.tsx
+++ b/apps/remix/app/routes/_unauthenticated+/signin.tsx
@@ -12,13 +12,13 @@ import { SignInForm } from '~/components/forms/signin';
import type { Route } from './+types/signin';
-export function meta(_args: Route.MetaArgs) {
+export function meta() {
return [{ title: 'Sign In' }];
}
-export async function loader({ context }: Route.LoaderArgs) {
+export function loader({ context }: Route.LoaderArgs) {
if (context.session) {
- return redirect('/documents');
+ throw redirect('/documents');
}
}
diff --git a/apps/remix/app/routes/_unauthenticated+/signup.tsx b/apps/remix/app/routes/_unauthenticated+/signup.tsx
index 3ea1b0836..a2f1d420c 100644
--- a/apps/remix/app/routes/_unauthenticated+/signup.tsx
+++ b/apps/remix/app/routes/_unauthenticated+/signup.tsx
@@ -13,7 +13,7 @@ export function loader() {
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
if (NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
- return redirect('/signin');
+ throw redirect('/signin');
}
}
diff --git a/apps/remix/app/routes/api+/theme.tsx b/apps/remix/app/routes/api+/theme.tsx
new file mode 100644
index 000000000..b30f9e7dd
--- /dev/null
+++ b/apps/remix/app/routes/api+/theme.tsx
@@ -0,0 +1,5 @@
+import { createThemeAction } from 'remix-themes';
+
+import { themeSessionResolver } from '~/storage/theme-session.server';
+
+export const action = createThemeAction(themeSessionResolver);
diff --git a/apps/remix/app/storage/theme-session.server.tsx b/apps/remix/app/storage/theme-session.server.tsx
new file mode 100644
index 000000000..efbb44bb7
--- /dev/null
+++ b/apps/remix/app/storage/theme-session.server.tsx
@@ -0,0 +1,19 @@
+import { createCookieSessionStorage } from 'react-router';
+import { createThemeSessionResolver } from 'remix-themes';
+
+import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
+
+const themeSessionStorage = createCookieSessionStorage({
+ cookie: {
+ name: 'theme',
+ path: '/',
+ httpOnly: true,
+ sameSite: 'lax',
+ secrets: ['insecure-secret'], // Todo: Don't need secret
+ // Todo: Check this works on production.
+ // Set domain and secure only if in production
+ ...(import.meta.env.PROD ? { domain: NEXT_PUBLIC_WEBAPP_URL(), secure: true } : {}),
+ },
+});
+
+export const themeSessionResolver = createThemeSessionResolver(themeSessionStorage);
diff --git a/apps/remix/app/helpers/get-asset-buffer.ts b/apps/remix/app/utils/get-asset-buffer.ts
similarity index 100%
rename from apps/remix/app/helpers/get-asset-buffer.ts
rename to apps/remix/app/utils/get-asset-buffer.ts
diff --git a/apps/remix/app/utils/super-json-loader.ts b/apps/remix/app/utils/super-json-loader.ts
new file mode 100644
index 000000000..a511a757c
--- /dev/null
+++ b/apps/remix/app/utils/super-json-loader.ts
@@ -0,0 +1,57 @@
+/* eslint-disable @typescript-eslint/consistent-type-assertions */
+
+/* eslint-disable @typescript-eslint/no-unnecessary-type-constraint */
+
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+/**
+ * https://github.com/kiliman/remix-superjson/
+ */
+import { useActionData, useLoaderData } from 'react-router';
+import * as _superjson from 'superjson';
+
+export type SuperJsonFunction = (
+ data: Data,
+ init?: number | ResponseInit,
+) => SuperTypedResponse;
+
+export declare type SuperTypedResponse = Response & {
+ superjson(): Promise;
+};
+
+type AppData = any;
+type DataFunction = (...args: any[]) => unknown; // matches any function
+type DataOrFunction = AppData | DataFunction;
+
+export type UseDataFunctionReturn = T extends (
+ ...args: any[]
+) => infer Output
+ ? Awaited extends SuperTypedResponse
+ ? U
+ : Awaited>
+ : Awaited;
+
+export const superLoaderJson: SuperJsonFunction = (data, init = {}) => {
+ const responseInit = typeof init === 'number' ? { status: init } : init;
+ const headers = new Headers(responseInit.headers);
+
+ if (!headers.has('Content-Type')) {
+ headers.set('Content-Type', 'application/json; charset=utf-8');
+ }
+
+ return new Response(_superjson.stringify(data), {
+ ...responseInit,
+ headers,
+ }) as SuperTypedResponse;
+};
+
+export function useSuperLoaderData(): UseDataFunctionReturn {
+ const data = useLoaderData();
+
+ return _superjson.deserialize(data);
+}
+export function useSuperActionData(): UseDataFunctionReturn | null {
+ const data = useActionData();
+
+ return data ? _superjson.deserialize(data) : null;
+}
diff --git a/apps/remix/app/helpers/truncate-title.ts b/apps/remix/app/utils/truncate-title.ts
similarity index 100%
rename from apps/remix/app/helpers/truncate-title.ts
rename to apps/remix/app/utils/truncate-title.ts
diff --git a/apps/remix/package.json b/apps/remix/package.json
index 60253bd6e..6e1ebb563 100644
--- a/apps/remix/package.json
+++ b/apps/remix/package.json
@@ -4,8 +4,8 @@
"type": "module",
"scripts": {
"build": "cross-env NODE_ENV=production react-router build",
- "dev": "react-router dev",
- "dev:bun": "bunx --bun vite",
+ "dev": "bunx --bun vite",
+ "dev:node": "react-router dev",
"start": "cross-env NODE_ENV=production node dist/server/index.js",
"clean": "rimraf .react-router && rimraf node_modules",
"typecheck": "react-router typegen && tsc",
@@ -32,6 +32,7 @@
"react-dom": "^18",
"react-router": "^7.1.3",
"remix-hono": "^0.0.18",
+ "remix-themes": "^2.0.4",
"tailwindcss": "^3.4.15",
"ts-pattern": "^5.0.5"
},
@@ -50,4 +51,4 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
}
-}
\ No newline at end of file
+}
diff --git a/apps/remix/server/load-context.ts b/apps/remix/server/load-context.ts
index dbf8727a1..e0d8fabdd 100644
--- a/apps/remix/server/load-context.ts
+++ b/apps/remix/server/load-context.ts
@@ -1,6 +1,7 @@
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { type TGetTeamsResponse, getTeams } from '@documenso/lib/server-only/team/get-teams';
+import { extractRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
type GetLoadContextArgs = {
request: Request;
@@ -21,6 +22,7 @@ export async function getLoadContext(args: GetLoadContextArgs) {
if (request.method !== 'GET' || !config.matcher.test(url.pathname)) {
console.log('[Session]: Pathname ignored', url.pathname);
return {
+ requestMetadata: extractRequestMetadata(request),
session: null,
};
}
@@ -51,6 +53,7 @@ export async function getLoadContext(args: GetLoadContextArgs) {
// Todo: This is server only right?? Results not exposed?
return {
+ requestMetadata: extractRequestMetadata(request),
session: session.isAuthenticated
? {
session: session.session,
diff --git a/apps/remix/vite.config.ts b/apps/remix/vite.config.ts
index 8d8fc59bd..7a27af093 100644
--- a/apps/remix/vite.config.ts
+++ b/apps/remix/vite.config.ts
@@ -26,7 +26,18 @@ export default defineConfig({
},
ssr: {
// , 'next/font/google' doesnot work
- noExternal: ['react-dropzone', 'recharts'],
+ noExternal: [
+ 'react-dropzone',
+ 'recharts',
+ 'superjson',
+ // '@node-rs/bcrypt-wasm32-wasi',
+ // '@noble/ciphers/chacha',
+ // '@noble/ciphers/utils',
+ // '@noble/ciphers/webcrypto/utils',
+ // '@noble/hashes/sha256a',
+ // '@node-rs/bcrypt',
+ // 'crypto',
+ ],
},
server: {
port: 3000,
@@ -42,8 +53,25 @@ export default defineConfig({
}),
tsconfigPaths(),
],
+ optimizeDeps: {
+ exclude: ['superjson'],
+ force: true,
+ },
+ build: {
+ commonjsOptions: {
+ include: ['superjson'],
+ },
+ },
// optimizeDeps: {
- // exclude: ['next/font/google'], // Todo: Probably remove.
+ // exclude: [
+ // '@node-rs/bcrypt-wasm32-wasi',
+ // '@noble/ciphers/chacha',
+ // '@noble/ciphers/utils',
+ // '@noble/ciphers/webcrypto/utils',
+ // '@noble/hashes/sha256a',
+ // 'crypto',
+ // '@node-rs/bcrypt',
+ // ], // Todo: Probably remove.
// force: true,
// },
});
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index 53d735200..87328e378 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -8,7 +8,7 @@ import { PublicEnvScript } from 'next-runtime-env';
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
import { I18nClientProvider } from '@documenso/lib/client-only/providers/i18n.client';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
-import { IS_APP_WEB_I18N_ENABLED, NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
+import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getServerComponentAllFlags } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
import { TrpcProvider } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@@ -69,7 +69,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
- {IS_APP_WEB_I18N_ENABLED && }
+
diff --git a/apps/web/src/components/(teams)/dialogs/create-team-dialog.tsx b/apps/web/src/components/(teams)/dialogs/create-team-dialog.tsx
index bff29a493..b80234a96 100644
--- a/apps/web/src/components/(teams)/dialogs/create-team-dialog.tsx
+++ b/apps/web/src/components/(teams)/dialogs/create-team-dialog.tsx
@@ -12,7 +12,7 @@ import { useForm } from 'react-hook-form';
import type { z } from 'zod';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
-import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
+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 { ZCreateTeamMutationSchema } from '@documenso/trpc/server/team-router/schema';
@@ -201,7 +201,7 @@ export const CreateTeamDialog = ({ trigger, ...props }: CreateTeamDialogProps) =
{!form.formState.errors.teamUrl && (
{field.value ? (
- `${WEBAPP_BASE_URL}/t/${field.value}`
+ `${NEXT_PUBLIC_WEBAPP_URL()}/t/${field.value}`
) : (
A unique URL to identify your team
)}
diff --git a/apps/web/src/components/(teams)/forms/update-team-form.tsx b/apps/web/src/components/(teams)/forms/update-team-form.tsx
index be2e7edc2..f83c01d73 100644
--- a/apps/web/src/components/(teams)/forms/update-team-form.tsx
+++ b/apps/web/src/components/(teams)/forms/update-team-form.tsx
@@ -9,7 +9,7 @@ import { AnimatePresence, motion } from 'framer-motion';
import { useForm } from 'react-hook-form';
import type { z } from 'zod';
-import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
+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 { ZUpdateTeamMutationSchema } from '@documenso/trpc/server/team-router/schema';
@@ -75,7 +75,7 @@ export const UpdateTeamForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogPr
});
if (url !== teamUrl) {
- router.push(`${WEBAPP_BASE_URL}/t/${url}/settings`);
+ router.push(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${url}/settings`);
}
} catch (err) {
const error = AppError.parseError(err);
@@ -133,7 +133,7 @@ export const UpdateTeamForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogPr
{!form.formState.errors.url && (
{field.value ? (
- `${WEBAPP_BASE_URL}/t/${field.value}`
+ `${NEXT_PUBLIC_WEBAPP_URL()}/t/${field.value}`
) : (
A unique URL to identify your team
)}
diff --git a/apps/web/src/components/(teams)/tables/current-user-teams-data-table.tsx b/apps/web/src/components/(teams)/tables/current-user-teams-data-table.tsx
index d9984aace..9715d37d1 100644
--- a/apps/web/src/components/(teams)/tables/current-user-teams-data-table.tsx
+++ b/apps/web/src/components/(teams)/tables/current-user-teams-data-table.tsx
@@ -9,7 +9,7 @@ import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
-import { NEXT_PUBLIC_WEBAPP_URL, WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
+import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
@@ -71,7 +71,7 @@ export const CurrentUserTeamsDataTable = () => {
primaryText={
{row.original.name}
}
- secondaryText={`${WEBAPP_BASE_URL}/t/${row.original.url}`}
+ secondaryText={`${NEXT_PUBLIC_WEBAPP_URL()}/t/${row.original.url}`}
/>
),
diff --git a/apps/web/src/components/(teams)/tables/pending-user-teams-data-table.tsx b/apps/web/src/components/(teams)/tables/pending-user-teams-data-table.tsx
index b656308f7..81638a434 100644
--- a/apps/web/src/components/(teams)/tables/pending-user-teams-data-table.tsx
+++ b/apps/web/src/components/(teams)/tables/pending-user-teams-data-table.tsx
@@ -8,7 +8,7 @@ import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
-import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
+import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
import { trpc } from '@documenso/trpc/react';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
@@ -68,7 +68,7 @@ export const PendingUserTeamsDataTable = () => {
primaryText={
{row.original.name}
}
- secondaryText={`${WEBAPP_BASE_URL}/t/${row.original.url}`}
+ secondaryText={`${NEXT_PUBLIC_WEBAPP_URL()}/t/${row.original.url}`}
/>
),
},
diff --git a/apps/web/src/providers/next-theme.tsx b/apps/web/src/providers/next-theme.tsx
deleted file mode 100644
index d15114606..000000000
--- a/apps/web/src/providers/next-theme.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-'use client';
-
-import * as React from 'react';
-
-import { ThemeProvider as NextThemesProvider } from 'next-themes';
-import type { ThemeProviderProps } from 'next-themes/dist/types';
-
-export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
- return {children} ;
-}
diff --git a/package-lock.json b/package-lock.json
index e4666d598..953fa5e9b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -128,6 +128,7 @@
"react-dom": "^18",
"react-router": "^7.1.3",
"remix-hono": "^0.0.18",
+ "remix-themes": "^2.0.4",
"tailwindcss": "^3.4.15",
"ts-pattern": "^5.0.5"
},
@@ -32019,6 +32020,19 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/remix-themes": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/remix-themes/-/remix-themes-2.0.4.tgz",
+ "integrity": "sha512-S1vIx86xdsMv+ceaWGWtlVZBMhU4tmZeBOBzOiZnhbHnGBbd5YeyIlZ5EL3BSEhZq35oEcINeTrMrVju9GPYEA==",
+ "license": "MIT",
+ "workspaces": [
+ "test-apps/*",
+ "."
+ ],
+ "peerDependencies": {
+ "react-router": ">=7.0.0"
+ }
+ },
"node_modules/remote-git-tags": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/remote-git-tags/-/remote-git-tags-3.0.0.tgz",
diff --git a/packages/api/v1/api-documentation.tsx b/packages/api/v1/api-documentation.tsx
index 7ce8709bf..58b7a717a 100644
--- a/packages/api/v1/api-documentation.tsx
+++ b/packages/api/v1/api-documentation.tsx
@@ -9,6 +9,7 @@ import 'swagger-ui-react/swagger-ui.css';
import { OpenAPIV1 } from '@documenso/api/v1/openapi';
export const OpenApiDocsPage = () => {
+ // Todo
const { resolvedTheme } = useTheme();
useEffect(() => {
diff --git a/packages/app-tests/e2e/api/v1/document-sending.spec.ts b/packages/app-tests/e2e/api/v1/document-sending.spec.ts
index 81e1e606c..3b76dff3e 100644
--- a/packages/app-tests/e2e/api/v1/document-sending.spec.ts
+++ b/packages/app-tests/e2e/api/v1/document-sending.spec.ts
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
-import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
+import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { prisma } from '@documenso/prisma';
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
@@ -22,15 +22,18 @@ test.describe('Document API', () => {
});
// Test with sendCompletionEmails: false
- const response = await request.post(`${WEBAPP_BASE_URL}/api/v1/documents/${document.id}/send`, {
- headers: {
- Authorization: `Bearer ${token}`,
- 'Content-Type': 'application/json',
+ const response = await request.post(
+ `${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/documents/${document.id}/send`,
+ {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ },
+ data: {
+ sendCompletionEmails: false,
+ },
},
- data: {
- sendCompletionEmails: false,
- },
- });
+ );
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
@@ -48,7 +51,7 @@ test.describe('Document API', () => {
// Test with sendCompletionEmails: true
const response2 = await request.post(
- `${WEBAPP_BASE_URL}/api/v1/documents/${document.id}/send`,
+ `${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/documents/${document.id}/send`,
{
headers: {
Authorization: `Bearer ${token}`,
@@ -110,15 +113,18 @@ test.describe('Document API', () => {
expiresIn: null,
});
- const response = await request.post(`${WEBAPP_BASE_URL}/api/v1/documents/${document.id}/send`, {
- headers: {
- Authorization: `Bearer ${token}`,
- 'Content-Type': 'application/json',
+ const response = await request.post(
+ `${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/documents/${document.id}/send`,
+ {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ },
+ data: {
+ sendEmail: true,
+ },
},
- data: {
- sendEmail: true,
- },
- });
+ );
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
diff --git a/packages/app-tests/e2e/api/v1/team-user-management.spec.ts b/packages/app-tests/e2e/api/v1/team-user-management.spec.ts
index c527fcfe1..9225239b9 100644
--- a/packages/app-tests/e2e/api/v1/team-user-management.spec.ts
+++ b/packages/app-tests/e2e/api/v1/team-user-management.spec.ts
@@ -7,7 +7,7 @@ import {
ZSuccessfulUpdateTeamMemberResponseSchema,
ZUnsuccessfulResponseSchema,
} from '@documenso/api/v1/schema';
-import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
+import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client';
@@ -32,11 +32,14 @@ test.describe('Team API', () => {
expiresIn: null,
});
- const response = await request.get(`${WEBAPP_BASE_URL}/api/v1/team/${team.id}/members`, {
- headers: {
- Authorization: `Bearer ${token}`,
+ const response = await request.get(
+ `${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/team/${team.id}/members`,
+ {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
},
- });
+ );
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
@@ -74,7 +77,7 @@ test.describe('Team API', () => {
const newUser = await seedUser();
const response = await request.post(
- `${WEBAPP_BASE_URL}/api/v1/team/${team.id}/members/invite`,
+ `${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/team/${team.id}/members/invite`,
{
headers: {
Authorization: `Bearer ${token}`,
@@ -126,7 +129,7 @@ test.describe('Team API', () => {
expect(member).toBeTruthy();
const response = await request.put(
- `${WEBAPP_BASE_URL}/api/v1/team/${team.id}/members/${member.id}`,
+ `${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/team/${team.id}/members/${member.id}`,
{
headers: {
Authorization: `Bearer ${token}`,
@@ -171,7 +174,7 @@ test.describe('Team API', () => {
expect(member).toBeTruthy();
const response = await request.delete(
- `${WEBAPP_BASE_URL}/api/v1/team/${team.id}/members/${member.id}`,
+ `${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/team/${team.id}/members/${member.id}`,
{
headers: {
Authorization: `Bearer ${token}`,
@@ -221,7 +224,7 @@ test.describe('Team API', () => {
expect(ownerMember).toBeTruthy();
const response = await request.delete(
- `${WEBAPP_BASE_URL}/api/v1/team/${team.id}/members/${ownerMember.id}`,
+ `${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/team/${team.id}/members/${ownerMember.id}`,
{
headers: {
Authorization: `Bearer ${token}`,
@@ -261,7 +264,7 @@ test.describe('Team API', () => {
});
const response = await request.delete(
- `${WEBAPP_BASE_URL}/api/v1/team/${team.id}/members/${member.id}`,
+ `${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/team/${team.id}/members/${member.id}`,
{
headers: {
Authorization: `Bearer ${token}`,
diff --git a/packages/app-tests/e2e/fixtures/authentication.ts b/packages/app-tests/e2e/fixtures/authentication.ts
index fe52b65d8..b730327bb 100644
--- a/packages/app-tests/e2e/fixtures/authentication.ts
+++ b/packages/app-tests/e2e/fixtures/authentication.ts
@@ -1,6 +1,6 @@
import { type Page } from '@playwright/test';
-import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
+import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
type LoginOptions = {
page: Page;
@@ -23,7 +23,7 @@ export const apiSignin = async ({
const csrfToken = await getCsrfToken(page);
- await request.post(`${WEBAPP_BASE_URL}/api/auth/callback/credentials`, {
+ await request.post(`${NEXT_PUBLIC_WEBAPP_URL()}/api/auth/callback/credentials`, {
form: {
email,
password,
@@ -32,7 +32,7 @@ export const apiSignin = async ({
},
});
- await page.goto(`${WEBAPP_BASE_URL}${redirectPath}`);
+ await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}${redirectPath}`);
};
export const apiSignout = async ({ page }: { page: Page }) => {
@@ -40,20 +40,20 @@ export const apiSignout = async ({ page }: { page: Page }) => {
const csrfToken = await getCsrfToken(page);
- await request.post(`${WEBAPP_BASE_URL}/api/auth/signout`, {
+ await request.post(`${NEXT_PUBLIC_WEBAPP_URL()}/api/auth/signout`, {
form: {
csrfToken,
json: true,
},
});
- await page.goto(`${WEBAPP_BASE_URL}/signin`);
+ await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/signin`);
};
const getCsrfToken = async (page: Page) => {
const { request } = page.context();
- const response = await request.fetch(`${WEBAPP_BASE_URL}/api/auth/csrf`, {
+ const response = await request.fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/api/auth/csrf`, {
method: 'get',
});
diff --git a/packages/app-tests/e2e/teams/manage-team.spec.ts b/packages/app-tests/e2e/teams/manage-team.spec.ts
index 3d0fb0ace..95bae9df5 100644
--- a/packages/app-tests/e2e/teams/manage-team.spec.ts
+++ b/packages/app-tests/e2e/teams/manage-team.spec.ts
@@ -1,6 +1,6 @@
import { test } from '@playwright/test';
-import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
+import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
@@ -50,7 +50,7 @@ test('[TEAMS]: delete team', async ({ page }) => {
await page.getByRole('button', { name: 'Delete' }).click();
// Check that we have been redirected to the teams page.
- await page.waitForURL(`${WEBAPP_BASE_URL}/settings/teams`);
+ await page.waitForURL(`${NEXT_PUBLIC_WEBAPP_URL()}/settings/teams`);
});
test('[TEAMS]: update team', async ({ page }) => {
@@ -81,5 +81,5 @@ test('[TEAMS]: update team', async ({ page }) => {
await page.getByRole('button', { name: 'Update team' }).click();
// Check we have been redirected to the new team URL and the name is updated.
- await page.waitForURL(`${WEBAPP_BASE_URL}/t/${updatedTeamId}/settings`);
+ await page.waitForURL(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${updatedTeamId}/settings`);
});
diff --git a/packages/app-tests/e2e/teams/team-email.spec.ts b/packages/app-tests/e2e/teams/team-email.spec.ts
index 7becb2418..ea749891e 100644
--- a/packages/app-tests/e2e/teams/team-email.spec.ts
+++ b/packages/app-tests/e2e/teams/team-email.spec.ts
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
-import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
+import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { seedTeam, seedTeamEmailVerification } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
@@ -43,7 +43,7 @@ test('[TEAMS]: accept team email request', async ({ page }) => {
teamId: team.id,
});
- await page.goto(`${WEBAPP_BASE_URL}/team/verify/email/${teamEmailVerification.token}`);
+ await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/team/verify/email/${teamEmailVerification.token}`);
await expect(page.getByRole('heading')).toContainText('Team email verified!');
});
diff --git a/packages/app-tests/e2e/teams/team-members.spec.ts b/packages/app-tests/e2e/teams/team-members.spec.ts
index 99cae7f47..8b1c43c42 100644
--- a/packages/app-tests/e2e/teams/team-members.spec.ts
+++ b/packages/app-tests/e2e/teams/team-members.spec.ts
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
-import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
+import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { seedTeam, seedTeamInvite } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
@@ -49,7 +49,7 @@ test('[TEAMS]: accept team invitation without account', async ({ page }) => {
teamId: team.id,
});
- await page.goto(`${WEBAPP_BASE_URL}/team/invite/${teamInvite.token}`);
+ await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/team/invite/${teamInvite.token}`);
await expect(page.getByRole('heading')).toContainText('Team invitation');
});
@@ -62,7 +62,7 @@ test('[TEAMS]: accept team invitation with account', async ({ page }) => {
teamId: team.id,
});
- await page.goto(`${WEBAPP_BASE_URL}/team/invite/${teamInvite.token}`);
+ await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/team/invite/${teamInvite.token}`);
await expect(page.getByRole('heading')).toContainText('Invitation accepted!');
});
diff --git a/packages/app-tests/e2e/teams/transfer-team.spec.ts b/packages/app-tests/e2e/teams/transfer-team.spec.ts
index d45e8110f..0dfe2d736 100644
--- a/packages/app-tests/e2e/teams/transfer-team.spec.ts
+++ b/packages/app-tests/e2e/teams/transfer-team.spec.ts
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
-import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
+import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { seedTeam, seedTeamTransfer } from '@documenso/prisma/seed/teams';
import { apiSignin } from '../fixtures/authentication';
@@ -60,6 +60,6 @@ test.skip('[TEAMS]: accept team transfer', async ({ page }) => {
newOwnerUserId: newOwnerMember.userId,
});
- await page.goto(`${WEBAPP_BASE_URL}/team/verify/transfer/${teamTransferRequest.token}`);
+ await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/team/verify/transfer/${teamTransferRequest.token}`);
await expect(page.getByRole('heading')).toContainText('Team ownership transferred!');
});
diff --git a/packages/app-tests/e2e/templates/direct-templates.spec.ts b/packages/app-tests/e2e/templates/direct-templates.spec.ts
index c8f41f4fc..c148458be 100644
--- a/packages/app-tests/e2e/templates/direct-templates.spec.ts
+++ b/packages/app-tests/e2e/templates/direct-templates.spec.ts
@@ -1,7 +1,7 @@
import { expect, test } from '@playwright/test';
import { customAlphabet } from 'nanoid';
-import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
+import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import {
DIRECT_TEMPLATE_RECIPIENT_EMAIL,
DIRECT_TEMPLATE_RECIPIENT_NAME,
@@ -52,8 +52,8 @@ test('[DIRECT_TEMPLATES]: create direct link for template', async ({ page }) =>
});
const urls = [
- `${WEBAPP_BASE_URL}/t/${team.url}/templates/${teamTemplate.id}`,
- `${WEBAPP_BASE_URL}/templates/${personalTemplate.id}`,
+ `${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/templates/${teamTemplate.id}`,
+ `${NEXT_PUBLIC_WEBAPP_URL()}/templates/${personalTemplate.id}`,
];
// Run test for personal and team templates.
@@ -108,7 +108,7 @@ test('[DIRECT_TEMPLATES]: toggle direct template link', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
// Navigate to template settings and disable access.
- await page.goto(`${WEBAPP_BASE_URL}${formatTemplatesPath(template.team?.url)}`);
+ await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}${formatTemplatesPath(template.team?.url)}`);
await page.getByRole('cell', { name: 'Use Template' }).getByRole('button').nth(1).click();
await page.getByRole('menuitem', { name: 'Direct link' }).click();
await page.getByRole('switch').click();
@@ -153,7 +153,7 @@ test('[DIRECT_TEMPLATES]: delete direct template link', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
// Navigate to template settings and delete the access.
- await page.goto(`${WEBAPP_BASE_URL}${formatTemplatesPath(template.team?.url)}`);
+ await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}${formatTemplatesPath(template.team?.url)}`);
await page.getByRole('cell', { name: 'Use Template' }).getByRole('button').nth(1).click();
await page.getByRole('menuitem', { name: 'Direct link' }).click();
await page.getByRole('button', { name: 'Remove' }).click();
@@ -241,7 +241,7 @@ test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ p
// Check that the owner has the documents.
for (const template of [personalDirectTemplate, teamDirectTemplate]) {
- await page.goto(`${WEBAPP_BASE_URL}${formatDocumentsPath(template.team?.url)}`);
+ await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}${formatDocumentsPath(template.team?.url)}`);
await expect(async () => {
// Check that the document is in the 'All' tab.
@@ -314,7 +314,7 @@ test('[DIRECT_TEMPLATES]: use direct template link with 2 recipients', async ({
// Check that the owner has the documents.
for (const template of [personalDirectTemplate, teamDirectTemplate]) {
- await page.goto(`${WEBAPP_BASE_URL}${formatDocumentsPath(template.team?.url)}`);
+ await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}${formatDocumentsPath(template.team?.url)}`);
// Check that the document is in the 'All' tab.
await checkDocumentTabCount(page, 'All', 1);
diff --git a/packages/app-tests/e2e/templates/manage-templates.spec.ts b/packages/app-tests/e2e/templates/manage-templates.spec.ts
index 135ab96f1..c2d2fdfb0 100644
--- a/packages/app-tests/e2e/templates/manage-templates.spec.ts
+++ b/packages/app-tests/e2e/templates/manage-templates.spec.ts
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
-import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
+import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedTemplate } from '@documenso/prisma/seed/templates';
@@ -43,11 +43,11 @@ test('[TEMPLATES]: view templates', async ({ page }) => {
});
// Owner should see both team templates.
- await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates`);
+ await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/templates`);
await expect(page.getByRole('main')).toContainText('Showing 2 results');
// Only should only see their personal template.
- await page.goto(`${WEBAPP_BASE_URL}/templates`);
+ await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/templates`);
await expect(page.getByRole('main')).toContainText('Showing 1 result');
});
@@ -92,7 +92,7 @@ test('[TEMPLATES]: delete template', async ({ page }) => {
await expect(page.getByText('Template deleted').first()).toBeVisible();
// Team member should be able to delete all templates.
- await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates`);
+ await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/templates`);
for (const template of ['Team template 1', 'Team template 2']) {
await page
@@ -144,7 +144,7 @@ test('[TEMPLATES]: duplicate template', async ({ page }) => {
await expect(page.getByText('Template duplicated').first()).toBeVisible();
await expect(page.getByRole('main')).toContainText('Showing 2 results');
- await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates`);
+ await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/templates`);
// Duplicate team template.
await page.getByRole('cell', { name: 'Use Template' }).getByRole('button').nth(1).click();
@@ -196,7 +196,7 @@ test('[TEMPLATES]: use template', async ({ page }) => {
await page.waitForURL('/documents');
await expect(page.getByRole('main')).toContainText('Showing 1 result');
- await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates`);
+ await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/templates`);
await page.waitForTimeout(1000);
// Use team template.
diff --git a/packages/app-tests/e2e/user/delete-account.spec.ts b/packages/app-tests/e2e/user/delete-account.spec.ts
index e04283240..40fee1125 100644
--- a/packages/app-tests/e2e/user/delete-account.spec.ts
+++ b/packages/app-tests/e2e/user/delete-account.spec.ts
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
-import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
+import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
import { seedUser } from '@documenso/prisma/seed/users';
@@ -17,7 +17,7 @@ test('[USER] delete account', async ({ page }) => {
await expect(page.getByRole('button', { name: 'Confirm Deletion' })).not.toBeDisabled();
await page.getByRole('button', { name: 'Confirm Deletion' }).click();
- await page.waitForURL(`${WEBAPP_BASE_URL}/signin`);
+ await page.waitForURL(`${NEXT_PUBLIC_WEBAPP_URL()}/signin`);
// Verify that the user no longer exists in the database
await expect(getUserByEmail({ email: user.email })).rejects.toThrow();
diff --git a/packages/auth/server/lib/session/session.ts b/packages/auth/server/lib/session/session.ts
index ba927e4c7..44be722ce 100644
--- a/packages/auth/server/lib/session/session.ts
+++ b/packages/auth/server/lib/session/session.ts
@@ -8,10 +8,11 @@ import { prisma } from '@documenso/prisma';
export type SessionValidationResult =
| {
session: Session;
- user: Pick<
- User,
- 'id' | 'name' | 'email' | 'emailVerified' | 'avatarImageId' | 'twoFactorEnabled' | 'roles' // Todo
- >;
+ user: User;
+ // user: Pick<
+ // User,
+ // 'id' | 'name' | 'email' | 'emailVerified' | 'avatarImageId' | 'twoFactorEnabled' | 'roles' // Todo
+ // >;
isAuthenticated: true;
}
| { session: null; user: null; isAuthenticated: false };
diff --git a/packages/ee/server-only/limits/client.ts b/packages/ee/server-only/limits/client.ts
index f6dfaec5b..0a7f6bd49 100644
--- a/packages/ee/server-only/limits/client.ts
+++ b/packages/ee/server-only/limits/client.ts
@@ -1,4 +1,4 @@
-import { APP_BASE_URL } from '@documenso/lib/constants/app';
+import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { FREE_PLAN_LIMITS } from './constants';
import type { TLimitsResponseSchema } from './schema';
@@ -12,7 +12,7 @@ export type GetLimitsOptions = {
export const getLimits = async ({ headers, teamId }: GetLimitsOptions = {}) => {
const requestHeaders = headers ?? {};
- const url = new URL('/api/limits', APP_BASE_URL() ?? 'http://localhost:3000');
+ const url = new URL('/api/limits', NEXT_PUBLIC_WEBAPP_URL());
if (teamId) {
requestHeaders['team-id'] = teamId.toString();
diff --git a/packages/lib/client-only/hooks/use-analytics.ts b/packages/lib/client-only/hooks/use-analytics.ts
index a659a6d70..e5939dddf 100644
--- a/packages/lib/client-only/hooks/use-analytics.ts
+++ b/packages/lib/client-only/hooks/use-analytics.ts
@@ -7,7 +7,7 @@ import {
} from '@documenso/lib/constants/feature-flags';
export function useAnalytics() {
- const featureFlags = useFeatureFlags();
+ // const featureFlags = useFeatureFlags();
const isPostHogEnabled = extractPostHogConfig();
/**
@@ -30,27 +30,29 @@ export function useAnalytics() {
* @param eventFlag The event to check against feature flags to determine whether tracking is enabled.
*/
const startSessionRecording = (eventFlag?: string) => {
- const isSessionRecordingEnabled = featureFlags.getFlag(FEATURE_FLAG_GLOBAL_SESSION_RECORDING);
- const isSessionRecordingEnabledForEvent = Boolean(eventFlag && featureFlags.getFlag(eventFlag));
+ return;
+ // const isSessionRecordingEnabled = featureFlags.getFlag(FEATURE_FLAG_GLOBAL_SESSION_RECORDING);
+ // const isSessionRecordingEnabledForEvent = Boolean(eventFlag && featureFlags.getFlag(eventFlag));
- if (!isPostHogEnabled || !isSessionRecordingEnabled || !isSessionRecordingEnabledForEvent) {
- return;
- }
+ // if (!isPostHogEnabled || !isSessionRecordingEnabled || !isSessionRecordingEnabledForEvent) {
+ // return;
+ // }
- posthog.startSessionRecording();
+ // posthog.startSessionRecording();
};
/**
* Stop the current session recording.
*/
const stopSessionRecording = () => {
- const isSessionRecordingEnabled = featureFlags.getFlag(FEATURE_FLAG_GLOBAL_SESSION_RECORDING);
+ return;
+ // const isSessionRecordingEnabled = featureFlags.getFlag(FEATURE_FLAG_GLOBAL_SESSION_RECORDING);
- if (!isPostHogEnabled || !isSessionRecordingEnabled) {
- return;
- }
+ // if (!isPostHogEnabled || !isSessionRecordingEnabled) {
+ // return;
+ // }
- posthog.stopSessionRecording();
+ // posthog.stopSessionRecording();
};
return {
diff --git a/packages/lib/client-only/providers/session.tsx b/packages/lib/client-only/providers/session.tsx
index b51c20fe3..cc0539c2f 100644
--- a/packages/lib/client-only/providers/session.tsx
+++ b/packages/lib/client-only/providers/session.tsx
@@ -5,14 +5,15 @@ import type { Session, User } from '@prisma/client';
interface AuthProviderProps {
children: React.ReactNode;
- session: Session;
- user: User;
+ session: DocumensoSession | null;
}
-const SessionContext = createContext<{
+export type DocumensoSession = {
user: User; // Todo: Exclude password
session: Session;
-} | null>(null);
+};
+
+const SessionContext = createContext(null);
export const useSession = () => {
const context = useContext(SessionContext);
@@ -24,6 +25,15 @@ export const useSession = () => {
return context;
};
-export const SessionProvider = ({ children, session, user }: AuthProviderProps) => {
- return {children} ;
+export const useOptionalSession = () => {
+ return (
+ useContext(SessionContext) || {
+ user: null,
+ session: null,
+ }
+ );
+};
+
+export const SessionProvider = ({ children, session }: AuthProviderProps) => {
+ return {children} ;
};
diff --git a/packages/lib/constants/app.ts b/packages/lib/constants/app.ts
index a2f079a56..2077df7ef 100644
--- a/packages/lib/constants/app.ts
+++ b/packages/lib/constants/app.ts
@@ -3,21 +3,10 @@ import { env } from '@documenso/lib/utils/env';
export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT =
Number(env('NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT')) || 50;
-// Todo: env('NEXT_PUBLIC_WEBAPP_URL')
-export const NEXT_PUBLIC_WEBAPP_URL = () => 'http://localhost:3000';
+export const NEXT_PUBLIC_WEBAPP_URL = () =>
+ env('NEXT_PUBLIC_WEBAPP_URL') ?? 'http://localhost:3000';
export const NEXT_PUBLIC_MARKETING_URL = () => env('NEXT_PUBLIC_MARKETING_URL');
export const NEXT_PRIVATE_INTERNAL_WEBAPP_URL =
env('NEXT_PRIVATE_INTERNAL_WEBAPP_URL') ?? NEXT_PUBLIC_WEBAPP_URL();
-export const IS_APP_MARKETING = env('NEXT_PUBLIC_PROJECT') === 'marketing';
-export const IS_APP_WEB = env('NEXT_PUBLIC_PROJECT') === 'web';
export const IS_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED') === 'true';
-export const IS_APP_WEB_I18N_ENABLED = true;
-
-export const APP_FOLDER = () => (IS_APP_MARKETING ? 'marketing' : 'web');
-
-export const APP_BASE_URL = () =>
- IS_APP_WEB ? NEXT_PUBLIC_WEBAPP_URL() : NEXT_PUBLIC_MARKETING_URL();
-
-export const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000';
-export const MARKETING_BASE_URL = NEXT_PUBLIC_MARKETING_URL() ?? 'http://localhost:3001';
diff --git a/packages/lib/constants/feature-flags.ts b/packages/lib/constants/feature-flags.ts
index 70380ecd6..50d1ff6b5 100644
--- a/packages/lib/constants/feature-flags.ts
+++ b/packages/lib/constants/feature-flags.ts
@@ -1,6 +1,6 @@
import { env } from '@documenso/lib/utils/env';
-import { APP_BASE_URL, WEBAPP_BASE_URL } from './app';
+import { NEXT_PUBLIC_WEBAPP_URL } from './app';
const NEXT_PUBLIC_FEATURE_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED');
const NEXT_PUBLIC_POSTHOG_KEY = () => env('NEXT_PUBLIC_POSTHOG_KEY');
@@ -24,7 +24,7 @@ export const LOCAL_FEATURE_FLAGS: Record = {
app_allow_encrypted_documents: false,
app_billing: NEXT_PUBLIC_FEATURE_BILLING_ENABLED() === 'true',
app_document_page_view_history_sheet: false,
- app_passkey: WEBAPP_BASE_URL === 'http://localhost:3000', // Temp feature flag.
+ app_passkey: true,
app_public_profile: true,
marketing_header_single_player_mode: false,
marketing_profiles_announcement_bar: true,
@@ -35,7 +35,7 @@ export const LOCAL_FEATURE_FLAGS: Record = {
*/
export function extractPostHogConfig(): { key: string; host: string } | null {
const postHogKey = NEXT_PUBLIC_POSTHOG_KEY();
- const postHogHost = `${APP_BASE_URL()}/ingest`;
+ const postHogHost = `${NEXT_PUBLIC_WEBAPP_URL()}/ingest`;
if (!postHogKey || !postHogHost) {
return null;
diff --git a/packages/lib/constants/pdf.ts b/packages/lib/constants/pdf.ts
index 1ac3508d5..cd4987b55 100644
--- a/packages/lib/constants/pdf.ts
+++ b/packages/lib/constants/pdf.ts
@@ -1,4 +1,4 @@
-import { APP_BASE_URL } from './app';
+import { NEXT_PUBLIC_WEBAPP_URL } from './app';
export const DEFAULT_STANDARD_FONT_SIZE = 12;
export const DEFAULT_HANDWRITING_FONT_SIZE = 50;
@@ -6,4 +6,4 @@ export const DEFAULT_HANDWRITING_FONT_SIZE = 50;
export const MIN_STANDARD_FONT_SIZE = 8;
export const MIN_HANDWRITING_FONT_SIZE = 20;
-export const CAVEAT_FONT_PATH = () => `${APP_BASE_URL()}/fonts/caveat.ttf`;
+export const CAVEAT_FONT_PATH = () => `${NEXT_PUBLIC_WEBAPP_URL()}/fonts/caveat.ttf`;
diff --git a/packages/lib/jobs/definitions/emails/send-team-member-joined-email.handler.ts b/packages/lib/jobs/definitions/emails/send-team-member-joined-email.handler.ts
index a6c4e115f..5de139525 100644
--- a/packages/lib/jobs/definitions/emails/send-team-member-joined-email.handler.ts
+++ b/packages/lib/jobs/definitions/emails/send-team-member-joined-email.handler.ts
@@ -8,7 +8,7 @@ import TeamJoinEmailTemplate from '@documenso/email/templates/team-join';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
-import { WEBAPP_BASE_URL } from '../../../constants/app';
+import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../../utils/team-global-settings-to-branding';
@@ -60,8 +60,8 @@ export const run = async ({
`send-team-member-joined-email--${invitedMember.id}_${member.id}`,
async () => {
const emailContent = createElement(TeamJoinEmailTemplate, {
- assetBaseUrl: WEBAPP_BASE_URL,
- baseUrl: WEBAPP_BASE_URL,
+ assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
+ baseUrl: NEXT_PUBLIC_WEBAPP_URL(),
memberName: invitedMember.user.name || '',
memberEmail: invitedMember.user.email,
teamName: team.name,
diff --git a/packages/lib/jobs/definitions/emails/send-team-member-left-email.handler.ts b/packages/lib/jobs/definitions/emails/send-team-member-left-email.handler.ts
index 1a2d2a849..5d4c11714 100644
--- a/packages/lib/jobs/definitions/emails/send-team-member-left-email.handler.ts
+++ b/packages/lib/jobs/definitions/emails/send-team-member-left-email.handler.ts
@@ -8,7 +8,7 @@ import TeamJoinEmailTemplate from '@documenso/email/templates/team-join';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
-import { WEBAPP_BASE_URL } from '../../../constants/app';
+import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../../utils/team-global-settings-to-branding';
@@ -50,8 +50,8 @@ export const run = async ({
for (const member of team.members) {
await io.runTask(`send-team-member-left-email--${oldMember.id}_${member.id}`, async () => {
const emailContent = createElement(TeamJoinEmailTemplate, {
- assetBaseUrl: WEBAPP_BASE_URL,
- baseUrl: WEBAPP_BASE_URL,
+ assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
+ baseUrl: NEXT_PUBLIC_WEBAPP_URL(),
memberName: oldMember.name || '',
memberEmail: oldMember.email,
teamName: team.name,
diff --git a/packages/lib/server-only/team/create-team-checkout-session.ts b/packages/lib/server-only/team/create-team-checkout-session.ts
index 9a476ea70..706bea48e 100644
--- a/packages/lib/server-only/team/create-team-checkout-session.ts
+++ b/packages/lib/server-only/team/create-team-checkout-session.ts
@@ -1,6 +1,6 @@
import { getCheckoutSession } from '@documenso/ee/server-only/stripe/get-checkout-session';
import { getTeamPrices } from '@documenso/ee/server-only/stripe/get-team-prices';
-import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
+import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
@@ -32,7 +32,7 @@ export const createTeamPendingCheckoutSession = async ({
const stripeCheckoutSession = await getCheckoutSession({
customerId: teamPendingCreation.customerId,
priceId,
- returnUrl: `${WEBAPP_BASE_URL}/settings/teams`,
+ returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/settings/teams`,
subscriptionMetadata: {
pendingTeamId: pendingTeamId.toString(),
},
diff --git a/packages/lib/server-only/team/create-team-email-verification.ts b/packages/lib/server-only/team/create-team-email-verification.ts
index ca327229f..1b88aa530 100644
--- a/packages/lib/server-only/team/create-team-email-verification.ts
+++ b/packages/lib/server-only/team/create-team-email-verification.ts
@@ -7,7 +7,7 @@ import { z } from 'zod';
import { mailer } from '@documenso/email/mailer';
import { ConfirmTeamEmailTemplate } from '@documenso/email/templates/confirm-team-email';
-import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
+import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
@@ -127,7 +127,7 @@ export const sendTeamEmailVerificationEmail = async (
const template = createElement(ConfirmTeamEmailTemplate, {
assetBaseUrl,
- baseUrl: WEBAPP_BASE_URL,
+ baseUrl: NEXT_PUBLIC_WEBAPP_URL(),
teamName: team.name,
teamUrl: team.url,
token,
diff --git a/packages/lib/server-only/team/create-team-member-invites.ts b/packages/lib/server-only/team/create-team-member-invites.ts
index 3f698720f..2af91ee2d 100644
--- a/packages/lib/server-only/team/create-team-member-invites.ts
+++ b/packages/lib/server-only/team/create-team-member-invites.ts
@@ -7,7 +7,7 @@ import { nanoid } from 'nanoid';
import { mailer } from '@documenso/email/mailer';
import { TeamInviteEmailTemplate } from '@documenso/email/templates/team-invite';
-import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
+import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
@@ -154,8 +154,8 @@ export const sendTeamMemberInviteEmail = async ({
team,
}: SendTeamMemberInviteEmailOptions) => {
const template = createElement(TeamInviteEmailTemplate, {
- assetBaseUrl: WEBAPP_BASE_URL,
- baseUrl: WEBAPP_BASE_URL,
+ assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
+ baseUrl: NEXT_PUBLIC_WEBAPP_URL(),
senderName,
token,
teamName: team.name,
diff --git a/packages/lib/server-only/team/delete-team-email.ts b/packages/lib/server-only/team/delete-team-email.ts
index 1c7b3bd42..232a75fc1 100644
--- a/packages/lib/server-only/team/delete-team-email.ts
+++ b/packages/lib/server-only/team/delete-team-email.ts
@@ -4,7 +4,7 @@ import { msg } from '@lingui/macro';
import { mailer } from '@documenso/email/mailer';
import { TeamEmailRemovedTemplate } from '@documenso/email/templates/team-email-removed';
-import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
+import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { prisma } from '@documenso/prisma';
@@ -74,7 +74,7 @@ export const deleteTeamEmail = async ({ userId, userEmail, teamId }: DeleteTeamE
const template = createElement(TeamEmailRemovedTemplate, {
assetBaseUrl,
- baseUrl: WEBAPP_BASE_URL,
+ baseUrl: NEXT_PUBLIC_WEBAPP_URL(),
teamEmail: team.teamEmail?.email ?? '',
teamName: team.name,
teamUrl: team.url,
diff --git a/packages/lib/server-only/team/delete-team.ts b/packages/lib/server-only/team/delete-team.ts
index a478d9426..74a44514c 100644
--- a/packages/lib/server-only/team/delete-team.ts
+++ b/packages/lib/server-only/team/delete-team.ts
@@ -5,7 +5,7 @@ import type { Team, TeamGlobalSettings } from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import { TeamDeleteEmailTemplate } from '@documenso/email/templates/team-delete';
-import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
+import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { AppError } from '@documenso/lib/errors/app-error';
import { stripe } from '@documenso/lib/server-only/stripe';
@@ -96,8 +96,8 @@ type SendTeamDeleteEmailOptions = {
export const sendTeamDeleteEmail = async ({ email, isOwner, team }: SendTeamDeleteEmailOptions) => {
const template = createElement(TeamDeleteEmailTemplate, {
- assetBaseUrl: WEBAPP_BASE_URL,
- baseUrl: WEBAPP_BASE_URL,
+ assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
+ baseUrl: NEXT_PUBLIC_WEBAPP_URL(),
teamUrl: team.url,
isOwner,
});
diff --git a/packages/lib/server-only/team/request-team-ownership-transfer.ts b/packages/lib/server-only/team/request-team-ownership-transfer.ts
index abba8b889..d3ad3857f 100644
--- a/packages/lib/server-only/team/request-team-ownership-transfer.ts
+++ b/packages/lib/server-only/team/request-team-ownership-transfer.ts
@@ -4,7 +4,7 @@ import { msg } from '@lingui/macro';
import { mailer } from '@documenso/email/mailer';
import { TeamTransferRequestTemplate } from '@documenso/email/templates/team-transfer-request';
-import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
+import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { createTokenVerification } from '@documenso/lib/utils/token-verification';
import { prisma } from '@documenso/prisma';
@@ -89,8 +89,8 @@ export const requestTeamOwnershipTransfer = async ({
});
const template = createElement(TeamTransferRequestTemplate, {
- assetBaseUrl: WEBAPP_BASE_URL,
- baseUrl: WEBAPP_BASE_URL,
+ assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
+ baseUrl: NEXT_PUBLIC_WEBAPP_URL(),
senderName: userName,
teamName: team.name,
teamUrl: team.url,
diff --git a/packages/lib/universal/get-feature-flag.ts b/packages/lib/universal/get-feature-flag.ts
index 92f186ab3..88c5471ca 100644
--- a/packages/lib/universal/get-feature-flag.ts
+++ b/packages/lib/universal/get-feature-flag.ts
@@ -2,7 +2,7 @@ import { z } from 'zod';
import type { TFeatureFlagValue } from '@documenso/lib/client-only/providers/feature-flag.types';
import { ZFeatureFlagValueSchema } from '@documenso/lib/client-only/providers/feature-flag.types';
-import { APP_BASE_URL } from '@documenso/lib/constants/app';
+import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { LOCAL_FEATURE_FLAGS, isFeatureFlagEnabled } from '@documenso/lib/constants/feature-flags';
/**
@@ -23,7 +23,7 @@ export const getFlag = async (
return LOCAL_FEATURE_FLAGS[flag] ?? true;
}
- const url = new URL(`${APP_BASE_URL()}/api/feature-flag/get`);
+ const url = new URL(`${NEXT_PUBLIC_WEBAPP_URL()}/api/feature-flag/get`);
url.searchParams.set('flag', flag);
return await fetch(url, {
@@ -58,7 +58,7 @@ export const getAllFlags = async (
return LOCAL_FEATURE_FLAGS;
}
- const url = new URL(`${APP_BASE_URL()}/api/feature-flag/all`);
+ const url = new URL(`${NEXT_PUBLIC_WEBAPP_URL()}/api/feature-flag/all`);
return fetch(url, {
headers: {
@@ -86,7 +86,7 @@ export const getAllAnonymousFlags = async (): Promise {
- const webAppBaseUrl = new URL(WEBAPP_BASE_URL);
+ const webAppBaseUrl = new URL(NEXT_PUBLIC_WEBAPP_URL());
const rpId = webAppBaseUrl.hostname;
return {
rpName: 'Documenso',
rpId,
- origin: WEBAPP_BASE_URL,
+ origin: NEXT_PUBLIC_WEBAPP_URL(),
timeout: PASSKEY_TIMEOUT,
};
};
diff --git a/packages/lib/utils/env.ts b/packages/lib/utils/env.ts
index cd2b6e6ab..dcf7b8e90 100644
--- a/packages/lib/utils/env.ts
+++ b/packages/lib/utils/env.ts
@@ -2,7 +2,7 @@
type EnvironmentVariable = keyof NodeJS.ProcessEnv;
-export const env = (variable: EnvironmentVariable | (string & {})) => {
+export const env = (variable: EnvironmentVariable | (string & {})): string | undefined => {
// console.log({
// ['typeof window']: typeof window,
// ['process.env']: process.env,
diff --git a/packages/lib/utils/i18n.ts b/packages/lib/utils/i18n.ts
index bde48a2a6..2c23633f1 100644
--- a/packages/lib/utils/i18n.ts
+++ b/packages/lib/utils/i18n.ts
@@ -2,7 +2,6 @@ import type { ReadonlyRequestCookies } from 'next/dist/server/web/spec-extension
import type { I18n, MessageDescriptor } from '@lingui/core';
-import { IS_APP_WEB, IS_APP_WEB_I18N_ENABLED } from '../constants/app';
import type { I18nLocaleData, SupportedLanguageCodes } from '../constants/i18n';
import { APP_I18N_OPTIONS } from '../constants/i18n';
import { env } from './env';
@@ -88,11 +87,6 @@ export const extractLocaleData = ({
lang = langHeader.lang;
}
- // Override web app to be English.
- if (!IS_APP_WEB_I18N_ENABLED && IS_APP_WEB) {
- lang = 'en';
- }
-
// Filter out locales that are not valid.
const locales = (langHeader?.locales ?? []).filter((locale) => {
try {
diff --git a/packages/lib/utils/public-profiles.ts b/packages/lib/utils/public-profiles.ts
index d303c9cb7..cccdfbd4a 100644
--- a/packages/lib/utils/public-profiles.ts
+++ b/packages/lib/utils/public-profiles.ts
@@ -1,15 +1,15 @@
-import { WEBAPP_BASE_URL } from '../constants/app';
+import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
export const formatUserProfilePath = (
profileUrl: string,
options: { excludeBaseUrl?: boolean } = {},
) => {
- return `${!options?.excludeBaseUrl ? WEBAPP_BASE_URL : ''}/p/${profileUrl}`;
+ return `${!options?.excludeBaseUrl ? NEXT_PUBLIC_WEBAPP_URL() : ''}/p/${profileUrl}`;
};
export const formatTeamProfilePath = (
profileUrl: string,
options: { excludeBaseUrl?: boolean } = {},
) => {
- return `${!options?.excludeBaseUrl ? WEBAPP_BASE_URL : ''}/p/${profileUrl}`;
+ return `${!options?.excludeBaseUrl ? NEXT_PUBLIC_WEBAPP_URL() : ''}/p/${profileUrl}`;
};
diff --git a/packages/lib/utils/teams.ts b/packages/lib/utils/teams.ts
index c6dfd27fd..de5b70973 100644
--- a/packages/lib/utils/teams.ts
+++ b/packages/lib/utils/teams.ts
@@ -1,9 +1,9 @@
-import { WEBAPP_BASE_URL } from '../constants/app';
+import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
import type { TEAM_MEMBER_ROLE_MAP } from '../constants/teams';
import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../constants/teams';
export const formatTeamUrl = (teamUrl: string, baseUrl?: string) => {
- const formattedBaseUrl = (baseUrl ?? WEBAPP_BASE_URL).replace(/https?:\/\//, '');
+ const formattedBaseUrl = (baseUrl ?? NEXT_PUBLIC_WEBAPP_URL()).replace(/https?:\/\//, '');
return `${formattedBaseUrl}/t/${teamUrl}`;
};
diff --git a/packages/lib/utils/templates.ts b/packages/lib/utils/templates.ts
index c277c2fd4..edac17011 100644
--- a/packages/lib/utils/templates.ts
+++ b/packages/lib/utils/templates.ts
@@ -1,9 +1,9 @@
import type { Recipient } from '@prisma/client';
-import { WEBAPP_BASE_URL } from '../constants/app';
+import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
export const formatDirectTemplatePath = (token: string) => {
- return `${WEBAPP_BASE_URL}/d/${token}`;
+ return `${NEXT_PUBLIC_WEBAPP_URL()}/d/${token}`;
};
/**
diff --git a/packages/ui/components/call-to-action.tsx b/packages/ui/components/call-to-action.tsx
index 1e741e37e..34fd87ae1 100644
--- a/packages/ui/components/call-to-action.tsx
+++ b/packages/ui/components/call-to-action.tsx
@@ -1,4 +1,4 @@
-import Link from 'next/link';
+import { Link } from 'react-router';
import { Button } from '../primitives/button';
import { Card, CardContent } from '../primitives/card';
@@ -25,7 +25,7 @@ export const CallToAction = ({ className, utmSource = 'generic-cta' }: CallToAct
size="lg"
asChild
>
-
+
Get started
diff --git a/packages/ui/components/document/document-dialog.tsx b/packages/ui/components/document/document-dialog.tsx
index adb556675..c4388c190 100644
--- a/packages/ui/components/document/document-dialog.tsx
+++ b/packages/ui/components/document/document-dialog.tsx
@@ -1,5 +1,3 @@
-'use client';
-
import { useState } from 'react';
import type { DocumentData } from '@prisma/client';
@@ -7,17 +5,18 @@ import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '../../lib/utils';
-import { Dialog, DialogOverlay, DialogPortal } from '../../primitives/dialog';
+import { Dialog, DialogOverlay, DialogPortal, DialogTrigger } from '../../primitives/dialog';
import { LazyPDFViewerNoLoader } from '../../primitives/lazy-pdf-viewer';
export type DocumentDialogProps = {
+ trigger?: React.ReactNode;
documentData: DocumentData;
} & Omit;
/**
* A dialog which renders the provided document.
*/
-export default function DocumentDialog({ documentData, ...props }: DocumentDialogProps) {
+export default function DocumentDialog({ trigger, documentData, ...props }: DocumentDialogProps) {
const [documentLoaded, setDocumentLoaded] = useState(false);
const onDocumentLoad = () => {
@@ -29,6 +28,12 @@ export default function DocumentDialog({ documentData, ...props }: DocumentDialo
+ {trigger && (
+ e.stopPropagation()} asChild={true}>
+ {trigger}
+
+ )}
+
{
@@ -232,7 +227,7 @@ const SigningCardImage = ({ signingCelebrationImage }: SigningCardImageProps) =>
duration: 0.5,
}}
>
-
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
}}
- priority
/>
);
diff --git a/packages/ui/primitives/constants.ts b/packages/ui/primitives/constants.ts
deleted file mode 100644
index 9771eb35a..000000000
--- a/packages/ui/primitives/constants.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export const THEMES_TYPE = {
- DARK: 'dark',
- LIGHT: 'light',
- SYSTEM: 'system'
-};
\ No newline at end of file
diff --git a/packages/ui/primitives/theme-switcher.tsx b/packages/ui/primitives/theme-switcher.tsx
index ab7a7d2bd..11422954a 100644
--- a/packages/ui/primitives/theme-switcher.tsx
+++ b/packages/ui/primitives/theme-switcher.tsx
@@ -1,22 +1,20 @@
import { motion } from 'framer-motion';
import { Monitor, MoonStar, Sun } from 'lucide-react';
-import { useTheme } from 'next-themes';
+import { Theme, useTheme } from 'remix-themes';
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
-import { THEMES_TYPE } from './constants';
-
export const ThemeSwitcher = () => {
- const { theme, setTheme } = useTheme();
+ const [theme, setTheme] = useTheme();
const isMounted = useIsMounted();
return (
setTheme(THEMES_TYPE.LIGHT)}
+ onClick={() => setTheme(Theme.LIGHT)}
>
- {isMounted && theme === THEMES_TYPE.LIGHT && (
+ {isMounted && theme === Theme.LIGHT && (
{
setTheme(THEMES_TYPE.DARK)}
+ onClick={() => setTheme(Theme.DARK)}
>
- {isMounted && theme === THEMES_TYPE.DARK && (
+ {isMounted && theme === Theme.DARK && (
{
setTheme(THEMES_TYPE.SYSTEM)}
+ onClick={() => setTheme(null)}
>
- {isMounted && theme === THEMES_TYPE.SYSTEM && (
+ {isMounted && theme === null && (