mirror of
https://github.com/documenso/documenso.git
synced 2026-07-01 16:50:50 +10:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 46d712f4cc | |||
| 62c609f105 | |||
| 4babe9b192 | |||
| 7e422bc3fd | |||
| f1c91c4951 | |||
| a5ef1d23e6 | |||
| d91414697d | |||
| e222a872d2 | |||
| e3b0087be6 | |||
| da89ce7c9a | |||
| b762561f11 |
@@ -149,7 +149,12 @@ export const EnvelopesBulkDeleteDialog = ({
|
||||
</Alert>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" disabled={isPending}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ export const OrganisationGroupDeleteDialog = ({
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
<Trans>
|
||||
<Trans context="Removing group from organisation">
|
||||
You are about to remove the following group from{' '}
|
||||
<span className="font-semibold">{organisation.name}</span>.
|
||||
</Trans>
|
||||
|
||||
@@ -81,7 +81,7 @@ export const TeamGroupDeleteDialog = ({
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
<Trans>
|
||||
<Trans context="Removing group from team">
|
||||
You are about to remove the following group from{' '}
|
||||
<span className="font-semibold">{team.name}</span>.
|
||||
</Trans>
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { useLingui as useLinguiMacro } from '@lingui/react/macro';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
|
||||
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||
import type { TeamMemberRole } from '@documenso/prisma/generated/types';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
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 { HoverCard, HoverCardContent, HoverCardTrigger } from '@documenso/ui/primitives/hover-card';
|
||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||
import { TableCell } from '@documenso/ui/primitives/table';
|
||||
|
||||
type AdminUserTeamsTableProps = {
|
||||
userId: number;
|
||||
};
|
||||
|
||||
export const AdminUserTeamsTable = ({ userId }: AdminUserTeamsTableProps) => {
|
||||
const { i18n } = useLingui();
|
||||
const { t } = useLinguiMacro();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
|
||||
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
|
||||
|
||||
const { data, isLoading, isLoadingError } = trpc.admin.user.findTeams.useQuery({
|
||||
userId,
|
||||
query: parsedSearchParams.query,
|
||||
page: parsedSearchParams.page,
|
||||
perPage: parsedSearchParams.perPage,
|
||||
});
|
||||
|
||||
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`Team`,
|
||||
accessorKey: 'name',
|
||||
cell: ({ row }) => (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger className="cursor-default underline decoration-dotted underline-offset-4">
|
||||
{row.original.name}
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
className="w-auto font-mono text-xs text-muted-foreground"
|
||||
align="start"
|
||||
>
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
|
||||
<dt>id</dt>
|
||||
<dd>{row.original.id}</dd>
|
||||
<dt>url</dt>
|
||||
<dd>{row.original.url}</dd>
|
||||
</dl>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t`Organisation`,
|
||||
accessorKey: 'organisation',
|
||||
cell: ({ row }) => (
|
||||
<Link
|
||||
to={`/admin/organisations/${row.original.organisation.id}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{row.original.organisation.name}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t`Role`,
|
||||
accessorKey: 'teamRole',
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="neutral">
|
||||
{i18n._(TEAM_MEMBER_ROLE_MAP[row.original.teamRole as TeamMemberRole])}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t`Created At`,
|
||||
accessorKey: 'createdAt',
|
||||
cell: ({ row }) => i18n.date(row.original.createdAt),
|
||||
},
|
||||
] 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: 3,
|
||||
component: (
|
||||
<>
|
||||
<TableCell className="py-4 pr-4">
|
||||
<Skeleton className="h-4 w-20 rounded-full" />
|
||||
</TableCell>
|
||||
<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-16 rounded-full" />
|
||||
</TableCell>
|
||||
</>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{(table) =>
|
||||
table.getPageCount() > 1 ? (
|
||||
<DataTablePagination additionalInformation="VisibleCount" table={table} />
|
||||
) : null
|
||||
}
|
||||
</DataTable>
|
||||
);
|
||||
};
|
||||
@@ -24,7 +24,7 @@ export const EnvelopesTableBulkActionBar = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-x-4 rounded-lg border border-border bg-widget px-4 py-3 shadow-lg">
|
||||
<div className="fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-x-4 rounded-lg border border-border bg-background px-4 py-3 shadow-lg">
|
||||
<span className="text-sm font-medium">
|
||||
<Trans>{selectedCount} selected</Trans>
|
||||
</span>
|
||||
@@ -36,13 +36,7 @@ export const EnvelopesTableBulkActionBar = ({
|
||||
<Trans>Move to Folder</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onDeleteClick}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Button type="button" variant="destructive" size="sm" onClick={onDeleteClick}>
|
||||
<Trash2Icon className="mr-2 h-4 w-4" />
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
|
||||
@@ -10,6 +10,12 @@ import type { z } from 'zod';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types';
|
||||
import { ZUpdateUserRequestSchema } from '@documenso/trpc/server/admin-router/update-user.types';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@documenso/ui/primitives/accordion';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Form,
|
||||
@@ -30,6 +36,7 @@ import { AdminUserEnableDialog } from '~/components/dialogs/admin-user-enable-di
|
||||
import { AdminUserResetTwoFactorDialog } from '~/components/dialogs/admin-user-reset-two-factor-dialog';
|
||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||
import { AdminOrganisationsTable } from '~/components/tables/admin-organisations-table';
|
||||
import { AdminUserTeamsTable } from '~/components/tables/admin-user-teams-table';
|
||||
|
||||
import { MultiSelectRoleCombobox } from '../../../components/general/multiselect-role-combobox';
|
||||
|
||||
@@ -197,7 +204,7 @@ const AdminUserPage = ({ user }: { user: TGetUserResponse }) => {
|
||||
<h3 className="text-lg font-semibold leading-none tracking-tight">
|
||||
<Trans>User Organisations</Trans>
|
||||
</h3>
|
||||
<p className="text-muted-foreground mt-1.5 text-sm">
|
||||
<p className="mt-1.5 text-sm text-muted-foreground">
|
||||
<Trans>Organisations that the user is a member of.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
@@ -219,6 +226,28 @@ const AdminUserPage = ({ user }: { user: TGetUserResponse }) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<hr className="my-8" />
|
||||
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="team-memberships" className="border-b-0">
|
||||
<AccordionTrigger className="py-0">
|
||||
<div className="text-left">
|
||||
<h3 className="text-lg font-semibold leading-none tracking-tight">
|
||||
<Trans>Team Memberships</Trans>
|
||||
</h3>
|
||||
<p className="mt-1.5 text-sm font-normal text-muted-foreground">
|
||||
<Trans>Teams that this user is a member of and their roles.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="mt-4">
|
||||
<AdminUserTeamsTable userId={user.id} />
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
<div className="mt-16 flex flex-col gap-4">
|
||||
{user && user.twoFactorEnabled && <AdminUserResetTwoFactorDialog user={user} />}
|
||||
{user && user.disabled && <AdminUserEnableDialog userToEnable={user} />}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useParams, useSearchParams } from 'react-router';
|
||||
import { Link } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useSessionStorage } from '@documenso/lib/client-only/hooks/use-session-storage';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { parseToIntegerArray } from '@documenso/lib/utils/params';
|
||||
@@ -58,7 +59,10 @@ export default function DocumentsPage() {
|
||||
const [isMovingDocument, setIsMovingDocument] = useState(false);
|
||||
const [documentToMove, setDocumentToMove] = useState<number | null>(null);
|
||||
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>(
|
||||
'documents-bulk-selection',
|
||||
{},
|
||||
);
|
||||
const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false);
|
||||
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
||||
|
||||
@@ -121,11 +125,6 @@ export default function DocumentsPage() {
|
||||
}
|
||||
}, [data?.stats]);
|
||||
|
||||
// Clear selection when navigation or filters change
|
||||
useEffect(() => {
|
||||
setRowSelection({});
|
||||
}, [folderId, findDocumentSearchParams]);
|
||||
|
||||
return (
|
||||
<EnvelopeDropZoneWrapper type={EnvelopeType.DOCUMENT}>
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||
|
||||
@@ -207,7 +207,9 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
|
||||
directTemplates={enabledPrivateDirectTemplates}
|
||||
trigger={
|
||||
<Button variant="outline">
|
||||
<Trans>Link template</Trans>
|
||||
<Trans context="Action button to link template to public profile">
|
||||
Link template
|
||||
</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import { Bird } from 'lucide-react';
|
||||
import { useParams, useSearchParams } from 'react-router';
|
||||
|
||||
import { useSessionStorage } from '@documenso/lib/client-only/hooks/use-session-storage';
|
||||
import { FolderType } from '@documenso/lib/types/folder-type';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
@@ -34,7 +35,10 @@ export default function TemplatesPage() {
|
||||
const page = Number(searchParams.get('page')) || 1;
|
||||
const perPage = Number(searchParams.get('perPage')) || 10;
|
||||
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>(
|
||||
'templates-bulk-selection',
|
||||
{},
|
||||
);
|
||||
const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false);
|
||||
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
||||
|
||||
@@ -51,11 +55,6 @@ export default function TemplatesPage() {
|
||||
folderId,
|
||||
});
|
||||
|
||||
// Clear selection when navigation or filters change
|
||||
useEffect(() => {
|
||||
setRowSelection({});
|
||||
}, [folderId, page, perPage]);
|
||||
|
||||
return (
|
||||
<EnvelopeDropZoneWrapper type={EnvelopeType.TEMPLATE}>
|
||||
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import * as React from 'react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
function dispatchStorageEvent(key: string, newValue: string | null) {
|
||||
window.dispatchEvent(new StorageEvent('storage', { key, newValue }));
|
||||
}
|
||||
|
||||
const setSessionStorageItem = <T>(key: string, value: T) => {
|
||||
const stringifiedValue = JSON.stringify(value);
|
||||
window.sessionStorage.setItem(key, stringifiedValue);
|
||||
dispatchStorageEvent(key, stringifiedValue);
|
||||
};
|
||||
|
||||
const removeSessionStorageItem = (key: string) => {
|
||||
window.sessionStorage.removeItem(key);
|
||||
dispatchStorageEvent(key, null);
|
||||
};
|
||||
|
||||
const getSessionStorageItem = (key: string) => {
|
||||
return window.sessionStorage.getItem(key);
|
||||
};
|
||||
|
||||
const useSessionStorageSubscribe = (callback: (event: StorageEvent) => void) => {
|
||||
window.addEventListener('storage', callback);
|
||||
return () => window.removeEventListener('storage', callback);
|
||||
};
|
||||
|
||||
export function useSessionStorage<T>(
|
||||
key: string,
|
||||
initialValue: T,
|
||||
): [T, Dispatch<SetStateAction<T>>] {
|
||||
const serializedInitialValue = JSON.stringify(initialValue);
|
||||
|
||||
const getSnapshot = () => getSessionStorageItem(key);
|
||||
const getServerSnapshot = () => serializedInitialValue;
|
||||
|
||||
const store = React.useSyncExternalStore(
|
||||
useSessionStorageSubscribe,
|
||||
getSnapshot,
|
||||
getServerSnapshot,
|
||||
);
|
||||
|
||||
const setState: Dispatch<SetStateAction<T>> = React.useCallback(
|
||||
(v) => {
|
||||
try {
|
||||
const prevValue = store ? JSON.parse(store) : initialValue;
|
||||
// @ts-expect-error - SetStateAction function check is safe at runtime
|
||||
const nextState = typeof v === 'function' ? v(prevValue) : v;
|
||||
|
||||
if (nextState === undefined || nextState === null) {
|
||||
removeSessionStorageItem(key);
|
||||
} else {
|
||||
setSessionStorageItem(key, nextState);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
},
|
||||
[key, store, initialValue],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (getSessionStorageItem(key) === null && typeof initialValue !== 'undefined') {
|
||||
setSessionStorageItem(key, initialValue);
|
||||
}
|
||||
}, [key, initialValue]);
|
||||
|
||||
return [store ? JSON.parse(store) : initialValue, setState];
|
||||
}
|
||||
@@ -169,12 +169,28 @@ export const run = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const envelopeCompletedAuditLog = createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED,
|
||||
envelopeId: envelope.id,
|
||||
requestMetadata,
|
||||
user: null,
|
||||
data: {
|
||||
transactionId: nanoid(),
|
||||
...(isRejected ? { isRejected: true, rejectionReason: rejectionReason } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
const finalEnvelopeStatus = isRejected ? DocumentStatus.REJECTED : DocumentStatus.COMPLETED;
|
||||
|
||||
let certificateDoc: PDF | null = null;
|
||||
let auditLogDoc: PDF | null = null;
|
||||
|
||||
if (settings.includeSigningCertificate || settings.includeAuditLog) {
|
||||
const certificatePayload = {
|
||||
envelope,
|
||||
envelope: {
|
||||
...envelope,
|
||||
status: finalEnvelopeStatus,
|
||||
},
|
||||
recipients: envelope.recipients, // Need to use the recipients from envelope which contains ALL recipients.
|
||||
fields,
|
||||
language: envelope.documentMeta.language,
|
||||
@@ -185,6 +201,7 @@ export const run = async ({
|
||||
envelopeItems: envelopeItems.map((item) => item.title),
|
||||
pageWidth: PDF_SIZE_A4_72PPI.width,
|
||||
pageHeight: PDF_SIZE_A4_72PPI.height,
|
||||
envelopeCompletedAuditLog,
|
||||
};
|
||||
|
||||
// Use Playwright-based PDF generation if enabled, otherwise use Konva-based generation.
|
||||
@@ -263,22 +280,13 @@ export const run = async ({
|
||||
id: envelope.id,
|
||||
},
|
||||
data: {
|
||||
status: isRejected ? DocumentStatus.REJECTED : DocumentStatus.COMPLETED,
|
||||
status: finalEnvelopeStatus,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED,
|
||||
envelopeId: envelope.id,
|
||||
requestMetadata,
|
||||
user: null,
|
||||
data: {
|
||||
transactionId: nanoid(),
|
||||
...(isRejected ? { isRejected: true, rejectionReason: rejectionReason } : {}),
|
||||
},
|
||||
}),
|
||||
data: envelopeCompletedAuditLog,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { PDF } from '@libpdf/core';
|
||||
import { i18n } from '@lingui/core';
|
||||
|
||||
import { type TDocumentAuditLog } from '@documenso/lib/types/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { ZSupportedLanguageCodeSchema } from '../../constants/i18n';
|
||||
@@ -15,12 +16,20 @@ type GenerateAuditLogPdfOptions = GenerateCertificatePdfOptions & {
|
||||
};
|
||||
|
||||
export const generateAuditLogPdf = async (options: GenerateAuditLogPdfOptions) => {
|
||||
const { envelope, envelopeOwner, envelopeItems, recipients, language, pageWidth, pageHeight } =
|
||||
options;
|
||||
const {
|
||||
envelope,
|
||||
envelopeOwner,
|
||||
envelopeItems,
|
||||
recipients,
|
||||
language,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
envelopeCompletedAuditLog,
|
||||
} = options;
|
||||
|
||||
const documentLanguage = ZSupportedLanguageCodeSchema.parse(language);
|
||||
|
||||
const [organisationClaim, auditLogs, messages] = await Promise.all([
|
||||
const [organisationClaim, partialAuditLogs, messages] = await Promise.all([
|
||||
getOrganisationClaimByTeamId({ teamId: envelope.teamId }),
|
||||
getAuditLogs(envelope.id),
|
||||
getTranslations(documentLanguage),
|
||||
@@ -31,6 +40,17 @@ export const generateAuditLogPdf = async (options: GenerateAuditLogPdfOptions) =
|
||||
messages,
|
||||
});
|
||||
|
||||
const auditLogs: TDocumentAuditLog[] = [...partialAuditLogs];
|
||||
|
||||
if (envelopeCompletedAuditLog) {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
auditLogs.unshift({
|
||||
...envelopeCompletedAuditLog,
|
||||
id: '',
|
||||
createdAt: new Date(),
|
||||
} satisfies Omit<TDocumentAuditLog, 'type'> as TDocumentAuditLog);
|
||||
}
|
||||
|
||||
const auditLogPages = await renderAuditLogs({
|
||||
envelope,
|
||||
envelopeOwner,
|
||||
|
||||
@@ -7,6 +7,8 @@ import { FieldType } from '@prisma/client';
|
||||
import { prop, sortBy } from 'remeda';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import type { CreateDocumentAuditLogDataResponse } from '@documenso/lib/utils/document-audit-logs';
|
||||
|
||||
import { ZSupportedLanguageCodeSchema } from '../../constants/i18n';
|
||||
import type { TDocumentAuditLogBaseSchema } from '../../types/document-audit-logs';
|
||||
import { extractDocumentAuthMethods } from '../../utils/document-auth';
|
||||
@@ -16,7 +18,14 @@ import { getOrganisationClaimByTeamId } from '../organisation/get-organisation-c
|
||||
import { renderCertificate } from './render-certificate';
|
||||
|
||||
export type GenerateCertificatePdfOptions = {
|
||||
envelope: Envelope & {
|
||||
/**
|
||||
* Note: completedAt is not included since it's not real at this point in time.
|
||||
*
|
||||
* If we actually need it here in the future, we will need to preserve the
|
||||
* completedAt value and pass it to the final `envelope.update` function when
|
||||
* the document is initially sealed.
|
||||
*/
|
||||
envelope: Omit<Envelope, 'completedAt'> & {
|
||||
documentMeta: DocumentMeta;
|
||||
};
|
||||
envelopeOwner: {
|
||||
@@ -30,6 +39,7 @@ export type GenerateCertificatePdfOptions = {
|
||||
language?: string;
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
envelopeCompletedAuditLog?: CreateDocumentAuditLogDataResponse;
|
||||
};
|
||||
|
||||
export const generateCertificatePdf = async (options: GenerateCertificatePdfOptions) => {
|
||||
|
||||
@@ -30,7 +30,7 @@ export type AuditLogRecipient = {
|
||||
};
|
||||
|
||||
type GenerateAuditLogsOptions = {
|
||||
envelope: Envelope & {
|
||||
envelope: Omit<Envelope, 'completedAt'> & {
|
||||
documentMeta: DocumentMeta;
|
||||
};
|
||||
envelopeItems: string[];
|
||||
@@ -168,7 +168,7 @@ const renderVerticalLabelAndText = (options: RenderVerticalLabelAndTextOptions)
|
||||
};
|
||||
|
||||
type RenderOverviewCardOptions = {
|
||||
envelope: Envelope & {
|
||||
envelope: Omit<Envelope, 'completedAt'> & {
|
||||
documentMeta: DocumentMeta;
|
||||
};
|
||||
envelopeItems: string[];
|
||||
|
||||
@@ -78,6 +78,7 @@ const getDevice = (userAgent?: string | null): string => {
|
||||
const textMutedForegroundLight = '#929DAE';
|
||||
const textForeground = '#000';
|
||||
const textMutedForeground = '#64748B';
|
||||
const textRejectedRed = '#dc2626';
|
||||
const textBase = 10;
|
||||
const textSm = 9;
|
||||
const textXs = 8;
|
||||
@@ -97,6 +98,8 @@ type RenderLabelAndTextOptions = {
|
||||
text: string;
|
||||
width: number;
|
||||
y?: number;
|
||||
labelFill?: string;
|
||||
valueFill?: string;
|
||||
};
|
||||
|
||||
const renderLabelAndText = (options: RenderLabelAndTextOptions) => {
|
||||
@@ -106,13 +109,16 @@ const renderLabelAndText = (options: RenderLabelAndTextOptions) => {
|
||||
y,
|
||||
});
|
||||
|
||||
const labelFill = options.labelFill ?? textMutedForeground;
|
||||
const valueFill = options.valueFill ?? textMutedForeground;
|
||||
|
||||
const label = new Konva.Text({
|
||||
x: 0,
|
||||
y: 0,
|
||||
text: `${options.label}: `,
|
||||
fontStyle: fontMedium,
|
||||
fontFamily: 'Inter',
|
||||
fill: textMutedForeground,
|
||||
fill: labelFill,
|
||||
fontSize: textSm,
|
||||
});
|
||||
|
||||
@@ -124,7 +130,7 @@ const renderLabelAndText = (options: RenderLabelAndTextOptions) => {
|
||||
width: width - label.width(),
|
||||
fontFamily: 'Inter',
|
||||
text: options.text,
|
||||
fill: textMutedForeground,
|
||||
fill: valueFill,
|
||||
wrap: 'char',
|
||||
fontSize: textSm,
|
||||
});
|
||||
@@ -269,6 +275,8 @@ const renderColumnTwo = (options: RenderColumnOptions) => {
|
||||
|
||||
const columnWidth = width - columnPadding;
|
||||
|
||||
const isRejected = Boolean(recipient.logs.rejected);
|
||||
|
||||
if (recipient.signatureField?.secondaryId) {
|
||||
// Signature container with green border
|
||||
const signatureContainer = new Konva.Group({ x: 0, y: 0 });
|
||||
@@ -313,7 +321,10 @@ const renderColumnTwo = (options: RenderColumnOptions) => {
|
||||
signatureContainer.add(typedSig);
|
||||
}
|
||||
|
||||
column.add(signatureContainer);
|
||||
// Do not add the signature container for rejected recipients.
|
||||
if (!isRejected) {
|
||||
column.add(signatureContainer);
|
||||
}
|
||||
|
||||
const signatureHeight = Math.max(signatureContainer.getClientRect().height, minSignatureHeight);
|
||||
|
||||
@@ -342,7 +353,7 @@ const renderColumnTwo = (options: RenderColumnOptions) => {
|
||||
// Signature ID
|
||||
const sigIdLabel = new Konva.Text({
|
||||
x: 0,
|
||||
y: signatureHeight + 10,
|
||||
y: isRejected ? 0 : signatureHeight + 10,
|
||||
text: `${i18n._(msg`Signature ID`)}:`,
|
||||
fill: textMutedForeground,
|
||||
width: columnWidth,
|
||||
@@ -376,9 +387,11 @@ const renderColumnTwo = (options: RenderColumnOptions) => {
|
||||
column.add(naText);
|
||||
}
|
||||
|
||||
const relevantLog = isRejected ? recipient.logs.rejected : recipient.logs.completed;
|
||||
|
||||
const ipLabelAndText = renderLabelAndText({
|
||||
label: i18n._(msg`IP Address`),
|
||||
text: recipient.logs.completed?.ipAddress ?? i18n._(msg`Unknown`),
|
||||
text: relevantLog?.ipAddress ?? i18n._(msg`Unknown`),
|
||||
width,
|
||||
y: column.getClientRect().height + 6,
|
||||
});
|
||||
@@ -386,7 +399,7 @@ const renderColumnTwo = (options: RenderColumnOptions) => {
|
||||
|
||||
const deviceLabelAndText = renderLabelAndText({
|
||||
label: i18n._(msg`Device`),
|
||||
text: getDevice(recipient.logs.completed?.userAgent),
|
||||
text: getDevice(relevantLog?.userAgent),
|
||||
width,
|
||||
y: column.getClientRect().height + 6,
|
||||
});
|
||||
@@ -400,7 +413,14 @@ const renderColumnThree = (options: RenderColumnOptions) => {
|
||||
|
||||
const column = new Konva.Group();
|
||||
|
||||
const itemsToRender = [
|
||||
type DetailItem = {
|
||||
label: string;
|
||||
value: string;
|
||||
labelFill?: string;
|
||||
valueFill?: string;
|
||||
};
|
||||
|
||||
const itemsToRender: DetailItem[] = [
|
||||
{
|
||||
label: i18n._(msg`Sent`),
|
||||
value: recipient.logs.emailed
|
||||
@@ -429,6 +449,8 @@ const renderColumnThree = (options: RenderColumnOptions) => {
|
||||
value: DateTime.fromJSDate(recipient.logs.rejected.createdAt)
|
||||
.setLocale(APP_I18N_OPTIONS.defaultLocale)
|
||||
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)'),
|
||||
labelFill: textRejectedRed,
|
||||
valueFill: textRejectedRed,
|
||||
});
|
||||
} else {
|
||||
itemsToRender.push({
|
||||
@@ -459,6 +481,8 @@ const renderColumnThree = (options: RenderColumnOptions) => {
|
||||
text: item.value,
|
||||
width,
|
||||
y: column.getClientRect().height + (index === 0 ? 0 : 8),
|
||||
labelFill: item.labelFill,
|
||||
valueFill: item.valueFill,
|
||||
});
|
||||
column.add(labelAndText);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,12 @@ export const submitSupportTicket = async ({
|
||||
organisationId,
|
||||
teamId,
|
||||
}: SubmitSupportTicketOptions) => {
|
||||
if (!plainClient) {
|
||||
throw new AppError(AppErrorCode.NOT_SETUP, {
|
||||
message: 'Support ticket system is not configured',
|
||||
});
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: userId,
|
||||
@@ -52,6 +58,29 @@ export const submitSupportTicket = async ({
|
||||
})
|
||||
: null;
|
||||
|
||||
// Ensure the customer exists in Plain before creating a thread
|
||||
const plainCustomer = await plainClient.upsertCustomer({
|
||||
identifier: {
|
||||
emailAddress: user.email,
|
||||
},
|
||||
onCreate: {
|
||||
// If the user doesn't have a name, default to their email
|
||||
fullName: user.name || user.email,
|
||||
email: {
|
||||
email: user.email,
|
||||
isVerified: !!user.emailVerified,
|
||||
},
|
||||
},
|
||||
// No need to update the customer if it already exists
|
||||
onUpdate: {},
|
||||
});
|
||||
|
||||
if (plainCustomer.error) {
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: `Failed to create customer in support system: ${plainCustomer.error.message}`,
|
||||
});
|
||||
}
|
||||
|
||||
const customMessage = `
|
||||
Organisation: ${organisation.name} (${organisation.id})
|
||||
Team: ${team ? `${team.name} (${team.id})` : 'No team provided'}
|
||||
@@ -60,12 +89,14 @@ ${message}`;
|
||||
|
||||
const res = await plainClient.createThread({
|
||||
title: subject,
|
||||
customerIdentifier: { emailAddress: user.email },
|
||||
customerIdentifier: { customerId: plainCustomer.data.customer.id },
|
||||
components: [{ componentText: { text: customMessage } }],
|
||||
});
|
||||
|
||||
if (res.error) {
|
||||
throw new Error(res.error.message);
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: `Failed to create support ticket: ${res.error.message}`,
|
||||
});
|
||||
}
|
||||
|
||||
return res;
|
||||
|
||||
+485
-183
File diff suppressed because it is too large
Load Diff
+477
-175
File diff suppressed because it is too large
Load Diff
+485
-183
File diff suppressed because it is too large
Load Diff
+485
-183
File diff suppressed because it is too large
Load Diff
+485
-183
File diff suppressed because it is too large
Load Diff
+485
-183
File diff suppressed because it is too large
Load Diff
+485
-183
File diff suppressed because it is too large
Load Diff
+485
-183
File diff suppressed because it is too large
Load Diff
+485
-183
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+485
-183
File diff suppressed because it is too large
Load Diff
@@ -294,8 +294,10 @@ export const formatDocumentAuditLogAction = (
|
||||
auditLog: TDocumentAuditLog,
|
||||
userId?: number,
|
||||
) => {
|
||||
const prefix =
|
||||
userId === auditLog.userId ? i18n._(msg`You`) : auditLog.name || auditLog.email || '';
|
||||
const isCurrentUser = userId === auditLog.userId;
|
||||
const user = auditLog.name || auditLog.email || '';
|
||||
|
||||
const prefix = isCurrentUser ? i18n._(msg`You`) : user || '';
|
||||
|
||||
const description = match(auditLog)
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED }, () => ({
|
||||
@@ -303,245 +305,295 @@ export const formatDocumentAuditLogAction = (
|
||||
message: `A field was added`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`${prefix} added a field`,
|
||||
you: msg`You added a field`,
|
||||
user: msg`${user} added a field`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_DELETED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `A field was removed`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`${prefix} removed a field`,
|
||||
you: msg`You removed a field`,
|
||||
user: msg`${user} removed a field`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `A field was updated`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`${prefix} updated a field`,
|
||||
you: msg`You updated a field`,
|
||||
user: msg`${user} updated a field`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `A recipient was added`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`${prefix} added a recipient`,
|
||||
you: msg`You added a recipient`,
|
||||
user: msg`${user} added a recipient`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `A recipient was removed`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`${prefix} removed a recipient`,
|
||||
you: msg`You removed a recipient`,
|
||||
user: msg`${user} removed a recipient`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `A recipient was updated`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`${prefix} updated a recipient`,
|
||||
you: msg`You updated a recipient`,
|
||||
user: msg`${user} updated a recipient`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `Document created`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`${prefix} created the document`,
|
||||
you: msg`You created the document`,
|
||||
user: msg`${user} created the document`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `Document deleted`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`${prefix} deleted the document`,
|
||||
you: msg`You deleted the document`,
|
||||
user: msg`${user} deleted the document`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELDS_AUTO_INSERTED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `System auto inserted fields`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`System auto inserted fields`,
|
||||
you: msg({
|
||||
message: `System auto inserted fields`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
user: msg({
|
||||
message: `System auto inserted fields`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `Field signed`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`${prefix} signed a field`,
|
||||
you: msg`You signed a field`,
|
||||
user: msg`${user} signed a field`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `Field unsigned`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`${prefix} unsigned a field`,
|
||||
you: msg`You unsigned a field`,
|
||||
user: msg`${user} unsigned a field`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_PREFILLED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `Field prefilled by assistant`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`${prefix} prefilled a field`,
|
||||
you: msg`You prefilled a field`,
|
||||
user: msg`${user} prefilled a field`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VISIBILITY_UPDATED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `Document visibility updated`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`${prefix} updated the document visibility`,
|
||||
you: msg`You updated the document visibility`,
|
||||
user: msg`${user} updated the document visibility`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `Document access auth updated`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`${prefix} updated the document access auth requirements`,
|
||||
you: msg`You updated the document access auth requirements`,
|
||||
user: msg`${user} updated the document access auth requirements`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `Document signing auth updated`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`${prefix} updated the document signing auth requirements`,
|
||||
you: msg`You updated the document signing auth requirements`,
|
||||
user: msg`${user} updated the document signing auth requirements`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `Document updated`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`${prefix} updated the document`,
|
||||
you: msg`You updated the document`,
|
||||
user: msg`${user} updated the document`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `Document opened`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`${prefix} opened the document`,
|
||||
you: msg`You opened the document`,
|
||||
user: msg`${user} opened the document`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VIEWED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `Document viewed`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`${prefix} viewed the document`,
|
||||
you: msg`You viewed the document`,
|
||||
user: msg`${user} viewed the document`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `Document title updated`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`${prefix} updated the document title`,
|
||||
you: msg`You updated the document title`,
|
||||
user: msg`${user} updated the document title`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_EXTERNAL_ID_UPDATED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `Document external ID updated`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`${prefix} updated the document external ID`,
|
||||
you: msg`You updated the document external ID`,
|
||||
user: msg`${user} updated the document external ID`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT }, () => ({
|
||||
anonymous: msg({
|
||||
message: `Document sent`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`${prefix} sent the document`,
|
||||
you: msg`You sent the document`,
|
||||
user: msg`${user} sent the document`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_MOVED_TO_TEAM }, () => ({
|
||||
anonymous: msg({
|
||||
message: `Document moved to team`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`${prefix} moved the document to team`,
|
||||
you: msg`You moved the document to team`,
|
||||
user: msg`${user} moved the document to team`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED }, ({ data }) => {
|
||||
const userName = prefix || i18n._(msg`Recipient`);
|
||||
|
||||
const result = match(data.recipientRole)
|
||||
.with(RecipientRole.SIGNER, () => msg`${userName} signed the document`)
|
||||
.with(RecipientRole.VIEWER, () => msg`${userName} viewed the document`)
|
||||
.with(RecipientRole.APPROVER, () => msg`${userName} approved the document`)
|
||||
.with(RecipientRole.CC, () => msg`${userName} CC'd the document`)
|
||||
.otherwise(() => msg`${userName} completed their task`);
|
||||
|
||||
return {
|
||||
anonymous: result,
|
||||
identified: result,
|
||||
};
|
||||
return match(data.recipientRole)
|
||||
.with(RecipientRole.SIGNER, () => ({
|
||||
anonymous: msg`Recipient signed the document`,
|
||||
you: msg`You signed the document`,
|
||||
user: msg`${user} signed the document`,
|
||||
}))
|
||||
.with(RecipientRole.VIEWER, () => ({
|
||||
anonymous: msg`Recipient viewed the document`,
|
||||
you: msg`You viewed the document`,
|
||||
user: msg`${user} viewed the document`,
|
||||
}))
|
||||
.with(RecipientRole.APPROVER, () => ({
|
||||
anonymous: msg`Recipient approved the document`,
|
||||
you: msg`You approved the document`,
|
||||
user: msg`${user} approved the document`,
|
||||
}))
|
||||
.with(RecipientRole.CC, () => ({
|
||||
anonymous: msg`Recipient CC'd the document`,
|
||||
you: msg`You CC'd the document`,
|
||||
user: msg`${user} CC'd the document`,
|
||||
}))
|
||||
.otherwise(() => ({
|
||||
anonymous: msg`Recipient completed their task`,
|
||||
you: msg`You completed your task`,
|
||||
user: msg`${user} completed their task`,
|
||||
}));
|
||||
})
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED }, ({ data }) => {
|
||||
const userName = prefix || i18n._(msg`Recipient`);
|
||||
|
||||
const result = msg`${userName} rejected the document`;
|
||||
|
||||
return {
|
||||
anonymous: result,
|
||||
identified: result,
|
||||
};
|
||||
})
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_REQUESTED }, ({ data }) => {
|
||||
const userName = prefix || i18n._(msg`Recipient`);
|
||||
|
||||
const result = msg`${userName} requested a 2FA token for the document`;
|
||||
|
||||
return {
|
||||
anonymous: result,
|
||||
identified: result,
|
||||
};
|
||||
})
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_VALIDATED }, ({ data }) => {
|
||||
const userName = prefix || i18n._(msg`Recipient`);
|
||||
|
||||
const result = msg`${userName} validated a 2FA token for the document`;
|
||||
|
||||
return {
|
||||
anonymous: result,
|
||||
identified: result,
|
||||
};
|
||||
})
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_FAILED }, ({ data }) => {
|
||||
const userName = prefix || i18n._(msg`Recipient`);
|
||||
|
||||
const result = msg`${userName} failed to validate a 2FA token for the document`;
|
||||
|
||||
return {
|
||||
anonymous: result,
|
||||
identified: result,
|
||||
};
|
||||
})
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT }, ({ data }) => ({
|
||||
anonymous: data.isResending ? msg`Email resent` : msg`Email sent`,
|
||||
identified: data.isResending
|
||||
? msg`${prefix} resent an email to ${data.recipientEmail}`
|
||||
: msg`${prefix} sent an email to ${data.recipientEmail}`,
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED }, () => ({
|
||||
anonymous: msg`Recipient rejected the document`,
|
||||
you: msg`You rejected the document`,
|
||||
user: msg`${user} rejected the document`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_REQUESTED }, () => ({
|
||||
anonymous: msg`Recipient requested a 2FA token for the document`,
|
||||
you: msg`You requested a 2FA token for the document`,
|
||||
user: msg`${user} requested a 2FA token for the document`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_VALIDATED }, () => ({
|
||||
anonymous: msg`Recipient validated a 2FA token for the document`,
|
||||
you: msg`You validated a 2FA token for the document`,
|
||||
user: msg`${user} validated a 2FA token for the document`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_FAILED }, () => ({
|
||||
anonymous: msg`Recipient failed to validate a 2FA token for the document`,
|
||||
you: msg`You failed to validate a 2FA token for the document`,
|
||||
user: msg`${user} failed to validate a 2FA token for the document`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT }, ({ data }) => {
|
||||
if (data.isResending) {
|
||||
return {
|
||||
anonymous: msg({
|
||||
message: `Email resent`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
you: msg`You resent an email to ${data.recipientEmail}`,
|
||||
user: msg`${user} resent an email to ${data.recipientEmail}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
anonymous: msg({
|
||||
message: `Email sent`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
you: msg`You sent an email to ${data.recipientEmail}`,
|
||||
user: msg`${user} sent an email to ${data.recipientEmail}`,
|
||||
};
|
||||
})
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `Document completed`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg({
|
||||
message: `Document completed`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
anonymous: msg({ message: `Document completed`, context: `Audit log format` }),
|
||||
you: msg({ message: `Document completed`, context: `Audit log format` }),
|
||||
user: msg({ message: `Document completed`, context: `Audit log format` }),
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_CREATED }, ({ data }) => ({
|
||||
anonymous: msg`Envelope item created`,
|
||||
identified: msg`${prefix} created an envelope item with title ${data.envelopeItemTitle}`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_DELETED }, ({ data }) => ({
|
||||
anonymous: msg`Envelope item deleted`,
|
||||
identified: msg`${prefix} deleted an envelope item with title ${data.envelopeItemTitle}`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELEGATED_OWNER_CREATED }, ({ data }) => ({
|
||||
anonymous: msg({
|
||||
message: `Document ownership delegated`,
|
||||
message: `Envelope item created`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`The document ownership was delegated to ${data.delegatedOwnerName || data.delegatedOwnerEmail} on behalf of ${data.teamName}`,
|
||||
you: msg`You created an envelope item with title ${data.envelopeItemTitle}`,
|
||||
user: msg`${user} created an envelope item with title ${data.envelopeItemTitle}`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_DELETED }, ({ data }) => ({
|
||||
anonymous: msg({
|
||||
message: `Envelope item deleted`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
you: msg`You deleted an envelope item with title ${data.envelopeItemTitle}`,
|
||||
user: msg`${user} deleted an envelope item with title ${data.envelopeItemTitle}`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELEGATED_OWNER_CREATED }, ({ data }) => {
|
||||
const message = msg({
|
||||
message: `The document ownership was delegated to ${data.delegatedOwnerName || data.delegatedOwnerEmail} on behalf of ${data.teamName}`,
|
||||
context: `Audit log format`,
|
||||
});
|
||||
return {
|
||||
anonymous: message,
|
||||
you: message,
|
||||
user: message,
|
||||
};
|
||||
})
|
||||
.exhaustive();
|
||||
|
||||
let selectedDescription = description.anonymous;
|
||||
|
||||
if (isCurrentUser) {
|
||||
selectedDescription = description.you;
|
||||
} else if (user) {
|
||||
selectedDescription = description.user;
|
||||
}
|
||||
|
||||
return {
|
||||
prefix,
|
||||
description: i18n._(prefix ? description.identified : description.anonymous),
|
||||
description: i18n._(selectedDescription),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import type { FindResultResponse } from '@documenso/lib/types/search-params';
|
||||
import { getHighestTeamRoleInGroup } from '@documenso/lib/utils/teams';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { adminProcedure } from '../trpc';
|
||||
import { ZFindUserTeamsRequestSchema, ZFindUserTeamsResponseSchema } from './find-user-teams.types';
|
||||
|
||||
export const findUserTeamsRoute = adminProcedure
|
||||
.input(ZFindUserTeamsRequestSchema)
|
||||
.output(ZFindUserTeamsResponseSchema)
|
||||
.query(async ({ input }) => {
|
||||
const { userId, query, page, perPage } = input;
|
||||
|
||||
return await findUserTeams({
|
||||
userId,
|
||||
query,
|
||||
page,
|
||||
perPage,
|
||||
});
|
||||
});
|
||||
|
||||
type FindUserTeamsOptions = {
|
||||
userId: number;
|
||||
query?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
};
|
||||
|
||||
const findUserTeams = async ({ userId, query, page = 1, perPage = 10 }: FindUserTeamsOptions) => {
|
||||
const whereClause: Prisma.TeamWhereInput = {
|
||||
teamGroups: {
|
||||
some: {
|
||||
organisationGroup: {
|
||||
organisationGroupMembers: {
|
||||
some: {
|
||||
organisationMember: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (query && query.length > 0) {
|
||||
whereClause.name = {
|
||||
contains: query,
|
||||
mode: Prisma.QueryMode.insensitive,
|
||||
};
|
||||
}
|
||||
|
||||
const [data, count] = await Promise.all([
|
||||
prisma.team.findMany({
|
||||
where: whereClause,
|
||||
skip: Math.max(page - 1, 0) * perPage,
|
||||
take: perPage,
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
include: {
|
||||
organisation: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
url: true,
|
||||
},
|
||||
},
|
||||
teamGroups: {
|
||||
where: {
|
||||
organisationGroup: {
|
||||
organisationGroupMembers: {
|
||||
some: {
|
||||
organisationMember: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.team.count({
|
||||
where: whereClause,
|
||||
}),
|
||||
]);
|
||||
|
||||
const mappedData = data.map((team) => ({
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
url: team.url,
|
||||
createdAt: team.createdAt,
|
||||
teamRole: getHighestTeamRoleInGroup(team.teamGroups),
|
||||
organisation: team.organisation,
|
||||
}));
|
||||
|
||||
return {
|
||||
data: mappedData,
|
||||
count,
|
||||
currentPage: Math.max(page, 1),
|
||||
perPage,
|
||||
totalPages: Math.ceil(count / perPage),
|
||||
} satisfies FindResultResponse<typeof mappedData>;
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||
import { TeamMemberRoleSchema } from '@documenso/prisma/generated/zod/inputTypeSchemas/TeamMemberRoleSchema';
|
||||
import OrganisationSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationSchema';
|
||||
import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
|
||||
|
||||
export const ZFindUserTeamsRequestSchema = ZFindSearchParamsSchema.extend({
|
||||
userId: z.number(),
|
||||
});
|
||||
|
||||
export const ZFindUserTeamsResponseSchema = ZFindResultResponse.extend({
|
||||
data: TeamSchema.pick({
|
||||
id: true,
|
||||
name: true,
|
||||
url: true,
|
||||
createdAt: true,
|
||||
})
|
||||
.extend({
|
||||
teamRole: TeamMemberRoleSchema,
|
||||
organisation: OrganisationSchema.pick({
|
||||
id: true,
|
||||
name: true,
|
||||
url: true,
|
||||
}),
|
||||
})
|
||||
.array(),
|
||||
});
|
||||
|
||||
export type TFindUserTeamsRequest = z.infer<typeof ZFindUserTeamsRequestSchema>;
|
||||
export type TFindUserTeamsResponse = z.infer<typeof ZFindUserTeamsResponseSchema>;
|
||||
@@ -12,6 +12,7 @@ import { findDocumentAuditLogsRoute } from './find-document-audit-logs';
|
||||
import { findDocumentJobsRoute } from './find-document-jobs';
|
||||
import { findDocumentsRoute } from './find-documents';
|
||||
import { findSubscriptionClaimsRoute } from './find-subscription-claims';
|
||||
import { findUserTeamsRoute } from './find-user-teams';
|
||||
import { getAdminOrganisationRoute } from './get-admin-organisation';
|
||||
import { getUserRoute } from './get-user';
|
||||
import { promoteMemberToOwnerRoute } from './promote-member-to-owner';
|
||||
@@ -55,6 +56,7 @@ export const adminRouter = router({
|
||||
enable: enableUserRoute,
|
||||
disable: disableUserRoute,
|
||||
resetTwoFactor: resetTwoFactorRoute,
|
||||
findTeams: findUserTeamsRoute,
|
||||
},
|
||||
document: {
|
||||
find: findDocumentsRoute,
|
||||
|
||||
Reference in New Issue
Block a user