mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
Compare commits
12 Commits
v1.12.1
...
feat/billi
| Author | SHA1 | Date | |
|---|---|---|---|
| 02f36a0eb8 | |||
| ff1343b422 | |||
| 91a38045f7 | |||
| e04e5a7d2e | |||
| 49c70fc8a8 | |||
| 4195a871ce | |||
| 37ed5ad222 | |||
| d6c11bd195 | |||
| cb73d21e05 | |||
| 106f796fea | |||
| 9917def0ca | |||
| cdb9b9ee03 |
6
.gitignore
vendored
6
.gitignore
vendored
@ -52,4 +52,8 @@ yarn-error.log*
|
||||
!.vscode/extensions.json
|
||||
|
||||
# logs
|
||||
logs.json
|
||||
logs.json
|
||||
|
||||
# claude
|
||||
.claude
|
||||
CLAUDE.md
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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' && (
|
||||
|
||||
49
apps/remix/app/routes/billing-redirect.tsx
Normal file
49
apps/remix/app/routes/billing-redirect.tsx
Normal 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;
|
||||
}
|
||||
@ -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
6
package-lock.json
generated
@ -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": "*",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
1323
packages/app-tests/e2e/api/v2/test-unauthorized-api-access.spec.ts
Normal file
1323
packages/app-tests/e2e/api/v2/test-unauthorized-api-access.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
8
packages/lib/constants/autosign.ts
Normal file
8
packages/lib/constants/autosign.ts
Normal 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,
|
||||
];
|
||||
@ -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);
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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]',
|
||||
|
||||
Reference in New Issue
Block a user