mirror of
https://github.com/documenso/documenso.git
synced 2025-11-17 18:21:32 +10:00
Merge branch 'main' into feat/org-insights
This commit is contained in:
@ -188,7 +188,7 @@ export const DocumentsTableActionDropdown = ({
|
||||
<Trans>Duplicate</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{onMoveDocument && (
|
||||
{onMoveDocument && canManageDocument && (
|
||||
<DropdownMenuItem onClick={onMoveDocument} onSelect={(e) => e.preventDefault()}>
|
||||
<FolderInput className="mr-2 h-4 w-4" />
|
||||
<Trans>Move to Folder</Trans>
|
||||
|
||||
@ -1,20 +1,18 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { DateTime } from 'luxon';
|
||||
import type { DateTimeFormatOptions } from 'luxon';
|
||||
import { DateTime } from 'luxon';
|
||||
import { P, match } from 'ts-pattern';
|
||||
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';
|
||||
DOCUMENT_AUDIT_LOG_TYPE,
|
||||
type TDocumentAuditLog,
|
||||
} from '@documenso/lib/types/document-audit-logs';
|
||||
import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
|
||||
export type AuditLogDataTableProps = {
|
||||
logs: TDocumentAuditLog[];
|
||||
@ -25,71 +23,129 @@ const dateFormat: DateTimeFormatOptions = {
|
||||
hourCycle: 'h12',
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the color indicator for the audit log type
|
||||
*/
|
||||
|
||||
const getAuditLogIndicatorColor = (type: string) =>
|
||||
match(type)
|
||||
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, () => 'bg-green-500')
|
||||
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED, () => 'bg-red-500')
|
||||
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT, () => 'bg-orange-500')
|
||||
.with(
|
||||
P.union(
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED,
|
||||
),
|
||||
() => 'bg-blue-500',
|
||||
)
|
||||
.otherwise(() => 'bg-muted');
|
||||
|
||||
/**
|
||||
* DO NOT USE TRANS. YOU MUST USE _ FOR THIS FILE AND ALL CHILDREN COMPONENTS.
|
||||
*/
|
||||
|
||||
const formatUserAgent = (userAgent: string | null | undefined, userAgentInfo: UAParser.IResult) => {
|
||||
if (!userAgent) {
|
||||
return msg`N/A`;
|
||||
}
|
||||
|
||||
const browser = userAgentInfo.browser.name;
|
||||
const version = userAgentInfo.browser.version;
|
||||
const os = userAgentInfo.os.name;
|
||||
|
||||
// If we can parse meaningful browser info, format it nicely
|
||||
if (browser && os) {
|
||||
const browserInfo = version ? `${browser} ${version}` : browser;
|
||||
|
||||
return msg`${browserInfo} on ${os}`;
|
||||
}
|
||||
|
||||
return msg`${userAgent}`;
|
||||
};
|
||||
|
||||
export const InternalAuditLogTable = ({ logs }: AuditLogDataTableProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const parser = new UAParser();
|
||||
|
||||
const uppercaseFistLetter = (text: string) => {
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<Table overflowHidden>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{_(msg`Time`)}</TableHead>
|
||||
<TableHead>{_(msg`User`)}</TableHead>
|
||||
<TableHead>{_(msg`Action`)}</TableHead>
|
||||
<TableHead>{_(msg`IP Address`)}</TableHead>
|
||||
<TableHead>{_(msg`Browser`)}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<div className="space-y-4">
|
||||
{logs.map((log, index) => {
|
||||
parser.setUA(log.userAgent || '');
|
||||
const formattedAction = formatDocumentAuditLogAction(_, log);
|
||||
const userAgentInfo = parser.getResult();
|
||||
|
||||
<TableBody className="print:text-xs">
|
||||
{logs.map((log, i) => (
|
||||
<TableRow className="break-inside-avoid" key={i}>
|
||||
<TableCell>
|
||||
{DateTime.fromJSDate(log.createdAt)
|
||||
.setLocale(APP_I18N_OPTIONS.defaultLocale)
|
||||
.toLocaleString(dateFormat)}
|
||||
</TableCell>
|
||||
return (
|
||||
<Card
|
||||
key={index}
|
||||
// Add top margin for the first card to ensure it's not cut off from the 2nd page onwards
|
||||
className={`border shadow-sm ${index > 0 ? 'print:mt-8' : ''}`}
|
||||
style={{
|
||||
pageBreakInside: 'avoid',
|
||||
breakInside: 'avoid',
|
||||
}}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
{/* Header Section with indicator, event type, and timestamp */}
|
||||
<div className="mb-3 flex items-start justify-between">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<div
|
||||
className={cn(`h-2 w-2 rounded-full`, getAuditLogIndicatorColor(log.type))}
|
||||
/>
|
||||
|
||||
<TableCell>
|
||||
{log.name || log.email ? (
|
||||
<div>
|
||||
{log.name && (
|
||||
<p className="break-all" title={log.name}>
|
||||
{log.name}
|
||||
</p>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-muted-foreground text-sm font-medium uppercase tracking-wide print:text-[8pt]">
|
||||
{log.type.replace(/_/g, ' ')}
|
||||
</div>
|
||||
|
||||
{log.email && (
|
||||
<p className="text-muted-foreground break-all" title={log.email}>
|
||||
{log.email}
|
||||
</p>
|
||||
)}
|
||||
<div className="text-foreground text-sm font-medium print:text-[8pt]">
|
||||
{formattedAction.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p>N/A</p>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{uppercaseFistLetter(formatDocumentAuditLogAction(_, log).description)}
|
||||
</TableCell>
|
||||
<div className="text-muted-foreground text-sm print:text-[8pt]">
|
||||
{DateTime.fromJSDate(log.createdAt)
|
||||
.setLocale(APP_I18N_OPTIONS.defaultLocale)
|
||||
.toLocaleString(dateFormat)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TableCell>{log.ipAddress}</TableCell>
|
||||
<hr className="my-4" />
|
||||
|
||||
<TableCell>
|
||||
{log.userAgent ? parser.setUA(log.userAgent).getBrowser().name : 'N/A'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{/* Details Section - Two column layout */}
|
||||
<div className="grid grid-cols-2 gap-x-8 gap-y-2 text-xs print:text-[6pt]">
|
||||
<div>
|
||||
<div className="text-muted-foreground/70 font-medium uppercase tracking-wide">
|
||||
{_(msg`User`)}
|
||||
</div>
|
||||
|
||||
<div className="text-foreground mt-1 font-mono">{log.email || 'N/A'}</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<div className="text-muted-foreground/70 font-medium uppercase tracking-wide">
|
||||
{_(msg`IP Address`)}
|
||||
</div>
|
||||
|
||||
<div className="text-foreground mt-1 font-mono">{log.ipAddress || 'N/A'}</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<div className="text-muted-foreground/70 font-medium uppercase tracking-wide">
|
||||
{_(msg`User Agent`)}
|
||||
</div>
|
||||
|
||||
<div className="text-foreground mt-1">
|
||||
{_(formatUserAgent(log.userAgent, userAgentInfo))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -25,7 +25,7 @@ export const OrganisationBillingInvoicesTable = ({
|
||||
}: OrganisationBillingInvoicesTableProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const { data, isLoading, isLoadingError } = trpc.billing.invoices.get.useQuery(
|
||||
const { data, isLoading, isLoadingError } = trpc.enterprise.billing.invoices.get.useQuery(
|
||||
{
|
||||
organisationId,
|
||||
},
|
||||
|
||||
@ -0,0 +1,205 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { EmailDomainStatus } from '@prisma/client';
|
||||
import { CheckCircle2Icon, ClockIcon } from 'lucide-react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||
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 { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { OrganisationEmailDomainDeleteDialog } from '../dialogs/organisation-email-domain-delete-dialog';
|
||||
|
||||
export const OrganisationEmailDomainsDataTable = () => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
|
||||
|
||||
const { mutate: verifyEmails, isPending: isVerifyingEmails } =
|
||||
trpc.enterprise.organisation.emailDomain.verify.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: t`Email domains synced`,
|
||||
description: t`All email domains have been synced successfully`,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { data, isLoading, isLoadingError } =
|
||||
trpc.enterprise.organisation.emailDomain.find.useQuery(
|
||||
{
|
||||
organisationId: organisation.id,
|
||||
query: parsedSearchParams.query,
|
||||
page: parsedSearchParams.page,
|
||||
perPage: parsedSearchParams.perPage,
|
||||
},
|
||||
{
|
||||
placeholderData: (previousData) => previousData,
|
||||
},
|
||||
);
|
||||
|
||||
const onPaginationChange = (page: number, perPage: number) => {
|
||||
updateSearchParams({
|
||||
page,
|
||||
perPage,
|
||||
});
|
||||
};
|
||||
|
||||
const results = data ?? {
|
||||
data: [],
|
||||
perPage: 10,
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
header: t`Domain`,
|
||||
accessorKey: 'domain',
|
||||
},
|
||||
{
|
||||
header: t`Status`,
|
||||
accessorKey: 'status',
|
||||
cell: ({ row }) =>
|
||||
match(row.original.status)
|
||||
.with(EmailDomainStatus.ACTIVE, () => (
|
||||
<Badge>
|
||||
<CheckCircle2Icon className="mr-2 h-4 w-4 text-green-500 dark:text-green-300" />
|
||||
<Trans>Active</Trans>
|
||||
</Badge>
|
||||
))
|
||||
.with(EmailDomainStatus.PENDING, () => (
|
||||
<Badge variant="warning">
|
||||
<ClockIcon className="mr-2 h-4 w-4 text-yellow-500 dark:text-yellow-200" />
|
||||
<Trans>Pending</Trans>
|
||||
</Badge>
|
||||
))
|
||||
.exhaustive(),
|
||||
},
|
||||
{
|
||||
header: t`Emails`,
|
||||
accessorKey: 'emailCount',
|
||||
cell: ({ row }) => row.original.emailCount,
|
||||
},
|
||||
{
|
||||
header: t`Actions`,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button asChild variant="outline">
|
||||
<Link to={`/o/${organisation.url}/settings/email-domains/${row.original.id}`}>
|
||||
Manage
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<OrganisationEmailDomainDeleteDialog
|
||||
emailDomainId={row.original.id}
|
||||
emailDomain={row.original.domain}
|
||||
trigger={
|
||||
<Button variant="destructive" title={t`Remove email domain`}>
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={results.data}
|
||||
perPage={results.perPage}
|
||||
currentPage={results.currentPage}
|
||||
totalPages={results.totalPages}
|
||||
onPaginationChange={onPaginationChange}
|
||||
error={{
|
||||
enable: isLoadingError,
|
||||
}}
|
||||
skeleton={{
|
||||
enable: isLoading,
|
||||
rows: 1,
|
||||
component: (
|
||||
<>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-20 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-20 rounded" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-12 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-row justify-end space-x-2">
|
||||
<Skeleton className="h-10 w-20 rounded" />
|
||||
<Skeleton className="h-10 w-20 rounded" />
|
||||
</div>
|
||||
</TableCell>
|
||||
</>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{(table) =>
|
||||
results.totalPages > 1 && (
|
||||
<DataTablePagination additionalInformation="VisibleCount" table={table} />
|
||||
)
|
||||
}
|
||||
</DataTable>
|
||||
|
||||
<AnimateGenericFadeInOut key={results.data.length}>
|
||||
{results.data.length > 0 && (
|
||||
<Alert
|
||||
className="mt-2 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||
variant="neutral"
|
||||
>
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<AlertTitle>
|
||||
<Trans>Sync Email Domains</Trans>
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription className="mr-2">
|
||||
<Trans>
|
||||
This will check and sync the status of all email domains for this organisation
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
loading={isVerifyingEmails}
|
||||
onClick={() => {
|
||||
verifyEmails({
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trans>Sync</Trans>
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
</AnimateGenericFadeInOut>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user