Compare commits

..

3 Commits

Author SHA1 Message Date
Ephraim Atta-Duncan 0aae25e423 fix: build errors 2025-10-20 22:19:31 +00:00
Ephraim Duncan a39b4efc28 Merge branch 'main' into feat/team-dashboard 2025-10-20 15:27:25 +00:00
Ephraim Atta-Duncan 202702b1c7 feat: team analytics 2025-08-20 15:19:09 +00:00
66 changed files with 846 additions and 1780 deletions
@@ -0,0 +1,67 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { MonthlyStats } from '@documenso/lib/server-only/analytics';
import { MonthlyActiveUsersChart } from '~/components/general/admin-monthly-active-user-charts';
export type AnalyticsChartsProps = {
monthlyData?: MonthlyStats;
showActiveUsers?: boolean;
};
export const AnalyticsCharts = ({ monthlyData, showActiveUsers = false }: AnalyticsChartsProps) => {
const { _ } = useLingui();
if (!monthlyData) {
return null;
}
// Ensure all data has cume_count for chart compatibility
const formatDataForChart = (data: MonthlyStats) => {
return data.map((item) => ({
...item,
count: item.count,
cume_count: item.cume_count || 0,
}));
};
return (
<div className="mt-16">
<h3 className="text-3xl font-semibold">
<Trans>Charts</Trans>
</h3>
<div className="mt-5 grid grid-cols-1 gap-8 lg:grid-cols-2">
{showActiveUsers && (
<>
<MonthlyActiveUsersChart
title={_(msg`Active Users (created document)`)}
data={formatDataForChart(monthlyData)}
/>
<MonthlyActiveUsersChart
title={_(msg`Cumulative Active Users`)}
data={formatDataForChart(monthlyData)}
cummulative
/>
</>
)}
{!showActiveUsers && (
<>
<MonthlyActiveUsersChart
title={_(msg`Documents Created`)}
data={formatDataForChart(monthlyData)}
/>
<MonthlyActiveUsersChart
title={_(msg`Documents Completed`)}
data={formatDataForChart(
monthlyData.map((d) => ({ ...d, count: d.signed_count || 0 })),
)}
/>
</>
)}
</div>
</div>
);
};
@@ -0,0 +1,88 @@
import { useMemo } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { DateTime } from 'luxon';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
export type DateFilterPeriod = 'all' | 'year' | 'month' | 'week' | 'day';
export type AnalyticsDateFilterProps = {
value: DateFilterPeriod;
onChange: (value: DateFilterPeriod) => void;
className?: string;
};
export const AnalyticsDateFilter = ({ value, onChange, className }: AnalyticsDateFilterProps) => {
const { _ } = useLingui();
const options = useMemo(
() => [
{ value: 'all' as const, label: _(msg`All Time`) },
{ value: 'year' as const, label: _(msg`This Year`) },
{ value: 'month' as const, label: _(msg`This Month`) },
{ value: 'week' as const, label: _(msg`This Week`) },
{ value: 'day' as const, label: _(msg`Today`) },
],
[_],
);
const selectedOption = options.find((option) => option.value === value);
return (
<div className={className}>
<Select value={value} onValueChange={onChange}>
<SelectTrigger className="w-40">
<SelectValue placeholder={_(msg`Select period`)}>{selectedOption?.label}</SelectValue>
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
};
export const getDateRangeFromPeriod = (period: DateFilterPeriod) => {
const now = DateTime.now();
switch (period) {
case 'day':
return {
dateFrom: now.startOf('day').toJSDate(),
dateTo: now.endOf('day').toJSDate(),
};
case 'week':
return {
dateFrom: now.startOf('week').toJSDate(),
dateTo: now.endOf('week').toJSDate(),
};
case 'month':
return {
dateFrom: now.startOf('month').toJSDate(),
dateTo: now.endOf('month').toJSDate(),
};
case 'year':
return {
dateFrom: now.startOf('year').toJSDate(),
dateTo: now.endOf('year').toJSDate(),
};
case 'all':
default:
return {
dateFrom: undefined,
dateTo: undefined,
};
}
};
@@ -0,0 +1,105 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import {
File,
FileCheck,
FileClock,
FileEdit,
Mail,
MailOpen,
PenTool,
UserSquare2,
} from 'lucide-react';
import type { DocumentStats, RecipientStats } from '@documenso/lib/server-only/analytics';
import { cn } from '@documenso/ui/lib/utils';
import { CardMetric } from '~/components/general/metric-card';
export type AnalyticsMetricsProps = {
docStats: DocumentStats;
recipientStats: RecipientStats;
isLoading?: boolean;
className?: string;
};
export const AnalyticsMetrics = ({
docStats,
recipientStats,
isLoading = false,
className,
}: AnalyticsMetricsProps) => {
const { _ } = useLingui();
return (
<div className={className}>
{/* Overview Metrics */}
<div
className={cn('grid flex-1 grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4', {
'pointer-events-none opacity-50': isLoading,
})}
>
<CardMetric icon={File} title={_(msg`Total Documents`)} value={docStats.ALL} />
<CardMetric
icon={FileCheck}
title={_(msg`Completed Documents`)}
value={docStats.COMPLETED}
/>
<CardMetric
icon={UserSquare2}
title={_(msg`Total Recipients`)}
value={recipientStats.TOTAL_RECIPIENTS}
/>
<CardMetric
icon={PenTool}
title={_(msg`Signatures Collected`)}
value={recipientStats.SIGNED}
/>
</div>
{/* Document Metrics Section */}
<div className="mt-16">
<h3 className="text-3xl font-semibold">
<Trans>Document metrics</Trans>
</h3>
<div className="mb-8 mt-4 grid flex-1 grid-cols-1 gap-4 md:grid-cols-2">
<CardMetric icon={FileEdit} title={_(msg`Drafted Documents`)} value={docStats.DRAFT} />
<CardMetric icon={FileClock} title={_(msg`Pending Documents`)} value={docStats.PENDING} />
<CardMetric
icon={FileCheck}
title={_(msg`Completed Documents`)}
value={docStats.COMPLETED}
/>
</div>
</div>
{/* Recipients Metrics Section */}
<div>
<h3 className="text-3xl font-semibold">
<Trans>Recipients metrics</Trans>
</h3>
<div className="mb-8 mt-4 grid flex-1 grid-cols-1 gap-4 md:grid-cols-2">
<CardMetric
icon={UserSquare2}
title={_(msg`Total Recipients`)}
value={recipientStats.TOTAL_RECIPIENTS}
/>
<CardMetric icon={Mail} title={_(msg`Documents Sent`)} value={recipientStats.SENT} />
<CardMetric
icon={MailOpen}
title={_(msg`Documents Viewed`)}
value={recipientStats.OPENED}
/>
<CardMetric
icon={PenTool}
title={_(msg`Signatures Collected`)}
value={recipientStats.SIGNED}
/>
</div>
</div>
</div>
);
};
@@ -0,0 +1,71 @@
import { Trans } from '@lingui/react/macro';
import { useNavigation } from 'react-router';
import type {
DocumentStats,
MonthlyStats,
RecipientStats,
} from '@documenso/lib/server-only/analytics';
import { AnalyticsCharts } from './analytics-charts';
import { AnalyticsDateFilter, type DateFilterPeriod } from './analytics-date-filter';
import { AnalyticsMetrics } from './analytics-metrics';
export type AnalyticsPageData = {
docStats: DocumentStats;
recipientStats: RecipientStats;
monthlyData?: MonthlyStats;
period: DateFilterPeriod;
};
export type AnalyticsPageProps = {
title: string;
subtitle: string;
data: AnalyticsPageData;
showCharts?: boolean;
onPeriodChange: (period: DateFilterPeriod) => void;
containerClassName?: string;
};
export const AnalyticsPage = ({
title,
subtitle,
data,
showCharts = true,
onPeriodChange,
containerClassName = 'mx-auto w-full max-w-screen-xl px-4 md:px-8',
}: AnalyticsPageProps) => {
const navigation = useNavigation();
const { docStats, recipientStats, monthlyData, period } = data;
const isLoading = navigation.state === 'loading';
return (
<div className={containerClassName}>
<div className="flex items-center justify-between">
<div>
<h2 className="text-4xl font-semibold">
<Trans>{title}</Trans>
</h2>
<p className="text-muted-foreground mt-2">
<Trans>{subtitle}</Trans>
</p>
</div>
<AnalyticsDateFilter value={period} onChange={onPeriodChange} />
</div>
<AnalyticsMetrics
docStats={docStats}
recipientStats={recipientStats}
isLoading={isLoading}
className="mt-8"
/>
{showCharts && (
<AnalyticsCharts
monthlyData={monthlyData}
showActiveUsers={!!monthlyData?.[0]?.cume_count}
/>
)}
</div>
);
};
@@ -223,8 +223,6 @@ export const DocumentEditForm = ({
meta: {
allowDictateNextSigner: data.allowDictateNextSigner,
signingOrder: data.signingOrder,
expiryAmount: data.meta.expiryAmount,
expiryUnit: data.meta.expiryUnit,
},
}),
@@ -249,8 +247,6 @@ export const DocumentEditForm = ({
meta: {
allowDictateNextSigner: data.allowDictateNextSigner,
signingOrder: data.signingOrder,
expiryAmount: data.meta.expiryAmount,
expiryUnit: data.meta.expiryUnit,
},
}),
@@ -480,17 +476,6 @@ export const DocumentEditForm = ({
recipients={recipients}
signingOrder={document.documentMeta?.signingOrder}
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
expiryAmount={document.documentMeta?.expiryAmount}
expiryUnit={
document.documentMeta?.expiryUnit as
| 'minutes'
| 'hours'
| 'days'
| 'weeks'
| 'months'
| null
| undefined
}
fields={fields}
onSubmit={onAddSignersFormSubmit}
onAutoSave={onAddSignersFormAutoSave}
@@ -156,14 +156,6 @@ export const DocumentPageViewRecipients = ({
</PopoverHover>
)}
{envelope.status !== DocumentStatus.DRAFT &&
recipient.signingStatus === SigningStatus.EXPIRED && (
<Badge variant="warning">
<Clock className="mr-1 h-3 w-3" />
<Trans>Expired</Trans>
</Badge>
)}
{envelope.status === DocumentStatus.PENDING &&
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
recipient.role !== RecipientRole.CC && (
@@ -97,6 +97,12 @@ export const MenuSwitcher = () => {
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<Link to="/settings/analytics">
<Trans>Personal analytics</Trans>
</Link>
</DropdownMenuItem>
<DropdownMenuItem
className="text-muted-foreground px-4 py-2"
onClick={() => setLanguageSwitcherOpen(true)}
@@ -307,11 +307,18 @@ export const OrgMenuSwitcher = () => {
)}
{currentTeam && canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole) && (
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<Link to={`/t/${currentTeam.url}/settings`}>
<Trans>Team settings</Trans>
</Link>
</DropdownMenuItem>
<>
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<Link to={`/t/${currentTeam.url}/settings`}>
<Trans>Team settings</Trans>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<Link to={`/t/${currentTeam.url}/analytics`}>
<Trans>Team analytics</Trans>
</Link>
</DropdownMenuItem>
</>
)}
<DropdownMenuItem
@@ -2,6 +2,7 @@ import type { HTMLAttributes } from 'react';
import { Trans } from '@lingui/react/macro';
import {
BarChart3Icon,
BracesIcon,
CreditCardIcon,
Globe2Icon,
@@ -128,6 +129,19 @@ export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavPr
</Button>
</Link>
<Link to="/settings/analytics">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/analytics') && 'bg-secondary',
)}
>
<BarChart3Icon className="mr-2 h-5 w-5" />
<Trans>Analytics</Trans>
</Button>
</Link>
{IS_BILLING_ENABLED() && (
<Link to="/settings/billing">
<Button
@@ -2,6 +2,7 @@ import type { HTMLAttributes } from 'react';
import { Trans } from '@lingui/react/macro';
import {
BarChart3Icon,
BracesIcon,
CreditCardIcon,
Globe2Icon,
@@ -128,6 +129,19 @@ export const SettingsMobileNav = ({ className, ...props }: SettingsMobileNavProp
</Button>
</Link>
<Link to="/settings/analytics">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/analytics') && 'bg-secondary',
)}
>
<BarChart3Icon className="mr-2 h-5 w-5" />
<Trans>Analytics</Trans>
</Button>
</Link>
{IS_BILLING_ENABLED() && (
<Link to="/settings/billing">
<Button
@@ -41,9 +41,6 @@ export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAva
case RecipientStatusType.REJECTED:
classes = 'bg-red-200 text-red-800';
break;
case RecipientStatusType.EXPIRED:
classes = 'bg-orange-200 text-orange-800';
break;
default:
break;
}
@@ -48,20 +48,13 @@ export const StackAvatarsWithTooltip = ({
(recipient) => getRecipientType(recipient) === RecipientStatusType.REJECTED,
);
const expiredRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) === RecipientStatusType.EXPIRED,
);
const sortedRecipients = useMemo(() => {
const otherRecipients = recipients.filter(
(recipient) =>
getRecipientType(recipient) !== RecipientStatusType.REJECTED &&
getRecipientType(recipient) !== RecipientStatusType.EXPIRED,
(recipient) => getRecipientType(recipient) !== RecipientStatusType.REJECTED,
);
return [
...rejectedRecipients.sort((a, b) => a.id - b.id),
...expiredRecipients.sort((a, b) => a.id - b.id),
...otherRecipients.sort((a, b) => {
return a.id - b.id;
}),
@@ -124,30 +117,6 @@ export const StackAvatarsWithTooltip = ({
</div>
)}
{expiredRecipients.length > 0 && (
<div>
<h1 className="text-base font-medium">
<Trans>Expired</Trans>
</h1>
{expiredRecipients.map((recipient: Recipient) => (
<div key={recipient.id} className="my-1 flex items-center gap-2">
<StackAvatar
first={true}
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<div>
<p className="text-muted-foreground text-sm">{recipient.email}</p>
<p className="text-muted-foreground/70 text-xs">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
</div>
</div>
))}
</div>
)}
{waitingRecipients.length > 0 && (
<div>
<h1 className="text-base font-medium">
@@ -2,7 +2,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
import { CheckCircle, Clock, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
import { CheckCircle, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
@@ -36,7 +36,6 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
const isPending = row.status === DocumentStatus.PENDING;
const isComplete = isDocumentCompleted(row.status);
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const isExpired = recipient?.signingStatus === SigningStatus.EXPIRED;
const role = recipient?.role;
const isCurrentTeamDocument = team && row.team?.url === team.url;
@@ -88,15 +87,8 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
isPending,
isComplete,
isSigned,
isExpired,
isCurrentTeamDocument,
})
.with({ isRecipient: true, isExpired: true }, () => (
<Button className="w-32 bg-orange-100 text-orange-600 hover:bg-orange-200" disabled={true}>
<Clock className="-ml-1 mr-2 h-4 w-4" />
<Trans>Expired</Trans>
</Button>
))
.with(
isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true },
() => (
@@ -5,7 +5,7 @@ import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus as DocumentStatusEnum } from '@prisma/client';
import { RecipientRole, SigningStatus } from '@prisma/client';
import { CheckCircleIcon, Clock, DownloadIcon, EyeIcon, Loader, PencilIcon } from 'lucide-react';
import { CheckCircleIcon, DownloadIcon, EyeIcon, Loader, PencilIcon } from 'lucide-react';
import { DateTime } from 'luxon';
import { Link, useSearchParams } from 'react-router';
import { match } from 'ts-pattern';
@@ -193,7 +193,6 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
const isPending = row.status === DocumentStatusEnum.PENDING;
const isComplete = isDocumentCompleted(row.status);
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const isExpired = recipient?.signingStatus === SigningStatus.EXPIRED;
const role = recipient?.role;
if (!recipient) {
@@ -231,14 +230,7 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
isPending,
isComplete,
isSigned,
isExpired,
})
.with({ isExpired: true }, () => (
<Button className="w-32 bg-orange-100 text-orange-600 hover:bg-orange-200" disabled={true}>
<Clock className="-ml-1 mr-2 h-4 w-4" />
<Trans>Expired</Trans>
</Button>
))
.with({ isPending: true, isSigned: false }, () => (
<Button className="w-32" asChild>
<Link to={`/sign/${recipient?.token}`}>
@@ -0,0 +1,24 @@
import { useSearchParams } from 'react-router';
import type { DateFilterPeriod } from '~/components/analytics/analytics-date-filter';
export const useAnalyticsFilter = () => {
const [searchParams, setSearchParams] = useSearchParams();
const period = (searchParams.get('period') as DateFilterPeriod) || 'all';
const handlePeriodChange = (newPeriod: DateFilterPeriod) => {
const params = new URLSearchParams(searchParams);
if (newPeriod === 'all') {
params.delete('period');
} else {
params.set('period', newPeriod);
}
setSearchParams(params);
};
return {
period,
handlePeriodChange,
};
};
@@ -0,0 +1,55 @@
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { getUserDocumentStats, getUserRecipientsStats } from '@documenso/lib/server-only/analytics';
import {
type DateFilterPeriod,
getDateRangeFromPeriod,
} from '~/components/analytics/analytics-date-filter';
import { AnalyticsPage } from '~/components/analytics/analytics-page';
import { useAnalyticsFilter } from '~/hooks/use-analytics-filter';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/analytics';
export function meta() {
return appMetaTags('Personal Analytics');
}
export async function loader({ request }: Route.LoaderArgs) {
const session = await getSession(request);
const url = new URL(request.url);
const period = (url.searchParams.get('period') as DateFilterPeriod) || 'all';
const { dateFrom, dateTo } = getDateRangeFromPeriod(period);
const [docStats, recipientStats] = await Promise.all([
getUserDocumentStats({ userId: session.user.id, dateFrom, dateTo }),
getUserRecipientsStats({ userId: session.user.id, dateFrom, dateTo }),
]);
return {
docStats,
recipientStats,
period,
};
}
export default function PersonalAnalyticsPage({ loaderData }: Route.ComponentProps) {
const { handlePeriodChange } = useAnalyticsFilter();
const { docStats, recipientStats, period } = loaderData;
return (
<AnalyticsPage
title="Personal Analytics"
subtitle="Your personal document signing analytics and insights"
data={{
docStats,
recipientStats,
period,
}}
showCharts={false}
onPeriodChange={handlePeriodChange}
containerClassName=""
/>
);
}
@@ -0,0 +1,88 @@
import { redirect } from 'react-router';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import {
getTeamDocumentStats,
getTeamMonthlyActiveUsers,
getTeamRecipientsStats,
} from '@documenso/lib/server-only/analytics';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import {
type DateFilterPeriod,
getDateRangeFromPeriod,
} from '~/components/analytics/analytics-date-filter';
import { AnalyticsPage } from '~/components/analytics/analytics-page';
import { useAnalyticsFilter } from '~/hooks/use-analytics-filter';
import { useCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/analytics';
export function meta() {
return appMetaTags('Team Analytics');
}
export async function loader({ request, params }: Route.LoaderArgs) {
try {
const session = await getSession(request);
const url = new URL(request.url);
const period = (url.searchParams.get('period') as DateFilterPeriod) || 'all';
const team = await getTeamByUrl({
userId: session.user.id,
teamUrl: params.teamUrl,
});
if (!team || !canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole)) {
throw redirect(`/t/${params.teamUrl}`);
}
const { dateFrom, dateTo } = getDateRangeFromPeriod(period);
const [docStats, recipientStats, monthlyActiveUsers] = await Promise.all([
getTeamDocumentStats({ teamId: team.id, dateFrom, dateTo }),
getTeamRecipientsStats({ teamId: team.id, dateFrom, dateTo }),
getTeamMonthlyActiveUsers(team.id),
]);
return {
docStats,
recipientStats,
monthlyActiveUsers,
period,
};
} catch (error) {
console.error('Failed to load team analytics:', error);
// If it's a redirect, re-throw it
if (error instanceof Response) {
throw error;
}
throw new Response('Failed to load team analytics data', { status: 500 });
}
}
export default function TeamAnalyticsPage({ loaderData }: Route.ComponentProps) {
const team = useCurrentTeam();
const { handlePeriodChange } = useAnalyticsFilter();
const { docStats, recipientStats, monthlyActiveUsers, period } = loaderData;
return (
<AnalyticsPage
title="Team Analytics"
subtitle={`Analytics and insights for ${team.name}`}
data={{
docStats,
recipientStats,
monthlyData: monthlyActiveUsers,
period,
}}
showCharts={true}
onPeriodChange={handlePeriodChange}
/>
);
}
@@ -16,7 +16,6 @@ import { getEnvelopeForRecipientSigning } from '@documenso/lib/server-only/envel
import { getEnvelopeRequiredAccessData } from '@documenso/lib/server-only/envelope/get-envelope-required-access-data';
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { expireRecipient } from '@documenso/lib/server-only/recipient/expire-recipient';
import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
import { getNextPendingRecipient } from '@documenso/lib/server-only/recipient/get-next-pending-recipient';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
@@ -26,7 +25,6 @@ import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settin
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { isRecipientExpired } from '@documenso/lib/utils/expiry';
import { prisma } from '@documenso/prisma';
import { SigningCard3D } from '@documenso/ui/components/signing-card';
@@ -138,13 +136,6 @@ const handleV1Loader = async ({ params, request }: Route.LoaderArgs) => {
const { documentMeta } = document;
if (isRecipientExpired(recipient)) {
const expiredRecipient = await expireRecipient({ recipientId: recipient.id });
if (expiredRecipient) {
throw redirect(`/sign/${token}/expired`);
}
}
if (recipient.signingStatus === SigningStatus.REJECTED) {
throw redirect(`/sign/${token}/rejected`);
}
@@ -248,13 +239,6 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
recipientAccessAuth: derivedRecipientAccessAuth,
}).catch(() => null);
if (isRecipientExpired(recipient)) {
const expiredRecipient = await expireRecipient({ recipientId: recipient.id });
if (expiredRecipient) {
throw redirect(`/sign/${token}/expired`);
}
}
if (isRejected) {
throw redirect(`/sign/${token}/rejected`);
}
@@ -1,141 +0,0 @@
import { Trans } from '@lingui/react/macro';
import { FieldType } from '@prisma/client';
import { Clock8 } from 'lucide-react';
import { Link } from 'react-router';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { isRecipientExpired } from '@documenso/lib/utils/expiry';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
import { truncateTitle } from '~/utils/truncate-title';
import type { Route } from './+types/expired';
export async function loader({ params, request }: Route.LoaderArgs) {
const { user } = await getOptionalSession(request);
const { token } = params;
if (!token) {
throw new Response('Not Found', { status: 404 });
}
const document = await getDocumentAndSenderByToken({
token,
requireAccessAuth: false,
}).catch(() => null);
if (!document) {
throw new Response('Not Found', { status: 404 });
}
const truncatedTitle = truncateTitle(document.title);
const [fields, recipient] = await Promise.all([
getFieldsForToken({ token }),
getRecipientByToken({ token }).catch(() => null),
]);
if (!recipient) {
throw new Response('Not Found', { status: 404 });
}
if (!isRecipientExpired(recipient)) {
throw new Response('Not Found', { status: 404 });
}
const isDocumentAccessValid = await isRecipientAuthorized({
type: 'ACCESS',
documentAuthOptions: document.authOptions,
recipient,
userId: user?.id,
});
const recipientReference =
recipient.name ||
fields.find((field) => field.type === FieldType.NAME)?.customText ||
recipient.email;
if (isDocumentAccessValid) {
return {
isDocumentAccessValid: true,
recipientReference,
truncatedTitle,
recipient,
};
}
// Don't leak data if access is denied.
return {
isDocumentAccessValid: false,
recipientReference,
};
}
export default function SigningExpiredPage({ loaderData }: Route.ComponentProps) {
const { sessionData } = useOptionalSession();
const user = sessionData?.user;
const { isDocumentAccessValid, recipientReference, truncatedTitle, recipient } = loaderData;
if (!isDocumentAccessValid) {
return <DocumentSigningAuthPageView email={recipientReference} />;
}
return (
<div className="flex flex-col items-center pt-24 lg:pt-36 xl:pt-44">
<Badge variant="neutral" size="default" className="mb-6 rounded-xl border bg-transparent">
{truncatedTitle}
</Badge>
<div className="flex flex-col items-center">
<div className="flex items-center gap-x-4">
<Clock8 className="h-10 w-10 text-orange-500" />
<h2 className="max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
<Trans>Signing Link Expired</Trans>
</h2>
</div>
<div className="mt-4 flex items-center text-center text-sm text-orange-600">
<Trans>This signing link is no longer valid</Trans>
</div>
<p className="text-muted-foreground mt-6 max-w-[60ch] text-center text-sm">
<Trans>
The signing link has expired and can no longer be used to sign the document. Please
contact the document sender if you need a new signing link.
</Trans>
</p>
{recipient?.expired && (
<p className="text-muted-foreground mt-2 max-w-[60ch] text-center text-sm">
<Trans>
Expired on:{' '}
{new Date(recipient.expired).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</Trans>
</p>
)}
{user && (
<Button className="mt-6" asChild>
<Link to={`/`}>Return Home</Link>
</Button>
)}
</div>
</div>
);
}
@@ -4,6 +4,7 @@ import { ZBaseEmbedDataSchema } from './embed-base-schemas';
export const ZBaseEmbedAuthoringSchema = z
.object({
token: z.string(),
externalId: z.string().optional(),
features: z
.object({
-3
View File
@@ -1,7 +1,6 @@
import { Hono } from 'hono';
import { rateLimiter } from 'hono-rate-limiter';
import { contextStorage } from 'hono/context-storage';
import { cors } from 'hono/cors';
import { requestId } from 'hono/request-id';
import type { RequestIdVariables } from 'hono/request-id';
import type { Logger } from 'pino';
@@ -84,14 +83,12 @@ app.route('/api/auth', auth);
app.route('/api/files', filesRoute);
// API servers.
app.use(`/api/v1/*`, cors());
app.route('/api/v1', tsRestHonoApp);
app.use('/api/jobs/*', jobsClient.getApiHandler());
app.use('/api/trpc/*', reactRouterTrpcServer);
// Unstable API server routes. Order matters for these two.
app.get(`${API_V2_BETA_URL}/openapi.json`, (c) => c.json(openApiDocument));
app.use(`${API_V2_BETA_URL}/*`, cors());
app.use(`${API_V2_BETA_URL}/*`, async (c) => openApiTrpcServerHandler(c));
export default app;
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/consistent-type-assertions */
import { useEffect, useState } from 'react';
import { RefObject, useEffect, useState } from 'react';
/**
* Calculate the width and height of a text element.
@@ -13,7 +13,6 @@ export enum RecipientStatusType {
WAITING = 'waiting',
UNSIGNED = 'unsigned',
REJECTED = 'rejected',
EXPIRED = 'expired',
}
export const getRecipientType = (
@@ -28,10 +27,6 @@ export const getRecipientType = (
return RecipientStatusType.REJECTED;
}
if (recipient.signingStatus === SigningStatus.EXPIRED) {
return RecipientStatusType.EXPIRED;
}
if (
recipient.readStatus === ReadStatus.OPENED &&
recipient.signingStatus === SigningStatus.NOT_SIGNED
@@ -57,10 +52,6 @@ export const getExtraRecipientsType = (extraRecipients: Recipient[]) => {
return RecipientStatusType.UNSIGNED;
}
if (types.includes(RecipientStatusType.EXPIRED)) {
return RecipientStatusType.EXPIRED;
}
if (types.includes(RecipientStatusType.OPENED)) {
return RecipientStatusType.OPENED;
}
@@ -15,7 +15,6 @@ export const getRecipientsStats = async () => {
[SigningStatus.SIGNED]: 0,
[SigningStatus.NOT_SIGNED]: 0,
[SigningStatus.REJECTED]: 0,
[SigningStatus.EXPIRED]: 0,
[SendStatus.SENT]: 0,
[SendStatus.NOT_SENT]: 0,
};
@@ -0,0 +1,68 @@
import type { DocumentStatus, Prisma } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import type {
AnalyticsFilters,
DocumentStats,
TeamDocumentStatsFilters,
UserDocumentStatsFilters,
} from './types';
export const getDocumentStats = async (filters: AnalyticsFilters): Promise<DocumentStats> => {
const { dateFrom, dateTo, ...entityFilter } = filters;
const where: Prisma.EnvelopeWhereInput = {
...entityFilter,
deletedAt: null,
...(dateFrom || dateTo
? {
createdAt: {
...(dateFrom && { gte: dateFrom }),
...(dateTo && { lte: dateTo }),
},
}
: {}),
};
const counts = await prisma.envelope.groupBy({
by: ['status'],
where,
_count: {
_all: true,
},
});
const stats: DocumentStats = {
[ExtendedDocumentStatus.DRAFT]: 0,
[ExtendedDocumentStatus.PENDING]: 0,
[ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.REJECTED]: 0,
[ExtendedDocumentStatus.ALL]: 0,
};
counts.forEach((stat: { status: DocumentStatus; _count: { _all: number } }) => {
stats[stat.status as DocumentStatus] = stat._count._all;
stats.ALL += stat._count._all;
});
return stats;
};
// Legacy wrapper functions for backwards compatibility
export const getTeamDocumentStats = async (filters: TeamDocumentStatsFilters) => {
return getDocumentStats({
teamId: filters.teamId,
dateFrom: filters.dateFrom,
dateTo: filters.dateTo,
});
};
export const getUserDocumentStats = async (filters: UserDocumentStatsFilters) => {
return getDocumentStats({
userId: filters.userId,
dateFrom: filters.dateFrom,
dateTo: filters.dateTo,
});
};
@@ -0,0 +1,80 @@
import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
import type { MonthlyStats } from './types';
type MonthlyDocumentGrowthQueryResult = Array<{
month: Date;
count: bigint;
signed_count: bigint;
}>;
type MonthlyActiveUsersQueryResult = Array<{
month: Date;
count: bigint;
cume_count: bigint;
}>;
export const getTeamMonthlyDocumentGrowth = async (teamId: number): Promise<MonthlyStats> => {
const result = await prisma.$queryRaw<MonthlyDocumentGrowthQueryResult>`
SELECT
DATE_TRUNC('month', "Document"."createdAt") AS "month",
COUNT(DISTINCT "Document"."id") as "count",
COUNT(DISTINCT CASE WHEN "Document"."status" = 'COMPLETED' THEN "Document"."id" END) as "signed_count"
FROM "Document"
WHERE "Document"."teamId" = ${teamId}
AND "Document"."deletedAt" IS NULL
GROUP BY "month"
ORDER BY "month" DESC
LIMIT 12
`;
return result.map((row) => ({
month: DateTime.fromJSDate(row.month).toFormat('yyyy-MM'),
count: Number(row.count),
signed_count: Number(row.signed_count),
}));
};
export const getUserMonthlyDocumentGrowth = async (userId: number): Promise<MonthlyStats> => {
const result = await prisma.$queryRaw<MonthlyDocumentGrowthQueryResult>`
SELECT
DATE_TRUNC('month', "Document"."createdAt") AS "month",
COUNT(DISTINCT "Document"."id") as "count",
COUNT(DISTINCT CASE WHEN "Document"."status" = 'COMPLETED' THEN "Document"."id" END) as "signed_count"
FROM "Document"
WHERE "Document"."userId" = ${userId}
AND "Document"."deletedAt" IS NULL
GROUP BY "month"
ORDER BY "month" DESC
LIMIT 12
`;
return result.map((row) => ({
month: DateTime.fromJSDate(row.month).toFormat('yyyy-MM'),
count: Number(row.count),
signed_count: Number(row.signed_count),
}));
};
export const getTeamMonthlyActiveUsers = async (teamId: number): Promise<MonthlyStats> => {
const result = await prisma.$queryRaw<MonthlyActiveUsersQueryResult>`
SELECT
DATE_TRUNC('month', "Document"."createdAt") AS "month",
COUNT(DISTINCT "Document"."userId") as "count",
SUM(COUNT(DISTINCT "Document"."userId")) OVER (ORDER BY DATE_TRUNC('month', "Document"."createdAt")) as "cume_count"
FROM "Document"
WHERE "Document"."teamId" = ${teamId}
AND "Document"."deletedAt" IS NULL
GROUP BY "month"
ORDER BY "month" DESC
LIMIT 12
`;
return result.map((row) => ({
month: DateTime.fromJSDate(row.month).toFormat('yyyy-MM'),
count: Number(row.count),
cume_count: Number(row.cume_count),
}));
};
@@ -0,0 +1,75 @@
import { type Prisma, ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import type {
AnalyticsFilters,
RecipientStats,
TeamRecipientsStatsFilters,
UserRecipientsStatsFilters,
} from './types';
export const getRecipientStats = async (filters: AnalyticsFilters): Promise<RecipientStats> => {
const { dateFrom, dateTo, ...entityFilter } = filters;
const where: Prisma.RecipientWhereInput = {
envelope: {
...entityFilter,
deletedAt: null,
...(dateFrom || dateTo
? {
createdAt: {
...(dateFrom && { gte: dateFrom }),
...(dateTo && { lte: dateTo }),
},
}
: {}),
},
};
const results = await prisma.recipient.groupBy({
by: ['readStatus', 'signingStatus', 'sendStatus'],
where,
_count: true,
});
const stats: RecipientStats = {
TOTAL_RECIPIENTS: 0,
[ReadStatus.OPENED]: 0,
[ReadStatus.NOT_OPENED]: 0,
[SigningStatus.SIGNED]: 0,
[SigningStatus.NOT_SIGNED]: 0,
[SigningStatus.REJECTED]: 0,
[SendStatus.SENT]: 0,
[SendStatus.NOT_SENT]: 0,
};
results.forEach((result) => {
const { readStatus, signingStatus, sendStatus, _count } = result;
stats[readStatus] += _count;
stats[signingStatus] += _count;
stats[sendStatus] += _count;
stats.TOTAL_RECIPIENTS += _count;
});
return stats;
};
// Legacy wrapper functions for backwards compatibility
export const getTeamRecipientsStats = async (filters: TeamRecipientsStatsFilters) => {
return getRecipientStats({
teamId: filters.teamId,
dateFrom: filters.dateFrom,
dateTo: filters.dateTo,
});
};
export const getUserRecipientsStats = async (filters: UserRecipientsStatsFilters) => {
return getRecipientStats({
userId: filters.userId,
dateFrom: filters.dateFrom,
dateTo: filters.dateTo,
});
};
@@ -0,0 +1,4 @@
export * from './types';
export * from './get-document-stats';
export * from './get-recipient-stats';
export * from './get-monthly-stats';
@@ -0,0 +1,63 @@
import type { ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
export type EntityFilter = { teamId: number } | { userId: number };
export type AnalyticsDateFilter = {
dateFrom?: Date;
dateTo?: Date;
};
export type AnalyticsFilters = EntityFilter & AnalyticsDateFilter;
export type DocumentStats = Record<Exclude<ExtendedDocumentStatus, 'INBOX'>, number>;
export type RecipientStats = {
TOTAL_RECIPIENTS: number;
[ReadStatus.OPENED]: number;
[ReadStatus.NOT_OPENED]: number;
[SigningStatus.SIGNED]: number;
[SigningStatus.NOT_SIGNED]: number;
[SigningStatus.REJECTED]: number;
[SendStatus.SENT]: number;
[SendStatus.NOT_SENT]: number;
};
export type MonthlyStats = Array<{
month: string;
count: number;
signed_count?: number;
cume_count?: number;
}>;
export type UserMonthlyDocumentGrowth = MonthlyStats;
export type TeamMonthlyDocumentGrowth = MonthlyStats;
export type TeamMonthlyActiveUsers = MonthlyStats;
// Legacy type exports for backwards compatibility
export type TeamDocumentStatsFilters = {
teamId: number;
dateFrom?: Date;
dateTo?: Date;
};
export type UserDocumentStatsFilters = {
userId: number;
dateFrom?: Date;
dateTo?: Date;
};
export type TeamRecipientsStatsFilters = {
teamId: number;
dateFrom?: Date;
dateTo?: Date;
};
export type UserRecipientsStatsFilters = {
userId: number;
dateFrom?: Date;
dateTo?: Date;
};
@@ -10,7 +10,6 @@ import {
createDocumentAuditLogData,
diffDocumentMetaChanges,
} from '@documenso/lib/utils/document-audit-logs';
import { calculateRecipientExpiry } from '@documenso/lib/utils/expiry';
import { prisma } from '@documenso/prisma';
import type { SupportedLanguageCodes } from '../../constants/i18n';
@@ -38,8 +37,6 @@ export type CreateDocumentMetaOptions = {
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
language?: SupportedLanguageCodes;
expiryAmount?: number;
expiryUnit?: string;
requestMetadata: ApiRequestMetadata;
};
@@ -62,8 +59,6 @@ export const updateDocumentMeta = async ({
uploadSignatureEnabled,
drawSignatureEnabled,
language,
expiryAmount,
expiryUnit,
requestMetadata,
}: CreateDocumentMetaOptions) => {
const { envelopeWhereInput, team } = await getEnvelopeWhereInput({
@@ -125,30 +120,9 @@ export const updateDocumentMeta = async ({
uploadSignatureEnabled,
drawSignatureEnabled,
language,
expiryAmount,
expiryUnit,
},
});
if (expiryAmount !== undefined || expiryUnit !== undefined) {
const newExpiryDate = calculateRecipientExpiry(
upsertedDocumentMeta.expiryAmount,
upsertedDocumentMeta.expiryUnit,
new Date(),
);
await tx.recipient.updateMany({
where: {
envelopeId: envelope.id,
signingStatus: { not: 'SIGNED' },
role: { not: 'CC' },
},
data: {
expired: newExpiryDate,
},
});
}
const changes = diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta);
// Create audit logs only for document type envelopes.
@@ -1,292 +0,0 @@
import type { DocumentVisibility, TemplateMeta } from '@prisma/client';
import {
DocumentSource,
FolderType,
RecipientRole,
SendStatus,
SigningStatus,
WebhookTriggerEvents,
} from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import type { TCreateDocumentTemporaryRequest } from '@documenso/trpc/server/document-router/create-document-temporary.types';
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
import type { TDocumentFormValues } from '../../types/document-form-values';
import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { extractDerivedDocumentMeta } from '../../utils/document';
import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../utils/document-auth';
import { determineDocumentVisibility } from '../../utils/document-visibility';
import { calculateRecipientExpiry } from '../../utils/expiry';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getMemberRoles } from '../team/get-member-roles';
import { getTeamSettings } from '../team/get-team-settings';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type CreateDocumentOptions = {
userId: number;
teamId: number;
documentDataId: string;
normalizePdf?: boolean;
data: {
title: string;
externalId?: string;
visibility?: DocumentVisibility;
globalAccessAuth?: TDocumentAccessAuthTypes[];
globalActionAuth?: TDocumentActionAuthTypes[];
formValues?: TDocumentFormValues;
recipients: TCreateDocumentTemporaryRequest['recipients'];
folderId?: string;
expiryAmount?: number;
expiryUnit?: string;
};
meta?: Partial<Omit<TemplateMeta, 'id' | 'templateId'>>;
requestMetadata: ApiRequestMetadata;
};
export const createDocumentV2 = async ({
userId,
teamId,
documentDataId,
normalizePdf,
data,
meta,
requestMetadata,
}: CreateDocumentOptions) => {
const { title, formValues, folderId } = data;
const team = await prisma.team.findFirst({
where: buildTeamWhereQuery({ teamId, userId }),
include: {
organisation: {
select: {
organisationClaim: true,
},
},
},
});
if (!team) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Team not found',
});
}
if (folderId) {
const folder = await prisma.folder.findUnique({
where: {
id: folderId,
type: FolderType.DOCUMENT,
team: buildTeamWhereQuery({ teamId, userId }),
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
}
const settings = await getTeamSettings({
userId,
teamId,
});
if (normalizePdf) {
const documentData = await prisma.documentData.findFirst({
where: {
id: documentDataId,
},
});
if (documentData) {
const buffer = await getFileServerSide(documentData);
const normalizedPdf = await makeNormalizedPdf(Buffer.from(buffer));
const newDocumentData = await putPdfFileServerSide({
name: title.endsWith('.pdf') ? title : `${title}.pdf`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(normalizedPdf),
});
// eslint-disable-next-line require-atomic-updates
documentDataId = newDocumentData.id;
}
}
const authOptions = createDocumentAuthOptions({
globalAccessAuth: data?.globalAccessAuth || [],
globalActionAuth: data?.globalActionAuth || [],
});
const recipientsHaveActionAuth = data.recipients?.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
// Check if user has permission to set the global action auth.
if (
(authOptions.globalActionAuth.length > 0 || recipientsHaveActionAuth) &&
!team.organisation.organisationClaim.flags.cfr21
) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
const { teamRole } = await getMemberRoles({
teamId,
reference: {
type: 'User',
id: userId,
},
});
const visibility = determineDocumentVisibility(settings.documentVisibility, teamRole);
const emailId = meta?.emailId;
// Validate that the email ID belongs to the organisation.
if (emailId) {
const email = await prisma.organisationEmail.findFirst({
where: {
id: emailId,
organisationId: team.organisationId,
},
});
if (!email) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Email not found',
});
}
}
return await prisma.$transaction(async (tx) => {
const document = await tx.document.create({
data: {
title,
qrToken: prefixedId('qr'),
externalId: data.externalId,
documentDataId,
userId,
teamId,
authOptions,
visibility,
folderId,
formValues,
source: DocumentSource.DOCUMENT,
documentMeta: {
create: extractDerivedDocumentMeta(settings, {
...meta,
expiryAmount: data.expiryAmount,
expiryUnit: data.expiryUnit,
}),
},
},
});
await Promise.all(
(data.recipients || []).map(async (recipient) => {
const recipientAuthOptions = createRecipientAuthOptions({
accessAuth: recipient.accessAuth ?? [],
actionAuth: recipient.actionAuth ?? [],
});
const expiryDate = calculateRecipientExpiry(
data.expiryAmount ?? null,
data.expiryUnit ?? null,
new Date(), // Calculate from current time
);
await tx.recipient.create({
data: {
documentId: document.id,
name: recipient.name,
email: recipient.email,
role: recipient.role,
signingOrder: recipient.signingOrder,
token: nanoid(),
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus:
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
authOptions: recipientAuthOptions,
expired: expiryDate,
fields: {
createMany: {
data: (recipient.fields || []).map((field) => ({
documentId: document.id,
type: field.type,
page: field.pageNumber,
positionX: field.pageX,
positionY: field.pageY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta,
})),
},
},
},
});
}),
);
// Todo: Is it necessary to create a full audit logs with all fields and recipients audit logs?
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
documentId: document.id,
metadata: requestMetadata,
data: {
title,
source: {
type: DocumentSource.DOCUMENT,
},
},
}),
});
const createdDocument = await tx.document.findFirst({
where: {
id: document.id,
},
include: {
documentData: true,
documentMeta: true,
recipients: true,
fields: true,
folder: true,
},
});
if (!createdDocument) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CREATED,
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(createdDocument)),
userId,
teamId,
});
return createdDocument;
});
};
@@ -1,177 +0,0 @@
import { DocumentSource, WebhookTriggerEvents } from '@prisma/client';
import type { DocumentVisibility } from '@prisma/client';
import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { prefixedId } from '../../universal/id';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { extractDerivedDocumentMeta } from '../../utils/document';
import { determineDocumentVisibility } from '../../utils/document-visibility';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getTeamById } from '../team/get-team';
import { getTeamSettings } from '../team/get-team-settings';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type CreateDocumentOptions = {
title: string;
externalId?: string | null;
userId: number;
teamId: number;
documentDataId: string;
formValues?: Record<string, string | number | boolean>;
normalizePdf?: boolean;
timezone?: string;
userTimezone?: string;
requestMetadata: ApiRequestMetadata;
folderId?: string;
expiryAmount?: number;
expiryUnit?: string;
};
export const createDocument = async ({
userId,
title,
externalId,
documentDataId,
teamId,
normalizePdf,
formValues,
requestMetadata,
timezone,
userTimezone,
folderId,
expiryAmount,
expiryUnit,
}: CreateDocumentOptions) => {
const team = await getTeamById({ userId, teamId });
const settings = await getTeamSettings({
userId,
teamId,
});
let folderVisibility: DocumentVisibility | undefined;
if (folderId) {
const folder = await prisma.folder.findFirst({
where: {
id: folderId,
team: buildTeamWhereQuery({
teamId,
userId,
}),
},
select: {
visibility: true,
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
folderVisibility = folder.visibility;
}
if (normalizePdf) {
const documentData = await prisma.documentData.findFirst({
where: {
id: documentDataId,
},
});
if (documentData) {
const buffer = await getFileServerSide(documentData);
const normalizedPdf = await makeNormalizedPdf(Buffer.from(buffer));
const newDocumentData = await putPdfFileServerSide({
name: title.endsWith('.pdf') ? title : `${title}.pdf`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(normalizedPdf),
});
// eslint-disable-next-line require-atomic-updates
documentDataId = newDocumentData.id;
}
}
// userTimezone is last because it's always passed in regardless of the organisation/team settings
// for uploads from the frontend
const timezoneToUse = timezone || settings.documentTimezone || userTimezone;
return await prisma.$transaction(async (tx) => {
const document = await tx.document.create({
data: {
title,
qrToken: prefixedId('qr'),
externalId,
documentDataId,
userId,
teamId,
folderId,
visibility:
folderVisibility ??
determineDocumentVisibility(settings.documentVisibility, team.currentTeamRole),
formValues,
source: DocumentSource.DOCUMENT,
documentMeta: {
create: extractDerivedDocumentMeta(settings, {
timezone: timezoneToUse,
expiryAmount,
expiryUnit,
}),
},
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
documentId: document.id,
metadata: requestMetadata,
data: {
title,
source: {
type: DocumentSource.DOCUMENT,
},
},
}),
});
const createdDocument = await tx.document.findFirst({
where: {
id: document.id,
},
include: {
documentMeta: true,
recipients: true,
},
});
if (!createdDocument) {
throw new Error('Document not found');
}
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CREATED,
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(createdDocument)),
userId,
teamId,
});
return createdDocument;
});
};
@@ -26,7 +26,6 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { isDocumentCompleted } from '../../utils/document';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { calculateRecipientExpiry } from '../../utils/expiry';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
@@ -211,39 +210,6 @@ export const resendDocument = async ({
text,
});
if (envelope.documentMeta?.expiryAmount && envelope.documentMeta?.expiryUnit) {
const previousExpiryDate = recipient.expired;
const newExpiryDate = calculateRecipientExpiry(
envelope.documentMeta.expiryAmount,
envelope.documentMeta.expiryUnit,
new Date(),
);
await tx.recipient.update({
where: {
id: recipient.id,
},
data: {
expired: newExpiryDate,
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_EXPIRY_EXTENDED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
recipientId: recipient.id,
recipientName: recipient.name,
recipientEmail: recipient.email,
previousExpiryDate,
newExpiryDate,
},
}),
});
}
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
@@ -24,7 +24,6 @@ import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { isDocumentCompleted } from '../../utils/document';
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
import { calculateRecipientExpiry } from '../../utils/expiry';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@@ -178,24 +177,6 @@ export const sendDocument = async ({
});
}
if (envelope.documentMeta?.expiryAmount && envelope.documentMeta?.expiryUnit) {
const expiryDate = calculateRecipientExpiry(
envelope.documentMeta.expiryAmount,
envelope.documentMeta.expiryUnit,
new Date(), // Calculate from current time
);
await tx.recipient.updateMany({
where: {
envelopeId: envelope.id,
expired: null,
},
data: {
expired: expiryDate,
},
});
}
return await tx.envelope.update({
where: {
id: envelope.id,
@@ -25,9 +25,7 @@ import {
} from '../../types/field-meta';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { isRecipientExpired } from '../../utils/expiry';
import { validateFieldAuth } from '../document/validate-field-auth';
import { expireRecipient } from '../recipient/expire-recipient';
export type SignFieldWithTokenOptions = {
token: string;
@@ -117,11 +115,6 @@ export const signFieldWithToken = async ({
throw new Error(`Recipient ${recipient.id} has already signed`);
}
if (isRecipientExpired(recipient)) {
await expireRecipient({ recipientId: recipient.id });
throw new Error(`Signing link has expired`);
}
if (field.inserted) {
throw new Error(`Field ${fieldId} has already been inserted`);
}
@@ -1,36 +0,0 @@
import { SigningStatus } from '@prisma/client';
import { prisma } from '@documenso/prisma';
export type ExpireRecipientOptions = {
recipientId: number;
};
export const expireRecipient = async ({ recipientId }: ExpireRecipientOptions) => {
const recipient = await prisma.recipient.findFirst({
where: {
id: recipientId,
},
select: {
id: true,
signingStatus: true,
},
});
if (!recipient) {
return null;
}
if (recipient.signingStatus === SigningStatus.EXPIRED) {
return recipient;
}
return await prisma.recipient.update({
where: {
id: recipientId,
},
data: {
signingStatus: SigningStatus.EXPIRED,
},
});
};
@@ -52,7 +52,6 @@ import {
} from '../../utils/document-auth';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
import { calculateRecipientExpiry } from '../../utils/expiry';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { incrementDocumentId } from '../envelope/increment-id';
@@ -111,8 +110,6 @@ export type CreateDocumentFromTemplateOptions = {
typedSignatureEnabled?: boolean;
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
expiryAmount?: number;
expiryUnit?: string;
};
requestMetadata: ApiRequestMetadata;
};
@@ -511,16 +508,6 @@ export const createDocumentFromTemplate = async ({
data: finalRecipients.map((recipient) => {
const authOptions = ZRecipientAuthOptionsSchema.parse(recipient?.authOptions);
// Calculate expiry date based on override
// Note: Templates no longer have default expiry settings (TemplateMeta removed)
const expiryAmount = override?.expiryAmount ?? null;
const expiryUnit = override?.expiryUnit ?? null;
const recipientExpiryDate = calculateRecipientExpiry(
expiryAmount,
expiryUnit,
new Date(), // Calculate from current time
);
return {
email: recipient.email,
name: recipient.name,
@@ -536,7 +523,6 @@ export const createDocumentFromTemplate = async ({
? SigningStatus.SIGNED
: SigningStatus.NOT_SIGNED,
signingOrder: recipient.signingOrder,
expired: recipientExpiryDate,
token: recipient.token,
};
}),
-52
View File
@@ -267,11 +267,6 @@ msgstr "{prefix} hat das Dokument erstellt"
msgid "{prefix} deleted the document"
msgstr "{prefix} hat das Dokument gelöscht"
#. placeholder {0}: data.data.recipientEmail
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} extended expiry for {0}"
msgstr ""
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} moved the document to team"
msgstr "{prefix} hat das Dokument ins Team verschoben"
@@ -1710,10 +1705,6 @@ msgctxt "Recipient role progressive verb"
msgid "Assisting"
msgstr ""
#: packages/ui/primitives/date-time-picker.tsx
msgid "at"
msgstr ""
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: packages/ui/primitives/template-flow/add-template-settings.types.tsx
@@ -4085,18 +4076,9 @@ msgid "Exceeded timeout"
msgstr "Zeitüberschreitung überschritten"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
#: apps/remix/app/components/tables/inbox-table.tsx
#: apps/remix/app/components/tables/documents-table-action-button.tsx
#: apps/remix/app/components/general/stack-avatars-with-tooltip.tsx
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
msgid "Expired"
msgstr "Abgelaufen"
#. placeholder {0}: new Date(recipient.expired).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', })
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "Expired on: {0}"
msgstr ""
#. placeholder {0}: DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat( 'mm:ss', )
#: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx
msgid "Expires in {0}"
@@ -4976,11 +4958,6 @@ msgstr "Link läuft in 1 Stunde ab."
msgid "Link expires in 30 minutes."
msgstr ""
#: packages/ui/primitives/expiry-settings-picker.tsx
#: packages/ui/primitives/document-flow/add-settings.tsx
msgid "Link Expiry"
msgstr ""
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
msgid "Link template"
msgstr "Vorlage verlinken"
@@ -5002,11 +4979,6 @@ msgstr ""
msgid "Links Generated"
msgstr "Links generiert"
#. placeholder {0}: formatExpiryDate(calculatedExpiryDate)
#: packages/ui/primitives/expiry-settings-picker.tsx
msgid "Links will expire on: {0}"
msgstr ""
#. placeholder {0}: webhook.eventTriggers .map((trigger) => toFriendlyWebhookEventName(trigger)) .join(', ')
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
msgid "Listening to {0}"
@@ -5995,10 +5967,6 @@ msgstr "Persönliches Konto"
msgid "Personal Inbox"
msgstr "Persönlicher Posteingang"
#: packages/ui/primitives/date-time-picker.tsx
msgid "Pick a date"
msgstr ""
#: apps/remix/app/components/forms/editor/editor-field-checkbox-form.tsx
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
msgid "Pick a number"
@@ -6413,10 +6381,6 @@ msgstr "Empfänger"
msgid "Recipient action authentication"
msgstr "Empfängeraktion Authentifizierung"
#: packages/lib/utils/document-audit-logs.ts
msgid "Recipient expiry extended"
msgstr ""
#: packages/ui/components/document/document-email-checkboxes.tsx
msgid "Recipient removed email"
msgstr "E-Mail des entfernten Empfängers"
@@ -7131,10 +7095,6 @@ msgstr "Sitzungen wurden widerrufen"
msgid "Set a password"
msgstr "Ein Passwort festlegen"
#: packages/ui/primitives/expiry-settings-picker.tsx
msgid "Set an expiry duration for signing links (leave empty to disable)"
msgstr ""
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
msgid "Set up your document properties and recipient information"
msgstr "Richten Sie Ihre Dokumenteigenschaften und Empfängerinformationen ein"
@@ -7433,10 +7393,6 @@ msgstr "Unterzeichne für"
msgid "Signing in..."
msgstr "Anmeldung..."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "Signing Link Expired"
msgstr ""
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
msgid "Signing Links"
@@ -8311,10 +8267,6 @@ msgstr "Der Name des Unterzeichners"
msgid "The signing link has been copied to your clipboard."
msgstr "Der Signierlink wurde in die Zwischenablage kopiert."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "The signing link has expired and can no longer be used to sign the document. Please contact the document sender if you need a new signing link."
msgstr ""
#: apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx
msgid "The site banner is a message that is shown at the top of the site. It can be used to display important information to your users."
msgstr "Das Seitenbanner ist eine Nachricht, die oben auf der Seite angezeigt wird. Es kann verwendet werden, um Ihren Nutzern wichtige Informationen anzuzeigen."
@@ -8633,10 +8585,6 @@ msgstr "Diese Sitzung ist abgelaufen. Bitte versuchen Sie es erneut."
msgid "This signer has already signed the document."
msgstr "Dieser Unterzeichner hat das Dokument bereits unterschrieben."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "This signing link is no longer valid"
msgstr ""
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
msgid "This team, and any associated data excluding billing invoices will be permanently deleted."
msgstr "Dieses Team und alle zugehörigen Daten, ausgenommen Rechnungen, werden permanent gelöscht."
-52
View File
@@ -262,11 +262,6 @@ msgstr "{prefix} created the document"
msgid "{prefix} deleted the document"
msgstr "{prefix} deleted the document"
#. placeholder {0}: data.data.recipientEmail
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} extended expiry for {0}"
msgstr "{prefix} extended expiry for {0}"
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} moved the document to team"
msgstr "{prefix} moved the document to team"
@@ -1705,10 +1700,6 @@ msgctxt "Recipient role progressive verb"
msgid "Assisting"
msgstr "Assisting"
#: packages/ui/primitives/date-time-picker.tsx
msgid "at"
msgstr "at"
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: packages/ui/primitives/template-flow/add-template-settings.types.tsx
@@ -4080,18 +4071,9 @@ msgid "Exceeded timeout"
msgstr "Exceeded timeout"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
#: apps/remix/app/components/tables/inbox-table.tsx
#: apps/remix/app/components/tables/documents-table-action-button.tsx
#: apps/remix/app/components/general/stack-avatars-with-tooltip.tsx
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
msgid "Expired"
msgstr "Expired"
#. placeholder {0}: new Date(recipient.expired).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', })
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "Expired on: {0}"
msgstr "Expired on: {0}"
#. placeholder {0}: DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat( 'mm:ss', )
#: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx
msgid "Expires in {0}"
@@ -4971,11 +4953,6 @@ msgstr "Link expires in 1 hour."
msgid "Link expires in 30 minutes."
msgstr "Link expires in 30 minutes."
#: packages/ui/primitives/expiry-settings-picker.tsx
#: packages/ui/primitives/document-flow/add-settings.tsx
msgid "Link Expiry"
msgstr "Link Expiry"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
msgid "Link template"
msgstr "Link template"
@@ -4997,11 +4974,6 @@ msgstr "Linked At"
msgid "Links Generated"
msgstr "Links Generated"
#. placeholder {0}: formatExpiryDate(calculatedExpiryDate)
#: packages/ui/primitives/expiry-settings-picker.tsx
msgid "Links will expire on: {0}"
msgstr "Links will expire on: {0}"
#. placeholder {0}: webhook.eventTriggers .map((trigger) => toFriendlyWebhookEventName(trigger)) .join(', ')
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
msgid "Listening to {0}"
@@ -5990,10 +5962,6 @@ msgstr "Personal Account"
msgid "Personal Inbox"
msgstr "Personal Inbox"
#: packages/ui/primitives/date-time-picker.tsx
msgid "Pick a date"
msgstr "Pick a date"
#: apps/remix/app/components/forms/editor/editor-field-checkbox-form.tsx
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
msgid "Pick a number"
@@ -6408,10 +6376,6 @@ msgstr "Recipient"
msgid "Recipient action authentication"
msgstr "Recipient action authentication"
#: packages/lib/utils/document-audit-logs.ts
msgid "Recipient expiry extended"
msgstr "Recipient expiry extended"
#: packages/ui/components/document/document-email-checkboxes.tsx
msgid "Recipient removed email"
msgstr "Recipient removed email"
@@ -7126,10 +7090,6 @@ msgstr "Sessions have been revoked"
msgid "Set a password"
msgstr "Set a password"
#: packages/ui/primitives/expiry-settings-picker.tsx
msgid "Set an expiry duration for signing links (leave empty to disable)"
msgstr "Set an expiry duration for signing links (leave empty to disable)"
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
msgid "Set up your document properties and recipient information"
msgstr "Set up your document properties and recipient information"
@@ -7428,10 +7388,6 @@ msgstr "Signing for"
msgid "Signing in..."
msgstr "Signing in..."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "Signing Link Expired"
msgstr "Signing Link Expired"
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
msgid "Signing Links"
@@ -8316,10 +8272,6 @@ msgstr "The signer's name"
msgid "The signing link has been copied to your clipboard."
msgstr "The signing link has been copied to your clipboard."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "The signing link has expired and can no longer be used to sign the document. Please contact the document sender if you need a new signing link."
msgstr "The signing link has expired and can no longer be used to sign the document. Please contact the document sender if you need a new signing link."
#: apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx
msgid "The site banner is a message that is shown at the top of the site. It can be used to display important information to your users."
msgstr "The site banner is a message that is shown at the top of the site. It can be used to display important information to your users."
@@ -8648,10 +8600,6 @@ msgstr "This session has expired. Please try again."
msgid "This signer has already signed the document."
msgstr "This signer has already signed the document."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "This signing link is no longer valid"
msgstr "This signing link is no longer valid"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
msgid "This team, and any associated data excluding billing invoices will be permanently deleted."
msgstr "This team, and any associated data excluding billing invoices will be permanently deleted."
-52
View File
@@ -267,11 +267,6 @@ msgstr "{prefix} creó el documento"
msgid "{prefix} deleted the document"
msgstr "{prefix} eliminó el documento"
#. placeholder {0}: data.data.recipientEmail
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} extended expiry for {0}"
msgstr ""
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} moved the document to team"
msgstr "{prefix} movió el documento al equipo"
@@ -1710,10 +1705,6 @@ msgctxt "Recipient role progressive verb"
msgid "Assisting"
msgstr ""
#: packages/ui/primitives/date-time-picker.tsx
msgid "at"
msgstr ""
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: packages/ui/primitives/template-flow/add-template-settings.types.tsx
@@ -4085,18 +4076,9 @@ msgid "Exceeded timeout"
msgstr "Tiempo de espera excedido"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
#: apps/remix/app/components/tables/inbox-table.tsx
#: apps/remix/app/components/tables/documents-table-action-button.tsx
#: apps/remix/app/components/general/stack-avatars-with-tooltip.tsx
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
msgid "Expired"
msgstr "Expirado"
#. placeholder {0}: new Date(recipient.expired).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', })
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "Expired on: {0}"
msgstr ""
#. placeholder {0}: DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat( 'mm:ss', )
#: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx
msgid "Expires in {0}"
@@ -4976,11 +4958,6 @@ msgstr "El enlace expira en 1 hora."
msgid "Link expires in 30 minutes."
msgstr ""
#: packages/ui/primitives/expiry-settings-picker.tsx
#: packages/ui/primitives/document-flow/add-settings.tsx
msgid "Link Expiry"
msgstr ""
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
msgid "Link template"
msgstr "Enlace de plantilla"
@@ -5002,11 +4979,6 @@ msgstr ""
msgid "Links Generated"
msgstr "Enlaces generados"
#. placeholder {0}: formatExpiryDate(calculatedExpiryDate)
#: packages/ui/primitives/expiry-settings-picker.tsx
msgid "Links will expire on: {0}"
msgstr ""
#. placeholder {0}: webhook.eventTriggers .map((trigger) => toFriendlyWebhookEventName(trigger)) .join(', ')
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
msgid "Listening to {0}"
@@ -5995,10 +5967,6 @@ msgstr "Cuenta personal"
msgid "Personal Inbox"
msgstr "Bandeja de entrada personal"
#: packages/ui/primitives/date-time-picker.tsx
msgid "Pick a date"
msgstr ""
#: apps/remix/app/components/forms/editor/editor-field-checkbox-form.tsx
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
msgid "Pick a number"
@@ -6413,10 +6381,6 @@ msgstr "Destinatario"
msgid "Recipient action authentication"
msgstr "Autenticación de acción de destinatario"
#: packages/lib/utils/document-audit-logs.ts
msgid "Recipient expiry extended"
msgstr ""
#: packages/ui/components/document/document-email-checkboxes.tsx
msgid "Recipient removed email"
msgstr "Correo electrónico de destinatario eliminado"
@@ -7131,10 +7095,6 @@ msgstr "Las sesiones han sido revocadas"
msgid "Set a password"
msgstr "Establecer una contraseña"
#: packages/ui/primitives/expiry-settings-picker.tsx
msgid "Set an expiry duration for signing links (leave empty to disable)"
msgstr ""
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
msgid "Set up your document properties and recipient information"
msgstr "Configura las propiedades de tu documento y la información del destinatario"
@@ -7433,10 +7393,6 @@ msgstr "Firmando para"
msgid "Signing in..."
msgstr "Iniciando sesión..."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "Signing Link Expired"
msgstr ""
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
msgid "Signing Links"
@@ -8311,10 +8267,6 @@ msgstr "El nombre del firmante"
msgid "The signing link has been copied to your clipboard."
msgstr "El enlace de firma ha sido copiado a tu portapapeles."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "The signing link has expired and can no longer be used to sign the document. Please contact the document sender if you need a new signing link."
msgstr ""
#: apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx
msgid "The site banner is a message that is shown at the top of the site. It can be used to display important information to your users."
msgstr "El banner del sitio es un mensaje que se muestra en la parte superior del sitio. Se puede usar para mostrar información importante a tus usuarios."
@@ -8635,10 +8587,6 @@ msgstr "Esta sesión ha expirado. Por favor, inténtalo de nuevo."
msgid "This signer has already signed the document."
msgstr "Este firmante ya ha firmado el documento."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "This signing link is no longer valid"
msgstr ""
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
msgid "This team, and any associated data excluding billing invoices will be permanently deleted."
msgstr "Este equipo, y cualquier dato asociado, excluyendo las facturas de facturación, serán eliminados permanentemente."
-52
View File
@@ -267,11 +267,6 @@ msgstr "{prefix} a créé le document"
msgid "{prefix} deleted the document"
msgstr "{prefix} a supprimé le document"
#. placeholder {0}: data.data.recipientEmail
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} extended expiry for {0}"
msgstr ""
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} moved the document to team"
msgstr "{prefix} a déplacé le document vers l'équipe"
@@ -1710,10 +1705,6 @@ msgctxt "Recipient role progressive verb"
msgid "Assisting"
msgstr ""
#: packages/ui/primitives/date-time-picker.tsx
msgid "at"
msgstr ""
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: packages/ui/primitives/template-flow/add-template-settings.types.tsx
@@ -4085,18 +4076,9 @@ msgid "Exceeded timeout"
msgstr "Délai dépassé"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
#: apps/remix/app/components/tables/inbox-table.tsx
#: apps/remix/app/components/tables/documents-table-action-button.tsx
#: apps/remix/app/components/general/stack-avatars-with-tooltip.tsx
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
msgid "Expired"
msgstr "Expiré"
#. placeholder {0}: new Date(recipient.expired).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', })
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "Expired on: {0}"
msgstr ""
#. placeholder {0}: DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat( 'mm:ss', )
#: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx
msgid "Expires in {0}"
@@ -4976,11 +4958,6 @@ msgstr "Le lien expire dans 1 heure."
msgid "Link expires in 30 minutes."
msgstr ""
#: packages/ui/primitives/expiry-settings-picker.tsx
#: packages/ui/primitives/document-flow/add-settings.tsx
msgid "Link Expiry"
msgstr ""
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
msgid "Link template"
msgstr "Modèle de lien"
@@ -5002,11 +4979,6 @@ msgstr ""
msgid "Links Generated"
msgstr "Liens générés"
#. placeholder {0}: formatExpiryDate(calculatedExpiryDate)
#: packages/ui/primitives/expiry-settings-picker.tsx
msgid "Links will expire on: {0}"
msgstr ""
#. placeholder {0}: webhook.eventTriggers .map((trigger) => toFriendlyWebhookEventName(trigger)) .join(', ')
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
msgid "Listening to {0}"
@@ -5995,10 +5967,6 @@ msgstr "Compte personnel"
msgid "Personal Inbox"
msgstr "Boîte de réception personnelle"
#: packages/ui/primitives/date-time-picker.tsx
msgid "Pick a date"
msgstr ""
#: apps/remix/app/components/forms/editor/editor-field-checkbox-form.tsx
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
msgid "Pick a number"
@@ -6413,10 +6381,6 @@ msgstr "Destinataire"
msgid "Recipient action authentication"
msgstr "Authentification d'action de destinataire"
#: packages/lib/utils/document-audit-logs.ts
msgid "Recipient expiry extended"
msgstr ""
#: packages/ui/components/document/document-email-checkboxes.tsx
msgid "Recipient removed email"
msgstr "E-mail de destinataire supprimé"
@@ -7131,10 +7095,6 @@ msgstr "Les sessions ont été révoquées"
msgid "Set a password"
msgstr "Définir un mot de passe"
#: packages/ui/primitives/expiry-settings-picker.tsx
msgid "Set an expiry duration for signing links (leave empty to disable)"
msgstr ""
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
msgid "Set up your document properties and recipient information"
msgstr "Configurez les propriétés de votre document et les informations du destinataire"
@@ -7433,10 +7393,6 @@ msgstr "Signé pour"
msgid "Signing in..."
msgstr "Connexion en cours..."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "Signing Link Expired"
msgstr ""
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
msgid "Signing Links"
@@ -8311,10 +8267,6 @@ msgstr "Le nom du signataire"
msgid "The signing link has been copied to your clipboard."
msgstr "Le lien de signature a été copié dans votre presse-papiers."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "The signing link has expired and can no longer be used to sign the document. Please contact the document sender if you need a new signing link."
msgstr ""
#: apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx
msgid "The site banner is a message that is shown at the top of the site. It can be used to display important information to your users."
msgstr "La bannière du site est un message affiché en haut du site. Elle peut être utilisée pour afficher des informations importantes à vos utilisateurs."
@@ -8633,10 +8585,6 @@ msgstr "Cette session a expiré. Veuillez réessayer."
msgid "This signer has already signed the document."
msgstr "Ce signataire a déjà signé le document."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "This signing link is no longer valid"
msgstr ""
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
msgid "This team, and any associated data excluding billing invoices will be permanently deleted."
msgstr "Cette équipe, et toutes les données associées à l'exception des factures de facturation, seront définitivement supprimées."
-52
View File
@@ -267,11 +267,6 @@ msgstr "{prefix} ha creato il documento"
msgid "{prefix} deleted the document"
msgstr "{prefix} ha eliminato il documento"
#. placeholder {0}: data.data.recipientEmail
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} extended expiry for {0}"
msgstr ""
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} moved the document to team"
msgstr "{prefix} ha spostato il documento al team"
@@ -1710,10 +1705,6 @@ msgctxt "Recipient role progressive verb"
msgid "Assisting"
msgstr ""
#: packages/ui/primitives/date-time-picker.tsx
msgid "at"
msgstr ""
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: packages/ui/primitives/template-flow/add-template-settings.types.tsx
@@ -4085,18 +4076,9 @@ msgid "Exceeded timeout"
msgstr "Tempo scaduto"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
#: apps/remix/app/components/tables/inbox-table.tsx
#: apps/remix/app/components/tables/documents-table-action-button.tsx
#: apps/remix/app/components/general/stack-avatars-with-tooltip.tsx
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
msgid "Expired"
msgstr "Scaduto"
#. placeholder {0}: new Date(recipient.expired).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', })
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "Expired on: {0}"
msgstr ""
#. placeholder {0}: DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat( 'mm:ss', )
#: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx
msgid "Expires in {0}"
@@ -4976,11 +4958,6 @@ msgstr "Il link scade tra 1 ora."
msgid "Link expires in 30 minutes."
msgstr ""
#: packages/ui/primitives/expiry-settings-picker.tsx
#: packages/ui/primitives/document-flow/add-settings.tsx
msgid "Link Expiry"
msgstr ""
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
msgid "Link template"
msgstr "Collega modello"
@@ -5002,11 +4979,6 @@ msgstr ""
msgid "Links Generated"
msgstr "Link Generati"
#. placeholder {0}: formatExpiryDate(calculatedExpiryDate)
#: packages/ui/primitives/expiry-settings-picker.tsx
msgid "Links will expire on: {0}"
msgstr ""
#. placeholder {0}: webhook.eventTriggers .map((trigger) => toFriendlyWebhookEventName(trigger)) .join(', ')
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
msgid "Listening to {0}"
@@ -5995,10 +5967,6 @@ msgstr "Account personale"
msgid "Personal Inbox"
msgstr "Posta in arrivo personale"
#: packages/ui/primitives/date-time-picker.tsx
msgid "Pick a date"
msgstr ""
#: apps/remix/app/components/forms/editor/editor-field-checkbox-form.tsx
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
msgid "Pick a number"
@@ -6413,10 +6381,6 @@ msgstr "Destinatario"
msgid "Recipient action authentication"
msgstr "Autenticazione azione destinatario"
#: packages/lib/utils/document-audit-logs.ts
msgid "Recipient expiry extended"
msgstr ""
#: packages/ui/components/document/document-email-checkboxes.tsx
msgid "Recipient removed email"
msgstr "Email destinatario rimosso"
@@ -7131,10 +7095,6 @@ msgstr "Le sessioni sono state revocate"
msgid "Set a password"
msgstr "Imposta una password"
#: packages/ui/primitives/expiry-settings-picker.tsx
msgid "Set an expiry duration for signing links (leave empty to disable)"
msgstr ""
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
msgid "Set up your document properties and recipient information"
msgstr "Configura le proprietà del documento e le informazioni sui destinatari"
@@ -7433,10 +7393,6 @@ msgstr "Firma per"
msgid "Signing in..."
msgstr "Accesso in corso..."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "Signing Link Expired"
msgstr ""
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
msgid "Signing Links"
@@ -8319,10 +8275,6 @@ msgstr "Il nome del firmatario"
msgid "The signing link has been copied to your clipboard."
msgstr "Il link di firma è stato copiato negli appunti."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "The signing link has expired and can no longer be used to sign the document. Please contact the document sender if you need a new signing link."
msgstr ""
#: apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx
msgid "The site banner is a message that is shown at the top of the site. It can be used to display important information to your users."
msgstr "Il banner del sito è un messaggio che viene mostrato in cima al sito. Può essere utilizzato per visualizzare informazioni importanti ai tuoi utenti."
@@ -8649,10 +8601,6 @@ msgstr "Questa sessione è scaduta. Per favore prova di nuovo."
msgid "This signer has already signed the document."
msgstr "Questo firmatario ha già firmato il documento."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "This signing link is no longer valid"
msgstr ""
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
msgid "This team, and any associated data excluding billing invoices will be permanently deleted."
msgstr "Questo team e tutti i dati associati, escluse le fatture di fatturazione, verranno eliminati definitivamente."
-52
View File
@@ -267,11 +267,6 @@ msgstr "Użytkownik {prefix} utworzył dokument"
msgid "{prefix} deleted the document"
msgstr "Użytkownik {prefix} usunął dokument"
#. placeholder {0}: data.data.recipientEmail
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} extended expiry for {0}"
msgstr ""
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} moved the document to team"
msgstr "Użytkownik {prefix} przeniósł dokument do zespołu"
@@ -1710,10 +1705,6 @@ msgctxt "Recipient role progressive verb"
msgid "Assisting"
msgstr ""
#: packages/ui/primitives/date-time-picker.tsx
msgid "at"
msgstr ""
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: packages/ui/primitives/template-flow/add-template-settings.types.tsx
@@ -4085,18 +4076,9 @@ msgid "Exceeded timeout"
msgstr "Przekroczono limit czasu"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
#: apps/remix/app/components/tables/inbox-table.tsx
#: apps/remix/app/components/tables/documents-table-action-button.tsx
#: apps/remix/app/components/general/stack-avatars-with-tooltip.tsx
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
msgid "Expired"
msgstr "Wygasł"
#. placeholder {0}: new Date(recipient.expired).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', })
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "Expired on: {0}"
msgstr ""
#. placeholder {0}: DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat( 'mm:ss', )
#: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx
msgid "Expires in {0}"
@@ -4976,11 +4958,6 @@ msgstr "Link wygaśnie za 1 godzinę."
msgid "Link expires in 30 minutes."
msgstr ""
#: packages/ui/primitives/expiry-settings-picker.tsx
#: packages/ui/primitives/document-flow/add-settings.tsx
msgid "Link Expiry"
msgstr ""
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
msgid "Link template"
msgstr "Szablon linku"
@@ -5002,11 +4979,6 @@ msgstr ""
msgid "Links Generated"
msgstr "Wygenerowane linki"
#. placeholder {0}: formatExpiryDate(calculatedExpiryDate)
#: packages/ui/primitives/expiry-settings-picker.tsx
msgid "Links will expire on: {0}"
msgstr ""
#. placeholder {0}: webhook.eventTriggers .map((trigger) => toFriendlyWebhookEventName(trigger)) .join(', ')
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
msgid "Listening to {0}"
@@ -5995,10 +5967,6 @@ msgstr "Konto osobiste"
msgid "Personal Inbox"
msgstr "Skrzynka odbiorcza osobista"
#: packages/ui/primitives/date-time-picker.tsx
msgid "Pick a date"
msgstr ""
#: apps/remix/app/components/forms/editor/editor-field-checkbox-form.tsx
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
msgid "Pick a number"
@@ -6413,10 +6381,6 @@ msgstr "Odbiorca"
msgid "Recipient action authentication"
msgstr "Uwierzytelnianie odbiorcy"
#: packages/lib/utils/document-audit-logs.ts
msgid "Recipient expiry extended"
msgstr ""
#: packages/ui/components/document/document-email-checkboxes.tsx
msgid "Recipient removed email"
msgstr "Wiadomość o usuniętym odbiorcy"
@@ -7131,10 +7095,6 @@ msgstr "Sesje zostały odwołane"
msgid "Set a password"
msgstr "Ustaw hasło"
#: packages/ui/primitives/expiry-settings-picker.tsx
msgid "Set an expiry duration for signing links (leave empty to disable)"
msgstr ""
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
msgid "Set up your document properties and recipient information"
msgstr "Skonfiguruj właściwości dokumentu i informacje o odbiorcach"
@@ -7433,10 +7393,6 @@ msgstr "Podpis w imieniu"
msgid "Signing in..."
msgstr "Logowanie..."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "Signing Link Expired"
msgstr ""
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
msgid "Signing Links"
@@ -8311,10 +8267,6 @@ msgstr "Nazwa podpisującego"
msgid "The signing link has been copied to your clipboard."
msgstr "Link do podpisu został skopiowany do schowka."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "The signing link has expired and can no longer be used to sign the document. Please contact the document sender if you need a new signing link."
msgstr ""
#: apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx
msgid "The site banner is a message that is shown at the top of the site. It can be used to display important information to your users."
msgstr "Baner strony to wiadomość, która jest wyświetlana u góry strony. Może być używany do wyświetlania ważnych informacji użytkownikom."
@@ -8633,10 +8585,6 @@ msgstr "Ta sesja wygasła. Proszę spróbować ponownie."
msgid "This signer has already signed the document."
msgstr "Ten sygnatariusz już podpisał dokument."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "This signing link is no longer valid"
msgstr ""
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
msgid "This team, and any associated data excluding billing invoices will be permanently deleted."
msgstr "Ten zespół oraz wszelkie powiązane dane, z wyjątkiem faktur, zostaną trwale usunięte."
-16
View File
@@ -40,7 +40,6 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
'DOCUMENT_TITLE_UPDATED', // When the document title is updated.
'DOCUMENT_EXTERNAL_ID_UPDATED', // When the document external ID is updated.
'DOCUMENT_MOVED_TO_TEAM', // When the document is moved to a team.
'DOCUMENT_RECIPIENT_EXPIRY_EXTENDED', // When a recipient's expiry is extended via resend.
// ACCESS AUTH 2FA events.
'DOCUMENT_ACCESS_AUTH_2FA_REQUESTED', // When ACCESS AUTH 2FA is requested.
@@ -640,20 +639,6 @@ export const ZDocumentAuditLogEventDocumentMovedToTeamSchema = z.object({
}),
});
/**
* Event: Recipient expiry extended.
*/
export const ZDocumentAuditLogEventRecipientExpiryExtendedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_EXPIRY_EXTENDED),
data: z.object({
recipientId: z.number(),
recipientName: z.string().optional(),
recipientEmail: z.string(),
previousExpiryDate: z.date().nullable(),
newExpiryDate: z.date().nullable(),
}),
});
export const ZDocumentAuditLogBaseSchema = z.object({
id: z.string(),
createdAt: z.date(),
@@ -695,7 +680,6 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
ZDocumentAuditLogEventRecipientAddedSchema,
ZDocumentAuditLogEventRecipientUpdatedSchema,
ZDocumentAuditLogEventRecipientRemovedSchema,
ZDocumentAuditLogEventRecipientExpiryExtendedSchema,
]),
);
+1 -13
View File
@@ -107,16 +107,6 @@ export const ZDocumentMetaUploadSignatureEnabledSchema = z
.boolean()
.describe('Whether to allow recipients to sign using an uploaded signature.');
export const ZDocumentExpiryAmountSchema = z
.number()
.int()
.min(1)
.describe('The amount for expiry duration (e.g., 3 for "3 days").');
export const ZDocumentExpiryUnitSchema = z
.enum(['minutes', 'hours', 'days', 'weeks', 'months'])
.describe('The unit for expiry duration (e.g., "days" for "3 days").');
/**
* Note: Any updates to this will cause public API changes. You will need to update
* all corresponding areas where this is used (some places that use this needs to pass
@@ -137,9 +127,7 @@ export const ZDocumentMetaCreateSchema = z.object({
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
emailId: z.string().nullish(),
emailReplyTo: z.string().email().nullish(),
emailSettings: ZDocumentEmailSettingsSchema.nullish(),
expiryAmount: ZDocumentExpiryAmountSchema.optional(),
expiryUnit: ZDocumentExpiryUnitSchema.optional(),
emailSettings: ZDocumentEmailSettingsSchema.optional(),
});
export type TDocumentMetaCreate = z.infer<typeof ZDocumentMetaCreateSchema>;
-2
View File
@@ -69,8 +69,6 @@ export const ZDocumentSchema = LegacyDocumentSchema.pick({
emailSettings: true,
emailId: true,
emailReplyTo: true,
expiryAmount: true,
expiryUnit: true,
}).extend({
password: z.string().nullable().default(null),
documentId: z.number().default(-1).optional(),
@@ -515,10 +515,6 @@ export const formatDocumentAuditLogAction = (
context: `Audit log format`,
}),
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_EXPIRY_EXTENDED }, (data) => ({
anonymous: msg`Recipient expiry extended`,
identified: msg`${prefix} extended expiry for ${data.data.recipientEmail}`,
}))
.exhaustive();
return {
-24
View File
@@ -20,26 +20,6 @@ export const isDocumentCompleted = (document: Pick<Envelope, 'status'> | Documen
return status === DocumentStatus.COMPLETED || status === DocumentStatus.REJECTED;
};
const getExpiryAmount = (meta: Partial<DocumentMeta> | undefined | null): number | null => {
if (!meta) return null;
if ('expiryAmount' in meta && meta.expiryAmount !== undefined) {
return meta.expiryAmount;
}
return null;
};
const getExpiryUnit = (meta: Partial<DocumentMeta> | undefined | null): string | null => {
if (!meta) return null;
if ('expiryUnit' in meta && meta.expiryUnit !== undefined) {
return meta.expiryUnit;
}
return null;
};
/**
* Extracts the derived document meta which should be used when creating a document
* from scratch, or from a template.
@@ -82,10 +62,6 @@ export const extractDerivedDocumentMeta = (
emailReplyTo: meta.emailReplyTo ?? settings.emailReplyTo,
emailSettings:
meta.emailSettings || settings.emailDocumentSettings || DEFAULT_DOCUMENT_EMAIL_SETTINGS,
// Expiry settings.
expiryAmount: getExpiryAmount(meta),
expiryUnit: getExpiryUnit(meta),
} satisfies Omit<DocumentMeta, 'id'>;
};
-72
View File
@@ -1,72 +0,0 @@
import type { Recipient } from '@prisma/client';
import { DateTime } from 'luxon';
export interface DurationValue {
amount: number;
unit: string;
}
export const calculateRecipientExpiry = (
documentExpiryAmount?: number | null,
documentExpiryUnit?: string | null,
fromDate: Date = new Date(),
): Date | null => {
if (!documentExpiryAmount || !documentExpiryUnit) {
return null;
}
switch (documentExpiryUnit) {
case 'minutes':
return DateTime.fromJSDate(fromDate).plus({ minutes: documentExpiryAmount }).toJSDate();
case 'hours':
return DateTime.fromJSDate(fromDate).plus({ hours: documentExpiryAmount }).toJSDate();
case 'days':
return DateTime.fromJSDate(fromDate).plus({ days: documentExpiryAmount }).toJSDate();
case 'weeks':
return DateTime.fromJSDate(fromDate).plus({ weeks: documentExpiryAmount }).toJSDate();
case 'months':
return DateTime.fromJSDate(fromDate).plus({ months: documentExpiryAmount }).toJSDate();
default:
return DateTime.fromJSDate(fromDate).plus({ days: documentExpiryAmount }).toJSDate();
}
};
export const isRecipientExpired = (recipient: Recipient): boolean => {
if (!recipient.expired) {
return false;
}
return DateTime.now() > DateTime.fromJSDate(recipient.expired);
};
export const isValidExpirySettings = (
expiryAmount?: number | null,
expiryUnit?: string | null,
): boolean => {
if (!expiryAmount || !expiryUnit) {
return true;
}
return expiryAmount > 0 && ['minutes', 'hours', 'days', 'weeks', 'months'].includes(expiryUnit);
};
export const calculateExpiryDate = (duration: DurationValue, fromDate: Date = new Date()): Date => {
switch (duration.unit) {
case 'minutes':
return DateTime.fromJSDate(fromDate).plus({ minutes: duration.amount }).toJSDate();
case 'hours':
return DateTime.fromJSDate(fromDate).plus({ hours: duration.amount }).toJSDate();
case 'days':
return DateTime.fromJSDate(fromDate).plus({ days: duration.amount }).toJSDate();
case 'weeks':
return DateTime.fromJSDate(fromDate).plus({ weeks: duration.amount }).toJSDate();
case 'months':
return DateTime.fromJSDate(fromDate).plus({ months: duration.amount }).toJSDate();
default:
return DateTime.fromJSDate(fromDate).plus({ days: duration.amount }).toJSDate();
}
};
export const formatExpiryDate = (date: Date): string => {
return DateTime.fromJSDate(date).toFormat('MMM dd, yyyy HH:mm');
};
@@ -1,6 +0,0 @@
-- AlterEnum
ALTER TYPE "SigningStatus" ADD VALUE 'EXPIRED';
-- AlterTable
ALTER TABLE "DocumentMeta" ADD COLUMN "expiryAmount" INTEGER,
ADD COLUMN "expiryUnit" TEXT;
-4
View File
@@ -505,9 +505,6 @@ model DocumentMeta {
emailReplyTo String?
emailId String?
expiryAmount Int?
expiryUnit String?
envelope Envelope?
}
@@ -525,7 +522,6 @@ enum SigningStatus {
NOT_SIGNED
SIGNED
REJECTED
EXPIRED
}
enum RecipientRole {
@@ -6,7 +6,6 @@ import { createDocumentData } from '@documenso/lib/server-only/document-data/cre
import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope';
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { isValidExpirySettings } from '@documenso/lib/utils/expiry';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
@@ -38,17 +37,8 @@ export const createDocumentTemporaryRoute = authenticatedProcedure
recipients,
meta,
folderId,
expiryAmount,
expiryUnit,
} = input;
// Validate expiry settings
if ((expiryAmount || expiryUnit) && !isValidExpirySettings(expiryAmount, expiryUnit)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Invalid expiry settings. Please check your expiry configuration.',
});
}
const { remaining } = await getServerLimits({ userId: user.id, teamId });
if (remaining.documents <= 0) {
@@ -96,12 +86,7 @@ export const createDocumentTemporaryRoute = authenticatedProcedure
},
],
},
meta: {
...meta,
emailSettings: meta?.emailSettings ?? undefined,
expiryAmount,
expiryUnit,
},
meta,
requestMetadata: ctx.metadata,
});
@@ -19,8 +19,6 @@ import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { ZCreateRecipientSchema } from '../recipient-router/schema';
import type { TrpcRouteMeta } from '../trpc';
import {
ZDocumentExpiryAmountSchema,
ZDocumentExpiryUnitSchema,
ZDocumentExternalIdSchema,
ZDocumentTitleSchema,
ZDocumentVisibilitySchema,
@@ -53,8 +51,6 @@ export const ZCreateDocumentTemporaryRequestSchema = z.object({
'The ID of the folder to create the document in. If not provided, the document will be created in the root folder.',
)
.optional(),
expiryAmount: ZDocumentExpiryAmountSchema.optional(),
expiryUnit: ZDocumentExpiryUnitSchema.optional(),
recipients: z
.array(
ZCreateRecipientSchema.extend({
@@ -4,7 +4,6 @@ import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { isValidExpirySettings } from '@documenso/lib/utils/expiry';
import { authenticatedProcedure } from '../trpc';
import {
@@ -17,14 +16,7 @@ export const createDocumentRoute = authenticatedProcedure
.output(ZCreateDocumentResponseSchema)
.mutation(async ({ input, ctx }) => {
const { user, teamId } = ctx;
const { title, documentDataId, timezone, folderId, expiryAmount, expiryUnit } = input;
// Validate expiry settings
if ((expiryAmount || expiryUnit) && !isValidExpirySettings(expiryAmount, expiryUnit)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Invalid expiry settings. Please check your expiry configuration.',
});
}
const { title, documentDataId, timezone, folderId } = input;
ctx.logger.info({
input: {
@@ -56,10 +48,6 @@ export const createDocumentRoute = authenticatedProcedure
},
],
},
meta: {
expiryAmount,
expiryUnit,
},
normalizePdf: true,
requestMetadata: ctx.metadata,
});
@@ -1,10 +1,6 @@
import { z } from 'zod';
import {
ZDocumentExpiryAmountSchema,
ZDocumentExpiryUnitSchema,
ZDocumentMetaTimezoneSchema,
} from '@documenso/lib/types/document-meta';
import { ZDocumentMetaTimezoneSchema } from '@documenso/lib/types/document-meta';
import { ZDocumentTitleSchema } from './schema';
@@ -23,8 +19,6 @@ export const ZCreateDocumentRequestSchema = z.object({
documentDataId: z.string().min(1),
timezone: ZDocumentMetaTimezoneSchema.optional(),
folderId: z.string().describe('The ID of the folder to create the document in').optional(),
expiryAmount: ZDocumentExpiryAmountSchema.optional(),
expiryUnit: ZDocumentExpiryUnitSchema.optional(),
});
export const ZCreateDocumentResponseSchema = z.object({
@@ -37,7 +37,7 @@ export const distributeDocumentRoute = authenticatedProcedure
timezone: meta.timezone,
redirectUrl: meta.redirectUrl,
distributionMethod: meta.distributionMethod,
emailSettings: meta.emailSettings ?? undefined,
emailSettings: meta.emailSettings,
language: meta.language,
emailId: meta.emailId,
emailReplyTo: meta.emailReplyTo,
@@ -1,11 +1,6 @@
import { DocumentVisibility } from '@prisma/client';
import { z } from 'zod';
import {
ZDocumentExpiryAmountSchema,
ZDocumentExpiryUnitSchema,
} from '@documenso/lib/types/document-meta';
/**
* Required for empty responses since we currently can't 201 requests for our openapi setup.
*
@@ -35,6 +30,3 @@ export const ZDocumentExternalIdSchema = z
export const ZDocumentVisibilitySchema = z
.nativeEnum(DocumentVisibility)
.describe('The visibility of the document.');
// Re-export expiry schemas for convenience
export { ZDocumentExpiryAmountSchema, ZDocumentExpiryUnitSchema };
@@ -1,7 +1,5 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { updateEnvelope } from '@documenso/lib/server-only/envelope/update-envelope';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { isValidExpirySettings } from '@documenso/lib/utils/expiry';
import { authenticatedProcedure } from '../trpc';
import {
@@ -29,15 +27,6 @@ export const updateDocumentRoute = authenticatedProcedure
const userId = ctx.user.id;
if (
(meta.expiryAmount || meta.expiryUnit) &&
!isValidExpirySettings(meta.expiryAmount, meta.expiryUnit)
) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Invalid expiry settings. Please check your expiry configuration.',
});
}
const envelope = await updateEnvelope({
userId,
teamId,
@@ -35,7 +35,7 @@ export const distributeEnvelopeRoute = authenticatedProcedure
timezone: meta.timezone,
redirectUrl: meta.redirectUrl,
distributionMethod: meta.distributionMethod,
emailSettings: meta.emailSettings ?? undefined,
emailSettings: meta.emailSettings,
language: meta.language,
emailId: meta.emailId,
emailReplyTo: meta.emailReplyTo,
-131
View File
@@ -1,131 +0,0 @@
'use client';
import React from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { CalendarIcon } from 'lucide-react';
import { DateTime } from 'luxon';
import { cn } from '../lib/utils';
import { Button } from './button';
import { Calendar } from './calendar';
import { Input } from './input';
import { Popover, PopoverContent, PopoverTrigger } from './popover';
export interface DateTimePickerProps {
value?: Date;
onChange?: (date: Date | undefined) => void;
placeholder?: string;
disabled?: boolean;
className?: string;
minDate?: Date;
}
export const DateTimePicker = ({
value,
onChange,
placeholder,
disabled = false,
className,
minDate = new Date(),
}: DateTimePickerProps) => {
const { _ } = useLingui();
const [open, setOpen] = React.useState(false);
const handleDateSelect = (selectedDate: Date | undefined) => {
if (!selectedDate) {
onChange?.(undefined);
return;
}
if (value) {
const existingTime = DateTime.fromJSDate(value);
const newDateTime = DateTime.fromJSDate(selectedDate).set({
hour: existingTime.hour,
minute: existingTime.minute,
});
onChange?.(newDateTime.toJSDate());
} else {
const now = DateTime.now();
const newDateTime = DateTime.fromJSDate(selectedDate).set({
hour: now.hour,
minute: now.minute,
});
onChange?.(newDateTime.toJSDate());
}
setOpen(false);
};
const handleTimeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const timeValue = event.target.value;
if (!timeValue || !value) return;
const [hours, minutes] = timeValue.split(':').map(Number);
const newDateTime = DateTime.fromJSDate(value).set({
hour: hours,
minute: minutes,
});
onChange?.(newDateTime.toJSDate());
};
const formatDateTime = (date: Date) => {
return DateTime.fromJSDate(date).toFormat('MMM dd, yyyy');
};
const formatTime = (date: Date) => {
return DateTime.fromJSDate(date).toFormat('HH:mm');
};
return (
<div className={cn('flex gap-2', className)}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
'w-[200px] justify-start text-left font-normal',
!value && 'text-muted-foreground',
)}
disabled={disabled}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{value ? formatDateTime(value) : <span>{placeholder || _(msg`Pick a date`)}</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={value}
onSelect={handleDateSelect}
disabled={
disabled
? true
: (date) => {
return date < minDate;
}
}
initialFocus
/>
</PopoverContent>
</Popover>
{value && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-sm">
<Trans>at</Trans>
</span>
<Input
type="time"
value={formatTime(value)}
onChange={handleTimeChange}
disabled={disabled}
className="w-[120px]"
/>
</div>
)}
</div>
);
};
@@ -11,7 +11,7 @@ import {
TeamMemberRole,
} from '@prisma/client';
import { InfoIcon } from 'lucide-react';
import { useForm, useWatch } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
@@ -57,7 +57,6 @@ import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combo
import { DocumentSignatureSettingsTooltip } from '../../components/document/document-signature-settings-tooltip';
import { Combobox } from '../combobox';
import { ExpirySettingsPicker } from '../expiry-settings-picker';
import { Input } from '../input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../select';
import { useStep } from '../stepper';
@@ -73,18 +72,6 @@ import {
} from './document-flow-root';
import type { DocumentFlowStep } from './types';
const isExpiryUnit = (
value: unknown,
): value is 'minutes' | 'hours' | 'days' | 'weeks' | 'months' => {
return (
value === 'minutes' ||
value === 'hours' ||
value === 'days' ||
value === 'weeks' ||
value === 'months'
);
};
export type AddSettingsFormProps = {
documentFlow: DocumentFlowStep;
recipients: Recipient[];
@@ -114,9 +101,6 @@ export const AddSettingsFormPartial = ({
documentAuth: document.authOptions,
});
const documentExpiryUnit = document.documentMeta?.expiryUnit;
const initialExpiryUnit = isExpiryUnit(documentExpiryUnit) ? documentExpiryUnit : undefined;
const form = useForm<TAddSettingsFormSchema>({
resolver: zodResolver(ZAddSettingsFormSchema),
defaultValues: {
@@ -136,8 +120,6 @@ export const AddSettingsFormPartial = ({
redirectUrl: document.documentMeta?.redirectUrl ?? '',
language: document.documentMeta?.language ?? 'en',
signatureTypes: extractTeamSignatureSettings(document.documentMeta),
expiryAmount: document.documentMeta?.expiryAmount ?? undefined,
expiryUnit: initialExpiryUnit,
},
},
});
@@ -148,9 +130,6 @@ export const AddSettingsFormPartial = ({
(recipient) => recipient.sendStatus === SendStatus.SENT,
);
const expiryAmount = useWatch({ control: form.control, name: 'meta.expiryAmount' });
const expiryUnit = useWatch({ control: form.control, name: 'meta.expiryUnit' });
const canUpdateVisibility = match(currentTeamMemberRole)
.with(TeamMemberRole.ADMIN, () => true)
.with(
@@ -543,33 +522,6 @@ export const AddSettingsFormPartial = ({
</FormItem>
)}
/>
<div>
<FormLabel className="mb-4 block">
<Trans>Link Expiry</Trans>
</FormLabel>
<ExpirySettingsPicker
value={{
expiryDuration:
expiryAmount && expiryUnit
? {
amount: expiryAmount,
unit: expiryUnit,
}
: undefined,
}}
disabled={documentHasBeenSent}
onValueChange={(value) => {
if (value.expiryDuration) {
form.setValue('meta.expiryAmount', value.expiryDuration.amount);
form.setValue('meta.expiryUnit', value.expiryDuration.unit);
} else {
form.setValue('meta.expiryAmount', undefined);
form.setValue('meta.expiryUnit', undefined);
}
}}
/>
</div>
</div>
</AccordionContent>
</AccordionItem>
@@ -46,8 +46,6 @@ export const ZAddSettingsFormSchema = z.object({
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, {
message: msg`At least one signature type must be enabled`.id,
}),
expiryAmount: z.number().int().min(1).optional(),
expiryUnit: z.enum(['minutes', 'hours', 'days', 'weeks', 'months']).optional(),
}),
});
@@ -63,8 +63,6 @@ export type AddSignersFormProps = {
fields: Field[];
signingOrder?: DocumentSigningOrder | null;
allowDictateNextSigner?: boolean;
expiryAmount?: number | null;
expiryUnit?: 'minutes' | 'hours' | 'days' | 'weeks' | 'months' | null;
onSubmit: (_data: TAddSignersFormSchema) => void;
onAutoSave: (_data: TAddSignersFormSchema) => Promise<AutoSaveResponse>;
isDocumentPdfLoaded: boolean;
@@ -76,8 +74,6 @@ export const AddSignersFormPartial = ({
fields,
signingOrder,
allowDictateNextSigner,
expiryAmount,
expiryUnit,
onSubmit,
onAutoSave,
isDocumentPdfLoaded,
@@ -142,10 +138,6 @@ export const AddSignersFormPartial = ({
: defaultRecipients,
signingOrder: signingOrder || DocumentSigningOrder.PARALLEL,
allowDictateNextSigner: allowDictateNextSigner ?? false,
meta: {
expiryAmount: expiryAmount ?? undefined,
expiryUnit: expiryUnit ?? undefined,
},
},
});
@@ -3,10 +3,6 @@ import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import { z } from 'zod';
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
import {
ZDocumentExpiryAmountSchema,
ZDocumentExpiryUnitSchema,
} from '@documenso/lib/types/document-meta';
export const ZAddSignersFormSchema = z.object({
signers: z.array(
@@ -25,10 +21,6 @@ export const ZAddSignersFormSchema = z.object({
),
signingOrder: z.nativeEnum(DocumentSigningOrder),
allowDictateNextSigner: z.boolean().default(false),
meta: z.object({
expiryAmount: ZDocumentExpiryAmountSchema.optional(),
expiryUnit: ZDocumentExpiryUnitSchema.optional(),
}),
});
export type TAddSignersFormSchema = z.infer<typeof ZAddSignersFormSchema>;
@@ -1,79 +0,0 @@
'use client';
import React from 'react';
import type { DurationValue } from '@documenso/lib/utils/expiry';
import { cn } from '../lib/utils';
import { Input } from './input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select';
export interface DurationSelectorProps {
value?: DurationValue;
onChange?: (value: DurationValue) => void;
disabled?: boolean;
className?: string;
minAmount?: number;
maxAmount?: number;
}
const TIME_UNITS: Array<{ value: string; label: string; labelPlural: string }> = [
{ value: 'minutes', label: 'Minute', labelPlural: 'Minutes' },
{ value: 'hours', label: 'Hour', labelPlural: 'Hours' },
{ value: 'days', label: 'Day', labelPlural: 'Days' },
{ value: 'weeks', label: 'Week', labelPlural: 'Weeks' },
{ value: 'months', label: 'Month', labelPlural: 'Months' },
];
export const DurationSelector = ({
value = { amount: 1, unit: 'days' },
onChange,
disabled = false,
className,
minAmount = 1,
maxAmount = 365,
}: DurationSelectorProps) => {
const handleAmountChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const amount = parseInt(event.target.value, 10);
if (!isNaN(amount) && amount >= minAmount && amount <= maxAmount) {
onChange?.({ ...value, amount });
}
};
const handleUnitChange = (unit: string) => {
onChange?.({ ...value, unit });
};
const getUnitLabel = (unit: string, amount: number) => {
const unitConfig = TIME_UNITS.find((u) => u.value === unit);
if (!unitConfig) return unit;
return amount === 1 ? unitConfig.label : unitConfig.labelPlural;
};
return (
<div className={cn('flex items-center gap-2', className)}>
<Input
type="number"
value={value.amount}
onChange={handleAmountChange}
disabled={disabled}
min={minAmount}
max={maxAmount}
className="w-20"
/>
<Select value={value.unit} onValueChange={handleUnitChange} disabled={disabled}>
<SelectTrigger className="w-24">
<SelectValue>{getUnitLabel(value.unit, value.amount)}</SelectValue>
</SelectTrigger>
<SelectContent>
{TIME_UNITS.map((unit) => (
<SelectItem key={unit.value} value={unit.value}>
{getUnitLabel(unit.value, value.amount)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
};
@@ -1,132 +0,0 @@
'use client';
import React from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { calculateExpiryDate, formatExpiryDate } from '@documenso/lib/utils/expiry';
import { cn } from '../lib/utils';
import { DurationSelector } from './duration-selector';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from './form/form';
const ZExpirySettingsSchema = z.object({
expiryDuration: z
.object({
amount: z.number().int().min(1),
unit: z.enum(['minutes', 'hours', 'days', 'weeks', 'months']),
})
.optional(),
});
export type ExpirySettings = z.infer<typeof ZExpirySettingsSchema>;
export interface ExpirySettingsPickerProps {
className?: string;
defaultValues?: Partial<ExpirySettings>;
disabled?: boolean;
onValueChange?: (value: ExpirySettings) => void;
value?: ExpirySettings;
}
export const ExpirySettingsPicker = ({
className,
defaultValues = {
expiryDuration: undefined,
},
disabled = false,
onValueChange,
value,
}: ExpirySettingsPickerProps) => {
const form = useForm<ExpirySettings>({
resolver: zodResolver(ZExpirySettingsSchema),
defaultValues,
mode: 'onChange',
});
const { watch, setValue, getValues } = form;
const expiryDuration = watch('expiryDuration');
const calculatedExpiryDate = React.useMemo(() => {
if (expiryDuration?.amount && expiryDuration?.unit) {
return calculateExpiryDate(expiryDuration);
}
return null;
}, [expiryDuration]);
// Call onValueChange when form values change
React.useEffect(() => {
const subscription = watch((value) => {
if (onValueChange) {
onValueChange(value as ExpirySettings);
}
});
return () => subscription.unsubscribe();
}, [watch, onValueChange]);
// Keep internal form state in sync when a controlled value is provided
React.useEffect(() => {
if (value === undefined) return;
const current = getValues('expiryDuration');
const next = value.expiryDuration;
const amountsDiffer = (current?.amount ?? null) !== (next?.amount ?? null);
const unitsDiffer = (current?.unit ?? null) !== (next?.unit ?? null);
if (amountsDiffer || unitsDiffer) {
setValue('expiryDuration', next, {
shouldDirty: false,
shouldTouch: false,
shouldValidate: false,
});
}
}, [value, getValues, setValue]);
return (
<div className={cn('space-y-4', className)}>
<Form {...form}>
<FormField
control={form.control}
name="expiryDuration"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Link Expiry</Trans>
</FormLabel>
<FormDescription>
<Trans>Set an expiry duration for signing links (leave empty to disable)</Trans>
</FormDescription>
<FormControl>
<DurationSelector
value={field.value}
onChange={field.onChange}
disabled={disabled}
minAmount={1}
maxAmount={365}
/>
</FormControl>
{calculatedExpiryDate && (
<FormDescription>
<Trans>Links will expire on: {formatExpiryDate(calculatedExpiryDate)}</Trans>
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
</Form>
</div>
);
};