Merge branch 'main' into feat/change-radio-direction

This commit is contained in:
Ephraim Duncan
2025-10-07 20:32:45 +00:00
committed by GitHub
335 changed files with 15880 additions and 3438 deletions

View File

@ -34,7 +34,7 @@ export const AdminDocumentDeleteDialog = ({ document }: AdminDocumentDeleteDialo
const [reason, setReason] = useState('');
const { mutateAsync: deleteDocument, isPending: isDeletingDocument } =
trpc.admin.deleteDocument.useMutation();
trpc.admin.document.delete.useMutation();
const handleDeleteDocument = async () => {
try {

View File

@ -3,12 +3,12 @@ import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { User } from '@prisma/client';
import { useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
@ -25,7 +25,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export type AdminUserDeleteDialogProps = {
className?: string;
user: User;
user: TGetUserResponse;
};
export const AdminUserDeleteDialog = ({ className, user }: AdminUserDeleteDialogProps) => {
@ -35,7 +35,7 @@ export const AdminUserDeleteDialog = ({ className, user }: AdminUserDeleteDialog
const [email, setEmail] = useState('');
const { mutateAsync: deleteUser, isPending: isDeletingUser } =
trpc.admin.deleteUser.useMutation();
trpc.admin.user.delete.useMutation();
const onDeleteAccount = async () => {
try {

View File

@ -3,11 +3,11 @@ import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { User } from '@prisma/client';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
@ -24,7 +24,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export type AdminUserDisableDialogProps = {
className?: string;
userToDisable: User;
userToDisable: TGetUserResponse;
};
export const AdminUserDisableDialog = ({
@ -37,7 +37,7 @@ export const AdminUserDisableDialog = ({
const [email, setEmail] = useState('');
const { mutateAsync: disableUser, isPending: isDisablingUser } =
trpc.admin.disableUser.useMutation();
trpc.admin.user.disable.useMutation();
const onDisableAccount = async () => {
try {

View File

@ -3,11 +3,11 @@ import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { User } from '@prisma/client';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
@ -24,7 +24,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export type AdminUserEnableDialogProps = {
className?: string;
userToEnable: User;
userToEnable: TGetUserResponse;
};
export const AdminUserEnableDialog = ({ className, userToEnable }: AdminUserEnableDialogProps) => {
@ -34,7 +34,7 @@ export const AdminUserEnableDialog = ({ className, userToEnable }: AdminUserEnab
const [email, setEmail] = useState('');
const { mutateAsync: enableUser, isPending: isEnablingUser } =
trpc.admin.enableUser.useMutation();
trpc.admin.user.enable.useMutation();
const onEnableAccount = async () => {
try {

View File

@ -3,12 +3,12 @@ import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { User } from '@prisma/client';
import { useRevalidator } from 'react-router';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
@ -25,7 +25,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export type AdminUserResetTwoFactorDialogProps = {
className?: string;
user: User;
user: TGetUserResponse;
};
export const AdminUserResetTwoFactorDialog = ({

View File

@ -49,7 +49,7 @@ export const DocumentDeleteDialog = ({
const [inputValue, setInputValue] = useState('');
const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT);
const { mutateAsync: deleteDocument, isPending } = trpcReact.document.deleteDocument.useMutation({
const { mutateAsync: deleteDocument, isPending } = trpcReact.document.delete.useMutation({
onSuccess: async () => {
void refreshLimits();

View File

@ -36,11 +36,12 @@ export const DocumentDuplicateDialog = ({
const team = useCurrentTeam();
const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery(
const { data: document, isLoading } = trpcReact.document.get.useQuery(
{
documentId: id,
},
{
queryHash: `document-duplicate-dialog-${id}`,
enabled: open === true,
},
);
@ -55,7 +56,7 @@ export const DocumentDuplicateDialog = ({
const documentsPath = formatDocumentsPath(team.url);
const { mutateAsync: duplicateDocument, isPending: isDuplicateLoading } =
trpcReact.document.duplicateDocument.useMutation({
trpcReact.document.duplicate.useMutation({
onSuccess: async ({ documentId }) => {
toast({
title: _(msg`Document Duplicated`),

View File

@ -6,7 +6,7 @@ import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { type Recipient, SigningStatus } from '@prisma/client';
import { History } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useForm, useWatch } from 'react-hook-form';
import * as z from 'zod';
import { useSession } from '@documenso/lib/client-only/providers/session';
@ -71,7 +71,7 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
document.status !== 'PENDING' ||
!recipients.some((r) => r.signingStatus === SigningStatus.NOT_SIGNED);
const { mutateAsync: resendDocument } = trpcReact.document.resendDocument.useMutation();
const { mutateAsync: resendDocument } = trpcReact.document.redistribute.useMutation();
const form = useForm<TResendDocumentFormSchema>({
resolver: zodResolver(ZResendDocumentFormSchema),
@ -85,6 +85,11 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
formState: { isSubmitting },
} = form;
const selectedRecipients = useWatch({
control: form.control,
name: 'recipients',
});
const onFormSubmit = async ({ recipients }: TResendDocumentFormSchema) => {
try {
await resendDocument({ documentId: document.id, recipients });
@ -151,7 +156,7 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
<FormControl>
<Checkbox
className="h-5 w-5 rounded-full"
className="h-5 w-5 rounded-full border border-neutral-400"
value={recipient.id}
checked={value.includes(recipient.id)}
onCheckedChange={(checked: boolean) =>
@ -182,7 +187,13 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
</Button>
</DialogClose>
<Button className="flex-1" loading={isSubmitting} type="submit" form={FORM_ID}>
<Button
className="flex-1"
loading={isSubmitting}
type="submit"
form={FORM_ID}
disabled={isSubmitting || selectedRecipients.length === 0}
>
<Trans>Send reminder</Trans>
</Button>
</div>

View File

@ -65,9 +65,9 @@ export const PasskeyCreateDialog = ({ trigger, onSuccess, ...props }: PasskeyCre
});
const { mutateAsync: createPasskeyRegistrationOptions, isPending } =
trpc.auth.createPasskeyRegistrationOptions.useMutation();
trpc.auth.passkey.createRegistrationOptions.useMutation();
const { mutateAsync: createPasskey } = trpc.auth.createPasskey.useMutation();
const { mutateAsync: createPasskey } = trpc.auth.passkey.create.useMutation();
const onFormSubmit = async ({ passkeyName }: TCreatePasskeyFormSchema) => {
setFormError(null);

View File

@ -15,7 +15,6 @@ import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import {
TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
isTemplateRecipientEmailPlaceholder,
} from '@documenso/lib/constants/template';
import { AppError } from '@documenso/lib/errors/app-error';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
@ -46,50 +45,22 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitive
import type { Toast } from '@documenso/ui/primitives/use-toast';
import { useToast } from '@documenso/ui/primitives/use-toast';
const ZAddRecipientsForNewDocumentSchema = z
.object({
distributeDocument: z.boolean(),
useCustomDocument: z.boolean().default(false),
customDocumentData: z
.any()
.refine((data) => data instanceof File || data === undefined)
.optional(),
recipients: z.array(
z.object({
id: z.number(),
email: z.string().email(),
name: z.string(),
signingOrder: z.number().optional(),
}),
),
})
// Display exactly which rows are duplicates.
.superRefine((items, ctx) => {
const uniqueEmails = new Map<string, number>();
for (const [index, recipients] of items.recipients.entries()) {
const email = recipients.email.toLowerCase();
const firstFoundIndex = uniqueEmails.get(email);
if (firstFoundIndex === undefined) {
uniqueEmails.set(email, index);
continue;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Emails must be unique',
path: ['recipients', index, 'email'],
});
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Emails must be unique',
path: ['recipients', firstFoundIndex, 'email'],
});
}
});
const ZAddRecipientsForNewDocumentSchema = z.object({
distributeDocument: z.boolean(),
useCustomDocument: z.boolean().default(false),
customDocumentData: z
.any()
.refine((data) => data instanceof File || data === undefined)
.optional(),
recipients: z.array(
z.object({
id: z.number(),
email: z.string().email(),
name: z.string(),
signingOrder: z.number().optional(),
}),
),
});
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
@ -278,14 +249,7 @@ export function TemplateUseDialog({
)}
<FormControl>
<Input
{...field}
placeholder={
isTemplateRecipientEmailPlaceholder(field.value)
? ''
: _(msg`Email`)
}
/>
<Input {...field} aria-label="Email" placeholder={_(msg`Email`)} />
</FormControl>
<FormMessage />
</FormItem>
@ -306,6 +270,7 @@ export function TemplateUseDialog({
<FormControl>
<Input
{...field}
aria-label="Name"
placeholder={recipients[index].name || _(msg`Name`)}
/>
</FormControl>

View File

@ -56,7 +56,7 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe
type TDeleteTokenByIdMutationSchema = z.infer<typeof ZTokenDeleteDialogSchema>;
const { mutateAsync: deleteTokenMutation } = trpc.apiToken.deleteTokenById.useMutation({
const { mutateAsync: deleteTokenMutation } = trpc.apiToken.delete.useMutation({
onSuccess() {
onDelete?.();
},

View File

@ -172,6 +172,8 @@ export const ConfigureFieldsView = ({
name: 'fields',
});
const highestPageNumber = Math.max(...localFields.map((field) => field.pageNumber));
const onFieldCopy = useCallback(
(event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => {
const { duplicate = false, duplicateAll = false } = options ?? {};
@ -540,7 +542,9 @@ export const ConfigureFieldsView = ({
<div>
<PDFViewer documentData={normalizedDocumentData} />
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
<ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
>
{localFields.map((field, index) => {
const recipientIndex = recipients.findIndex(
(r) => r.id === field.recipientId,

View File

@ -57,7 +57,7 @@ export const EmbedDirectTemplateClientPage = ({
token,
updatedAt,
documentData,
recipient,
recipient: _recipient,
fields,
metadata,
hidePoweredBy = false,
@ -91,8 +91,12 @@ export const EmbedDirectTemplateClientPage = ({
localFields.filter((field) => field.inserted),
];
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
const hasSignatureField = localFields.some((field) => field.type === FieldType.SIGNATURE);
const signatureValid = !hasSignatureField || (signature && signature.trim() !== '');
const { mutateAsync: createDocumentFromDirectTemplate, isPending: isSubmitting } =
trpc.template.createDocumentFromDirectTemplate.useMutation();
@ -343,19 +347,34 @@ export const EmbedDirectTemplateClientPage = ({
<Trans>Sign document</Trans>
</h3>
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
{isExpanded ? (
<LucideChevronDown
className="text-muted-foreground h-5 w-5"
onClick={() => setIsExpanded(false)}
/>
) : (
<LucideChevronUp
className="text-muted-foreground h-5 w-5"
onClick={() => setIsExpanded(true)}
/>
)}
</Button>
{isExpanded ? (
<Button
variant="outline"
className="h-8 w-8 p-0 md:hidden"
onClick={() => setIsExpanded(false)}
>
<LucideChevronDown className="text-muted-foreground h-5 w-5" />
</Button>
) : pendingFields.length > 0 ? (
<Button
variant="outline"
className="h-8 w-8 p-0 md:hidden"
onClick={() => setIsExpanded(true)}
>
<LucideChevronUp className="text-muted-foreground h-5 w-5" />
</Button>
) : (
<Button
variant="default"
size="sm"
className="md:hidden"
disabled={isThrottled || (hasSignatureField && !signatureValid)}
loading={isSubmitting}
onClick={() => throttledOnCompleteClick()}
>
<Trans>Complete</Trans>
</Button>
)}
</div>
</div>
@ -442,7 +461,9 @@ export const EmbedDirectTemplateClientPage = ({
</div>
</div>
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
<ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPendingPageNumber}"]`}
>
{showPendingFieldTooltip && pendingFields.length > 0 && (
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
<Trans>Click to insert field</Trans>

View File

@ -50,8 +50,10 @@ export const EmbedDocumentFields = ({
onSignField,
onUnsignField,
}: EmbedDocumentFieldsProps) => {
const highestPageNumber = Math.max(...fields.map((field) => field.page));
return (
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
<ElementVisible target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}>
{fields.map((field) =>
match(field.type)
.with(FieldType.SIGNATURE, () => (

View File

@ -89,7 +89,7 @@ export const EmbedSignDocumentClientPage = ({
const [isExpanded, setIsExpanded] = useState(false);
const [isNameLocked, setIsNameLocked] = useState(false);
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
const [showOtherRecipientsCompletedFields, setShowOtherRecipientsCompletedFields] =
const [_showOtherRecipientsCompletedFields, setShowOtherRecipientsCompletedFields] =
useState(false);
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
@ -106,6 +106,8 @@ export const EmbedSignDocumentClientPage = ({
fields.filter((field) => field.inserted),
];
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
const { mutateAsync: completeDocumentWithToken, isPending: isSubmitting } =
trpc.recipient.completeDocumentWithToken.useMutation();
@ -116,6 +118,8 @@ export const EmbedSignDocumentClientPage = ({
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
const signatureValid = !hasSignatureField || (signature && signature.trim() !== '');
const assistantSignersId = useId();
const onNextFieldClick = () => {
@ -305,19 +309,36 @@ export const EmbedSignDocumentClientPage = ({
)}
</h3>
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
{isExpanded ? (
<LucideChevronDown
className="text-muted-foreground h-5 w-5"
onClick={() => setIsExpanded(false)}
/>
) : (
<LucideChevronUp
className="text-muted-foreground h-5 w-5"
onClick={() => setIsExpanded(true)}
/>
)}
</Button>
{isExpanded ? (
<Button
variant="outline"
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
onClick={() => setIsExpanded(false)}
>
<LucideChevronDown className="text-muted-foreground dark:text-background h-5 w-5" />
</Button>
) : pendingFields.length > 0 ? (
<Button
variant="outline"
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
onClick={() => setIsExpanded(true)}
>
<LucideChevronUp className="text-muted-foreground dark:text-background h-5 w-5" />
</Button>
) : (
<Button
variant="default"
size="sm"
className="md:hidden"
disabled={
isThrottled || (!isAssistantMode && hasSignatureField && !signatureValid)
}
loading={isSubmitting}
onClick={() => throttledOnCompleteClick()}
>
<Trans>Complete</Trans>
</Button>
)}
</div>
</div>
@ -465,7 +486,9 @@ export const EmbedSignDocumentClientPage = ({
</div>
</div>
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
<ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPendingPageNumber}"]`}
>
{showPendingFieldTooltip && pendingFields.length > 0 && (
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
<Trans>Click to insert field</Trans>

View File

@ -92,6 +92,8 @@ export const MultiSignDocumentSigningView = ({
[],
];
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
const uninsertedFields = document?.fields.filter((field) => !field.inserted) ?? [];
const onSignField = async (payload: TSignFieldWithTokenMutationSchema) => {
@ -357,7 +359,9 @@ export const MultiSignDocumentSigningView = ({
</div>
{hasDocumentLoaded && (
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
<ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPendingPageNumber}"]`}
>
{showPendingFieldTooltip && pendingFields.length > 0 && (
<FieldToolTip
key={pendingFields[0].id}

View File

@ -3,10 +3,11 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import type { TeamGlobalSettings } from '@prisma/client';
import { DocumentVisibility } from '@prisma/client';
import { DocumentVisibility, OrganisationType } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { DATE_FORMATS } from '@documenso/lib/constants/date-formats';
import { DOCUMENT_SIGNATURE_TYPES, DocumentSignatureType } from '@documenso/lib/constants/document';
@ -86,8 +87,10 @@ export const DocumentPreferencesForm = ({
}: DocumentPreferencesFormProps) => {
const { t } = useLingui();
const { user, organisations } = useSession();
const currentOrganisation = useCurrentOrganisation();
const isPersonalLayoutMode = isPersonalLayout(organisations);
const isPersonalOrganisation = currentOrganisation.type === OrganisationType.PERSONAL;
const placeholderEmail = user.email ?? 'user@example.com';
@ -331,7 +334,7 @@ export const DocumentPreferencesForm = ({
)}
/>
{!isPersonalLayoutMode && (
{!isPersonalLayoutMode && !isPersonalOrganisation && (
<FormField
control={form.control}
name="includeSenderDetails"

View File

@ -114,7 +114,7 @@ export const SignInForm = ({
}, [returnTo]);
const { mutateAsync: createPasskeySigninOptions } =
trpc.auth.createPasskeySigninOptions.useMutation();
trpc.auth.passkey.createSigninOptions.useMutation();
const form = useForm<TSignInFormSchema>({
values: {

View File

@ -13,7 +13,7 @@ import type { z } from 'zod';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { ZCreateTokenMutationSchema } from '@documenso/trpc/server/api-token-router/schema';
import { ZCreateApiTokenRequestSchema } from '@documenso/trpc/server/api-token-router/create-api-token.types';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
@ -47,7 +47,7 @@ export const EXPIRATION_DATES = {
ONE_YEAR: msg`12 months`,
} as const;
const ZCreateTokenFormSchema = ZCreateTokenMutationSchema.pick({
const ZCreateTokenFormSchema = ZCreateApiTokenRequestSchema.pick({
tokenName: true,
expirationDate: true,
});
@ -75,7 +75,7 @@ export const ApiTokenForm = ({ className, tokens }: ApiTokenFormProps) => {
const [newlyCreatedToken, setNewlyCreatedToken] = useState<NewlyCreatedToken | null>();
const [noExpirationDate, setNoExpirationDate] = useState(false);
const { mutateAsync: createTokenMutation } = trpc.apiToken.createToken.useMutation({
const { mutateAsync: createTokenMutation } = trpc.apiToken.create.useMutation({
onSuccess(data) {
setNewlyCreatedToken(data);
},

View File

@ -64,7 +64,7 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
const [pages, setPages] = useState<string[]>([]);
const { data: searchDocumentsData, isPending: isSearchingDocuments } =
trpcReact.document.searchDocuments.useQuery(
trpcReact.document.search.useQuery(
{
query: search,
},

View File

@ -79,6 +79,8 @@ export const DirectTemplateSigningForm = ({
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const highestPageNumber = Math.max(...localFields.map((field) => field.page));
const fieldsRequiringValidation = useMemo(() => {
return localFields.filter((field) => isFieldUnsignedAndRequired(field));
}, [localFields]);
@ -221,7 +223,9 @@ export const DirectTemplateSigningForm = ({
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
<DocumentFlowFormContainerContent>
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
<ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
>
{validateUninsertedFields && uninsertedFields[0] && (
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
<Trans>Click to insert field</Trans>
@ -413,11 +417,11 @@ export const DirectTemplateSigningForm = ({
<DocumentSigningCompleteDialog
isSubmitting={isSubmitting}
onSignatureComplete={handleSubmit}
onSignatureComplete={async () => handleSubmit()}
documentTitle={template.title}
fields={localFields}
fieldsValidated={fieldsValidated}
role={directRecipient.role}
recipient={directRecipient}
/>
</div>
</DocumentFlowFormContainerFooter>

View File

@ -0,0 +1,312 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { ArrowLeftIcon, KeyIcon, MailIcon } from 'lucide-react';
import { DateTime } from 'luxon';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Alert } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Form, FormField, FormItem } from '@documenso/ui/primitives/form/form';
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
type FormStep = 'method-selection' | 'code-input';
type TwoFactorMethod = 'email' | 'authenticator';
const ZAccessAuth2FAFormSchema = z.object({
token: z.string().length(6, { message: 'Token must be 6 characters long' }),
});
type TAccessAuth2FAFormSchema = z.infer<typeof ZAccessAuth2FAFormSchema>;
export type AccessAuth2FAFormProps = {
onSubmit: (accessAuthOptions: TRecipientAccessAuth) => void;
token: string;
error?: string | null;
};
export const AccessAuth2FAForm = ({ onSubmit, token, error }: AccessAuth2FAFormProps) => {
const [step, setStep] = useState<FormStep>('method-selection');
const [selectedMethod, setSelectedMethod] = useState<TwoFactorMethod | null>(null);
const [expiresAt, setExpiresAt] = useState<Date | null>(null);
const [millisecondsRemaining, setMillisecondsRemaining] = useState<number | null>(null);
const { _ } = useLingui();
const { toast } = useToast();
const { user } = useRequiredDocumentSigningAuthContext();
const { mutateAsync: request2FAEmail, isPending: isRequesting2FAEmail } =
trpc.document.accessAuth.request2FAEmail.useMutation();
const form = useForm({
resolver: zodResolver(ZAccessAuth2FAFormSchema),
defaultValues: {
token: '',
},
});
const hasAuthenticatorEnabled = user?.twoFactorEnabled === true;
const onMethodSelect = async (method: TwoFactorMethod) => {
setSelectedMethod(method);
if (method === 'email') {
try {
const result = await request2FAEmail({
token: token,
});
setExpiresAt(result.expiresAt);
setMillisecondsRemaining(result.expiresAt.valueOf() - Date.now());
setStep('code-input');
} catch (error) {
toast({
title: _(msg`An error occurred`),
description: _(
msg`We encountered an unknown error while attempting to request the two-factor authentication code. Please try again later.`,
),
variant: 'destructive',
});
return;
}
}
setStep('code-input');
};
const onFormSubmit = (data: TAccessAuth2FAFormSchema) => {
if (!selectedMethod) {
return;
}
// Prepare the auth options for the completion attempt
const accessAuthOptions: TRecipientAccessAuth = {
type: 'TWO_FACTOR_AUTH',
token: data.token, // Just the user's code - backend will validate using method type
method: selectedMethod,
};
onSubmit(accessAuthOptions);
};
const onGoBack = () => {
setStep('method-selection');
setSelectedMethod(null);
setExpiresAt(null);
setMillisecondsRemaining(null);
};
const onResendEmail = async () => {
if (selectedMethod !== 'email') {
return;
}
try {
const result = await request2FAEmail({
token: token,
});
setExpiresAt(result.expiresAt);
setMillisecondsRemaining(result.expiresAt.valueOf() - Date.now());
} catch (error) {
toast({
title: _(msg`An error occurred`),
description: _(
msg`We encountered an unknown error while attempting to request the two-factor authentication code. Please try again later.`,
),
variant: 'destructive',
});
}
};
useEffect(() => {
const interval = setInterval(() => {
if (expiresAt) {
setMillisecondsRemaining(expiresAt.valueOf() - Date.now());
}
}, 1000);
return () => clearInterval(interval);
}, [expiresAt]);
return (
<div className="py-4">
{step === 'method-selection' && (
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold">
<Trans>Choose verification method</Trans>
</h3>
<p className="text-muted-foreground text-sm">
<Trans>Please select how you'd like to receive your verification code.</Trans>
</p>
</div>
{error && (
<Alert variant="destructive" padding="tight" className="text-sm">
{error}
</Alert>
)}
<div className="space-y-3">
<Button
type="button"
variant="outline"
className="flex h-auto w-full justify-start gap-3 p-4"
onClick={async () => onMethodSelect('email')}
disabled={isRequesting2FAEmail}
>
<MailIcon className="h-5 w-5" />
<div className="text-left">
<div className="font-medium">
<Trans>Email verification</Trans>
</div>
<div className="text-muted-foreground text-sm">
<Trans>We'll send a 6-digit code to your email</Trans>
</div>
</div>
</Button>
{hasAuthenticatorEnabled && (
<Button
type="button"
variant="outline"
className="flex h-auto w-full justify-start gap-3 p-4"
onClick={async () => onMethodSelect('authenticator')}
disabled={isRequesting2FAEmail}
>
<KeyIcon className="h-5 w-5" />
<div className="text-left">
<div className="font-medium">
<Trans>Authenticator app</Trans>
</div>
<div className="text-muted-foreground text-sm">
<Trans>Use your authenticator app to generate a code</Trans>
</div>
</div>
</Button>
)}
</div>
</div>
)}
{step === 'code-input' && (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Button type="button" variant="ghost" size="sm" onClick={onGoBack}>
<ArrowLeftIcon className="h-4 w-4" />
</Button>
<h3 className="text-lg font-semibold">
<Trans>Enter verification code</Trans>
</h3>
</div>
<div className="text-muted-foreground text-sm">
{selectedMethod === 'email' ? (
<Trans>
We've sent a 6-digit verification code to your email. Please enter it below to
complete the document.
</Trans>
) : (
<Trans>
Please open your authenticator app and enter the 6-digit code for this document.
</Trans>
)}
</div>
<Form {...form}>
<form
id="access-auth-2fa-form"
className="space-y-4"
onSubmit={form.handleSubmit(onFormSubmit)}
>
<fieldset disabled={isRequesting2FAEmail || form.formState.isSubmitting}>
<FormField
control={form.control}
name="token"
render={({ field }) => (
<FormItem className="flex-1 items-center justify-center">
<PinInput
{...field}
maxLength={6}
autoFocus
inputMode="numeric"
pattern="^\d+$"
aria-label="2FA code"
containerClassName="h-12 justify-center"
>
<PinInputGroup>
<PinInputSlot className="h-12 w-12 text-lg" index={0} />
<PinInputSlot className="h-12 w-12 text-lg" index={1} />
<PinInputSlot className="h-12 w-12 text-lg" index={2} />
<PinInputSlot className="h-12 w-12 text-lg" index={3} />
<PinInputSlot className="h-12 w-12 text-lg" index={4} />
<PinInputSlot className="h-12 w-12 text-lg" index={5} />
</PinInputGroup>
</PinInput>
{expiresAt && millisecondsRemaining !== null && (
<div
className={cn('text-muted-foreground mt-2 text-center text-sm', {
'text-destructive': millisecondsRemaining <= 0,
})}
>
<Trans>
Expires in{' '}
{DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat(
'mm:ss',
)}
</Trans>
</div>
)}
</FormItem>
)}
/>
<div className="mt-4 space-y-2">
<Button
type="submit"
form="access-auth-2fa-form"
className="w-full"
disabled={!form.formState.isValid}
loading={isRequesting2FAEmail || form.formState.isSubmitting}
>
<Trans>Verify & Complete</Trans>
</Button>
{selectedMethod === 'email' && (
<Button
type="button"
variant="ghost"
size="sm"
className="w-full"
onClick={onResendEmail}
loading={isRequesting2FAEmail}
>
<Trans>Resend code</Trans>
</Button>
)}
</div>
</fieldset>
</form>
</Form>
</div>
)}
</div>
);
};

View File

@ -77,7 +77,7 @@ export const DocumentSigningAuthPasskey = ({
});
const { mutateAsync: createPasskeyAuthenticationOptions } =
trpc.auth.createPasskeyAuthenticationOptions.useMutation();
trpc.auth.passkey.createAuthenticationOptions.useMutation();
const [formErrorCode, setFormErrorCode] = useState<string | null>(null);

View File

@ -93,7 +93,7 @@ export const DocumentSigningAuthProvider = ({
[documentAuthOptions, recipient],
);
const passkeyQuery = trpc.auth.findPasskeys.useQuery(
const passkeyQuery = trpc.auth.passkey.find.useQuery(
{
perPage: MAXIMUM_PASSKEYS,
},

View File

@ -2,12 +2,17 @@ import { useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import type { Field } from '@prisma/client';
import type { Field, Recipient } from '@prisma/client';
import { RecipientRole } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import {
type TRecipientAccessAuth,
ZDocumentAccessAuthSchema,
} from '@documenso/lib/types/document-auth';
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import { Button } from '@documenso/ui/primitives/button';
import {
@ -27,15 +32,21 @@ import {
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { AccessAuth2FAForm } from '~/components/general/document-signing/access-auth-2fa-form';
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
export type DocumentSigningCompleteDialogProps = {
isSubmitting: boolean;
documentTitle: string;
fields: Field[];
fieldsValidated: () => void | Promise<void>;
onSignatureComplete: (nextSigner?: { name: string; email: string }) => void | Promise<void>;
role: RecipientRole;
onSignatureComplete: (
nextSigner?: { name: string; email: string },
accessAuthOptions?: TRecipientAccessAuth,
) => void | Promise<void>;
recipient: Pick<Recipient, 'name' | 'email' | 'role' | 'token'>;
disabled?: boolean;
allowDictateNextSigner?: boolean;
defaultNextSigner?: {
@ -47,6 +58,7 @@ export type DocumentSigningCompleteDialogProps = {
const ZNextSignerFormSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email address'),
accessAuthOptions: ZDocumentAccessAuthSchema.optional(),
});
type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
@ -57,7 +69,7 @@ export const DocumentSigningCompleteDialog = ({
fields,
fieldsValidated,
onSignatureComplete,
role,
recipient,
disabled = false,
allowDictateNextSigner = false,
defaultNextSigner,
@ -65,6 +77,11 @@ export const DocumentSigningCompleteDialog = ({
const [showDialog, setShowDialog] = useState(false);
const [isEditingNextSigner, setIsEditingNextSigner] = useState(false);
const [showTwoFactorForm, setShowTwoFactorForm] = useState(false);
const [twoFactorValidationError, setTwoFactorValidationError] = useState<string | null>(null);
const { derivedRecipientAccessAuth, user } = useRequiredDocumentSigningAuthContext();
const form = useForm<TNextSignerFormSchema>({
resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined,
defaultValues: {
@ -75,6 +92,11 @@ export const DocumentSigningCompleteDialog = ({
const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]);
const completionRequires2FA = useMemo(
() => derivedRecipientAccessAuth.includes('TWO_FACTOR_AUTH'),
[derivedRecipientAccessAuth],
);
const handleOpenChange = (open: boolean) => {
if (form.formState.isSubmitting || !isComplete) {
return;
@ -93,16 +115,43 @@ export const DocumentSigningCompleteDialog = ({
const onFormSubmit = async (data: TNextSignerFormSchema) => {
try {
if (allowDictateNextSigner && data.name && data.email) {
await onSignatureComplete({ name: data.name, email: data.email });
} else {
await onSignatureComplete();
// Check if 2FA is required
if (completionRequires2FA && !data.accessAuthOptions) {
setShowTwoFactorForm(true);
return;
}
const nextSigner =
allowDictateNextSigner && data.name && data.email
? { name: data.name, email: data.email }
: undefined;
await onSignatureComplete(nextSigner, data.accessAuthOptions);
} catch (error) {
console.error('Error completing signature:', error);
const err = AppError.parseError(error);
if (AppErrorCode.TWO_FACTOR_AUTH_FAILED === err.code) {
// This was a 2FA validation failure - show the 2FA dialog again with error
form.setValue('accessAuthOptions', undefined);
setTwoFactorValidationError('Invalid verification code. Please try again.');
setShowTwoFactorForm(true);
return;
}
}
};
const onTwoFactorFormSubmit = (validatedAuthOptions: TRecipientAccessAuth) => {
form.setValue('accessAuthOptions', validatedAuthOptions);
setShowTwoFactorForm(false);
setTwoFactorValidationError(null);
// Now trigger the form submission with auth options
void form.handleSubmit(onFormSubmit)();
};
const isNextSignerValid = !allowDictateNextSigner || (form.watch('name') && form.watch('email'));
return (
@ -116,7 +165,7 @@ export const DocumentSigningCompleteDialog = ({
loading={isSubmitting}
disabled={disabled}
>
{match({ isComplete, role })
{match({ isComplete, role: recipient.role })
.with({ isComplete: false }, () => <Trans>Next field</Trans>)
.with({ isComplete: true, role: RecipientRole.APPROVER }, () => <Trans>Approve</Trans>)
.with({ isComplete: true, role: RecipientRole.VIEWER }, () => (
@ -128,184 +177,194 @@ export const DocumentSigningCompleteDialog = ({
</DialogTrigger>
<DialogContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
<DialogTitle>
<div className="text-foreground text-xl font-semibold">
{match(role)
.with(RecipientRole.VIEWER, () => <Trans>Complete Viewing</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Complete Signing</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Complete Approval</Trans>)
.with(RecipientRole.CC, () => <Trans>Complete Viewing</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Complete Assisting</Trans>)
.exhaustive()}
</div>
</DialogTitle>
<div className="text-muted-foreground max-w-[50ch]">
{match(role)
.with(RecipientRole.VIEWER, () => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete viewing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
))
.with(RecipientRole.SIGNER, () => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete signing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
))
.with(RecipientRole.APPROVER, () => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete approving{' '}
<span className="inline-block max-w-[11rem] truncate align-baseline">
"{documentTitle}"
</span>
.
</span>
<br /> Are you sure?
</Trans>
</span>
))
.otherwise(() => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete viewing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
))}
</div>
{allowDictateNextSigner && (
<div className="mt-4 flex flex-col gap-4">
{!isEditingNextSigner && (
<div>
<p className="text-muted-foreground text-sm">
The next recipient to sign this document will be{' '}
<span className="font-semibold">{form.watch('name')}</span> (
<span className="font-semibold">{form.watch('email')}</span>).
</p>
<Button
type="button"
className="mt-2"
variant="outline"
size="sm"
onClick={() => setIsEditingNextSigner((prev) => !prev)}
>
<Trans>Update Recipient</Trans>
</Button>
</div>
)}
{isEditingNextSigner && (
<div className="flex flex-col gap-4 md:flex-row">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
className="mt-2"
placeholder="Enter the next signer's name"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
type="email"
className="mt-2"
placeholder="Enter the next signer's email"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
)}
<DocumentSigningDisclosure className="mt-4" />
<DialogFooter className="mt-4">
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
className="flex-1"
variant="secondary"
onClick={() => setShowDialog(false)}
disabled={form.formState.isSubmitting}
>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
className="flex-1"
disabled={!isComplete || !isNextSignerValid}
loading={form.formState.isSubmitting}
>
{match(role)
.with(RecipientRole.VIEWER, () => <Trans>Mark as Viewed</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Sign</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Approve</Trans>)
.with(RecipientRole.CC, () => <Trans>Mark as Viewed</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Complete</Trans>)
{!showTwoFactorForm && (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
<DialogTitle>
<div className="text-foreground text-xl font-semibold">
{match(recipient.role)
.with(RecipientRole.VIEWER, () => <Trans>Complete Viewing</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Complete Signing</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Complete Approval</Trans>)
.with(RecipientRole.CC, () => <Trans>Complete Viewing</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Complete Assisting</Trans>)
.exhaustive()}
</Button>
</div>
</DialogTitle>
<div className="text-muted-foreground max-w-[50ch]">
{match(recipient.role)
.with(RecipientRole.VIEWER, () => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete viewing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
))
.with(RecipientRole.SIGNER, () => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete signing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
))
.with(RecipientRole.APPROVER, () => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete approving{' '}
<span className="inline-block max-w-[11rem] truncate align-baseline">
"{documentTitle}"
</span>
.
</span>
<br /> Are you sure?
</Trans>
</span>
))
.otherwise(() => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete viewing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
))}
</div>
</DialogFooter>
</fieldset>
</form>
</Form>
{allowDictateNextSigner && (
<div className="mt-4 flex flex-col gap-4">
{!isEditingNextSigner && (
<div>
<p className="text-muted-foreground text-sm">
The next recipient to sign this document will be{' '}
<span className="font-semibold">{form.watch('name')}</span> (
<span className="font-semibold">{form.watch('email')}</span>).
</p>
<Button
type="button"
className="mt-2"
variant="outline"
size="sm"
onClick={() => setIsEditingNextSigner((prev) => !prev)}
>
<Trans>Update Recipient</Trans>
</Button>
</div>
)}
{isEditingNextSigner && (
<div className="flex flex-col gap-4 md:flex-row">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
className="mt-2"
placeholder="Enter the next signer's name"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
type="email"
className="mt-2"
placeholder="Enter the next signer's email"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
)}
<DocumentSigningDisclosure className="mt-4" />
<DialogFooter className="mt-4">
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
className="flex-1"
variant="secondary"
onClick={() => setShowDialog(false)}
disabled={form.formState.isSubmitting}
>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
className="flex-1"
disabled={!isComplete || !isNextSignerValid}
loading={form.formState.isSubmitting}
>
{match(recipient.role)
.with(RecipientRole.VIEWER, () => <Trans>Mark as Viewed</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Sign</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Approve</Trans>)
.with(RecipientRole.CC, () => <Trans>Mark as Viewed</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Complete</Trans>)
.exhaustive()}
</Button>
</div>
</DialogFooter>
</fieldset>
</form>
</Form>
)}
{showTwoFactorForm && (
<AccessAuth2FAForm
token={recipient.token}
error={twoFactorValidationError}
onSubmit={onTwoFactorFormSubmit}
/>
)}
</DialogContent>
</Dialog>
);

View File

@ -7,14 +7,11 @@ import { type Field, FieldType, type Recipient, RecipientRole } from '@prisma/cl
import { Controller, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import { sortFieldsByPosition } from '@documenso/lib/utils/fields';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { trpc } from '@documenso/trpc/react';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { Button } from '@documenso/ui/primitives/button';
import { Input } from '@documenso/ui/primitives/input';
@ -34,29 +31,33 @@ export type DocumentSigningFormProps = {
document: DocumentAndSender;
recipient: Recipient;
fields: Field[];
redirectUrl?: string | null;
isRecipientsTurn: boolean;
allRecipients?: RecipientWithFields[];
setSelectedSignerId?: (id: number | null) => void;
completeDocument: (options: {
accessAuthOptions?: TRecipientAccessAuth;
nextSigner?: { email: string; name: string };
}) => Promise<void>;
isSubmitting: boolean;
fieldsValidated: () => void;
nextRecipient?: RecipientWithFields;
};
export const DocumentSigningForm = ({
document,
recipient,
fields,
redirectUrl,
isRecipientsTurn,
allRecipients = [],
setSelectedSignerId,
completeDocument,
isSubmitting,
fieldsValidated,
nextRecipient,
}: DocumentSigningFormProps) => {
const { sessionData } = useOptionalSession();
const user = sessionData?.user;
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const analytics = useAnalytics();
const assistantSignersId = useId();
@ -66,21 +67,12 @@ export const DocumentSigningForm = ({
const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false);
const [isAssistantSubmitting, setIsAssistantSubmitting] = useState(false);
const {
mutateAsync: completeDocumentWithToken,
isPending,
isSuccess,
} = trpc.recipient.completeDocumentWithToken.useMutation();
const assistantForm = useForm<{ selectedSignerId: number | undefined }>({
defaultValues: {
selectedSignerId: undefined,
},
});
// Keep the loading state going if successful since the redirect may take some time.
const isSubmitting = isPending || isSuccess;
const fieldsRequiringValidation = useMemo(
() => fields.filter(isFieldUnsignedAndRequired),
[fields],
@ -96,9 +88,9 @@ export const DocumentSigningForm = ({
return fieldsRequiringValidation.filter((field) => field.recipientId === recipient.id);
}, [fieldsRequiringValidation, recipient]);
const fieldsValidated = () => {
const localFieldsValidated = () => {
setValidateUninsertedFields(true);
validateFieldsInserted(fieldsRequiringValidation);
fieldsValidated();
};
const onAssistantFormSubmit = () => {
@ -113,7 +105,7 @@ export const DocumentSigningForm = ({
setIsAssistantSubmitting(true);
try {
await completeDocument(undefined, nextSigner);
await completeDocument({ nextSigner });
} catch (err) {
toast({
title: 'Error',
@ -126,55 +118,6 @@ export const DocumentSigningForm = ({
}
};
const completeDocument = async (
authOptions?: TRecipientActionAuth,
nextSigner?: { email: string; name: string },
) => {
const payload = {
token: recipient.token,
documentId: document.id,
authOptions,
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
};
await completeDocumentWithToken(payload);
analytics.capture('App: Recipient has completed signing', {
signerId: recipient.id,
documentId: document.id,
timestamp: new Date().toISOString(),
});
if (redirectUrl) {
window.location.href = redirectUrl;
} else {
await navigate(`/sign/${recipient.token}/complete`);
}
};
const nextRecipient = useMemo(() => {
if (
!document.documentMeta?.signingOrder ||
document.documentMeta.signingOrder !== 'SEQUENTIAL'
) {
return undefined;
}
const sortedRecipients = allRecipients.sort((a, b) => {
// Sort by signingOrder first (nulls last), then by id
if (a.signingOrder === null && b.signingOrder === null) return a.id - b.id;
if (a.signingOrder === null) return 1;
if (b.signingOrder === null) return -1;
if (a.signingOrder === b.signingOrder) return a.id - b.id;
return a.signingOrder - b.signingOrder;
});
const currentIndex = sortedRecipients.findIndex((r) => r.id === recipient.id);
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
? sortedRecipients[currentIndex + 1]
: undefined;
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
return (
<div className="flex h-full flex-col">
{validateUninsertedFields && uninsertedFields[0] && (
@ -205,11 +148,11 @@ export const DocumentSigningForm = ({
isSubmitting={isSubmitting}
documentTitle={document.title}
fields={fields}
fieldsValidated={fieldsValidated}
onSignatureComplete={async (nextSigner) => {
await completeDocument(undefined, nextSigner);
}}
role={recipient.role}
fieldsValidated={localFieldsValidated}
onSignatureComplete={async (nextSigner, accessAuthOptions) =>
completeDocument({ nextSigner, accessAuthOptions })
}
recipient={recipient}
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
defaultNextSigner={
nextRecipient
@ -364,12 +307,15 @@ export const DocumentSigningForm = ({
isSubmitting={isSubmitting || isAssistantSubmitting}
documentTitle={document.title}
fields={fields}
fieldsValidated={fieldsValidated}
fieldsValidated={localFieldsValidated}
disabled={!isRecipientsTurn}
onSignatureComplete={async (nextSigner) => {
await completeDocument(undefined, nextSigner);
}}
role={recipient.role}
onSignatureComplete={async (nextSigner, accessAuthOptions) =>
completeDocument({
accessAuthOptions,
nextSigner,
})
}
recipient={recipient}
allowDictateNextSigner={
nextRecipient && document.documentMeta?.allowDictateNextSigner
}

View File

@ -1,15 +1,18 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import type { Field } from '@prisma/client';
import { FieldType, RecipientRole } from '@prisma/client';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { match } from 'ts-pattern';
import { useNavigate } from 'react-router';
import { P, match } from 'ts-pattern';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
import {
ZCheckboxFieldMeta,
ZDropdownFieldMeta,
@ -18,8 +21,11 @@ import {
ZTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import type { CompletedField } from '@documenso/lib/types/fields';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { trpc } from '@documenso/trpc/react';
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
@ -40,6 +46,8 @@ import { DocumentSigningRejectDialog } from '~/components/general/document-signi
import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field';
import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider';
export type DocumentSigningPageViewProps = {
@ -63,9 +71,64 @@ export const DocumentSigningPageView = ({
}: DocumentSigningPageViewProps) => {
const { documentData, documentMeta } = document;
const { derivedRecipientAccessAuth, user: authUser } = useRequiredDocumentSigningAuthContext();
const hasAuthenticator = authUser?.twoFactorEnabled
? authUser.twoFactorEnabled && authUser.email === recipient.email
: false;
const navigate = useNavigate();
const analytics = useAnalytics();
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
const [isExpanded, setIsExpanded] = useState(false);
const {
mutateAsync: completeDocumentWithToken,
isPending,
isSuccess,
} = trpc.recipient.completeDocumentWithToken.useMutation();
// Keep the loading state going if successful since the redirect may take some time.
const isSubmitting = isPending || isSuccess;
const fieldsRequiringValidation = useMemo(
() => fields.filter(isFieldUnsignedAndRequired),
[fields],
);
const fieldsValidated = () => {
validateFieldsInserted(fieldsRequiringValidation);
};
const completeDocument = async (options: {
accessAuthOptions?: TRecipientAccessAuth;
nextSigner?: { email: string; name: string };
}) => {
const { accessAuthOptions, nextSigner } = options;
const payload = {
token: recipient.token,
documentId: document.id,
accessAuthOptions,
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
};
await completeDocumentWithToken(payload);
analytics.capture('App: Recipient has completed signing', {
signerId: recipient.id,
documentId: document.id,
timestamp: new Date().toISOString(),
});
if (documentMeta?.redirectUrl) {
window.location.href = documentMeta.redirectUrl;
} else {
await navigate(`/sign/${recipient.token}/complete`);
}
};
let senderName = document.user.name ?? '';
let senderEmail = `(${document.user.email})`;
@ -78,9 +141,42 @@ export const DocumentSigningPageView = ({
const targetSigner =
recipient.role === RecipientRole.ASSISTANT && selectedSigner ? selectedSigner : null;
const nextRecipient = useMemo(() => {
if (!documentMeta?.signingOrder || documentMeta.signingOrder !== 'SEQUENTIAL') {
return undefined;
}
const sortedRecipients = allRecipients.sort((a, b) => {
// Sort by signingOrder first (nulls last), then by id
if (a.signingOrder === null && b.signingOrder === null) return a.id - b.id;
if (a.signingOrder === null) return 1;
if (b.signingOrder === null) return -1;
if (a.signingOrder === b.signingOrder) return a.id - b.id;
return a.signingOrder - b.signingOrder;
});
const currentIndex = sortedRecipients.findIndex((r) => r.id === recipient.id);
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
? sortedRecipients[currentIndex + 1]
: undefined;
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
const highestPageNumber = Math.max(...fields.map((field) => field.page));
const pendingFields = fieldsRequiringValidation.filter((field) => !field.inserted);
const hasPendingFields = pendingFields.length > 0;
return (
<DocumentSigningRecipientProvider recipient={recipient} targetSigner={targetSigner}>
<div className="mx-auto w-full max-w-screen-xl sm:px-6">
{document.team.teamGlobalSettings.brandingEnabled &&
document.team.teamGlobalSettings.brandingLogo && (
<img
src={`/api/branding/logo/team/${document.teamId}`}
alt={`${document.team.name}'s Logo`}
className="mb-4 h-12 w-12 md:mb-2"
/>
)}
<h1
className="block max-w-[20rem] truncate text-2xl font-semibold sm:mt-4 md:max-w-[30rem] md:text-3xl"
title={document.title}
@ -163,19 +259,55 @@ export const DocumentSigningPageView = ({
.otherwise(() => null)}
</h3>
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
{isExpanded ? (
<LucideChevronDown
className="text-muted-foreground h-5 w-5"
{match({ hasPendingFields, isExpanded, role: recipient.role })
.with(
{
hasPendingFields: false,
role: P.not(RecipientRole.ASSISTANT),
isExpanded: false,
},
() => (
<div className="md:hidden">
<DocumentSigningCompleteDialog
isSubmitting={isSubmitting}
documentTitle={document.title}
fields={fields}
fieldsValidated={fieldsValidated}
disabled={!isRecipientsTurn}
onSignatureComplete={async (nextSigner) =>
completeDocument({ nextSigner })
}
recipient={recipient}
allowDictateNextSigner={
nextRecipient && documentMeta?.allowDictateNextSigner
}
defaultNextSigner={
nextRecipient
? { name: nextRecipient.name, email: nextRecipient.email }
: undefined
}
/>
</div>
),
)
.with({ isExpanded: true }, () => (
<Button
variant="outline"
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
onClick={() => setIsExpanded(false)}
/>
) : (
<LucideChevronUp
className="text-muted-foreground h-5 w-5"
>
<LucideChevronDown className="text-muted-foreground dark:text-background h-5 w-5" />
</Button>
))
.otherwise(() => (
<Button
variant="outline"
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
onClick={() => setIsExpanded(true)}
/>
)}
</Button>
>
<LucideChevronUp className="text-muted-foreground dark:text-background h-5 w-5" />
</Button>
))}
</div>
<div className="hidden group-data-[expanded]/document-widget:block md:block">
@ -204,10 +336,13 @@ export const DocumentSigningPageView = ({
document={document}
recipient={recipient}
fields={fields}
redirectUrl={documentMeta?.redirectUrl}
isRecipientsTurn={isRecipientsTurn}
allRecipients={allRecipients}
setSelectedSignerId={setSelectedSignerId}
completeDocument={completeDocument}
isSubmitting={isSubmitting}
fieldsValidated={fieldsValidated}
nextRecipient={nextRecipient}
/>
</div>
</div>
@ -224,7 +359,9 @@ export const DocumentSigningPageView = ({
<DocumentSigningAutoSign recipient={recipient} fields={fields} />
)}
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
<ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
>
{fields
.filter(
(field) =>

View File

@ -21,7 +21,7 @@ export const DocumentAuditLogDownloadButton = ({
const { _ } = useLingui();
const { mutateAsync: downloadAuditLogs, isPending } =
trpc.document.downloadAuditLogs.useMutation();
trpc.document.auditLog.download.useMutation();
const onDownloadAuditLogsClick = async () => {
try {

View File

@ -4,7 +4,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useDropzone } from 'react-dropzone';
import { ErrorCode, type FileRejection, useDropzone } from 'react-dropzone';
import { Link, useNavigate, useParams } from 'react-router';
import { match } from 'ts-pattern';
@ -49,7 +49,7 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
const { quota, remaining, refreshLimits } = useLimits();
const { mutateAsync: createDocument } = trpc.document.createDocument.useMutation();
const { mutateAsync: createDocument } = trpc.document.create.useMutation();
const isUploadDisabled = remaining.documents === 0 || !user.emailVerified;
@ -108,15 +108,51 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
}
};
const onFileDropRejected = () => {
const onFileDropRejected = (fileRejections: FileRejection[]) => {
if (!fileRejections.length) {
return;
}
// Since users can only upload only one file (no multi-upload), we only handle the first file rejection
const { file, errors } = fileRejections[0];
if (!errors.length) {
return;
}
const errorNodes = errors.map((error, index) => (
<span key={index} className="block">
{match(error.code)
.with(ErrorCode.FileTooLarge, () => (
<Trans>File is larger than {APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB</Trans>
))
.with(ErrorCode.FileInvalidType, () => <Trans>Only PDF files are allowed</Trans>)
.with(ErrorCode.FileTooSmall, () => <Trans>File is too small</Trans>)
.with(ErrorCode.TooManyFiles, () => (
<Trans>Only one file can be uploaded at a time</Trans>
))
.otherwise(() => (
<Trans>Unknown error</Trans>
))}
</span>
));
const description = (
<>
<span className="font-medium">
{file.name} <Trans>couldn't be uploaded:</Trans>
</span>
{errorNodes}
</>
);
toast({
title: _(msg`Your document failed to upload.`),
description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`),
title: _(msg`Upload failed`),
description,
duration: 5000,
variant: 'destructive',
});
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({
accept: {
'application/pdf': ['.pdf'],
@ -129,8 +165,8 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
void onFileDrop(acceptedFile);
}
},
onDropRejected: () => {
void onFileDropRejected();
onDropRejected: (fileRejections) => {
onFileDropRejected(fileRejections);
},
noClick: true,
noDragEventsBubbling: true,

View File

@ -59,23 +59,22 @@ export const DocumentEditForm = ({
const utils = trpc.useUtils();
const { data: document, refetch: refetchDocument } =
trpc.document.getDocumentWithDetailsById.useQuery(
{
documentId: initialDocument.id,
},
{
initialData: initialDocument,
...SKIP_QUERY_BATCH_META,
},
);
const { data: document, refetch: refetchDocument } = trpc.document.get.useQuery(
{
documentId: initialDocument.id,
},
{
initialData: initialDocument,
...SKIP_QUERY_BATCH_META,
},
);
const { recipients, fields } = document;
const { mutateAsync: updateDocument } = trpc.document.updateDocument.useMutation({
const { mutateAsync: updateDocument } = trpc.document.update.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.document.getDocumentWithDetailsById.setData(
utils.document.get.setData(
{
documentId: initialDocument.id,
},
@ -84,23 +83,10 @@ export const DocumentEditForm = ({
},
});
const { mutateAsync: setSigningOrderForDocument } =
trpc.document.setSigningOrderForDocument.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.document.getDocumentWithDetailsById.setData(
{
documentId: initialDocument.id,
},
(oldData) => ({ ...(oldData || initialDocument), ...newData, id: Number(newData.id) }),
);
},
});
const { mutateAsync: addFields } = trpc.field.addFields.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: ({ fields: newFields }) => {
utils.document.getDocumentWithDetailsById.setData(
utils.document.get.setData(
{
documentId: initialDocument.id,
},
@ -112,7 +98,7 @@ export const DocumentEditForm = ({
const { mutateAsync: setRecipients } = trpc.recipient.setDocumentRecipients.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: ({ recipients: newRecipients }) => {
utils.document.getDocumentWithDetailsById.setData(
utils.document.get.setData(
{
documentId: initialDocument.id,
},
@ -121,10 +107,10 @@ export const DocumentEditForm = ({
},
});
const { mutateAsync: sendDocument } = trpc.document.sendDocument.useMutation({
const { mutateAsync: sendDocument } = trpc.document.distribute.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.document.getDocumentWithDetailsById.setData(
utils.document.get.setData(
{
documentId: initialDocument.id,
},
@ -173,34 +159,37 @@ export const DocumentEditForm = ({
return initialStep;
});
const saveSettingsData = async (data: TAddSettingsFormSchema) => {
const { timezone, dateFormat, redirectUrl, language, signatureTypes } = data.meta;
const parsedGlobalAccessAuth = z
.array(ZDocumentAccessAuthTypesSchema)
.safeParse(data.globalAccessAuth);
return updateDocument({
documentId: document.id,
data: {
title: data.title,
externalId: data.externalId || null,
visibility: data.visibility,
globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [],
globalActionAuth: data.globalActionAuth ?? [],
},
meta: {
timezone,
dateFormat,
redirectUrl,
language: isValidLanguageCode(language) ? language : undefined,
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
},
});
};
const onAddSettingsFormSubmit = async (data: TAddSettingsFormSchema) => {
try {
const { timezone, dateFormat, redirectUrl, language, signatureTypes } = data.meta;
const parsedGlobalAccessAuth = z
.array(ZDocumentAccessAuthTypesSchema)
.safeParse(data.globalAccessAuth);
await updateDocument({
documentId: document.id,
data: {
title: data.title,
externalId: data.externalId || null,
visibility: data.visibility,
globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [],
globalActionAuth: data.globalActionAuth ?? [],
},
meta: {
timezone,
dateFormat,
redirectUrl,
language: isValidLanguageCode(language) ? language : undefined,
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
},
});
await saveSettingsData(data);
setStep('signers');
} catch (err) {
console.error(err);
@ -213,18 +202,50 @@ export const DocumentEditForm = ({
}
};
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
const onAddSettingsFormAutoSave = async (data: TAddSettingsFormSchema) => {
try {
await Promise.all([
setSigningOrderForDocument({
documentId: document.id,
signingOrder: data.signingOrder,
}),
await saveSettingsData(data);
} catch (err) {
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while auto-saving the document settings.`),
variant: 'destructive',
});
}
};
const saveSignersData = async (data: TAddSignersFormSchema) => {
return Promise.all([
updateDocument({
documentId: document.id,
meta: {
allowDictateNextSigner: data.allowDictateNextSigner,
signingOrder: data.signingOrder,
},
}),
setRecipients({
documentId: document.id,
recipients: data.signers.map((signer) => ({
...signer,
// Explicitly set to null to indicate we want to remove auth if required.
actionAuth: signer.actionAuth ?? [],
})),
}),
]);
};
const onAddSignersFormAutoSave = async (data: TAddSignersFormSchema) => {
try {
// For autosave, we need to return the recipients response for form state sync
const [, recipientsResponse] = await Promise.all([
updateDocument({
documentId: document.id,
meta: {
allowDictateNextSigner: data.allowDictateNextSigner,
signingOrder: data.signingOrder,
},
}),
@ -238,6 +259,24 @@ export const DocumentEditForm = ({
}),
]);
return recipientsResponse;
} catch (err) {
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while adding signers.`),
variant: 'destructive',
});
throw err; // Re-throw so the autosave hook can handle the error
}
};
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
try {
await saveSignersData(data);
setStep('fields');
} catch (err) {
console.error(err);
@ -250,12 +289,16 @@ export const DocumentEditForm = ({
}
};
const saveFieldsData = async (data: TAddFieldsFormSchema) => {
return addFields({
documentId: document.id,
fields: data.fields,
});
};
const onAddFieldsFormSubmit = async (data: TAddFieldsFormSchema) => {
try {
await addFields({
documentId: document.id,
fields: data.fields,
});
await saveFieldsData(data);
// Clear all field data from localStorage
for (let i = 0; i < localStorage.length; i++) {
@ -277,24 +320,60 @@ export const DocumentEditForm = ({
}
};
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
const onAddFieldsFormAutoSave = async (data: TAddFieldsFormSchema) => {
try {
await saveFieldsData(data);
// Don't clear localStorage on auto-save, only on explicit submit
} catch (err) {
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while auto-saving the fields.`),
variant: 'destructive',
});
}
};
const saveSubjectData = async (data: TAddSubjectFormSchema) => {
const { subject, message, distributionMethod, emailId, emailReplyTo, emailSettings } =
data.meta;
try {
await sendDocument({
documentId: document.id,
meta: {
subject,
message,
distributionMethod,
emailId,
emailReplyTo: emailReplyTo || null,
emailSettings: emailSettings,
},
});
return updateDocument({
documentId: document.id,
meta: {
subject,
message,
distributionMethod,
emailId,
emailReplyTo,
emailSettings: emailSettings,
},
});
};
if (distributionMethod === DocumentDistributionMethod.EMAIL) {
const sendDocumentWithSubject = async (data: TAddSubjectFormSchema) => {
const { subject, message, distributionMethod, emailId, emailReplyTo, emailSettings } =
data.meta;
return sendDocument({
documentId: document.id,
meta: {
subject,
message,
distributionMethod,
emailId,
emailReplyTo: emailReplyTo || null,
emailSettings,
},
});
};
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
try {
await sendDocumentWithSubject(data);
if (data.meta.distributionMethod === DocumentDistributionMethod.EMAIL) {
toast({
title: _(msg`Document sent`),
description: _(msg`Your document has been sent successfully.`),
@ -322,6 +401,21 @@ export const DocumentEditForm = ({
}
};
const onAddSubjectFormAutoSave = async (data: TAddSubjectFormSchema) => {
try {
// Save form data without sending the document
await saveSubjectData(data);
} catch (err) {
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while auto-saving the subject form.`),
variant: 'destructive',
});
}
};
const currentDocumentFlow = documentFlow[step];
/**
@ -367,25 +461,28 @@ export const DocumentEditForm = ({
fields={fields}
isDocumentPdfLoaded={isDocumentPdfLoaded}
onSubmit={onAddSettingsFormSubmit}
onAutoSave={onAddSettingsFormAutoSave}
/>
<AddSignersFormPartial
key={recipients.length}
key={document.id}
documentFlow={documentFlow.signers}
recipients={recipients}
signingOrder={document.documentMeta?.signingOrder}
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
fields={fields}
onSubmit={onAddSignersFormSubmit}
onAutoSave={onAddSignersFormAutoSave}
isDocumentPdfLoaded={isDocumentPdfLoaded}
/>
<AddFieldsFormPartial
key={fields.length}
key={document.id}
documentFlow={documentFlow.fields}
recipients={recipients}
fields={fields}
onSubmit={onAddFieldsFormSubmit}
onAutoSave={onAddFieldsFormAutoSave}
isDocumentPdfLoaded={isDocumentPdfLoaded}
teamId={team.id}
/>
@ -397,6 +494,7 @@ export const DocumentEditForm = ({
recipients={recipients}
fields={fields}
onSubmit={onAddSubjectFormSubmit}
onAutoSave={onAddSubjectFormAutoSave}
isDocumentPdfLoaded={isDocumentPdfLoaded}
/>
</Stepper>

View File

@ -42,7 +42,7 @@ export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps
const onDownloadClick = async () => {
try {
const documentWithData = await trpcClient.document.getDocumentById.query(
const documentWithData = await trpcClient.document.get.query(
{
documentId: document.id,
},

View File

@ -71,7 +71,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
const onDownloadClick = async () => {
try {
const documentWithData = await trpcClient.document.getDocumentById.query(
const documentWithData = await trpcClient.document.get.query(
{
documentId: document.id,
},
@ -100,7 +100,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
const onDownloadOriginalClick = async () => {
try {
const documentWithData = await trpcClient.document.getDocumentById.query(
const documentWithData = await trpcClient.document.get.query(
{
documentId: document.id,
},

View File

@ -32,7 +32,7 @@ export const DocumentPageViewRecentActivity = ({
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = trpc.document.findDocumentAuditLogs.useInfiniteQuery(
} = trpc.document.auditLog.find.useInfiniteQuery(
{
documentId,
filterForRecentActivity: true,

View File

@ -52,7 +52,7 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
const [isLoading, setIsLoading] = useState(false);
const { mutateAsync: createDocument } = trpc.document.createDocument.useMutation();
const { mutateAsync: createDocument } = trpc.document.create.useMutation();
const disabledMessage = useMemo(() => {
if (organisation.subscription && remaining.documents === 0) {

View File

@ -54,7 +54,7 @@ export const FolderCard = ({
};
return (
<Link to={formatPath()} key={folder.id}>
<Link to={formatPath()} data-folder-id={folder.id} data-folder-name={folder.name}>
<Card className="hover:bg-muted/50 border-border h-full border transition-all">
<CardContent className="p-4">
<div className="flex min-w-0 items-center gap-3">

View File

@ -28,7 +28,7 @@ export const LegacyFieldWarningPopover = ({
const { mutateAsync: updateTemplate, isPending: isUpdatingTemplate } =
trpc.template.updateTemplate.useMutation();
const { mutateAsync: updateDocument, isPending: isUpdatingDocument } =
trpc.document.updateDocument.useMutation();
trpc.document.update.useMutation();
const onUpdateFieldsClick = async () => {
if (type === 'document') {

View File

@ -4,8 +4,9 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useDropzone } from 'react-dropzone';
import { ErrorCode, type FileRejection, useDropzone } from 'react-dropzone';
import { useNavigate, useParams } from 'react-router';
import { match } from 'ts-pattern';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
@ -67,10 +68,47 @@ export const TemplateDropZoneWrapper = ({ children, className }: TemplateDropZon
}
};
const onFileDropRejected = () => {
const onFileDropRejected = (fileRejections: FileRejection[]) => {
if (!fileRejections.length) {
return;
}
// Since users can only upload only one file (no multi-upload), we only handle the first file rejection
const { file, errors } = fileRejections[0];
if (!errors.length) {
return;
}
const errorNodes = errors.map((error, index) => (
<span key={index} className="block">
{match(error.code)
.with(ErrorCode.FileTooLarge, () => (
<Trans>File is larger than {APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB</Trans>
))
.with(ErrorCode.FileInvalidType, () => <Trans>Only PDF files are allowed</Trans>)
.with(ErrorCode.FileTooSmall, () => <Trans>File is too small</Trans>)
.with(ErrorCode.TooManyFiles, () => (
<Trans>Only one file can be uploaded at a time</Trans>
))
.otherwise(() => (
<Trans>Unknown error</Trans>
))}
</span>
));
const description = (
<>
<span className="font-medium">
{file.name} <Trans>couldn't be uploaded:</Trans>
</span>
{errorNodes}
</>
);
toast({
title: _(msg`Your template failed to upload.`),
description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`),
title: _(msg`Upload failed`),
description,
duration: 5000,
variant: 'destructive',
});
@ -88,8 +126,8 @@ export const TemplateDropZoneWrapper = ({ children, className }: TemplateDropZon
void onFileDrop(acceptedFile);
}
},
onDropRejected: () => {
void onFileDropRejected();
onDropRejected: (fileRejections) => {
onFileDropRejected(fileRejections);
},
noClick: true,
noDragEventsBubbling: true,

View File

@ -124,32 +124,36 @@ export const TemplateEditForm = ({
},
});
const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => {
const saveSettingsData = async (data: TAddTemplateSettingsFormSchema) => {
const { signatureTypes } = data.meta;
const parsedGlobalAccessAuth = z
.array(ZDocumentAccessAuthTypesSchema)
.safeParse(data.globalAccessAuth);
return updateTemplateSettings({
templateId: template.id,
data: {
title: data.title,
externalId: data.externalId || null,
visibility: data.visibility,
globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [],
globalActionAuth: data.globalActionAuth ?? [],
},
meta: {
...data.meta,
emailReplyTo: data.meta.emailReplyTo || null,
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
language: isValidLanguageCode(data.meta.language) ? data.meta.language : undefined,
},
});
};
const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => {
try {
await updateTemplateSettings({
templateId: template.id,
data: {
title: data.title,
externalId: data.externalId || null,
visibility: data.visibility,
globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [],
globalActionAuth: data.globalActionAuth ?? [],
},
meta: {
...data.meta,
emailReplyTo: data.meta.emailReplyTo || null,
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
language: isValidLanguageCode(data.meta.language) ? data.meta.language : undefined,
},
});
await saveSettingsData(data);
setStep('signers');
} catch (err) {
@ -163,24 +167,44 @@ export const TemplateEditForm = ({
}
};
const onAddSettingsFormAutoSave = async (data: TAddTemplateSettingsFormSchema) => {
try {
await saveSettingsData(data);
} catch (err) {
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while auto-saving the template settings.`),
variant: 'destructive',
});
}
};
const saveTemplatePlaceholderData = async (data: TAddTemplatePlacholderRecipientsFormSchema) => {
const [, recipients] = await Promise.all([
updateTemplateSettings({
templateId: template.id,
meta: {
signingOrder: data.signingOrder,
allowDictateNextSigner: data.allowDictateNextSigner,
},
}),
setRecipients({
templateId: template.id,
recipients: data.signers,
}),
]);
return recipients;
};
const onAddTemplatePlaceholderFormSubmit = async (
data: TAddTemplatePlacholderRecipientsFormSchema,
) => {
try {
await Promise.all([
updateTemplateSettings({
templateId: template.id,
meta: {
signingOrder: data.signingOrder,
allowDictateNextSigner: data.allowDictateNextSigner,
},
}),
setRecipients({
templateId: template.id,
recipients: data.signers,
}),
]);
await saveTemplatePlaceholderData(data);
setStep('fields');
} catch (err) {
@ -192,12 +216,48 @@ export const TemplateEditForm = ({
}
};
const onAddTemplatePlaceholderFormAutoSave = async (
data: TAddTemplatePlacholderRecipientsFormSchema,
) => {
try {
return await saveTemplatePlaceholderData(data);
} catch (err) {
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while auto-saving the template placeholders.`),
variant: 'destructive',
});
throw err;
}
};
const saveFieldsData = async (data: TAddTemplateFieldsFormSchema) => {
return addTemplateFields({
templateId: template.id,
fields: data.fields,
});
};
const onAddFieldsFormAutoSave = async (data: TAddTemplateFieldsFormSchema) => {
try {
await saveFieldsData(data);
} catch (err) {
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while auto-saving the template fields.`),
variant: 'destructive',
});
}
};
const onAddFieldsFormSubmit = async (data: TAddTemplateFieldsFormSchema) => {
try {
await addTemplateFields({
templateId: template.id,
fields: data.fields,
});
await saveFieldsData(data);
// Clear all field data from localStorage
for (let i = 0; i < localStorage.length; i++) {
@ -270,11 +330,12 @@ export const TemplateEditForm = ({
recipients={recipients}
fields={fields}
onSubmit={onAddSettingsFormSubmit}
onAutoSave={onAddSettingsFormAutoSave}
isDocumentPdfLoaded={isDocumentPdfLoaded}
/>
<AddTemplatePlaceholderRecipientsFormPartial
key={recipients.length}
key={template.id}
documentFlow={documentFlow.signers}
recipients={recipients}
fields={fields}
@ -282,15 +343,17 @@ export const TemplateEditForm = ({
allowDictateNextSigner={template.templateMeta?.allowDictateNextSigner}
templateDirectLink={template.directLink}
onSubmit={onAddTemplatePlaceholderFormSubmit}
onAutoSave={onAddTemplatePlaceholderFormAutoSave}
isDocumentPdfLoaded={isDocumentPdfLoaded}
/>
<AddTemplateFieldsFormPartial
key={fields.length}
key={template.id}
documentFlow={documentFlow.fields}
recipients={recipients}
fields={fields}
onSubmit={onAddFieldsFormSubmit}
onAutoSave={onAddFieldsFormAutoSave}
teamId={team?.id}
/>
</Stepper>

View File

@ -67,7 +67,7 @@ export const TemplatePageViewDocumentsTable = ({
Object.fromEntries(searchParams ?? []),
);
const { data, isLoading, isLoadingError } = trpc.document.findDocuments.useQuery(
const { data, isLoading, isLoadingError } = trpc.document.find.useQuery(
{
templateId,
page: parsedSearchParams.page,

View File

@ -18,7 +18,7 @@ export const TemplatePageViewRecentActivity = ({
templateId,
documentRootPath,
}: TemplatePageViewRecentActivityProps) => {
const { data, isLoading, isLoadingError, refetch } = trpc.document.findDocuments.useQuery({
const { data, isLoading, isLoadingError, refetch } = trpc.document.find.useQuery({
templateId,
orderByColumn: 'createdAt',
orderByDirection: 'asc',

View File

@ -52,7 +52,7 @@ export const AdminDocumentRecipientItemTable = ({ recipient }: RecipientItemProp
},
});
const { mutateAsync: updateRecipient } = trpc.admin.updateRecipient.useMutation();
const { mutateAsync: updateRecipient } = trpc.admin.recipient.update.useMutation();
const columns = useMemo(() => {
return [

View File

@ -34,7 +34,7 @@ export const DocumentLogsTable = ({ documentId }: DocumentLogsTableProps) => {
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
const { data, isLoading, isLoadingError } = trpc.document.findDocumentAuditLogs.useQuery(
const { data, isLoading, isLoadingError } = trpc.document.auditLog.find.useQuery(
{
documentId,
page: parsedSearchParams.page,

View File

@ -45,7 +45,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
const onDownloadClick = async () => {
try {
const document = !recipient
? await trpcClient.document.getDocumentById.query(
? await trpcClient.document.get.query(
{
documentId: row.id,
},

View File

@ -77,7 +77,7 @@ export const DocumentsTableActionDropdown = ({
const onDownloadClick = async () => {
try {
const document = !recipient
? await trpcClient.document.getDocumentById.query({
? await trpcClient.document.get.query({
documentId: row.id,
})
: await trpcClient.document.getDocumentByToken.query({
@ -103,7 +103,7 @@ export const DocumentsTableActionDropdown = ({
const onDownloadOriginalClick = async () => {
try {
const document = !recipient
? await trpcClient.document.getDocumentById.query({
? await trpcClient.document.get.query({
documentId: row.id,
})
: await trpcClient.document.getDocumentByToken.query({

View File

@ -11,7 +11,7 @@ import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-upda
import { useSession } from '@documenso/lib/client-only/providers/session';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/schema';
import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/find-documents.types';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';

View File

@ -17,7 +17,6 @@ import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
import type { TFindInboxResponse } from '@documenso/trpc/server/document-router/find-inbox.types';
import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
@ -32,12 +31,12 @@ import { useOptionalCurrentTeam } from '~/providers/team';
import { StackAvatarsWithTooltip } from '../general/stack-avatars-with-tooltip';
export type DocumentsTableProps = {
data?: TFindDocumentsResponse;
data?: TFindInboxResponse;
isLoading?: boolean;
isLoadingError?: boolean;
};
type DocumentsTableRow = TFindDocumentsResponse['data'][number];
type DocumentsTableRow = TFindInboxResponse['data'][number];
export const InboxTable = () => {
const { _, i18n } = useLingui();

View File

@ -62,7 +62,7 @@ export const SettingsSecurityPasskeyTableActions = ({
});
const { mutateAsync: updatePasskey, isPending: isUpdatingPasskey } =
trpc.auth.updatePasskey.useMutation({
trpc.auth.passkey.update.useMutation({
onSuccess: () => {
toast({
title: _(msg`Success`),
@ -84,7 +84,7 @@ export const SettingsSecurityPasskeyTableActions = ({
});
const { mutateAsync: deletePasskey, isPending: isDeletingPasskey } =
trpc.auth.deletePasskey.useMutation({
trpc.auth.passkey.delete.useMutation({
onSuccess: () => {
toast({
title: _(msg`Success`),

View File

@ -26,7 +26,7 @@ export const SettingsSecurityPasskeyTable = () => {
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
const { data, isLoading, isLoadingError } = trpc.auth.findPasskeys.useQuery(
const { data, isLoading, isLoadingError } = trpc.auth.passkey.find.useQuery(
{
page: parsedSearchParams.page,
perPage: parsedSearchParams.perPage,

View File

@ -48,7 +48,7 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
const { toast } = useToast();
const { mutate: resealDocument, isPending: isResealDocumentLoading } =
trpc.admin.resealDocument.useMutation({
trpc.admin.document.reseal.useMutation({
onSuccess: () => {
toast({
title: _(msg`Success`),

View File

@ -33,7 +33,7 @@ export default function AdminDocumentsPage() {
const perPage = searchParams?.get?.('perPage') ? Number(searchParams.get('perPage')) : undefined;
const { data: findDocumentsData, isPending: isFindDocumentsLoading } =
trpc.admin.findDocuments.useQuery(
trpc.admin.document.find.useQuery(
{
query: debouncedTerm,
page: page || 1,

View File

@ -71,6 +71,23 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
},
});
const { mutateAsync: promoteToOwner, isPending: isPromotingToOwner } =
trpc.admin.organisationMember.promoteToOwner.useMutation({
onSuccess: () => {
toast({
title: t`Success`,
description: t`Member promoted to owner successfully`,
});
},
onError: () => {
toast({
title: t`Error`,
description: t`We couldn't promote the member to owner. Please try again.`,
variant: 'destructive',
});
},
});
const teamsColumns = useMemo(() => {
return [
{
@ -101,6 +118,26 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
<Link to={`/admin/users/${row.original.user.id}`}>{row.original.user.email}</Link>
),
},
{
header: t`Actions`,
cell: ({ row }) => (
<div className="flex justify-end space-x-2">
<Button
variant="outline"
disabled={row.original.userId === organisation?.ownerUserId}
loading={isPromotingToOwner}
onClick={async () =>
promoteToOwner({
organisationId,
userId: row.original.userId,
})
}
>
<Trans>Promote to owner</Trans>
</Button>
</div>
),
},
] satisfies DataTableColumnDef<TGetAdminOrganisationResponse['members'][number]>[];
}, [organisation]);

View File

@ -2,14 +2,14 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { User } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import { Link } from 'react-router';
import type { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { ZAdminUpdateProfileMutationSchema } from '@documenso/trpc/server/admin-router/schema';
import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types';
import { ZUpdateUserRequestSchema } from '@documenso/trpc/server/admin-router/update-user.types';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
@ -33,12 +33,12 @@ import { AdminOrganisationsTable } from '~/components/tables/admin-organisations
import { MultiSelectRoleCombobox } from '../../../components/general/multiselect-role-combobox';
const ZUserFormSchema = ZAdminUpdateProfileMutationSchema.omit({ id: true });
const ZUserFormSchema = ZUpdateUserRequestSchema.omit({ id: true });
type TUserFormSchema = z.infer<typeof ZUserFormSchema>;
export default function UserPage({ params }: { params: { id: number } }) {
const { data: user, isLoading: isLoadingUser } = trpc.profile.getUser.useQuery(
const { data: user, isLoading: isLoadingUser } = trpc.admin.user.get.useQuery(
{
id: Number(params.id),
},
@ -78,14 +78,14 @@ export default function UserPage({ params }: { params: { id: number } }) {
return <AdminUserPage user={user} />;
}
const AdminUserPage = ({ user }: { user: User }) => {
const AdminUserPage = ({ user }: { user: TGetUserResponse }) => {
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const roles = user.roles ?? [];
const { mutateAsync: updateUserMutation } = trpc.admin.updateUser.useMutation();
const { mutateAsync: updateUserMutation } = trpc.admin.user.update.useMutation();
const form = useForm<TUserFormSchema>({
resolver: zodResolver(ZUserFormSchema),

View File

@ -6,6 +6,7 @@ import {
GroupIcon,
MailboxIcon,
Settings2Icon,
ShieldCheckIcon,
Users2Icon,
} from 'lucide-react';
import { FaUsers } from 'react-icons/fa6';
@ -77,6 +78,11 @@ export default function SettingsLayout() {
label: t`Groups`,
icon: GroupIcon,
},
{
path: `/o/${organisation.url}/settings/sso`,
label: t`SSO`,
icon: ShieldCheckIcon,
},
{
path: `/o/${organisation.url}/settings/billing`,
label: t`Billing`,
@ -94,6 +100,13 @@ export default function SettingsLayout() {
return false;
}
if (
(!isBillingEnabled || !organisation.organisationClaim.flags.authenticationPortal) &&
route.path.includes('/sso')
) {
return false;
}
return true;
});

View File

@ -0,0 +1,432 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { OrganisationMemberRole } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/organisations';
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import {
formatOrganisationCallbackUrl,
formatOrganisationLoginUrl,
} from '@documenso/lib/utils/organisation-authentication-portal';
import { trpc } from '@documenso/trpc/react';
import { domainRegex } from '@documenso/trpc/server/enterprise-router/create-organisation-email-domain.types';
import type { TGetOrganisationAuthenticationPortalResponse } from '@documenso/trpc/server/enterprise-router/get-organisation-authentication-portal.types';
import { ZUpdateOrganisationAuthenticationPortalRequestSchema } from '@documenso/trpc/server/enterprise-router/update-organisation-authentication-portal.types';
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { Switch } from '@documenso/ui/primitives/switch';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { SettingsHeader } from '~/components/general/settings-header';
import { appMetaTags } from '~/utils/meta';
const ZProviderFormSchema = ZUpdateOrganisationAuthenticationPortalRequestSchema.shape.data
.pick({
enabled: true,
wellKnownUrl: true,
clientId: true,
autoProvisionUsers: true,
defaultOrganisationRole: true,
})
.extend({
clientSecret: z.string().nullable(),
allowedDomains: z.string().refine(
(value) => {
const domains = value.split(' ').filter(Boolean);
return domains.every((domain) => domainRegex.test(domain));
},
{
message: msg`Invalid domains`.id,
},
),
});
type TProviderFormSchema = z.infer<typeof ZProviderFormSchema>;
export function meta() {
return appMetaTags('Organisation SSO Portal');
}
export default function OrganisationSettingSSOLoginPage() {
const { t } = useLingui();
const organisation = useCurrentOrganisation();
const { data: authenticationPortal, isLoading: isLoadingAuthenticationPortal } =
trpc.enterprise.organisation.authenticationPortal.get.useQuery({
organisationId: organisation.id,
});
if (isLoadingAuthenticationPortal || !authenticationPortal) {
return <SpinnerBox className="py-32" />;
}
return (
<div className="max-w-2xl">
<SettingsHeader
title={t`Organisation SSO Portal`}
subtitle={t`Manage a custom SSO login portal for your organisation.`}
/>
<SSOProviderForm authenticationPortal={authenticationPortal} />
</div>
);
}
type SSOProviderFormProps = {
authenticationPortal: TGetOrganisationAuthenticationPortalResponse;
};
const SSOProviderForm = ({ authenticationPortal }: SSOProviderFormProps) => {
const { t } = useLingui();
const { toast } = useToast();
const organisation = useCurrentOrganisation();
const { mutateAsync: updateOrganisationAuthenticationPortal } =
trpc.enterprise.organisation.authenticationPortal.update.useMutation();
const form = useForm<TProviderFormSchema>({
resolver: zodResolver(ZProviderFormSchema),
defaultValues: {
enabled: authenticationPortal.enabled,
clientId: authenticationPortal.clientId,
clientSecret: authenticationPortal.clientSecretProvided ? null : '',
wellKnownUrl: authenticationPortal.wellKnownUrl,
autoProvisionUsers: authenticationPortal.autoProvisionUsers,
defaultOrganisationRole: authenticationPortal.defaultOrganisationRole,
allowedDomains: authenticationPortal.allowedDomains.join(' '),
},
});
const onSubmit = async (values: TProviderFormSchema) => {
const { enabled, clientId, clientSecret, wellKnownUrl } = values;
if (enabled && !clientId) {
form.setError('clientId', {
message: t`Client ID is required`,
});
return;
}
if (enabled && clientSecret === '') {
form.setError('clientSecret', {
message: t`Client secret is required`,
});
return;
}
if (enabled && !wellKnownUrl) {
form.setError('wellKnownUrl', {
message: t`Well-known URL is required`,
});
return;
}
try {
await updateOrganisationAuthenticationPortal({
organisationId: organisation.id,
data: {
enabled,
clientId,
clientSecret: values.clientSecret ?? undefined,
wellKnownUrl,
autoProvisionUsers: values.autoProvisionUsers,
defaultOrganisationRole: values.defaultOrganisationRole,
allowedDomains: values.allowedDomains.split(' ').filter(Boolean),
},
});
toast({
title: t`Success`,
description: t`Provider has been updated successfully`,
duration: 5000,
});
} catch (err) {
console.error(err);
toast({
title: t`An error occurred`,
description: t`We couldn't update the provider. Please try again.`,
variant: 'destructive',
});
}
};
const isSsoEnabled = form.watch('enabled');
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="space-y-6">
<div className="space-y-2">
<Label>
<Trans>Organisation authentication portal URL</Trans>
</Label>
<div className="relative">
<Input
className="pr-12"
disabled
value={formatOrganisationLoginUrl(organisation.url)}
/>
<div className="absolute bottom-0 right-2 top-0 flex items-center justify-center">
<CopyTextButton
value={formatOrganisationLoginUrl(organisation.url)}
onCopySuccess={() => toast({ title: t`Copied to clipboard` })}
/>
</div>
</div>
<p className="text-muted-foreground text-xs">
<Trans>This is the URL which users will use to sign in to your organisation.</Trans>
</p>
</div>
<div className="space-y-2">
<Label>
<Trans>Redirect URI</Trans>
</Label>
<div className="relative">
<Input
className="pr-12"
disabled
value={formatOrganisationCallbackUrl(organisation.url)}
/>
<div className="absolute bottom-0 right-2 top-0 flex items-center justify-center">
<CopyTextButton
value={formatOrganisationCallbackUrl(organisation.url)}
onCopySuccess={() => toast({ title: t`Copied to clipboard` })}
/>
</div>
</div>
<p className="text-muted-foreground text-xs">
<Trans>Add this URL to your provider's allowed redirect URIs</Trans>
</p>
</div>
<div className="space-y-2">
<Label>
<Trans>Required scopes</Trans>
</Label>
<Input className="pr-12" disabled value={`openid profile email`} />
<p className="text-muted-foreground text-xs">
<Trans>This is the required scopes you must set in your provider's settings</Trans>
</p>
</div>
<FormField
control={form.control}
name="wellKnownUrl"
render={({ field }) => (
<FormItem>
<FormLabel required={isSsoEnabled}>
<Trans>Issuer URL</Trans>
</FormLabel>
<FormControl>
<Input
placeholder={'https://your-provider.com/.well-known/openid-configuration'}
{...field}
/>
</FormControl>
{!form.formState.errors.wellKnownUrl && (
<p className="text-muted-foreground text-xs">
<Trans>The OpenID discovery endpoint URL for your provider</Trans>
</p>
)}
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="clientId"
render={({ field }) => (
<FormItem>
<FormLabel required={isSsoEnabled}>
<Trans>Client ID</Trans>
</FormLabel>
<FormControl>
<Input id="client-id" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientSecret"
render={({ field }) => (
<FormItem>
<FormLabel required={isSsoEnabled}>
<Trans>Client Secret</Trans>
</FormLabel>
<FormControl>
<Input
id="client-secret"
type="password"
{...field}
value={field.value === null ? '**********************' : field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="defaultOrganisationRole"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Default Organisation Role for New Users</Trans>
</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t`Select default role`} />
</SelectTrigger>
<SelectContent>
{ORGANISATION_MEMBER_ROLE_HIERARCHY[OrganisationMemberRole.MANAGER].map(
(role) => (
<SelectItem key={role} value={role}>
{t(ORGANISATION_MEMBER_ROLE_MAP[role])}
</SelectItem>
),
)}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="allowedDomains"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Allowed Email Domains</Trans>
</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder={t`your-domain.com another-domain.com`}
className="min-h-[80px]"
/>
</FormControl>
{!form.formState.errors.allowedDomains && (
<p className="text-muted-foreground text-xs">
<Trans>
Space-separated list of domains. Leave empty to allow all domains.
</Trans>
</p>
)}
<FormMessage />
</FormItem>
)}
/>
{/* Todo: This is just dummy toggle, we need to decide what this does first. */}
{/* <FormField
control={form.control}
name="autoProvisionUsers"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border px-4 py-3">
<div className="space-y-0.5">
<FormLabel>
<Trans>Auto-provision Users</Trans>
</FormLabel>
<p className="text-muted-foreground text-sm">
<Trans>Automatically create accounts for new users on first login</Trans>
</p>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/> */}
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border px-4 py-3">
<div className="space-y-0.5">
<FormLabel>
<Trans>Enable SSO portal</Trans>
</FormLabel>
<p className="text-muted-foreground text-sm">
<Trans>Whether to enable the SSO portal for your organisation</Trans>
</p>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Alert variant="warning">
<AlertDescription>
<Trans>
Please note that anyone who signs in through your portal will be added to your
organisation as a member.
</Trans>
</AlertDescription>
</Alert>
<div className="flex justify-end gap-2">
<Button loading={form.formState.isSubmitting} type="submit">
<Trans>Update</Trans>
</Button>
</div>
</fieldset>
</form>
</Form>
);
};

View File

@ -192,6 +192,27 @@ export default function SettingsSecurity({ loaderData }: Route.ComponentProps) {
</Link>
</Button>
</Alert>
<Alert
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
variant="neutral"
>
<div className="mb-4 mr-4 sm:mb-0">
<AlertTitle>
<Trans>Linked Accounts</Trans>
</AlertTitle>
<AlertDescription className="mr-2">
<Trans>View and manage all login methods linked to your account.</Trans>
</AlertDescription>
</div>
<Button asChild variant="outline" className="bg-background">
<Link to="/settings/security/linked-accounts">
<Trans>Manage linked accounts</Trans>
</Link>
</Button>
</Alert>
</div>
);
}

View File

@ -0,0 +1,179 @@
import { useMemo, useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { useQuery } from '@tanstack/react-query';
import { DateTime } from 'luxon';
import { authClient } from '@documenso/auth/client';
import { Button } from '@documenso/ui/primitives/button';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { SettingsHeader } from '~/components/general/settings-header';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Linked Accounts');
}
export default function SettingsSecurityLinkedAccounts() {
const { t } = useLingui();
const { data, isLoading, isLoadingError, refetch } = useQuery({
queryKey: ['linked-accounts'],
queryFn: async () => await authClient.account.getMany(),
});
const results = data?.accounts ?? [];
const columns = useMemo(() => {
return [
{
header: t`Provider`,
accessorKey: 'provider',
cell: ({ row }) => row.original.provider,
},
{
header: t`Linked At`,
accessorKey: 'createdAt',
cell: ({ row }) =>
row.original.createdAt
? DateTime.fromJSDate(row.original.createdAt).toRelative()
: t`Unknown`,
},
{
id: 'actions',
cell: ({ row }) => (
<AccountUnlinkDialog
accountId={row.original.id}
provider={row.original.provider}
onSuccess={refetch}
/>
),
},
] satisfies DataTableColumnDef<(typeof results)[number]>[];
}, []);
return (
<div>
<SettingsHeader
title={t`Linked Accounts`}
subtitle={t`View and manage all login methods linked to your account.`}
/>
<div className="mt-4">
<DataTable
columns={columns}
data={results}
hasFilters={false}
error={{
enable: isLoadingError,
}}
skeleton={{
enable: isLoading,
rows: 3,
component: (
<>
<TableCell>
<Skeleton className="h-4 w-40 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-24 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-8 w-16 rounded" />
</TableCell>
</>
),
}}
/>
</div>
</div>
);
}
type AccountUnlinkDialogProps = {
accountId: string;
provider: string;
onSuccess: () => Promise<unknown>;
};
const AccountUnlinkDialog = ({ accountId, onSuccess, provider }: AccountUnlinkDialogProps) => {
const { toast } = useToast();
const { t } = useLingui();
const [isLoading, setIsLoading] = useState(false);
const [open, setOpen] = useState(false);
const handleRevoke = async () => {
setIsLoading(true);
try {
await authClient.account.delete(accountId);
await onSuccess();
toast({
title: t`Account unlinked`,
});
} catch (error) {
console.error(error);
toast({
title: t`Error`,
description: t`Failed to unlink account`,
variant: 'destructive',
});
}
setIsLoading(false);
};
return (
<Dialog open={open} onOpenChange={(value) => !isLoading && setOpen(value)}>
<DialogTrigger asChild>
<Button variant="destructive" size="sm">
<Trans>Unlink</Trans>
</Button>
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Are you sure?</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>
You are about to remove the <span className="font-semibold">{provider}</span> login
method from your account.
</Trans>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button variant="destructive" loading={isLoading} onClick={handleRevoke}>
<Trans>Unlink</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -67,6 +67,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
const documentVisibility = document?.visibility;
const currentTeamMemberRole = team.currentTeamRole;
const isRecipient = document?.recipients.find((recipient) => recipient.email === user.email);
let canAccessDocument = true;
if (!isRecipient && document?.userId !== user.id) {

View File

@ -50,10 +50,6 @@ export async function loader({ params, request }: Route.LoaderArgs) {
throw redirect(documentRootPath);
}
if (document.folderId) {
throw redirect(documentRootPath);
}
const recipients = await getRecipientsForDocument({
documentId,
userId: user.id,
@ -68,13 +64,13 @@ export async function loader({ params, request }: Route.LoaderArgs) {
return {
document,
documentRootPath,
recipients,
documentRootPath,
};
}
export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps) {
const { document, documentRootPath, recipients } = loaderData;
const { document, recipients, documentRootPath } = loaderData;
const { _, i18n } = useLingui();

View File

@ -12,10 +12,8 @@ import { parseToIntegerArray } from '@documenso/lib/utils/params';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { trpc } from '@documenso/trpc/react';
import {
type TFindDocumentsInternalResponse,
ZFindDocumentsInternalRequestSchema,
} from '@documenso/trpc/server/document-router/schema';
import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/find-documents-internal.types';
import { ZFindDocumentsInternalRequestSchema } from '@documenso/trpc/server/document-router/find-documents-internal.types';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';

View File

@ -21,7 +21,7 @@ export function meta() {
export default function ApiTokensPage() {
const { i18n } = useLingui();
const { data: tokens } = trpc.apiToken.getTokens.useQuery();
const { data: tokens } = trpc.apiToken.getMany.useQuery();
const team = useOptionalCurrentTeam();

View File

@ -9,10 +9,10 @@ import { trpc } from '@documenso/trpc/react';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { FolderGrid } from '~/components/general/folder/folder-grid';
import { TemplateDropZoneWrapper } from '~/components/general/template/template-drop-zone-wrapper';
import { TemplatesTable } from '~/components/tables/templates-table';
import { useCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
import { TemplateDropZoneWrapper } from '~/components/general/template/template-drop-zone-wrapper';
export function meta() {
return appMetaTags('Templates');

View File

@ -151,6 +151,7 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
authLevel = match(accessAuthMethod)
.with('ACCOUNT', () => _(msg`Account Authentication`))
.with('TWO_FACTOR_AUTH', () => _(msg`Two-Factor Authentication`))
.with(undefined, () => _(msg`Email`))
.exhaustive();
}

View File

@ -47,10 +47,12 @@ export async function loader({ params, request }: Route.LoaderArgs) {
});
// Ensure typesafety when we add more options.
const isAccessAuthValid = match(derivedRecipientAccessAuth.at(0))
.with(DocumentAccessAuth.ACCOUNT, () => Boolean(session.user))
.with(undefined, () => true)
.exhaustive();
const isAccessAuthValid = derivedRecipientAccessAuth.every((auth) =>
match(auth)
.with(DocumentAccessAuth.ACCOUNT, () => Boolean(session.user))
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => true)
.exhaustive(),
);
if (!isAccessAuthValid) {
return superLoaderJson({

View File

@ -3,12 +3,12 @@ import { DocumentSigningOrder, DocumentStatus, RecipientRole, SigningStatus } fr
import { Clock8 } from 'lucide-react';
import { Link, redirect } from 'react-router';
import { getOptionalLoaderContext } from 'server/utils/get-loader-session';
import { match } from 'ts-pattern';
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
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 { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
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';
@ -19,6 +19,7 @@ import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
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 { SigningCard3D } from '@documenso/ui/components/signing-card';
@ -98,16 +99,16 @@ export async function loader({ params, request }: Route.LoaderArgs) {
recipientAuth: recipient.authOptions,
});
const isDocumentAccessValid = await isRecipientAuthorized({
type: 'ACCESS',
documentAuthOptions: document.authOptions,
recipient,
userId: user?.id,
});
const isAccessAuthValid = derivedRecipientAccessAuth.every((accesssAuth) =>
match(accesssAuth)
.with(DocumentAccessAuth.ACCOUNT, () => user && user.email === recipient.email)
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => true) // Allow without account requirement
.exhaustive(),
);
let recipientHasAccount: boolean | null = null;
if (!isDocumentAccessValid) {
if (!isAccessAuthValid) {
recipientHasAccount = await getUserByEmail({ email: recipient.email })
.then((user) => !!user)
.catch(() => false);

View File

@ -0,0 +1,218 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { MailsIcon } from 'lucide-react';
import { Link, redirect, useSearchParams } from 'react-router';
import { authClient } from '@documenso/auth/client';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/o.$orgUrl.signin';
export function meta() {
return appMetaTags('Sign In');
}
export function ErrorBoundary() {
return (
<GenericErrorLayout
errorCode={404}
errorCodeMap={{
404: {
heading: msg`Authentication Portal Not Found`,
subHeading: msg`404 Not Found`,
message: msg`The organisation authentication portal does not exist, or is not configured`,
},
}}
primaryButton={
<Button asChild>
<Link to={`/`}>
<Trans>Go back</Trans>
</Link>
</Button>
}
secondaryButton={null}
/>
);
}
export async function loader({ request, params }: Route.LoaderArgs) {
const { isAuthenticated, user } = await getOptionalSession(request);
const orgUrl = params.orgUrl;
const organisation = await prisma.organisation.findFirst({
where: {
url: orgUrl,
},
select: {
name: true,
organisationClaim: true,
organisationAuthenticationPortal: {
select: {
enabled: true,
},
},
members: {
select: {
userId: true,
},
},
},
});
if (
!organisation ||
!organisation.organisationAuthenticationPortal.enabled ||
!organisation.organisationClaim.flags.authenticationPortal
) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Organisation not found',
});
}
// Redirect to organisation if already signed in and a member of the organisation.
if (isAuthenticated && user && organisation.members.find((member) => member.userId === user.id)) {
throw redirect(`/o/${orgUrl}`);
}
return {
organisationName: organisation.name,
orgUrl,
};
}
export default function OrganisationSignIn({ loaderData }: Route.ComponentProps) {
const [searchParams] = useSearchParams();
const { organisationName, orgUrl } = loaderData;
const { t } = useLingui();
const { toast } = useToast();
const [isSubmitting, setIsSubmitting] = useState(false);
const [isConfirmationChecked, setIsConfirmationChecked] = useState(false);
const action = searchParams.get('action');
const onSignInWithOIDCClick = async () => {
setIsSubmitting(true);
try {
await authClient.oidc.org.signIn({
orgUrl,
});
} catch (err) {
toast({
title: t`An unknown error occurred`,
description: t`We encountered an unknown error while attempting to sign you In. Please try again later.`,
variant: 'destructive',
});
}
setIsSubmitting(false);
};
if (action === 'verification-required') {
return (
<div className="w-screen max-w-lg px-4">
<div className="flex items-start">
<div className="mr-4 mt-1 hidden md:block">
<MailsIcon className="text-primary h-10 w-10" strokeWidth={2} />
</div>
<div className="">
<h2 className="text-2xl font-bold md:text-4xl">
<Trans>Confirmation email sent</Trans>
</h2>
<p className="text-muted-foreground mt-4">
<Trans>
To gain access to your account, please confirm your email address by clicking on the
confirmation link from your inbox.
</Trans>
</p>
<div className="mt-4 flex items-center gap-x-2">
<Button asChild>
<Link to={`/o/${orgUrl}/signin`} replace>
<Trans>Return</Trans>
</Link>
</Button>
</div>
</div>
</div>
</div>
);
}
return (
<div className="w-screen max-w-lg px-4">
<div className="border-border dark:bg-background z-10 rounded-xl border bg-neutral-100 p-6">
<h1 className="text-2xl font-semibold">
<Trans>Welcome to {organisationName}</Trans>
</h1>
<p className="text-muted-foreground mt-2 text-sm">
<Trans>Sign in to your account</Trans>
</p>
<hr className="-mx-6 my-4" />
<div className="mb-4 flex items-center gap-x-2">
<Checkbox
id={`flag-3rd-party-service`}
checked={isConfirmationChecked}
onCheckedChange={(checked) =>
setIsConfirmationChecked(checked === 'indeterminate' ? false : checked)
}
/>
<label
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
htmlFor={`flag-3rd-party-service`}
>
<Trans>
I understand that I am providing my credentials to a 3rd party service configured by
this organisation
</Trans>
</label>
</div>
<Button
type="button"
size="lg"
variant="outline"
className="bg-background w-full"
loading={isSubmitting}
disabled={!isConfirmationChecked}
onClick={onSignInWithOIDCClick}
>
<Trans>Sign In</Trans>
</Button>
<div className="relative mt-2 flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<div className="bg-border h-px flex-1" />
<span className="text-muted-foreground bg-transparent">
<Trans>OR</Trans>
</span>
<div className="bg-border h-px flex-1" />
</div>
<div className="text-muted-foreground mt-1 flex items-center justify-center text-xs">
<Link to="/signin">
<Trans>Return to Documenso sign in page here</Trans>
</Link>
</div>
</div>
</div>
);
}

View File

@ -45,6 +45,9 @@ export async function loader({ params, request }: Route.LoaderArgs) {
mode: 'insensitive',
},
},
select: {
id: true,
},
});
// Directly convert the team member invite to a team member if they already have an account.

View File

@ -0,0 +1,333 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { AlertTriangle, Building2, Database, Eye, Settings, UserCircle2 } from 'lucide-react';
import { data, isRouteErrorResponse } from 'react-router';
import { useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import { ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER } from '@documenso/lib/constants/organisations';
import { ZOrganisationAccountLinkMetadataSchema } from '@documenso/lib/types/organisation';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { formatOrganisationLoginPath } from '@documenso/lib/utils/organisation-authentication-portal';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { prisma } from '@documenso/prisma';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@documenso/ui/primitives/card';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import { Separator } from '@documenso/ui/primitives/separator';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { GenericErrorLayout, defaultErrorCodeMap } from '~/components/general/generic-error-layout';
import type { Route } from './+types/organisation.sso.confirmation.$token';
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
const errorCode = isRouteErrorResponse(error) ? error.data.type : 500;
const errorMap = match(errorCode)
.with('invalid-token', () => ({
subHeading: msg`400 Error`,
heading: msg`Invalid Token`,
message: msg`The token is invalid or has expired.`,
}))
.otherwise(() => defaultErrorCodeMap[500]);
return (
<GenericErrorLayout errorCode={500} errorCodeMap={{ 500: errorMap }} secondaryButton={null} />
);
}
export async function loader({ params }: Route.LoaderArgs) {
const { token } = params;
if (!token) {
throw data({
type: 'invalid-token',
});
}
const verificationToken = await prisma.verificationToken.findFirst({
where: {
token,
identifier: ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
},
include: {
user: {
select: {
name: true,
email: true,
avatarImageId: true,
},
},
},
});
if (!verificationToken || verificationToken.expires < new Date()) {
throw data({
type: 'invalid-token',
});
}
const metadata = ZOrganisationAccountLinkMetadataSchema.safeParse(verificationToken.metadata);
if (!metadata.success) {
throw data({
type: 'invalid-token',
});
}
const organisation = await prisma.organisation.findFirst({
where: {
id: metadata.data.organisationId,
},
select: {
name: true,
url: true,
avatarImageId: true,
},
});
if (!organisation) {
throw data({
type: 'invalid-token',
});
}
return {
token,
type: metadata.data.type,
user: {
name: verificationToken.user.name,
email: verificationToken.user.email,
avatar: verificationToken.user.avatarImageId,
},
organisation: {
name: organisation.name,
url: organisation.url,
avatar: organisation.avatarImageId,
},
} as const;
}
export default function OrganisationSsoConfirmationTokenPage({ loaderData }: Route.ComponentProps) {
const { token, type, user, organisation } = loaderData;
const { toast } = useToast();
const navigate = useNavigate();
const [isConfirmationChecked, setIsConfirmationChecked] = useState(false);
const { mutate: declineLinkOrganisationAccount, isPending: isDeclining } =
trpc.enterprise.organisation.authenticationPortal.declineLinkAccount.useMutation({
onSuccess: async () => {
await navigate('/');
toast({
title: 'Account link declined',
});
},
onError: (error) => {
toast({
title: 'Error declining account link',
description: error.message,
});
},
});
const { mutate: linkOrganisationAccount, isPending: isLinking } =
trpc.enterprise.organisation.authenticationPortal.linkAccount.useMutation({
onSuccess: async () => {
await navigate(formatOrganisationLoginPath(organisation.url));
toast({
title: 'Account linked successfully',
});
},
onError: (error) => {
toast({
title: 'Error linking account',
description: error.message,
});
},
});
return (
<div>
<Card className="w-full max-w-2xl border">
<CardHeader>
<CardTitle>
{type === 'link' ? (
<Trans>Account Linking Request</Trans>
) : (
<Trans>Account Creation Request</Trans>
)}
</CardTitle>
<CardDescription>
{type === 'link' ? (
<Trans>
An organisation wants to link your account. Please review the details below.
</Trans>
) : (
<Trans>
An organisation wants to create an account for you. Please review the details below.
</Trans>
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Current User Section */}
<div className="space-y-3">
<h3 className="text-muted-foreground flex items-center gap-2 font-semibold">
<UserCircle2 className="h-4 w-4" />
<Trans>Your Account</Trans>
</h3>
<div className="bg-muted/50 flex items-center justify-between gap-3 rounded-lg p-3">
<AvatarWithText
avatarSrc={formatAvatarUrl(user.avatar)}
avatarFallback={extractInitials(user.name || user.email)}
primaryText={user.name}
secondaryText={user.email}
/>
<Badge variant="secondary">
<Trans>Account</Trans>
</Badge>
</div>
</div>
<Separator />
{/* Organisation Section */}
<div className="space-y-3">
<h3 className="text-muted-foreground flex items-center gap-2 font-semibold">
<Building2 className="h-4 w-4" />
<Trans>Requesting Organisation</Trans>
</h3>
<div className="bg-muted/50 flex items-center justify-between gap-3 rounded-lg p-3">
<AvatarWithText
avatarSrc={formatAvatarUrl(organisation.avatar)}
avatarFallback={extractInitials(organisation.name)}
primaryText={organisation.name}
secondaryText={`/o/${organisation.url}`}
/>
<Badge variant="secondary">
<Trans>Organisation</Trans>
</Badge>
</div>
</div>
<Separator />
{/* Warnings Section */}
<div className="space-y-3">
<h3 className="text-muted-foreground flex items-center gap-2 font-semibold">
<AlertTriangle className="h-4 w-4" />
<Trans>Important: What This Means</Trans>
</h3>
<div className="space-y-3 rounded-lg border p-4">
<p className="text-sm font-medium">
<Trans>
By accepting this request, you grant {organisation.name} the following
permissions:
</Trans>
</p>
<ul className="space-y-2 text-sm">
<li className="flex items-start gap-2">
<Eye className="mt-0.5 h-4 w-4 flex-shrink-0" />
<span>
<Trans>
<span className="text-muted-foreground font-semibold">
Full account access:
</span>{' '}
View all your profile information, settings, and activity
</Trans>
</span>
</li>
<li className="flex items-start gap-2">
<Settings className="mt-0.5 h-4 w-4 flex-shrink-0" />
<span>
<Trans>
<span className="text-muted-foreground font-semibold">
Account management:
</span>{' '}
Modify your account settings, permissions, and preferences
</Trans>
</span>
</li>
<li className="flex items-start gap-2">
<Database className="mt-0.5 h-4 w-4 flex-shrink-0" />
<span>
<Trans>
<span className="text-muted-foreground font-semibold">Data access:</span>{' '}
Access all data associated with your account
</Trans>
</span>
</li>
</ul>
<Alert variant="warning" className="mt-3">
<AlertDescription>
<Trans>
This organisation will have administrative control over your account. You can
revoke this access later, but they will retain access to any data they've
already collected.
</Trans>
</AlertDescription>
</Alert>
</div>
</div>
<div className="mb-4 flex items-center gap-x-2">
<Checkbox
id={`accept-conditions`}
checked={isConfirmationChecked}
onCheckedChange={(checked) =>
setIsConfirmationChecked(checked === 'indeterminate' ? false : checked)
}
/>
<label
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
htmlFor={`accept-conditions`}
>
<Trans>I agree to link my account with this organization</Trans>
</label>
</div>
</CardContent>
<CardFooter className="flex justify-end gap-3">
<Button
variant="outline"
disabled={isDeclining || isLinking}
onClick={() => declineLinkOrganisationAccount({ token })}
>
<Trans>Decline</Trans>
</Button>
<Button
disabled={!isConfirmationChecked || isDeclining || isLinking}
loading={isLinking}
onClick={() => linkOrganisationAccount({ token })}
>
<Trans>Accept & Link Account</Trans>
</Button>
</CardFooter>
</Card>
</div>
);
}

View File

@ -0,0 +1,20 @@
import { getCertificateStatus } from '@documenso/lib/server-only/cert/cert-status';
export const loader = () => {
try {
const certStatus = getCertificateStatus();
return Response.json({
isAvailable: certStatus.isAvailable,
timestamp: new Date().toISOString(),
});
} catch {
return Response.json(
{
isAvailable: false,
timestamp: new Date().toISOString(),
},
{ status: 500 },
);
}
};

View File

@ -1,22 +1,49 @@
import { getCertificateStatus } from '@documenso/lib/server-only/cert/cert-status';
import { prisma } from '@documenso/prisma';
export async function loader() {
type CheckStatus = 'ok' | 'warning' | 'error';
export const loader = async () => {
const checks: {
database: { status: CheckStatus };
certificate: { status: CheckStatus };
} = {
database: { status: 'ok' },
certificate: { status: 'ok' },
};
let overallStatus: CheckStatus = 'ok';
try {
await prisma.$queryRaw`SELECT 1`;
return Response.json({
status: 'ok',
message: 'All systems operational',
});
} catch (err) {
console.error(err);
return Response.json(
{
status: 'error',
message: err instanceof Error ? err.message : 'Unknown error',
},
{ status: 500 },
);
} catch {
checks.database = { status: 'error' };
overallStatus = 'error';
}
}
try {
const certStatus = getCertificateStatus();
if (certStatus.isAvailable) {
checks.certificate = { status: 'ok' };
} else {
checks.certificate = { status: 'warning' };
if (overallStatus === 'ok') {
overallStatus = 'warning';
}
}
} catch {
checks.certificate = { status: 'error' };
overallStatus = 'error';
}
return Response.json(
{
status: overallStatus,
timestamp: new Date().toISOString(),
checks,
},
{ status: overallStatus === 'error' ? 500 : 200 },
);
};

View File

@ -58,10 +58,12 @@ export async function loader({ params, request }: Route.LoaderArgs) {
documentAuth: template.authOptions,
});
const isAccessAuthValid = match(derivedRecipientAccessAuth.at(0))
.with(DocumentAccessAuth.ACCOUNT, () => !!user)
.with(undefined, () => true)
.exhaustive();
const isAccessAuthValid = derivedRecipientAccessAuth.every((auth) =>
match(auth)
.with(DocumentAccessAuth.ACCOUNT, () => !!user)
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => false) // Not supported for direct links
.exhaustive(),
);
if (!isAccessAuthValid) {
throw data(

View File

@ -1,10 +1,12 @@
import { RecipientRole } from '@prisma/client';
import { data } from 'react-router';
import { getOptionalLoaderContext } from 'server/utils/get-loader-session';
import { match } from 'ts-pattern';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
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 { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
@ -23,6 +25,8 @@ import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/sign.$url';
export async function loader({ params, request }: Route.LoaderArgs) {
const { requestMetadata } = getOptionalLoaderContext();
if (!params.url) {
throw new Response('Not found', { status: 404 });
}
@ -71,10 +75,12 @@ export async function loader({ params, request }: Route.LoaderArgs) {
documentAuth: document.authOptions,
});
const isAccessAuthValid = match(derivedRecipientAccessAuth.at(0))
.with(DocumentAccessAuth.ACCOUNT, () => user && user.email === recipient.email)
.with(undefined, () => true)
.exhaustive();
const isAccessAuthValid = derivedRecipientAccessAuth.every((accesssAuth) =>
match(accesssAuth)
.with(DocumentAccessAuth.ACCOUNT, () => user && user.email === recipient.email)
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => true) // Allow without account requirement
.exhaustive(),
);
if (!isAccessAuthValid) {
throw data(
@ -102,6 +108,12 @@ export async function loader({ params, request }: Route.LoaderArgs) {
);
}
await viewedDocument({
token,
requestMetadata,
recipientAccessAuth: derivedRecipientAccessAuth,
});
const allRecipients =
recipient.role === RecipientRole.ASSISTANT
? await getRecipientsForAssistant({