mirror of
https://github.com/documenso/documenso.git
synced 2025-11-11 13:02:31 +10:00
Merge branch 'main' of https://github.com/documenso/documenso into feat/sign-redirect
This commit is contained in:
@ -74,6 +74,8 @@ NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN=
|
|||||||
NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR=
|
NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR=
|
||||||
# OPTIONAL: The private key to use for DKIM signing.
|
# OPTIONAL: The private key to use for DKIM signing.
|
||||||
NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY=
|
NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY=
|
||||||
|
# OPTIONAL: Displays the maximum document upload limit to the user in MBs
|
||||||
|
NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT=5
|
||||||
|
|
||||||
# [[STRIPE]]
|
# [[STRIPE]]
|
||||||
NEXT_PRIVATE_STRIPE_API_KEY=
|
NEXT_PRIVATE_STRIPE_API_KEY=
|
||||||
|
|||||||
@ -158,6 +158,7 @@ export const SinglePlayerClient = () => {
|
|||||||
readStatus: 'OPENED',
|
readStatus: 'OPENED',
|
||||||
signingStatus: 'NOT_SIGNED',
|
signingStatus: 'NOT_SIGNED',
|
||||||
sendStatus: 'NOT_SENT',
|
sendStatus: 'NOT_SENT',
|
||||||
|
role: 'SIGNER',
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFileDrop = async (file: File) => {
|
const onFileDrop = async (file: File) => {
|
||||||
|
|||||||
@ -399,6 +399,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
|
|
||||||
<SignaturePad
|
<SignaturePad
|
||||||
|
disabled={isSubmitting}
|
||||||
className="aspect-video w-full rounded-md border"
|
className="aspect-video w-full rounded-md border"
|
||||||
defaultValue={signatureDataUrl || ''}
|
defaultValue={signatureDataUrl || ''}
|
||||||
onChange={setDraftSignatureDataUrl}
|
onChange={setDraftSignatureDataUrl}
|
||||||
|
|||||||
@ -45,6 +45,7 @@
|
|||||||
"sharp": "0.33.1",
|
"sharp": "0.33.1",
|
||||||
"ts-pattern": "^5.0.5",
|
"ts-pattern": "^5.0.5",
|
||||||
"typescript": "5.2.2",
|
"typescript": "5.2.2",
|
||||||
|
"ua-parser-js": "^1.0.37",
|
||||||
"uqr": "^0.1.2",
|
"uqr": "^0.1.2",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
@ -53,7 +54,8 @@
|
|||||||
"@types/luxon": "^3.3.1",
|
"@types/luxon": "^3.3.1",
|
||||||
"@types/node": "20.1.0",
|
"@types/node": "20.1.0",
|
||||||
"@types/react": "18.2.18",
|
"@types/react": "18.2.18",
|
||||||
"@types/react-dom": "18.2.7"
|
"@types/react-dom": "18.2.7",
|
||||||
|
"@types/ua-parser-js": "^0.7.39"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"next-auth": {
|
"next-auth": {
|
||||||
|
|||||||
@ -219,9 +219,9 @@ export const EditDocumentForm = ({
|
|||||||
<AddTitleFormPartial
|
<AddTitleFormPartial
|
||||||
key={recipients.length}
|
key={recipients.length}
|
||||||
documentFlow={documentFlow.title}
|
documentFlow={documentFlow.title}
|
||||||
|
document={document}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
document={document}
|
|
||||||
onSubmit={onAddTitleFormSubmit}
|
onSubmit={onAddTitleFormSubmit}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { Download, Edit, Pencil } from 'lucide-react';
|
import { CheckCircle, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||||
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
||||||
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||||
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -37,6 +37,7 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
|||||||
const isPending = row.status === DocumentStatus.PENDING;
|
const isPending = row.status === DocumentStatus.PENDING;
|
||||||
const isComplete = row.status === DocumentStatus.COMPLETED;
|
const isComplete = row.status === DocumentStatus.COMPLETED;
|
||||||
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||||
|
const role = recipient?.role;
|
||||||
|
|
||||||
const onDownloadClick = async () => {
|
const onDownloadClick = async () => {
|
||||||
try {
|
try {
|
||||||
@ -68,6 +69,11 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// TODO: Consider if want to keep this logic for hiding viewing for CC'ers
|
||||||
|
if (recipient?.role === RecipientRole.CC && isComplete === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return match({
|
return match({
|
||||||
isOwner,
|
isOwner,
|
||||||
isRecipient,
|
isRecipient,
|
||||||
@ -87,15 +93,32 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
|||||||
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
|
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
|
||||||
<Button className="w-32" asChild>
|
<Button className="w-32" asChild>
|
||||||
<Link href={`/sign/${recipient?.token}`}>
|
<Link href={`/sign/${recipient?.token}`}>
|
||||||
|
{match(role)
|
||||||
|
.with(RecipientRole.SIGNER, () => (
|
||||||
|
<>
|
||||||
<Pencil className="-ml-1 mr-2 h-4 w-4" />
|
<Pencil className="-ml-1 mr-2 h-4 w-4" />
|
||||||
Sign
|
Sign
|
||||||
|
</>
|
||||||
|
))
|
||||||
|
.with(RecipientRole.APPROVER, () => (
|
||||||
|
<>
|
||||||
|
<CheckCircle className="-ml-1 mr-2 h-4 w-4" />
|
||||||
|
Approve
|
||||||
|
</>
|
||||||
|
))
|
||||||
|
.otherwise(() => (
|
||||||
|
<>
|
||||||
|
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
|
||||||
|
View
|
||||||
|
</>
|
||||||
|
))}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
))
|
))
|
||||||
.with({ isPending: true, isSigned: true }, () => (
|
.with({ isPending: true, isSigned: true }, () => (
|
||||||
<Button className="w-32" disabled={true}>
|
<Button className="w-32" disabled={true}>
|
||||||
<Pencil className="-ml-1 mr-2 inline h-4 w-4" />
|
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
|
||||||
Sign
|
View
|
||||||
</Button>
|
</Button>
|
||||||
))
|
))
|
||||||
.with({ isComplete: true }, () => (
|
.with({ isComplete: true }, () => (
|
||||||
|
|||||||
@ -5,9 +5,11 @@ import { useState } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
CheckCircle,
|
||||||
Copy,
|
Copy,
|
||||||
Download,
|
Download,
|
||||||
Edit,
|
Edit,
|
||||||
|
EyeIcon,
|
||||||
Loader,
|
Loader,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Pencil,
|
Pencil,
|
||||||
@ -19,7 +21,7 @@ import { useSession } from 'next-auth/react';
|
|||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||||
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
||||||
import { DocumentStatus } from '@documenso/prisma/client';
|
import { DocumentStatus, RecipientRole } from '@documenso/prisma/client';
|
||||||
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||||
@ -105,12 +107,32 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||||
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
||||||
|
|
||||||
|
{recipient?.role !== RecipientRole.CC && (
|
||||||
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
|
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
|
||||||
<Link href={`/sign/${recipient?.token}`}>
|
<Link href={`/sign/${recipient?.token}`}>
|
||||||
|
{recipient?.role === RecipientRole.VIEWER && (
|
||||||
|
<>
|
||||||
|
<EyeIcon className="mr-2 h-4 w-4" />
|
||||||
|
View
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{recipient?.role === RecipientRole.SIGNER && (
|
||||||
|
<>
|
||||||
<Pencil className="mr-2 h-4 w-4" />
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
Sign
|
Sign
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{recipient?.role === RecipientRole.APPROVER && (
|
||||||
|
<>
|
||||||
|
<CheckCircle className="mr-2 h-4 w-4" />
|
||||||
|
Approve
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
<DropdownMenuItem disabled={!isOwner || isComplete} asChild>
|
<DropdownMenuItem disabled={!isOwner || isComplete} asChild>
|
||||||
<Link href={`/documents/${row.id}`}>
|
<Link href={`/documents/${row.id}`}>
|
||||||
|
|||||||
@ -74,7 +74,7 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
|
|||||||
<h1 className="text-4xl font-semibold">Documents</h1>
|
<h1 className="text-4xl font-semibold">Documents</h1>
|
||||||
|
|
||||||
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
|
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
|
||||||
<Tabs defaultValue={status} className="overflow-x-auto">
|
<Tabs value={status} className="overflow-x-auto">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
{[
|
{[
|
||||||
ExtendedDocumentStatus.INBOX,
|
ExtendedDocumentStatus.INBOX,
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { useSession } from 'next-auth/react';
|
|||||||
|
|
||||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
|
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||||
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
||||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||||
import { TRPCClientError } from '@documenso/trpc/client';
|
import { TRPCClientError } from '@documenso/trpc/client';
|
||||||
@ -96,6 +97,15 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onFileDropRejected = () => {
|
||||||
|
toast({
|
||||||
|
title: 'Your document failed to upload.',
|
||||||
|
description: `File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`,
|
||||||
|
duration: 5000,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('relative', className)}>
|
<div className={cn('relative', className)}>
|
||||||
<DocumentDropzone
|
<DocumentDropzone
|
||||||
@ -103,6 +113,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
|||||||
disabled={remaining.documents === 0 || !session?.user.emailVerified}
|
disabled={remaining.documents === 0 || !session?.user.emailVerified}
|
||||||
disabledMessage={disabledMessage}
|
disabledMessage={disabledMessage}
|
||||||
onDrop={onFileDrop}
|
onDrop={onFileDrop}
|
||||||
|
onDropRejected={onFileDropRejected}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="absolute -bottom-6 right-0">
|
<div className="absolute -bottom-6 right-0">
|
||||||
|
|||||||
@ -0,0 +1,23 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
import { UserSecurityActivityDataTable } from './user-security-activity-data-table';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Security activity',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SettingsSecurityActivityPage() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-2xl font-semibold">Security activity</h3>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
|
View all recent security activity related to your account.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr className="my-4" />
|
||||||
|
|
||||||
|
<UserSecurityActivityDataTable />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,156 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import type { DateTimeFormatOptions } from 'luxon';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import { UAParser } from 'ua-parser-js';
|
||||||
|
|
||||||
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
|
import { USER_SECURITY_AUDIT_LOG_MAP } from '@documenso/lib/constants/auth';
|
||||||
|
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
|
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||||
|
import { TableCell } from '@documenso/ui/primitives/table';
|
||||||
|
|
||||||
|
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||||
|
|
||||||
|
const dateFormat: DateTimeFormatOptions = {
|
||||||
|
...DateTime.DATETIME_SHORT,
|
||||||
|
hourCycle: 'h12',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UserSecurityActivityDataTable = () => {
|
||||||
|
const parser = new UAParser();
|
||||||
|
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
|
||||||
|
const parsedSearchParams = ZBaseTableSearchParamsSchema.parse(
|
||||||
|
Object.fromEntries(searchParams ?? []),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isLoading, isInitialLoading, isLoadingError } =
|
||||||
|
trpc.profile.findUserSecurityAuditLogs.useQuery(
|
||||||
|
{
|
||||||
|
page: parsedSearchParams.page,
|
||||||
|
perPage: parsedSearchParams.perPage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keepPreviousData: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const onPaginationChange = (page: number, perPage: number) => {
|
||||||
|
updateSearchParams({
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const results = data ?? {
|
||||||
|
data: [],
|
||||||
|
perPage: 10,
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
header: 'Date',
|
||||||
|
accessorKey: 'createdAt',
|
||||||
|
cell: ({ row }) => <LocaleDate format={dateFormat} date={row.original.createdAt} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Device',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
if (!row.original.userAgent) {
|
||||||
|
return 'N/A';
|
||||||
|
}
|
||||||
|
|
||||||
|
parser.setUA(row.original.userAgent);
|
||||||
|
|
||||||
|
const result = parser.getResult();
|
||||||
|
|
||||||
|
let output = result.os.name;
|
||||||
|
|
||||||
|
if (!output) {
|
||||||
|
return 'N/A';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.os.version) {
|
||||||
|
output += ` (${result.os.version})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Browser',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
if (!row.original.userAgent) {
|
||||||
|
return 'N/A';
|
||||||
|
}
|
||||||
|
|
||||||
|
parser.setUA(row.original.userAgent);
|
||||||
|
|
||||||
|
const result = parser.getResult();
|
||||||
|
|
||||||
|
return result.browser.name ?? 'N/A';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'IP Address',
|
||||||
|
accessorKey: 'ipAddress',
|
||||||
|
cell: ({ row }) => row.original.ipAddress ?? 'N/A',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Action',
|
||||||
|
accessorKey: 'type',
|
||||||
|
cell: ({ row }) => USER_SECURITY_AUDIT_LOG_MAP[row.original.type],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
data={results.data}
|
||||||
|
perPage={results.perPage}
|
||||||
|
currentPage={results.currentPage}
|
||||||
|
totalPages={results.totalPages}
|
||||||
|
onPaginationChange={onPaginationChange}
|
||||||
|
hasFilters={parsedSearchParams.page !== undefined || parsedSearchParams.perPage !== undefined}
|
||||||
|
onClearFilters={() => router.push(pathname ?? '/')}
|
||||||
|
error={{
|
||||||
|
enable: isLoadingError,
|
||||||
|
}}
|
||||||
|
skeleton={{
|
||||||
|
enable: isLoading && isInitialLoading,
|
||||||
|
rows: 3,
|
||||||
|
component: (
|
||||||
|
<>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-20 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(table) => <DataTablePagination table={table} />}
|
||||||
|
</DataTable>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,7 +1,10 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { IDENTITY_PROVIDER_NAME } from '@documenso/lib/constants/auth';
|
import { IDENTITY_PROVIDER_NAME } from '@documenso/lib/constants/auth';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
import { AuthenticatorApp } from '~/components/forms/2fa/authenticator-app';
|
import { AuthenticatorApp } from '~/components/forms/2fa/authenticator-app';
|
||||||
import { RecoveryCodes } from '~/components/forms/2fa/recovery-codes';
|
import { RecoveryCodes } from '~/components/forms/2fa/recovery-codes';
|
||||||
@ -26,43 +29,74 @@ export default async function SecuritySettingsPage() {
|
|||||||
|
|
||||||
{user.identityProvider === 'DOCUMENSO' ? (
|
{user.identityProvider === 'DOCUMENSO' ? (
|
||||||
<div>
|
<div>
|
||||||
<PasswordForm user={user} className="max-w-xl" />
|
<PasswordForm user={user} />
|
||||||
|
|
||||||
<hr className="mb-4 mt-8" />
|
<hr className="border-border/50 mt-6" />
|
||||||
|
|
||||||
<h4 className="text-lg font-medium">Two Factor Authentication</h4>
|
<Alert
|
||||||
|
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||||
|
variant="neutral"
|
||||||
|
>
|
||||||
|
<div className="mb-4 sm:mb-0">
|
||||||
|
<AlertTitle>Two factor authentication</AlertTitle>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
<AlertDescription className="mr-4">
|
||||||
Add and manage your two factor security settings to add an extra layer of security to
|
Create one-time passwords that serve as a secondary authentication method for
|
||||||
your account!
|
confirming your identity when requested during the sign-in process.
|
||||||
</p>
|
</AlertDescription>
|
||||||
|
</div>
|
||||||
<div className="mt-4 max-w-xl">
|
|
||||||
<h5 className="font-medium">Two-factor methods</h5>
|
|
||||||
|
|
||||||
<AuthenticatorApp isTwoFactorEnabled={user.twoFactorEnabled} />
|
<AuthenticatorApp isTwoFactorEnabled={user.twoFactorEnabled} />
|
||||||
</div>
|
</Alert>
|
||||||
|
|
||||||
{user.twoFactorEnabled && (
|
{user.twoFactorEnabled && (
|
||||||
<div className="mt-4 max-w-xl">
|
<Alert
|
||||||
<h5 className="font-medium">Recovery methods</h5>
|
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||||
|
variant="neutral"
|
||||||
|
>
|
||||||
|
<div className="mb-4 sm:mb-0">
|
||||||
|
<AlertTitle>Recovery codes</AlertTitle>
|
||||||
|
|
||||||
|
<AlertDescription className="mr-4">
|
||||||
|
Two factor authentication recovery codes are used to access your account in the
|
||||||
|
event that you lose access to your authenticator app.
|
||||||
|
</AlertDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
<RecoveryCodes isTwoFactorEnabled={user.twoFactorEnabled} />
|
<RecoveryCodes isTwoFactorEnabled={user.twoFactorEnabled} />
|
||||||
</div>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<Alert className="p-6" variant="neutral">
|
||||||
<h4 className="text-lg font-medium">
|
<AlertTitle>
|
||||||
Your account is managed by {IDENTITY_PROVIDER_NAME[user.identityProvider]}
|
Your account is managed by {IDENTITY_PROVIDER_NAME[user.identityProvider]}
|
||||||
</h4>
|
</AlertTitle>
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
|
||||||
|
<AlertDescription>
|
||||||
To update your password, enable two-factor authentication, and manage other security
|
To update your password, enable two-factor authentication, and manage other security
|
||||||
settings, please go to your {IDENTITY_PROVIDER_NAME[user.identityProvider]} account
|
settings, please go to your {IDENTITY_PROVIDER_NAME[user.identityProvider]} account
|
||||||
settings.
|
settings.
|
||||||
</p>
|
</AlertDescription>
|
||||||
</div>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Alert
|
||||||
|
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||||
|
variant="neutral"
|
||||||
|
>
|
||||||
|
<div className="mb-4 mr-4 sm:mb-0">
|
||||||
|
<AlertTitle>Recent activity</AlertTitle>
|
||||||
|
|
||||||
|
<AlertDescription className="mr-2">
|
||||||
|
View all recent security activity related to your account.
|
||||||
|
</AlertDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/settings/security/activity">View activity</Link>
|
||||||
|
</Button>
|
||||||
|
</Alert>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,13 +2,16 @@
|
|||||||
|
|
||||||
import { useState, useTransition } from 'react';
|
import { useState, useTransition } from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { Loader, Plus } from 'lucide-react';
|
import { AlertTriangle, Loader, Plus } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
import type { Template } from '@documenso/prisma/client';
|
import type { Template } from '@documenso/prisma/client';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
@ -36,6 +39,8 @@ export const TemplatesDataTable = ({
|
|||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
|
||||||
|
const { remaining } = useLimits();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@ -77,6 +82,19 @@ export const TemplatesDataTable = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
{remaining.documents === 0 && (
|
||||||
|
<Alert className="mb-4 mt-5">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Document Limit Exceeded!</AlertTitle>
|
||||||
|
<AlertDescription className="mt-2">
|
||||||
|
You have reached your document limit.{' '}
|
||||||
|
<Link className="underline underline-offset-4" href="/settings/billing">
|
||||||
|
Upgrade your account to continue!
|
||||||
|
</Link>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
@ -102,7 +120,7 @@ export const TemplatesDataTable = ({
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-x-4">
|
<div className="flex items-center gap-x-4">
|
||||||
<Button
|
<Button
|
||||||
disabled={isRowLoading}
|
disabled={isRowLoading || remaining.documents === 0}
|
||||||
loading={isRowLoading}
|
loading={isRowLoading}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setLoadingStates((prev) => ({ ...prev, [row.original.id]: true }));
|
setLoadingStates((prev) => ({ ...prev, [row.original.id]: true }));
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document
|
|||||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||||
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
||||||
import { DocumentStatus, FieldType } from '@documenso/prisma/client';
|
import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||||
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
|
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
|
||||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||||
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
||||||
@ -94,7 +94,10 @@ export default async function CompletedSigningPage({
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
||||||
You have signed
|
You have
|
||||||
|
{recipient.role === RecipientRole.SIGNER && ' signed '}
|
||||||
|
{recipient.role === RecipientRole.VIEWER && ' viewed '}
|
||||||
|
{recipient.role === RecipientRole.APPROVER && ' approved '}
|
||||||
<span className="mt-1.5 block">"{truncatedTitle}"</span>
|
<span className="mt-1.5 block">"{truncatedTitle}"</span>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import { useForm } from 'react-hook-form';
|
|||||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
import { getDocumentMetaByDocumentId } from '@documenso/lib/server-only/document/get-document-meta-by-document-id';
|
import { getDocumentMetaByDocumentId } from '@documenso/lib/server-only/document/get-document-meta-by-document-id';
|
||||||
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||||
import type { Document, Field, Recipient } from '@documenso/prisma/client';
|
import { type Document, type Field, type Recipient, RecipientRole } from '@documenso/prisma/client';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -99,15 +99,52 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
|||||||
|
|
||||||
<fieldset
|
<fieldset
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className={cn('-mx-2 flex flex-1 flex-col overflow-hidden px-2')}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2',
|
'custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<h3 className="text-foreground text-2xl font-semibold">Sign Document</h3>
|
<div className={cn('flex flex-1 flex-col')}>
|
||||||
|
<h3 className="text-foreground text-2xl font-semibold">
|
||||||
|
{recipient.role === RecipientRole.VIEWER && 'View Document'}
|
||||||
|
{recipient.role === RecipientRole.SIGNER && 'Sign Document'}
|
||||||
|
{recipient.role === RecipientRole.APPROVER && 'Approve Document'}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{recipient.role === RecipientRole.VIEWER ? (
|
||||||
|
<>
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
|
Please mark as viewed to complete
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr className="border-border mb-8 mt-4" />
|
||||||
|
|
||||||
|
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
|
||||||
|
<div className="flex flex-1 flex-col gap-y-4" />
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
||||||
|
variant="secondary"
|
||||||
|
size="lg"
|
||||||
|
disabled={typeof window !== 'undefined' && window.history.length <= 1}
|
||||||
|
onClick={() => router.back()}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<SignDialog
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
onSignatureComplete={handleSubmit(onFormSubmit)}
|
||||||
|
document={document}
|
||||||
|
fields={fields}
|
||||||
|
fieldsValidated={fieldsValidated}
|
||||||
|
role={recipient.role}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
Please review the document before signing.
|
Please review the document before signing.
|
||||||
</p>
|
</p>
|
||||||
@ -135,6 +172,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
|||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<SignaturePad
|
<SignaturePad
|
||||||
className="h-44 w-full"
|
className="h-44 w-full"
|
||||||
|
disabled={isSubmitting}
|
||||||
defaultValue={signature ?? undefined}
|
defaultValue={signature ?? undefined}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
setSignature(value);
|
setSignature(value);
|
||||||
@ -163,9 +201,12 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
|||||||
document={document}
|
document={document}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
fieldsValidated={fieldsValidated}
|
fieldsValidated={fieldsValidated}
|
||||||
|
role={recipient.role}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-f
|
|||||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||||
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
||||||
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||||
import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client';
|
import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
@ -111,7 +111,10 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
|
|
||||||
<div className="mt-2.5 flex items-center gap-x-6">
|
<div className="mt-2.5 flex items-center gap-x-6">
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
{document.User.name} ({document.User.email}) has invited you to sign this document.
|
{document.User.name} ({document.User.email}) has invited you to{' '}
|
||||||
|
{recipient.role === RecipientRole.VIEWER && 'view'}
|
||||||
|
{recipient.role === RecipientRole.SIGNER && 'sign'}
|
||||||
|
{recipient.role === RecipientRole.APPROVER && 'approve'} this document.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import type { Document, Field } from '@documenso/prisma/client';
|
import type { Document, Field } from '@documenso/prisma/client';
|
||||||
|
import { RecipientRole } from '@documenso/prisma/client';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@ -17,6 +18,7 @@ export type SignDialogProps = {
|
|||||||
fields: Field[];
|
fields: Field[];
|
||||||
fieldsValidated: () => void | Promise<void>;
|
fieldsValidated: () => void | Promise<void>;
|
||||||
onSignatureComplete: () => void | Promise<void>;
|
onSignatureComplete: () => void | Promise<void>;
|
||||||
|
role: RecipientRole;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SignDialog = ({
|
export const SignDialog = ({
|
||||||
@ -25,6 +27,7 @@ export const SignDialog = ({
|
|||||||
fields,
|
fields,
|
||||||
fieldsValidated,
|
fieldsValidated,
|
||||||
onSignatureComplete,
|
onSignatureComplete,
|
||||||
|
role,
|
||||||
}: SignDialogProps) => {
|
}: SignDialogProps) => {
|
||||||
const [showDialog, setShowDialog] = useState(false);
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
const truncatedTitle = truncateTitle(document.title);
|
const truncatedTitle = truncateTitle(document.title);
|
||||||
@ -45,9 +48,18 @@ export const SignDialog = ({
|
|||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-foreground text-xl font-semibold">Sign Document</div>
|
<div className="text-foreground text-xl font-semibold">
|
||||||
|
{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">
|
<div className="text-muted-foreground mx-auto w-4/5 py-2 text-center">
|
||||||
You are about to finish signing "{truncatedTitle}". Are you sure?
|
{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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -71,7 +83,9 @@ export const SignDialog = ({
|
|||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
onClick={onSignatureComplete}
|
onClick={onSignatureComplete}
|
||||||
>
|
>
|
||||||
Sign
|
{role === RecipientRole.VIEWER && 'Mark as Viewed'}
|
||||||
|
{role === RecipientRole.SIGNER && 'Sign'}
|
||||||
|
{role === RecipientRole.APPROVER && 'Approve'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import React from 'react';
|
|||||||
|
|
||||||
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||||
|
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import type { Recipient } from '@documenso/prisma/client';
|
import type { Recipient } from '@documenso/prisma/client';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -47,8 +48,17 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
|
|||||||
type={getRecipientType(recipient)}
|
type={getRecipientType(recipient)}
|
||||||
fallbackText={recipientAbbreviation(recipient)}
|
fallbackText={recipientAbbreviation(recipient)}
|
||||||
/>
|
/>
|
||||||
|
<div>
|
||||||
<span className="text-muted-foreground text-sm">{recipient.email}</span>
|
<div
|
||||||
|
className="text-muted-foreground text-sm"
|
||||||
|
title="Click to copy signing link for sending to recipient"
|
||||||
|
>
|
||||||
|
<p>{recipient.email} </p>
|
||||||
|
<p className="text-muted-foreground/70 text-xs">
|
||||||
|
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||||
|
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import type { Recipient } from '@documenso/prisma/client';
|
import type { Recipient } from '@documenso/prisma/client';
|
||||||
import {
|
import {
|
||||||
@ -59,7 +60,12 @@ export const StackAvatarsWithTooltip = ({
|
|||||||
type={getRecipientType(recipient)}
|
type={getRecipientType(recipient)}
|
||||||
fallbackText={recipientAbbreviation(recipient)}
|
fallbackText={recipientAbbreviation(recipient)}
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground text-sm">{recipient.email}</span>
|
<div className="">
|
||||||
|
<p className="text-muted-foreground text-sm">{recipient.email}</p>
|
||||||
|
<p className="text-muted-foreground/70 text-xs">
|
||||||
|
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -19,28 +19,15 @@ export const AuthenticatorApp = ({ isTwoFactorEnabled }: AuthenticatorAppProps)
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mt-4 flex flex-col justify-between gap-4 rounded-lg border p-4 md:flex-row md:items-center md:gap-8">
|
<div className="flex-shrink-0">
|
||||||
<div className="flex-1">
|
|
||||||
<p>Authenticator app</p>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 max-w-[50ch] text-sm">
|
|
||||||
Create one-time passwords that serve as a secondary authentication method for confirming
|
|
||||||
your identity when requested during the sign-in process.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
{isTwoFactorEnabled ? (
|
{isTwoFactorEnabled ? (
|
||||||
<Button variant="destructive" onClick={() => setModalState('disable')} size="sm">
|
<Button variant="destructive" onClick={() => setModalState('disable')}>
|
||||||
Disable 2FA
|
Disable 2FA
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button onClick={() => setModalState('enable')} size="sm">
|
<Button onClick={() => setModalState('enable')}>Enable 2FA</Button>
|
||||||
Enable 2FA
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<EnableAuthenticatorAppDialog
|
<EnableAuthenticatorAppDialog
|
||||||
key={isEnableDialogOpen ? 'open' : 'closed'}
|
key={isEnableDialogOpen ? 'open' : 'closed'}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
@ -145,8 +146,8 @@ export const DisableAuthenticatorAppDialog = ({
|
|||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<div className="flex w-full items-center justify-between">
|
<DialogFooter>
|
||||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@ -157,7 +158,7 @@ export const DisableAuthenticatorAppDialog = ({
|
|||||||
>
|
>
|
||||||
Disable 2FA
|
Disable 2FA
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import {
|
|||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
@ -190,15 +191,15 @@ export const EnableAuthenticatorAppDialog = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex w-full items-center justify-between">
|
<DialogFooter>
|
||||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button type="submit" loading={isSetupTwoFactorAuthenticationSubmitting}>
|
<Button type="submit" loading={isSetupTwoFactorAuthenticationSubmitting}>
|
||||||
Continue
|
Continue
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
@ -251,15 +252,15 @@ export const EnableAuthenticatorAppDialog = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex w-full items-center justify-between">
|
<DialogFooter>
|
||||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button type="submit" loading={isEnableTwoFactorAuthenticationSubmitting}>
|
<Button type="submit" loading={isEnableTwoFactorAuthenticationSubmitting}>
|
||||||
Enable 2FA
|
Enable 2FA
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
))
|
))
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import { Button } from '@documenso/ui/primitives/button';
|
|||||||
import { ViewRecoveryCodesDialog } from './view-recovery-codes-dialog';
|
import { ViewRecoveryCodesDialog } from './view-recovery-codes-dialog';
|
||||||
|
|
||||||
type RecoveryCodesProps = {
|
type RecoveryCodesProps = {
|
||||||
// backupCodes: string[] | null;
|
|
||||||
isTwoFactorEnabled: boolean;
|
isTwoFactorEnabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -16,22 +15,13 @@ export const RecoveryCodes = ({ isTwoFactorEnabled }: RecoveryCodesProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mt-4 flex flex-col justify-between gap-4 rounded-lg border p-4 md:flex-row md:items-center md:gap-8">
|
<Button
|
||||||
<div className="flex-1">
|
className="flex-shrink-0"
|
||||||
<p>Recovery Codes</p>
|
onClick={() => setIsOpen(true)}
|
||||||
|
disabled={!isTwoFactorEnabled}
|
||||||
<p className="text-muted-foreground mt-2 max-w-[50ch] text-sm">
|
>
|
||||||
Recovery codes are used to access your account in the event that you lose access to your
|
|
||||||
authenticator app.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Button onClick={() => setIsOpen(true)} disabled={!isTwoFactorEnabled} size="sm">
|
|
||||||
View Codes
|
View Codes
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ViewRecoveryCodesDialog
|
<ViewRecoveryCodesDialog
|
||||||
key={isOpen ? 'open' : 'closed'}
|
key={isOpen ? 'open' : 'closed'}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
@ -119,15 +120,15 @@ export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCode
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex w-full items-center justify-between">
|
<DialogFooter>
|
||||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button type="submit" loading={isViewRecoveryCodesSubmitting}>
|
<Button type="submit" loading={isViewRecoveryCodesSubmitting}>
|
||||||
Continue
|
Continue
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -137,7 +137,7 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
|
|||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="ml-auto mt-4">
|
||||||
<Button type="submit" loading={isSubmitting}>
|
<Button type="submit" loading={isSubmitting}>
|
||||||
{isSubmitting ? 'Updating password...' : 'Update password'}
|
{isSubmitting ? 'Updating password...' : 'Update password'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -121,10 +121,8 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<SignaturePad
|
<SignaturePad
|
||||||
className="h-44 w-full"
|
className="h-44 w-full"
|
||||||
containerClassName={cn(
|
disabled={isSubmitting}
|
||||||
'rounded-lg border bg-background',
|
containerClassName={cn('rounded-lg border bg-background')}
|
||||||
isSubmitting ? 'pointer-events-none opacity-50' : null,
|
|
||||||
)}
|
|
||||||
defaultValue={user.signature ?? undefined}
|
defaultValue={user.signature ?? undefined}
|
||||||
onChange={(v) => onChange(v ?? '')}
|
onChange={(v) => onChange(v ?? '')}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -12,7 +12,13 @@ import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes';
|
|||||||
import { ZCurrentPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
import { ZCurrentPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@documenso/ui/primitives/dialog';
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@ -111,7 +117,6 @@ export const SignInForm = ({ className, isGoogleSSOEnabled }: SignInFormProps) =
|
|||||||
|
|
||||||
const result = await signIn('credentials', {
|
const result = await signIn('credentials', {
|
||||||
...credentials,
|
...credentials,
|
||||||
|
|
||||||
callbackUrl: LOGIN_REDIRECT_PATH,
|
callbackUrl: LOGIN_REDIRECT_PATH,
|
||||||
redirect: false,
|
redirect: false,
|
||||||
});
|
});
|
||||||
@ -270,21 +275,23 @@ export const SignInForm = ({ className, isGoogleSSOEnabled }: SignInFormProps) =
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<div className="mt-4 flex items-center justify-between">
|
<DialogFooter className="mt-4">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="secondary"
|
||||||
onClick={onToggleTwoFactorAuthenticationMethodClick}
|
onClick={onToggleTwoFactorAuthenticationMethodClick}
|
||||||
>
|
>
|
||||||
{twoFactorAuthenticationMethod === 'totp' ? 'Use Backup Code' : 'Use Authenticator'}
|
{twoFactorAuthenticationMethod === 'totp'
|
||||||
|
? 'Use Backup Code'
|
||||||
|
: 'Use Authenticator'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button type="submit" loading={isSubmitting}>
|
<Button type="submit" loading={isSubmitting}>
|
||||||
{isSubmitting ? 'Signing in...' : 'Sign In'}
|
{isSubmitting ? 'Signing in...' : 'Sign In'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@ -172,6 +172,7 @@ export const SignUpForm = ({ className, isGoogleSSOEnabled }: SignUpFormProps) =
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<SignaturePad
|
<SignaturePad
|
||||||
className="h-36 w-full"
|
className="h-36 w-full"
|
||||||
|
disabled={isSubmitting}
|
||||||
containerClassName="mt-2 rounded-lg border bg-background"
|
containerClassName="mt-2 rounded-lg border bg-background"
|
||||||
onChange={(v) => onChange(v ?? '')}
|
onChange={(v) => onChange(v ?? '')}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,17 +1,65 @@
|
|||||||
// import { NextApiRequest, NextApiResponse } from 'next';
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
|
||||||
import NextAuth from 'next-auth';
|
import NextAuth from 'next-auth';
|
||||||
|
|
||||||
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
|
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
|
||||||
|
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||||
|
|
||||||
export default NextAuth({
|
export default async function auth(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const { ipAddress, userAgent } = extractNextApiRequestMetadata(req);
|
||||||
|
|
||||||
|
return await NextAuth(req, res, {
|
||||||
...NEXT_AUTH_OPTIONS,
|
...NEXT_AUTH_OPTIONS,
|
||||||
pages: {
|
pages: {
|
||||||
signIn: '/signin',
|
signIn: '/signin',
|
||||||
signOut: '/signout',
|
signOut: '/signout',
|
||||||
error: '/signin',
|
error: '/signin',
|
||||||
},
|
},
|
||||||
});
|
events: {
|
||||||
|
signIn: async ({ user }) => {
|
||||||
|
await prisma.userSecurityAuditLog.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
type: UserSecurityAuditLogType.SIGN_IN,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
signOut: async ({ token }) => {
|
||||||
|
const userId = typeof token.id === 'string' ? parseInt(token.id) : token.id;
|
||||||
|
|
||||||
// export default async function handler(_req: NextApiRequest, res: NextApiResponse) {
|
if (isNaN(userId)) {
|
||||||
// res.json({ hello: 'world' });
|
return;
|
||||||
// }
|
}
|
||||||
|
|
||||||
|
await prisma.userSecurityAuditLog.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
type: UserSecurityAuditLogType.SIGN_OUT,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
linkAccount: async ({ user }) => {
|
||||||
|
const userId = typeof user.id === 'string' ? parseInt(user.id) : user.id;
|
||||||
|
|
||||||
|
if (isNaN(userId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.userSecurityAuditLog.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
type: UserSecurityAuditLogType.ACCOUNT_SSO_LINK,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -39,6 +39,14 @@ ENV HUSKY 0
|
|||||||
ENV DOCKER_OUTPUT 1
|
ENV DOCKER_OUTPUT 1
|
||||||
ENV NEXT_TELEMETRY_DISABLED 1
|
ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
|
||||||
|
# Encryption keys
|
||||||
|
ARG NEXT_PRIVATE_ENCRYPTION_KEY="CAFEBABE"
|
||||||
|
ENV NEXT_PRIVATE_ENCRYPTION_KEY="$NEXT_PRIVATE_ENCRYPTION_KEY"
|
||||||
|
|
||||||
|
ARG NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="DEADBEEF"
|
||||||
|
ENV NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="$NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY"
|
||||||
|
|
||||||
|
|
||||||
# Uncomment and use build args to enable remote caching
|
# Uncomment and use build args to enable remote caching
|
||||||
# ARG TURBO_TEAM
|
# ARG TURBO_TEAM
|
||||||
# ENV TURBO_TEAM=$TURBO_TEAM
|
# ENV TURBO_TEAM=$TURBO_TEAM
|
||||||
|
|||||||
32
package-lock.json
generated
32
package-lock.json
generated
@ -158,6 +158,7 @@
|
|||||||
"sharp": "0.33.1",
|
"sharp": "0.33.1",
|
||||||
"ts-pattern": "^5.0.5",
|
"ts-pattern": "^5.0.5",
|
||||||
"typescript": "5.2.2",
|
"typescript": "5.2.2",
|
||||||
|
"ua-parser-js": "^1.0.37",
|
||||||
"uqr": "^0.1.2",
|
"uqr": "^0.1.2",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
@ -166,7 +167,8 @@
|
|||||||
"@types/luxon": "^3.3.1",
|
"@types/luxon": "^3.3.1",
|
||||||
"@types/node": "20.1.0",
|
"@types/node": "20.1.0",
|
||||||
"@types/react": "18.2.18",
|
"@types/react": "18.2.18",
|
||||||
"@types/react-dom": "18.2.7"
|
"@types/react-dom": "18.2.7",
|
||||||
|
"@types/ua-parser-js": "^0.7.39"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"apps/web/node_modules/@types/node": {
|
"apps/web/node_modules/@types/node": {
|
||||||
@ -6756,6 +6758,12 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz",
|
||||||
"integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A=="
|
"integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ua-parser-js": {
|
||||||
|
"version": "0.7.39",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ua-parser-js/-/ua-parser-js-0.7.39.tgz",
|
||||||
|
"integrity": "sha512-P/oDfpofrdtF5xw433SPALpdSchtJmY7nsJItf8h3KXqOslkbySh8zq4dSWXH2oTjRvJ5PczVEoCZPow6GicLg==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"node_modules/@types/unist": {
|
"node_modules/@types/unist": {
|
||||||
"version": "2.0.10",
|
"version": "2.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz",
|
||||||
@ -18643,6 +18651,28 @@
|
|||||||
"node": ">=14.17"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ua-parser-js": {
|
||||||
|
"version": "1.0.37",
|
||||||
|
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.37.tgz",
|
||||||
|
"integrity": "sha512-bhTyI94tZofjo+Dn8SN6Zv8nBDvyXTymAdM3LDI/0IboIUwTu1rEhW7v2TfiVsoYWgkQ4kOVqnI8APUFbIQIFQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/ua-parser-js"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "paypal",
|
||||||
|
"url": "https://paypal.me/faisalman"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/faisalman"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/unbox-primitive": {
|
"node_modules/unbox-primitive": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz",
|
||||||
|
|||||||
@ -12,7 +12,7 @@ test.describe.configure({ mode: 'serial' });
|
|||||||
|
|
||||||
const username = 'Test User';
|
const username = 'Test User';
|
||||||
const email = 'test-user@auth-flow.documenso.com';
|
const email = 'test-user@auth-flow.documenso.com';
|
||||||
const password = 'Password123';
|
const password = 'Password123#';
|
||||||
|
|
||||||
test('user can sign up with email and password', async ({ page }: { page: Page }) => {
|
test('user can sign up with email and password', async ({ page }: { page: Page }) => {
|
||||||
await page.goto('/signup');
|
await page.goto('/signup');
|
||||||
|
|||||||
@ -1,3 +1,6 @@
|
|||||||
|
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||||
|
import type { RecipientRole } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { Button, Section, Text } from '../components';
|
import { Button, Section, Text } from '../components';
|
||||||
import { TemplateDocumentImage } from './template-document-image';
|
import { TemplateDocumentImage } from './template-document-image';
|
||||||
|
|
||||||
@ -7,6 +10,7 @@ export interface TemplateDocumentInviteProps {
|
|||||||
documentName: string;
|
documentName: string;
|
||||||
signDocumentLink: string;
|
signDocumentLink: string;
|
||||||
assetBaseUrl: string;
|
assetBaseUrl: string;
|
||||||
|
role: RecipientRole;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TemplateDocumentInvite = ({
|
export const TemplateDocumentInvite = ({
|
||||||
@ -14,19 +18,22 @@ export const TemplateDocumentInvite = ({
|
|||||||
documentName,
|
documentName,
|
||||||
signDocumentLink,
|
signDocumentLink,
|
||||||
assetBaseUrl,
|
assetBaseUrl,
|
||||||
|
role,
|
||||||
}: TemplateDocumentInviteProps) => {
|
}: TemplateDocumentInviteProps) => {
|
||||||
|
const { actionVerb, progressiveVerb } = RECIPIENT_ROLES_DESCRIPTION[role];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||||
|
|
||||||
<Section>
|
<Section>
|
||||||
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
|
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
|
||||||
{inviterName} has invited you to sign
|
{inviterName} has invited you to {actionVerb.toLowerCase()}
|
||||||
<br />"{documentName}"
|
<br />"{documentName}"
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text className="my-1 text-center text-base text-slate-400">
|
<Text className="my-1 text-center text-base text-slate-400">
|
||||||
Continue by signing the document.
|
Continue by {progressiveVerb.toLowerCase()} the document.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Section className="mb-6 mt-8 text-center">
|
<Section className="mb-6 mt-8 text-center">
|
||||||
@ -34,7 +41,7 @@ export const TemplateDocumentInvite = ({
|
|||||||
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
|
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
|
||||||
href={signDocumentLink}
|
href={signDocumentLink}
|
||||||
>
|
>
|
||||||
Sign Document
|
{actionVerb} Document
|
||||||
</Button>
|
</Button>
|
||||||
</Section>
|
</Section>
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||||
|
import type { RecipientRole } from '@documenso/prisma/client';
|
||||||
import config from '@documenso/tailwind-config';
|
import config from '@documenso/tailwind-config';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -19,6 +21,7 @@ import { TemplateFooter } from '../template-components/template-footer';
|
|||||||
|
|
||||||
export type DocumentInviteEmailTemplateProps = Partial<TemplateDocumentInviteProps> & {
|
export type DocumentInviteEmailTemplateProps = Partial<TemplateDocumentInviteProps> & {
|
||||||
customBody?: string;
|
customBody?: string;
|
||||||
|
role: RecipientRole;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentInviteEmailTemplate = ({
|
export const DocumentInviteEmailTemplate = ({
|
||||||
@ -28,8 +31,11 @@ export const DocumentInviteEmailTemplate = ({
|
|||||||
signDocumentLink = 'https://documenso.com',
|
signDocumentLink = 'https://documenso.com',
|
||||||
assetBaseUrl = 'http://localhost:3002',
|
assetBaseUrl = 'http://localhost:3002',
|
||||||
customBody,
|
customBody,
|
||||||
|
role,
|
||||||
}: DocumentInviteEmailTemplateProps) => {
|
}: DocumentInviteEmailTemplateProps) => {
|
||||||
const previewText = `${inviterName} has invited you to sign ${documentName}`;
|
const action = RECIPIENT_ROLES_DESCRIPTION[role].actionVerb.toLowerCase();
|
||||||
|
|
||||||
|
const previewText = `${inviterName} has invited you to ${action} ${documentName}`;
|
||||||
|
|
||||||
const getAssetUrl = (path: string) => {
|
const getAssetUrl = (path: string) => {
|
||||||
return new URL(path, assetBaseUrl).toString();
|
return new URL(path, assetBaseUrl).toString();
|
||||||
@ -64,6 +70,7 @@ export const DocumentInviteEmailTemplate = ({
|
|||||||
documentName={documentName}
|
documentName={documentName}
|
||||||
signDocumentLink={signDocumentLink}
|
signDocumentLink={signDocumentLink}
|
||||||
assetBaseUrl={assetBaseUrl}
|
assetBaseUrl={assetBaseUrl}
|
||||||
|
role={role}
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
</Container>
|
</Container>
|
||||||
@ -81,7 +88,7 @@ export const DocumentInviteEmailTemplate = ({
|
|||||||
{customBody ? (
|
{customBody ? (
|
||||||
<pre className="font-sans text-base text-slate-400">{customBody}</pre>
|
<pre className="font-sans text-base text-slate-400">{customBody}</pre>
|
||||||
) : (
|
) : (
|
||||||
`${inviterName} has invited you to sign the document "${documentName}".`
|
`${inviterName} has invited you to ${action} the document "${documentName}".`
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import type { Recipient } from '@documenso/prisma/client';
|
import type { Recipient } from '@documenso/prisma/client';
|
||||||
import { ReadStatus, SendStatus, SigningStatus } from '@documenso/prisma/client';
|
import { ReadStatus, RecipientRole, SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
export const getRecipientType = (recipient: Recipient) => {
|
export const getRecipientType = (recipient: Recipient) => {
|
||||||
if (
|
if (
|
||||||
recipient.sendStatus === SendStatus.SENT &&
|
recipient.role === RecipientRole.CC ||
|
||||||
recipient.signingStatus === SigningStatus.SIGNED
|
(recipient.sendStatus === SendStatus.SENT && recipient.signingStatus === SigningStatus.SIGNED)
|
||||||
) {
|
) {
|
||||||
return 'completed';
|
return 'completed';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,3 +6,6 @@ export const APP_FOLDER = IS_APP_MARKETING ? 'marketing' : 'web';
|
|||||||
export const APP_BASE_URL = IS_APP_WEB
|
export const APP_BASE_URL = IS_APP_WEB
|
||||||
? process.env.NEXT_PUBLIC_WEBAPP_URL
|
? process.env.NEXT_PUBLIC_WEBAPP_URL
|
||||||
: process.env.NEXT_PUBLIC_MARKETING_URL;
|
: process.env.NEXT_PUBLIC_MARKETING_URL;
|
||||||
|
|
||||||
|
export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT =
|
||||||
|
Number(process.env.NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT) || 50;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { IdentityProvider } from '@documenso/prisma/client';
|
import { IdentityProvider, UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||||
|
|
||||||
export const SALT_ROUNDS = 12;
|
export const SALT_ROUNDS = 12;
|
||||||
|
|
||||||
@ -10,3 +10,16 @@ export const IDENTITY_PROVIDER_NAME: { [key in IdentityProvider]: string } = {
|
|||||||
export const IS_GOOGLE_SSO_ENABLED = Boolean(
|
export const IS_GOOGLE_SSO_ENABLED = Boolean(
|
||||||
process.env.NEXT_PRIVATE_GOOGLE_CLIENT_ID && process.env.NEXT_PRIVATE_GOOGLE_CLIENT_SECRET,
|
process.env.NEXT_PRIVATE_GOOGLE_CLIENT_ID && process.env.NEXT_PRIVATE_GOOGLE_CLIENT_SECRET,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const USER_SECURITY_AUDIT_LOG_MAP: { [key in UserSecurityAuditLogType]: string } = {
|
||||||
|
[UserSecurityAuditLogType.ACCOUNT_SSO_LINK]: 'Linked account to SSO',
|
||||||
|
[UserSecurityAuditLogType.ACCOUNT_PROFILE_UPDATE]: 'Profile updated',
|
||||||
|
[UserSecurityAuditLogType.AUTH_2FA_DISABLE]: '2FA Disabled',
|
||||||
|
[UserSecurityAuditLogType.AUTH_2FA_ENABLE]: '2FA Enabled',
|
||||||
|
[UserSecurityAuditLogType.PASSWORD_RESET]: 'Password reset',
|
||||||
|
[UserSecurityAuditLogType.PASSWORD_UPDATE]: 'Password updated',
|
||||||
|
[UserSecurityAuditLogType.SIGN_OUT]: 'Signed Out',
|
||||||
|
[UserSecurityAuditLogType.SIGN_IN]: 'Signed In',
|
||||||
|
[UserSecurityAuditLogType.SIGN_IN_FAIL]: 'Sign in attempt failed',
|
||||||
|
[UserSecurityAuditLogType.SIGN_IN_2FA_FAIL]: 'Sign in 2FA attempt failed',
|
||||||
|
};
|
||||||
|
|||||||
@ -2,14 +2,16 @@ export const DOCUMENSO_ENCRYPTION_KEY = process.env.NEXT_PRIVATE_ENCRYPTION_KEY;
|
|||||||
|
|
||||||
export const DOCUMENSO_ENCRYPTION_SECONDARY_KEY = process.env.NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY;
|
export const DOCUMENSO_ENCRYPTION_SECONDARY_KEY = process.env.NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY;
|
||||||
|
|
||||||
if (!DOCUMENSO_ENCRYPTION_KEY || !DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
|
if (typeof window === 'undefined') {
|
||||||
|
if (!DOCUMENSO_ENCRYPTION_KEY || !DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
|
||||||
throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY or DOCUMENSO_ENCRYPTION_SECONDARY_KEY keys');
|
throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY or DOCUMENSO_ENCRYPTION_SECONDARY_KEY keys');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (DOCUMENSO_ENCRYPTION_KEY === DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
|
if (DOCUMENSO_ENCRYPTION_KEY === DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'DOCUMENSO_ENCRYPTION_KEY and DOCUMENSO_ENCRYPTION_SECONDARY_KEY cannot be equal',
|
'DOCUMENSO_ENCRYPTION_KEY and DOCUMENSO_ENCRYPTION_SECONDARY_KEY cannot be equal',
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (DOCUMENSO_ENCRYPTION_KEY === 'CAFEBABE') {
|
if (DOCUMENSO_ENCRYPTION_KEY === 'CAFEBABE') {
|
||||||
|
|||||||
26
packages/lib/constants/recipient-roles.ts
Normal file
26
packages/lib/constants/recipient-roles.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { RecipientRole } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export const RECIPIENT_ROLES_DESCRIPTION: {
|
||||||
|
[key in RecipientRole]: { actionVerb: string; progressiveVerb: string; roleName: string };
|
||||||
|
} = {
|
||||||
|
[RecipientRole.APPROVER]: {
|
||||||
|
actionVerb: 'Approve',
|
||||||
|
progressiveVerb: 'Approving',
|
||||||
|
roleName: 'Approver',
|
||||||
|
},
|
||||||
|
[RecipientRole.CC]: {
|
||||||
|
actionVerb: 'CC',
|
||||||
|
progressiveVerb: 'CC',
|
||||||
|
roleName: 'CC',
|
||||||
|
},
|
||||||
|
[RecipientRole.SIGNER]: {
|
||||||
|
actionVerb: 'Sign',
|
||||||
|
progressiveVerb: 'Signing',
|
||||||
|
roleName: 'Signer',
|
||||||
|
},
|
||||||
|
[RecipientRole.VIEWER]: {
|
||||||
|
actionVerb: 'View',
|
||||||
|
progressiveVerb: 'Viewing',
|
||||||
|
roleName: 'Viewer',
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -9,11 +9,12 @@ import type { GoogleProfile } from 'next-auth/providers/google';
|
|||||||
import GoogleProvider from 'next-auth/providers/google';
|
import GoogleProvider from 'next-auth/providers/google';
|
||||||
|
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { IdentityProvider } from '@documenso/prisma/client';
|
import { IdentityProvider, UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble';
|
import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble';
|
||||||
import { validateTwoFactorAuthentication } from '../server-only/2fa/validate-2fa';
|
import { validateTwoFactorAuthentication } from '../server-only/2fa/validate-2fa';
|
||||||
import { getUserByEmail } from '../server-only/user/get-user-by-email';
|
import { getUserByEmail } from '../server-only/user/get-user-by-email';
|
||||||
|
import { extractNextAuthRequestMetadata } from '../universal/extract-request-metadata';
|
||||||
import { ErrorCode } from './error-codes';
|
import { ErrorCode } from './error-codes';
|
||||||
|
|
||||||
export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||||
@ -35,7 +36,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
|||||||
},
|
},
|
||||||
backupCode: { label: 'Backup Code', type: 'input', placeholder: 'Two-factor backup code' },
|
backupCode: { label: 'Backup Code', type: 'input', placeholder: 'Two-factor backup code' },
|
||||||
},
|
},
|
||||||
authorize: async (credentials, _req) => {
|
authorize: async (credentials, req) => {
|
||||||
if (!credentials) {
|
if (!credentials) {
|
||||||
throw new Error(ErrorCode.CREDENTIALS_NOT_FOUND);
|
throw new Error(ErrorCode.CREDENTIALS_NOT_FOUND);
|
||||||
}
|
}
|
||||||
@ -51,8 +52,18 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isPasswordsSame = await compare(password, user.password);
|
const isPasswordsSame = await compare(password, user.password);
|
||||||
|
const requestMetadata = extractNextAuthRequestMetadata(req);
|
||||||
|
|
||||||
if (!isPasswordsSame) {
|
if (!isPasswordsSame) {
|
||||||
|
await prisma.userSecurityAuditLog.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
ipAddress: requestMetadata.ipAddress,
|
||||||
|
userAgent: requestMetadata.userAgent,
|
||||||
|
type: UserSecurityAuditLogType.SIGN_IN_FAIL,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
throw new Error(ErrorCode.INCORRECT_EMAIL_PASSWORD);
|
throw new Error(ErrorCode.INCORRECT_EMAIL_PASSWORD);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -62,6 +73,15 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
|||||||
const isValid = await validateTwoFactorAuthentication({ backupCode, totpCode, user });
|
const isValid = await validateTwoFactorAuthentication({ backupCode, totpCode, user });
|
||||||
|
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
|
await prisma.userSecurityAuditLog.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
ipAddress: requestMetadata.ipAddress,
|
||||||
|
userAgent: requestMetadata.userAgent,
|
||||||
|
type: UserSecurityAuditLogType.SIGN_IN_2FA_FAIL,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
totpCode
|
totpCode
|
||||||
? ErrorCode.INCORRECT_TWO_FACTOR_CODE
|
? ErrorCode.INCORRECT_TWO_FACTOR_CODE
|
||||||
@ -192,4 +212,5 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
|||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// Note: `events` are handled in `apps/web/src/pages/api/auth/[...nextauth].ts` to allow access to the request.
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,21 +1,25 @@
|
|||||||
import { compare } from 'bcrypt';
|
import { compare } from 'bcrypt';
|
||||||
|
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { User } from '@documenso/prisma/client';
|
import type { User } from '@documenso/prisma/client';
|
||||||
|
import { UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { ErrorCode } from '../../next-auth/error-codes';
|
import { ErrorCode } from '../../next-auth/error-codes';
|
||||||
|
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||||
import { validateTwoFactorAuthentication } from './validate-2fa';
|
import { validateTwoFactorAuthentication } from './validate-2fa';
|
||||||
|
|
||||||
type DisableTwoFactorAuthenticationOptions = {
|
type DisableTwoFactorAuthenticationOptions = {
|
||||||
user: User;
|
user: User;
|
||||||
backupCode: string;
|
backupCode: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
requestMetadata?: RequestMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const disableTwoFactorAuthentication = async ({
|
export const disableTwoFactorAuthentication = async ({
|
||||||
backupCode,
|
backupCode,
|
||||||
user,
|
user,
|
||||||
password,
|
password,
|
||||||
|
requestMetadata,
|
||||||
}: DisableTwoFactorAuthenticationOptions) => {
|
}: DisableTwoFactorAuthenticationOptions) => {
|
||||||
if (!user.password) {
|
if (!user.password) {
|
||||||
throw new Error(ErrorCode.USER_MISSING_PASSWORD);
|
throw new Error(ErrorCode.USER_MISSING_PASSWORD);
|
||||||
@ -33,7 +37,8 @@ export const disableTwoFactorAuthentication = async ({
|
|||||||
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE);
|
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE);
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.user.update({
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.user.update({
|
||||||
where: {
|
where: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
},
|
},
|
||||||
@ -44,5 +49,15 @@ export const disableTwoFactorAuthentication = async ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await tx.userSecurityAuditLog.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
type: UserSecurityAuditLogType.AUTH_2FA_DISABLE,
|
||||||
|
userAgent: requestMetadata?.userAgent,
|
||||||
|
ipAddress: requestMetadata?.ipAddress,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,18 +1,21 @@
|
|||||||
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
|
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { User } from '@documenso/prisma/client';
|
import { type User, UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||||
import { getBackupCodes } from './get-backup-code';
|
import { getBackupCodes } from './get-backup-code';
|
||||||
import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token';
|
import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token';
|
||||||
|
|
||||||
type EnableTwoFactorAuthenticationOptions = {
|
type EnableTwoFactorAuthenticationOptions = {
|
||||||
user: User;
|
user: User;
|
||||||
code: string;
|
code: string;
|
||||||
|
requestMetadata?: RequestMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const enableTwoFactorAuthentication = async ({
|
export const enableTwoFactorAuthentication = async ({
|
||||||
user,
|
user,
|
||||||
code,
|
code,
|
||||||
|
requestMetadata,
|
||||||
}: EnableTwoFactorAuthenticationOptions) => {
|
}: EnableTwoFactorAuthenticationOptions) => {
|
||||||
if (user.identityProvider !== 'DOCUMENSO') {
|
if (user.identityProvider !== 'DOCUMENSO') {
|
||||||
throw new Error(ErrorCode.INCORRECT_IDENTITY_PROVIDER);
|
throw new Error(ErrorCode.INCORRECT_IDENTITY_PROVIDER);
|
||||||
@ -32,7 +35,17 @@ export const enableTwoFactorAuthentication = async ({
|
|||||||
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_CODE);
|
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_CODE);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedUser = await prisma.user.update({
|
const updatedUser = await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.userSecurityAuditLog.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
type: UserSecurityAuditLogType.AUTH_2FA_ENABLE,
|
||||||
|
userAgent: requestMetadata?.userAgent,
|
||||||
|
ipAddress: requestMetadata?.ipAddress,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return await tx.user.update({
|
||||||
where: {
|
where: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
},
|
},
|
||||||
@ -40,6 +53,7 @@ export const enableTwoFactorAuthentication = async ({
|
|||||||
twoFactorEnabled: true,
|
twoFactorEnabled: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const recoveryCodes = getBackupCodes({ user: updatedUser });
|
const recoveryCodes = getBackupCodes({ user: updatedUser });
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { createTOTPKeyURI } from 'oslo/otp';
|
|||||||
|
|
||||||
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
|
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { User } from '@documenso/prisma/client';
|
import { type User } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
|
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
|
||||||
import { symmetricEncrypt } from '../../universal/crypto';
|
import { symmetricEncrypt } from '../../universal/crypto';
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { P, match } from 'ts-pattern';
|
|||||||
|
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import type { Document, Prisma } from '@documenso/prisma/client';
|
import type { Document, Prisma } from '@documenso/prisma/client';
|
||||||
import { SigningStatus } from '@documenso/prisma/client';
|
import { RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||||
|
|
||||||
import type { FindResultSet } from '../../types/find-result-set';
|
import type { FindResultSet } from '../../types/find-result-set';
|
||||||
@ -87,6 +87,9 @@ export const findDocuments = async ({
|
|||||||
some: {
|
some: {
|
||||||
email: user.email,
|
email: user.email,
|
||||||
signingStatus: SigningStatus.NOT_SIGNED,
|
signingStatus: SigningStatus.NOT_SIGNED,
|
||||||
|
role: {
|
||||||
|
not: RecipientRole.CC,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
@ -109,6 +112,9 @@ export const findDocuments = async ({
|
|||||||
some: {
|
some: {
|
||||||
email: user.email,
|
email: user.email,
|
||||||
signingStatus: SigningStatus.SIGNED,
|
signingStatus: SigningStatus.SIGNED,
|
||||||
|
role: {
|
||||||
|
not: RecipientRole.CC,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
|
|||||||
@ -6,7 +6,9 @@ import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document
|
|||||||
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
|
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
|
||||||
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
|
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { RECIPIENT_ROLES_DESCRIPTION } from '../../constants/recipient-roles';
|
||||||
|
|
||||||
export type ResendDocumentOptions = {
|
export type ResendDocumentOptions = {
|
||||||
documentId: number;
|
documentId: number;
|
||||||
@ -59,6 +61,10 @@ export const resendDocument = async ({ documentId, userId, recipients }: ResendD
|
|||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
document.Recipient.map(async (recipient) => {
|
document.Recipient.map(async (recipient) => {
|
||||||
|
if (recipient.role === RecipientRole.CC) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const { email, name } = recipient;
|
const { email, name } = recipient;
|
||||||
|
|
||||||
const customEmailTemplate = {
|
const customEmailTemplate = {
|
||||||
@ -77,8 +83,11 @@ export const resendDocument = async ({ documentId, userId, recipients }: ResendD
|
|||||||
assetBaseUrl,
|
assetBaseUrl,
|
||||||
signDocumentLink,
|
signDocumentLink,
|
||||||
customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate),
|
customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate),
|
||||||
|
role: recipient.role,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
|
||||||
|
|
||||||
await mailer.sendMail({
|
await mailer.sendMail({
|
||||||
to: {
|
to: {
|
||||||
address: email,
|
address: email,
|
||||||
@ -90,7 +99,7 @@ export const resendDocument = async ({ documentId, userId, recipients }: ResendD
|
|||||||
},
|
},
|
||||||
subject: customEmail?.subject
|
subject: customEmail?.subject
|
||||||
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
|
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
|
||||||
: 'Please sign this document',
|
: `Please ${actionVerb.toLowerCase()} this document`,
|
||||||
html: render(template),
|
html: render(template),
|
||||||
text: render(template, { plainText: true }),
|
text: render(template, { plainText: true }),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { PDFDocument } from 'pdf-lib';
|
|||||||
|
|
||||||
import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
|
import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||||
import { signPdf } from '@documenso/signing';
|
import { signPdf } from '@documenso/signing';
|
||||||
|
|
||||||
import { getFile } from '../../universal/upload/get-file';
|
import { getFile } from '../../universal/upload/get-file';
|
||||||
@ -44,6 +44,9 @@ export const sealDocument = async ({ documentId, sendEmail = true }: SealDocumen
|
|||||||
const recipients = await prisma.recipient.findMany({
|
const recipients = await prisma.recipient.findMany({
|
||||||
where: {
|
where: {
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
|
role: {
|
||||||
|
not: RecipientRole.CC,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,9 @@ import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document
|
|||||||
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
|
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
|
||||||
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
|
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { DocumentStatus, SendStatus } from '@documenso/prisma/client';
|
import { DocumentStatus, RecipientRole, SendStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { RECIPIENT_ROLES_DESCRIPTION } from '../../constants/recipient-roles';
|
||||||
|
|
||||||
export type SendDocumentOptions = {
|
export type SendDocumentOptions = {
|
||||||
documentId: number;
|
documentId: number;
|
||||||
@ -47,6 +49,10 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
|
|||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
document.Recipient.map(async (recipient) => {
|
document.Recipient.map(async (recipient) => {
|
||||||
|
if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const { email, name } = recipient;
|
const { email, name } = recipient;
|
||||||
|
|
||||||
const customEmailTemplate = {
|
const customEmailTemplate = {
|
||||||
@ -55,10 +61,6 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
|
|||||||
'document.name': document.title,
|
'document.name': document.title,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (recipient.sendStatus === SendStatus.SENT) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
|
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
|
||||||
const signDocumentLink = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${recipient.token}`;
|
const signDocumentLink = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${recipient.token}`;
|
||||||
|
|
||||||
@ -69,8 +71,11 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
|
|||||||
assetBaseUrl,
|
assetBaseUrl,
|
||||||
signDocumentLink,
|
signDocumentLink,
|
||||||
customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate),
|
customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate),
|
||||||
|
role: recipient.role,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
|
||||||
|
|
||||||
await mailer.sendMail({
|
await mailer.sendMail({
|
||||||
to: {
|
to: {
|
||||||
address: email,
|
address: email,
|
||||||
@ -82,7 +87,7 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
|
|||||||
},
|
},
|
||||||
subject: customEmail?.subject
|
subject: customEmail?.subject
|
||||||
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
|
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
|
||||||
: 'Please sign this document',
|
: `Please ${actionVerb.toLowerCase()} this document`,
|
||||||
html: render(template),
|
html: render(template),
|
||||||
text: render(template, { plainText: true }),
|
text: render(template, { plainText: true }),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { RecipientRole } from '@documenso/prisma/client';
|
||||||
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
|
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { nanoid } from '../../universal/id';
|
import { nanoid } from '../../universal/id';
|
||||||
@ -10,6 +11,7 @@ export interface SetRecipientsForDocumentOptions {
|
|||||||
id?: number | null;
|
id?: number | null;
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
role: RecipientRole;
|
||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,13 +81,20 @@ export const setRecipientsForDocument = async ({
|
|||||||
update: {
|
update: {
|
||||||
name: recipient.name,
|
name: recipient.name,
|
||||||
email: recipient.email,
|
email: recipient.email,
|
||||||
|
role: recipient.role,
|
||||||
documentId,
|
documentId,
|
||||||
|
signingStatus:
|
||||||
|
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
name: recipient.name,
|
name: recipient.name,
|
||||||
email: recipient.email,
|
email: recipient.email,
|
||||||
|
role: recipient.role,
|
||||||
token: nanoid(),
|
token: nanoid(),
|
||||||
documentId,
|
documentId,
|
||||||
|
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
|
||||||
|
signingStatus:
|
||||||
|
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -0,0 +1,52 @@
|
|||||||
|
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import type { UserSecurityAuditLog, UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export type FindUserSecurityAuditLogsOptions = {
|
||||||
|
userId: number;
|
||||||
|
type?: UserSecurityAuditLogType;
|
||||||
|
page?: number;
|
||||||
|
perPage?: number;
|
||||||
|
orderBy?: {
|
||||||
|
column: keyof Omit<UserSecurityAuditLog, 'id' | 'userId'>;
|
||||||
|
direction: 'asc' | 'desc';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findUserSecurityAuditLogs = async ({
|
||||||
|
userId,
|
||||||
|
type,
|
||||||
|
page = 1,
|
||||||
|
perPage = 10,
|
||||||
|
orderBy,
|
||||||
|
}: FindUserSecurityAuditLogsOptions) => {
|
||||||
|
const orderByColumn = orderBy?.column ?? 'createdAt';
|
||||||
|
const orderByDirection = orderBy?.direction ?? 'desc';
|
||||||
|
|
||||||
|
const whereClause = {
|
||||||
|
userId,
|
||||||
|
type,
|
||||||
|
};
|
||||||
|
|
||||||
|
const [data, count] = await Promise.all([
|
||||||
|
prisma.userSecurityAuditLog.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
skip: Math.max(page - 1, 0) * perPage,
|
||||||
|
take: perPage,
|
||||||
|
orderBy: {
|
||||||
|
[orderByColumn]: orderByDirection,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.userSecurityAuditLog.count({
|
||||||
|
where: whereClause,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
count,
|
||||||
|
currentPage: Math.max(page, 1),
|
||||||
|
perPage,
|
||||||
|
totalPages: Math.ceil(count / perPage),
|
||||||
|
} satisfies FindResultSet<typeof data>;
|
||||||
|
};
|
||||||
@ -1,16 +1,19 @@
|
|||||||
import { compare, hash } from 'bcrypt';
|
import { compare, hash } from 'bcrypt';
|
||||||
|
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { SALT_ROUNDS } from '../../constants/auth';
|
import { SALT_ROUNDS } from '../../constants/auth';
|
||||||
|
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||||
import { sendResetPassword } from '../auth/send-reset-password';
|
import { sendResetPassword } from '../auth/send-reset-password';
|
||||||
|
|
||||||
export type ResetPasswordOptions = {
|
export type ResetPasswordOptions = {
|
||||||
token: string;
|
token: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
requestMetadata?: RequestMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const resetPassword = async ({ token, password }: ResetPasswordOptions) => {
|
export const resetPassword = async ({ token, password, requestMetadata }: ResetPasswordOptions) => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error('Invalid token provided. Please try again.');
|
throw new Error('Invalid token provided. Please try again.');
|
||||||
}
|
}
|
||||||
@ -56,6 +59,14 @@ export const resetPassword = async ({ token, password }: ResetPasswordOptions) =
|
|||||||
userId: foundToken.userId,
|
userId: foundToken.userId,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
prisma.userSecurityAuditLog.create({
|
||||||
|
data: {
|
||||||
|
userId: foundToken.userId,
|
||||||
|
type: UserSecurityAuditLogType.PASSWORD_RESET,
|
||||||
|
userAgent: requestMetadata?.userAgent,
|
||||||
|
ipAddress: requestMetadata?.ipAddress,
|
||||||
|
},
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await sendResetPassword({ userId: foundToken.userId });
|
await sendResetPassword({ userId: foundToken.userId });
|
||||||
|
|||||||
@ -1,19 +1,22 @@
|
|||||||
import { compare, hash } from 'bcrypt';
|
import { compare, hash } from 'bcrypt';
|
||||||
|
|
||||||
|
import { SALT_ROUNDS } from '@documenso/lib/constants/auth';
|
||||||
|
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||||
import { SALT_ROUNDS } from '../../constants/auth';
|
|
||||||
|
|
||||||
export type UpdatePasswordOptions = {
|
export type UpdatePasswordOptions = {
|
||||||
userId: number;
|
userId: number;
|
||||||
password: string;
|
password: string;
|
||||||
currentPassword: string;
|
currentPassword: string;
|
||||||
|
requestMetadata?: RequestMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updatePassword = async ({
|
export const updatePassword = async ({
|
||||||
userId,
|
userId,
|
||||||
password,
|
password,
|
||||||
currentPassword,
|
currentPassword,
|
||||||
|
requestMetadata,
|
||||||
}: UpdatePasswordOptions) => {
|
}: UpdatePasswordOptions) => {
|
||||||
// Existence check
|
// Existence check
|
||||||
const user = await prisma.user.findFirstOrThrow({
|
const user = await prisma.user.findFirstOrThrow({
|
||||||
@ -39,7 +42,17 @@ export const updatePassword = async ({
|
|||||||
|
|
||||||
const hashedNewPassword = await hash(password, SALT_ROUNDS);
|
const hashedNewPassword = await hash(password, SALT_ROUNDS);
|
||||||
|
|
||||||
const updatedUser = await prisma.user.update({
|
return await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.userSecurityAuditLog.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
type: UserSecurityAuditLogType.PASSWORD_UPDATE,
|
||||||
|
userAgent: requestMetadata?.userAgent,
|
||||||
|
ipAddress: requestMetadata?.ipAddress,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return await tx.user.update({
|
||||||
where: {
|
where: {
|
||||||
id: userId,
|
id: userId,
|
||||||
},
|
},
|
||||||
@ -47,6 +60,5 @@ export const updatePassword = async ({
|
|||||||
password: hashedNewPassword,
|
password: hashedNewPassword,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
});
|
||||||
return updatedUser;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,12 +1,21 @@
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||||
|
|
||||||
export type UpdateProfileOptions = {
|
export type UpdateProfileOptions = {
|
||||||
userId: number;
|
userId: number;
|
||||||
name: string;
|
name: string;
|
||||||
signature: string;
|
signature: string;
|
||||||
|
requestMetadata?: RequestMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateProfile = async ({ userId, name, signature }: UpdateProfileOptions) => {
|
export const updateProfile = async ({
|
||||||
|
userId,
|
||||||
|
name,
|
||||||
|
signature,
|
||||||
|
requestMetadata,
|
||||||
|
}: UpdateProfileOptions) => {
|
||||||
// Existence check
|
// Existence check
|
||||||
await prisma.user.findFirstOrThrow({
|
await prisma.user.findFirstOrThrow({
|
||||||
where: {
|
where: {
|
||||||
@ -14,7 +23,17 @@ export const updateProfile = async ({ userId, name, signature }: UpdateProfileOp
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatedUser = await prisma.user.update({
|
return await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.userSecurityAuditLog.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
type: UserSecurityAuditLogType.ACCOUNT_PROFILE_UPDATE,
|
||||||
|
userAgent: requestMetadata?.userAgent,
|
||||||
|
ipAddress: requestMetadata?.ipAddress,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return await tx.user.update({
|
||||||
where: {
|
where: {
|
||||||
id: userId,
|
id: userId,
|
||||||
},
|
},
|
||||||
@ -23,6 +42,5 @@ export const updateProfile = async ({ userId, name, signature }: UpdateProfileOp
|
|||||||
signature,
|
signature,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
});
|
||||||
return updatedUser;
|
|
||||||
};
|
};
|
||||||
|
|||||||
20
packages/lib/types/search-params.ts
Normal file
20
packages/lib/types/search-params.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const ZBaseTableSearchParamsSchema = z.object({
|
||||||
|
query: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.catch(() => undefined),
|
||||||
|
page: z.coerce
|
||||||
|
.number()
|
||||||
|
.min(1)
|
||||||
|
.optional()
|
||||||
|
.catch(() => undefined),
|
||||||
|
perPage: z.coerce
|
||||||
|
.number()
|
||||||
|
.min(1)
|
||||||
|
.optional()
|
||||||
|
.catch(() => undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TBaseTableSearchParamsSchema = z.infer<typeof ZBaseTableSearchParamsSchema>;
|
||||||
37
packages/lib/universal/extract-request-metadata.ts
Normal file
37
packages/lib/universal/extract-request-metadata.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import type { NextApiRequest } from 'next';
|
||||||
|
|
||||||
|
import type { RequestInternal } from 'next-auth';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const ZIpSchema = z.string().ip();
|
||||||
|
|
||||||
|
export type RequestMetadata = {
|
||||||
|
ipAddress?: string;
|
||||||
|
userAgent?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extractNextApiRequestMetadata = (req: NextApiRequest): RequestMetadata => {
|
||||||
|
const parsedIp = ZIpSchema.safeParse(req.headers['x-forwarded-for'] || req.socket.remoteAddress);
|
||||||
|
|
||||||
|
const ipAddress = parsedIp.success ? parsedIp.data : undefined;
|
||||||
|
const userAgent = req.headers['user-agent'];
|
||||||
|
|
||||||
|
return {
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extractNextAuthRequestMetadata = (
|
||||||
|
req: Pick<RequestInternal, 'body' | 'query' | 'headers' | 'method'>,
|
||||||
|
): RequestMetadata => {
|
||||||
|
const parsedIp = ZIpSchema.safeParse(req.headers?.['x-forwarded-for']);
|
||||||
|
|
||||||
|
const ipAddress = parsedIp.success ? parsedIp.data : undefined;
|
||||||
|
const userAgent = req.headers?.['user-agent'];
|
||||||
|
|
||||||
|
return {
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "RecipientRole" AS ENUM ('CC', 'SIGNER', 'VIEWER', 'APPROVER');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Recipient" ADD COLUMN "role" "RecipientRole" NOT NULL DEFAULT 'SIGNER';
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "UserSecurityAuditLogType" AS ENUM ('ACCOUNT_PROFILE_UPDATE', 'ACCOUNT_SSO_LINK', 'AUTH_2FA_DISABLE', 'AUTH_2FA_ENABLE', 'PASSWORD_RESET', 'PASSWORD_UPDATE', 'SIGN_OUT', 'SIGN_IN', 'SIGN_IN_FAIL', 'SIGN_IN_2FA_FAIL');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "UserSecurityAuditLog" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"userId" INTEGER NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"type" "UserSecurityAuditLogType" NOT NULL,
|
||||||
|
"userAgent" TEXT,
|
||||||
|
"ipAddress" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "UserSecurityAuditLog_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "UserSecurityAuditLog" ADD CONSTRAINT "UserSecurityAuditLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
UPDATE "User"
|
||||||
|
SET "emailVerified" = NOW()
|
||||||
|
FROM "Subscription"
|
||||||
|
WHERE "User"."id" = "Subscription"."userId"
|
||||||
|
AND "Subscription"."status" = 'ACTIVE'
|
||||||
|
AND "User"."emailVerified" IS NULL
|
||||||
@ -40,12 +40,38 @@ model User {
|
|||||||
twoFactorSecret String?
|
twoFactorSecret String?
|
||||||
twoFactorEnabled Boolean @default(false)
|
twoFactorEnabled Boolean @default(false)
|
||||||
twoFactorBackupCodes String?
|
twoFactorBackupCodes String?
|
||||||
|
|
||||||
VerificationToken VerificationToken[]
|
VerificationToken VerificationToken[]
|
||||||
Template Template[]
|
Template Template[]
|
||||||
|
securityAuditLogs UserSecurityAuditLog[]
|
||||||
|
|
||||||
@@index([email])
|
@@index([email])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum UserSecurityAuditLogType {
|
||||||
|
ACCOUNT_PROFILE_UPDATE
|
||||||
|
ACCOUNT_SSO_LINK
|
||||||
|
AUTH_2FA_DISABLE
|
||||||
|
AUTH_2FA_ENABLE
|
||||||
|
PASSWORD_RESET
|
||||||
|
PASSWORD_UPDATE
|
||||||
|
SIGN_OUT
|
||||||
|
SIGN_IN
|
||||||
|
SIGN_IN_FAIL
|
||||||
|
SIGN_IN_2FA_FAIL
|
||||||
|
}
|
||||||
|
|
||||||
|
model UserSecurityAuditLog {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
userId Int
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
type UserSecurityAuditLogType
|
||||||
|
userAgent String?
|
||||||
|
ipAddress String?
|
||||||
|
|
||||||
|
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
model PasswordResetToken {
|
model PasswordResetToken {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
token String @unique
|
token String @unique
|
||||||
@ -161,9 +187,9 @@ model DocumentMeta {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
subject String?
|
subject String?
|
||||||
message String?
|
message String?
|
||||||
timezone String? @db.Text @default("Etc/UTC")
|
timezone String? @default("Etc/UTC") @db.Text
|
||||||
password String?
|
password String?
|
||||||
dateFormat String? @db.Text @default("yyyy-MM-dd hh:mm a")
|
dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text
|
||||||
documentId Int @unique
|
documentId Int @unique
|
||||||
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||||
redirectUrl String? @db.Text
|
redirectUrl String? @db.Text
|
||||||
@ -184,6 +210,13 @@ enum SigningStatus {
|
|||||||
SIGNED
|
SIGNED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum RecipientRole {
|
||||||
|
CC
|
||||||
|
SIGNER
|
||||||
|
VIEWER
|
||||||
|
APPROVER
|
||||||
|
}
|
||||||
|
|
||||||
model Recipient {
|
model Recipient {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
documentId Int?
|
documentId Int?
|
||||||
@ -193,6 +226,7 @@ model Recipient {
|
|||||||
token String
|
token String
|
||||||
expired DateTime?
|
expired DateTime?
|
||||||
signedAt DateTime?
|
signedAt DateTime?
|
||||||
|
role RecipientRole @default(SIGNER)
|
||||||
readStatus ReadStatus @default(NOT_OPENED)
|
readStatus ReadStatus @default(NOT_OPENED)
|
||||||
signingStatus SigningStatus @default(NOT_SIGNED)
|
signingStatus SigningStatus @default(NOT_SIGNED)
|
||||||
sendStatus SendStatus @default(NOT_SENT)
|
sendStatus SendStatus @default(NOT_SENT)
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { CreateNextContextOptions } from '@trpc/server/adapters/next';
|
import type { CreateNextContextOptions } from '@trpc/server/adapters/next';
|
||||||
|
|
||||||
import { getServerSession } from '@documenso/lib/next-auth/get-server-session';
|
import { getServerSession } from '@documenso/lib/next-auth/get-server-session';
|
||||||
|
|
||||||
@ -9,6 +9,7 @@ export const createTrpcContext = async ({ req, res }: CreateNextContextOptions)
|
|||||||
return {
|
return {
|
||||||
session: null,
|
session: null,
|
||||||
user: null,
|
user: null,
|
||||||
|
req,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -16,12 +17,14 @@ export const createTrpcContext = async ({ req, res }: CreateNextContextOptions)
|
|||||||
return {
|
return {
|
||||||
session: null,
|
session: null,
|
||||||
user: null,
|
user: null,
|
||||||
|
req,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
session,
|
session,
|
||||||
user,
|
user,
|
||||||
|
req,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { DocumentStatus, FieldType } from '@documenso/prisma/client';
|
import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||||
|
|
||||||
export const ZGetDocumentByIdQuerySchema = z.object({
|
export const ZGetDocumentByIdQuerySchema = z.object({
|
||||||
id: z.number().min(1),
|
id: z.number().min(1),
|
||||||
@ -35,6 +35,7 @@ export const ZSetRecipientsForDocumentMutationSchema = z.object({
|
|||||||
id: z.number().nullish(),
|
id: z.number().nullish(),
|
||||||
email: z.string().min(1).email(),
|
email: z.string().min(1).email(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
|
role: z.nativeEnum(RecipientRole),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,15 +1,18 @@
|
|||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
|
|
||||||
|
import { findUserSecurityAuditLogs } from '@documenso/lib/server-only/user/find-user-security-audit-logs';
|
||||||
import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password';
|
import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password';
|
||||||
import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id';
|
import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id';
|
||||||
import { resetPassword } from '@documenso/lib/server-only/user/reset-password';
|
import { resetPassword } from '@documenso/lib/server-only/user/reset-password';
|
||||||
import { sendConfirmationToken } from '@documenso/lib/server-only/user/send-confirmation-token';
|
import { sendConfirmationToken } from '@documenso/lib/server-only/user/send-confirmation-token';
|
||||||
import { updatePassword } from '@documenso/lib/server-only/user/update-password';
|
import { updatePassword } from '@documenso/lib/server-only/user/update-password';
|
||||||
import { updateProfile } from '@documenso/lib/server-only/user/update-profile';
|
import { updateProfile } from '@documenso/lib/server-only/user/update-profile';
|
||||||
|
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
|
|
||||||
import { adminProcedure, authenticatedProcedure, procedure, router } from '../trpc';
|
import { adminProcedure, authenticatedProcedure, procedure, router } from '../trpc';
|
||||||
import {
|
import {
|
||||||
ZConfirmEmailMutationSchema,
|
ZConfirmEmailMutationSchema,
|
||||||
|
ZFindUserSecurityAuditLogsSchema,
|
||||||
ZForgotPasswordFormSchema,
|
ZForgotPasswordFormSchema,
|
||||||
ZResetPasswordFormSchema,
|
ZResetPasswordFormSchema,
|
||||||
ZRetrieveUserByIdQuerySchema,
|
ZRetrieveUserByIdQuerySchema,
|
||||||
@ -18,6 +21,22 @@ import {
|
|||||||
} from './schema';
|
} from './schema';
|
||||||
|
|
||||||
export const profileRouter = router({
|
export const profileRouter = router({
|
||||||
|
findUserSecurityAuditLogs: authenticatedProcedure
|
||||||
|
.input(ZFindUserSecurityAuditLogsSchema)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
try {
|
||||||
|
return await findUserSecurityAuditLogs({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
...input,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'We were unable to find user security audit logs. Please try again.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
getUser: adminProcedure.input(ZRetrieveUserByIdQuerySchema).query(async ({ input }) => {
|
getUser: adminProcedure.input(ZRetrieveUserByIdQuerySchema).query(async ({ input }) => {
|
||||||
try {
|
try {
|
||||||
const { id } = input;
|
const { id } = input;
|
||||||
@ -41,6 +60,7 @@ export const profileRouter = router({
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
name,
|
name,
|
||||||
signature,
|
signature,
|
||||||
|
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@ -63,6 +83,7 @@ export const profileRouter = router({
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
password,
|
password,
|
||||||
currentPassword,
|
currentPassword,
|
||||||
|
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
let message =
|
let message =
|
||||||
@ -91,13 +112,14 @@ export const profileRouter = router({
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
resetPassword: procedure.input(ZResetPasswordFormSchema).mutation(async ({ input }) => {
|
resetPassword: procedure.input(ZResetPasswordFormSchema).mutation(async ({ input, ctx }) => {
|
||||||
try {
|
try {
|
||||||
const { password, token } = input;
|
const { password, token } = input;
|
||||||
|
|
||||||
return await resetPassword({
|
return await resetPassword({
|
||||||
token,
|
token,
|
||||||
password,
|
password,
|
||||||
|
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
let message = 'We were unable to reset your password. Please try again.';
|
let message = 'We were unable to reset your password. Please try again.';
|
||||||
|
|||||||
@ -2,6 +2,11 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
import { ZCurrentPasswordSchema, ZPasswordSchema } from '../auth-router/schema';
|
import { ZCurrentPasswordSchema, ZPasswordSchema } from '../auth-router/schema';
|
||||||
|
|
||||||
|
export const ZFindUserSecurityAuditLogsSchema = z.object({
|
||||||
|
page: z.number().optional(),
|
||||||
|
perPage: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
export const ZRetrieveUserByIdQuerySchema = z.object({
|
export const ZRetrieveUserByIdQuerySchema = z.object({
|
||||||
id: z.number().min(1),
|
id: z.number().min(1),
|
||||||
});
|
});
|
||||||
@ -29,6 +34,7 @@ export const ZConfirmEmailMutationSchema = z.object({
|
|||||||
email: z.string().email().min(1),
|
email: z.string().email().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export type TFindUserSecurityAuditLogsSchema = z.infer<typeof ZFindUserSecurityAuditLogsSchema>;
|
||||||
export type TRetrieveUserByIdQuerySchema = z.infer<typeof ZRetrieveUserByIdQuerySchema>;
|
export type TRetrieveUserByIdQuerySchema = z.infer<typeof ZRetrieveUserByIdQuerySchema>;
|
||||||
export type TUpdateProfileMutationSchema = z.infer<typeof ZUpdateProfileMutationSchema>;
|
export type TUpdateProfileMutationSchema = z.infer<typeof ZUpdateProfileMutationSchema>;
|
||||||
export type TUpdatePasswordMutationSchema = z.infer<typeof ZUpdatePasswordMutationSchema>;
|
export type TUpdatePasswordMutationSchema = z.infer<typeof ZUpdatePasswordMutationSchema>;
|
||||||
|
|||||||
@ -25,6 +25,7 @@ export const recipientRouter = router({
|
|||||||
id: signer.nativeId,
|
id: signer.nativeId,
|
||||||
email: signer.email,
|
email: signer.email,
|
||||||
name: signer.name,
|
name: signer.name,
|
||||||
|
role: signer.role,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { RecipientRole } from '@documenso/prisma/client';
|
||||||
|
|
||||||
export const ZAddSignersMutationSchema = z
|
export const ZAddSignersMutationSchema = z
|
||||||
.object({
|
.object({
|
||||||
documentId: z.number(),
|
documentId: z.number(),
|
||||||
@ -8,6 +10,7 @@ export const ZAddSignersMutationSchema = z
|
|||||||
nativeId: z.number().optional(),
|
nativeId: z.number().optional(),
|
||||||
email: z.string().email().min(1),
|
email: z.string().email().min(1),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
|
role: z.nativeEnum(RecipientRole),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
|
|
||||||
|
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
|
||||||
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
|
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
|
||||||
import { createTemplate } from '@documenso/lib/server-only/template/create-template';
|
import { createTemplate } from '@documenso/lib/server-only/template/create-template';
|
||||||
import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template';
|
import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template';
|
||||||
@ -41,6 +42,12 @@ export const templateRouter = router({
|
|||||||
try {
|
try {
|
||||||
const { templateId } = input;
|
const { templateId } = input;
|
||||||
|
|
||||||
|
const limits = await getServerLimits({ email: ctx.user.email });
|
||||||
|
|
||||||
|
if (limits.remaining.documents === 0) {
|
||||||
|
throw new Error('You have reached your document limit.');
|
||||||
|
}
|
||||||
|
|
||||||
return await createDocumentFromTemplate({
|
return await createDocumentFromTemplate({
|
||||||
templateId,
|
templateId,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { enableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/en
|
|||||||
import { getBackupCodes } from '@documenso/lib/server-only/2fa/get-backup-code';
|
import { getBackupCodes } from '@documenso/lib/server-only/2fa/get-backup-code';
|
||||||
import { setupTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/setup-2fa';
|
import { setupTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/setup-2fa';
|
||||||
import { compareSync } from '@documenso/lib/server-only/auth/hash';
|
import { compareSync } from '@documenso/lib/server-only/auth/hash';
|
||||||
|
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
|
|
||||||
import { authenticatedProcedure, router } from '../trpc';
|
import { authenticatedProcedure, router } from '../trpc';
|
||||||
import {
|
import {
|
||||||
@ -23,7 +24,10 @@ export const twoFactorAuthenticationRouter = router({
|
|||||||
|
|
||||||
const { password } = input;
|
const { password } = input;
|
||||||
|
|
||||||
return await setupTwoFactorAuthentication({ user, password });
|
return await setupTwoFactorAuthentication({
|
||||||
|
user,
|
||||||
|
password,
|
||||||
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
enable: authenticatedProcedure
|
enable: authenticatedProcedure
|
||||||
@ -34,7 +38,11 @@ export const twoFactorAuthenticationRouter = router({
|
|||||||
|
|
||||||
const { code } = input;
|
const { code } = input;
|
||||||
|
|
||||||
return await enableTwoFactorAuthentication({ user, code });
|
return await enableTwoFactorAuthentication({
|
||||||
|
user,
|
||||||
|
code,
|
||||||
|
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
@ -53,7 +61,12 @@ export const twoFactorAuthenticationRouter = router({
|
|||||||
|
|
||||||
const { password, backupCode } = input;
|
const { password, backupCode } = input;
|
||||||
|
|
||||||
return await disableTwoFactorAuthentication({ user, password, backupCode });
|
return await disableTwoFactorAuthentication({
|
||||||
|
user,
|
||||||
|
password,
|
||||||
|
backupCode,
|
||||||
|
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
|
|||||||
@ -1,21 +1,33 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
import { VariantProps, cva } from 'class-variance-authority';
|
import type { VariantProps } from 'class-variance-authority';
|
||||||
|
import { cva } from 'class-variance-authority';
|
||||||
|
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
|
|
||||||
const alertVariants = cva(
|
const alertVariants = cva(
|
||||||
'relative w-full rounded-lg border p-4 [&>svg]:absolute [&>svg]:text-foreground [&>svg]:left-4 [&>svg]:top-4 [&>svg+div]:translate-y-[-3px] [&:has(svg)]:pl-11',
|
'relative w-full rounded-lg p-4 [&>svg]:absolute [&>svg]:text-foreground [&>svg]:left-4 [&>svg]:top-4 [&>svg+div]:translate-y-[-3px] [&>svg~*]:pl-8',
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: 'bg-background text-foreground',
|
default:
|
||||||
destructive:
|
'bg-green-50 text-green-700 [&_.alert-title]:text-green-800 [&>svg]:text-green-400',
|
||||||
'text-destructive border-destructive/50 dark:border-destructive [&>svg]:text-destructive text-destructive',
|
neutral:
|
||||||
|
'bg-gray-50 dark:bg-neutral-900/20 text-muted-foreground [&_.alert-title]:text-foreground',
|
||||||
|
secondary: 'bg-blue-50 text-blue-700 [&_.alert-title]:text-blue-800 [&>svg]:text-blue-400',
|
||||||
|
destructive: 'bg-red-50 text-red-700 [&_.alert-title]:text-red-800 [&>svg]:text-red-400',
|
||||||
|
warning:
|
||||||
|
'bg-yellow-50 text-yellow-700 [&_.alert-title]:text-yellow-800 [&>svg]:text-yellow-400',
|
||||||
|
},
|
||||||
|
padding: {
|
||||||
|
tighter: 'p-2',
|
||||||
|
tight: 'px-4 py-2',
|
||||||
|
default: 'p-4',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: 'default',
|
variant: 'default',
|
||||||
|
padding: 'default',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -23,19 +35,20 @@ const alertVariants = cva(
|
|||||||
const Alert = React.forwardRef<
|
const Alert = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||||
>(({ className, variant, ...props }, ref) => (
|
>(({ className, variant, padding, ...props }, ref) => (
|
||||||
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant, padding }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
));
|
));
|
||||||
|
|
||||||
Alert.displayName = 'Alert';
|
Alert.displayName = 'Alert';
|
||||||
|
|
||||||
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||||
({ className, ...props }, ref) => (
|
({ className, ...props }, ref) => (
|
||||||
<h5
|
<h5 ref={ref} className={cn('alert-title text-base font-medium', className)} {...props} />
|
||||||
ref={ref}
|
|
||||||
className={cn('mb-1 font-medium leading-none tracking-tight', className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -45,7 +58,7 @@ const AlertDescription = React.forwardRef<
|
|||||||
HTMLParagraphElement,
|
HTMLParagraphElement,
|
||||||
React.HTMLAttributes<HTMLParagraphElement>
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<div ref={ref} className={cn('text-sm [&_p]:leading-relaxed', className)} {...props} />
|
<div ref={ref} className={cn('text-sm', className)} {...props} />
|
||||||
));
|
));
|
||||||
|
|
||||||
AlertDescription.displayName = 'AlertDescription';
|
AlertDescription.displayName = 'AlertDescription';
|
||||||
|
|||||||
@ -2,36 +2,53 @@
|
|||||||
|
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
|
|
||||||
import {
|
import type {
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
PaginationState,
|
PaginationState,
|
||||||
Table as TTable,
|
Table as TTable,
|
||||||
Updater,
|
Updater,
|
||||||
flexRender,
|
VisibilityState,
|
||||||
getCoreRowModel,
|
|
||||||
useReactTable,
|
|
||||||
} from '@tanstack/react-table';
|
} from '@tanstack/react-table';
|
||||||
|
import { flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
import { Skeleton } from './skeleton';
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './table';
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './table';
|
||||||
|
|
||||||
export type DataTableChildren<TData> = (_table: TTable<TData>) => React.ReactNode;
|
export type DataTableChildren<TData> = (_table: TTable<TData>) => React.ReactNode;
|
||||||
|
|
||||||
export interface DataTableProps<TData, TValue> {
|
export interface DataTableProps<TData, TValue> {
|
||||||
columns: ColumnDef<TData, TValue>[];
|
columns: ColumnDef<TData, TValue>[];
|
||||||
|
columnVisibility?: VisibilityState;
|
||||||
data: TData[];
|
data: TData[];
|
||||||
perPage?: number;
|
perPage?: number;
|
||||||
currentPage?: number;
|
currentPage?: number;
|
||||||
totalPages?: number;
|
totalPages?: number;
|
||||||
onPaginationChange?: (_page: number, _perPage: number) => void;
|
onPaginationChange?: (_page: number, _perPage: number) => void;
|
||||||
|
onClearFilters?: () => void;
|
||||||
|
hasFilters?: boolean;
|
||||||
children?: DataTableChildren<TData>;
|
children?: DataTableChildren<TData>;
|
||||||
|
skeleton?: {
|
||||||
|
enable: boolean;
|
||||||
|
rows: number;
|
||||||
|
component?: React.ReactNode;
|
||||||
|
};
|
||||||
|
error?: {
|
||||||
|
enable: boolean;
|
||||||
|
component?: React.ReactNode;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DataTable<TData, TValue>({
|
export function DataTable<TData, TValue>({
|
||||||
columns,
|
columns,
|
||||||
|
columnVisibility,
|
||||||
data,
|
data,
|
||||||
|
error,
|
||||||
perPage,
|
perPage,
|
||||||
currentPage,
|
currentPage,
|
||||||
totalPages,
|
totalPages,
|
||||||
|
skeleton,
|
||||||
|
hasFilters,
|
||||||
|
onClearFilters,
|
||||||
onPaginationChange,
|
onPaginationChange,
|
||||||
children,
|
children,
|
||||||
}: DataTableProps<TData, TValue>) {
|
}: DataTableProps<TData, TValue>) {
|
||||||
@ -67,6 +84,7 @@ export function DataTable<TData, TValue>({
|
|||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
state: {
|
state: {
|
||||||
pagination: manualPagination ? pagination : undefined,
|
pagination: manualPagination ? pagination : undefined,
|
||||||
|
columnVisibility,
|
||||||
},
|
},
|
||||||
manualPagination,
|
manualPagination,
|
||||||
pageCount: totalPages,
|
pageCount: totalPages,
|
||||||
@ -103,10 +121,31 @@ export function DataTable<TData, TValue>({
|
|||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
|
) : error?.enable ? (
|
||||||
|
<TableRow>
|
||||||
|
{error.component ?? (
|
||||||
|
<TableCell colSpan={columns.length} className="h-32 text-center">
|
||||||
|
Something went wrong.
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
) : skeleton?.enable ? (
|
||||||
|
Array.from({ length: skeleton.rows }).map((_, i) => (
|
||||||
|
<TableRow key={`skeleton-row-${i}`}>{skeleton.component ?? <Skeleton />}</TableRow>
|
||||||
|
))
|
||||||
) : (
|
) : (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
<TableCell colSpan={columns.length} className="h-32 text-center">
|
||||||
No results.
|
<p>No results found</p>
|
||||||
|
|
||||||
|
{hasFilters && onClearFilters !== undefined && (
|
||||||
|
<button
|
||||||
|
onClick={() => onClearFilters()}
|
||||||
|
className="text-foreground mt-1 text-sm"
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { motion } from 'framer-motion';
|
|||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { useDropzone } from 'react-dropzone';
|
import { useDropzone } from 'react-dropzone';
|
||||||
|
|
||||||
|
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||||
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
|
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
|
||||||
|
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
@ -89,6 +90,7 @@ export type DocumentDropzoneProps = {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
disabledMessage?: string;
|
disabledMessage?: string;
|
||||||
onDrop?: (_file: File) => void | Promise<void>;
|
onDrop?: (_file: File) => void | Promise<void>;
|
||||||
|
onDropRejected?: () => void | Promise<void>;
|
||||||
type?: 'document' | 'template';
|
type?: 'document' | 'template';
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
@ -96,6 +98,7 @@ export type DocumentDropzoneProps = {
|
|||||||
export const DocumentDropzone = ({
|
export const DocumentDropzone = ({
|
||||||
className,
|
className,
|
||||||
onDrop,
|
onDrop,
|
||||||
|
onDropRejected,
|
||||||
disabled,
|
disabled,
|
||||||
disabledMessage = 'You cannot upload documents at this time.',
|
disabledMessage = 'You cannot upload documents at this time.',
|
||||||
type = 'document',
|
type = 'document',
|
||||||
@ -112,7 +115,12 @@ export const DocumentDropzone = ({
|
|||||||
void onDrop(acceptedFile);
|
void onDrop(acceptedFile);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
maxSize: megabytesToBytes(50),
|
onDropRejected: () => {
|
||||||
|
if (onDropRejected) {
|
||||||
|
void onDropRejected();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT),
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -175,7 +183,7 @@ export const DocumentDropzone = ({
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="text-muted-foreground/80 mt-1 text-sm">
|
<p className="text-muted-foreground/80 mt-1 text-sm">
|
||||||
{disabled ? disabledMessage : 'Drag & drop your document here.'}
|
{disabled ? disabledMessage : 'Drag & drop your PDF here.'}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { Caveat } from 'next/font/google';
|
import { Caveat } from 'next/font/google';
|
||||||
|
|
||||||
@ -10,8 +10,10 @@ import { useFieldArray, useForm } from 'react-hook-form';
|
|||||||
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
|
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
|
||||||
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
|
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
|
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||||
import { nanoid } from '@documenso/lib/universal/id';
|
import { nanoid } from '@documenso/lib/universal/id';
|
||||||
import type { Field, Recipient } from '@documenso/prisma/client';
|
import type { Field, Recipient } from '@documenso/prisma/client';
|
||||||
|
import { RecipientRole } from '@documenso/prisma/client';
|
||||||
import { FieldType, SendStatus } from '@documenso/prisma/client';
|
import { FieldType, SendStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
@ -30,8 +32,7 @@ import {
|
|||||||
DocumentFlowFormContainerStep,
|
DocumentFlowFormContainerStep,
|
||||||
} from './document-flow-root';
|
} from './document-flow-root';
|
||||||
import { FieldItem } from './field-item';
|
import { FieldItem } from './field-item';
|
||||||
import type { DocumentFlowStep } from './types';
|
import { type DocumentFlowStep, FRIENDLY_FIELD_TYPE } from './types';
|
||||||
import { FRIENDLY_FIELD_TYPE } from './types';
|
|
||||||
|
|
||||||
const fontCaveat = Caveat({
|
const fontCaveat = Caveat({
|
||||||
weight: ['500'],
|
weight: ['500'],
|
||||||
@ -103,6 +104,12 @@ export const AddFieldsFormPartial = ({
|
|||||||
|
|
||||||
const hasSelectedSignerBeenSent = selectedSigner?.sendStatus === SendStatus.SENT;
|
const hasSelectedSignerBeenSent = selectedSigner?.sendStatus === SendStatus.SENT;
|
||||||
|
|
||||||
|
const isFieldsDisabled =
|
||||||
|
!selectedSigner ||
|
||||||
|
hasSelectedSignerBeenSent ||
|
||||||
|
selectedSigner?.role === RecipientRole.VIEWER ||
|
||||||
|
selectedSigner?.role === RecipientRole.CC;
|
||||||
|
|
||||||
const [isFieldWithinBounds, setIsFieldWithinBounds] = useState(false);
|
const [isFieldWithinBounds, setIsFieldWithinBounds] = useState(false);
|
||||||
const [coords, setCoords] = useState({
|
const [coords, setCoords] = useState({
|
||||||
x: 0,
|
x: 0,
|
||||||
@ -282,12 +289,28 @@ export const AddFieldsFormPartial = ({
|
|||||||
setSelectedSigner(recipients.find((r) => r.sendStatus !== SendStatus.SENT) ?? recipients[0]);
|
setSelectedSigner(recipients.find((r) => r.sendStatus !== SendStatus.SENT) ?? recipients[0]);
|
||||||
}, [recipients]);
|
}, [recipients]);
|
||||||
|
|
||||||
|
const recipientsByRole = useMemo(() => {
|
||||||
|
const recipientsByRole: Record<RecipientRole, Recipient[]> = {
|
||||||
|
CC: [],
|
||||||
|
VIEWER: [],
|
||||||
|
SIGNER: [],
|
||||||
|
APPROVER: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
recipients.forEach((recipient) => {
|
||||||
|
recipientsByRole[recipient.role].push(recipient);
|
||||||
|
});
|
||||||
|
|
||||||
|
return recipientsByRole;
|
||||||
|
}, [recipients]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DocumentFlowFormContainerHeader
|
<DocumentFlowFormContainerHeader
|
||||||
title={documentFlow.title}
|
title={documentFlow.title}
|
||||||
description={documentFlow.description}
|
description={documentFlow.description}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DocumentFlowFormContainerContent>
|
<DocumentFlowFormContainerContent>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{selectedField && (
|
{selectedField && (
|
||||||
@ -352,17 +375,35 @@ export const AddFieldsFormPartial = ({
|
|||||||
<PopoverContent className="p-0" align="start">
|
<PopoverContent className="p-0" align="start">
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput />
|
<CommandInput />
|
||||||
|
|
||||||
<CommandEmpty>
|
<CommandEmpty>
|
||||||
<span className="text-muted-foreground inline-block px-4">
|
<span className="text-muted-foreground inline-block px-4">
|
||||||
No recipient matching this description was found.
|
No recipient matching this description was found.
|
||||||
</span>
|
</span>
|
||||||
</CommandEmpty>
|
</CommandEmpty>
|
||||||
|
|
||||||
<CommandGroup>
|
{Object.entries(recipientsByRole).map(([role, recipients], roleIndex) => (
|
||||||
{recipients.map((recipient, index) => (
|
<CommandGroup key={roleIndex}>
|
||||||
|
<div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium">
|
||||||
|
{
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
RECIPIENT_ROLES_DESCRIPTION[role as RecipientRole].roleName
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{recipients.length === 0 && (
|
||||||
|
<div
|
||||||
|
key={`${role}-empty`}
|
||||||
|
className="text-muted-foreground/80 px-4 pb-4 pt-2.5 text-center text-xs"
|
||||||
|
>
|
||||||
|
No recipients with this role
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{recipients.map((recipient) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={index}
|
key={recipient.id}
|
||||||
className={cn({
|
className={cn('!rounded-2xl px-4 last:mb-1 [&:not(:first-child)]:mt-1', {
|
||||||
'text-muted-foreground': recipient.sendStatus === SendStatus.SENT,
|
'text-muted-foreground': recipient.sendStatus === SendStatus.SENT,
|
||||||
})}
|
})}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
@ -370,10 +411,27 @@ export const AddFieldsFormPartial = ({
|
|||||||
setShowRecipientsSelector(false);
|
setShowRecipientsSelector(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<span
|
||||||
|
className={cn('text-foreground/70 truncate', {
|
||||||
|
'text-foreground': recipient === selectedSigner,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{recipient.name && (
|
||||||
|
<span title={`${recipient.name} (${recipient.email})`}>
|
||||||
|
{recipient.name} ({recipient.email})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!recipient.name && (
|
||||||
|
<span title={recipient.email}>{recipient.email}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center justify-center">
|
||||||
{recipient.sendStatus !== SendStatus.SENT ? (
|
{recipient.sendStatus !== SendStatus.SENT ? (
|
||||||
<Check
|
<Check
|
||||||
aria-hidden={recipient !== selectedSigner}
|
aria-hidden={recipient !== selectedSigner}
|
||||||
className={cn('mr-2 h-4 w-4 flex-shrink-0', {
|
className={cn('h-4 w-4 flex-shrink-0', {
|
||||||
'opacity-0': recipient !== selectedSigner,
|
'opacity-0': recipient !== selectedSigner,
|
||||||
'opacity-100': recipient === selectedSigner,
|
'opacity-100': recipient === selectedSigner,
|
||||||
})}
|
})}
|
||||||
@ -381,43 +439,30 @@ export const AddFieldsFormPartial = ({
|
|||||||
) : (
|
) : (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
<Info className="mr-2 h-4 w-4" />
|
<Info className="mx-2 h-4 w-4" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="max-w-xs">
|
|
||||||
|
<TooltipContent className="text-muted-foreground max-w-xs">
|
||||||
This document has already been sent to this recipient. You can no
|
This document has already been sent to this recipient. You can no
|
||||||
longer edit this recipient.
|
longer edit this recipient.
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
{recipient.name && (
|
|
||||||
<span
|
|
||||||
className="truncate"
|
|
||||||
title={`${recipient.name} (${recipient.email})`}
|
|
||||||
>
|
|
||||||
{recipient.name} ({recipient.email})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!recipient.name && (
|
|
||||||
<span className="truncate" title={recipient.email}>
|
|
||||||
{recipient.email}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
|
))}
|
||||||
</Command>
|
</Command>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="-mx-2 flex-1 overflow-y-auto px-2">
|
<div className="-mx-2 flex-1 overflow-y-auto px-2">
|
||||||
<div className="grid grid-cols-2 gap-x-4 gap-y-8">
|
<fieldset disabled={isFieldsDisabled} className="grid grid-cols-2 gap-x-4 gap-y-8">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="group h-full w-full"
|
className="group h-full w-full"
|
||||||
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
|
|
||||||
onClick={() => setSelectedField(FieldType.SIGNATURE)}
|
onClick={() => setSelectedField(FieldType.SIGNATURE)}
|
||||||
onMouseDown={() => setSelectedField(FieldType.SIGNATURE)}
|
onMouseDown={() => setSelectedField(FieldType.SIGNATURE)}
|
||||||
data-selected={selectedField === FieldType.SIGNATURE ? true : undefined}
|
data-selected={selectedField === FieldType.SIGNATURE ? true : undefined}
|
||||||
@ -441,7 +486,6 @@ export const AddFieldsFormPartial = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="group h-full w-full"
|
className="group h-full w-full"
|
||||||
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
|
|
||||||
onClick={() => setSelectedField(FieldType.EMAIL)}
|
onClick={() => setSelectedField(FieldType.EMAIL)}
|
||||||
onMouseDown={() => setSelectedField(FieldType.EMAIL)}
|
onMouseDown={() => setSelectedField(FieldType.EMAIL)}
|
||||||
data-selected={selectedField === FieldType.EMAIL ? true : undefined}
|
data-selected={selectedField === FieldType.EMAIL ? true : undefined}
|
||||||
@ -464,7 +508,6 @@ export const AddFieldsFormPartial = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="group h-full w-full"
|
className="group h-full w-full"
|
||||||
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
|
|
||||||
onClick={() => setSelectedField(FieldType.NAME)}
|
onClick={() => setSelectedField(FieldType.NAME)}
|
||||||
onMouseDown={() => setSelectedField(FieldType.NAME)}
|
onMouseDown={() => setSelectedField(FieldType.NAME)}
|
||||||
data-selected={selectedField === FieldType.NAME ? true : undefined}
|
data-selected={selectedField === FieldType.NAME ? true : undefined}
|
||||||
@ -487,7 +530,6 @@ export const AddFieldsFormPartial = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="group h-full w-full"
|
className="group h-full w-full"
|
||||||
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
|
|
||||||
onClick={() => setSelectedField(FieldType.DATE)}
|
onClick={() => setSelectedField(FieldType.DATE)}
|
||||||
onMouseDown={() => setSelectedField(FieldType.DATE)}
|
onMouseDown={() => setSelectedField(FieldType.DATE)}
|
||||||
data-selected={selectedField === FieldType.DATE ? true : undefined}
|
data-selected={selectedField === FieldType.DATE ? true : undefined}
|
||||||
@ -506,7 +548,7 @@ export const AddFieldsFormPartial = ({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DocumentFlowFormContainerContent>
|
</DocumentFlowFormContainerContent>
|
||||||
|
|||||||
@ -4,19 +4,20 @@ import React, { useId } from 'react';
|
|||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { Plus, Trash } from 'lucide-react';
|
import { BadgeCheck, Copy, Eye, PencilLine, Plus, Trash } from 'lucide-react';
|
||||||
import { Controller, useFieldArray, useForm } from 'react-hook-form';
|
import { Controller, useFieldArray, useForm } from 'react-hook-form';
|
||||||
|
|
||||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||||
import { nanoid } from '@documenso/lib/universal/id';
|
import { nanoid } from '@documenso/lib/universal/id';
|
||||||
import type { Field, Recipient } from '@documenso/prisma/client';
|
import type { Field, Recipient } from '@documenso/prisma/client';
|
||||||
import { DocumentStatus, SendStatus } from '@documenso/prisma/client';
|
import { DocumentStatus, RecipientRole, SendStatus } from '@documenso/prisma/client';
|
||||||
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||||
|
|
||||||
import { Button } from '../button';
|
import { Button } from '../button';
|
||||||
import { FormErrorMessage } from '../form/form-error-message';
|
import { FormErrorMessage } from '../form/form-error-message';
|
||||||
import { Input } from '../input';
|
import { Input } from '../input';
|
||||||
import { Label } from '../label';
|
import { Label } from '../label';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger } from '../select';
|
||||||
import { useStep } from '../stepper';
|
import { useStep } from '../stepper';
|
||||||
import { useToast } from '../use-toast';
|
import { useToast } from '../use-toast';
|
||||||
import type { TAddSignersFormSchema } from './add-signers.types';
|
import type { TAddSignersFormSchema } from './add-signers.types';
|
||||||
@ -28,8 +29,16 @@ import {
|
|||||||
DocumentFlowFormContainerHeader,
|
DocumentFlowFormContainerHeader,
|
||||||
DocumentFlowFormContainerStep,
|
DocumentFlowFormContainerStep,
|
||||||
} from './document-flow-root';
|
} from './document-flow-root';
|
||||||
|
import { ShowFieldItem } from './show-field-item';
|
||||||
import type { DocumentFlowStep } from './types';
|
import type { DocumentFlowStep } from './types';
|
||||||
|
|
||||||
|
const ROLE_ICONS: Record<RecipientRole, JSX.Element> = {
|
||||||
|
SIGNER: <PencilLine className="h-4 w-4" />,
|
||||||
|
APPROVER: <BadgeCheck className="h-4 w-4" />,
|
||||||
|
CC: <Copy className="h-4 w-4" />,
|
||||||
|
VIEWER: <Eye className="h-4 w-4" />,
|
||||||
|
};
|
||||||
|
|
||||||
export type AddSignersFormProps = {
|
export type AddSignersFormProps = {
|
||||||
documentFlow: DocumentFlowStep;
|
documentFlow: DocumentFlowStep;
|
||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
@ -42,7 +51,7 @@ export const AddSignersFormPartial = ({
|
|||||||
documentFlow,
|
documentFlow,
|
||||||
recipients,
|
recipients,
|
||||||
document,
|
document,
|
||||||
fields: _fields,
|
fields,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: AddSignersFormProps) => {
|
}: AddSignersFormProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@ -66,12 +75,14 @@ export const AddSignersFormPartial = ({
|
|||||||
formId: String(recipient.id),
|
formId: String(recipient.id),
|
||||||
name: recipient.name,
|
name: recipient.name,
|
||||||
email: recipient.email,
|
email: recipient.email,
|
||||||
|
role: recipient.role,
|
||||||
}))
|
}))
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
formId: initialId,
|
formId: initialId,
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
|
role: RecipientRole.SIGNER,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -103,6 +114,7 @@ export const AddSignersFormPartial = ({
|
|||||||
formId: nanoid(12),
|
formId: nanoid(12),
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
|
role: RecipientRole.SIGNER,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -136,6 +148,10 @@ export const AddSignersFormPartial = ({
|
|||||||
/>
|
/>
|
||||||
<DocumentFlowFormContainerContent>
|
<DocumentFlowFormContainerContent>
|
||||||
<div className="flex w-full flex-col gap-y-4">
|
<div className="flex w-full flex-col gap-y-4">
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<ShowFieldItem key={index} field={field} recipients={recipients} />
|
||||||
|
))}
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{signers.map((signer, index) => (
|
{signers.map((signer, index) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
@ -184,6 +200,48 @@ export const AddSignersFormPartial = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="w-[60px]">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={`signers.${index}.role`}
|
||||||
|
render={({ field: { value, onChange } }) => (
|
||||||
|
<Select value={value} onValueChange={(x) => onChange(x)}>
|
||||||
|
<SelectTrigger className="bg-background">{ROLE_ICONS[value]}</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent className="" align="end">
|
||||||
|
<SelectItem value={RecipientRole.SIGNER}>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span>
|
||||||
|
Signer
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
|
||||||
|
<SelectItem value={RecipientRole.CC}>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span>
|
||||||
|
Receives copy
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
|
||||||
|
<SelectItem value={RecipientRole.APPROVER}>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span>
|
||||||
|
Approver
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
|
||||||
|
<SelectItem value={RecipientRole.VIEWER}>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
|
||||||
|
Viewer
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { RecipientRole } from '.prisma/client';
|
||||||
|
|
||||||
export const ZAddSignersFormSchema = z
|
export const ZAddSignersFormSchema = z
|
||||||
.object({
|
.object({
|
||||||
signers: z.array(
|
signers: z.array(
|
||||||
@ -8,6 +10,7 @@ export const ZAddSignersFormSchema = z
|
|||||||
nativeId: z.number().optional(),
|
nativeId: z.number().optional(),
|
||||||
email: z.string().email().min(1),
|
email: z.string().email().min(1),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
|
role: z.nativeEnum(RecipientRole),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|||||||
@ -38,6 +38,7 @@ import {
|
|||||||
DocumentFlowFormContainerHeader,
|
DocumentFlowFormContainerHeader,
|
||||||
DocumentFlowFormContainerStep,
|
DocumentFlowFormContainerStep,
|
||||||
} from './document-flow-root';
|
} from './document-flow-root';
|
||||||
|
import { ShowFieldItem } from './show-field-item';
|
||||||
import type { DocumentFlowStep } from './types';
|
import type { DocumentFlowStep } from './types';
|
||||||
|
|
||||||
export type AddSubjectFormProps = {
|
export type AddSubjectFormProps = {
|
||||||
@ -98,6 +99,10 @@ export const AddSubjectFormPartial = ({
|
|||||||
/>
|
/>
|
||||||
<DocumentFlowFormContainerContent>
|
<DocumentFlowFormContainerContent>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<ShowFieldItem key={index} field={field} recipients={recipients} />
|
||||||
|
))}
|
||||||
|
|
||||||
<div className="flex flex-col gap-y-4">
|
<div className="flex flex-col gap-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="subject">
|
<Label htmlFor="subject">
|
||||||
@ -106,7 +111,6 @@ export const AddSubjectFormPartial = ({
|
|||||||
|
|
||||||
<Input
|
<Input
|
||||||
id="subject"
|
id="subject"
|
||||||
// placeholder="Subject"
|
|
||||||
className="bg-background mt-2"
|
className="bg-background mt-2"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
{...register('meta.subject')}
|
{...register('meta.subject')}
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import {
|
|||||||
DocumentFlowFormContainerHeader,
|
DocumentFlowFormContainerHeader,
|
||||||
DocumentFlowFormContainerStep,
|
DocumentFlowFormContainerStep,
|
||||||
} from './document-flow-root';
|
} from './document-flow-root';
|
||||||
|
import { ShowFieldItem } from './show-field-item';
|
||||||
import type { DocumentFlowStep } from './types';
|
import type { DocumentFlowStep } from './types';
|
||||||
|
|
||||||
export type AddTitleFormProps = {
|
export type AddTitleFormProps = {
|
||||||
@ -29,8 +30,8 @@ export type AddTitleFormProps = {
|
|||||||
|
|
||||||
export const AddTitleFormPartial = ({
|
export const AddTitleFormPartial = ({
|
||||||
documentFlow,
|
documentFlow,
|
||||||
recipients: _recipients,
|
recipients,
|
||||||
fields: _fields,
|
fields,
|
||||||
document,
|
document,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: AddTitleFormProps) => {
|
}: AddTitleFormProps) => {
|
||||||
@ -55,6 +56,10 @@ export const AddTitleFormPartial = ({
|
|||||||
description={documentFlow.description}
|
description={documentFlow.description}
|
||||||
/>
|
/>
|
||||||
<DocumentFlowFormContainerContent>
|
<DocumentFlowFormContainerContent>
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<ShowFieldItem key={index} field={field} recipients={recipients} />
|
||||||
|
))}
|
||||||
|
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="flex flex-col gap-y-4">
|
<div className="flex flex-col gap-y-4">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
49
packages/ui/primitives/document-flow/show-field-item.tsx
Normal file
49
packages/ui/primitives/document-flow/show-field-item.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { Prisma } from '@prisma/client';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
|
import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords';
|
||||||
|
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
import { Card, CardContent } from '../card';
|
||||||
|
import { FRIENDLY_FIELD_TYPE } from './types';
|
||||||
|
|
||||||
|
export type ShowFieldItemProps = {
|
||||||
|
field: Prisma.FieldGetPayload<null>;
|
||||||
|
recipients: Prisma.RecipientGetPayload<null>[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ShowFieldItem = ({ field, recipients }: ShowFieldItemProps) => {
|
||||||
|
const coords = useFieldPageCoords(field);
|
||||||
|
|
||||||
|
const signerEmail =
|
||||||
|
recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '';
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className={cn('pointer-events-none absolute z-10 opacity-75')}
|
||||||
|
style={{
|
||||||
|
top: `${coords.y}px`,
|
||||||
|
left: `${coords.x}px`,
|
||||||
|
height: `${coords.height}px`,
|
||||||
|
width: `${coords.width}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card className={cn('bg-background h-full w-full')}>
|
||||||
|
<CardContent
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground/50 flex h-full w-full flex-col items-center justify-center p-2',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{FRIENDLY_FIELD_TYPE[field.type]}
|
||||||
|
|
||||||
|
<p className="text-muted-foreground/50 w-full truncate text-center text-xs">
|
||||||
|
{signerEmail}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -16,6 +16,7 @@ const DPI = 2;
|
|||||||
export type SignaturePadProps = Omit<HTMLAttributes<HTMLCanvasElement>, 'onChange'> & {
|
export type SignaturePadProps = Omit<HTMLAttributes<HTMLCanvasElement>, 'onChange'> & {
|
||||||
onChange?: (_signatureDataUrl: string | null) => void;
|
onChange?: (_signatureDataUrl: string | null) => void;
|
||||||
containerClassName?: string;
|
containerClassName?: string;
|
||||||
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SignaturePad = ({
|
export const SignaturePad = ({
|
||||||
@ -23,6 +24,7 @@ export const SignaturePad = ({
|
|||||||
containerClassName,
|
containerClassName,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
onChange,
|
onChange,
|
||||||
|
disabled = false,
|
||||||
...props
|
...props
|
||||||
}: SignaturePadProps) => {
|
}: SignaturePadProps) => {
|
||||||
const $el = useRef<HTMLCanvasElement>(null);
|
const $el = useRef<HTMLCanvasElement>(null);
|
||||||
@ -214,7 +216,11 @@ export const SignaturePad = ({
|
|||||||
}, [defaultValue]);
|
}, [defaultValue]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('relative block', containerClassName)}>
|
<div
|
||||||
|
className={cn('relative block', containerClassName, {
|
||||||
|
'pointer-events-none opacity-50': disabled,
|
||||||
|
})}
|
||||||
|
>
|
||||||
<canvas
|
<canvas
|
||||||
ref={$el}
|
ref={$el}
|
||||||
className={cn('relative block dark:invert', className)}
|
className={cn('relative block dark:invert', className)}
|
||||||
|
|||||||
@ -45,6 +45,7 @@
|
|||||||
"NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID",
|
"NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID",
|
||||||
"NEXT_PUBLIC_STRIPE_FREE_PLAN_ID",
|
"NEXT_PUBLIC_STRIPE_FREE_PLAN_ID",
|
||||||
"NEXT_PUBLIC_DISABLE_SIGNUP",
|
"NEXT_PUBLIC_DISABLE_SIGNUP",
|
||||||
|
"NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT",
|
||||||
"NEXT_PRIVATE_DATABASE_URL",
|
"NEXT_PRIVATE_DATABASE_URL",
|
||||||
"NEXT_PRIVATE_DIRECT_DATABASE_URL",
|
"NEXT_PRIVATE_DIRECT_DATABASE_URL",
|
||||||
"NEXT_PRIVATE_GOOGLE_CLIENT_ID",
|
"NEXT_PRIVATE_GOOGLE_CLIENT_ID",
|
||||||
|
|||||||
Reference in New Issue
Block a user