mirror of
https://github.com/documenso/documenso.git
synced 2026-06-23 04:42:09 +10:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 43486d8448 | |||
| 4d3d1b8d14 | |||
| 0387f3c20a | |||
| c5032d0c43 | |||
| 3bd34964cd | |||
| fe93b11a2c | |||
| 7638faf27b | |||
| 8fca029d96 |
@@ -178,7 +178,7 @@ The dropdown/select field collects a single choice from a list of options.
|
||||
|
||||
Place the dropdown/select field on the document where you want the signer to select a choice. The dropdown/select field comes with additional settings that can be configured.
|
||||
|
||||
{/*  */}
|
||||

|
||||
|
||||
The dropdown/select field settings include:
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
@@ -0,0 +1,141 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
|
||||
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
type AiFeaturesEnableDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onEnabled: () => void;
|
||||
};
|
||||
|
||||
export const AiFeaturesEnableDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onEnabled,
|
||||
}: AiFeaturesEnableDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const isTeamAdmin = team.currentTeamRole === TeamMemberRole.ADMIN;
|
||||
const isOrganisationAdmin = organisation.currentOrganisationRole === OrganisationMemberRole.ADMIN;
|
||||
const canEnableAiFeatures = isTeamAdmin || isOrganisationAdmin;
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { mutateAsync: updateTeamSettings, isPending: isUpdatingTeamSettings } =
|
||||
trpc.team.settings.update.useMutation();
|
||||
const { mutateAsync: updateOrganisationSettings, isPending: isUpdatingOrganisationSettings } =
|
||||
trpc.organisation.settings.update.useMutation();
|
||||
|
||||
const isSubmitting = isUpdatingTeamSettings || isUpdatingOrganisationSettings;
|
||||
|
||||
const onEnableClick = async () => {
|
||||
if (!canEnableAiFeatures) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (isTeamAdmin) {
|
||||
await updateTeamSettings({
|
||||
teamId: team.id,
|
||||
data: { aiFeaturesEnabled: true },
|
||||
});
|
||||
} else {
|
||||
await updateOrganisationSettings({
|
||||
organisationId: organisation.id,
|
||||
data: { aiFeaturesEnabled: true },
|
||||
});
|
||||
}
|
||||
|
||||
onEnabled();
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
console.error('Failed to enable AI features', err);
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t`We couldn't enable AI features right now. Please try again.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Enable AI features</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
Turn on AI detection to automatically find recipients and fields in your documents. AI
|
||||
providers do not retain your data for training.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Alert variant="neutral">
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
Your document content will be sent securely to our AI provider solely for detection
|
||||
and will not be stored or used for training.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{canEnableAiFeatures ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
You're an admin. You can enable AI features for this team right away. Everyone on
|
||||
the team will see AI detection once enabled.
|
||||
</Trans>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
AI features are disabled for your team. Please ask your team owner or organisation
|
||||
owner to enable them.
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
|
||||
{canEnableAiFeatures ? (
|
||||
<Button type="button" onClick={() => void onEnableClick()} loading={isSubmitting}>
|
||||
<Trans>Enable AI features</Trans>
|
||||
</Button>
|
||||
) : null}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Plural, Trans } from '@lingui/react/macro';
|
||||
import { CheckIcon, FormInputIcon, ShieldCheckIcon } from 'lucide-react';
|
||||
|
||||
import type { NormalizedFieldWithContext } from '@documenso/lib/server-only/ai/envelope/detect-fields/types';
|
||||
@@ -232,10 +232,19 @@ export const AiFieldDetectionDialog = ({
|
||||
|
||||
{progress && (
|
||||
<p className="mt-2 text-xs text-muted-foreground/60">
|
||||
<Trans>
|
||||
Page {progress.pagesProcessed} of {progress.totalPages} -{' '}
|
||||
{progress.fieldsDetected} field(s) found
|
||||
</Trans>
|
||||
<Plural
|
||||
value={progress.fieldsDetected}
|
||||
one={
|
||||
<Trans>
|
||||
Page {progress.pagesProcessed} of {progress.totalPages} - # field found
|
||||
</Trans>
|
||||
}
|
||||
other={
|
||||
<Trans>
|
||||
Page {progress.pagesProcessed} of {progress.totalPages} - # fields found
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -279,7 +288,11 @@ export const AiFieldDetectionDialog = ({
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>We found {detectedFields.length} field(s) in your document.</Trans>
|
||||
<Plural
|
||||
value={detectedFields.length}
|
||||
one="We found # field in your document."
|
||||
other="We found # fields in your document."
|
||||
/>
|
||||
</p>
|
||||
|
||||
<ul className="mt-4 divide-y rounded-lg border">
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Plural, Trans } from '@lingui/react/macro';
|
||||
import { CheckIcon, ShieldCheckIcon, UserIcon, XIcon } from 'lucide-react';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
@@ -190,10 +190,19 @@ export const AiRecipientDetectionDialog = ({
|
||||
|
||||
{progress && (
|
||||
<p className="mt-2 text-xs text-muted-foreground/60">
|
||||
<Trans>
|
||||
Page {progress.pagesProcessed} of {progress.totalPages} -{' '}
|
||||
{progress.recipientsDetected} recipient(s) found
|
||||
</Trans>
|
||||
<Plural
|
||||
value={progress.recipientsDetected}
|
||||
one={
|
||||
<Trans>
|
||||
Page {progress.pagesProcessed} of {progress.totalPages} - # recipient found
|
||||
</Trans>
|
||||
}
|
||||
other={
|
||||
<Trans>
|
||||
Page {progress.pagesProcessed} of {progress.totalPages} - # recipients found
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -237,9 +246,11 @@ export const AiRecipientDetectionDialog = ({
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
We found {detectedRecipients.length} recipient(s) in your document.
|
||||
</Trans>
|
||||
<Plural
|
||||
value={detectedRecipients.length}
|
||||
one="We found # recipient in your document."
|
||||
other="We found # recipients in your document."
|
||||
/>
|
||||
</p>
|
||||
|
||||
<ul className="mt-4 divide-y rounded-lg border">
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
|
||||
import { FileTextIcon, SparklesIcon } from 'lucide-react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { Link, useRevalidator, useSearchParams } from 'react-router';
|
||||
import { isDeepEqual } from 'remeda';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
@@ -34,6 +34,7 @@ import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/al
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
|
||||
import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-dialog';
|
||||
import { AiFieldDetectionDialog } from '~/components/dialogs/ai-field-detection-dialog';
|
||||
import { EditorFieldCheckboxForm } from '~/components/forms/editor/editor-field-checkbox-form';
|
||||
import { EditorFieldDateForm } from '~/components/forms/editor/editor-field-date-form';
|
||||
@@ -81,6 +82,8 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [isAiFieldDialogOpen, setIsAiFieldDialogOpen] = useState(false);
|
||||
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
|
||||
const { revalidate } = useRevalidator();
|
||||
|
||||
const selectedField = useMemo(
|
||||
() => structuredClone(editorFields.selectedField),
|
||||
@@ -135,6 +138,22 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
editorFields.setSelectedRecipient(firstSelectableRecipient?.id ?? null);
|
||||
}, []);
|
||||
|
||||
const onDetectClick = () => {
|
||||
if (!team.preferences.aiFeaturesEnabled) {
|
||||
setIsAiEnableDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAiFieldDialogOpen(true);
|
||||
};
|
||||
|
||||
const onAiFeaturesEnabled = () => {
|
||||
void revalidate().then(() => {
|
||||
setIsAiEnableDialogOpen(false);
|
||||
setIsAiFieldDialogOpen(true);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full">
|
||||
<div className="flex w-full flex-col overflow-y-auto">
|
||||
@@ -230,34 +249,36 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
|
||||
/>
|
||||
|
||||
{team.preferences.aiFeaturesEnabled && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-4 w-full"
|
||||
onClick={() => setIsAiFieldDialogOpen(true)}
|
||||
disabled={envelope.status !== DocumentStatus.DRAFT}
|
||||
title={
|
||||
envelope.status !== DocumentStatus.DRAFT
|
||||
? _(msg`You can only detect fields in draft envelopes`)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<SparklesIcon className="-ml-1 mr-2 h-4 w-4" />
|
||||
<Trans>Detect with AI</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-4 w-full"
|
||||
onClick={onDetectClick}
|
||||
disabled={envelope.status !== DocumentStatus.DRAFT}
|
||||
title={
|
||||
envelope.status !== DocumentStatus.DRAFT
|
||||
? _(msg`You can only detect fields in draft envelopes`)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<SparklesIcon className="-ml-1 mr-2 h-4 w-4" />
|
||||
<Trans>Detect with AI</Trans>
|
||||
</Button>
|
||||
|
||||
<AiFieldDetectionDialog
|
||||
open={isAiFieldDialogOpen}
|
||||
onOpenChange={setIsAiFieldDialogOpen}
|
||||
onComplete={onFieldDetectionComplete}
|
||||
envelopeId={envelope.id}
|
||||
teamId={envelope.teamId}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<AiFieldDetectionDialog
|
||||
open={isAiFieldDialogOpen}
|
||||
onOpenChange={setIsAiFieldDialogOpen}
|
||||
onComplete={onFieldDetectionComplete}
|
||||
envelopeId={envelope.id}
|
||||
teamId={envelope.teamId}
|
||||
/>
|
||||
|
||||
<AiFeaturesEnableDialog
|
||||
open={isAiEnableDialogOpen}
|
||||
onOpenChange={setIsAiEnableDialogOpen}
|
||||
onEnabled={onAiFeaturesEnabled}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Field details section. */}
|
||||
|
||||
+60
-20
@@ -8,12 +8,13 @@ import {
|
||||
type SensorAPI,
|
||||
} from '@hello-pangea/dnd';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { plural } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { DocumentSigningOrder, EnvelopeType, RecipientRole, SendStatus } from '@prisma/client';
|
||||
import { motion } from 'framer-motion';
|
||||
import { GripVerticalIcon, HelpCircleIcon, PlusIcon, SparklesIcon, TrashIcon } from 'lucide-react';
|
||||
import { useFieldArray, useForm, useWatch } from 'react-hook-form';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { useRevalidator, useSearchParams } from 'react-router';
|
||||
import { isDeepEqual, prop, sortBy } from 'remeda';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -62,6 +63,7 @@ import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-dialog';
|
||||
import { AiRecipientDetectionDialog } from '~/components/dialogs/ai-recipient-detection-dialog';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
@@ -96,11 +98,19 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
|
||||
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
|
||||
|
||||
// AI recipient detection dialog state
|
||||
const [isAiDialogOpen, setIsAiDialogOpen] = useState(() => searchParams.get('ai') === 'true');
|
||||
const { revalidate } = useRevalidator();
|
||||
|
||||
const onAiDialogOpenChange = (open: boolean) => {
|
||||
if (open && !team.preferences.aiFeaturesEnabled) {
|
||||
setIsAiEnableDialogOpen(true);
|
||||
setIsAiDialogOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAiDialogOpen(open);
|
||||
|
||||
if (!open && searchParams.get('ai') === 'true') {
|
||||
@@ -117,6 +127,22 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const onDetectRecipientsClick = () => {
|
||||
if (!team.preferences.aiFeaturesEnabled) {
|
||||
setIsAiEnableDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAiDialogOpen(true);
|
||||
};
|
||||
|
||||
const onAiFeaturesEnabled = () => {
|
||||
void revalidate().then(() => {
|
||||
setIsAiEnableDialogOpen(false);
|
||||
setIsAiDialogOpen(true);
|
||||
});
|
||||
};
|
||||
|
||||
const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);
|
||||
|
||||
const initialId = useId();
|
||||
@@ -329,8 +355,14 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Recipients added`,
|
||||
description: t`${detectedRecipients.length} recipient(s) have been added from AI detection.`,
|
||||
title: plural(detectedRecipients.length, {
|
||||
one: `Recipient added`,
|
||||
other: `Recipients added`,
|
||||
}),
|
||||
description: plural(detectedRecipients.length, {
|
||||
one: `# recipient have been added from AI detection.`,
|
||||
other: `# recipients have been added from AI detection.`,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -625,25 +657,27 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center space-x-2">
|
||||
{team.preferences.aiFeaturesEnabled && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => setIsAiDialogOpen(true)}
|
||||
>
|
||||
<SparklesIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={isSubmitting}
|
||||
onClick={onDetectRecipientsClick}
|
||||
>
|
||||
<SparklesIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent>
|
||||
<TooltipContent>
|
||||
{team.preferences.aiFeaturesEnabled ? (
|
||||
<Trans>Detect recipients with AI</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
) : (
|
||||
<Trans>Enable AI detection</Trans>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -1100,6 +1134,12 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
envelopeId={envelope.id}
|
||||
teamId={envelope.teamId}
|
||||
/>
|
||||
|
||||
<AiFeaturesEnableDialog
|
||||
open={isAiEnableDialogOpen}
|
||||
onOpenChange={setIsAiEnableDialogOpen}
|
||||
onEnabled={onAiFeaturesEnabled}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType, SigningStatus } from '@prisma/client';
|
||||
import { EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
import { Link, redirect } from 'react-router';
|
||||
|
||||
@@ -86,7 +86,7 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground mt-4 text-sm">
|
||||
<div className="mt-4 text-sm text-muted-foreground">
|
||||
<div>
|
||||
<Trans>Created on</Trans>: {i18n.date(envelope.createdAt, DateTime.DATETIME_MED)}
|
||||
</div>
|
||||
@@ -112,7 +112,8 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
|
||||
disabled={envelope.recipients.some(
|
||||
(recipient) =>
|
||||
recipient.signingStatus !== SigningStatus.SIGNED &&
|
||||
recipient.signingStatus !== SigningStatus.REJECTED,
|
||||
recipient.signingStatus !== SigningStatus.REJECTED &&
|
||||
recipient.role !== RecipientRole.CC,
|
||||
)}
|
||||
onClick={() => resealDocument({ id: envelope.id })}
|
||||
>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import satori from 'satori';
|
||||
import sharp from 'sharp';
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { getRecipientOrSenderByShareLinkSlug } from '@documenso/lib/server-only/document/get-recipient-or-sender-by-share-link-slug';
|
||||
import { svgToPng } from '@documenso/lib/utils/images/svg-to-png';
|
||||
|
||||
import type { Route } from './+types/share.$slug.opengraph';
|
||||
|
||||
@@ -181,8 +181,7 @@ export const loader = async ({ params }: Route.LoaderArgs) => {
|
||||
},
|
||||
);
|
||||
|
||||
// Convert SVG to PNG using sharp
|
||||
const pngBuffer = await sharp(Buffer.from(svg)).toFormat('png').toBuffer();
|
||||
const pngBuffer = await svgToPng(svg.toString());
|
||||
|
||||
return new Response(pngBuffer, {
|
||||
headers: {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import sharp from 'sharp';
|
||||
|
||||
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
|
||||
import { loadLogo } from '@documenso/lib/utils/images/logo';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { Route } from './+types/branding.logo.organisation.$orgId';
|
||||
@@ -63,16 +62,12 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||
);
|
||||
}
|
||||
|
||||
const img = await sharp(file)
|
||||
.toFormat('png', {
|
||||
quality: 80,
|
||||
})
|
||||
.toBuffer();
|
||||
const { content, contentType } = await loadLogo(file);
|
||||
|
||||
return new Response(Buffer.from(img), {
|
||||
return new Response(content, {
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
'Content-Length': img.length.toString(),
|
||||
'Content-Type': contentType,
|
||||
'Content-Length': content.length.toString(),
|
||||
// Stale while revalidate for 1 hours to 24 hours
|
||||
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
|
||||
},
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import sharp from 'sharp';
|
||||
|
||||
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
|
||||
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
|
||||
import { loadLogo } from '@documenso/lib/utils/images/logo';
|
||||
|
||||
import type { Route } from './+types/branding.logo.team.$teamId';
|
||||
|
||||
@@ -56,16 +55,12 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||
);
|
||||
}
|
||||
|
||||
const img = await sharp(file)
|
||||
.toFormat('png', {
|
||||
quality: 80,
|
||||
})
|
||||
.toBuffer();
|
||||
const { content, contentType } = await loadLogo(file);
|
||||
|
||||
return new Response(img, {
|
||||
return new Response(content, {
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
'Content-Length': img.length.toString(),
|
||||
'Content-Type': contentType,
|
||||
'Content-Length': content.length.toString(),
|
||||
// Stale while revalidate for 1 hours to 24 hours
|
||||
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
|
||||
},
|
||||
|
||||
@@ -70,7 +70,6 @@
|
||||
"remeda": "^2.32.0",
|
||||
"remix-themes": "^2.0.4",
|
||||
"satori": "^0.18.3",
|
||||
"sharp": "0.34.5",
|
||||
"tailwindcss": "^3.4.18",
|
||||
"ts-pattern": "^5.9.0",
|
||||
"ua-parser-js": "^1.0.41",
|
||||
@@ -108,5 +107,5 @@
|
||||
"vite-plugin-babel-macros": "^1.0.6",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"version": "2.2.5"
|
||||
"version": "2.2.6"
|
||||
}
|
||||
|
||||
Generated
+3
-4
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@documenso/root",
|
||||
"version": "2.2.5",
|
||||
"version": "2.2.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@documenso/root",
|
||||
"version": "2.2.5",
|
||||
"version": "2.2.6",
|
||||
"hasInstallScript": true,
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
@@ -109,7 +109,7 @@
|
||||
},
|
||||
"apps/remix": {
|
||||
"name": "@documenso/remix",
|
||||
"version": "2.2.5",
|
||||
"version": "2.2.6",
|
||||
"dependencies": {
|
||||
"@cantoo/pdf-lib": "^2.5.3",
|
||||
"@documenso/api": "*",
|
||||
@@ -167,7 +167,6 @@
|
||||
"remeda": "^2.32.0",
|
||||
"remix-themes": "^2.0.4",
|
||||
"satori": "^0.18.3",
|
||||
"sharp": "0.34.5",
|
||||
"tailwindcss": "^3.4.18",
|
||||
"ts-pattern": "^5.9.0",
|
||||
"ua-parser-js": "^1.0.41",
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
],
|
||||
"version": "2.2.5",
|
||||
"version": "2.2.6",
|
||||
"scripts": {
|
||||
"postinstall": "patch-package",
|
||||
"build": "turbo run build",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type Page } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
|
||||
@@ -58,3 +58,15 @@ const getCsrfToken = async (page: Page) => {
|
||||
|
||||
return csrfToken;
|
||||
};
|
||||
|
||||
export const checkSessionValid = async (page: Page): Promise<boolean> => {
|
||||
const { request } = page.context();
|
||||
|
||||
const response = await request.fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/api/auth/session`, {
|
||||
method: 'get',
|
||||
});
|
||||
|
||||
const session = await response.json();
|
||||
|
||||
return session.isAuthenticated === true;
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import { type Page, expect, test } from '@playwright/test';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
import { apiSignin, apiSignout, checkSessionValid } from '../fixtures/authentication';
|
||||
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
@@ -17,6 +17,7 @@ test('[USER] can reset password via forgot password', async ({ page }: { page: P
|
||||
|
||||
await page.goto('http://localhost:3000/signin');
|
||||
await page.getByRole('link', { name: 'Forgot your password?' }).click();
|
||||
await expect(page).toHaveURL('http://localhost:3000/forgot-password');
|
||||
|
||||
await page.getByRole('textbox', { name: 'Email' }).click();
|
||||
await page.getByRole('textbox', { name: 'Email' }).fill(user.email);
|
||||
@@ -24,7 +25,9 @@ test('[USER] can reset password via forgot password', async ({ page }: { page: P
|
||||
await expect(page.getByRole('button', { name: 'Reset Password' })).toBeEnabled();
|
||||
await page.getByRole('button', { name: 'Reset Password' }).click();
|
||||
|
||||
await expect(page.locator('body')).toContainText('Reset email sent', { timeout: 10000 });
|
||||
await expect(page.locator('body')).toContainText('Reset email sent', {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
const foundToken = await prisma.passwordResetToken.findFirstOrThrow({
|
||||
where: {
|
||||
@@ -109,3 +112,116 @@ test('[USER] can reset password via user settings', async ({ page }: { page: Pag
|
||||
await page.waitForURL('/settings/profile');
|
||||
await expect(page).toHaveURL('/settings/profile');
|
||||
});
|
||||
|
||||
test('[USER] password reset invalidates all sessions', async ({ page }: { page: Page }) => {
|
||||
const oldPassword = 'Test123!';
|
||||
const newPassword = 'Test124!';
|
||||
|
||||
const { user } = await seedUser({
|
||||
password: oldPassword,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
password: oldPassword,
|
||||
redirectPath: '/settings/profile',
|
||||
});
|
||||
|
||||
expect(await checkSessionValid(page)).toBe(true);
|
||||
|
||||
const initialCookies = await page.context().cookies();
|
||||
|
||||
await page.context().clearCookies();
|
||||
|
||||
await page.goto('http://localhost:3000/signin');
|
||||
await page.getByRole('link', { name: 'Forgot your password?' }).click();
|
||||
await expect(page).toHaveURL('http://localhost:3000/forgot-password');
|
||||
await page.getByRole('textbox', { name: 'Email' }).fill(user.email);
|
||||
await page.getByRole('button', { name: 'Reset Password' }).click();
|
||||
await expect(page.locator('body')).toContainText('Reset email sent', {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
const foundToken = await prisma.passwordResetToken.findFirstOrThrow({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
|
||||
await page.goto(`http://localhost:3000/reset-password/${foundToken.token}`);
|
||||
await page.getByLabel('Password', { exact: true }).fill(newPassword);
|
||||
await page.getByLabel('Repeat Password').fill(newPassword);
|
||||
await page.getByRole('button', { name: 'Reset Password' }).click();
|
||||
await expect(page.locator('body')).toContainText('Your password has been updated successfully.');
|
||||
|
||||
await page.context().addCookies(initialCookies);
|
||||
|
||||
await page.goto('http://localhost:3000/settings/profile');
|
||||
await expect(page).toHaveURL('http://localhost:3000/signin');
|
||||
|
||||
expect(await checkSessionValid(page)).toBe(false);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
password: newPassword,
|
||||
redirectPath: '/settings/profile',
|
||||
});
|
||||
|
||||
await page.waitForURL('/settings/profile');
|
||||
expect(await checkSessionValid(page)).toBe(true);
|
||||
});
|
||||
|
||||
test('[USER] password update invalidates other sessions but keeps current', async ({
|
||||
page,
|
||||
}: {
|
||||
page: Page;
|
||||
}) => {
|
||||
const oldPassword = 'Test123!';
|
||||
const newPassword = 'Test124!';
|
||||
|
||||
const { user } = await seedUser({
|
||||
password: oldPassword,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
password: oldPassword,
|
||||
redirectPath: '/settings/profile',
|
||||
});
|
||||
|
||||
expect(await checkSessionValid(page)).toBe(true);
|
||||
|
||||
const initialCookies = await page.context().cookies();
|
||||
|
||||
await page.context().clearCookies();
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
password: oldPassword,
|
||||
redirectPath: '/settings/profile',
|
||||
});
|
||||
|
||||
expect(await checkSessionValid(page)).toBe(true);
|
||||
|
||||
await page.goto('http://localhost:3000/settings/security');
|
||||
await page.getByLabel('Current password').fill(oldPassword);
|
||||
await page.getByLabel('New password').fill(newPassword);
|
||||
await page.getByLabel('Repeat password').fill(newPassword);
|
||||
await page.getByRole('button', { name: 'Update password' }).click();
|
||||
await expect(page.locator('body')).toContainText('Password updated');
|
||||
|
||||
const finalCookies = await page.context().cookies();
|
||||
|
||||
await page.context().clearCookies();
|
||||
await page.context().addCookies(initialCookies);
|
||||
await page.goto('http://localhost:3000/settings/profile');
|
||||
await expect(page).toHaveURL('http://localhost:3000/signin');
|
||||
expect(await checkSessionValid(page)).toBe(false);
|
||||
|
||||
await page.context().clearCookies();
|
||||
await page.context().addCookies(finalCookies);
|
||||
await page.goto('http://localhost:3000/settings/security');
|
||||
await expect(page).toHaveURL('http://localhost:3000/settings/security');
|
||||
expect(await checkSessionValid(page)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ import { env } from '@documenso/lib/utils/env';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AuthenticationErrorCode } from '../lib/errors/error-codes';
|
||||
import { invalidateSessions } from '../lib/session/session';
|
||||
import { getCsrfCookie } from '../lib/session/session-cookies';
|
||||
import { onAuthorize } from '../lib/utils/authorizer';
|
||||
import { getSession } from '../lib/utils/get-session';
|
||||
@@ -170,15 +171,38 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
||||
const { password, currentPassword } = c.req.valid('json');
|
||||
const requestMetadata = c.get('requestMetadata');
|
||||
|
||||
const session = await getSession(c);
|
||||
const { session, user } = await getSession(c);
|
||||
|
||||
await updatePassword({
|
||||
userId: session.user.id,
|
||||
userId: user.id,
|
||||
password,
|
||||
currentPassword,
|
||||
requestMetadata,
|
||||
});
|
||||
|
||||
const userSessionIds = await prisma.session
|
||||
.findMany({
|
||||
where: {
|
||||
userId: user.id satisfies number, // Incase we pass undefined somehow.
|
||||
id: {
|
||||
not: session.id,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
})
|
||||
.then((sessions) => sessions.map((s) => s.id));
|
||||
|
||||
if (userSessionIds.length > 0) {
|
||||
await invalidateSessions({
|
||||
userId: user.id,
|
||||
sessionIds: userSessionIds,
|
||||
metadata: requestMetadata,
|
||||
isRevoke: true,
|
||||
});
|
||||
}
|
||||
|
||||
return c.text('OK', 201);
|
||||
})
|
||||
/**
|
||||
@@ -231,12 +255,33 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
||||
|
||||
const requestMetadata = c.get('requestMetadata');
|
||||
|
||||
await resetPassword({
|
||||
const { userId } = await resetPassword({
|
||||
token,
|
||||
password,
|
||||
requestMetadata,
|
||||
});
|
||||
|
||||
// Invalidate all sessions after successful password reset
|
||||
const userSessionIds = await prisma.session
|
||||
.findMany({
|
||||
where: {
|
||||
userId: userId satisfies number, // Incase we pass undefined somehow.
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
})
|
||||
.then((sessions) => sessions.map((session) => session.id));
|
||||
|
||||
if (userSessionIds.length > 0) {
|
||||
await invalidateSessions({
|
||||
userId,
|
||||
sessionIds: userSessionIds,
|
||||
metadata: requestMetadata,
|
||||
isRevoke: true,
|
||||
});
|
||||
}
|
||||
|
||||
return c.text('OK', 201);
|
||||
})
|
||||
/**
|
||||
|
||||
@@ -92,9 +92,23 @@ export const run = async ({
|
||||
teamId: envelope.teamId,
|
||||
});
|
||||
|
||||
// Ensure all CC recipients are marked as signed
|
||||
await prisma.recipient.updateMany({
|
||||
where: {
|
||||
envelopeId: envelope.id,
|
||||
role: RecipientRole.CC,
|
||||
},
|
||||
data: {
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
},
|
||||
});
|
||||
|
||||
const isComplete =
|
||||
envelope.recipients.some((recipient) => recipient.signingStatus === SigningStatus.REJECTED) ||
|
||||
envelope.recipients.every((recipient) => recipient.signingStatus === SigningStatus.SIGNED);
|
||||
envelope.recipients.every(
|
||||
(recipient) =>
|
||||
recipient.signingStatus === SigningStatus.SIGNED || recipient.role === RecipientRole.CC,
|
||||
);
|
||||
|
||||
if (!isComplete) {
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
|
||||
@@ -2,12 +2,12 @@ import { createCanvas, loadImage } from '@napi-rs/canvas';
|
||||
import { DocumentStatus, type Field, RecipientRole } from '@prisma/client';
|
||||
import { generateObject } from 'ai';
|
||||
import pMap from 'p-map';
|
||||
import sharp from 'sharp';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../../../errors/app-error';
|
||||
import { getFileServerSide } from '../../../../universal/upload/get-file.server';
|
||||
import { resizeImageToGeminiImage } from '../../../../utils/images/resize-image-to-gemini-image';
|
||||
import { getEnvelopeById } from '../../../envelope/get-envelope-by-id';
|
||||
import { createEnvelopeRecipients } from '../../../recipient/create-envelope-recipients';
|
||||
import { vertex } from '../../google';
|
||||
@@ -238,21 +238,6 @@ const maskFieldsOnImage = async ({ image, width, height, fields }: MaskFieldsOnI
|
||||
return canvas.encode('jpeg');
|
||||
};
|
||||
|
||||
const TARGET_SIZE = 1000;
|
||||
|
||||
type ResizeImageOptions = {
|
||||
image: Buffer;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resize image to 1000x1000 using fill strategy.
|
||||
* Scales to cover the target area and crops any overflow.
|
||||
*/
|
||||
const resizeImageToSquare = async ({ image, size = TARGET_SIZE }: ResizeImageOptions) => {
|
||||
return await sharp(image).resize(size, size, { fit: 'fill' }).toBuffer();
|
||||
};
|
||||
|
||||
type DetectFieldsFromPageOptions = {
|
||||
image: Buffer;
|
||||
pageNumber: number;
|
||||
@@ -267,7 +252,7 @@ const detectFieldsFromPage = async ({
|
||||
context,
|
||||
}: DetectFieldsFromPageOptions) => {
|
||||
// Resize to 1000x1000 for consistent coordinate mapping
|
||||
const resizedImage = await resizeImageToSquare({ image });
|
||||
const resizedImage = await resizeImageToGeminiImage({ image });
|
||||
|
||||
// Build messages array
|
||||
const messages: Parameters<typeof generateObject>[0]['messages'] = [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import sharp from 'sharp';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { loadAvatar } from '../../utils/images/avatar';
|
||||
|
||||
export type GetAvatarImageOptions = {
|
||||
id: string;
|
||||
};
|
||||
@@ -17,10 +17,5 @@ export const getAvatarImage = async ({ id }: GetAvatarImageOptions) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bytes = Buffer.from(avatarImage.bytes, 'base64');
|
||||
|
||||
return {
|
||||
contentType: 'image/jpeg',
|
||||
content: await sharp(bytes).toFormat('jpeg').toBuffer(),
|
||||
};
|
||||
return await loadAvatar(avatarImage.bytes);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import sharp from 'sharp';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/organisations';
|
||||
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
|
||||
import { AppError } from '../../errors/app-error';
|
||||
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { optimiseAvatar } from '../../utils/images/avatar';
|
||||
import { buildOrganisationWhereQuery } from '../../utils/organisations';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
@@ -100,10 +99,7 @@ export const setAvatarImage = async ({
|
||||
let newAvatarImageId: string | null = null;
|
||||
|
||||
if (bytes) {
|
||||
const optimisedBytes = await sharp(Buffer.from(bytes, 'base64'))
|
||||
.resize(512, 512)
|
||||
.toFormat('jpeg', { quality: 75 })
|
||||
.toBuffer();
|
||||
const optimisedBytes = await optimiseAvatar(bytes);
|
||||
|
||||
const avatarImage = await prisma.avatarImage.create({
|
||||
data: {
|
||||
|
||||
@@ -85,4 +85,8 @@ export const resetPassword = async ({ token, password, requestMetadata }: ResetP
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
userId: foundToken.userId,
|
||||
};
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1368
-1007
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
import sharp from 'sharp';
|
||||
|
||||
export const optimiseAvatar = async (bytes: string) => {
|
||||
return await sharp(Buffer.from(bytes, 'base64'))
|
||||
.resize(512, 512)
|
||||
.toFormat('jpeg', { quality: 75 })
|
||||
.toBuffer();
|
||||
};
|
||||
|
||||
export const loadAvatar = async (bytes: string) => {
|
||||
const content = await sharp(Buffer.from(bytes, 'base64')).toFormat('jpeg').toBuffer();
|
||||
return {
|
||||
contentType: 'image/jpeg',
|
||||
content,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import sharp from 'sharp';
|
||||
|
||||
export const loadLogo = async (file: Uint8Array) => {
|
||||
const content = await sharp(file).toFormat('png', { quality: 80 }).toBuffer();
|
||||
|
||||
return {
|
||||
contentType: 'image/png',
|
||||
content,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import sharp from 'sharp';
|
||||
|
||||
export const TARGET_SIZE = 1000;
|
||||
|
||||
type ResizeImageToGeminiImageOptions = {
|
||||
image: Buffer;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resize image to 1000x1000 using fill strategy.
|
||||
* Scales to cover the target area and crops any overflow.
|
||||
*/
|
||||
export const resizeImageToGeminiImage = async ({
|
||||
image,
|
||||
size = TARGET_SIZE,
|
||||
}: ResizeImageToGeminiImageOptions) => {
|
||||
return await sharp(image).resize(size, size, { fit: 'fill' }).toBuffer();
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import sharp from 'sharp';
|
||||
|
||||
export const svgToPng = async (svg: string) => {
|
||||
return await sharp(Buffer.from(svg)).toFormat('png').toBuffer();
|
||||
};
|
||||
Reference in New Issue
Block a user