Compare commits
19 Commits
v1.5.4-rc.
...
feat/add-d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c0918963d | ||
|
|
47916e3127 | ||
|
|
62dd737cf0 | ||
|
|
0d510cf280 | ||
|
|
f88faa43d9 | ||
|
|
074adccae2 | ||
|
|
844261c35c | ||
|
|
c0fb5caf9c | ||
|
|
b6c4cc9dc8 | ||
|
|
94da57704d | ||
|
|
1375946bc5 | ||
|
|
dc6aba58e6 | ||
|
|
364b9e03e1 | ||
|
|
5f3152b7db | ||
|
|
dc8d8433dd | ||
|
|
6781ff137e | ||
|
|
fa9099bc86 | ||
|
|
228ac90036 | ||
|
|
8d1b0adbb2 |
@@ -14,7 +14,6 @@ import { LocaleDate } from '~/components/formatter/locale-date';
|
||||
|
||||
import { AdminActions } from './admin-actions';
|
||||
import { RecipientItem } from './recipient-item';
|
||||
import { SuperDeleteDocumentDialog } from './super-delete-document-dialog';
|
||||
|
||||
type AdminDocumentDetailsPageProps = {
|
||||
params: {
|
||||
@@ -82,10 +81,6 @@ export default async function AdminDocumentDetailsPage({ params }: AdminDocument
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
{document && <SuperDeleteDocumentDialog document={document} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import type { Document } from '@documenso/prisma/client';
|
||||
import { TRPCClientError } from '@documenso/trpc/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type SuperDeleteDocumentDialogProps = {
|
||||
document: Document;
|
||||
};
|
||||
|
||||
export const SuperDeleteDocumentDialog = ({ document }: SuperDeleteDocumentDialogProps) => {
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const [reason, setReason] = useState('');
|
||||
|
||||
const { mutateAsync: deleteDocument, isLoading: isDeletingDocument } =
|
||||
trpc.admin.deleteDocument.useMutation();
|
||||
|
||||
const handleDeleteDocument = async () => {
|
||||
try {
|
||||
if (!reason) {
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteDocument({ id: document.id, reason });
|
||||
|
||||
toast({
|
||||
title: 'Document deleted',
|
||||
description: 'The Document has been deleted successfully.',
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
router.push('/admin/documents');
|
||||
} catch (err) {
|
||||
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
|
||||
toast({
|
||||
title: 'An error occurred',
|
||||
description: err.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: 'An unknown error occurred',
|
||||
variant: 'destructive',
|
||||
description:
|
||||
err.message ??
|
||||
'We encountered an unknown error while attempting to delete your document. Please try again later.',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<Alert
|
||||
className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row "
|
||||
variant="neutral"
|
||||
>
|
||||
<div>
|
||||
<AlertTitle>Delete Document</AlertTitle>
|
||||
<AlertDescription className="mr-2">
|
||||
Delete the document. This action is irreversible so proceed with caution.
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0">
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="destructive">Delete Document</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader className="space-y-4">
|
||||
<DialogTitle>Delete Document</DialogTitle>
|
||||
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription className="selection:bg-red-100">
|
||||
This action is not reversible. Please be certain.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</DialogHeader>
|
||||
|
||||
<div>
|
||||
<DialogDescription>To confirm, please enter the reason</DialogDescription>
|
||||
|
||||
<Input
|
||||
className="mt-2"
|
||||
type="text"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={handleDeleteDocument}
|
||||
loading={isDeletingDocument}
|
||||
variant="destructive"
|
||||
disabled={!reason}
|
||||
>
|
||||
{isDeletingDocument ? 'Deleting document...' : 'Delete Document'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -58,7 +58,6 @@ export const UsersDataTable = ({
|
||||
perPage,
|
||||
});
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedSearchString]);
|
||||
|
||||
const onPaginationChange = (page: number, perPage: number) => {
|
||||
|
||||
@@ -7,11 +7,9 @@ import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
|
||||
import { SigningDisclosure } from '~/components/general/signing-disclosure';
|
||||
import { truncateTitle } from '~/helpers/truncate-title';
|
||||
|
||||
export type SignDialogProps = {
|
||||
@@ -68,39 +66,23 @@ export const SignDialog = ({
|
||||
{isComplete ? 'Complete' : 'Next field'}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogTitle>
|
||||
<div className="text-center">
|
||||
<div className="text-foreground text-xl font-semibold">
|
||||
{role === RecipientRole.VIEWER && 'Complete Viewing'}
|
||||
{role === RecipientRole.SIGNER && 'Complete Signing'}
|
||||
{role === RecipientRole.APPROVER && 'Complete Approval'}
|
||||
{role === RecipientRole.VIEWER && 'Mark Document as Viewed'}
|
||||
{role === RecipientRole.SIGNER && 'Sign Document'}
|
||||
{role === RecipientRole.APPROVER && 'Approve Document'}
|
||||
</div>
|
||||
<div className="text-muted-foreground mx-auto w-4/5 py-2 text-center">
|
||||
{role === RecipientRole.VIEWER &&
|
||||
`You are about to finish viewing "${truncatedTitle}". Are you sure?`}
|
||||
{role === RecipientRole.SIGNER &&
|
||||
`You are about to finish signing "${truncatedTitle}". Are you sure?`}
|
||||
{role === RecipientRole.APPROVER &&
|
||||
`You are about to finish approving "${truncatedTitle}". Are you sure?`}
|
||||
</div>
|
||||
</DialogTitle>
|
||||
|
||||
<div className="text-muted-foreground max-w-[50ch]">
|
||||
{role === RecipientRole.VIEWER && (
|
||||
<span>
|
||||
You are about to complete viewing "{truncatedTitle}".
|
||||
<br /> Are you sure?
|
||||
</span>
|
||||
)}
|
||||
{role === RecipientRole.SIGNER && (
|
||||
<span>
|
||||
You are about to complete signing "{truncatedTitle}".
|
||||
<br /> Are you sure?
|
||||
</span>
|
||||
)}
|
||||
{role === RecipientRole.APPROVER && (
|
||||
<span>
|
||||
You are about to complete approving "{truncatedTitle}".
|
||||
<br /> Are you sure?
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SigningDisclosure className="mt-4" />
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||
<Button
|
||||
|
||||
@@ -18,8 +18,6 @@ import { Label } from '@documenso/ui/primitives/label';
|
||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { SigningDisclosure } from '~/components/general/signing-disclosure';
|
||||
|
||||
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||
import { useRequiredSigningContext } from './provider';
|
||||
import { SigningFieldContainer } from './signing-field-container';
|
||||
@@ -202,8 +200,6 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SigningDisclosure />
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||
<Button
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
export default function SignatureDisclosure() {
|
||||
return (
|
||||
<div>
|
||||
<article className="prose">
|
||||
<h1>Electronic Signature Disclosure</h1>
|
||||
|
||||
<h2>Welcome</h2>
|
||||
<p>
|
||||
Thank you for using Documenso to perform your electronic document signing. The purpose of
|
||||
this disclosure is to inform you about the process, legality, and your rights regarding
|
||||
the use of electronic signatures on our platform. By opting to use an electronic
|
||||
signature, you are agreeing to the terms and conditions outlined below.
|
||||
</p>
|
||||
|
||||
<h2>Acceptance and Consent</h2>
|
||||
<p>
|
||||
When you use our platform to affix your electronic signature to documents, you are
|
||||
consenting to do so under the Electronic Signatures in Global and National Commerce Act
|
||||
(E-Sign Act) and other applicable laws. This action indicates your agreement to use
|
||||
electronic means to sign documents and receive notifications.
|
||||
</p>
|
||||
|
||||
<h2>Legality of Electronic Signatures</h2>
|
||||
<p>
|
||||
An electronic signature provided by you on our platform, achieved through clicking through
|
||||
to a document and entering your name, or any other electronic signing method we provide,
|
||||
is legally binding. It carries the same weight and enforceability as a manual signature
|
||||
written with ink on paper.
|
||||
</p>
|
||||
|
||||
<h2>System Requirements</h2>
|
||||
<p>To use our electronic signature service, you must have access to:</p>
|
||||
<ul>
|
||||
<li>A stable internet connection</li>
|
||||
<li>An email account</li>
|
||||
<li>A device capable of accessing, opening, and reading documents</li>
|
||||
<li>A means to print or download documents for your records</li>
|
||||
</ul>
|
||||
|
||||
<h2>Electronic Delivery of Documents</h2>
|
||||
<p>
|
||||
All documents related to the electronic signing process will be provided to you
|
||||
electronically through our platform or via email. It is your responsibility to ensure that
|
||||
your email address is current and that you can receive and open our emails.
|
||||
</p>
|
||||
|
||||
<h2>Consent to Electronic Transactions</h2>
|
||||
<p>
|
||||
By using the electronic signature feature, you are consenting to conduct transactions and
|
||||
receive disclosures electronically. You acknowledge that your electronic signature on
|
||||
documents is binding and that you accept the terms outlined in the documents you are
|
||||
signing.
|
||||
</p>
|
||||
|
||||
<h2>Withdrawing Consent</h2>
|
||||
<p>
|
||||
You have the right to withdraw your consent to use electronic signatures at any time
|
||||
before completing the signing process. To withdraw your consent, please contact the sender
|
||||
of the document. In failing to contact the sender you may reach out to{' '}
|
||||
<a href="mailto:support@documenso.com">support@documenso.com</a> for assistance. Be aware
|
||||
that withdrawing consent may delay or halt the completion of the related transaction or
|
||||
service.
|
||||
</p>
|
||||
|
||||
<h2>Updating Your Information</h2>
|
||||
<p>
|
||||
It is crucial to keep your contact information, especially your email address, up to date
|
||||
with us. Please notify us immediately of any changes to ensure that you continue to
|
||||
receive all necessary communications.
|
||||
</p>
|
||||
|
||||
<h2>Retention of Documents</h2>
|
||||
<p>
|
||||
After signing a document electronically, you will be provided the opportunity to view,
|
||||
download, and print the document for your records. It is highly recommended that you
|
||||
retain a copy of all electronically signed documents for your personal records. We will
|
||||
also retain a copy of the signed document for our records however we may not be able to
|
||||
provide you with a copy of the signed document after a certain period of time.
|
||||
</p>
|
||||
|
||||
<h2>Acknowledgment</h2>
|
||||
<p>
|
||||
By proceeding to use the electronic signature service provided by Documenso, you affirm
|
||||
that you have read and understood this disclosure. You agree to all terms and conditions
|
||||
related to the use of electronic signatures and electronic transactions as outlined
|
||||
herein.
|
||||
</p>
|
||||
|
||||
<h2>Contact Information</h2>
|
||||
<p>
|
||||
For any questions regarding this disclosure, electronic signatures, or any related
|
||||
process, please contact us at:{' '}
|
||||
<a href="mailto:support@documenso.com">support@documenso.com</a>
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<div className="mt-8">
|
||||
<Button asChild>
|
||||
<Link href="/documents">Back to Documents</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
|
||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import { Recipient } from '@documenso/prisma/client';
|
||||
|
||||
import { StackAvatar } from './stack-avatar';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SVGAttributes } from 'react';
|
||||
import { SVGAttributes } from 'react';
|
||||
|
||||
export type LogoProps = SVGAttributes<SVGSVGElement>;
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { HTMLAttributes } from 'react';
|
||||
|
||||
import { Globe, Lock } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
|
||||
|
||||
import type { TemplateType as TemplateTypePrisma } from '@documenso/prisma/client';
|
||||
import { TemplateType as TemplateTypePrisma } from '@documenso/prisma/client';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
type TemplateTypeIcon = {
|
||||
|
||||
@@ -47,9 +47,12 @@ export const ViewRecoveryCodesDialog = () => {
|
||||
data: recoveryCodes,
|
||||
mutate,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation();
|
||||
|
||||
// error?.data?.code
|
||||
|
||||
const viewRecoveryCodesForm = useForm<TViewRecoveryCodesForm>({
|
||||
defaultValues: {
|
||||
token: '',
|
||||
|
||||
@@ -55,8 +55,11 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
|
||||
});
|
||||
|
||||
const isSubmitting = form.formState.isSubmitting;
|
||||
const hasTwoFactorAuthentication = user.twoFactorEnabled;
|
||||
|
||||
const { mutateAsync: updateProfile } = trpc.profile.updateProfile.useMutation();
|
||||
const { mutateAsync: deleteAccount, isLoading: isDeletingAccount } =
|
||||
trpc.profile.deleteAccount.useMutation();
|
||||
|
||||
const onFormSubmit = async ({ name, signature }: TProfileFormSchema) => {
|
||||
try {
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
export type SigningDisclosureProps = HTMLAttributes<HTMLParagraphElement>;
|
||||
|
||||
export const SigningDisclosure = ({ className, ...props }: SigningDisclosureProps) => {
|
||||
return (
|
||||
<p className={cn('text-muted-foreground text-xs', className)} {...props}>
|
||||
By proceeding with your electronic signature, you acknowledge and consent that it will be used
|
||||
to sign the given document and holds the same legal validity as a handwritten signature. By
|
||||
completing the electronic signing process, you affirm your understanding and acceptance of
|
||||
these conditions.
|
||||
<span className="mt-2 block">
|
||||
Read the full{' '}
|
||||
<Link
|
||||
className="text-documenso-700 underline"
|
||||
href="/articles/signature-disclosure"
|
||||
target="_blank"
|
||||
>
|
||||
signature disclosure
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SVGAttributes } from 'react';
|
||||
import { SVGAttributes } from 'react';
|
||||
|
||||
export type BackgroundProps = Omit<SVGAttributes<SVGElement>, 'viewBox'>;
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ export const UserProfileSkeleton = ({ className, user, rows = 2 }: UserProfileSk
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="border-border bg-background text-muted-foreground inline-block max-w-full truncate rounded-md border px-2.5 py-1.5 text-sm lowercase">
|
||||
<div className="border-border bg-background text-muted-foreground inline-block max-w-full truncate rounded-md border px-2.5 py-1.5 text-sm">
|
||||
{baseUrl.host}/u/{user.url}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||
import type { ThemeProviderProps } from 'next-themes/dist/types';
|
||||
import { ThemeProviderProps } from 'next-themes/dist/types';
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
|
||||
@@ -1,25 +1,13 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import path from 'node:path';
|
||||
|
||||
import { getDocumentByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { getRecipientByEmail } from '@documenso/lib/server-only/recipient/get-recipient-by-email';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from './fixtures/authentication';
|
||||
|
||||
const getDocumentByToken = async (token: string) => {
|
||||
return await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
Recipient: {
|
||||
some: {
|
||||
token,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
test(`[PR-718]: should be able to create a document`, async ({ page }) => {
|
||||
await page.goto('/signin');
|
||||
|
||||
@@ -258,7 +246,7 @@ test('should be able to create, send and sign a document', async ({ page }) => {
|
||||
await page.waitForURL(`/sign/${token}`);
|
||||
|
||||
// Check if document has been viewed
|
||||
const { status } = await getDocumentByToken(token);
|
||||
const { status } = await getDocumentByToken({ token });
|
||||
expect(status).toBe(DocumentStatus.PENDING);
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
@@ -269,7 +257,7 @@ test('should be able to create, send and sign a document', async ({ page }) => {
|
||||
await expect(page.getByText('You have signed')).toBeVisible();
|
||||
|
||||
// Check if document has been signed
|
||||
const { status: completedStatus } = await getDocumentByToken(token);
|
||||
const { status: completedStatus } = await getDocumentByToken({ token });
|
||||
expect(completedStatus).toBe(DocumentStatus.COMPLETED);
|
||||
});
|
||||
|
||||
@@ -343,7 +331,7 @@ test('should be able to create, send with redirect url, sign a document and redi
|
||||
await page.waitForURL(`/sign/${token}`);
|
||||
|
||||
// Check if document has been viewed
|
||||
const { status } = await getDocumentByToken(token);
|
||||
const { status } = await getDocumentByToken({ token });
|
||||
expect(status).toBe(DocumentStatus.PENDING);
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
@@ -353,6 +341,6 @@ test('should be able to create, send with redirect url, sign a document and redi
|
||||
await page.waitForURL('https://documenso.com');
|
||||
|
||||
// Check if document has been signed
|
||||
const { status: completedStatus } = await getDocumentByToken(token);
|
||||
const { status: completedStatus } = await getDocumentByToken({ token });
|
||||
expect(completedStatus).toBe(DocumentStatus.COMPLETED);
|
||||
});
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { Section, Text } from '../components';
|
||||
import { TemplateDocumentImage } from './template-document-image';
|
||||
|
||||
export interface TemplateDocumentDeleteProps {
|
||||
reason: string;
|
||||
documentName: string;
|
||||
assetBaseUrl: string;
|
||||
}
|
||||
|
||||
export const TemplateDocumentDelete = ({
|
||||
reason,
|
||||
documentName,
|
||||
assetBaseUrl,
|
||||
}: TemplateDocumentDeleteProps) => {
|
||||
return (
|
||||
<>
|
||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||
|
||||
<Section>
|
||||
<Text className="text-primary mb-0 mt-6 text-left text-lg font-semibold">
|
||||
Your document has been deleted by an admin!
|
||||
</Text>
|
||||
|
||||
<Text className="mx-auto mb-6 mt-1 text-left text-base text-slate-400">
|
||||
"{documentName}" has been deleted by an admin.
|
||||
</Text>
|
||||
|
||||
<Text className="mx-auto mb-6 mt-1 text-left text-base text-slate-400">
|
||||
This document can not be recovered, if you would like to dispute the reason for future
|
||||
documents please contact support.
|
||||
</Text>
|
||||
|
||||
<Text className="mx-auto mt-1 text-left text-base text-slate-400">
|
||||
The reason provided for deletion is the following:
|
||||
</Text>
|
||||
|
||||
<Text className="mx-auto mb-6 mt-1 text-left text-base italic text-slate-400">
|
||||
{reason}
|
||||
</Text>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateDocumentDelete;
|
||||
@@ -1,66 +0,0 @@
|
||||
import config from '@documenso/tailwind-config';
|
||||
|
||||
import { Body, Container, Head, Hr, Html, Img, Preview, Section, Tailwind } from '../components';
|
||||
import {
|
||||
TemplateDocumentDelete,
|
||||
type TemplateDocumentDeleteProps,
|
||||
} from '../template-components/template-document-super-delete';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
|
||||
export type DocumentDeleteEmailTemplateProps = Partial<TemplateDocumentDeleteProps>;
|
||||
|
||||
export const DocumentSuperDeleteEmailTemplate = ({
|
||||
documentName = 'Open Source Pledge.pdf',
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
reason = 'Unknown',
|
||||
}: DocumentDeleteEmailTemplateProps) => {
|
||||
const previewText = `An admin has deleted your document "${documentName}".`;
|
||||
|
||||
const getAssetUrl = (path: string) => {
|
||||
return new URL(path, assetBaseUrl).toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: config.theme.extend.colors,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Body className="mx-auto my-auto bg-white font-sans">
|
||||
<Section>
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
<Img
|
||||
src={getAssetUrl('/static/logo.png')}
|
||||
alt="Documenso Logo"
|
||||
className="mb-4 h-6"
|
||||
/>
|
||||
|
||||
<TemplateDocumentDelete
|
||||
reason={reason}
|
||||
documentName={documentName}
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
/>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
<Hr className="mx-auto mt-12 max-w-xl" />
|
||||
|
||||
<Container className="mx-auto max-w-xl">
|
||||
<TemplateFooter />
|
||||
</Container>
|
||||
</Section>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentSuperDeleteEmailTemplate;
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { nanoid } from 'nanoid';
|
||||
import path from 'node:path';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { PDFDocument, PDFSignature, rectangle } from 'pdf-lib';
|
||||
|
||||
import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
@@ -15,9 +15,7 @@ import { signPdf } from '@documenso/signing';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { getFile } from '../../universal/upload/get-file';
|
||||
import { putFile } from '../../universal/upload/put-file';
|
||||
import { flattenAnnotations } from '../pdf/flatten-annotations';
|
||||
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
|
||||
import { normalizeSignatureAppearances } from '../pdf/normalize-signature-appearances';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
import { sendCompletedEmail } from './send-completed-email';
|
||||
|
||||
@@ -93,10 +91,31 @@ export const sealDocument = async ({
|
||||
|
||||
const doc = await PDFDocument.load(pdfData);
|
||||
|
||||
// Normalize and flatten layers that could cause issues with the signature
|
||||
normalizeSignatureAppearances(doc);
|
||||
doc.getForm().flatten();
|
||||
flattenAnnotations(doc);
|
||||
const form = doc.getForm();
|
||||
|
||||
// Remove old signatures
|
||||
for (const field of form.getFields()) {
|
||||
if (field instanceof PDFSignature) {
|
||||
field.acroField.getWidgets().forEach((widget) => {
|
||||
widget.ensureAP();
|
||||
|
||||
try {
|
||||
widget.getNormalAppearance();
|
||||
} catch (e) {
|
||||
const { context } = widget.dict;
|
||||
|
||||
const xobj = context.formXObject([rectangle(0, 0, 0, 0)]);
|
||||
|
||||
const streamRef = context.register(xobj);
|
||||
|
||||
widget.setNormalAppearance(streamRef);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Flatten the form to stop annotation layers from appearing above documenso fields
|
||||
form.flatten();
|
||||
|
||||
for (const field of fields) {
|
||||
await insertFieldInPDF(doc, field);
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { render } from '@documenso/email/render';
|
||||
import { DocumentSuperDeleteEmailTemplate } from '@documenso/email/templates/document-super-delete';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
|
||||
export interface SendDeleteEmailOptions {
|
||||
documentId: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOptions) => {
|
||||
const document = await prisma.document.findFirst({
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
include: {
|
||||
User: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
const { email, name } = document.User;
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
|
||||
const template = createElement(DocumentSuperDeleteEmailTemplate, {
|
||||
documentName: document.title,
|
||||
reason,
|
||||
assetBaseUrl,
|
||||
});
|
||||
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: email,
|
||||
name: name || '',
|
||||
},
|
||||
from: {
|
||||
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
|
||||
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
|
||||
},
|
||||
subject: 'Document Deleted!',
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
};
|
||||
@@ -1,85 +0,0 @@
|
||||
'use server';
|
||||
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { render } from '@documenso/email/render';
|
||||
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
|
||||
export type SuperDeleteDocumentOptions = {
|
||||
id: number;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDocumentOptions) => {
|
||||
const document = await prisma.document.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
include: {
|
||||
Recipient: true,
|
||||
documentMeta: true,
|
||||
User: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
const { status, User: user } = document;
|
||||
|
||||
// if the document is pending, send cancellation emails to all recipients
|
||||
if (status === DocumentStatus.PENDING && document.Recipient.length > 0) {
|
||||
await Promise.all(
|
||||
document.Recipient.map(async (recipient) => {
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
const template = createElement(DocumentCancelTemplate, {
|
||||
documentName: document.title,
|
||||
inviterName: user.name || undefined,
|
||||
inviterEmail: user.email,
|
||||
assetBaseUrl,
|
||||
});
|
||||
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: recipient.email,
|
||||
name: recipient.name,
|
||||
},
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: 'Document Cancelled',
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// always hard delete if deleted from admin
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
documentId: id,
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
type: 'HARD',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return await tx.document.delete({ where: { id } });
|
||||
});
|
||||
};
|
||||
@@ -1,63 +0,0 @@
|
||||
import { PDFAnnotation, PDFRef } from 'pdf-lib';
|
||||
import {
|
||||
PDFDict,
|
||||
type PDFDocument,
|
||||
PDFName,
|
||||
drawObject,
|
||||
popGraphicsState,
|
||||
pushGraphicsState,
|
||||
rotateInPlace,
|
||||
translate,
|
||||
} from 'pdf-lib';
|
||||
|
||||
export const flattenAnnotations = (document: PDFDocument) => {
|
||||
const pages = document.getPages();
|
||||
|
||||
for (const page of pages) {
|
||||
const annotations = page.node.Annots()?.asArray() ?? [];
|
||||
|
||||
annotations.forEach((annotation) => {
|
||||
if (!(annotation instanceof PDFRef)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const actualAnnotation = page.node.context.lookup(annotation);
|
||||
|
||||
if (!(actualAnnotation instanceof PDFDict)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pdfAnnot = PDFAnnotation.fromDict(actualAnnotation);
|
||||
|
||||
const appearance = pdfAnnot.ensureAP();
|
||||
|
||||
// Skip annotations without a normal appearance
|
||||
if (!appearance.has(PDFName.of('N'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalAppearance = pdfAnnot.getNormalAppearance();
|
||||
const rectangle = pdfAnnot.getRectangle();
|
||||
|
||||
if (!(normalAppearance instanceof PDFRef)) {
|
||||
// Not sure how to get the reference to the normal appearance yet
|
||||
// so we should skip this annotation for now
|
||||
return;
|
||||
}
|
||||
|
||||
const xobj = page.node.newXObject('FlatAnnot', normalAppearance);
|
||||
|
||||
const operators = [
|
||||
pushGraphicsState(),
|
||||
translate(rectangle.x, rectangle.y),
|
||||
...rotateInPlace({ ...rectangle, rotation: 0 }),
|
||||
drawObject(xobj),
|
||||
popGraphicsState(),
|
||||
].filter((op) => !!op);
|
||||
|
||||
page.pushOperators(...operators);
|
||||
|
||||
page.node.removeAnnot(annotation);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { PDFDocument } from 'pdf-lib';
|
||||
import { PDFSignature, rectangle } from 'pdf-lib';
|
||||
|
||||
export const normalizeSignatureAppearances = (document: PDFDocument) => {
|
||||
const form = document.getForm();
|
||||
|
||||
for (const field of form.getFields()) {
|
||||
if (field instanceof PDFSignature) {
|
||||
field.acroField.getWidgets().forEach((widget) => {
|
||||
widget.ensureAP();
|
||||
|
||||
try {
|
||||
widget.getNormalAppearance();
|
||||
} catch {
|
||||
const { context } = widget.dict;
|
||||
|
||||
const xobj = context.formXObject([rectangle(0, 0, 0, 0)]);
|
||||
|
||||
const streamRef = context.register(xobj);
|
||||
|
||||
widget.setNormalAppearance(streamRef);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -6,13 +6,7 @@
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "VerificationToken" ADD COLUMN "secondaryId" TEXT;
|
||||
|
||||
-- Set all null secondaryId fields to a uuid
|
||||
UPDATE "VerificationToken" SET "secondaryId" = gen_random_uuid()::text WHERE "secondaryId" IS NULL;
|
||||
|
||||
-- Restrict the VerificationToken to required
|
||||
ALTER TABLE "VerificationToken" ALTER COLUMN "secondaryId" SET NOT NULL;
|
||||
ALTER TABLE "VerificationToken" ADD COLUMN "secondaryId" TEXT NOT NULL;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "VerificationToken_secondaryId_key" ON "VerificationToken"("secondaryId");
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
PDFArray,
|
||||
PDFDict,
|
||||
PDFDocument,
|
||||
PDFHexString,
|
||||
PDFName,
|
||||
@@ -17,7 +16,7 @@ export type AddSigningPlaceholderOptions = {
|
||||
|
||||
export const addSigningPlaceholder = async ({ pdf }: AddSigningPlaceholderOptions) => {
|
||||
const doc = await PDFDocument.load(pdf);
|
||||
const [firstPage] = doc.getPages();
|
||||
const pages = doc.getPages();
|
||||
|
||||
const byteRange = PDFArray.withContext(doc.context);
|
||||
|
||||
@@ -26,71 +25,64 @@ export const addSigningPlaceholder = async ({ pdf }: AddSigningPlaceholderOption
|
||||
byteRange.push(PDFName.of(BYTE_RANGE_PLACEHOLDER));
|
||||
byteRange.push(PDFName.of(BYTE_RANGE_PLACEHOLDER));
|
||||
|
||||
const signature = doc.context.register(
|
||||
const signature = doc.context.obj({
|
||||
Type: 'Sig',
|
||||
Filter: 'Adobe.PPKLite',
|
||||
SubFilter: 'adbe.pkcs7.detached',
|
||||
ByteRange: byteRange,
|
||||
Contents: PDFHexString.fromText(' '.repeat(8192)),
|
||||
Reason: PDFString.of('Signed by Documenso'),
|
||||
M: PDFString.fromDate(new Date()),
|
||||
});
|
||||
|
||||
const signatureRef = doc.context.register(signature);
|
||||
|
||||
const widget = doc.context.obj({
|
||||
Type: 'Annot',
|
||||
Subtype: 'Widget',
|
||||
FT: 'Sig',
|
||||
Rect: [0, 0, 0, 0],
|
||||
V: signatureRef,
|
||||
T: PDFString.of('Signature1'),
|
||||
F: 4,
|
||||
P: pages[0].ref,
|
||||
});
|
||||
|
||||
const xobj = widget.context.formXObject([rectangle(0, 0, 0, 0)]);
|
||||
|
||||
const streamRef = widget.context.register(xobj);
|
||||
|
||||
widget.set(PDFName.of('AP'), widget.context.obj({ N: streamRef }));
|
||||
|
||||
const widgetRef = doc.context.register(widget);
|
||||
|
||||
let widgets = pages[0].node.get(PDFName.of('Annots'));
|
||||
|
||||
if (widgets instanceof PDFArray) {
|
||||
widgets.push(widgetRef);
|
||||
} else {
|
||||
const newWidgets = PDFArray.withContext(doc.context);
|
||||
|
||||
newWidgets.push(widgetRef);
|
||||
|
||||
pages[0].node.set(PDFName.of('Annots'), newWidgets);
|
||||
|
||||
widgets = pages[0].node.get(PDFName.of('Annots'));
|
||||
}
|
||||
|
||||
if (!widgets) {
|
||||
throw new Error('No widgets');
|
||||
}
|
||||
|
||||
pages[0].node.set(PDFName.of('Annots'), widgets);
|
||||
|
||||
doc.catalog.set(
|
||||
PDFName.of('AcroForm'),
|
||||
doc.context.obj({
|
||||
Type: 'Sig',
|
||||
Filter: 'Adobe.PPKLite',
|
||||
SubFilter: 'adbe.pkcs7.detached',
|
||||
ByteRange: byteRange,
|
||||
Contents: PDFHexString.fromText(' '.repeat(8192)),
|
||||
Reason: PDFString.of('Signed by Documenso'),
|
||||
M: PDFString.fromDate(new Date()),
|
||||
SigFlags: 3,
|
||||
Fields: [widgetRef],
|
||||
}),
|
||||
);
|
||||
|
||||
const widget = doc.context.register(
|
||||
doc.context.obj({
|
||||
Type: 'Annot',
|
||||
Subtype: 'Widget',
|
||||
FT: 'Sig',
|
||||
Rect: [0, 0, 0, 0],
|
||||
V: signature,
|
||||
T: PDFString.of('Signature1'),
|
||||
F: 4,
|
||||
P: firstPage.ref,
|
||||
AP: doc.context.obj({
|
||||
N: doc.context.register(doc.context.formXObject([rectangle(0, 0, 0, 0)])),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
let widgets: PDFArray;
|
||||
|
||||
try {
|
||||
widgets = firstPage.node.lookup(PDFName.of('Annots'), PDFArray);
|
||||
} catch {
|
||||
widgets = PDFArray.withContext(doc.context);
|
||||
|
||||
firstPage.node.set(PDFName.of('Annots'), widgets);
|
||||
}
|
||||
|
||||
widgets.push(widget);
|
||||
|
||||
let arcoForm: PDFDict;
|
||||
|
||||
try {
|
||||
arcoForm = doc.catalog.lookup(PDFName.of('AcroForm'), PDFDict);
|
||||
} catch {
|
||||
arcoForm = doc.context.obj({
|
||||
Fields: PDFArray.withContext(doc.context),
|
||||
});
|
||||
|
||||
doc.catalog.set(PDFName.of('AcroForm'), arcoForm);
|
||||
}
|
||||
|
||||
let fields: PDFArray;
|
||||
|
||||
try {
|
||||
fields = arcoForm.lookup(PDFName.of('Fields'), PDFArray);
|
||||
} catch {
|
||||
fields = PDFArray.withContext(doc.context);
|
||||
|
||||
arcoForm.set(PDFName.of('Fields'), fields);
|
||||
}
|
||||
|
||||
fields.push(widget);
|
||||
|
||||
arcoForm.set(PDFName.of('SigFlags'), PDFNumber.of(3));
|
||||
|
||||
return Buffer.from(await doc.save({ useObjectStreams: false }));
|
||||
};
|
||||
|
||||
@@ -4,16 +4,12 @@ import { findDocuments } from '@documenso/lib/server-only/admin/get-all-document
|
||||
import { updateRecipient } from '@documenso/lib/server-only/admin/update-recipient';
|
||||
import { updateUser } from '@documenso/lib/server-only/admin/update-user';
|
||||
import { sealDocument } from '@documenso/lib/server-only/document/seal-document';
|
||||
import { sendDeleteEmail } from '@documenso/lib/server-only/document/send-delete-email';
|
||||
import { superDeleteDocument } from '@documenso/lib/server-only/document/super-delete-document';
|
||||
import { upsertSiteSetting } from '@documenso/lib/server-only/site-settings/upsert-site-setting';
|
||||
import { deleteUser } from '@documenso/lib/server-only/user/delete-user';
|
||||
import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id';
|
||||
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
|
||||
import { adminProcedure, router } from '../trpc';
|
||||
import {
|
||||
ZAdminDeleteDocumentMutationSchema,
|
||||
ZAdminDeleteUserMutationSchema,
|
||||
ZAdminFindDocumentsQuerySchema,
|
||||
ZAdminResealDocumentMutationSchema,
|
||||
@@ -122,25 +118,4 @@ export const adminRouter = router({
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
deleteDocument: adminProcedure
|
||||
.input(ZAdminDeleteDocumentMutationSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, reason } = input;
|
||||
try {
|
||||
await sendDeleteEmail({ documentId: id, reason });
|
||||
|
||||
return await superDeleteDocument({
|
||||
id,
|
||||
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to delete the specified document. Please try again.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -48,10 +48,3 @@ export const ZAdminDeleteUserMutationSchema = z.object({
|
||||
});
|
||||
|
||||
export type TAdminDeleteUserMutationSchema = z.infer<typeof ZAdminDeleteUserMutationSchema>;
|
||||
|
||||
export const ZAdminDeleteDocumentMutationSchema = z.object({
|
||||
id: z.number().min(1),
|
||||
reason: z.string(),
|
||||
});
|
||||
|
||||
export type TAdminDeleteDocomentMutationSchema = z.infer<typeof ZAdminDeleteDocumentMutationSchema>;
|
||||
|
||||
@@ -153,10 +153,7 @@ export const authRouter = router({
|
||||
}),
|
||||
|
||||
createPasskeySigninOptions: procedure.mutation(async ({ ctx }) => {
|
||||
const cookies = parse(ctx.req.headers.cookie ?? '');
|
||||
|
||||
const sessionIdToken =
|
||||
cookies['__Host-next-auth.csrf-token'] || cookies['next-auth.csrf-token'];
|
||||
const sessionIdToken = parse(ctx.req.headers.cookie ?? '')['next-auth.csrf-token'];
|
||||
|
||||
if (!sessionIdToken) {
|
||||
throw new Error('Missing CSRF token');
|
||||
|
||||
@@ -91,6 +91,11 @@
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-feature-settings: 'rlig' 1, 'calt' 1;
|
||||
@@ -125,11 +130,11 @@
|
||||
background: rgb(100 116 139 / 0.5);
|
||||
}
|
||||
|
||||
/* Custom Swagger Dark Theme */
|
||||
/* Custom Swagger Dark Theme */
|
||||
.swagger-dark-theme .swagger-ui {
|
||||
filter: invert(88%) hue-rotate(180deg);
|
||||
}
|
||||
|
||||
.swagger-dark-theme .swagger-ui .microlight {
|
||||
filter: invert(100%) hue-rotate(180deg);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user