Compare commits

...

12 Commits

Author SHA1 Message Date
02f36a0eb8 fix: open dialog 2025-07-11 15:31:38 +00:00
ff1343b422 fix: preserve params when you are not signed in 2025-07-11 13:01:07 +00:00
91a38045f7 Merge branch 'main' into feat/billing-page 2025-07-11 11:58:34 +00:00
e04e5a7d2e feat: add direct marketing to billing subscription flow 2025-07-11 11:48:49 +00:00
49c70fc8a8 chore: update docs 2025-07-11 17:02:10 +10:00
4195a871ce chore: update gitginore (#1894) 2025-07-11 13:16:51 +10:00
37ed5ad222 v1.12.2-rc.1 2025-07-11 12:55:56 +10:00
d6c11bd195 fix: sign-able readonly fields (#1885) 2025-07-10 16:47:36 +10:00
cb73d21e05 chore: api tests (#1856) 2025-07-10 12:56:46 +10:00
106f796fea fix: readonly field styling (#1887)
Changes:
- Updating styling of read only fields
- Removed truncation for fields and used overflow hidden instead
2025-07-10 12:35:18 +10:00
9917def0ca v1.12.2-rc.0 2025-07-03 10:31:22 +10:00
cdb9b9ee03 chore: add certificate error logs (#1875)
Add certificate logs
2025-07-03 10:13:12 +10:00
23 changed files with 2248 additions and 81 deletions

6
.gitignore vendored
View File

@ -52,4 +52,8 @@ yarn-error.log*
!.vscode/extensions.json
# logs
logs.json
logs.json
# claude
.claude
CLAUDE.md

View File

@ -33,7 +33,7 @@ Our new API V2 supports the following typed SDKs:
<Callout type="info">
For the staging API, please use the following base URL:
`https://stg-app.documenso.dev/api/v2-beta/`
`https://stg-app.documenso.com/api/v2-beta/`
</Callout>
🚀 [V2 Announcement](https://documen.so/sdk-blog)

View File

@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
@ -44,12 +44,22 @@ const MotionCard = motion(Card);
export type BillingPlansProps = {
plans: InternalClaimPlans;
selectedPlan?: string | null;
selectedCycle?: 'monthly' | 'yearly' | null;
isFromPricingPage?: boolean;
};
export const BillingPlans = ({ plans }: BillingPlansProps) => {
export const BillingPlans = ({
plans,
selectedPlan,
selectedCycle,
isFromPricingPage,
}: BillingPlansProps) => {
const isMounted = useIsMounted();
const [interval, setInterval] = useState<'monthlyPrice' | 'yearlyPrice'>('yearlyPrice');
const [interval, setInterval] = useState<'monthlyPrice' | 'yearlyPrice'>(
selectedCycle === 'monthly' ? 'monthlyPrice' : 'yearlyPrice',
);
const pricesToDisplay = useMemo(() => {
const prices = [];
@ -85,56 +95,65 @@ export const BillingPlans = ({ plans }: BillingPlansProps) => {
<div className="mt-8 grid gap-8 lg:grid-cols-2 2xl:grid-cols-3">
<AnimatePresence mode="wait">
{pricesToDisplay.map((price) => (
<MotionCard
key={price.id}
initial={{ opacity: isMounted ? 0 : 1, y: isMounted ? 20 : 0 }}
animate={{ opacity: 1, y: 0, transition: { duration: 0.3 } }}
exit={{ opacity: 0, transition: { duration: 0.3 } }}
>
<CardContent className="flex h-full flex-col p-6">
<CardTitle>{price.product.name}</CardTitle>
{pricesToDisplay.map((price) => {
const planId = price.claim.toLowerCase().replace('claim_', '');
const isSelected = selectedPlan && planId === selectedPlan?.toLowerCase();
<div className="text-muted-foreground mt-2 text-lg font-medium">
{price.friendlyPrice + ' '}
<span className="text-xs">
{interval === 'monthlyPrice' ? (
<Trans>per month</Trans>
) : (
<Trans>per year</Trans>
)}
</span>
</div>
return (
<MotionCard
key={price.id}
initial={{ opacity: isMounted ? 0 : 1, y: isMounted ? 20 : 0 }}
animate={{ opacity: 1, y: 0, transition: { duration: 0.3 } }}
exit={{ opacity: 0, transition: { duration: 0.3 } }}
className={isSelected ? 'ring-primary ring-2' : ''}
>
<CardContent className="flex h-full flex-col p-6">
<CardTitle>{price.product.name}</CardTitle>
<div className="text-muted-foreground mt-1.5 text-sm">
{price.product.description}
</div>
{price.product.features && price.product.features.length > 0 && (
<div className="text-muted-foreground mt-4">
<div className="text-sm font-medium">Includes:</div>
<ul className="mt-1 divide-y text-sm">
{price.product.features.map((feature, index) => (
<li key={index} className="py-2">
{feature.name}
</li>
))}
</ul>
<div className="text-muted-foreground mt-2 text-lg font-medium">
{price.friendlyPrice + ' '}
<span className="text-xs">
{interval === 'monthlyPrice' ? (
<Trans>per month</Trans>
) : (
<Trans>per year</Trans>
)}
</span>
</div>
)}
<div className="flex-1" />
<div className="text-muted-foreground mt-1.5 text-sm">
{price.product.description}
</div>
<BillingDialog
priceId={price.id}
planName={price.product.name}
memberCount={price.memberCount}
claim={price.claim}
/>
</CardContent>
</MotionCard>
))}
{price.product.features && price.product.features.length > 0 && (
<div className="text-muted-foreground mt-4">
<div className="text-sm font-medium">Includes:</div>
<ul className="mt-1 divide-y text-sm">
{price.product.features.map((feature, index) => (
<li key={index} className="py-2">
{feature.name}
</li>
))}
</ul>
</div>
)}
<div className="flex-1" />
<BillingDialog
priceId={price.id}
planName={price.product.name}
memberCount={price.memberCount}
claim={price.claim}
isSelected={isSelected || false}
isFromPricingPage={isFromPricingPage}
interval={interval}
/>
</CardContent>
</MotionCard>
);
})}
</AnimatePresence>
</div>
</div>
@ -145,14 +164,26 @@ const BillingDialog = ({
priceId,
planName,
claim,
isSelected,
isFromPricingPage,
interval,
}: {
priceId: string;
planName: string;
memberCount: number;
claim: string;
isSelected?: boolean;
isFromPricingPage?: boolean;
interval: 'monthlyPrice' | 'yearlyPrice';
}) => {
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
if (isSelected && isFromPricingPage) {
setIsOpen(true);
}
}, [isSelected, isFromPricingPage]);
const { t } = useLingui();
const { toast } = useToast();
@ -227,11 +258,13 @@ const BillingDialog = ({
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Subscribe</Trans>
<Trans>
Subscribe to {planName} {interval === 'monthlyPrice' ? '(Monthly)' : '(Yearly)'}
</Trans>
</DialogTitle>
<DialogDescription>
<Trans>You are about to subscribe to the {planName}</Trans>
<Trans>Choose how to proceed with your subscription</Trans>
</DialogDescription>
</DialogHeader>

View File

@ -10,6 +10,7 @@ import { useRevalidator } from 'react-router';
import { P, match } from 'ts-pattern';
import { unsafe_useEffectOnce } from '@documenso/lib/client-only/hooks/use-effect-once';
import { AUTO_SIGNABLE_FIELD_TYPES } from '@documenso/lib/constants/autosign';
import { DocumentAuth } from '@documenso/lib/types/document-auth';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { trpc } from '@documenso/trpc/react';
@ -30,13 +31,6 @@ import { DocumentSigningDisclosure } from '~/components/general/document-signing
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
import { useRequiredDocumentSigningContext } from './document-signing-provider';
const AUTO_SIGNABLE_FIELD_TYPES: string[] = [
FieldType.NAME,
FieldType.INITIALS,
FieldType.EMAIL,
FieldType.DATE,
];
// The action auth types that are not allowed to be auto signed
//
// Reasoning: If the action auth is a passkey or 2FA, it's likely that the owner of the document

View File

@ -286,6 +286,7 @@ export const DocumentSigningCheckboxField = ({
className="h-3 w-3"
id={`checkbox-${field.id}-${item.id}`}
checked={checkedValues.includes(itemValue)}
disabled={isReadOnly}
onCheckedChange={() => handleCheckboxChange(item.value, item.id)}
/>
{!item.value.includes('empty-value-') && item.value && (
@ -314,7 +315,7 @@ export const DocumentSigningCheckboxField = ({
className="h-3 w-3"
id={`checkbox-${field.id}-${item.id}`}
checked={parsedCheckedValues.includes(itemValue)}
disabled={isLoading}
disabled={isLoading || isReadOnly}
onCheckedChange={() => void handleCheckboxOptionClick(item)}
/>
{!item.value.includes('empty-value-') && item.value && (

View File

@ -131,7 +131,12 @@ export const DocumentSigningFieldContainer = ({
return (
<div className={cn('[container-type:size]')}>
<FieldRootContainer color={RECIPIENT_COLOR_STYLES.green} field={field}>
<FieldRootContainer
color={
field.fieldMeta?.readOnly ? RECIPIENT_COLOR_STYLES.readOnly : RECIPIENT_COLOR_STYLES.green
}
field={field}
>
{!field.inserted && !loading && !readOnlyField && (
<button
type="submit"
@ -140,14 +145,6 @@ export const DocumentSigningFieldContainer = ({
/>
)}
{readOnlyField && (
<button className="bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 duration-200 group-hover:opacity-100">
<span className="bg-foreground/50 text-background rounded-xl p-2">
<Trans>Read only field</Trans>
</span>
</button>
)}
{type === 'Checkbox' && field.inserted && !loading && !readOnlyField && (
<button
className="absolute -bottom-10 flex items-center justify-evenly rounded-md border bg-gray-900 opacity-0 group-hover:opacity-100"

View File

@ -34,7 +34,7 @@ export const DocumentSigningFieldsInserted = ({
textAlign = 'left',
}: DocumentSigningFieldsInsertedProps) => {
return (
<div className="flex h-full w-full items-center">
<div className="flex h-full w-full items-center overflow-hidden">
<p
className={cn(
'text-foreground w-full text-left text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',

View File

@ -41,6 +41,7 @@ export const DocumentSigningRadioField = ({
const { recipient, targetSigner, isAssistantMode } = useDocumentSigningRecipientContext();
const parsedFieldMeta = ZRadioFieldMeta.parse(field.fieldMeta);
const isReadOnly = parsedFieldMeta.readOnly;
const values = parsedFieldMeta.values?.map((item) => ({
...item,
value: item.value.length > 0 ? item.value : `empty-value-${item.id}`,
@ -164,6 +165,7 @@ export const DocumentSigningRadioField = ({
value={item.value}
id={`option-${field.id}-${item.id}`}
checked={item.checked}
disabled={isReadOnly}
/>
{!item.value.includes('empty-value-') && item.value && (
<Label
@ -187,6 +189,7 @@ export const DocumentSigningRadioField = ({
value={item.value}
id={`option-${field.id}-${item.id}`}
checked={item.value === field.customText}
disabled={isReadOnly}
/>
{!item.value.includes('empty-value-') && item.value && (
<Label

View File

@ -262,9 +262,7 @@ export const DocumentSigningTextField = ({
{field.inserted && (
<DocumentSigningFieldsInserted textAlign={parsedFieldMeta?.textAlign}>
{field.customText.length < 20
? field.customText
: field.customText.substring(0, 20) + '...'}
{field.customText}
</DocumentSigningFieldsInserted>
)}

View File

@ -1,6 +1,7 @@
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useSearchParams } from 'react-router';
import type Stripe from 'stripe';
import { match } from 'ts-pattern';
@ -19,9 +20,14 @@ export function meta() {
export default function TeamsSettingBillingPage() {
const { _, i18n } = useLingui();
const [searchParams] = useSearchParams();
const organisation = useCurrentOrganisation();
const selectedPlan = searchParams.get('plan');
const selectedCycle = searchParams.get('cycle') as 'monthly' | 'yearly' | null;
const source = searchParams.get('source');
const { data: subscriptionQuery, isLoading: isLoadingSubscription } =
trpc.billing.subscription.get.useQuery({
organisationId: organisation.id,
@ -48,8 +54,21 @@ export default function TeamsSettingBillingPage() {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
(stripeSubscription?.items.data[0].price.product as Stripe.Product | undefined)?.name;
const isFromPricingPage = source === 'pricing';
return (
<div>
{isFromPricingPage && selectedPlan && !subscription && (
<div className="bg-muted mb-4 rounded-lg p-4">
<p className="text-sm">
<Trans>
Select a plan below to upgrade <strong>{organisation.name}</strong> to the{' '}
{selectedPlan} plan
</Trans>
</p>
</div>
)}
<div className="flex flex-row items-end justify-between">
<div>
<h3 className="text-2xl font-semibold">
@ -134,7 +153,14 @@ export default function TeamsSettingBillingPage() {
<hr className="my-4" />
{!subscription && canManageBilling && <BillingPlans plans={plans} />}
{!subscription && canManageBilling && (
<BillingPlans
plans={plans}
selectedPlan={selectedPlan}
selectedCycle={selectedCycle}
isFromPricingPage={source === 'pricing'}
/>
)}
<section className="mt-6">
<OrganisationBillingInvoicesTable

View File

@ -20,6 +20,8 @@ export function meta() {
export async function loader({ request }: Route.LoaderArgs) {
const { isAuthenticated } = await getOptionalSession(request);
const url = new URL(request.url);
const redirectParam = url.searchParams.get('redirect');
// SSR env variables.
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED;
@ -27,6 +29,9 @@ export async function loader({ request }: Route.LoaderArgs) {
const oidcProviderLabel = OIDC_PROVIDER_LABEL;
if (isAuthenticated) {
if (redirectParam) {
throw redirect(redirectParam);
}
throw redirect('/');
}
@ -34,11 +39,12 @@ export async function loader({ request }: Route.LoaderArgs) {
isGoogleSSOEnabled,
isOIDCSSOEnabled,
oidcProviderLabel,
redirectTo: redirectParam,
};
}
export default function SignIn({ loaderData }: Route.ComponentProps) {
const { isGoogleSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } = loaderData;
const { isGoogleSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel, redirectTo } = loaderData;
return (
<div className="w-screen max-w-lg px-4">
@ -56,6 +62,7 @@ export default function SignIn({ loaderData }: Route.ComponentProps) {
isGoogleSSOEnabled={isGoogleSSOEnabled}
isOIDCSSOEnabled={isOIDCSSOEnabled}
oidcProviderLabel={oidcProviderLabel}
returnTo={redirectTo || undefined}
/>
{env('NEXT_PUBLIC_DISABLE_SIGNUP') !== 'true' && (

View File

@ -0,0 +1,49 @@
import { redirect } from 'react-router';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { getOrganisations } from '@documenso/trpc/server/organisation-router/get-organisations';
import type { Route } from './+types/billing-redirect';
export async function loader({ request }: Route.LoaderArgs) {
const session = await getOptionalSession(request);
if (!session.isAuthenticated) {
const currentUrl = new URL(request.url);
const redirectParam = encodeURIComponent(currentUrl.pathname + currentUrl.search);
throw redirect(`/signin?redirect=${redirectParam}`);
}
const url = new URL(request.url);
const plan = url.searchParams.get('plan');
const cycle = url.searchParams.get('cycle');
const source = url.searchParams.get('source');
const queryParams = new URLSearchParams();
if (plan) {
queryParams.set('plan', plan);
}
if (cycle) {
queryParams.set('cycle', cycle);
}
if (source) {
queryParams.set('source', source);
}
const queryString = queryParams.toString() ? `?${queryParams.toString()}` : '';
const organisations = await getOrganisations({ userId: session.user.id });
if (isPersonalLayout(organisations)) {
return redirect(`/settings/billing${queryString}`);
}
const personalOrg = organisations.find((org) => org.type === 'PERSONAL') || organisations[0];
if (personalOrg) {
return redirect(`/o/${personalOrg.url}/settings/billing${queryString}`);
}
return redirect('/settings/profile');
}
export default function BillingRedirect() {
return null;
}

View File

@ -101,5 +101,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "1.12.1"
"version": "1.12.2-rc.1"
}

6
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "@documenso/root",
"version": "1.12.1",
"version": "1.12.2-rc.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@documenso/root",
"version": "1.12.1",
"version": "1.12.2-rc.1",
"workspaces": [
"apps/*",
"packages/*"
@ -89,7 +89,7 @@
},
"apps/remix": {
"name": "@documenso/remix",
"version": "1.12.1",
"version": "1.12.2-rc.1",
"dependencies": {
"@documenso/api": "*",
"@documenso/assets": "*",

View File

@ -1,6 +1,6 @@
{
"private": true,
"version": "1.12.1",
"version": "1.12.2-rc.1",
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev --filter=@documenso/remix",

View File

@ -0,0 +1,540 @@
import { expect, test } from '@playwright/test';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { prisma } from '@documenso/prisma';
import { FieldType } from '@documenso/prisma/client';
import {
seedBlankDocument,
seedCompletedDocument,
seedDraftDocument,
seedPendingDocumentWithFullFields,
} from '@documenso/prisma/seed/documents';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe.configure({
mode: 'parallel',
});
test.describe('Document Access API V1', () => {
test('should block unauthorized access to documents not owned by the user', async ({
request,
}) => {
const { user: userA, team: teamA } = await seedUser();
const { user: userB, team: teamB } = await seedUser();
const { token: tokenB } = await createApiToken({
userId: userB.id,
teamId: teamB.id,
tokenName: 'userB',
expiresIn: null,
});
const documentA = await seedBlankDocument(userA, teamA.id);
// User B cannot access User A's document
const resB = await request.get(`${WEBAPP_BASE_URL}/api/v1/documents/${documentA.id}`, {
headers: { Authorization: `Bearer ${tokenB}` },
});
expect(resB.ok()).toBeFalsy();
expect(resB.status()).toBe(404);
});
test('should block unauthorized access to document download endpoint', async ({ request }) => {
const { user: userA, team: teamA } = await seedUser();
const { user: userB, team: teamB } = await seedUser();
const { token: tokenB } = await createApiToken({
userId: userB.id,
teamId: teamB.id,
tokenName: 'userB',
expiresIn: null,
});
const documentA = await seedCompletedDocument(userA, teamA.id, ['test@example.com'], {
createDocumentOptions: { title: 'Document 1 - Completed' },
});
const resB = await request.get(`${WEBAPP_BASE_URL}/api/v1/documents/${documentA.id}/download`, {
headers: { Authorization: `Bearer ${tokenB}` },
});
expect(resB.ok()).toBeFalsy();
expect(resB.status()).toBe(500);
});
test('should block unauthorized access to document delete endpoint', async ({ request }) => {
const { user: userA, team: teamA } = await seedUser();
const { user: userB, team: teamB } = await seedUser();
const { token: tokenB } = await createApiToken({
userId: userB.id,
teamId: teamB.id,
tokenName: 'userB',
expiresIn: null,
});
const documentA = await seedBlankDocument(userA, teamA.id);
const resB = await request.delete(`${WEBAPP_BASE_URL}/api/v1/documents/${documentA.id}`, {
headers: { Authorization: `Bearer ${tokenB}` },
});
expect(resB.ok()).toBeFalsy();
expect(resB.status()).toBe(404);
});
test('should block unauthorized access to document send endpoint', async ({ request }) => {
const { user: userA, team: teamA } = await seedUser();
const { user: userB, team: teamB } = await seedUser();
const { token: tokenB } = await createApiToken({
userId: userB.id,
teamId: teamB.id,
tokenName: 'userB',
expiresIn: null,
});
const { document: documentA } = await seedPendingDocumentWithFullFields({
owner: userA,
recipients: ['test@example.com'],
teamId: teamA.id,
});
const resB = await request.post(`${WEBAPP_BASE_URL}/api/v1/documents/${documentA.id}/send`, {
headers: { Authorization: `Bearer ${tokenB}` },
data: {},
});
expect(resB.ok()).toBeFalsy();
expect(resB.status()).toBe(500);
});
test('should block unauthorized access to document resend endpoint', async ({ request }) => {
const { user: userA, team: teamA } = await seedUser();
const { user: userB, team: teamB } = await seedUser();
const { token: tokenB } = await createApiToken({
userId: userB.id,
teamId: teamB.id,
tokenName: 'userB',
expiresIn: null,
});
const { user: recipientUser } = await seedUser();
const { document: documentA, recipients } = await seedPendingDocumentWithFullFields({
owner: userA,
recipients: [recipientUser.email],
teamId: teamA.id,
});
const resB = await request.post(`${WEBAPP_BASE_URL}/api/v1/documents/${documentA.id}/resend`, {
headers: { Authorization: `Bearer ${tokenB}` },
data: {
recipients: [recipients[0].id],
},
});
expect(resB.ok()).toBeFalsy();
expect(resB.status()).toBe(500);
});
test('should block unauthorized access to document recipients endpoint', async ({ request }) => {
const { user: userA, team: teamA } = await seedUser();
const { user: userB, team: teamB } = await seedUser();
const { token: tokenB } = await createApiToken({
userId: userB.id,
teamId: teamB.id,
tokenName: 'userB',
expiresIn: null,
});
const documentA = await seedBlankDocument(userA, teamA.id);
const resB = await request.post(
`${WEBAPP_BASE_URL}/api/v1/documents/${documentA.id}/recipients`,
{
headers: { Authorization: `Bearer ${tokenB}` },
data: { name: 'Test', email: 'test@example.com' },
},
);
expect(resB.ok()).toBeFalsy();
expect(resB.status()).toBe(401);
});
test('should block unauthorized access to PATCH on recipients endpoint', async ({ request }) => {
const { user: userA, team: teamA } = await seedUser();
const { user: userB, team: teamB } = await seedUser();
const { token: tokenB } = await createApiToken({
userId: userB.id,
teamId: teamB.id,
tokenName: 'userB',
expiresIn: null,
});
const { user: userRecipient } = await seedUser();
const documentA = await seedDraftDocument(userA, teamA.id, [userRecipient.email]);
const recipient = await prisma.recipient.findFirst({
where: {
documentId: documentA.id,
email: userRecipient.email,
},
});
const patchRes = await request.patch(
`${WEBAPP_BASE_URL}/api/v1/documents/${documentA.id}/recipients/${recipient!.id}`,
{
headers: { Authorization: `Bearer ${tokenB}` },
data: {
name: 'New Name',
email: 'new@example.com',
role: 'SIGNER',
signingOrder: null,
authOptions: {
accessAuth: [],
actionAuth: [],
},
},
},
);
expect(patchRes.ok()).toBeFalsy();
expect(patchRes.status()).toBe(401);
});
test('should block unauthorized access to DELETE on recipients endpoint', async ({ request }) => {
const { user: userA, team: teamA } = await seedUser();
const { user: userB, team: teamB } = await seedUser();
const { token: tokenB } = await createApiToken({
userId: userB.id,
teamId: teamB.id,
tokenName: 'userB',
expiresIn: null,
});
const { user: userRecipient } = await seedUser();
const documentA = await seedDraftDocument(userA, teamA.id, [userRecipient.email]);
const recipient = await prisma.recipient.findFirst({
where: {
documentId: documentA.id,
email: userRecipient.email,
},
});
const deleteRes = await request.delete(
`${WEBAPP_BASE_URL}/api/v1/documents/${documentA.id}/recipients/${recipient!.id}`,
{
headers: { Authorization: `Bearer ${tokenB}` },
data: {},
},
);
expect(deleteRes.ok()).toBeFalsy();
expect(deleteRes.status()).toBe(401);
});
test('should block unauthorized access to document fields endpoint', async ({ request }) => {
const { user: userA, team: teamA } = await seedUser();
const { user: userB, team: teamB } = await seedUser();
const { token: tokenB } = await createApiToken({
userId: userB.id,
teamId: teamB.id,
tokenName: 'userB',
expiresIn: null,
});
const { user: recipientUser } = await seedUser();
const documentA = await seedDraftDocument(userA, teamA.id, [recipientUser.email]);
const documentRecipient = await prisma.recipient.findFirst({
where: {
documentId: documentA.id,
email: recipientUser.email,
},
});
const resB = await request.post(`${WEBAPP_BASE_URL}/api/v1/documents/${documentA.id}/fields`, {
headers: { Authorization: `Bearer ${tokenB}` },
data: {
recipientId: documentRecipient!.id,
type: 'SIGNATURE',
pageNumber: 1,
pageX: 1,
pageY: 1,
pageWidth: 1,
pageHeight: 1,
},
});
expect(resB.ok()).toBeFalsy();
expect(resB.status()).toBe(404);
});
test('should block unauthorized access to template get endpoint', async ({ request }) => {
const { user: userA, team: teamA } = await seedUser();
const { user: userB, team: teamB } = await seedUser();
const { token: tokenB } = await createApiToken({
userId: userB.id,
teamId: teamB.id,
tokenName: 'userB',
expiresIn: null,
});
const templateA = await seedBlankTemplate(userA, teamA.id);
const resB = await request.get(`${WEBAPP_BASE_URL}/api/v1/templates/${templateA.id}`, {
headers: { Authorization: `Bearer ${tokenB}` },
});
expect(resB.ok()).toBeFalsy();
expect(resB.status()).toBe(404);
});
test('should block unauthorized access to template delete endpoint', async ({ request }) => {
const { user: userA, team: teamA } = await seedUser();
const { user: userB, team: teamB } = await seedUser();
const { token: tokenB } = await createApiToken({
userId: userB.id,
teamId: teamB.id,
tokenName: 'userB',
expiresIn: null,
});
const templateA = await seedBlankTemplate(userA, teamA.id);
const resB = await request.delete(`${WEBAPP_BASE_URL}/api/v1/templates/${templateA.id}`, {
headers: { Authorization: `Bearer ${tokenB}` },
});
expect(resB.ok()).toBeFalsy();
expect(resB.status()).toBe(404);
});
test('should block unauthorized access to PATCH on fields endpoint', async ({ request }) => {
const { user: userA, team: teamA } = await seedUser();
const { user: userB, team: teamB } = await seedUser();
const { token: tokenB } = await createApiToken({
userId: userB.id,
teamId: teamB.id,
tokenName: 'userB',
expiresIn: null,
});
const { user: userRecipient } = await seedUser();
const documentA = await seedDraftDocument(userA, teamA.id, [userRecipient.email]);
const recipient = await prisma.recipient.findFirst({
where: {
documentId: documentA.id,
email: userRecipient.email,
},
});
const field = await prisma.field.create({
data: {
documentId: documentA.id,
recipientId: recipient!.id,
type: FieldType.TEXT,
page: 1,
positionX: 5,
positionY: 5,
width: 10,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'text',
label: 'Default Text Field',
},
},
});
const patchRes = await request.patch(
`${WEBAPP_BASE_URL}/api/v1/documents/${documentA.id}/fields/${field.id}`,
{
headers: { Authorization: `Bearer ${tokenB}` },
data: {
recipientId: recipient!.id,
type: FieldType.TEXT,
pageNumber: 1,
pageX: 99,
pageY: 99,
pageWidth: 99,
pageHeight: 99,
fieldMeta: {
type: 'text',
label: 'My new field',
},
},
},
);
expect(patchRes.ok()).toBeFalsy();
expect(patchRes.status()).toBe(401);
});
test('should block unauthorized access to DELETE on fields endpoint', async ({ request }) => {
const { user: userA, team: teamA } = await seedUser();
const { user: userB, team: teamB } = await seedUser();
const { token: tokenB } = await createApiToken({
userId: userB.id,
teamId: teamB.id,
tokenName: 'userB',
expiresIn: null,
});
const { user: userRecipient } = await seedUser();
const documentA = await seedDraftDocument(userA, teamA.id, [userRecipient.email]);
const recipient = await prisma.recipient.findFirst({
where: {
documentId: documentA.id,
email: userRecipient.email,
},
});
const field = await prisma.field.create({
data: {
documentId: documentA.id,
recipientId: recipient!.id,
type: FieldType.NUMBER,
page: 1,
positionX: 5,
positionY: 5,
width: 10,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'number',
label: 'Default Number Field',
},
},
});
const deleteRes = await request.delete(
`${WEBAPP_BASE_URL}/api/v1/documents/${documentA.id}/fields/${field.id}`,
{
headers: { Authorization: `Bearer ${tokenB}` },
data: {},
},
);
expect(deleteRes.ok()).toBeFalsy();
expect(deleteRes.status()).toBe(401);
});
test('should block unauthorized access to documents list endpoint', async ({ request }) => {
const { user: userA, team: teamA } = await seedUser();
const { user: userB, team: teamB } = await seedUser();
const { token: tokenB } = await createApiToken({
userId: userB.id,
teamId: teamB.id,
tokenName: 'userB',
expiresIn: null,
});
await seedBlankDocument(userA, teamA.id);
const resB = await request.get(`${WEBAPP_BASE_URL}/api/v1/documents`, {
headers: { Authorization: `Bearer ${tokenB}` },
});
const reqData = await resB.json();
expect(resB.ok()).toBeTruthy();
expect(resB.status()).toBe(200);
expect(reqData.documents.every((doc: { userId: number }) => doc.userId !== userA.id)).toBe(
true,
);
expect(reqData.documents.length).toBe(0);
expect(reqData.totalPages).toBe(0);
});
test('should block unauthorized access to templates list endpoint', async ({ request }) => {
const { user: userA, team: teamA } = await seedUser();
const { user: userB, team: teamB } = await seedUser();
const { token: tokenB } = await createApiToken({
userId: userB.id,
teamId: teamB.id,
tokenName: 'userB',
expiresIn: null,
});
await seedBlankTemplate(userA, teamA.id);
const resB = await request.get(`${WEBAPP_BASE_URL}/api/v1/templates`, {
headers: { Authorization: `Bearer ${tokenB}` },
});
const reqData = await resB.json();
expect(resB.ok()).toBeTruthy();
expect(resB.status()).toBe(200);
expect(reqData.templates.every((tpl: { userId: number }) => tpl.userId !== userA.id)).toBe(
true,
);
expect(reqData.templates.length).toBe(0);
expect(reqData.totalPages).toBe(0);
});
test('should block unauthorized access to create-document-from-template endpoint', async ({
request,
}) => {
const { user: userA, team: teamA } = await seedUser();
const { user: userB, team: teamB } = await seedUser();
const { token: tokenB } = await createApiToken({
userId: userB.id,
teamId: teamB.id,
tokenName: 'userB',
expiresIn: null,
});
const templateA = await seedBlankTemplate(userA, teamA.id);
const resB = await request.post(
`${WEBAPP_BASE_URL}/api/v1/templates/${templateA.id}/create-document`,
{
headers: {
Authorization: `Bearer ${tokenB}`,
'Content-Type': 'application/json',
},
data: {
title: 'Should not work',
recipients: [{ name: 'Test user', email: 'test@example.com' }],
meta: {
subject: 'Test',
message: 'Test',
timezone: 'UTC',
dateFormat: 'yyyy-MM-dd',
redirectUrl: 'https://example.com',
},
},
},
);
expect(resB.ok()).toBeFalsy();
expect(resB.status()).toBe(401);
});
});

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,100 @@
import { expect, test } from '@playwright/test';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import {
seedBlankDocument,
seedCompletedDocument,
seedPendingDocument,
} from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({
mode: 'parallel',
});
test.describe('Unauthorized Access to Documents', () => {
test('should block unauthorized access to the draft document page', async ({ page }) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id);
const { user: unauthorizedUser } = await seedUser();
await apiSignin({
page,
email: unauthorizedUser.email,
redirectPath: `/t/${team.url}/documents`,
});
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/documents/${document.id}`);
await expect(page.getByRole('heading', { name: 'Oops! Something went wrong.' })).toBeVisible();
});
test('should block unauthorized access to the draft document edit page', async ({ page }) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id);
const { user: unauthorizedUser } = await seedUser();
await apiSignin({
page,
email: unauthorizedUser.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/documents/${document.id}/edit`);
await expect(page.getByRole('heading', { name: 'Oops! Something went wrong.' })).toBeVisible();
});
test('should block unauthorized access to the pending document page', async ({ page }) => {
const { user, team } = await seedUser();
const { user: recipient } = await seedUser();
const document = await seedPendingDocument(user, team.id, [recipient]);
const { user: unauthorizedUser } = await seedUser();
await apiSignin({
page,
email: unauthorizedUser.email,
redirectPath: `/t/${team.url}/documents/${document.id}`,
});
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/documents/${document.id}`);
await expect(page.getByRole('heading', { name: 'Oops! Something went wrong.' })).toBeVisible();
});
test('should block unauthorized access to pending document edit page', async ({ page }) => {
const { user, team } = await seedUser();
const { user: recipient } = await seedUser();
const document = await seedPendingDocument(user, team.id, [recipient]);
const { user: unauthorizedUser } = await seedUser();
await apiSignin({
page,
email: unauthorizedUser.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/documents/${document.id}/edit`);
await expect(page.getByRole('heading', { name: 'Oops! Something went wrong.' })).toBeVisible();
});
test('should block unauthorized access to completed document page', async ({ page }) => {
const { user, team } = await seedUser();
const { user: recipient } = await seedUser();
const document = await seedCompletedDocument(user, team.id, [recipient]);
const { user: unauthorizedUser } = await seedUser();
await apiSignin({
page,
email: unauthorizedUser.email,
redirectPath: `/t/${team.url}/documents/${document.id}`,
});
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/documents/${document.id}`);
await expect(page.getByRole('heading', { name: 'Oops! Something went wrong.' })).toBeVisible();
});
});

View File

@ -0,0 +1,45 @@
import { expect, test } from '@playwright/test';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({
mode: 'parallel',
});
test.describe('Unauthorized Access to Templates', () => {
test('should block unauthorized access to the template page', async ({ page }) => {
const { user, team } = await seedUser();
const template = await seedBlankTemplate(user, team.id);
const { user: unauthorizedUser } = await seedUser();
await apiSignin({
page,
email: unauthorizedUser.email,
redirectPath: `/t/${team.url}/templates/${template.id}`,
});
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/templates/${template.id}`);
await expect(page.getByRole('heading', { name: 'Oops! Something went wrong.' })).toBeVisible();
});
test('should block unauthorized access to the template edit page', async ({ page }) => {
const { user, team } = await seedUser();
const template = await seedBlankTemplate(user, team.id);
const { user: unauthorizedUser } = await seedUser();
await apiSignin({
page,
email: unauthorizedUser.email,
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
});
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/templates/${template.id}/edit`);
await expect(page.getByRole('heading', { name: 'Oops! Something went wrong.' })).toBeVisible();
});
});

View File

@ -0,0 +1,8 @@
import { FieldType } from '@prisma/client';
export const AUTO_SIGNABLE_FIELD_TYPES: FieldType[] = [
FieldType.NAME,
FieldType.INITIALS,
FieldType.EMAIL,
FieldType.DATE,
];

View File

@ -117,7 +117,12 @@ export const sealDocument = async ({
? await getCertificatePdf({
documentId,
language: document.documentMeta?.language,
}).catch(() => null)
}).catch((e) => {
console.log('Failed to get certificate PDF');
console.error(e);
return null;
})
: null;
const doc = await PDFDocument.load(pdfData);

View File

@ -1,5 +1,6 @@
import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
import { DateTime } from 'luxon';
import { isDeepEqual } from 'remeda';
import { match } from 'ts-pattern';
import { validateCheckboxField } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
@ -10,6 +11,7 @@ import { validateTextField } from '@documenso/lib/advanced-fields-validation/val
import { fromCheckboxValue } from '@documenso/lib/universal/field-checkbox';
import { prisma } from '@documenso/prisma';
import { AUTO_SIGNABLE_FIELD_TYPES } from '../../constants/autosign';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
@ -205,6 +207,29 @@ export const signFieldWithToken = async ({
throw new Error('Typed signatures are not allowed. Please draw your signature');
}
if (field.fieldMeta?.readOnly && !AUTO_SIGNABLE_FIELD_TYPES.includes(field.type)) {
// !: This is a bit of a hack at the moment, readonly fields with default values
// !: should be inserted with their default value on document creation instead of
// !: this weird programattic approach. Until that's fixed though this will verify
// !: that the programmatic signed value is only that of its default.
const isAutomaticSigningValueValid = match(field.fieldMeta)
.with({ type: 'text' }, (meta) => customText === meta.text)
.with({ type: 'number' }, (meta) => customText === meta.value)
.with({ type: 'checkbox' }, (meta) =>
isDeepEqual(
fromCheckboxValue(customText ?? ''),
meta.values?.filter((v) => v.checked).map((v) => v.value) ?? [],
),
)
.with({ type: 'radio' }, (meta) => customText === meta.values?.find((v) => v.checked)?.value)
.with({ type: 'dropdown' }, (meta) => customText === meta.defaultValue)
.otherwise(() => false);
if (!isAutomaticSigningValueValid) {
throw new Error('Field is read only and only accepts its default value for signing.');
}
}
const assistant = recipient.role === RecipientRole.ASSISTANT ? recipient : undefined;
return await prisma.$transaction(async (tx) => {

View File

@ -15,6 +15,15 @@ export type RecipientColorStyles = {
// !: values of the declared variable to do all the background, border and shadow styles.
export const RECIPIENT_COLOR_STYLES = {
readOnly: {
base: 'ring-neutral-400',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: '',
comboxBoxTrigger:
'ring-2 ring-recipient-green shadow-[0_0_0_5px_hsl(var(--recipient-green)/10%),0_0_0_2px_hsl(var(--recipient-green)/60%),0_0_0_0.5px_hsl(var(--recipient-green))]',
comboxBoxItem: '',
},
green: {
base: 'ring-recipient-green hover:bg-recipient-green/30',
fieldItem: 'group/field-item rounded-[2px]',