mirror of
https://github.com/documenso/documenso.git
synced 2026-08-23 14:52:23 +10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9825ea88b1 | ||
|
|
779de01fe8 | ||
|
|
283c6d274b | ||
|
|
688ef2fdf3 | ||
|
|
a5e37af3e8 | ||
|
|
617f8cc204 | ||
|
|
1bd09480e6 | ||
|
|
962cffc9f5 | ||
|
|
820b319474 |
@@ -1,3 +1,3 @@
|
||||
legacy-peer-deps = true
|
||||
prefer-dedupe = true
|
||||
# min-release-age = 7
|
||||
min-release-age = 7
|
||||
|
||||
@@ -465,6 +465,7 @@ const response = await fetch(`${BASE_URL}/template/use`, {
|
||||
typedSignatureEnabled: true,
|
||||
uploadSignatureEnabled: false,
|
||||
drawSignatureEnabled: true,
|
||||
qrSignatureEnabled: true,
|
||||
},
|
||||
distributeDocument: true,
|
||||
}),
|
||||
@@ -485,6 +486,7 @@ const response = await fetch(`${BASE_URL}/template/use`, {
|
||||
| `typedSignatureEnabled` | boolean | Allow typed signatures |
|
||||
| `uploadSignatureEnabled` | boolean | Allow uploaded signature images |
|
||||
| `drawSignatureEnabled` | boolean | Allow drawn signatures |
|
||||
| `qrSignatureEnabled` | boolean | Allow QR code handoff to a mobile device |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -390,6 +390,7 @@ const response = await fetch(`${BASE_URL}/template/update`, {
|
||||
typedSignatureEnabled: true, // Allow typed signatures
|
||||
drawSignatureEnabled: true, // Allow drawn signatures
|
||||
uploadSignatureEnabled: false, // Disable uploaded signatures
|
||||
qrSignatureEnabled: true, // Allow QR code handoff to a mobile device
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -68,6 +68,7 @@ All webhook events share a common structure:
|
||||
| `typedSignatureEnabled` | boolean | Whether typed signatures are allowed |
|
||||
| `uploadSignatureEnabled` | boolean | Whether uploaded signatures are allowed |
|
||||
| `drawSignatureEnabled` | boolean | Whether drawn signatures are allowed |
|
||||
| `qrSignatureEnabled` | boolean | Whether QR code handoff to a mobile device is allowed |
|
||||
| `language` | string | Document language code |
|
||||
| `distributionMethod` | string | How document is distributed |
|
||||
| `emailSettings` | object? | Custom email settings for this document |
|
||||
@@ -141,6 +142,7 @@ Triggered when a new document is created.
|
||||
"typedSignatureEnabled": true,
|
||||
"uploadSignatureEnabled": true,
|
||||
"drawSignatureEnabled": true,
|
||||
"qrSignatureEnabled": true,
|
||||
"language": "en",
|
||||
"distributionMethod": "EMAIL",
|
||||
"emailSettings": null
|
||||
@@ -235,6 +237,7 @@ The document status changes to `PENDING` and recipients have `sendStatus: "SENT"
|
||||
"typedSignatureEnabled": true,
|
||||
"uploadSignatureEnabled": true,
|
||||
"drawSignatureEnabled": true,
|
||||
"qrSignatureEnabled": true,
|
||||
"language": "en",
|
||||
"distributionMethod": "EMAIL",
|
||||
"emailSettings": null
|
||||
@@ -435,6 +438,7 @@ The document status changes to `COMPLETED` and `completedAt` is set.
|
||||
"typedSignatureEnabled": true,
|
||||
"uploadSignatureEnabled": true,
|
||||
"drawSignatureEnabled": true,
|
||||
"qrSignatureEnabled": true,
|
||||
"language": "en",
|
||||
"distributionMethod": "EMAIL",
|
||||
"emailSettings": null
|
||||
@@ -618,6 +622,7 @@ This event is **not** triggered when a recipient hides a document from their inb
|
||||
"typedSignatureEnabled": true,
|
||||
"uploadSignatureEnabled": true,
|
||||
"drawSignatureEnabled": true,
|
||||
"qrSignatureEnabled": true,
|
||||
"language": "en",
|
||||
"distributionMethod": "EMAIL",
|
||||
"emailSettings": null
|
||||
|
||||
@@ -81,7 +81,7 @@ services:
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?err}
|
||||
- POSTGRES_DB=${POSTGRES_DB:?err}
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER}']
|
||||
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
"postinstall": "fumadocs-mdx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"fumadocs-core": "16.5.0",
|
||||
"fumadocs-mdx": "14.2.6",
|
||||
"fumadocs-ui": "16.5.0",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/react": "18.3.27",
|
||||
"@types/react": "^19.2.17",
|
||||
"typescript": "5.6.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { InternalClaimPlans } from '@documenso/ee/server-only/stripe/get-internal-claim-plans';
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL, IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
||||
import { parseMessageDescriptorMacro } from '@documenso/lib/utils/i18n';
|
||||
@@ -380,7 +380,7 @@ const BillingPlanForm = ({ value, onChange, plans, canCreateFreeOrganisation }:
|
||||
))}
|
||||
|
||||
<Link
|
||||
to="https://documen.so/enterprise-cta"
|
||||
to={DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL}
|
||||
target="_blank"
|
||||
className="flex items-center space-x-2 rounded-md border bg-muted/30 p-4"
|
||||
>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { TQrSignatureContext } from '@documenso/lib/types/qr-signature';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@documenso/ui/primitives/dialog';
|
||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||
@@ -13,10 +14,21 @@ export type SignFieldSignatureDialogProps = {
|
||||
typedSignatureEnabled?: boolean;
|
||||
uploadSignatureEnabled?: boolean;
|
||||
drawSignatureEnabled?: boolean;
|
||||
qrSignatureEnabled?: boolean;
|
||||
qrSignatureContext?: TQrSignatureContext;
|
||||
};
|
||||
|
||||
export const SignFieldSignatureDialog = createCallable<SignFieldSignatureDialogProps, string | null>(
|
||||
({ call, fullName, typedSignatureEnabled, uploadSignatureEnabled, drawSignatureEnabled, initialSignature }) => {
|
||||
({
|
||||
call,
|
||||
fullName,
|
||||
typedSignatureEnabled,
|
||||
uploadSignatureEnabled,
|
||||
drawSignatureEnabled,
|
||||
qrSignatureEnabled,
|
||||
qrSignatureContext,
|
||||
initialSignature,
|
||||
}) => {
|
||||
const [localSignature, setLocalSignature] = useState(initialSignature);
|
||||
|
||||
return (
|
||||
@@ -36,6 +48,8 @@ export const SignFieldSignatureDialog = createCallable<SignFieldSignatureDialogP
|
||||
typedSignatureEnabled={typedSignatureEnabled}
|
||||
uploadSignatureEnabled={uploadSignatureEnabled}
|
||||
drawSignatureEnabled={drawSignatureEnabled}
|
||||
qrSignatureEnabled={qrSignatureEnabled}
|
||||
qrSignatureContext={qrSignatureContext}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -470,6 +470,7 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={metadata?.drawSignatureEnabled}
|
||||
qrSignatureEnabled={metadata?.qrSignatureEnabled}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -33,7 +33,12 @@ export type EmbedDocumentFieldsProps = {
|
||||
fields: Field[];
|
||||
metadata?: Pick<
|
||||
DocumentMeta,
|
||||
'timezone' | 'dateFormat' | 'typedSignatureEnabled' | 'uploadSignatureEnabled' | 'drawSignatureEnabled'
|
||||
| 'timezone'
|
||||
| 'dateFormat'
|
||||
| 'typedSignatureEnabled'
|
||||
| 'uploadSignatureEnabled'
|
||||
| 'drawSignatureEnabled'
|
||||
| 'qrSignatureEnabled'
|
||||
> | null;
|
||||
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
@@ -53,6 +58,7 @@ export const EmbedDocumentFields = ({ fields, metadata, onSignField, onUnsignFie
|
||||
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={metadata?.drawSignatureEnabled}
|
||||
qrSignatureEnabled={metadata?.qrSignatureEnabled}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.INITIALS, () => (
|
||||
|
||||
@@ -461,6 +461,8 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={metadata?.drawSignatureEnabled}
|
||||
qrSignatureEnabled={metadata?.qrSignatureEnabled}
|
||||
qrSignatureContext={{ type: 'DOCUMENT_SIGNATURE', recipientToken: recipient.token }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -313,6 +313,7 @@ export const MultiSignDocumentSigningView = ({
|
||||
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
||||
qrSignatureEnabled={document.documentMeta?.qrSignatureEnabled}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -55,6 +55,7 @@ type SettingsSubset = Pick<
|
||||
| 'typedSignatureEnabled'
|
||||
| 'uploadSignatureEnabled'
|
||||
| 'drawSignatureEnabled'
|
||||
| 'qrSignatureEnabled'
|
||||
| 'defaultRecipients'
|
||||
| 'delegateDocumentOwnership'
|
||||
| 'aiFeaturesEnabled'
|
||||
|
||||
@@ -111,6 +111,7 @@ export const ProfileForm = ({ className }: ProfileFormProps) => {
|
||||
<FormControl>
|
||||
<SignaturePadDialog
|
||||
disabled={isSubmitting}
|
||||
qrSignatureContext={{ type: 'PROFILE_SIGNATURE' }}
|
||||
fullName={user.name ?? ''}
|
||||
value={value}
|
||||
onChange={(v) => onChange(v ?? '')}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { AuthenticationErrorCode } from '@documenso/auth/server/lib/errors/error-codes';
|
||||
import { formatPath } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
@@ -44,7 +45,7 @@ const handleFallbackErrorMessages = (code: string) => {
|
||||
return message;
|
||||
};
|
||||
|
||||
const LOGIN_REDIRECT_PATH = '/';
|
||||
const LOGIN_REDIRECT_PATH = formatPath('/');
|
||||
|
||||
export const ZSignInFormSchema = z.object({
|
||||
email: zEmail().min(1),
|
||||
|
||||
@@ -314,6 +314,7 @@ export const SignUpForm = ({
|
||||
<FormControl>
|
||||
<SignaturePadDialog
|
||||
disabled={isSubmitting}
|
||||
qrSignatureContext={{ type: 'PROFILE_SIGNATURE' }}
|
||||
value={value}
|
||||
onChange={(v) => onChange(v ?? '')}
|
||||
/>
|
||||
|
||||
@@ -156,6 +156,10 @@ export const AdminGlobalSettingsSection = ({
|
||||
</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>QR signature</Trans>}>
|
||||
<DetailsValue>{booleanValue(settings.qrSignatureEnabled, inheritedSettings?.qrSignatureEnabled)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Branding</Trans>}>
|
||||
<DetailsValue>{booleanValue(settings.brandingEnabled, inheritedSettings?.brandingEnabled)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { formatPath } from '@documenso/lib/constants/app';
|
||||
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
|
||||
import {
|
||||
DOCUMENTS_PAGE_SHORTCUT,
|
||||
@@ -18,7 +19,7 @@ import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { keepPreviousData } from '@tanstack/react-query';
|
||||
import { commandScore } from 'cmdk/dist/command-score';
|
||||
import { defaultFilter as commandScore } from 'cmdk';
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
CheckIcon,
|
||||
@@ -862,7 +863,7 @@ const PromptLanguageCommands = ({
|
||||
|
||||
formData.append('lang', lang);
|
||||
|
||||
const response = await fetch('/api/locale', {
|
||||
const response = await fetch(formatPath('/api/locale'), {
|
||||
method: 'post',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
@@ -269,6 +269,7 @@ export const DirectTemplateSigningForm = ({
|
||||
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
|
||||
qrSignatureEnabled={template.templateMeta?.qrSignatureEnabled}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.INITIALS, () => (
|
||||
@@ -408,6 +409,7 @@ export const DirectTemplateSigningForm = ({
|
||||
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
|
||||
qrSignatureEnabled={template.templateMeta?.qrSignatureEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+4
-1
@@ -1,4 +1,5 @@
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { formatPath } from '@documenso/lib/constants/app';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { DialogFooter } from '@documenso/ui/primitives/dialog';
|
||||
@@ -34,7 +35,9 @@ export const DocumentSigningAuthAccount = ({
|
||||
const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
|
||||
await authClient.signOut({
|
||||
redirectPath: `/signin?returnTo=${encodeURIComponent(currentPath)}#embedded=true&email=${isDirectTemplate ? '' : email}`,
|
||||
redirectPath: formatPath(
|
||||
`/signin?returnTo=${encodeURIComponent(currentPath)}#embedded=true&email=${isDirectTemplate ? '' : email}`,
|
||||
),
|
||||
});
|
||||
} catch {
|
||||
setIsSigningOut(false);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { formatPath } from '@documenso/lib/constants/app';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@@ -21,10 +22,10 @@ export const DocumentSigningAuthPageView = ({ email, emailHasAccount }: Document
|
||||
try {
|
||||
setIsSigningOut(true);
|
||||
|
||||
let redirectPath = '/signin';
|
||||
let redirectPath = formatPath('/signin');
|
||||
|
||||
if (email) {
|
||||
redirectPath = emailHasAccount ? `/signin#email=${email}` : `/signup#email=${email}`;
|
||||
redirectPath = emailHasAccount ? formatPath(`/signin#email=${email}`) : formatPath(`/signup#email=${email}`);
|
||||
}
|
||||
|
||||
await authClient.signOut({
|
||||
|
||||
@@ -254,6 +254,8 @@ export const DocumentSigningForm = ({
|
||||
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
||||
qrSignatureEnabled={document.documentMeta?.qrSignatureEnabled}
|
||||
qrSignatureContext={{ type: 'DOCUMENT_SIGNATURE', recipientToken: recipient.token }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -408,6 +408,7 @@ export const DocumentSigningPageViewV1 = ({
|
||||
typedSignatureEnabled={documentMeta?.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={documentMeta?.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={documentMeta?.drawSignatureEnabled}
|
||||
qrSignatureEnabled={documentMeta?.qrSignatureEnabled}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.INITIALS, () => <DocumentSigningInitialsField key={field.id} field={field} />)
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface DocumentSigningProviderProps {
|
||||
typedSignatureEnabled?: boolean;
|
||||
uploadSignatureEnabled?: boolean;
|
||||
drawSignatureEnabled?: boolean;
|
||||
qrSignatureEnabled?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -43,6 +44,7 @@ export const DocumentSigningProvider = ({
|
||||
typedSignatureEnabled = true,
|
||||
uploadSignatureEnabled = true,
|
||||
drawSignatureEnabled = true,
|
||||
qrSignatureEnabled = true,
|
||||
children,
|
||||
}: DocumentSigningProviderProps) => {
|
||||
const [fullName, setFullName] = useState(initialFullName || '');
|
||||
@@ -54,7 +56,7 @@ export const DocumentSigningProvider = ({
|
||||
const sig = initialSignature || '';
|
||||
const isBase64 = isBase64Image(sig);
|
||||
|
||||
if (isBase64 && (uploadSignatureEnabled || drawSignatureEnabled)) {
|
||||
if (isBase64 && (uploadSignatureEnabled || drawSignatureEnabled || qrSignatureEnabled)) {
|
||||
return sig;
|
||||
}
|
||||
|
||||
|
||||
@@ -146,14 +146,17 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }:
|
||||
{isLoading && <DocumentSigningFieldsLoader />}
|
||||
|
||||
{!field.inserted && (
|
||||
<RadioGroup onValueChange={(value) => handleSelectItem(value)} className="z-10 my-0.5 gap-y-1">
|
||||
<RadioGroup
|
||||
value={selectedOption}
|
||||
onValueChange={(value) => handleSelectItem(value)}
|
||||
className="z-10 my-0.5 gap-y-1"
|
||||
>
|
||||
{values?.map((item, index) => (
|
||||
<div key={index} className="flex items-center">
|
||||
<RadioGroupItem
|
||||
className="h-3 w-3 shrink-0"
|
||||
value={item.value}
|
||||
id={`option-${field.id}-${item.id}`}
|
||||
checked={item.checked}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
{!item.value.includes('empty-value-') && item.value && (
|
||||
@@ -167,14 +170,13 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }:
|
||||
)}
|
||||
|
||||
{field.inserted && (
|
||||
<RadioGroup className="my-0.5 gap-y-1">
|
||||
<RadioGroup value={field.customText ?? ''} className="my-0.5 gap-y-1">
|
||||
{values?.map((item, index) => (
|
||||
<div key={index} className="flex items-center">
|
||||
<RadioGroupItem
|
||||
className="h-3 w-3"
|
||||
value={item.value}
|
||||
id={`option-${field.id}-${item.id}`}
|
||||
checked={item.value === field.customText}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
{!item.value.includes('empty-value-') && item.value && (
|
||||
|
||||
+4
@@ -34,6 +34,7 @@ export type DocumentSigningSignatureFieldProps = {
|
||||
typedSignatureEnabled?: boolean;
|
||||
uploadSignatureEnabled?: boolean;
|
||||
drawSignatureEnabled?: boolean;
|
||||
qrSignatureEnabled?: boolean;
|
||||
};
|
||||
|
||||
export const DocumentSigningSignatureField = ({
|
||||
@@ -43,6 +44,7 @@ export const DocumentSigningSignatureField = ({
|
||||
typedSignatureEnabled,
|
||||
uploadSignatureEnabled,
|
||||
drawSignatureEnabled,
|
||||
qrSignatureEnabled,
|
||||
}: DocumentSigningSignatureFieldProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
@@ -279,6 +281,8 @@ export const DocumentSigningSignatureField = ({
|
||||
typedSignatureEnabled={typedSignatureEnabled}
|
||||
uploadSignatureEnabled={uploadSignatureEnabled}
|
||||
drawSignatureEnabled={drawSignatureEnabled}
|
||||
qrSignatureEnabled={qrSignatureEnabled}
|
||||
qrSignatureContext={{ type: 'DOCUMENT_SIGNATURE', recipientToken: recipient.token }}
|
||||
/>
|
||||
|
||||
<DocumentSigningDisclosure />
|
||||
|
||||
@@ -172,7 +172,9 @@ export const EnvelopeSigningProvider = ({
|
||||
|
||||
if (
|
||||
!sig &&
|
||||
(envelope.documentMeta.uploadSignatureEnabled || envelope.documentMeta.drawSignatureEnabled) &&
|
||||
(envelope.documentMeta.uploadSignatureEnabled ||
|
||||
envelope.documentMeta.drawSignatureEnabled ||
|
||||
envelope.documentMeta.qrSignatureEnabled) &&
|
||||
envelopeData.recipientSignature?.signatureImageAsBase64
|
||||
) {
|
||||
return envelopeData.recipientSignature.signatureImageAsBase64;
|
||||
@@ -182,7 +184,12 @@ export const EnvelopeSigningProvider = ({
|
||||
return envelopeData.recipientSignature.typedSignature;
|
||||
}
|
||||
|
||||
if (isBase64 && (envelope.documentMeta.uploadSignatureEnabled || envelope.documentMeta.drawSignatureEnabled)) {
|
||||
if (
|
||||
isBase64 &&
|
||||
(envelope.documentMeta.uploadSignatureEnabled ||
|
||||
envelope.documentMeta.drawSignatureEnabled ||
|
||||
envelope.documentMeta.qrSignatureEnabled)
|
||||
) {
|
||||
return sig;
|
||||
}
|
||||
|
||||
|
||||
@@ -174,6 +174,7 @@ export const DocumentEditForm = ({ className, initialDocument, documentRootPath
|
||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||
qrSignatureEnabled: signatureTypes.includes(DocumentSignatureType.QR),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -278,6 +278,7 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
|
||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||
qrSignatureEnabled: signatureTypes.includes(DocumentSignatureType.QR),
|
||||
envelopeExpirationPeriod,
|
||||
reminderSettings,
|
||||
},
|
||||
|
||||
@@ -121,6 +121,8 @@ export default function EnvelopeSignerForm() {
|
||||
typedSignatureEnabled={envelope.documentMeta.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={envelope.documentMeta.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={envelope.documentMeta.drawSignatureEnabled}
|
||||
qrSignatureEnabled={envelope.documentMeta.qrSignatureEnabled}
|
||||
qrSignatureContext={{ type: 'DOCUMENT_SIGNATURE', recipientToken: recipient.token }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -384,6 +384,8 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
|
||||
typedSignatureEnabled: envelope.documentMeta.typedSignatureEnabled,
|
||||
uploadSignatureEnabled: envelope.documentMeta.uploadSignatureEnabled,
|
||||
drawSignatureEnabled: envelope.documentMeta.drawSignatureEnabled,
|
||||
qrSignatureEnabled: envelope.documentMeta.qrSignatureEnabled,
|
||||
recipientToken: envelopeData.recipient.token,
|
||||
})
|
||||
.then(async (payload) => {
|
||||
if (!payload) {
|
||||
|
||||
@@ -215,7 +215,7 @@ export default function PDFViewer({
|
||||
|
||||
type VirtualizedPageListProps = {
|
||||
scrollParentRef: ScrollTarget;
|
||||
constraintRef: React.RefObject<HTMLDivElement>;
|
||||
constraintRef: React.RefObject<HTMLDivElement | null>;
|
||||
pages: PageMeta[];
|
||||
numPages: number;
|
||||
pdf: pdfjsLib.PDFDocumentProxy;
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { motion, useReducedMotion } from 'framer-motion';
|
||||
|
||||
import { EASE, POP, SPRING } from './motion';
|
||||
import { SettingsUpsellCard } from './settings-upsell-card';
|
||||
import { useTimedCycle } from './use-timed-cycle';
|
||||
|
||||
const DEMO_BRANDS = [
|
||||
{
|
||||
name: 'Documenso',
|
||||
letter: 'D',
|
||||
domain: 'noreply@app.documenso.com',
|
||||
accent: '#A2E771',
|
||||
ink: '#162C07',
|
||||
tint: '#F2FBEA',
|
||||
sheen: 'rgba(162, 231, 113, 0.32)',
|
||||
},
|
||||
{
|
||||
name: 'Documenso',
|
||||
letter: 'D',
|
||||
domain: 'noreply@app.documenso.com',
|
||||
accent: '#387BC7',
|
||||
ink: '#ffffff',
|
||||
tint: '#EDF3FA',
|
||||
sheen: 'rgba(56, 123, 199, 0.28)',
|
||||
},
|
||||
{
|
||||
name: 'Documenso',
|
||||
letter: 'D',
|
||||
domain: 'noreply@app.documenso.com',
|
||||
accent: '#9747F5',
|
||||
ink: '#ffffff',
|
||||
tint: '#F4EDFE',
|
||||
sheen: 'rgba(151, 71, 245, 0.26)',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Milliseconds each brand is shown before cycling to the next.
|
||||
*/
|
||||
const BRAND_CYCLE_INTERVAL_MS = 2400;
|
||||
|
||||
export const BrandingUpsell = () => {
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const isReducedMotion = useReducedMotion();
|
||||
const brandIndex = useTimedCycle(DEMO_BRANDS.map(() => BRAND_CYCLE_INTERVAL_MS));
|
||||
|
||||
const isStatic = isReducedMotion ?? false;
|
||||
|
||||
const brand = DEMO_BRANDS[brandIndex];
|
||||
|
||||
return (
|
||||
<SettingsUpsellCard
|
||||
planLabel={<Trans>Teams</Trans>}
|
||||
title={<Trans>Unlock Branding Preferences</Trans>}
|
||||
description={
|
||||
<Trans>Put your own brand on every document you send. Branding is available on the Teams plan and above.</Trans>
|
||||
}
|
||||
features={[
|
||||
<Trans key="logo">Your logo on signing pages and emails</Trans>,
|
||||
<Trans key="details">Company details and website in email footers</Trans>,
|
||||
<Trans key="teams">Separate branding per team</Trans>,
|
||||
]}
|
||||
preview={
|
||||
<div className="mx-auto w-full max-w-xs">
|
||||
<div className="flex h-8 items-center justify-between px-1">
|
||||
<span className="font-mono text-[10px] text-muted-foreground uppercase tracking-widest">
|
||||
<Trans>Brand accent</Trans>
|
||||
</span>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{DEMO_BRANDS.map((dotBrand, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={isStatic ? false : undefined}
|
||||
animate={{
|
||||
scale: index === brandIndex ? 1.25 : 1,
|
||||
opacity: index === brandIndex ? 1 : 0.42,
|
||||
boxShadow:
|
||||
index === brandIndex ? '0 0 0 3px rgba(15, 23, 42, 0.08)' : '0 0 0 0 rgba(15, 23, 42, 0)',
|
||||
}}
|
||||
transition={SPRING}
|
||||
className="h-[13px] w-[13px] rounded-full"
|
||||
style={{ backgroundColor: dotBrand.accent }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative mt-3 flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm">
|
||||
<motion.div
|
||||
initial={isStatic ? false : undefined}
|
||||
animate={{ backgroundColor: brand.tint }}
|
||||
transition={{ duration: 0.45, ease: EASE }}
|
||||
className="flex items-center gap-2.5 border-b px-4 py-3"
|
||||
>
|
||||
{/* The sender identity never changes — only the tile colours tween per brand. */}
|
||||
<motion.div
|
||||
initial={isStatic ? false : undefined}
|
||||
animate={{ backgroundColor: brand.accent, color: brand.ink }}
|
||||
transition={{ backgroundColor: { duration: 0.4 }, color: { duration: 0.4 } }}
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg font-semibold text-sm"
|
||||
>
|
||||
{brand.letter}
|
||||
</motion.div>
|
||||
|
||||
{/*
|
||||
* Hardcoded inks (not theme tokens): this row sits on the
|
||||
* hardcoded light `tint` band, so it pairs with hardcoded ink
|
||||
* colours the same way the email sibling pairs its hardcoded
|
||||
* avatar surfaces (hardcoded surface => hardcoded ink).
|
||||
*/}
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-[#0f172a] text-sm">
|
||||
<p className="truncate">{brand.name}</p>
|
||||
</div>
|
||||
|
||||
<div className="font-mono text-[#64748b] text-xs">
|
||||
<p className="truncate">{brand.domain}</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="px-4 py-3.5">
|
||||
<p className="font-medium text-sm">
|
||||
<Trans>Please sign: Example.pdf</Trans>
|
||||
</p>
|
||||
|
||||
<p className="mt-1 text-muted-foreground text-xs">
|
||||
<Trans>{organisation.name} has invited you to sign this document.</Trans>
|
||||
</p>
|
||||
|
||||
{/* Same replay split as the logo tile: colours tween on the persistent button, the pop replays per brand on the remounting label. */}
|
||||
<motion.div
|
||||
initial={isStatic ? false : undefined}
|
||||
animate={{ backgroundColor: brand.accent, color: brand.ink }}
|
||||
transition={{ backgroundColor: { duration: 0.4 }, color: { duration: 0.4 } }}
|
||||
className="mt-3 inline-block rounded-md px-3 py-1.5 font-medium text-xs"
|
||||
>
|
||||
<motion.span
|
||||
key={brandIndex}
|
||||
initial={isStatic ? false : { scale: 0.96 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ ...POP, delay: 0.06 }}
|
||||
className="inline-block"
|
||||
>
|
||||
<Trans>Sign</Trans>
|
||||
</motion.span>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex items-center gap-2.5 border-t bg-muted px-4 py-2.5">
|
||||
<span className="font-mono text-[10px] text-muted-foreground uppercase tracking-widest">
|
||||
<Trans>Company details</Trans>
|
||||
</span>
|
||||
|
||||
<motion.div
|
||||
initial={isStatic ? false : undefined}
|
||||
animate={{ backgroundColor: brand.accent }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="h-1.5 w-[54px] rounded-full"
|
||||
style={{ opacity: 0.45 }}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
initial={isStatic ? false : undefined}
|
||||
animate={{ backgroundColor: brand.accent }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="h-1.5 w-[34px] rounded-full"
|
||||
style={{ opacity: 0.22 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
* Keyed remount replays the sweep per brand. No opacity envelope —
|
||||
* keyframe arrays are unreliable on strict-mode remounts; both
|
||||
* endpoints sit outside the overflow-hidden card, so the clip
|
||||
* provides the fade in/out instead.
|
||||
*/}
|
||||
<motion.div
|
||||
key={`sheen-${brandIndex}`}
|
||||
initial={isStatic ? false : { x: '-130%' }}
|
||||
animate={{ x: '240%' }}
|
||||
transition={{ duration: 1.15, ease: 'easeOut' }}
|
||||
className="pointer-events-none absolute inset-y-0 left-0 w-[55%]"
|
||||
style={{ background: `linear-gradient(105deg, transparent, ${brand.sheen}, transparent)` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL } from '@documenso/lib/constants/app';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
|
||||
import { BadgeCheckIcon, MailIcon } from 'lucide-react';
|
||||
import { BrandingLogoIcon } from '../branding-logo-icon';
|
||||
import { EASE, POP, SPRING } from './motion';
|
||||
import { SettingsUpsellCard } from './settings-upsell-card';
|
||||
import { useTimedCycle } from './use-timed-cycle';
|
||||
|
||||
/**
|
||||
* Named sender identities cycled through while the preview is in its branded
|
||||
* state — one per branded cycle step, shown as the sender name and address.
|
||||
*/
|
||||
const BRANDED_SENDERS = [
|
||||
{ name: 'Support', email: 'support@example.com' },
|
||||
{ name: 'Team', email: 'hello@example.com' },
|
||||
{ name: 'Sales', email: 'sales@example.com' },
|
||||
{ name: 'Example', email: 'noreply@example.com' },
|
||||
];
|
||||
|
||||
/**
|
||||
* How long the initial unbranded (Documenso default) state is shown before
|
||||
* the first flip starts. Shown exactly once — the cycle never returns to it.
|
||||
*/
|
||||
const INITIAL_STATE_DURATION_MS = 2500;
|
||||
|
||||
/**
|
||||
* How long each branded sender identity is shown before cycling to the next,
|
||||
* giving the viewer time to read the changed address.
|
||||
*/
|
||||
const BRANDED_STATE_DURATION_MS = 5000;
|
||||
|
||||
/**
|
||||
* One duration per cycle step: the unbranded state first, then one step per
|
||||
* named sender identity, derived from the identity count so the two cannot
|
||||
* drift.
|
||||
*/
|
||||
const EMAIL_CYCLE_DURATIONS_MS = [INITIAL_STATE_DURATION_MS, ...BRANDED_SENDERS.map(() => BRANDED_STATE_DURATION_MS)];
|
||||
|
||||
export const EmailDomainsUpsell = () => {
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const isReducedMotion = useReducedMotion();
|
||||
|
||||
// Loop from index 1: the unbranded Documenso intro plays exactly once,
|
||||
// then the cycle rotates through the branded senders only.
|
||||
const cycleIndex = useTimedCycle(EMAIL_CYCLE_DURATIONS_MS, 1);
|
||||
|
||||
const isBranded = cycleIndex > 0;
|
||||
const brandedSender = BRANDED_SENDERS[cycleIndex - 1] ?? BRANDED_SENDERS[0];
|
||||
|
||||
const isStatic = isReducedMotion ?? false;
|
||||
|
||||
return (
|
||||
<SettingsUpsellCard
|
||||
planLabel={<Trans>Enterprise</Trans>}
|
||||
title={<Trans>Unlock Email Domains</Trans>}
|
||||
description={
|
||||
<Trans>Send documents from your own domain. Email domains are available on the Enterprise plan.</Trans>
|
||||
}
|
||||
features={[
|
||||
<Trans key="journey">Send emails to recipients from your domain</Trans>,
|
||||
<Trans key="dns">Easy DNS setup with auto-generated DKIM and SPF records</Trans>,
|
||||
<Trans key="senders">Named senders with defaults per team, template or document</Trans>,
|
||||
]}
|
||||
ctaLabel={<Trans>Contact Sales</Trans>}
|
||||
ctaTo={DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL}
|
||||
ctaExternal
|
||||
preview={
|
||||
<div className="mx-auto w-full max-w-xs">
|
||||
<div className="relative h-8">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={isBranded ? 'chip-on' : 'chip-off'}
|
||||
initial={isStatic ? false : { opacity: 0, y: 8, scale: 0.96 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -8, scale: 0.96 }}
|
||||
transition={SPRING}
|
||||
className={cn(
|
||||
'absolute inset-0 flex items-center gap-2 rounded-full border bg-background px-3 font-mono text-xs',
|
||||
isBranded ? 'border-documenso-300 text-documenso-800' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{isBranded ? (
|
||||
<BadgeCheckIcon className="h-3.5 w-3.5 shrink-0 text-documenso-700" />
|
||||
) : (
|
||||
<MailIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
|
||||
<span className="truncate">
|
||||
{isBranded ? <Trans>Sending from your domain</Trans> : <Trans>Sending from app.documenso.com</Trans>}
|
||||
</span>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="relative mt-4 overflow-hidden rounded-lg border bg-background shadow-sm">
|
||||
<div className="flex items-center gap-2 border-b p-4">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full font-semibold text-sm">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.span
|
||||
key={`logo-${cycleIndex}`}
|
||||
initial={isStatic ? false : { opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
transition={{ duration: 0.22 }}
|
||||
>
|
||||
{/*
|
||||
* Remounts with its keyed parent on every cycle step so the
|
||||
* pop replays each change. Single-value spring (POP
|
||||
* overshoots past 1) instead of scale keyframes — keyframe
|
||||
* arrays are unreliable on strict-mode remounts.
|
||||
*/}
|
||||
<motion.span
|
||||
initial={isStatic ? false : { scale: 0.8 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ scale: POP }}
|
||||
className="inline-block"
|
||||
>
|
||||
{isBranded ? (
|
||||
<Avatar className="h-8 w-8 border border-solid">
|
||||
{organisation.avatarImageId && (
|
||||
<AvatarImage src={formatAvatarUrl(organisation.avatarImageId)} />
|
||||
)}
|
||||
<AvatarFallback className="text-sm">{brandedSender.name[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
) : (
|
||||
<BrandingLogoIcon className="h-8 w-8" />
|
||||
)}
|
||||
</motion.span>
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-sm">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={`name-${cycleIndex}`}
|
||||
initial={isStatic ? false : { opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.28, ease: EASE }}
|
||||
className="flex min-w-0 items-center gap-1.5"
|
||||
>
|
||||
<span className="min-w-0 truncate">{isBranded ? brandedSender.name : 'Documenso'}</span>
|
||||
|
||||
{/* Inside the keyed row so it exits with the name and pops back in on every cycle step. */}
|
||||
{isBranded && (
|
||||
<motion.span
|
||||
initial={isStatic ? false : { scale: 0, rotate: -40 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
transition={{ ...POP, delay: 0.12 }}
|
||||
className="shrink-0"
|
||||
>
|
||||
<BadgeCheckIcon className="h-3.5 w-3.5 text-documenso-700" />
|
||||
</motion.span>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="font-mono text-muted-foreground text-xs">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.p
|
||||
key={`addr-${cycleIndex}`}
|
||||
initial={isStatic ? false : { opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.28, ease: EASE }}
|
||||
className="truncate"
|
||||
>
|
||||
{isBranded ? brandedSender.email : 'noreply@app.documenso.com'}
|
||||
</motion.p>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<p className="font-medium text-sm">
|
||||
<Trans>Please sign: Example.pdf</Trans>
|
||||
</p>
|
||||
|
||||
<p className="mt-1 text-muted-foreground text-xs">
|
||||
<Trans>{organisation.name} has invited you to sign this document.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
* Keyed remount replays the sweep on every cycle step. No opacity
|
||||
* envelope — keyframe arrays are unreliable on strict-mode
|
||||
* remounts; both endpoints sit outside the overflow-hidden card,
|
||||
* so the clip provides the fade in/out instead.
|
||||
*/}
|
||||
<motion.div
|
||||
key={`sheen-${cycleIndex}`}
|
||||
initial={isStatic ? false : { x: '-130%' }}
|
||||
animate={{ x: '240%' }}
|
||||
transition={{ duration: 1.2, ease: 'easeOut' }}
|
||||
className="pointer-events-none absolute inset-y-0 left-0 w-[55%]"
|
||||
style={{ background: 'linear-gradient(105deg, transparent, rgba(162, 231, 113, 0.32), transparent)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Shared motion vocabulary for the settings upsell previews. Values ported
|
||||
* from the design prototype (`design/SSO Upsell.dc.html`).
|
||||
*/
|
||||
export const SPRING = { type: 'spring', stiffness: 280, damping: 22 } as const;
|
||||
|
||||
export const POP = { type: 'spring', stiffness: 420, damping: 16 } as const;
|
||||
|
||||
export const EASE = [0.22, 0.61, 0.36, 1] as const;
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { ArrowRightIcon, CheckIcon, LockIcon } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
export type SettingsUpsellCardProps = {
|
||||
planLabel: ReactNode;
|
||||
title: ReactNode;
|
||||
description: ReactNode;
|
||||
features: ReactNode[];
|
||||
preview: ReactNode;
|
||||
|
||||
/**
|
||||
* CTA label. Defaults to "Upgrade Plan".
|
||||
*/
|
||||
ctaLabel?: ReactNode;
|
||||
|
||||
/**
|
||||
* CTA destination. Defaults to the organisation billing settings page.
|
||||
*/
|
||||
ctaTo?: string;
|
||||
|
||||
/**
|
||||
* Render the CTA as an external link (new tab) instead of an internal route.
|
||||
*/
|
||||
ctaExternal?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared split-card layout for claim-gated settings upsells on Documenso
|
||||
* Cloud. The left pane pitches the feature (plan badge, title, description,
|
||||
* feature list, upgrade CTA); the right pane renders a decorative scenario
|
||||
* preview supplied by the caller.
|
||||
*
|
||||
* Callers decide *when* to render this (cloud + missing claim flag).
|
||||
*/
|
||||
export const SettingsUpsellCard = ({
|
||||
planLabel,
|
||||
title,
|
||||
description,
|
||||
features,
|
||||
preview,
|
||||
ctaLabel,
|
||||
ctaTo,
|
||||
ctaExternal = false,
|
||||
}: SettingsUpsellCardProps) => {
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const canManageBilling = canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole);
|
||||
|
||||
const ctaHref = ctaTo ?? `/o/${organisation.url}/settings/billing`;
|
||||
|
||||
const ctaContent = (
|
||||
<>
|
||||
{ctaLabel ?? <Trans>Upgrade Plan</Trans>}
|
||||
<ArrowRightIcon className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mt-8 overflow-hidden rounded-xl border-2 ring-4 ring-muted/70 md:grid md:grid-cols-[1.08fr_0.92fr] xl:-mx-8">
|
||||
{/*
|
||||
* `min-w-0` on both grid items: `fr` tracks have an `auto` content
|
||||
* minimum, so long preview content (e.g. a wide mono domain line) would
|
||||
* otherwise widen the right track beyond its 0.92fr share — and
|
||||
* re-balance the whole grid on every preview cycle (layout shift).
|
||||
*/}
|
||||
<div className="flex min-w-0 flex-col items-start p-6 md:p-8">
|
||||
<Badge size="small">
|
||||
<LockIcon className="mr-1 h-3 w-3" />
|
||||
<span className="uppercase">{planLabel}</span>
|
||||
</Badge>
|
||||
|
||||
<h3 className="mt-4 font-semibold text-xl">{title}</h3>
|
||||
|
||||
<p className="mt-2 max-w-[40ch] text-muted-foreground text-sm">{description}</p>
|
||||
|
||||
<ul className="mt-6 space-y-3">
|
||||
{features.map((feature, index) => (
|
||||
<li key={index} className="flex items-start gap-2.5 text-sm">
|
||||
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-documenso-200">
|
||||
<CheckIcon className="h-3 w-3 text-documenso-800" strokeWidth={2.5} />
|
||||
</span>
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{canManageBilling ? (
|
||||
<Button className="mt-8" asChild>
|
||||
{ctaExternal ? (
|
||||
<a href={ctaHref} target="_blank" rel="noreferrer">
|
||||
{ctaContent}
|
||||
</a>
|
||||
) : (
|
||||
<Link to={ctaHref}>{ctaContent}</Link>
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<p className="mt-8 text-muted-foreground text-xs">
|
||||
<Trans>Contact your organisation owner to upgrade plans.</Trans>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="flex min-w-0 flex-col justify-center gap-3 border-t bg-muted p-6 md:border-t-0 md:border-l md:p-8"
|
||||
>
|
||||
{preview}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,260 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL } from '@documenso/lib/constants/app';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
|
||||
import { FingerprintIcon } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { EASE, POP, SPRING } from './motion';
|
||||
import { SettingsUpsellCard } from './settings-upsell-card';
|
||||
import { useTimedCycle } from './use-timed-cycle';
|
||||
|
||||
export const SsoPortalUpsell = () => {
|
||||
const isReducedMotion = useReducedMotion();
|
||||
const sceneIndex = useTimedCycle(SSO_SCENE_DURATIONS_MS);
|
||||
|
||||
return (
|
||||
<SettingsUpsellCard
|
||||
planLabel={<Trans>Enterprise</Trans>}
|
||||
title={<Trans>Unlock the Organisation SSO Portal</Trans>}
|
||||
description={
|
||||
<Trans>
|
||||
Give your members a dedicated single sign-on portal. The SSO portal is available on the Enterprise plan.
|
||||
</Trans>
|
||||
}
|
||||
features={[
|
||||
<Trans key="oidc">Works with any OIDC provider — Okta, Entra ID, Google and more</Trans>,
|
||||
<Trans key="jit">Accounts are automatically added to your organisation on sign-in</Trans>,
|
||||
<Trans key="control">Restrict sign-ins by email domain and choose the default role</Trans>,
|
||||
]}
|
||||
ctaLabel={<Trans>Contact Sales</Trans>}
|
||||
ctaTo={DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL}
|
||||
ctaExternal
|
||||
preview={
|
||||
<div className="mx-auto w-full max-w-xs">
|
||||
<div className="relative h-[236px]">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{sceneIndex === 0 && <PortalScene key="portal" isStatic={isReducedMotion ?? false} />}
|
||||
{sceneIndex === 1 && <RedirectScene key="redirect" />}
|
||||
{sceneIndex === 2 && <SuccessScene key="success" />}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Absolute-positioned panel each scene renders in, handling the shared
|
||||
* slide-and-fade transition between scenes.
|
||||
*/
|
||||
const ScenePanel = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -12, scale: 0.98 }}
|
||||
transition={{ duration: 0.34, ease: EASE }}
|
||||
className="absolute inset-0 flex flex-col items-center justify-center overflow-hidden rounded-lg border bg-background p-6 text-center shadow-sm"
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Scene 1: the organisation's SSO portal, with a timed faux press on the
|
||||
* "Continue with SSO" button (cursor flies in, button dips, sheen sweeps).
|
||||
*
|
||||
* When `isStatic` is set (reduced motion) every element renders with
|
||||
* `initial={false}`, skipping entrance and press animations.
|
||||
*/
|
||||
const PortalScene = ({ isStatic }: { isStatic: boolean }) => {
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const rise = (delay: number) => ({
|
||||
initial: isStatic ? false : { y: 10, opacity: 0 },
|
||||
animate: { y: 0, opacity: 1 },
|
||||
transition: { ...SPRING, delay },
|
||||
});
|
||||
|
||||
return (
|
||||
<ScenePanel>
|
||||
<motion.div
|
||||
initial={isStatic ? false : { scale: 0.5, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ ...POP, delay: 0.04 }}
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-documenso-200 font-semibold text-documenso-900">
|
||||
{([...organisation.name][0] ?? 'D').toUpperCase()}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.p {...rise(0.12)} className="mt-3.5 font-semibold text-sm">
|
||||
<Trans>Welcome to {organisation.name}</Trans>
|
||||
</motion.p>
|
||||
|
||||
<motion.p {...rise(0.18)} className="mt-1 text-muted-foreground text-xs">
|
||||
<Trans>Single sign-on</Trans>
|
||||
</motion.p>
|
||||
|
||||
<div className="relative mt-4 w-full">
|
||||
<motion.div
|
||||
initial={isStatic ? false : { y: 10, opacity: 0, scale: 1 }}
|
||||
animate={{ y: 0, opacity: 1, scale: [1, 1, 0.955, 1] }}
|
||||
transition={{
|
||||
y: { ...SPRING, delay: 0.24 },
|
||||
opacity: { duration: 0.3, delay: 0.24 },
|
||||
scale: { duration: 2.6, times: [0, 0.63, 0.72, 0.84], ease: 'easeOut' },
|
||||
}}
|
||||
className="relative flex h-[38px] w-full items-center justify-center overflow-hidden rounded-md bg-foreground font-semibold text-background text-sm"
|
||||
>
|
||||
<span>
|
||||
<Trans>Continue with SSO</Trans>
|
||||
</span>
|
||||
|
||||
<motion.div
|
||||
initial={isStatic ? false : { x: '-130%' }}
|
||||
animate={{ x: '150%' }}
|
||||
transition={{ duration: 0.85, delay: 1.75, ease: 'easeOut' }}
|
||||
className="absolute top-0 bottom-0 left-[20%] w-3/5"
|
||||
style={{ background: 'linear-gradient(105deg, transparent, rgba(162, 231, 113, 0.45), transparent)' }}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<motion.svg
|
||||
initial={isStatic ? false : { x: 30, y: 30, opacity: 0, scale: 1 }}
|
||||
animate={{
|
||||
x: [30, 30, 0, 0, 0],
|
||||
y: [30, 30, 0, 0, 0],
|
||||
opacity: [0, 1, 1, 1, 0],
|
||||
scale: [1, 1, 1, 0.82, 1],
|
||||
}}
|
||||
transition={{ duration: 2.6, times: [0, 0.3, 0.63, 0.72, 0.94], ease: EASE }}
|
||||
width={17}
|
||||
height={17}
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.4}
|
||||
strokeLinejoin="round"
|
||||
className="absolute right-[26px] -bottom-2.5 fill-foreground stroke-background"
|
||||
>
|
||||
<path d="M4 2.5 19 12l-6.6 1.4L9.7 19.6z" />
|
||||
</motion.svg>
|
||||
</div>
|
||||
</ScenePanel>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Scene 2: redirecting to the identity provider, with a rotating ring around
|
||||
* a fingerprint tile and a filling progress bar.
|
||||
*/
|
||||
const RedirectScene = () => {
|
||||
return (
|
||||
<ScenePanel>
|
||||
<div className="relative flex h-[46px] w-[46px] items-center justify-center">
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 0.95, repeat: Number.POSITIVE_INFINITY, ease: 'linear' }}
|
||||
className="absolute inset-0 rounded-full border-2"
|
||||
style={{ borderTopColor: '#A2E771' }}
|
||||
/>
|
||||
|
||||
<div className="flex h-[34px] w-[34px] items-center justify-center rounded-full bg-muted">
|
||||
<FingerprintIcon className="h-[18px] w-[18px] text-muted-foreground" strokeWidth={1.6} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.p
|
||||
initial={{ y: 8, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ ...SPRING, delay: 0.08 }}
|
||||
className="mt-3.5 max-w-[22ch] text-sm"
|
||||
>
|
||||
<Trans>Redirecting to your identity provider</Trans>
|
||||
</motion.p>
|
||||
|
||||
<div className="mt-4 h-1 w-[140px] overflow-hidden rounded-full bg-border">
|
||||
<motion.div
|
||||
initial={{ width: '0%' }}
|
||||
animate={{ width: '100%' }}
|
||||
transition={{ duration: 1.35, ease: 'easeInOut' }}
|
||||
className="h-full rounded-full bg-documenso"
|
||||
/>
|
||||
</div>
|
||||
</ScenePanel>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Scene 3: signed in, with an expanding pulse ring, a popping green circle,
|
||||
* a drawn checkmark and the signed-in member's email.
|
||||
*/
|
||||
const SuccessScene = () => {
|
||||
const { user } = useSession();
|
||||
|
||||
return (
|
||||
<ScenePanel>
|
||||
<div className="relative h-10 w-10">
|
||||
<motion.div
|
||||
initial={{ scale: 0.7, opacity: 0.85 }}
|
||||
animate={{ scale: 2.1, opacity: 0 }}
|
||||
transition={{ duration: 1.1, ease: 'easeOut' }}
|
||||
className="absolute inset-0 rounded-full border-2"
|
||||
style={{ borderColor: '#A2E771' }}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
initial={{ scale: 0.4, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={POP}
|
||||
className="absolute inset-0 flex items-center justify-center rounded-full bg-documenso-200"
|
||||
>
|
||||
<svg
|
||||
width={19}
|
||||
height={19}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2.4}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-documenso-900"
|
||||
>
|
||||
<motion.path
|
||||
d="M20 6 9 17l-5-5"
|
||||
initial={{ pathLength: 0 }}
|
||||
animate={{ pathLength: 1 }}
|
||||
transition={{ duration: 0.4, delay: 0.12, ease: 'easeOut' }}
|
||||
/>
|
||||
</svg>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<motion.p
|
||||
initial={{ y: 10, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ ...SPRING, delay: 0.16 }}
|
||||
className="mt-3.5 font-semibold text-sm"
|
||||
>
|
||||
<Trans>Signed in</Trans>
|
||||
</motion.p>
|
||||
|
||||
<motion.p
|
||||
initial={{ y: 10, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ ...SPRING, delay: 0.24 }}
|
||||
className="mt-1 font-mono text-muted-foreground text-xs"
|
||||
>
|
||||
{user.email}
|
||||
</motion.p>
|
||||
</ScenePanel>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Milliseconds each scene is shown before advancing: portal, redirect,
|
||||
* success.
|
||||
*/
|
||||
const SSO_SCENE_DURATIONS_MS = [3000, 2500, 3000];
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useReducedMotion } from 'framer-motion';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Cycles an index through `durations.length` steps, waiting `durations[i]`
|
||||
* milliseconds on step `i` before advancing to the next.
|
||||
*
|
||||
* When the cycle wraps past the last step it continues from `loopStartIndex`
|
||||
* (default `0`), letting consumers play intro-only steps exactly once and
|
||||
* then loop through the remaining steps forever.
|
||||
*
|
||||
* Under `prefers-reduced-motion` the cycle never starts and the index stays
|
||||
* at 0, so consumers render their initial state statically.
|
||||
*
|
||||
* Pass module-level constants for `durations` and `loopStartIndex` — their
|
||||
* identities are intentionally not dependencies.
|
||||
*/
|
||||
export const useTimedCycle = (durations: number[], loopStartIndex = 0) => {
|
||||
const [index, setIndex] = useState(0);
|
||||
|
||||
const isReducedMotion = useReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
if (isReducedMotion || durations.length === 0) {
|
||||
setIndex(0);
|
||||
return;
|
||||
}
|
||||
|
||||
let current = 0;
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
const tick = () => {
|
||||
const next = current + 1;
|
||||
|
||||
current = next >= durations.length ? Math.min(loopStartIndex, durations.length - 1) : next;
|
||||
|
||||
setIndex(current);
|
||||
timeout = setTimeout(tick, durations[current]);
|
||||
};
|
||||
|
||||
timeout = setTimeout(tick, durations[0]);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [isReducedMotion]);
|
||||
|
||||
return index;
|
||||
};
|
||||
@@ -137,6 +137,7 @@ export const TemplateEditForm = ({ initialTemplate, className, templateRootPath
|
||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||
qrSignatureEnabled: signatureTypes.includes(DocumentSignatureType.QR),
|
||||
language: isValidLanguageCode(data.meta.language) ? data.meta.language : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -31,6 +31,26 @@ function initPosthog() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Surfaces hydration recoveries (React 19 discards the server HTML and
|
||||
* re-renders on the client instead of dying) so we can track how often
|
||||
* extensions/early clicks interfere with hydration in the wild.
|
||||
*/
|
||||
function onRecoverableError(error: unknown, errorInfo: { componentStack?: string }) {
|
||||
console.error('[hydration] recovered from error', error, errorInfo.componentStack);
|
||||
|
||||
if (extractPostHogConfig()) {
|
||||
void import('posthog-js').then(({ default: posthog }) => {
|
||||
if (posthog.__loaded) {
|
||||
posthog.capture('$hydration_recoverable_error', {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
componentStack: errorInfo.componentStack,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const locale = detect(fromHtmlTag('lang')) || 'en';
|
||||
|
||||
@@ -44,6 +64,7 @@ async function main() {
|
||||
<HydratedRouter />
|
||||
</I18nProvider>
|
||||
</StrictMode>,
|
||||
{ onRecoverableError },
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+10
-7
@@ -1,5 +1,6 @@
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { SessionProvider } from '@documenso/lib/client-only/providers/session';
|
||||
import { getBasePath } from '@documenso/lib/constants/app';
|
||||
import { APP_I18N_OPTIONS, type SupportedLanguageCodes } from '@documenso/lib/constants/i18n';
|
||||
import { createPublicEnv } from '@documenso/lib/utils/env';
|
||||
import { extractLocaleData } from '@documenso/lib/utils/i18n';
|
||||
@@ -20,7 +21,6 @@ import {
|
||||
useMatches,
|
||||
} from 'react-router';
|
||||
import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from 'remix-themes';
|
||||
|
||||
import type { Route } from './+types/root';
|
||||
import stylesheet from './app.css?url';
|
||||
import { GenericErrorLayout } from './components/general/generic-error-layout';
|
||||
@@ -68,6 +68,7 @@ export async function loader({ context, request }: Route.LoaderArgs) {
|
||||
lang,
|
||||
theme: getTheme(),
|
||||
disableAnimations,
|
||||
basePath: getBasePath(),
|
||||
// Surface the per-request CSP nonce produced by `securityHeadersMiddleware` so all
|
||||
// SSR-rendered <script>/<style> elements in this layout (and child
|
||||
// routes that need it) can carry the matching nonce attribute.
|
||||
@@ -90,10 +91,10 @@ export async function loader({ context, request }: Route.LoaderArgs) {
|
||||
}
|
||||
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
const { theme } = useLoaderData<typeof loader>() || {};
|
||||
const { theme, basePath } = useLoaderData<typeof loader>() || {};
|
||||
|
||||
return (
|
||||
<ThemeProvider specifiedTheme={theme} themeAction="/api/theme">
|
||||
<ThemeProvider specifiedTheme={theme} themeAction={`${basePath ?? ''}/api/theme`}>
|
||||
<LayoutContent>{children}</LayoutContent>
|
||||
</ThemeProvider>
|
||||
);
|
||||
@@ -111,6 +112,8 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const [theme] = useTheme();
|
||||
|
||||
const basePath = data.basePath ?? '';
|
||||
|
||||
// Recipient routes (signing pages) put `documenso-branded` on <body> so the
|
||||
// <style> block from `RecipientBranding` applies to BOTH the main tree and
|
||||
// any portaled content (Radix dialogs/popovers/dropdowns mount outside the
|
||||
@@ -126,11 +129,11 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
|
||||
<html translate="no" lang={lang} data-theme={theme} className={theme ?? ''} suppressHydrationWarning>
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href={`${basePath}/apple-touch-icon.png`} />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href={`${basePath}/favicon-32x32.png`} />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href={`${basePath}/favicon-16x16.png`} />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<link rel="manifest" href={`${basePath}/site.webmanifest`} />
|
||||
<meta name="google" content="notranslate" />
|
||||
<Meta />
|
||||
<Links nonce={nonce(cspNonce)} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { IS_BILLING_ENABLED, IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app';
|
||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type TBrandingPreferencesFormSchema,
|
||||
} from '~/components/forms/branding-preferences-form';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { BrandingUpsell } from '~/components/general/settings-upsell/branding-upsell';
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
@@ -121,11 +122,18 @@ export default function OrganisationSettingsBrandingPage() {
|
||||
? t`Here you can set branding preferences for your team.`
|
||||
: t`Here you can set branding preferences for your organisation. Teams will inherit these settings by default.`;
|
||||
|
||||
const brandingPreferencesFormEnabled =
|
||||
organisationWithSettings.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader title={settingsHeaderText} subtitle={settingsHeaderSubtitle} />
|
||||
<SettingsHeader
|
||||
title={settingsHeaderText}
|
||||
subtitle={settingsHeaderSubtitle}
|
||||
hideDivider={!brandingPreferencesFormEnabled}
|
||||
/>
|
||||
|
||||
{organisationWithSettings.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED() ? (
|
||||
{brandingPreferencesFormEnabled ? (
|
||||
<section>
|
||||
<BrandingPreferencesForm
|
||||
context="Organisation"
|
||||
@@ -160,6 +168,8 @@ export default function OrganisationSettingsBrandingPage() {
|
||||
</Alert>
|
||||
)}
|
||||
</section>
|
||||
) : IS_DOCUMENSO_CLOUD() ? (
|
||||
<BrandingUpsell />
|
||||
) : (
|
||||
<Alert className="mt-8 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
|
||||
<div className="mb-4 sm:mb-0">
|
||||
|
||||
@@ -62,6 +62,7 @@ export default function OrganisationSettingsDocumentPage() {
|
||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||
qrSignatureEnabled: signatureTypes.includes(DocumentSignatureType.QR),
|
||||
delegateDocumentOwnership,
|
||||
aiFeaturesEnabled,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { IS_BILLING_ENABLED, IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app';
|
||||
import { generateEmailDomainRecords } from '@documenso/lib/utils/email-domains';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TGetOrganisationEmailDomainResponse } from '@documenso/trpc/server/enterprise-router/get-organisation-email-domain.types';
|
||||
@@ -27,8 +27,9 @@ import { OrganisationEmailDomainRecordsDialog } from '~/components/dialogs/organ
|
||||
import { OrganisationEmailUpdateDialog } from '~/components/dialogs/organisation-email-update-dialog';
|
||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { EmailDomainsUpsell } from '~/components/general/settings-upsell/email-domains-upsell';
|
||||
|
||||
import type { Route } from './+types/o.$orgUrl.settings.groups.$id';
|
||||
import type { Route } from './+types/o.$orgUrl.settings.email-domains.$id';
|
||||
|
||||
export default function OrganisationEmailDomainSettingsPage({ params }: Route.ComponentProps) {
|
||||
const { t } = useLingui();
|
||||
@@ -96,10 +97,23 @@ export default function OrganisationEmailDomainSettingsPage({ params }: Route.Co
|
||||
] satisfies DataTableColumnDef<TGetOrganisationEmailDomainResponse['emails'][number]>[];
|
||||
}, [organisation]);
|
||||
|
||||
const pageHeader = t`Email Domain Settings`;
|
||||
const pageSubtitle = t`Manage your email domain settings.`;
|
||||
|
||||
if (!IS_BILLING_ENABLED()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!organisation.organisationClaim.flags.emailDomains && IS_DOCUMENSO_CLOUD()) {
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader hideDivider title={pageHeader} subtitle={pageSubtitle} />
|
||||
|
||||
<EmailDomainsUpsell />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoadingEmailDomain) {
|
||||
return <SpinnerBox className="py-32" />;
|
||||
}
|
||||
@@ -132,7 +146,7 @@ export default function OrganisationEmailDomainSettingsPage({ params }: Route.Co
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader hideDivider title={t`Email Domain Settings`} subtitle={t`Manage your email domain settings.`}>
|
||||
<SettingsHeader hideDivider title={pageHeader} subtitle={pageSubtitle}>
|
||||
<OrganisationEmailCreateDialog emailDomain={emailDomain} />
|
||||
</SettingsHeader>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { IS_BILLING_ENABLED, IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app';
|
||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -9,6 +9,7 @@ import { Link } from 'react-router';
|
||||
|
||||
import { OrganisationEmailDomainCreateDialog } from '~/components/dialogs/organisation-email-domain-create-dialog';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { EmailDomainsUpsell } from '~/components/general/settings-upsell/email-domains-upsell';
|
||||
import { OrganisationEmailDomainsDataTable } from '~/components/tables/organisation-email-domains-table';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
@@ -41,6 +42,8 @@ export default function OrganisationSettingsEmailDomains() {
|
||||
<section>
|
||||
<OrganisationEmailDomainsDataTable />
|
||||
</section>
|
||||
) : IS_DOCUMENSO_CLOUD() ? (
|
||||
<EmailDomainsUpsell />
|
||||
) : (
|
||||
<Alert className="mt-8 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
|
||||
<div className="mb-4 sm:mb-0">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app';
|
||||
import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/organisations';
|
||||
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
|
||||
import {
|
||||
@@ -28,6 +29,7 @@ import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { SsoPortalUpsell } from '~/components/general/settings-upsell/sso-portal-upsell';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
const ZProviderFormSchema = ZUpdateOrganisationAuthenticationPortalRequestSchema.shape.data
|
||||
@@ -63,10 +65,33 @@ export default function OrganisationSettingSSOLoginPage() {
|
||||
const { t } = useLingui();
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const isAuthenticationPortalEnabled = organisation.organisationClaim.flags.authenticationPortal === true;
|
||||
|
||||
const { data: authenticationPortal, isLoading: isLoadingAuthenticationPortal } =
|
||||
trpc.enterprise.organisation.authenticationPortal.get.useQuery({
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
trpc.enterprise.organisation.authenticationPortal.get.useQuery(
|
||||
{
|
||||
organisationId: organisation.id,
|
||||
},
|
||||
{
|
||||
// The endpoint rejects orgs without the claim flag, so don't fire
|
||||
// requests that are guaranteed to error.
|
||||
enabled: isAuthenticationPortalEnabled,
|
||||
},
|
||||
);
|
||||
|
||||
if (!isAuthenticationPortalEnabled && IS_DOCUMENSO_CLOUD()) {
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
hideDivider
|
||||
title={t`Organisation SSO Portal`}
|
||||
subtitle={t`Manage a custom SSO login portal for your organisation.`}
|
||||
/>
|
||||
|
||||
<SsoPortalUpsell />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoadingAuthenticationPortal || !authenticationPortal) {
|
||||
return <SpinnerBox className="py-32" />;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { IS_BILLING_ENABLED, IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app';
|
||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type TBrandingPreferencesFormSchema,
|
||||
} from '~/components/forms/branding-preferences-form';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { BrandingUpsell } from '~/components/general/settings-upsell/branding-upsell';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
export default function TeamsSettingsPage() {
|
||||
@@ -155,6 +156,8 @@ export default function TeamsSettingsPage() {
|
||||
</Alert>
|
||||
)}
|
||||
</section>
|
||||
) : IS_DOCUMENSO_CLOUD() ? (
|
||||
<BrandingUpsell />
|
||||
) : (
|
||||
<Alert className="mt-8 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
|
||||
<div className="mb-4 sm:mb-0">
|
||||
|
||||
@@ -50,11 +50,13 @@ export default function TeamsSettingsPage() {
|
||||
typedSignatureEnabled: null,
|
||||
uploadSignatureEnabled: null,
|
||||
drawSignatureEnabled: null,
|
||||
qrSignatureEnabled: null,
|
||||
}
|
||||
: {
|
||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||
qrSignatureEnabled: signatureTypes.includes(DocumentSignatureType.QR),
|
||||
}),
|
||||
delegateDocumentOwnership,
|
||||
},
|
||||
|
||||
@@ -215,6 +215,7 @@ const DirectSigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV
|
||||
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
|
||||
qrSignatureEnabled={template.templateMeta?.qrSignatureEnabled}
|
||||
>
|
||||
<DocumentSigningAuthProvider
|
||||
documentAuthOptions={template.authOptions}
|
||||
|
||||
@@ -474,6 +474,7 @@ const SigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV1Loade
|
||||
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
||||
qrSignatureEnabled={document.documentMeta?.qrSignatureEnabled}
|
||||
>
|
||||
<DocumentSigningAuthProvider documentAuthOptions={document.authOptions} recipient={recipient} user={user}>
|
||||
{sessionData?.user && <AuthenticatedHeader />}
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
|
||||
import { Outlet } from 'react-router';
|
||||
import { Outlet, useLocation } from 'react-router';
|
||||
|
||||
export default function Layout() {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
// Todo: Use the layout params to hide instead of hardcoding the pathname.
|
||||
const hideBackground = pathname.includes('mobile-signature');
|
||||
|
||||
return (
|
||||
<main className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-24">
|
||||
<div>
|
||||
<div className="absolute -inset-[min(600px,max(400px,60vw))] -z-[1] flex items-center justify-center opacity-70">
|
||||
<img
|
||||
src={backgroundPattern}
|
||||
alt="background pattern"
|
||||
className="dark:brightness-95 dark:contrast-[70%] dark:invert dark:sepia"
|
||||
style={{
|
||||
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
|
||||
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{!hideBackground && (
|
||||
<div className="absolute -inset-[min(600px,max(400px,60vw))] -z-[1] flex items-center justify-center opacity-70">
|
||||
<img
|
||||
src={backgroundPattern}
|
||||
alt="background pattern"
|
||||
className="dark:brightness-95 dark:contrast-[70%] dark:invert dark:sepia"
|
||||
style={{
|
||||
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
|
||||
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative w-full">
|
||||
<Outlet />
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type {
|
||||
TGetQrSignatureSessionResponse,
|
||||
TQrSignatureSessionContext,
|
||||
} from '@documenso/trpc/server/signature-router/qr/get-qr-signature-session.types';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Sheet, SheetContent, SheetTitle } from '@documenso/ui/primitives/sheet';
|
||||
import { SignaturePadDraw } from '@documenso/ui/primitives/signature-pad/signature-pad-draw';
|
||||
import { i18n } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { CheckCircle2Icon, ClockIcon, FileTextIcon, Loader2Icon, PenLineIcon, XCircleIcon } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import type { Route } from './+types/mobile-signature.$token';
|
||||
|
||||
export function meta() {
|
||||
return [
|
||||
{ title: i18n._(msg`Sign on mobile - Documenso`) },
|
||||
{ name: 'robots', content: 'noindex, nofollow, noarchive, nosnippet, noimageindex' },
|
||||
];
|
||||
}
|
||||
|
||||
export default function MobileSignaturePage({ params }: Route.ComponentProps) {
|
||||
const { token } = params;
|
||||
|
||||
const {
|
||||
data: session,
|
||||
isError: isSessionError,
|
||||
isLoading: isSessionLoading,
|
||||
} = trpc.signature.qr.getSession.useQuery(
|
||||
{
|
||||
token,
|
||||
},
|
||||
{
|
||||
// Do not refetch the session.
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
retry: false,
|
||||
},
|
||||
);
|
||||
|
||||
if (isSessionLoading || !session) {
|
||||
return (
|
||||
<div className="flex w-full flex-col items-center text-center">
|
||||
<Loader2Icon className="size-8 animate-spin text-muted-foreground" />
|
||||
<span className="sr-only">
|
||||
<Trans>Loading</Trans>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (session.status !== 'VALID' || isSessionError) {
|
||||
return <QrSignatureError reason={session.status !== 'VALID' ? session.status : undefined} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-screen max-w-lg select-none px-4">
|
||||
<QrSignature token={token} context={session.context} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type QrSignatureState = 'SIGNING' | 'SUCCESS' | 'EXPIRED' | 'ALREADY_SUBMITTED';
|
||||
|
||||
type QrSignatureProps = {
|
||||
token: string;
|
||||
context: TQrSignatureSessionContext;
|
||||
};
|
||||
|
||||
const QrSignature = ({ token, context }: QrSignatureProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const [signature, setSignature] = useState('');
|
||||
const [hasSubmissionError, setHasSubmissionError] = useState(false);
|
||||
|
||||
const [state, setState] = useState<QrSignatureState>('SIGNING');
|
||||
|
||||
// Portrait renders the pad in a bottom sheet beneath the document context;
|
||||
// landscape renders a single card. This component only ever renders on the
|
||||
// client (behind the session query), so the initial value can be read
|
||||
// synchronously - no flicker on landscape devices.
|
||||
const [isPortrait, setIsPortrait] = useState(
|
||||
() => typeof window === 'undefined' || window.matchMedia('(orientation: portrait)').matches,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia('(orientation: portrait)');
|
||||
|
||||
setIsPortrait(mediaQuery.matches);
|
||||
|
||||
const onOrientationChange = (event: MediaQueryListEvent) => {
|
||||
setIsPortrait(event.matches);
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', onOrientationChange);
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', onOrientationChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { mutateAsync: completeQrSignature, isPending } = trpc.signature.qr.complete.useMutation({
|
||||
// The session query must not refetch on completion: it would resolve to
|
||||
// ALREADY_SUBMITTED and replace the success screen with an error card.
|
||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
});
|
||||
|
||||
const contextInfo = useMemo(
|
||||
() =>
|
||||
match(context)
|
||||
.with({ type: 'DOCUMENT_SIGNATURE' }, (documentContext) => ({
|
||||
title: documentContext.documentTitle,
|
||||
subtitle: `${documentContext.teamName} · ${t`Signature requested`}`,
|
||||
icon: <FileTextIcon className="size-6 text-primary" />,
|
||||
}))
|
||||
.with({ type: 'PROFILE_SIGNATURE' }, () => ({
|
||||
title: t`Your signature`,
|
||||
subtitle: t`Signature requested`,
|
||||
icon: <PenLineIcon className="size-6 text-primary" />,
|
||||
}))
|
||||
// Context-less sessions show no subtitle, which would just repeat the title.
|
||||
.with({ type: 'NONE' }, () => ({
|
||||
title: t`Signature requested`,
|
||||
subtitle: null,
|
||||
icon: <PenLineIcon className="size-6 text-primary" />,
|
||||
}))
|
||||
.exhaustive(),
|
||||
[context, t],
|
||||
);
|
||||
|
||||
const onSubmitClick = async () => {
|
||||
setHasSubmissionError(false);
|
||||
|
||||
try {
|
||||
await completeQrSignature({
|
||||
token,
|
||||
signature,
|
||||
});
|
||||
|
||||
setState('SUCCESS');
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
if (error.code === AppErrorCode.EXPIRED_CODE || error.code === AppErrorCode.NOT_FOUND) {
|
||||
setState('EXPIRED');
|
||||
return;
|
||||
}
|
||||
|
||||
if (error.code === AppErrorCode.INVALID_REQUEST) {
|
||||
setState('ALREADY_SUBMITTED');
|
||||
return;
|
||||
}
|
||||
|
||||
setHasSubmissionError(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (state === 'EXPIRED' || state === 'ALREADY_SUBMITTED') {
|
||||
return <QrSignatureError reason={state} />;
|
||||
}
|
||||
|
||||
if (state === 'SUCCESS') {
|
||||
return (
|
||||
<div className="flex w-full flex-col items-center text-center">
|
||||
<CheckCircle2Icon className="size-10 text-primary" />
|
||||
|
||||
<h1 className="mt-4 font-semibold text-2xl">
|
||||
<Trans>Success</Trans>
|
||||
</h1>
|
||||
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
<Trans>You can now return to your main device to continue.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isPortrait) {
|
||||
return (
|
||||
<>
|
||||
{/* Document context hero. */}
|
||||
<div className="flex flex-col items-center pb-[45svh] text-center">
|
||||
<div className="flex size-14 items-center justify-center rounded-xl border border-primary/30 bg-primary/10">
|
||||
{contextInfo.icon}
|
||||
</div>
|
||||
|
||||
<h1 className="mt-4 font-semibold text-2xl">{contextInfo.title}</h1>
|
||||
|
||||
{contextInfo.subtitle && <p className="mt-2 text-muted-foreground text-sm">{contextInfo.subtitle}</p>}
|
||||
|
||||
<div className="mt-4 flex items-center gap-2 rounded-md border border-border bg-background px-3 py-1.5 text-muted-foreground text-xs">
|
||||
<span className="size-2 rounded-full bg-primary" />
|
||||
<Trans>Connected</Trans>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Persistent signing sheet - cannot be dismissed. */}
|
||||
<Sheet open>
|
||||
<SheetContent
|
||||
position="bottom"
|
||||
size="content"
|
||||
showOverlay={false}
|
||||
className="h-auto select-none rounded-t-2xl border-t px-4 pt-4 pb-6 [&>button:last-child]:hidden"
|
||||
onEscapeKeyDown={(event) => event.preventDefault()}
|
||||
onPointerDownOutside={(event) => event.preventDefault()}
|
||||
onInteractOutside={(event) => event.preventDefault()}
|
||||
>
|
||||
<div className="mx-auto mb-3 h-1 w-10 rounded-full bg-muted" />
|
||||
|
||||
<SheetTitle className="font-semibold text-lg">
|
||||
<Trans>Draw your signature</Trans>
|
||||
</SheetTitle>
|
||||
|
||||
<div className="relative mt-3 flex aspect-signature-pad items-center justify-center rounded-md border border-border bg-muted/25">
|
||||
<SignaturePadDraw className="h-full w-full" value={signature} onChange={(value) => setSignature(value)} />
|
||||
</div>
|
||||
|
||||
{hasSubmissionError && (
|
||||
<p className="mt-2 text-destructive text-sm">
|
||||
<Trans>Something went wrong. Please try again.</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex">
|
||||
<Button
|
||||
type="button"
|
||||
className="flex-1"
|
||||
disabled={!signature}
|
||||
loading={isPending}
|
||||
onClick={() => void onSubmitClick()}
|
||||
>
|
||||
<Trans>Next</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Landscape: a single card, no sheet.
|
||||
return (
|
||||
// Need this to override the parent layout styling.
|
||||
<div className="fixed inset-0 z-50 flex select-none items-center justify-center bg-background p-2">
|
||||
{/* The column width IS the pad width: all height left beneath the fixed
|
||||
h-12 header (100svh - 2*p-2 - h-12 - mb-2 = 100svh - 4.5rem) is
|
||||
converted through the pad's 16/7 aspect ratio, clamped by the
|
||||
viewport width. The header is w-full of the same column, so it always
|
||||
matches the pad width exactly. */}
|
||||
<div className="flex max-h-full w-[min(100%,calc((100svh-4.5rem)*16/7))] max-w-lg flex-col">
|
||||
<div className="mb-2 flex h-12 w-full shrink-0 items-center justify-between rounded-lg border border-border bg-muted/25 px-2">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-primary/30 bg-primary/10">
|
||||
{contextInfo.icon}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate font-semibold text-sm">{contextInfo.title}</h1>
|
||||
|
||||
<p className="truncate text-muted-foreground text-xs">{contextInfo.subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="ml-2 flex-shrink-0 px-6"
|
||||
disabled={!signature}
|
||||
loading={isPending}
|
||||
onClick={() => void onSubmitClick()}
|
||||
>
|
||||
<Trans>Next</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="relative flex aspect-signature-pad w-full items-center justify-center rounded-md border border-border bg-muted/25">
|
||||
<SignaturePadDraw className="h-full w-full" value={signature} onChange={(value) => setSignature(value)} />
|
||||
</div>
|
||||
|
||||
{hasSubmissionError && (
|
||||
<p className="mt-2 text-destructive text-sm">
|
||||
<Trans>Something went wrong. Please try again.</Trans>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type QrSignatureErrorReason = Exclude<TGetQrSignatureSessionResponse['status'], 'VALID'>;
|
||||
|
||||
type QrSignatureErrorProps = {
|
||||
reason?: QrSignatureErrorReason;
|
||||
};
|
||||
|
||||
const QrSignatureError = ({ reason }: QrSignatureErrorProps) => {
|
||||
const content = match(reason)
|
||||
.with('EXPIRED', () => ({
|
||||
icon: <ClockIcon className="size-10 text-yellow-500" />,
|
||||
title: <Trans>This link has expired</Trans>,
|
||||
description: <Trans>Generate a new QR code on the original device and scan it again.</Trans>,
|
||||
}))
|
||||
.with('ALREADY_SUBMITTED', () => ({
|
||||
icon: <CheckCircle2Icon className="size-10 text-primary" />,
|
||||
title: <Trans>Signature already sent</Trans>,
|
||||
description: <Trans>This link has already been used. Return to your computer to continue.</Trans>,
|
||||
}))
|
||||
.with('INVALID', () => ({
|
||||
icon: <XCircleIcon className="size-10 text-muted-foreground" />,
|
||||
title: <Trans>This signing request is invalid</Trans>,
|
||||
description: (
|
||||
<Trans>
|
||||
The request is invalid or no longer exists. Scan the new QR code on the original device to try again.
|
||||
</Trans>
|
||||
),
|
||||
}))
|
||||
.with(undefined, () => ({
|
||||
icon: <XCircleIcon className="size-10 text-muted-foreground" />,
|
||||
title: <Trans>Something went wrong</Trans>,
|
||||
description: <Trans>We couldn't load this signing request. Please refresh the page to try again.</Trans>,
|
||||
}))
|
||||
.exhaustive();
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col items-center text-center">
|
||||
{content.icon}
|
||||
|
||||
<h1 className="mt-2 font-semibold text-2xl">{content.title}</h1>
|
||||
|
||||
<p className="mt-2 text-muted-foreground text-sm">{content.description}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -266,6 +266,7 @@ const EmbedDirectTemplatePageV1 = ({ data }: { data: Awaited<ReturnType<typeof h
|
||||
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
|
||||
qrSignatureEnabled={template.templateMeta?.qrSignatureEnabled}
|
||||
>
|
||||
<DocumentSigningAuthProvider documentAuthOptions={template.authOptions} recipient={recipient} user={user}>
|
||||
<DocumentSigningRecipientProvider recipient={recipient}>
|
||||
|
||||
@@ -354,6 +354,7 @@ const EmbedSignDocumentPageV1 = ({ data }: { data: Awaited<ReturnType<typeof han
|
||||
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
||||
qrSignatureEnabled={document.documentMeta?.qrSignatureEnabled}
|
||||
>
|
||||
<DocumentSigningAuthProvider documentAuthOptions={document.authOptions} recipient={recipient} user={user}>
|
||||
<EmbedSignDocumentV1ClientPage
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
*
|
||||
* No translations required.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { formatPath } from '@documenso/lib/constants/app';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router';
|
||||
|
||||
export const loader = () => {
|
||||
@@ -147,7 +148,7 @@ export default function EmbedPlaygroundPage() {
|
||||
return inputToken;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/v2/embedding/create-presign-token', {
|
||||
const response = await fetch(formatPath('/api/v2/embedding/create-presign-token'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${inputToken}`,
|
||||
|
||||
@@ -83,6 +83,7 @@ export default function EmbeddingAuthoringDocumentCreatePage() {
|
||||
drawSignatureEnabled: signatureTypes.length === 0 || signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||
typedSignatureEnabled: signatureTypes.length === 0 || signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||
uploadSignatureEnabled: signatureTypes.length === 0 || signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||
qrSignatureEnabled: signatureTypes.length === 0 || signatureTypes.includes(DocumentSignatureType.QR),
|
||||
},
|
||||
recipients: configuration.signers.map((signer) => ({
|
||||
name: signer.name,
|
||||
|
||||
@@ -101,6 +101,10 @@ export default function EmbeddingAuthoringDocumentEditPage() {
|
||||
types.push(DocumentSignatureType.UPLOAD);
|
||||
}
|
||||
|
||||
if (document.documentMeta?.qrSignatureEnabled) {
|
||||
types.push(DocumentSignatureType.QR);
|
||||
}
|
||||
|
||||
return types;
|
||||
}, [document.documentMeta]);
|
||||
|
||||
@@ -216,6 +220,10 @@ export default function EmbeddingAuthoringDocumentEditPage() {
|
||||
? configuration.meta.signatureTypes.length === 0 ||
|
||||
configuration.meta.signatureTypes.includes(DocumentSignatureType.UPLOAD)
|
||||
: undefined,
|
||||
qrSignatureEnabled: configuration.meta.signatureTypes
|
||||
? configuration.meta.signatureTypes.length === 0 ||
|
||||
configuration.meta.signatureTypes.includes(DocumentSignatureType.QR)
|
||||
: undefined,
|
||||
},
|
||||
recipients: configuration.signers.map((signer) => ({
|
||||
id: signer.nativeId,
|
||||
|
||||
@@ -101,6 +101,10 @@ export default function EmbeddingAuthoringTemplateEditPage() {
|
||||
types.push(DocumentSignatureType.UPLOAD);
|
||||
}
|
||||
|
||||
if (template.templateMeta?.qrSignatureEnabled) {
|
||||
types.push(DocumentSignatureType.QR);
|
||||
}
|
||||
|
||||
return types;
|
||||
}, [template.templateMeta]);
|
||||
|
||||
@@ -215,6 +219,10 @@ export default function EmbeddingAuthoringTemplateEditPage() {
|
||||
? configuration.meta.signatureTypes.length === 0 ||
|
||||
configuration.meta.signatureTypes.includes(DocumentSignatureType.UPLOAD)
|
||||
: undefined,
|
||||
qrSignatureEnabled: configuration.meta.signatureTypes
|
||||
? configuration.meta.signatureTypes.length === 0 ||
|
||||
configuration.meta.signatureTypes.includes(DocumentSignatureType.QR)
|
||||
: undefined,
|
||||
},
|
||||
recipients: configuration.signers.map((signer) => ({
|
||||
id: signer.nativeId,
|
||||
|
||||
@@ -238,6 +238,7 @@ export default function MultisignPage() {
|
||||
typedSignatureEnabled={selectedDocument.documentMeta?.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={selectedDocument.documentMeta?.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={selectedDocument.documentMeta?.drawSignatureEnabled}
|
||||
qrSignatureEnabled={selectedDocument.documentMeta?.qrSignatureEnabled}
|
||||
>
|
||||
<DocumentSigningAuthProvider
|
||||
documentAuthOptions={selectedDocument.authOptions}
|
||||
|
||||
@@ -224,6 +224,7 @@ const EnvelopeCreatePage = ({ embedAuthoringOptions }: EnvelopeCreatePageProps)
|
||||
typedSignatureEnabled: envelope.documentMeta.typedSignatureEnabled ?? undefined,
|
||||
uploadSignatureEnabled: envelope.documentMeta.uploadSignatureEnabled ?? undefined,
|
||||
drawSignatureEnabled: envelope.documentMeta.drawSignatureEnabled ?? undefined,
|
||||
qrSignatureEnabled: envelope.documentMeta.qrSignatureEnabled ?? undefined,
|
||||
dateFormat: (envelope.documentMeta.dateFormat as TDocumentMetaDateFormat) ?? undefined,
|
||||
language: envelope.documentMeta.language as SupportedLanguageCodes,
|
||||
},
|
||||
|
||||
@@ -239,6 +239,7 @@ const EnvelopeEditPage = ({ embedAuthoringOptions }: EnvelopeEditPageProps) => {
|
||||
typedSignatureEnabled: envelope.documentMeta.typedSignatureEnabled, //
|
||||
uploadSignatureEnabled: envelope.documentMeta.uploadSignatureEnabled, //
|
||||
drawSignatureEnabled: envelope.documentMeta.drawSignatureEnabled, //
|
||||
qrSignatureEnabled: envelope.documentMeta.qrSignatureEnabled, //
|
||||
dateFormat: (envelope.documentMeta.dateFormat as TDocumentMetaDateFormat) ?? undefined,
|
||||
language: envelope.documentMeta.language as SupportedLanguageCodes,
|
||||
},
|
||||
|
||||
@@ -12,12 +12,23 @@ type HandleSignatureFieldClickOptions = {
|
||||
typedSignatureEnabled?: boolean;
|
||||
uploadSignatureEnabled?: boolean;
|
||||
drawSignatureEnabled?: boolean;
|
||||
qrSignatureEnabled?: boolean;
|
||||
recipientToken?: string;
|
||||
};
|
||||
|
||||
export const handleSignatureFieldClick = async (
|
||||
options: HandleSignatureFieldClickOptions,
|
||||
): Promise<Extract<TSignEnvelopeFieldValue, { type: typeof FieldType.SIGNATURE }> | null> => {
|
||||
const { field, fullName, signature, typedSignatureEnabled, uploadSignatureEnabled, drawSignatureEnabled } = options;
|
||||
const {
|
||||
field,
|
||||
fullName,
|
||||
signature,
|
||||
typedSignatureEnabled,
|
||||
uploadSignatureEnabled,
|
||||
drawSignatureEnabled,
|
||||
qrSignatureEnabled,
|
||||
recipientToken,
|
||||
} = options;
|
||||
|
||||
if (field.type !== FieldType.SIGNATURE) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
@@ -40,6 +51,8 @@ export const handleSignatureFieldClick = async (
|
||||
typedSignatureEnabled,
|
||||
uploadSignatureEnabled,
|
||||
drawSignatureEnabled,
|
||||
qrSignatureEnabled,
|
||||
qrSignatureContext: recipientToken ? { type: 'DOCUMENT_SIGNATURE', recipientToken } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
"autoprefixer": "^10.4.22",
|
||||
"colord": "^2.9.3",
|
||||
"content-disposition": "^1.0.1",
|
||||
"framer-motion": "^12.23.24",
|
||||
"framer-motion": "^12.43.0",
|
||||
"hono": "^4.12.14",
|
||||
"hono-react-router-adapter": "^0.6.5",
|
||||
"input-otp": "^1.4.2",
|
||||
@@ -57,9 +57,9 @@
|
||||
"papaparse": "^5.5.3",
|
||||
"posthog-js": "^1.297.2",
|
||||
"posthog-node": "4.18.0",
|
||||
"react": "^18",
|
||||
"react": "^19.2.7",
|
||||
"react-call": "^1.8.1",
|
||||
"react-dom": "^18",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-hook-form": "^7.66.1",
|
||||
"react-hotkeys-hook": "^4.6.2",
|
||||
@@ -93,8 +93,8 @@
|
||||
"@types/luxon": "^3.7.1",
|
||||
"@types/node": "^20",
|
||||
"@types/papaparse": "^5.5.0",
|
||||
"@types/react": "18.3.27",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
"cross-env": "^10.1.0",
|
||||
"esbuild": "^0.27.0",
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
"short_name": "Documenso",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"src": "./android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-512x512.png",
|
||||
"src": "./android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
|
||||
@@ -3,4 +3,9 @@ import type { Config } from '@react-router/dev/config';
|
||||
export default {
|
||||
appDirectory: 'app',
|
||||
ssr: true,
|
||||
// Must never be undefined and must start with the raw Vite `base` value,
|
||||
// otherwise @react-router/dev crashes / exits on `react-router dev`. Both are
|
||||
// kept without a trailing slash so they match exactly, and so the bare
|
||||
// sub-path URL (e.g. "/ESign") still matches the basename at runtime.
|
||||
basename: process.env.NEXT_PUBLIC_BASE_PATH ? process.env.NEXT_PUBLIC_BASE_PATH.replace(/\/$/, '') : '/',
|
||||
} satisfies Config;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { formatPath } from '@documenso/lib/constants/app';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type TDetectFieldsRequest, ZNormalizedFieldWithContextSchema } from './detect-fields.types';
|
||||
@@ -69,7 +70,7 @@ export const detectFields = async ({
|
||||
onError,
|
||||
signal,
|
||||
}: DetectFieldsOptions): Promise<void> => {
|
||||
const response = await fetch('/api/ai/detect-fields', {
|
||||
const response = await fetch(formatPath('/api/ai/detect-fields'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { formatPath } from '@documenso/lib/constants/app';
|
||||
import { ZDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -70,7 +71,7 @@ export const detectRecipients = async ({
|
||||
onError,
|
||||
signal,
|
||||
}: DetectRecipientsOptions): Promise<void> => {
|
||||
const response = await fetch('/api/ai/detect-recipients', {
|
||||
const response = await fetch(formatPath('/api/ai/detect-recipients'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -14,9 +14,22 @@ import { getLoadContext } from './hono/server/load-context.js';
|
||||
import server from './hono/server/router.js';
|
||||
import * as build from './index.js';
|
||||
|
||||
// Sub-path the app is served under (e.g. "/ESign"). Empty = root.
|
||||
// Must match the basePath used by the Hono router and the Vite `base`/RR
|
||||
// `basename` so that hashed asset URLs like `/ESign/assets/app-xxx.css`
|
||||
// resolve to files on disk at `build/client/assets/app-xxx.css`.
|
||||
const basePath = (process.env.NEXT_PUBLIC_BASE_PATH ?? '').replace(/\/$/, '');
|
||||
|
||||
server.use(
|
||||
serveStatic({
|
||||
root: 'build/client',
|
||||
rewriteRequestPath: (path) => {
|
||||
if (basePath && (path === basePath || path.startsWith(`${basePath}/`))) {
|
||||
const stripped = path.slice(basePath.length);
|
||||
return stripped === '' ? '/' : stripped;
|
||||
}
|
||||
return path;
|
||||
},
|
||||
onFound: (path, c) => {
|
||||
if (path.startsWith('build/client/assets')) {
|
||||
// Hard cache assets with hashed file names.
|
||||
|
||||
@@ -46,7 +46,9 @@ export interface HonoEnv {
|
||||
};
|
||||
}
|
||||
|
||||
const app = new Hono<HonoEnv>();
|
||||
const basePath = (env('NEXT_PUBLIC_BASE_PATH') ?? '').replace(/\/$/, '');
|
||||
|
||||
const app = new Hono<HonoEnv>().basePath(basePath || '/');
|
||||
|
||||
/**
|
||||
* Database-backed rate limiting for API routes.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { API_V2_BETA_URL, API_V2_URL } from '@documenso/lib/constants/app';
|
||||
import { API_V2_BETA_URL, API_V2_URL, formatPath } from '@documenso/lib/constants/app';
|
||||
import { AppError, genericErrorCodeToTrpcErrorCodeMap } from '@documenso/lib/errors/app-error';
|
||||
import { createTrpcContext } from '@documenso/trpc/server/context';
|
||||
import { appRouter } from '@documenso/trpc/server/router';
|
||||
@@ -11,8 +11,10 @@ type OpenApiTrpcServerHandlerOptions = {
|
||||
};
|
||||
|
||||
export const openApiTrpcServerHandler = async (c: Context, { isBeta }: OpenApiTrpcServerHandlerOptions) => {
|
||||
const endpoint = formatPath(isBeta ? API_V2_BETA_URL : API_V2_URL) as `/${string}`;
|
||||
|
||||
return createOpenApiFetchHandler<typeof appRouter>({
|
||||
endpoint: isBeta ? API_V2_BETA_URL : API_V2_URL,
|
||||
endpoint,
|
||||
router: appRouter,
|
||||
createContext: async () => createTrpcContext({ c, requestSource: 'apiV2' }),
|
||||
req: c.req.raw,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { formatPath } from '@documenso/lib/constants/app';
|
||||
import { createTrpcContext } from '@documenso/trpc/server/context';
|
||||
import { appRouter } from '@documenso/trpc/server/router';
|
||||
import { handleTrpcRouterError } from '@documenso/trpc/utils/trpc-error-handler';
|
||||
@@ -5,10 +6,14 @@ import { trpcServer } from '@hono/trpc-server';
|
||||
|
||||
/**
|
||||
* Trpc server for internal routes like /api/trpc/*
|
||||
*
|
||||
* `endpoint` must include the sub-path prefix (e.g. "/ESign") because the
|
||||
* @hono/trpc-server adapter slices the prefix off the full URL pathname to
|
||||
* compute the procedure name. Hono's `basePath` doesn't rewrite the URL.
|
||||
*/
|
||||
export const reactRouterTrpcServer = trpcServer({
|
||||
router: appRouter,
|
||||
endpoint: '/api/trpc',
|
||||
endpoint: formatPath('/api/trpc'),
|
||||
createContext: async (_, c) => createTrpcContext({ c, requestSource: 'app' }),
|
||||
onError: (opts) => handleTrpcRouterError(opts, 'trpc'),
|
||||
});
|
||||
|
||||
@@ -23,6 +23,10 @@ const cMapsDir = normalizePath(path.join(pdfjsDistPath, 'cmaps'));
|
||||
* Do not configure any envs here.
|
||||
*/
|
||||
export default defineConfig({
|
||||
// No trailing slash: the React Router dev server requires its `basename` to
|
||||
// start with this raw value (see react-router.config.ts). Vite normalizes
|
||||
// and joins asset URLs correctly either way.
|
||||
base: process.env.NEXT_PUBLIC_BASE_PATH ? process.env.NEXT_PUBLIC_BASE_PATH.replace(/\/$/, '') : '/',
|
||||
css: {
|
||||
postcss: {
|
||||
plugins: [tailwindcss, autoprefixer],
|
||||
@@ -117,7 +121,7 @@ export default defineConfig({
|
||||
'nodemailer',
|
||||
/playwright/,
|
||||
'@playwright/browser-chromium',
|
||||
'skia-canvas',
|
||||
'@documenso/skia-canvas',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -50,6 +50,12 @@ ENV NEXT_PRIVATE_ENCRYPTION_KEY="$NEXT_PRIVATE_ENCRYPTION_KEY"
|
||||
ARG NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="DEADBEEF"
|
||||
ENV NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="$NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY"
|
||||
|
||||
# Sub-path the app is served under (e.g. "/ESign"). Empty = root.
|
||||
# Baked into the client bundle by Vite/React Router at build time; also
|
||||
# required at runtime for SSR so window.__ENV__ exposes it to the client.
|
||||
ARG NEXT_PUBLIC_BASE_PATH=""
|
||||
ENV NEXT_PUBLIC_BASE_PATH="$NEXT_PUBLIC_BASE_PATH"
|
||||
|
||||
# Telemetry credentials (optional, baked into image at build time)
|
||||
ARG NEXT_PRIVATE_TELEMETRY_KEY=""
|
||||
ENV NEXT_PRIVATE_TELEMETRY_KEY="$NEXT_PRIVATE_TELEMETRY_KEY"
|
||||
|
||||
@@ -7,7 +7,7 @@ services:
|
||||
volumes:
|
||||
- documenso_database:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER}']
|
||||
test: ['CMD-SHELL', 'pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
@@ -8,7 +8,7 @@ services:
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?err}
|
||||
- POSTGRES_DB=${POSTGRES_DB:?err}
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER}']
|
||||
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
@@ -8,7 +8,7 @@ services:
|
||||
- POSTGRES_PASSWORD=password
|
||||
- POSTGRES_DB=documenso
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U documenso']
|
||||
test: ['CMD-SHELL', 'pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB']
|
||||
interval: 1s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
Generated
+1396
-2294
File diff suppressed because it is too large
Load Diff
+39
-4
@@ -48,14 +48,17 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.8",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@commitlint/cli": "^20.1.0",
|
||||
"@commitlint/config-conventional": "^20.0.0",
|
||||
"@datadog/pprof": "^5.13.5",
|
||||
"@documenso/skia-canvas": "^3.0.8-documenso.3",
|
||||
"@lingui/cli": "^5.6.0",
|
||||
"@prisma/client": "^6.19.0",
|
||||
"@trpc/client": "11.8.1",
|
||||
"@trpc/react-query": "11.8.1",
|
||||
"@trpc/server": "11.8.1",
|
||||
"@trpc/client": "11.17.0",
|
||||
"@trpc/react-query": "11.17.0",
|
||||
"@trpc/server": "11.17.0",
|
||||
"@ts-rest/core": "^3.52.1",
|
||||
"@ts-rest/open-api": "^3.52.1",
|
||||
"@ts-rest/serverless": "^3.52.1",
|
||||
@@ -92,13 +95,41 @@
|
||||
"@lingui/conf": "^5.6.0",
|
||||
"@lingui/core": "^5.6.0",
|
||||
"@prisma/extension-read-replicas": "^0.4.1",
|
||||
"@radix-ui/react-accordion": "^1.2.16",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.19",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.11",
|
||||
"@radix-ui/react-avatar": "^1.2.2",
|
||||
"@radix-ui/react-checkbox": "^1.3.7",
|
||||
"@radix-ui/react-collapsible": "^1.1.16",
|
||||
"@radix-ui/react-context-menu": "^2.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.19",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.20",
|
||||
"@radix-ui/react-hover-card": "^1.1.19",
|
||||
"@radix-ui/react-label": "^2.1.11",
|
||||
"@radix-ui/react-menubar": "^1.1.20",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.18",
|
||||
"@radix-ui/react-popover": "^1.1.19",
|
||||
"@radix-ui/react-progress": "^1.1.12",
|
||||
"@radix-ui/react-radio-group": "^1.4.3",
|
||||
"@radix-ui/react-scroll-area": "^1.2.14",
|
||||
"@radix-ui/react-select": "^2.3.3",
|
||||
"@radix-ui/react-separator": "^1.1.11",
|
||||
"@radix-ui/react-slider": "^1.4.3",
|
||||
"@radix-ui/react-slot": "^1.3.0",
|
||||
"@radix-ui/react-switch": "^1.3.3",
|
||||
"@radix-ui/react-tabs": "^1.1.17",
|
||||
"@radix-ui/react-toast": "^1.2.19",
|
||||
"@radix-ui/react-toggle": "^1.1.14",
|
||||
"@radix-ui/react-toggle-group": "^1.1.15",
|
||||
"@radix-ui/react-tooltip": "^1.2.12",
|
||||
"ai": "^5.0.104",
|
||||
"cron-parser": "^5.5.0",
|
||||
"fflate": "^0.8.3",
|
||||
"luxon": "^3.7.2",
|
||||
"patch-package": "^8.0.1",
|
||||
"posthog-node": "4.18.0",
|
||||
"react": "^18",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"typescript": "5.6.2",
|
||||
"@marsidev/react-turnstile": "^1.5.0",
|
||||
"zod": "^3.25.76"
|
||||
@@ -107,6 +138,10 @@
|
||||
"lodash": "4.18.1",
|
||||
"pdfjs-dist": "5.4.296",
|
||||
"postcss": "^8.5.19",
|
||||
"react": "$react",
|
||||
"react-dom": "$react-dom",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"typescript": "5.6.2",
|
||||
"zod": "$zod",
|
||||
"fumadocs-mdx": {
|
||||
|
||||
@@ -438,6 +438,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
||||
typedSignatureEnabled: body.meta.typedSignatureEnabled,
|
||||
uploadSignatureEnabled: body.meta.uploadSignatureEnabled,
|
||||
drawSignatureEnabled: body.meta.drawSignatureEnabled,
|
||||
qrSignatureEnabled: body.meta.qrSignatureEnabled,
|
||||
distributionMethod: body.meta.distributionMethod,
|
||||
emailSettings: body.meta.emailSettings,
|
||||
},
|
||||
|
||||
@@ -170,6 +170,8 @@ export const ZCreateDocumentMutationSchema = z.object({
|
||||
typedSignatureEnabled: z.boolean().optional().default(true),
|
||||
uploadSignatureEnabled: z.boolean().optional().default(true),
|
||||
drawSignatureEnabled: z.boolean().optional().default(true),
|
||||
// No default: omission must fall through to team/org settings.
|
||||
qrSignatureEnabled: z.boolean().optional(),
|
||||
distributionMethod: z.nativeEnum(DocumentDistributionMethod).optional(),
|
||||
emailSettings: ZDocumentEmailSettingsSchema.optional(),
|
||||
})
|
||||
@@ -340,6 +342,7 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
|
||||
typedSignatureEnabled: z.boolean(),
|
||||
uploadSignatureEnabled: z.boolean(),
|
||||
drawSignatureEnabled: z.boolean(),
|
||||
qrSignatureEnabled: z.boolean(),
|
||||
emailSettings: ZDocumentEmailSettingsSchema,
|
||||
})
|
||||
.partial()
|
||||
|
||||
@@ -196,6 +196,7 @@ test.describe('API V2 Envelopes', () => {
|
||||
typedSignatureEnabled: true,
|
||||
uploadSignatureEnabled: false,
|
||||
drawSignatureEnabled: false,
|
||||
qrSignatureEnabled: false,
|
||||
emailReplyTo: userA.email,
|
||||
emailSettings: {
|
||||
recipientSigningRequest: false,
|
||||
@@ -295,6 +296,7 @@ test.describe('API V2 Envelopes', () => {
|
||||
expect(envelope.documentMeta.typedSignatureEnabled).toBe(payload.meta.typedSignatureEnabled);
|
||||
expect(envelope.documentMeta.uploadSignatureEnabled).toBe(payload.meta.uploadSignatureEnabled);
|
||||
expect(envelope.documentMeta.drawSignatureEnabled).toBe(payload.meta.drawSignatureEnabled);
|
||||
expect(envelope.documentMeta.qrSignatureEnabled).toBe(payload.meta.qrSignatureEnabled);
|
||||
expect(envelope.documentMeta.emailReplyTo).toBe(payload.meta.emailReplyTo);
|
||||
expect(envelope.documentMeta.emailSettings).toEqual(payload.meta.emailSettings);
|
||||
|
||||
|
||||
@@ -158,6 +158,7 @@ test.describe('AutoSave Settings Step', () => {
|
||||
expect(retrieved.documentMeta?.drawSignatureEnabled).toBe(false);
|
||||
expect(retrieved.documentMeta?.typedSignatureEnabled).toBe(false);
|
||||
expect(retrieved.documentMeta?.uploadSignatureEnabled).toBe(true);
|
||||
expect(retrieved.documentMeta?.qrSignatureEnabled).toBe(true);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
|
||||
@@ -384,6 +384,7 @@ const assertEnvelopeSettingsPersistedInDatabase = async ({
|
||||
expect(envelope.documentMeta.drawSignatureEnabled).toBe(true);
|
||||
expect(envelope.documentMeta.typedSignatureEnabled).toBe(true);
|
||||
expect(envelope.documentMeta.uploadSignatureEnabled).toBe(false);
|
||||
expect(envelope.documentMeta.qrSignatureEnabled).toBe(true);
|
||||
expect(envelope.documentMeta.emailSettings).toMatchObject(DB_EXPECTED_VALUES.emailSettings);
|
||||
|
||||
const authOptions = parseAuthOptions(envelope.authOptions);
|
||||
|
||||
@@ -68,6 +68,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
|
||||
expect(teamSettings.typedSignatureEnabled).toEqual(true);
|
||||
expect(teamSettings.uploadSignatureEnabled).toEqual(false);
|
||||
expect(teamSettings.drawSignatureEnabled).toEqual(false);
|
||||
expect(teamSettings.qrSignatureEnabled).toEqual(true);
|
||||
|
||||
// Edit the team settings
|
||||
await page.goto(`/t/${team.url}/settings/document`);
|
||||
@@ -102,6 +103,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
|
||||
expect(updatedTeamSettings.typedSignatureEnabled).toEqual(true);
|
||||
expect(updatedTeamSettings.uploadSignatureEnabled).toEqual(false);
|
||||
expect(updatedTeamSettings.drawSignatureEnabled).toEqual(false);
|
||||
expect(updatedTeamSettings.qrSignatureEnabled).toEqual(true);
|
||||
|
||||
const document = await seedTeamDocumentWithMeta(team);
|
||||
|
||||
@@ -117,6 +119,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
|
||||
expect(documentMeta.typedSignatureEnabled).toEqual(true);
|
||||
expect(documentMeta.uploadSignatureEnabled).toEqual(false);
|
||||
expect(documentMeta.drawSignatureEnabled).toEqual(false);
|
||||
expect(documentMeta.qrSignatureEnabled).toEqual(true);
|
||||
expect(documentMeta.language).toEqual('pl');
|
||||
expect(documentMeta.timezone).toEqual('Europe/London');
|
||||
expect(documentMeta.dateFormat).toEqual('MM/dd/yyyy');
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { AnonymousVerificationTokenType, FieldType } from '@documenso/prisma/client';
|
||||
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
/**
|
||||
* Draw a zig-zag onto the drawing canvas so that it passes the minimum
|
||||
* signature coverage threshold.
|
||||
*/
|
||||
const drawOnSignaturePad = async (page: Page) => {
|
||||
const canvas = page.getByTestId('signature-pad-draw');
|
||||
|
||||
await canvas.waitFor({ state: 'visible' });
|
||||
|
||||
let capturedBox: { x: number; y: number; width: number; height: number } | null = null;
|
||||
|
||||
// `boundingBox()` can return null if the canvas is replaced mid-hydration,
|
||||
// so poll until a measurable element is attached, capturing the box inside
|
||||
// the retry closure so it is never re-fetched (and re-raced) afterwards.
|
||||
await expect(async () => {
|
||||
capturedBox = await canvas.boundingBox();
|
||||
|
||||
expect(capturedBox).not.toBeNull();
|
||||
expect(capturedBox?.width ?? 0).toBeGreaterThan(0);
|
||||
}).toPass({ timeout: 5_000 });
|
||||
|
||||
// TS cannot see the closure assignment above, so widen the type back out.
|
||||
const box = capturedBox as { x: number; y: number; width: number; height: number } | null;
|
||||
|
||||
if (!box) {
|
||||
throw new Error('Signature pad canvas not found');
|
||||
}
|
||||
|
||||
await page.mouse.move(box.x + box.width * 0.15, box.y + box.height * 0.5);
|
||||
await page.mouse.down();
|
||||
|
||||
for (let i = 0; i < 8; i++) {
|
||||
await page.mouse.move(box.x + box.width * (0.15 + i * 0.09), box.y + box.height * (i % 2 === 0 ? 0.25 : 0.75), {
|
||||
steps: 10,
|
||||
});
|
||||
}
|
||||
|
||||
await page.mouse.up();
|
||||
};
|
||||
|
||||
test('[QR_SIGNATURE]: complete signing via mobile qr handoff', async ({ page, browser }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
recipients: ['qr-signer@test.documenso.com'],
|
||||
teamId: team.id,
|
||||
fields: [FieldType.SIGNATURE],
|
||||
});
|
||||
|
||||
const recipient = recipients[0];
|
||||
|
||||
await page.goto(`/sign/${recipient.token}`);
|
||||
|
||||
// Wait for the client-side PDF render so we know the page has hydrated
|
||||
// before interacting with the signature pad.
|
||||
await page.waitForSelector(PDF_VIEWER_PAGE_SELECTOR);
|
||||
|
||||
// Open the signature dialog and switch to the Mobile tab.
|
||||
await page.getByTestId('signature-pad-dialog-button').click();
|
||||
await page.getByRole('tab', { name: 'Mobile' }).click();
|
||||
|
||||
// Read the handoff URL rendered beneath the QR code.
|
||||
await expect(page.getByTestId('signature-pad-qr-url')).toBeVisible();
|
||||
const handoffUrl = await page.getByTestId('signature-pad-qr-url').textContent();
|
||||
|
||||
expect(handoffUrl).toContain('/mobile-signature/');
|
||||
|
||||
// Open the mobile page in a fully isolated browser context (no shared
|
||||
// cookies or session) to prove the handoff requires no authentication.
|
||||
// A realistic landscape-phone viewport: the pad sizes itself dynamically to
|
||||
// the viewport, and the primitive's minimum-coverage check is a percentage
|
||||
// of the canvas area - a desktop-sized context would demand far more ink
|
||||
// than the drawn zigzag provides.
|
||||
const mobileContext = await browser.newContext({ viewport: { width: 844, height: 390 } });
|
||||
const mobilePage = await mobileContext.newPage();
|
||||
|
||||
await mobilePage.goto(handoffUrl ?? '');
|
||||
|
||||
// The phone page renders the signing card (landscape layout at the default
|
||||
// test viewport) with Next disabled until a valid signature is drawn.
|
||||
await expect(mobilePage.getByTestId('signature-pad-draw')).toBeVisible();
|
||||
await expect(mobilePage.getByRole('button', { name: 'Next' })).toBeDisabled();
|
||||
|
||||
await drawOnSignaturePad(mobilePage);
|
||||
|
||||
await mobilePage.getByRole('button', { name: 'Next' }).click();
|
||||
|
||||
await expect(mobilePage.getByText('Success')).toBeVisible();
|
||||
|
||||
await mobileContext.close();
|
||||
|
||||
// The desktop pad should receive the signature within a poll interval.
|
||||
await expect(page.getByTestId('signature-pad-qr-preview')).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// The session is single-use: the desktop pickup deletes the row on read, and
|
||||
// a missing row is indistinguishable from an expired one by design. So a
|
||||
// revisit must show the expired page (not "Signature already sent"), which
|
||||
// proves the deletion happened.
|
||||
const revisitContext = await browser.newContext();
|
||||
const revisitPage = await revisitContext.newPage();
|
||||
|
||||
await revisitPage.goto(handoffUrl ?? '');
|
||||
|
||||
await expect(revisitPage.getByRole('heading', { name: 'This link has expired' })).toBeVisible();
|
||||
|
||||
await revisitContext.close();
|
||||
|
||||
// Direct proof of consumption: the token row must be gone from the database.
|
||||
const consumedToken = (handoffUrl ?? '').split('/mobile-signature/')[1];
|
||||
|
||||
const consumedRow = await prisma.anonymousVerificationToken.findFirst({
|
||||
where: { token: consumedToken },
|
||||
});
|
||||
|
||||
expect(consumedRow).toBeNull();
|
||||
|
||||
// Confirm and finish signing the document.
|
||||
await page.getByRole('button', { name: 'Next' }).click();
|
||||
|
||||
await page.locator('[data-field-type="SIGNATURE"]:not([data-readonly="true"])').first().click();
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
|
||||
await page.waitForURL(`/sign/${recipient.token}/complete`);
|
||||
await expect(page.getByText('Document Signed')).toBeVisible();
|
||||
});
|
||||
|
||||
test('[QR_SIGNATURE]: mobile tab hidden when qr disabled', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { document, recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
recipients: ['qr-disabled-signer@test.documenso.com'],
|
||||
teamId: team.id,
|
||||
fields: [FieldType.SIGNATURE],
|
||||
});
|
||||
|
||||
// Seeded documents create their meta row with bare column defaults, which
|
||||
// leave qrSignatureEnabled true, so disable it directly on the meta row.
|
||||
await prisma.documentMeta.update({
|
||||
where: { id: document.documentMetaId },
|
||||
data: { qrSignatureEnabled: false },
|
||||
});
|
||||
|
||||
const recipient = recipients[0];
|
||||
|
||||
await page.goto(`/sign/${recipient.token}`);
|
||||
|
||||
await page.waitForSelector(PDF_VIEWER_PAGE_SELECTOR);
|
||||
|
||||
await page.getByTestId('signature-pad-dialog-button').click();
|
||||
|
||||
// Waiting on the Draw tab first guarantees the tab list has rendered before
|
||||
// asserting the Mobile tab is absent.
|
||||
await expect(page.getByRole('tab', { name: 'Draw' })).toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: 'Mobile' })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[QR_SIGNATURE]: mobile tab shown when draw disabled but qr enabled', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { document, recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
recipients: ['qr-only-signer@test.documenso.com'],
|
||||
teamId: team.id,
|
||||
fields: [FieldType.SIGNATURE],
|
||||
});
|
||||
|
||||
// qrSignatureEnabled already defaults to true on seeded metas, but set it
|
||||
// explicitly so the test still documents the required state if defaults change.
|
||||
await prisma.documentMeta.update({
|
||||
where: { id: document.documentMetaId },
|
||||
data: { drawSignatureEnabled: false, qrSignatureEnabled: true },
|
||||
});
|
||||
|
||||
const recipient = recipients[0];
|
||||
|
||||
await page.goto(`/sign/${recipient.token}`);
|
||||
|
||||
await page.waitForSelector(PDF_VIEWER_PAGE_SELECTOR);
|
||||
|
||||
await page.getByTestId('signature-pad-dialog-button').click();
|
||||
|
||||
await expect(page.getByRole('tab', { name: 'Mobile' })).toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: 'Draw' })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[QR_SIGNATURE]: unknown token shows expired page', async ({ page }) => {
|
||||
await page.goto('/mobile-signature/this-token-does-not-exist');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'This link has expired' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[QR_SIGNATURE]: expired token shows expired page', async ({ page }) => {
|
||||
const expiredToken = `qr-e2e-expired-${Date.now()}-${Math.floor(Math.random() * 100000)}`;
|
||||
|
||||
await prisma.anonymousVerificationToken.create({
|
||||
data: {
|
||||
type: AnonymousVerificationTokenType.QR_SIGNATURE,
|
||||
token: expiredToken,
|
||||
expiresAt: new Date(Date.now() - 60_000),
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`/mobile-signature/${expiredToken}`);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'This link has expired' })).toBeVisible();
|
||||
});
|
||||
@@ -25,6 +25,7 @@ test('[TEAMS]: check that default team signature settings are all enabled', asyn
|
||||
await expect(page.getByRole('combobox').filter({ hasText: 'Type' })).toBeVisible();
|
||||
await expect(page.getByRole('combobox').filter({ hasText: 'Upload' })).toBeVisible();
|
||||
await expect(page.getByRole('combobox').filter({ hasText: 'Draw' })).toBeVisible();
|
||||
await expect(page.getByRole('combobox').filter({ hasText: 'QR code' })).toBeVisible();
|
||||
|
||||
// Go to document and check that the signatured tabs are correct.
|
||||
await page.goto(`/sign/${document.recipients[0].token}`);
|
||||
@@ -34,6 +35,7 @@ test('[TEAMS]: check that default team signature settings are all enabled', asyn
|
||||
await expect(page.getByRole('tab', { name: 'Type' })).toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: 'Upload' })).toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: 'Draw' })).toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: 'Mobile' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[TEAMS]: check signature modes can be disabled', async ({ page }) => {
|
||||
@@ -45,8 +47,11 @@ test('[TEAMS]: check signature modes can be disabled', async ({ page }) => {
|
||||
redirectPath: `/t/${team.url}/settings/document`,
|
||||
});
|
||||
|
||||
const allTabs = ['Type', 'Upload', 'Draw'];
|
||||
const tabTest = [['Type', 'Upload', 'Draw'], ['Type', 'Upload'], ['Type']];
|
||||
// The 'QR code' signature type is surfaced as the 'Mobile' tab on the signing dialog.
|
||||
const allSignatureOptions = ['Type', 'Upload', 'Draw', 'QR code'];
|
||||
const tabNameForOption = (option: string) => (option === 'QR code' ? 'Mobile' : option);
|
||||
|
||||
const tabTest = [['Type', 'Upload', 'Draw', 'QR code'], ['Type', 'Upload'], ['Type']];
|
||||
|
||||
for (const tabs of tabTest) {
|
||||
await page.goto(`/t/${team.url}/settings/document`);
|
||||
@@ -57,9 +62,10 @@ test('[TEAMS]: check signature modes can be disabled', async ({ page }) => {
|
||||
await expect(page.getByRole('option', { name: 'Type' })).toBeVisible();
|
||||
await expect(page.getByRole('option', { name: 'Upload' })).toBeVisible();
|
||||
await expect(page.getByRole('option', { name: 'Draw' })).toBeVisible();
|
||||
await expect(page.getByRole('option', { name: 'QR code' })).toBeVisible();
|
||||
|
||||
// Clear all selected items.
|
||||
for (const tab of allTabs) {
|
||||
for (const tab of allSignatureOptions) {
|
||||
const item = page.getByRole('option', { name: tab });
|
||||
|
||||
const isSelected = (await item.innerHTML()).includes('opacity-100');
|
||||
@@ -90,12 +96,13 @@ test('[TEAMS]: check signature modes can be disabled', async ({ page }) => {
|
||||
await page.waitForSelector('[role="dialog"]');
|
||||
|
||||
// Check the tab values
|
||||
for (const tab of allTabs) {
|
||||
if (tabs.includes(tab)) {
|
||||
await expect(page.getByRole('tab', { name: tab })).toBeVisible();
|
||||
for (const option of allSignatureOptions) {
|
||||
const tabName = tabNameForOption(option);
|
||||
|
||||
if (tabs.includes(option)) {
|
||||
await expect(page.getByRole('tab', { name: tabName })).toBeVisible();
|
||||
} else {
|
||||
// await expect(page.getByRole('tab', { name: tab })).not.toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: tab })).toHaveCount(0);
|
||||
await expect(page.getByRole('tab', { name: tabName })).toHaveCount(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,8 +117,8 @@ test('[TEAMS]: check signature modes work for templates', async ({ page }) => {
|
||||
redirectPath: `/t/${team.url}/settings/document`,
|
||||
});
|
||||
|
||||
const allTabs = ['Type', 'Upload', 'Draw'];
|
||||
const tabTest = [['Type', 'Upload', 'Draw'], ['Type', 'Upload'], ['Type']];
|
||||
const allSignatureOptions = ['Type', 'Upload', 'Draw', 'QR code'];
|
||||
const tabTest = [['Type', 'Upload', 'Draw', 'QR code'], ['Type', 'Upload'], ['Type']];
|
||||
|
||||
for (const tabs of tabTest) {
|
||||
await page.goto(`/t/${team.url}/settings/document`);
|
||||
@@ -122,9 +129,10 @@ test('[TEAMS]: check signature modes work for templates', async ({ page }) => {
|
||||
await expect(page.getByRole('option', { name: 'Type' })).toBeVisible();
|
||||
await expect(page.getByRole('option', { name: 'Upload' })).toBeVisible();
|
||||
await expect(page.getByRole('option', { name: 'Draw' })).toBeVisible();
|
||||
await expect(page.getByRole('option', { name: 'QR code' })).toBeVisible();
|
||||
|
||||
// Clear all selected items.
|
||||
for (const tab of allTabs) {
|
||||
for (const tab of allSignatureOptions) {
|
||||
const item = page.getByRole('option', { name: tab });
|
||||
|
||||
const isSelected = (await item.innerHTML()).includes('opacity-100');
|
||||
@@ -176,5 +184,6 @@ test('[TEAMS]: check signature modes work for templates', async ({ page }) => {
|
||||
expect(document?.documentMeta?.typedSignatureEnabled).toEqual(tabs.includes('Type'));
|
||||
expect(document?.documentMeta?.uploadSignatureEnabled).toEqual(tabs.includes('Upload'));
|
||||
expect(document?.documentMeta?.drawSignatureEnabled).toEqual(tabs.includes('Draw'));
|
||||
expect(document?.documentMeta?.qrSignatureEnabled).toEqual(tabs.includes('QR code'));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -152,6 +152,7 @@ test.describe('AutoSave Settings Step - Templates', () => {
|
||||
expect(retrievedTemplate.templateMeta?.drawSignatureEnabled).toBe(false);
|
||||
expect(retrievedTemplate.templateMeta?.typedSignatureEnabled).toBe(false);
|
||||
expect(retrievedTemplate.templateMeta?.uploadSignatureEnabled).toBe(true);
|
||||
expect(retrievedTemplate.templateMeta?.qrSignatureEnabled).toBe(true);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { formatPath, NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import type { ClientResponse, InferRequestType } from 'hono/client';
|
||||
import { hc } from 'hono/client';
|
||||
@@ -36,8 +36,6 @@ type TPasskeySignin = InferRequestType<AuthClientType['passkey']['authorize']['$
|
||||
export class AuthClient {
|
||||
public client: AuthClientType;
|
||||
|
||||
private signOutredirectPath: string = '/signin';
|
||||
|
||||
constructor(options: { baseUrl: string }) {
|
||||
this.client = hc<AuthAppType>(options.baseUrl);
|
||||
}
|
||||
@@ -45,7 +43,7 @@ export class AuthClient {
|
||||
public async signOut({ redirectPath }: { redirectPath?: string } = {}) {
|
||||
await this.client.signout.$post();
|
||||
|
||||
window.location.href = redirectPath ?? this.signOutredirectPath;
|
||||
window.location.href = redirectPath ?? formatPath('/signin');
|
||||
}
|
||||
|
||||
public async signOutAllSessions() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { formatPath, NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import {
|
||||
isDisposableEmail,
|
||||
isEmailDomainAllowedForSignup,
|
||||
@@ -121,7 +121,7 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
|
||||
|
||||
// Check if signups are disabled for this provider.
|
||||
if (!isSignupEnabledForProvider(clientOptions.id as 'google' | 'microsoft' | 'oidc')) {
|
||||
const errorUrl = new URL('/signin', NEXT_PUBLIC_WEBAPP_URL());
|
||||
const errorUrl = new URL(formatPath('/signin'), NEXT_PUBLIC_WEBAPP_URL());
|
||||
|
||||
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisabled);
|
||||
|
||||
@@ -130,7 +130,7 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
|
||||
|
||||
// Check domain restriction for new SSO users.
|
||||
if (!isEmailDomainAllowedForSignup(email)) {
|
||||
const errorUrl = new URL('/signin', NEXT_PUBLIC_WEBAPP_URL());
|
||||
const errorUrl = new URL(formatPath('/signin'), NEXT_PUBLIC_WEBAPP_URL());
|
||||
|
||||
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisabled);
|
||||
|
||||
@@ -141,7 +141,7 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
|
||||
const additionalBlockedDomains = await getEmailBlocklistDomains();
|
||||
|
||||
if (isDisposableEmail(email, additionalBlockedDomains)) {
|
||||
const errorUrl = new URL('/signin', NEXT_PUBLIC_WEBAPP_URL());
|
||||
const errorUrl = new URL(formatPath('/signin'), NEXT_PUBLIC_WEBAPP_URL());
|
||||
|
||||
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisposableEmail);
|
||||
|
||||
@@ -213,15 +213,18 @@ export const validateOauth = async (options: HandleOAuthCallbackUrlOptions) => {
|
||||
// eslint-disable-next-line prefer-const
|
||||
let [redirectState, redirectPath] = storedRedirectPath.split(' ');
|
||||
|
||||
// The sub-path aware root, e.g. "/" or "/ESign/".
|
||||
const defaultRedirectPath = formatPath('/');
|
||||
|
||||
if (redirectState !== storedState || !redirectPath) {
|
||||
redirectPath = '/';
|
||||
redirectPath = defaultRedirectPath;
|
||||
}
|
||||
|
||||
if (!isValidReturnTo(redirectPath)) {
|
||||
redirectPath = '/';
|
||||
redirectPath = defaultRedirectPath;
|
||||
}
|
||||
|
||||
redirectPath = normalizeReturnTo(redirectPath) || '/';
|
||||
redirectPath = normalizeReturnTo(redirectPath) || defaultRedirectPath;
|
||||
|
||||
const tokens = await oAuthClient.validateAuthorizationCode(token_endpoint, code, storedCodeVerifier);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { sendOrganisationAccountLinkConfirmationEmail } from '@documenso/ee/server-only/lib/send-organisation-account-link-confirmation-email';
|
||||
import { formatPath } from '@documenso/lib/constants/app';
|
||||
import { isDisposableEmail, isSignupEnabledForProvider } from '@documenso/lib/constants/auth';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { getEmailBlocklistDomains } from '@documenso/lib/server-only/site-settings/get-email-blocklist-domains';
|
||||
@@ -56,7 +57,7 @@ export const handleOAuthOrganisationCallbackUrl = async (options: HandleOAuthOrg
|
||||
if (existingAccount) {
|
||||
await onAuthorize({ userId: existingAccount.user.id }, c);
|
||||
|
||||
return c.redirect(`/o/${orgUrl}`, 302);
|
||||
return c.redirect(formatPath(`/o/${orgUrl}`), 302);
|
||||
}
|
||||
|
||||
let userToLink = await prisma.user.findFirst({
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
|
||||
/**
|
||||
* Derive the default redirect target ("/" at the root, "/ESign/" when served under a sub-path).
|
||||
*/
|
||||
const getDefaultRedirect = () => {
|
||||
try {
|
||||
const pathname = new URL(NEXT_PUBLIC_WEBAPP_URL()).pathname.replace(/\/$/, '');
|
||||
return `${pathname}/`;
|
||||
} catch {
|
||||
return '/';
|
||||
}
|
||||
};
|
||||
|
||||
const getWebAppOrigin = () => {
|
||||
try {
|
||||
return new URL(NEXT_PUBLIC_WEBAPP_URL()).origin;
|
||||
} catch {
|
||||
return NEXT_PUBLIC_WEBAPP_URL();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle an optional redirect path.
|
||||
*/
|
||||
@@ -10,19 +30,20 @@ export const handleRequestRedirect = (redirectUrl?: string) => {
|
||||
|
||||
const url = new URL(redirectUrl, NEXT_PUBLIC_WEBAPP_URL());
|
||||
|
||||
if (url.origin !== NEXT_PUBLIC_WEBAPP_URL()) {
|
||||
window.location.href = '/';
|
||||
if (url.origin !== getWebAppOrigin()) {
|
||||
window.location.href = getDefaultRedirect();
|
||||
} else {
|
||||
window.location.href = redirectUrl;
|
||||
}
|
||||
};
|
||||
|
||||
export const handleSignInRedirect = (redirectUrl: string = '/') => {
|
||||
const url = new URL(redirectUrl, NEXT_PUBLIC_WEBAPP_URL());
|
||||
export const handleSignInRedirect = (redirectUrl?: string) => {
|
||||
const target = redirectUrl ?? getDefaultRedirect();
|
||||
const url = new URL(target, NEXT_PUBLIC_WEBAPP_URL());
|
||||
|
||||
if (url.origin !== NEXT_PUBLIC_WEBAPP_URL()) {
|
||||
window.location.href = '/';
|
||||
if (url.origin !== getWebAppOrigin()) {
|
||||
window.location.href = getDefaultRedirect();
|
||||
} else {
|
||||
window.location.href = redirectUrl;
|
||||
window.location.href = target;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"arctic": "^3.7.0",
|
||||
"hono": "^4.12.14",
|
||||
"luxon": "^3.7.2",
|
||||
"react": "^18",
|
||||
"react": "^19.2.7",
|
||||
"ts-pattern": "^5.9.0",
|
||||
"zod": "^3.25.76"
|
||||
}
|
||||
|
||||
@@ -12,7 +12,10 @@ export type GetLimitsOptions = {
|
||||
export const getLimits = async ({ headers, teamId }: GetLimitsOptions) => {
|
||||
const requestHeaders = headers ?? {};
|
||||
|
||||
const url = new URL('/api/limits', NEXT_PUBLIC_WEBAPP_URL());
|
||||
// Note: the path must be appended rather than passed as the `new URL()` path
|
||||
// argument, since a leading-slash path replaces the sub-path that
|
||||
// NEXT_PUBLIC_WEBAPP_URL may carry (e.g. https://host/ESign).
|
||||
const url = new URL(`${NEXT_PUBLIC_WEBAPP_URL()}/api/limits`);
|
||||
|
||||
if (teamId) {
|
||||
requestHeaders['team-id'] = teamId.toString();
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
export { Body } from '@react-email/body';
|
||||
export { Button } from '@react-email/button';
|
||||
export { Column } from '@react-email/column';
|
||||
export { Container } from '@react-email/container';
|
||||
export { Font } from '@react-email/font';
|
||||
export { Head } from '@react-email/head';
|
||||
export { Heading } from '@react-email/heading';
|
||||
export { Hr } from '@react-email/hr';
|
||||
export { Html } from '@react-email/html';
|
||||
export { Img } from '@react-email/img';
|
||||
export { Link } from '@react-email/link';
|
||||
export { Preview } from '@react-email/preview';
|
||||
export { render } from '@react-email/render';
|
||||
export { Row } from '@react-email/row';
|
||||
export { Section } from '@react-email/section';
|
||||
export { Tailwind } from '@react-email/tailwind';
|
||||
export { Text } from '@react-email/text';
|
||||
export {
|
||||
Body,
|
||||
Button,
|
||||
Column,
|
||||
Container,
|
||||
Font,
|
||||
Head,
|
||||
Heading,
|
||||
Hr,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Row,
|
||||
render,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from 'react-email';
|
||||
|
||||
@@ -19,27 +19,9 @@
|
||||
"dependencies": {
|
||||
"@documenso/nodemailer-resend": "5.0.0",
|
||||
"@documenso/tailwind-config": "*",
|
||||
"@react-email/body": "0.2.0",
|
||||
"@react-email/button": "0.2.0",
|
||||
"@react-email/code-block": "0.2.0",
|
||||
"@react-email/code-inline": "0.0.5",
|
||||
"@react-email/column": "0.0.13",
|
||||
"@react-email/container": "0.0.15",
|
||||
"@react-email/font": "0.0.9",
|
||||
"@react-email/head": "0.0.12",
|
||||
"@react-email/heading": "0.0.15",
|
||||
"@react-email/hr": "0.0.11",
|
||||
"@react-email/html": "0.0.11",
|
||||
"@react-email/img": "0.0.11",
|
||||
"@react-email/link": "0.0.12",
|
||||
"@react-email/preview": "0.0.13",
|
||||
"@react-email/render": "2.0.0",
|
||||
"@react-email/row": "0.0.12",
|
||||
"@react-email/section": "0.0.16",
|
||||
"@react-email/tailwind": "^2.0.1",
|
||||
"@react-email/text": "0.1.5",
|
||||
"@react-email/render": "2.1.0",
|
||||
"nodemailer": "^9.0.0",
|
||||
"react-email": "^5.0.6",
|
||||
"react-email": "^6.9.0",
|
||||
"resend": "^6.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -8,7 +8,7 @@ type SaveRequest<T, R> = {
|
||||
export const useAutoSave = <T, R = void>(onSave: (data: T) => Promise<R>, options: { delay?: number } = {}) => {
|
||||
const { delay = 2000 } = options;
|
||||
|
||||
const saveTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
const saveTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||
const saveQueueRef = useRef<SaveRequest<T, R>[]>([]);
|
||||
const isProcessingRef = useRef(false);
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { EnvelopeType, Prisma, ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
|
||||
import type React from 'react';
|
||||
import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
|
||||
import { createContext, useCallback, useContext, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import type { TDocumentEmailSettings } from '../../types/document-email';
|
||||
@@ -107,7 +107,39 @@ export const EnvelopeEditorProvider = ({
|
||||
|
||||
const [_searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [envelope, _setEnvelope] = useState(initialEnvelope);
|
||||
/**
|
||||
* The envelope is kept in a ref-backed external store instead of useState so
|
||||
* that async consumers (debounced autosave callbacks, flushAutosave, resetForms)
|
||||
* can synchronously read the latest value via `getEnvelope`.
|
||||
*
|
||||
* React subscribes to the store through useSyncExternalStore, keeping renders in
|
||||
* sync without maintaining a separate copy of the state.
|
||||
*/
|
||||
const envelopeStoreRef = useRef(initialEnvelope);
|
||||
const envelopeStoreSubscribersRef = useRef(new Set<() => void>());
|
||||
|
||||
const subscribeToEnvelopeStore = useCallback((onStoreChange: () => void) => {
|
||||
envelopeStoreSubscribersRef.current.add(onStoreChange);
|
||||
|
||||
return () => {
|
||||
envelopeStoreSubscribersRef.current.delete(onStoreChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getEnvelope = useCallback(() => envelopeStoreRef.current, []);
|
||||
|
||||
const setEnvelope = useCallback((action: React.SetStateAction<TEditorEnvelope>) => {
|
||||
const next = typeof action === 'function' ? action(envelopeStoreRef.current) : action;
|
||||
|
||||
envelopeStoreRef.current = next;
|
||||
|
||||
for (const onStoreChange of envelopeStoreSubscribersRef.current) {
|
||||
onStoreChange();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const envelope = useSyncExternalStore(subscribeToEnvelopeStore, getEnvelope, getEnvelope);
|
||||
|
||||
const [autosaveError, setAutosaveError] = useState<boolean>(false);
|
||||
|
||||
const isCscMode = IS_INSTANCE_CSC_MODE();
|
||||
@@ -135,8 +167,6 @@ export const EnvelopeEditorProvider = ({
|
||||
};
|
||||
}, [isCscMode, providedEditorConfig]);
|
||||
|
||||
const envelopeRef = useRef(initialEnvelope);
|
||||
|
||||
const externalFlushCallbacksRef = useRef<Map<string, () => Promise<void>>>(new Map());
|
||||
const pendingMutationsRef = useRef<Set<Promise<unknown>>>(new Set());
|
||||
|
||||
@@ -156,14 +186,6 @@ export const EnvelopeEditorProvider = ({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setEnvelope: typeof _setEnvelope = (action) => {
|
||||
_setEnvelope((prev) => {
|
||||
const next = typeof action === 'function' ? action(prev) : action;
|
||||
envelopeRef.current = next;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const isEmbedded = editorConfig.embedded !== undefined;
|
||||
|
||||
const editorFields = useEditorFields({
|
||||
@@ -192,16 +214,18 @@ export const EnvelopeEditorProvider = ({
|
||||
try {
|
||||
let recipients: TEditorEnvelope['recipients'] = [];
|
||||
|
||||
const currentEnvelope = getEnvelope();
|
||||
|
||||
if (!isEmbedded) {
|
||||
const response = await setRecipientsMutation.mutateAsync({
|
||||
envelopeId: envelope.id,
|
||||
envelopeType: envelope.type,
|
||||
envelopeId: currentEnvelope.id,
|
||||
envelopeType: currentEnvelope.type,
|
||||
recipients: localRecipients,
|
||||
});
|
||||
|
||||
recipients = response.data;
|
||||
} else {
|
||||
recipients = mapLocalRecipientsToRecipients({ envelope, localRecipients });
|
||||
recipients = mapLocalRecipientsToRecipients({ envelope: currentEnvelope, localRecipients });
|
||||
}
|
||||
|
||||
setEnvelope((prev) => ({
|
||||
@@ -211,9 +235,7 @@ export const EnvelopeEditorProvider = ({
|
||||
}));
|
||||
|
||||
// Reset the local fields to ensure deleted recipient fields are removed.
|
||||
editorFields.resetForm(
|
||||
envelope.fields.filter((field) => recipients.some((recipient) => recipient.id === field.recipientId)),
|
||||
);
|
||||
editorFields.resetForm(getEnvelope().fields);
|
||||
|
||||
setAutosaveError(false);
|
||||
} catch (err) {
|
||||
@@ -248,16 +270,18 @@ export const EnvelopeEditorProvider = ({
|
||||
try {
|
||||
let fields: TSetEnvelopeFieldsResponse['data'] = [];
|
||||
|
||||
const currentEnvelope = getEnvelope();
|
||||
|
||||
if (!isEmbedded) {
|
||||
const response = await setFieldsMutation.mutateAsync({
|
||||
envelopeId: envelope.id,
|
||||
envelopeType: envelope.type,
|
||||
envelopeId: currentEnvelope.id,
|
||||
envelopeType: currentEnvelope.type,
|
||||
fields: localFields,
|
||||
});
|
||||
|
||||
fields = response.data;
|
||||
} else {
|
||||
fields = mapLocalFieldsToFields({ envelope, localFields });
|
||||
fields = mapLocalFieldsToFields({ envelope: currentEnvelope, localFields });
|
||||
}
|
||||
|
||||
setEnvelope((prev) => ({
|
||||
@@ -309,7 +333,7 @@ export const EnvelopeEditorProvider = ({
|
||||
try {
|
||||
const response = !isEmbedded
|
||||
? await updateEnvelopeMutation.mutateAsync({
|
||||
envelopeId: envelope.id,
|
||||
envelopeId: getEnvelope().id,
|
||||
data,
|
||||
meta,
|
||||
})
|
||||
@@ -467,12 +491,14 @@ export const EnvelopeEditorProvider = ({
|
||||
};
|
||||
|
||||
const resetForms = () => {
|
||||
const currentEnvelope = getEnvelope();
|
||||
|
||||
editorRecipients.resetForm({
|
||||
recipients: envelopeRef.current.recipients,
|
||||
documentMeta: envelopeRef.current.documentMeta,
|
||||
recipients: currentEnvelope.recipients,
|
||||
documentMeta: currentEnvelope.documentMeta,
|
||||
});
|
||||
|
||||
editorFields.resetForm(envelopeRef.current.fields);
|
||||
editorFields.resetForm(currentEnvelope.fields);
|
||||
};
|
||||
|
||||
const flushAutosave = async (): Promise<TEditorEnvelope> => {
|
||||
@@ -488,7 +514,7 @@ export const EnvelopeEditorProvider = ({
|
||||
await Promise.allSettled(Array.from(pendingMutationsRef.current));
|
||||
}
|
||||
|
||||
return envelopeRef.current;
|
||||
return getEnvelope();
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -6,6 +6,42 @@ export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT = Number(env('NEXT_PUBLIC_DOCUMENT_S
|
||||
|
||||
export const NEXT_PUBLIC_WEBAPP_URL = () => env('NEXT_PUBLIC_WEBAPP_URL') ?? 'http://localhost:3000';
|
||||
|
||||
/**
|
||||
* The sub-path the app is served under (no trailing slash), e.g. "/ESign".
|
||||
* Returns an empty string when served at root.
|
||||
*
|
||||
* Prefers the explicit NEXT_PUBLIC_BASE_PATH (which is the same value baked
|
||||
* into the Vite/React Router build). Falls back to the pathname of
|
||||
* NEXT_PUBLIC_WEBAPP_URL so the function still works in dev when the env
|
||||
* variable is unset.
|
||||
*
|
||||
* Avoid using this to build URLs, use {@link formatPath} instead. Reserve this
|
||||
* for cases where the raw prefix itself is needed, such as path comparisons.
|
||||
*/
|
||||
export const getBasePath = (): string => {
|
||||
const explicit = env('NEXT_PUBLIC_BASE_PATH');
|
||||
|
||||
if (explicit) {
|
||||
return explicit.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(NEXT_PUBLIC_WEBAPP_URL()).pathname.replace(/\/$/, '');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Prefix a root-relative path with the app's base path.
|
||||
*
|
||||
* `formatPath('/api/trpc')` -> `/ESign/api/trpc` under sub-path hosting,
|
||||
* `/api/trpc` otherwise.
|
||||
*/
|
||||
export const formatPath = (path: string): string => {
|
||||
return `${getBasePath()}${path}`;
|
||||
};
|
||||
|
||||
export const NEXT_PUBLIC_SIGNING_CONTACT_INFO = () =>
|
||||
env('NEXT_PUBLIC_SIGNING_CONTACT_INFO') ?? NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
@@ -17,6 +53,14 @@ export const NEXT_PRIVATE_INTERNAL_WEBAPP_URL = () =>
|
||||
|
||||
export const IS_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED') === 'true';
|
||||
|
||||
/**
|
||||
* Whether this instance is Documenso Cloud (managed SaaS).
|
||||
*
|
||||
* Used so we can show a different UI for Documenso Cloud and self-hosted instances since
|
||||
* there are things like billing, upsells, documenso links, etc that don't make sense for self-hosted instances.
|
||||
*/
|
||||
export const IS_DOCUMENSO_CLOUD = () => env('NEXT_PUBLIC_IS_DOCUMENSO_CLOUD') === 'true';
|
||||
|
||||
export const API_V2_BETA_URL = '/api/v2-beta';
|
||||
export const API_V2_URL = '/api/v2';
|
||||
|
||||
@@ -99,3 +143,5 @@ export const CSC_INSTANCE_SIGNATURE_LEVEL = (): TSignatureLevel => {
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
export const DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL = 'https://documen.so/enterprise-cta';
|
||||
|
||||
@@ -77,4 +77,11 @@ export const DOCUMENT_SIGNATURE_TYPES = {
|
||||
}),
|
||||
value: DocumentSignatureType.UPLOAD,
|
||||
},
|
||||
[DocumentSignatureType.QR]: {
|
||||
label: msg({
|
||||
message: `QR code`,
|
||||
context: `Sign using a mobile phone via QR code`,
|
||||
}),
|
||||
value: DocumentSignatureType.QR,
|
||||
},
|
||||
} satisfies Record<DocumentSignatureType, DocumentSignatureTypeData>;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user