diff --git a/apps/remix/app/components/tables/internal-audit-log-table.tsx b/apps/remix/app/components/tables/internal-audit-log-table.tsx new file mode 100644 index 000000000..f8eae0223 --- /dev/null +++ b/apps/remix/app/components/tables/internal-audit-log-table.tsx @@ -0,0 +1,95 @@ +import { msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { DateTime } from 'luxon'; +import type { DateTimeFormatOptions } from 'luxon'; +import { UAParser } from 'ua-parser-js'; + +import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n'; +import type { TDocumentAuditLog } from '@documenso/lib/types/document-audit-logs'; +import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@documenso/ui/primitives/table'; + +export type AuditLogDataTableProps = { + logs: TDocumentAuditLog[]; +}; + +const dateFormat: DateTimeFormatOptions = { + ...DateTime.DATETIME_SHORT, + hourCycle: 'h12', +}; + +/** + * DO NOT USE TRANS. YOU MUST USE _ FOR THIS FILE AND ALL CHILDREN COMPONENTS. + */ +export const InternalAuditLogTable = ({ logs }: AuditLogDataTableProps) => { + const { _ } = useLingui(); + + const parser = new UAParser(); + + const uppercaseFistLetter = (text: string) => { + return text.charAt(0).toUpperCase() + text.slice(1); + }; + + return ( + + + + {_(msg`Time`)} + {_(msg`User`)} + {_(msg`Action`)} + {_(msg`IP Address`)} + {_(msg`Browser`)} + + + + + {logs.map((log, i) => ( + + + {DateTime.fromJSDate(log.createdAt) + .setLocale(APP_I18N_OPTIONS.defaultLocale) + .toLocaleString(dateFormat)} + + + + {log.name || log.email ? ( +
+ {log.name && ( +

+ {log.name} +

+ )} + + {log.email && ( +

+ {log.email} +

+ )} +
+ ) : ( +

N/A

+ )} +
+ + + {uppercaseFistLetter(formatDocumentAuditLogAction(_, log).description)} + + + {log.ipAddress} + + + {log.userAgent ? parser.setUA(log.userAgent).getBrowser().name : 'N/A'} + +
+ ))} +
+
+ ); +}; diff --git a/apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx b/apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx new file mode 100644 index 000000000..37d0d9d84 --- /dev/null +++ b/apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx @@ -0,0 +1,171 @@ +import { msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { DateTime } from 'luxon'; +import { useSearchParams } from 'react-router'; +import { redirect } from 'react-router'; + +import { DOCUMENT_STATUS } from '@documenso/lib/constants/document'; +import { APP_I18N_OPTIONS, ZSupportedLanguageCodeSchema } from '@documenso/lib/constants/i18n'; +import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; +import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document'; +import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt'; +import { findDocumentAuditLogs } from '@documenso/lib/server-only/document/find-document-audit-logs'; +import { dynamicActivate } from '@documenso/lib/utils/i18n'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; + +import { Logo } from '~/components/branding/logo'; +import { InternalAuditLogTable } from '~/components/tables/internal-audit-log-table'; + +import type { Route } from './+types/audit-log'; + +export async function loader({ request }: Route.LoaderArgs) { + const d = new URL(request.url).searchParams.get('d'); + + if (typeof d !== 'string' || !d) { + return redirect('/'); + } + + const rawDocumentId = decryptSecondaryData(d); + + if (!rawDocumentId || isNaN(Number(rawDocumentId))) { + return redirect('/'); + } + + const documentId = Number(rawDocumentId); + + const document = await getEntireDocument({ + id: documentId, + }).catch(() => null); + + if (!document) { + return redirect('/'); + } + + const documentLanguage = ZSupportedLanguageCodeSchema.parse(document.documentMeta?.language); + + const { data: auditLogs } = await findDocumentAuditLogs({ + documentId: documentId, + userId: document.userId, + teamId: document.teamId || undefined, + perPage: 100_000, + }); + + return { + auditLogs, + document, + documentLanguage, + }; +} + +/** + * DO NOT USE TRANS. YOU MUST USE _ FOR THIS FILE AND ALL CHILDREN COMPONENTS. + * + * Cannot use dynamicActivate by itself to translate this specific page and all + * children components because `not-found.tsx` page runs and overrides the i18n. + */ +export default function AuditLog({ loaderData }: Route.ComponentProps) { + const { auditLogs, document, documentLanguage } = loaderData; + + const { i18n } = useLingui(); + + dynamicActivate(i18n, documentLanguage); + + const { _ } = useLingui(); + + return ( +
+
+

{_(msg`Version History`)}

+
+ + + +

+ {_(msg`Document ID`)} + + {document.id} +

+ +

+ {_(msg`Enclosed Document`)} + + {document.title} +

+ +

+ {_(msg`Status`)} + + + {_( + document.deletedAt ? msg`Deleted` : DOCUMENT_STATUS[document.status].description, + ).toUpperCase()} + +

+ +

+ {_(msg`Owner`)} + + + {document.user.name} ({document.user.email}) + +

+ +

+ {_(msg`Created At`)} + + + {DateTime.fromJSDate(document.createdAt) + .setLocale(APP_I18N_OPTIONS.defaultLocale) + .toFormat('yyyy-mm-dd hh:mm:ss a (ZZZZ)')} + +

+ +

+ {_(msg`Last Updated`)} + + + {DateTime.fromJSDate(document.updatedAt) + .setLocale(APP_I18N_OPTIONS.defaultLocale) + .toFormat('yyyy-mm-dd hh:mm:ss a (ZZZZ)')} + +

+ +

+ {_(msg`Time Zone`)} + + + {document.documentMeta?.timezone ?? 'N/A'} + +

+ +
+

{_(msg`Recipients`)}

+ +
    + {document.recipients.map((recipient) => ( +
  • + + [{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}] + {' '} + {recipient.name} ({recipient.email}) +
  • + ))} +
+
+
+
+ + + + + + + +
+
+ +
+
+
+ ); +} diff --git a/apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx b/apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx new file mode 100644 index 000000000..06880d80e --- /dev/null +++ b/apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx @@ -0,0 +1,324 @@ +import { msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { DateTime } from 'luxon'; +import { redirect, useSearchParams } from 'react-router'; +import { match } from 'ts-pattern'; +import { UAParser } from 'ua-parser-js'; + +import { APP_I18N_OPTIONS, ZSupportedLanguageCodeSchema } from '@documenso/lib/constants/i18n'; +import { + RECIPIENT_ROLES_DESCRIPTION, + RECIPIENT_ROLE_SIGNING_REASONS, +} from '@documenso/lib/constants/recipient-roles'; +import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document'; +import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt'; +import { getDocumentCertificateAuditLogs } from '@documenso/lib/server-only/document/get-document-certificate-audit-logs'; +import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; +import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; +import { dynamicActivate } from '@documenso/lib/utils/i18n'; +import { FieldType } from '@documenso/prisma/client'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@documenso/ui/primitives/table'; + +import { Logo } from '~/components/branding/logo'; + +import type { Route } from './+types/certificate'; + +const FRIENDLY_SIGNING_REASONS = { + ['__OWNER__']: msg`I am the owner of this document`, + ...RECIPIENT_ROLE_SIGNING_REASONS, +}; + +export async function loader({ request }: Route.LoaderArgs) { + const d = new URL(request.url).searchParams.get('d'); + + if (typeof d !== 'string' || !d) { + return redirect('/'); + } + + const rawDocumentId = decryptSecondaryData(d); + + if (!rawDocumentId || isNaN(Number(rawDocumentId))) { + return redirect('/'); + } + + const documentId = Number(rawDocumentId); + + const document = await getEntireDocument({ + id: documentId, + }).catch(() => null); + + if (!document) { + return redirect('/'); + } + + const documentLanguage = ZSupportedLanguageCodeSchema.parse(document.documentMeta?.language); + + const auditLogs = await getDocumentCertificateAuditLogs({ + id: documentId, + }); + + return { + document, + documentLanguage, + auditLogs, + }; +} + +/** +/** + * DO NOT USE TRANS. YOU MUST USE _ FOR THIS FILE AND ALL CHILDREN COMPONENTS. + * + * Cannot use dynamicActivate by itself to translate this specific page and all + * children components because `not-found.tsx` page runs and overrides the i18n. + */ +export default function SigningCertificate({ loaderData }: Route.ComponentProps) { + const { document, documentLanguage, auditLogs } = loaderData; + + const { i18n } = useLingui(); + + const { _ } = useLingui(); + + dynamicActivate(i18n, documentLanguage); + + const isOwner = (email: string) => { + return email.toLowerCase() === document.user.email.toLowerCase(); + }; + + const getDevice = (userAgent?: string | null) => { + if (!userAgent) { + return 'Unknown'; + } + + const parser = new UAParser(userAgent); + + parser.setUA(userAgent); + + const result = parser.getResult(); + + return `${result.os.name} - ${result.browser.name} ${result.browser.version}`; + }; + + const getAuthenticationLevel = (recipientId: number) => { + const recipient = document.recipients.find((recipient) => recipient.id === recipientId); + + if (!recipient) { + return 'Unknown'; + } + + const extractedAuthMethods = extractDocumentAuthMethods({ + documentAuth: document.authOptions, + recipientAuth: recipient.authOptions, + }); + + let authLevel = match(extractedAuthMethods.derivedRecipientActionAuth) + .with('ACCOUNT', () => _(msg`Account Re-Authentication`)) + .with('TWO_FACTOR_AUTH', () => _(msg`Two-Factor Re-Authentication`)) + .with('PASSKEY', () => _(msg`Passkey Re-Authentication`)) + .with('EXPLICIT_NONE', () => _(msg`Email`)) + .with(null, () => null) + .exhaustive(); + + if (!authLevel) { + authLevel = match(extractedAuthMethods.derivedRecipientAccessAuth) + .with('ACCOUNT', () => _(msg`Account Authentication`)) + .with(null, () => _(msg`Email`)) + .exhaustive(); + } + + return authLevel; + }; + + const getRecipientAuditLogs = (recipientId: number) => { + return { + [DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT]: auditLogs[DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT].filter( + (log) => + log.type === DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT && log.data.recipientId === recipientId, + ), + [DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED]: auditLogs[ + DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED + ].filter( + (log) => + log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED && + log.data.recipientId === recipientId, + ), + [DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED]: auditLogs[ + DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED + ].filter( + (log) => + log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED && + log.data.recipientId === recipientId, + ), + }; + }; + + const getRecipientSignatureField = (recipientId: number) => { + return document.recipients + .find((recipient) => recipient.id === recipientId) + ?.fields.find( + (field) => field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE, + ); + }; + + return ( +
+
+

{_(msg`Signing Certificate`)}

+
+ + + + + + + {_(msg`Signer Events`)} + {_(msg`Signature`)} + {_(msg`Details`)} + {/* Security */} + + + + + {document.recipients.map((recipient, i) => { + const logs = getRecipientAuditLogs(recipient.id); + const signature = getRecipientSignatureField(recipient.id); + + return ( + + +
{recipient.name}
+
{recipient.email}
+

+ {_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)} +

+ +

+ {_(msg`Authentication Level`)}:{' '} + {getAuthenticationLevel(recipient.id)} +

+
+ + + {signature ? ( + <> +
+ {signature.signature?.signatureImageAsBase64 && ( + Signature + )} + + {signature.signature?.typedSignature && ( +

+ {signature.signature?.typedSignature} +

+ )} +
+ +

+ {_(msg`Signature ID`)}:{' '} + + {signature.secondaryId} + +

+ +

+ {_(msg`IP Address`)}:{' '} + + {logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.ipAddress ?? _(msg`Unknown`)} + +

+ +

+ {_(msg`Device`)}:{' '} + + {getDevice(logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.userAgent)} + +

+ + ) : ( +

N/A

+ )} +
+ + +
+

+ {_(msg`Sent`)}:{' '} + + {logs.EMAIL_SENT[0] + ? DateTime.fromJSDate(logs.EMAIL_SENT[0].createdAt) + .setLocale(APP_I18N_OPTIONS.defaultLocale) + .toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)') + : _(msg`Unknown`)} + +

+ +

+ {_(msg`Viewed`)}:{' '} + + {logs.DOCUMENT_OPENED[0] + ? DateTime.fromJSDate(logs.DOCUMENT_OPENED[0].createdAt) + .setLocale(APP_I18N_OPTIONS.defaultLocale) + .toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)') + : _(msg`Unknown`)} + +

+ +

+ {_(msg`Signed`)}:{' '} + + {logs.DOCUMENT_RECIPIENT_COMPLETED[0] + ? DateTime.fromJSDate(logs.DOCUMENT_RECIPIENT_COMPLETED[0].createdAt) + .setLocale(APP_I18N_OPTIONS.defaultLocale) + .toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)') + : _(msg`Unknown`)} + +

+ +

+ {_(msg`Reason`)}:{' '} + + {_( + isOwner(recipient.email) + ? FRIENDLY_SIGNING_REASONS['__OWNER__'] + : FRIENDLY_SIGNING_REASONS[recipient.role], + )} + +

+
+
+
+ ); + })} +
+
+
+
+ +
+
+

+ {_(msg`Signing certificate provided by`)}: +

+ + +
+
+
+ ); +}