mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 00:03:33 +10:00
feat: add separate document audit page
This commit is contained in:
@ -21,6 +21,7 @@ export const DocumentPageViewInformation = ({
|
|||||||
userId,
|
userId,
|
||||||
}: DocumentPageViewInformationProps) => {
|
}: DocumentPageViewInformationProps) => {
|
||||||
const isMounted = useIsMounted();
|
const isMounted = useIsMounted();
|
||||||
|
|
||||||
const { locale } = useLocale();
|
const { locale } = useLocale();
|
||||||
|
|
||||||
const documentInformation = useMemo(() => {
|
const documentInformation = useMemo(() => {
|
||||||
|
|||||||
@ -0,0 +1,165 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import type { DateTimeFormatOptions } from 'luxon';
|
||||||
|
import { UAParser } from 'ua-parser-js';
|
||||||
|
|
||||||
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
|
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||||
|
import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
|
||||||
|
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';
|
||||||
|
|
||||||
|
export type DocumentLogsDataTableProps = {
|
||||||
|
documentId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dateFormat: DateTimeFormatOptions = {
|
||||||
|
...DateTime.DATETIME_SHORT,
|
||||||
|
hourCycle: 'h12',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DocumentLogsDataTable = ({ documentId }: DocumentLogsDataTableProps) => {
|
||||||
|
const parser = new UAParser();
|
||||||
|
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
|
||||||
|
const parsedSearchParams = ZBaseTableSearchParamsSchema.parse(
|
||||||
|
Object.fromEntries(searchParams ?? []),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isLoading, isInitialLoading, isLoadingError } =
|
||||||
|
trpc.document.findDocumentAuditLogs.useQuery(
|
||||||
|
{
|
||||||
|
documentId,
|
||||||
|
page: parsedSearchParams.page,
|
||||||
|
perPage: parsedSearchParams.perPage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keepPreviousData: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const onPaginationChange = (page: number, perPage: number) => {
|
||||||
|
updateSearchParams({
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const uppercaseFistLetter = (text: string) => {
|
||||||
|
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const results = data ?? {
|
||||||
|
data: [],
|
||||||
|
perPage: 10,
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
header: 'Time',
|
||||||
|
accessorKey: 'createdAt',
|
||||||
|
cell: ({ row }) => <LocaleDate format={dateFormat} date={row.original.createdAt} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'User',
|
||||||
|
accessorKey: 'name',
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.name || row.original.email ? (
|
||||||
|
<div>
|
||||||
|
{row.original.name && (
|
||||||
|
<p className="truncate" title={row.original.name}>
|
||||||
|
{row.original.name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{row.original.email && (
|
||||||
|
<p className="truncate" title={row.original.email}>
|
||||||
|
{row.original.email}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p>N/A</p>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Action',
|
||||||
|
accessorKey: 'type',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span>
|
||||||
|
{uppercaseFistLetter(formatDocumentAuditLogAction(row.original).description)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'IP Address',
|
||||||
|
accessorKey: 'ipAddress',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
data={results.data}
|
||||||
|
perPage={results.perPage}
|
||||||
|
currentPage={results.currentPage}
|
||||||
|
totalPages={results.totalPages}
|
||||||
|
onPaginationChange={onPaginationChange}
|
||||||
|
error={{
|
||||||
|
enable: isLoadingError,
|
||||||
|
}}
|
||||||
|
skeleton={{
|
||||||
|
enable: isLoading && isInitialLoading,
|
||||||
|
rows: 3,
|
||||||
|
component: (
|
||||||
|
<>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-1/2 py-4 pr-4">
|
||||||
|
<div className="ml-2 flex flex-grow flex-col">
|
||||||
|
<Skeleton className="h-4 w-1/3 max-w-[8rem]" />
|
||||||
|
<Skeleton className="mt-1 h-4 w-1/2 max-w-[12rem]" />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-10 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-10 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||||
|
</DataTable>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,150 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import { ChevronLeft, DownloadIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||||
|
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||||
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
|
import type { Recipient, Team } from '@documenso/prisma/client';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Card } from '@documenso/ui/primitives/card';
|
||||||
|
|
||||||
|
import { FRIENDLY_STATUS_MAP } from '~/components/formatter/document-status';
|
||||||
|
|
||||||
|
import { DocumentLogsDataTable } from './document-logs-data-table';
|
||||||
|
|
||||||
|
export type DocumentLogsPageViewProps = {
|
||||||
|
params: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
team?: Team;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageViewProps) => {
|
||||||
|
const { id } = params;
|
||||||
|
|
||||||
|
const documentId = Number(id);
|
||||||
|
|
||||||
|
const documentRootPath = formatDocumentsPath(team?.url);
|
||||||
|
|
||||||
|
if (!documentId || Number.isNaN(documentId)) {
|
||||||
|
redirect(documentRootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
const [document, recipients] = await Promise.all([
|
||||||
|
getDocumentById({
|
||||||
|
id: documentId,
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
}).catch(() => null),
|
||||||
|
getRecipientsForDocument({
|
||||||
|
documentId,
|
||||||
|
userId: user.id,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!document || !document.documentData) {
|
||||||
|
redirect(documentRootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const documentInformation: { description: string; value: string }[] = [
|
||||||
|
{
|
||||||
|
description: 'Document title',
|
||||||
|
value: document.title,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Document ID',
|
||||||
|
value: document.id.toString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Document status',
|
||||||
|
value: FRIENDLY_STATUS_MAP[document.status].label,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Created by',
|
||||||
|
value: document.User.name ?? document.User.email,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Date created',
|
||||||
|
value: document.createdAt.toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Last updated',
|
||||||
|
value: document.updatedAt.toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Time zone',
|
||||||
|
value: document.documentMeta?.timezone ?? 'N/A',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const formatRecipientText = (recipient: Recipient) => {
|
||||||
|
let text = recipient.email;
|
||||||
|
|
||||||
|
if (recipient.name) {
|
||||||
|
text = `${recipient.name} (${recipient.email})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${text} - ${recipient.role}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||||
|
<Link
|
||||||
|
href={`${documentRootPath}/${document.id}`}
|
||||||
|
className="flex items-center text-[#7AC455] hover:opacity-80"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||||
|
Document
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex flex-col justify-between sm:flex-row">
|
||||||
|
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
||||||
|
{document.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end">
|
||||||
|
<Button variant="outline" className="mr-2 w-full sm:w-auto">
|
||||||
|
<DownloadIcon className="mr-1.5 h-4 w-4" />
|
||||||
|
Download certificate
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button className="w-full sm:w-auto">
|
||||||
|
<DownloadIcon className="mr-1.5 h-4 w-4" />
|
||||||
|
Download PDF
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="mt-6">
|
||||||
|
<Card className="grid grid-cols-1 gap-4 p-4 sm:grid-cols-2" degrees={45} gradient>
|
||||||
|
{documentInformation.map((info, i) => (
|
||||||
|
<div className="text-foreground text-sm" key={i}>
|
||||||
|
<h3 className="font-semibold">{info.description}</h3>
|
||||||
|
<p className="text-muted-foreground">{info.value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="text-foreground text-sm">
|
||||||
|
<h3 className="font-semibold">Recipients</h3>
|
||||||
|
<ul className="text-muted-foreground list-inside list-disc">
|
||||||
|
{recipients.map((recipient) => (
|
||||||
|
<li key={`recipient-${recipient.id}`}>
|
||||||
|
<span className="-ml-2">{formatRecipientText(recipient)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-6">
|
||||||
|
<DocumentLogsDataTable documentId={document.id} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
11
apps/web/src/app/(dashboard)/documents/[id]/logs/page.tsx
Normal file
11
apps/web/src/app/(dashboard)/documents/[id]/logs/page.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { DocumentLogsPageView } from './document-logs-page-view';
|
||||||
|
|
||||||
|
export type DocumentsLogsPageProps = {
|
||||||
|
params: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DocumentsLogsPage({ params }: DocumentsLogsPageProps) {
|
||||||
|
return <DocumentLogsPageView params={params} />;
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||||
|
|
||||||
|
import { DocumentLogsPageView } from '~/app/(dashboard)/documents/[id]/logs/document-logs-page-view';
|
||||||
|
|
||||||
|
export type TeamDocumentsLogsPageProps = {
|
||||||
|
params: {
|
||||||
|
id: string;
|
||||||
|
teamUrl: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function TeamsDocumentsLogsPage({ params }: TeamDocumentsLogsPageProps) {
|
||||||
|
const { teamUrl } = params;
|
||||||
|
|
||||||
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
const team = await getTeamByUrl({ userId: user.id, teamUrl });
|
||||||
|
|
||||||
|
return <DocumentLogsPageView params={params} team={team} />;
|
||||||
|
}
|
||||||
@ -133,9 +133,10 @@ export const documentRouter = router({
|
|||||||
.input(ZFindDocumentAuditLogsQuerySchema)
|
.input(ZFindDocumentAuditLogsQuerySchema)
|
||||||
.query(async ({ input, ctx }) => {
|
.query(async ({ input, ctx }) => {
|
||||||
try {
|
try {
|
||||||
const { perPage, documentId, cursor, filterForRecentActivity, orderBy } = input;
|
const { page, perPage, documentId, cursor, filterForRecentActivity, orderBy } = input;
|
||||||
|
|
||||||
return await findDocumentAuditLogs({
|
return await findDocumentAuditLogs({
|
||||||
|
page,
|
||||||
perPage,
|
perPage,
|
||||||
documentId,
|
documentId,
|
||||||
cursor,
|
cursor,
|
||||||
|
|||||||
Reference in New Issue
Block a user