Compare commits

...
Author SHA1 Message Date
ephraimduncan 3d0f8713f8 feat(admin): show team count in organisation stats 2026-09-28 10:36:46 +00:00
Lucas Smith a1d4bec143 fix: accept owner-password protected pdfs (#3396)
Strip encryption from PDFs that open with an empty user password via
libpdf's ignorePermissions, still rejecting user-password PDFs.

Upgrade @libpdf/core to 0.5.1, which also keeps overlapping and layered
text intact during text extraction.

Resolves #3303
2026-09-26 11:23:33 +10:00
8 changed files with 36 additions and 14 deletions
@@ -122,7 +122,7 @@ export const EnvelopeItemEditDialog = ({
toast({
title: t`Failed to read file`,
description: t`The file is not a valid PDF.`,
description: t`The file is not a valid PDF or is password protected.`,
variant: 'destructive',
});
}
@@ -12,7 +12,7 @@ import { ChevronDownIcon, ChevronsUpDownIcon, ChevronUpIcon } from 'lucide-react
import { useMemo } from 'react';
import { Link, useSearchParams } from 'react-router';
type OrderByColumn = 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports' | 'totalCount';
type OrderByColumn = 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports' | 'teamCount' | 'totalCount';
type OrderByDirection = 'asc' | 'desc';
const parseOrderByColumn = (value: string | undefined): OrderByColumn | undefined => {
@@ -21,6 +21,7 @@ const parseOrderByColumn = (value: string | undefined): OrderByColumn | undefine
value === 'emailCount' ||
value === 'apiCount' ||
value === 'emailReports' ||
value === 'teamCount' ||
value === 'totalCount'
) {
return value;
@@ -178,6 +179,11 @@ export const AdminOrganisationStatsTable = ({ displayMode = 'usage' }: AdminOrga
accessorKey: 'originalClaimId',
cell: ({ row }) => <span className="text-muted-foreground text-sm">{row.original.originalClaimId ?? '—'}</span>,
},
{
header: () => sortableHeader(t`Teams`, 'teamCount'),
accessorKey: 'teamCount',
cell: ({ row }) => row.original.teamCount,
},
{
header: t`Period`,
accessorKey: 'period',
@@ -240,6 +246,9 @@ export const AdminOrganisationStatsTable = ({ displayMode = 'usage' }: AdminOrga
<TableCell>
<Skeleton className="h-4 w-24 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-10 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-16 rounded-full" />
</TableCell>
+1 -1
View File
@@ -92,7 +92,7 @@ export const getUploadErrorMessage = (code: string): ToastMessageDescriptor => {
.with(AppErrorCode.TOO_MANY_REQUESTS, () => FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE)
.with('INVALID_DOCUMENT_FILE', () => ({
title: msg`Error`,
description: msg`You cannot upload encrypted PDFs.`,
description: msg`The file is not a valid PDF or is password protected.`,
}))
.with(AppErrorCode.LIMIT_EXCEEDED, () => ({
title: msg`Error`,
+4 -4
View File
@@ -15,7 +15,7 @@
"dependencies": {
"@ai-sdk/google-vertex": "5.0.48",
"@documenso/prisma": "*",
"@libpdf/core": "^0.4.2",
"@libpdf/core": "^0.5.1",
"@lingui/conf": "^5.6.0",
"@lingui/core": "^5.6.0",
"@marsidev/react-turnstile": "^1.5.0",
@@ -4480,9 +4480,9 @@
"license": "MIT"
},
"node_modules/@libpdf/core": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.4.2.tgz",
"integrity": "sha512-lbkIqLDZCCxjLpiC+8/Xvaru/ME7iVVoihl9tLqbp/CDUWZNF0q3u7s2tBJB9wRW/SzUWID6YPFvBWws770hrQ==",
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.5.1.tgz",
"integrity": "sha512-q+y4AEk9ngqyC1pdX/hNScnfh0ROr4vSiqX4C9CmuBzhtuVukbfTesXCaGvHZ3pksi8AJYDPGQ/0HzSeHZfi3A==",
"license": "MIT",
"dependencies": {
"@noble/ciphers": "^2.2.0",
+1 -1
View File
@@ -106,7 +106,7 @@
"dependencies": {
"@ai-sdk/google-vertex": "5.0.48",
"@documenso/prisma": "*",
"@libpdf/core": "^0.4.2",
"@libpdf/core": "^0.5.1",
"@lingui/conf": "^5.6.0",
"@lingui/core": "^5.6.0",
"@prisma/extension-read-replicas": "^0.4.1",
@@ -9,14 +9,18 @@ export const normalizePdf = async (pdf: Buffer, options: { flattenForm?: boolean
console.error(`PDF normalization error: ${e.message}`);
throw new AppError('INVALID_DOCUMENT_FILE', {
message: 'The document is not a valid PDF',
message: 'The document is not a valid PDF or is password protected',
});
});
if (pdfDoc.isEncrypted) {
throw new AppError('INVALID_DOCUMENT_FILE', {
message: 'The document is encrypted',
});
if (!pdfDoc.isAuthenticated) {
throw new AppError('INVALID_DOCUMENT_FILE', {
message: 'The document is password protected',
});
}
pdfDoc.removeProtection({ ignorePermissions: true });
}
pdfDoc.flattenLayers();
@@ -32,7 +32,7 @@ type FindOrganisationStatsOptions = {
claimId?: string;
page?: number;
perPage?: number;
orderByColumn?: 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports' | 'totalCount';
orderByColumn?: 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports' | 'teamCount' | 'totalCount';
orderByDirection?: 'asc' | 'desc';
};
@@ -95,6 +95,12 @@ export const findOrganisationStats = async ({
'OrganisationClaim.documentQuota as documentQuota',
'OrganisationClaim.emailQuota as emailQuota',
'OrganisationClaim.apiQuota as apiQuota',
// Teams have no monthly history, so this is the current count for every period.
eb
.selectFrom('Team')
.whereRef('Team.organisationId', '=', 'Organisation.id')
.select(sql<number>`count("Team"."id")`.as('count'))
.as('teamCount'),
totalCountExpression.as('totalCount'),
eb.fn.countAll().over().as('totalRows'),
])
@@ -104,6 +110,7 @@ export const findOrganisationStats = async ({
.with('emailCount', () => qb.orderBy('OrganisationMonthlyStat.emailCount', orderByDirection))
.with('apiCount', () => qb.orderBy('OrganisationMonthlyStat.apiCount', orderByDirection))
.with('emailReports', () => qb.orderBy('OrganisationMonthlyStat.emailReports', orderByDirection))
.with('teamCount', () => qb.orderBy('teamCount', orderByDirection))
.with('totalCount', () => qb.orderBy(totalCountExpression, orderByDirection))
.with(undefined, () =>
// Default ordering mirrors the desired SQL: email, api, document descending.
@@ -132,6 +139,7 @@ export const findOrganisationStats = async ({
emailCount: Number(row.emailCount),
apiCount: Number(row.apiCount),
emailReports: Number(row.emailReports),
teamCount: Number(row.teamCount),
documentQuota: row.documentQuota === null ? null : Number(row.documentQuota),
emailQuota: row.emailQuota === null ? null : Number(row.emailQuota),
apiQuota: row.apiQuota === null ? null : Number(row.apiQuota),
@@ -9,7 +9,7 @@ export const ZFindOrganisationStatsRequestSchema = ZFindSearchParamsSchema.exten
.optional(),
claimId: z.string().describe('Filter stats by the original subscription claim ID.').optional(),
orderByColumn: z
.enum(['documentCount', 'emailCount', 'apiCount', 'emailReports', 'totalCount'])
.enum(['documentCount', 'emailCount', 'apiCount', 'emailReports', 'teamCount', 'totalCount'])
.describe('The column to sort by.')
.optional(),
orderByDirection: z.enum(['asc', 'desc']).describe('Sort direction.').default('desc'),
@@ -27,6 +27,7 @@ export const ZFindOrganisationStatsResponseSchema = ZFindResultResponse.extend({
emailCount: z.number(),
apiCount: z.number(),
emailReports: z.number(),
teamCount: z.number(),
documentQuota: z.number().nullable(),
emailQuota: z.number().nullable(),
apiQuota: z.number().nullable(),