+
{senderName} {senderEmail}
@@ -139,26 +221,118 @@ export const DocumentSigningPageView = ({
-
-
-
-
-
-
+
+
-
-
+
+
+
+
+ {match(recipient.role)
+ .with(RecipientRole.VIEWER, () => View Document )
+ .with(RecipientRole.SIGNER, () => Sign Document )
+ .with(RecipientRole.APPROVER, () => Approve Document )
+ .with(RecipientRole.ASSISTANT, () => Assist Document )
+ .otherwise(() => null)}
+
+
+ {match({ hasPendingFields, isExpanded, role: recipient.role })
+ .with(
+ {
+ hasPendingFields: false,
+ role: P.not(RecipientRole.ASSISTANT),
+ isExpanded: false,
+ },
+ () => (
+
+ {
+ await completeDocument(undefined, nextSigner);
+ }}
+ role={recipient.role}
+ allowDictateNextSigner={
+ nextRecipient && documentMeta?.allowDictateNextSigner
+ }
+ defaultNextSigner={
+ nextRecipient
+ ? { name: nextRecipient.name, email: nextRecipient.email }
+ : undefined
+ }
+ />
+
+ ),
+ )
+ .with({ isExpanded: true }, () => (
+
setIsExpanded(false)}
+ >
+
+
+ ))
+ .otherwise(() => (
+
setIsExpanded(true)}
+ >
+
+
+ ))}
+
+
+
+
+ {match(recipient.role)
+ .with(RecipientRole.VIEWER, () => (
+ Please mark as viewed to complete.
+ ))
+ .with(RecipientRole.SIGNER, () => (
+ Please review the document before signing.
+ ))
+ .with(RecipientRole.APPROVER, () => (
+ Please review the document before approving.
+ ))
+ .with(RecipientRole.ASSISTANT, () => (
+ Complete the fields for the following signers.
+ ))
+ .otherwise(() => null)}
+
+
+
+
+
+
+
+
+
@@ -172,7 +346,9 @@ export const DocumentSigningPageView = ({
)}
-
+
{fields
.filter(
(field) =>
diff --git a/apps/remix/app/components/general/document-signing/document-signing-text-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-text-field.tsx
index 9fcf9c6f3..9f9c6316e 100644
--- a/apps/remix/app/components/general/document-signing/document-signing-text-field.tsx
+++ b/apps/remix/app/components/general/document-signing/document-signing-text-field.tsx
@@ -227,19 +227,8 @@ export const DocumentSigningTextField = ({
const parsedField = field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : undefined;
- const labelDisplay =
- parsedField?.label && parsedField.label.length < 20
- ? parsedField.label
- : parsedField?.label
- ? parsedField?.label.substring(0, 20) + '...'
- : undefined;
-
- const textDisplay =
- parsedField?.text && parsedField.text.length < 20
- ? parsedField.text
- : parsedField?.text
- ? parsedField?.text.substring(0, 20) + '...'
- : undefined;
+ const labelDisplay = parsedField?.label;
+ const textDisplay = parsedField?.text;
const fieldDisplayName = labelDisplay ? labelDisplay : textDisplay;
const charactersRemaining = (parsedFieldMeta?.characterLimit ?? 0) - (localText.length ?? 0);
diff --git a/apps/remix/app/components/general/document/document-audit-log-download-button.tsx b/apps/remix/app/components/general/document/document-audit-log-download-button.tsx
index fb531eb37..77e90eff8 100644
--- a/apps/remix/app/components/general/document/document-audit-log-download-button.tsx
+++ b/apps/remix/app/components/general/document/document-audit-log-download-button.tsx
@@ -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 {
diff --git a/apps/remix/app/components/general/document/document-drop-zone-wrapper.tsx b/apps/remix/app/components/general/document/document-drop-zone-wrapper.tsx
index 16a52194e..e5fe18636 100644
--- a/apps/remix/app/components/general/document/document-drop-zone-wrapper.tsx
+++ b/apps/remix/app/components/general/document/document-drop-zone-wrapper.tsx
@@ -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;
diff --git a/apps/remix/app/components/general/document/document-edit-form.tsx b/apps/remix/app/components/general/document/document-edit-form.tsx
index 60d120b3e..e8ffa5fe5 100644
--- a/apps/remix/app/components/general/document/document-edit-form.tsx
+++ b/apps/remix/app/components/general/document/document-edit-form.tsx
@@ -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,30 +202,58 @@ export const DocumentEditForm = ({
}
};
+ const onAddSettingsFormAutoSave = async (data: TAddSettingsFormSchema) => {
+ try {
+ 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 {
+ await saveSignersData(data);
+ } catch (err) {
+ console.error(err);
+
+ toast({
+ title: _(msg`Error`),
+ description: _(msg`An error occurred while adding signers.`),
+ variant: 'destructive',
+ });
+ }
+ };
+
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
try {
- await Promise.all([
- setSigningOrderForDocument({
- documentId: document.id,
- signingOrder: data.signingOrder,
- }),
-
- updateDocument({
- documentId: document.id,
- meta: {
- allowDictateNextSigner: data.allowDictateNextSigner,
- },
- }),
-
- 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 ?? [],
- })),
- }),
- ]);
+ await saveSignersData(data);
setStep('fields');
} catch (err) {
@@ -250,12 +267,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 +298,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,
- 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 +379,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 +439,28 @@ export const DocumentEditForm = ({
fields={fields}
isDocumentPdfLoaded={isDocumentPdfLoaded}
onSubmit={onAddSettingsFormSubmit}
+ onAutoSave={onAddSettingsFormAutoSave}
/>
@@ -397,6 +472,7 @@ export const DocumentEditForm = ({
recipients={recipients}
fields={fields}
onSubmit={onAddSubjectFormSubmit}
+ onAutoSave={onAddSubjectFormAutoSave}
isDocumentPdfLoaded={isDocumentPdfLoaded}
/>
diff --git a/apps/remix/app/components/general/document/document-page-view-button.tsx b/apps/remix/app/components/general/document/document-page-view-button.tsx
index 55f6d85c2..e5fea4d2b 100644
--- a/apps/remix/app/components/general/document/document-page-view-button.tsx
+++ b/apps/remix/app/components/general/document/document-page-view-button.tsx
@@ -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,
},
diff --git a/apps/remix/app/components/general/document/document-page-view-dropdown.tsx b/apps/remix/app/components/general/document/document-page-view-dropdown.tsx
index a57a849dd..326c7553c 100644
--- a/apps/remix/app/components/general/document/document-page-view-dropdown.tsx
+++ b/apps/remix/app/components/general/document/document-page-view-dropdown.tsx
@@ -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,
},
@@ -164,7 +164,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
- Audit Log
+ Audit Logs
diff --git a/apps/remix/app/components/general/document/document-page-view-recent-activity.tsx b/apps/remix/app/components/general/document/document-page-view-recent-activity.tsx
index 10beae93b..abeeacbc4 100644
--- a/apps/remix/app/components/general/document/document-page-view-recent-activity.tsx
+++ b/apps/remix/app/components/general/document/document-page-view-recent-activity.tsx
@@ -32,7 +32,7 @@ export const DocumentPageViewRecentActivity = ({
hasNextPage,
fetchNextPage,
isFetchingNextPage,
- } = trpc.document.findDocumentAuditLogs.useInfiniteQuery(
+ } = trpc.document.auditLog.find.useInfiniteQuery(
{
documentId,
filterForRecentActivity: true,
diff --git a/apps/remix/app/components/general/document/document-upload.tsx b/apps/remix/app/components/general/document/document-upload.tsx
index c21fcc5f0..b86a12ecc 100644
--- a/apps/remix/app/components/general/document/document-upload.tsx
+++ b/apps/remix/app/components/general/document/document-upload.tsx
@@ -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) {
diff --git a/apps/remix/app/components/general/legacy-field-warning-popover.tsx b/apps/remix/app/components/general/legacy-field-warning-popover.tsx
index 6bd489c27..3165b1be7 100644
--- a/apps/remix/app/components/general/legacy-field-warning-popover.tsx
+++ b/apps/remix/app/components/general/legacy-field-warning-popover.tsx
@@ -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') {
diff --git a/apps/remix/app/components/general/org-menu-switcher.tsx b/apps/remix/app/components/general/org-menu-switcher.tsx
index 5ba8dcea4..c6c7c0040 100644
--- a/apps/remix/app/components/general/org-menu-switcher.tsx
+++ b/apps/remix/app/components/general/org-menu-switcher.tsx
@@ -321,6 +321,19 @@ export const OrgMenuSwitcher = () => {
Language
+ {currentOrganisation && (
+
+
+ Support
+
+
+ )}
+
authClient.signOut()}
diff --git a/apps/remix/app/components/general/template/template-drop-zone-wrapper.tsx b/apps/remix/app/components/general/template/template-drop-zone-wrapper.tsx
new file mode 100644
index 000000000..0d9c3fc9e
--- /dev/null
+++ b/apps/remix/app/components/general/template/template-drop-zone-wrapper.tsx
@@ -0,0 +1,129 @@
+import { type ReactNode, useState } from 'react';
+
+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 { useNavigate, useParams } from 'react-router';
+
+import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
+import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
+import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
+import { formatTemplatesPath } from '@documenso/lib/utils/teams';
+import { trpc } from '@documenso/trpc/react';
+import { cn } from '@documenso/ui/lib/utils';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+import { useCurrentTeam } from '~/providers/team';
+
+export interface TemplateDropZoneWrapperProps {
+ children: ReactNode;
+ className?: string;
+}
+
+export const TemplateDropZoneWrapper = ({ children, className }: TemplateDropZoneWrapperProps) => {
+ const { _ } = useLingui();
+ const { toast } = useToast();
+ const { folderId } = useParams();
+
+ const team = useCurrentTeam();
+
+ const navigate = useNavigate();
+
+ const [isLoading, setIsLoading] = useState(false);
+
+ const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation();
+
+ const onFileDrop = async (file: File) => {
+ try {
+ setIsLoading(true);
+
+ const documentData = await putPdfFile(file);
+
+ const { id } = await createTemplate({
+ title: file.name,
+ templateDocumentDataId: documentData.id,
+ folderId: folderId ?? undefined,
+ });
+
+ toast({
+ title: _(msg`Template uploaded`),
+ description: _(
+ msg`Your template has been uploaded successfully. You will be redirected to the template page.`,
+ ),
+ duration: 5000,
+ });
+
+ await navigate(`${formatTemplatesPath(team.url)}/${id}/edit`);
+ } catch {
+ toast({
+ title: _(msg`Something went wrong`),
+ description: _(msg`Please try again later.`),
+ variant: 'destructive',
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const onFileDropRejected = () => {
+ toast({
+ title: _(msg`Your template failed to upload.`),
+ description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`),
+ duration: 5000,
+ variant: 'destructive',
+ });
+ };
+
+ const { getRootProps, getInputProps, isDragActive } = useDropzone({
+ accept: {
+ 'application/pdf': ['.pdf'],
+ },
+ //disabled: isUploadDisabled,
+ multiple: false,
+ maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT),
+ onDrop: ([acceptedFile]) => {
+ if (acceptedFile) {
+ void onFileDrop(acceptedFile);
+ }
+ },
+ onDropRejected: () => {
+ void onFileDropRejected();
+ },
+ noClick: true,
+ noDragEventsBubbling: true,
+ });
+
+ return (
+
+
+ {children}
+
+ {isDragActive && (
+
+
+
+ Upload Template
+
+
+
+ Drag and drop your PDF file here
+
+
+
+ )}
+
+ {isLoading && (
+
+
+
+
+ Uploading template...
+
+
+
+ )}
+
+ );
+};
diff --git a/apps/remix/app/components/general/template/template-edit-form.tsx b/apps/remix/app/components/general/template/template-edit-form.tsx
index ec1f234db..17d7a45a1 100644
--- a/apps/remix/app/components/general/template/template-edit-form.tsx
+++ b/apps/remix/app/components/general/template/template-edit-form.tsx
@@ -124,31 +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,
- 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) {
@@ -162,24 +167,42 @@ 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) => {
+ return Promise.all([
+ updateTemplateSettings({
+ templateId: template.id,
+ meta: {
+ signingOrder: data.signingOrder,
+ allowDictateNextSigner: data.allowDictateNextSigner,
+ },
+ }),
+
+ setRecipients({
+ templateId: template.id,
+ recipients: data.signers,
+ }),
+ ]);
+ };
+
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) {
@@ -191,12 +214,46 @@ export const TemplateEditForm = ({
}
};
+ const onAddTemplatePlaceholderFormAutoSave = async (
+ data: TAddTemplatePlacholderRecipientsFormSchema,
+ ) => {
+ try {
+ 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',
+ });
+ }
+ };
+
+ 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++) {
@@ -269,11 +326,12 @@ export const TemplateEditForm = ({
recipients={recipients}
fields={fields}
onSubmit={onAddSettingsFormSubmit}
+ onAutoSave={onAddSettingsFormAutoSave}
isDocumentPdfLoaded={isDocumentPdfLoaded}
/>
diff --git a/apps/remix/app/components/general/template/template-page-view-documents-table.tsx b/apps/remix/app/components/general/template/template-page-view-documents-table.tsx
index bef9189d5..6072a8846 100644
--- a/apps/remix/app/components/general/template/template-page-view-documents-table.tsx
+++ b/apps/remix/app/components/general/template/template-page-view-documents-table.tsx
@@ -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,
diff --git a/apps/remix/app/components/general/template/template-page-view-recent-activity.tsx b/apps/remix/app/components/general/template/template-page-view-recent-activity.tsx
index e0f3f67c9..9b39a27a8 100644
--- a/apps/remix/app/components/general/template/template-page-view-recent-activity.tsx
+++ b/apps/remix/app/components/general/template/template-page-view-recent-activity.tsx
@@ -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',
diff --git a/apps/remix/app/components/general/template/template-page-view-recipients.tsx b/apps/remix/app/components/general/template/template-page-view-recipients.tsx
index 0a65b3a09..3896baf11 100644
--- a/apps/remix/app/components/general/template/template-page-view-recipients.tsx
+++ b/apps/remix/app/components/general/template/template-page-view-recipients.tsx
@@ -6,6 +6,8 @@ import { PenIcon, PlusIcon } from 'lucide-react';
import { Link } from 'react-router';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
+import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
+import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
export type TemplatePageViewRecipientsProps = {
@@ -53,8 +55,18 @@ export const TemplatePageViewRecipients = ({
{recipients.map((recipient) => (
{recipient.email}}
+ avatarFallback={
+ isTemplateRecipientEmailPlaceholder(recipient.email)
+ ? extractInitials(recipient.name)
+ : recipient.email.slice(0, 1).toUpperCase()
+ }
+ primaryText={
+ isTemplateRecipientEmailPlaceholder(recipient.email) ? (
+ {recipient.name}
+ ) : (
+ {recipient.email}
+ )
+ }
secondaryText={
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
diff --git a/apps/remix/app/components/tables/admin-document-recipient-item-table.tsx b/apps/remix/app/components/tables/admin-document-recipient-item-table.tsx
index 58e25b179..89a9366b1 100644
--- a/apps/remix/app/components/tables/admin-document-recipient-item-table.tsx
+++ b/apps/remix/app/components/tables/admin-document-recipient-item-table.tsx
@@ -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 [
diff --git a/apps/remix/app/components/tables/document-logs-table.tsx b/apps/remix/app/components/tables/document-logs-table.tsx
index 8cdae26d5..a042c6a44 100644
--- a/apps/remix/app/components/tables/document-logs-table.tsx
+++ b/apps/remix/app/components/tables/document-logs-table.tsx
@@ -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,
diff --git a/apps/remix/app/components/tables/documents-table-action-button.tsx b/apps/remix/app/components/tables/documents-table-action-button.tsx
index 1333ca912..c1d68c133 100644
--- a/apps/remix/app/components/tables/documents-table-action-button.tsx
+++ b/apps/remix/app/components/tables/documents-table-action-button.tsx
@@ -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,
},
diff --git a/apps/remix/app/components/tables/documents-table-action-dropdown.tsx b/apps/remix/app/components/tables/documents-table-action-dropdown.tsx
index 1186afb18..8114c6cc1 100644
--- a/apps/remix/app/components/tables/documents-table-action-dropdown.tsx
+++ b/apps/remix/app/components/tables/documents-table-action-dropdown.tsx
@@ -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({
diff --git a/apps/remix/app/components/tables/documents-table.tsx b/apps/remix/app/components/tables/documents-table.tsx
index fa5be7d2d..a003f4d0d 100644
--- a/apps/remix/app/components/tables/documents-table.tsx
+++ b/apps/remix/app/components/tables/documents-table.tsx
@@ -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';
diff --git a/apps/remix/app/components/tables/inbox-table.tsx b/apps/remix/app/components/tables/inbox-table.tsx
index 45f837c17..f2d138e0d 100644
--- a/apps/remix/app/components/tables/inbox-table.tsx
+++ b/apps/remix/app/components/tables/inbox-table.tsx
@@ -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();
diff --git a/apps/remix/app/components/tables/internal-audit-log-table.tsx b/apps/remix/app/components/tables/internal-audit-log-table.tsx
index addbb4174..b3b68147a 100644
--- a/apps/remix/app/components/tables/internal-audit-log-table.tsx
+++ b/apps/remix/app/components/tables/internal-audit-log-table.tsx
@@ -1,20 +1,18 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
-import { DateTime } from 'luxon';
import type { DateTimeFormatOptions } from 'luxon';
+import { DateTime } from 'luxon';
+import { P, match } from 'ts-pattern';
import { UAParser } from 'ua-parser-js';
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
-import type { TDocumentAuditLog } from '@documenso/lib/types/document-audit-logs';
-import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from '@documenso/ui/primitives/table';
+ DOCUMENT_AUDIT_LOG_TYPE,
+ type TDocumentAuditLog,
+} from '@documenso/lib/types/document-audit-logs';
+import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
+import { cn } from '@documenso/ui/lib/utils';
+import { Card, CardContent } from '@documenso/ui/primitives/card';
export type AuditLogDataTableProps = {
logs: TDocumentAuditLog[];
@@ -25,71 +23,129 @@ const dateFormat: DateTimeFormatOptions = {
hourCycle: 'h12',
};
+/**
+ * Get the color indicator for the audit log type
+ */
+
+const getAuditLogIndicatorColor = (type: string) =>
+ match(type)
+ .with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, () => 'bg-green-500')
+ .with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED, () => 'bg-red-500')
+ .with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT, () => 'bg-orange-500')
+ .with(
+ P.union(
+ DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
+ DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED,
+ ),
+ () => 'bg-blue-500',
+ )
+ .otherwise(() => 'bg-muted');
+
/**
* DO NOT USE TRANS. YOU MUST USE _ FOR THIS FILE AND ALL CHILDREN COMPONENTS.
*/
+
+const formatUserAgent = (userAgent: string | null | undefined, userAgentInfo: UAParser.IResult) => {
+ if (!userAgent) {
+ return msg`N/A`;
+ }
+
+ const browser = userAgentInfo.browser.name;
+ const version = userAgentInfo.browser.version;
+ const os = userAgentInfo.os.name;
+
+ // If we can parse meaningful browser info, format it nicely
+ if (browser && os) {
+ const browserInfo = version ? `${browser} ${version}` : browser;
+
+ return msg`${browserInfo} on ${os}`;
+ }
+
+ return msg`${userAgent}`;
+};
+
export const InternalAuditLogTable = ({ logs }: AuditLogDataTableProps) => {
const { _ } = useLingui();
const parser = new UAParser();
- const uppercaseFistLetter = (text: string) => {
- return text.charAt(0).toUpperCase() + text.slice(1);
- };
-
return (
-
-
-
- {_(msg`Time`)}
- {_(msg`User`)}
- {_(msg`Action`)}
- {_(msg`IP Address`)}
- {_(msg`Browser`)}
-
-
+
+ {logs.map((log, index) => {
+ parser.setUA(log.userAgent || '');
+ const formattedAction = formatDocumentAuditLogAction(_, log);
+ const userAgentInfo = parser.getResult();
-
- {logs.map((log, i) => (
-
-
- {DateTime.fromJSDate(log.createdAt)
- .setLocale(APP_I18N_OPTIONS.defaultLocale)
- .toLocaleString(dateFormat)}
-
+ return (
+ 0 ? 'print:mt-8' : ''}`}
+ style={{
+ pageBreakInside: 'avoid',
+ breakInside: 'avoid',
+ }}
+ >
+
+ {/* Header Section with indicator, event type, and timestamp */}
+
+
+
-
- {log.name || log.email ? (
-
- {log.name && (
-
- {log.name}
-
- )}
+
+
+ {log.type.replace(/_/g, ' ')}
+
- {log.email && (
-
- {log.email}
-
- )}
+
+ {formattedAction.description}
+
+
- ) : (
- N/A
- )}
-
-
- {uppercaseFistLetter(formatDocumentAuditLogAction(_, log).description)}
-
+
+ {DateTime.fromJSDate(log.createdAt)
+ .setLocale(APP_I18N_OPTIONS.defaultLocale)
+ .toLocaleString(dateFormat)}
+
+
-
{log.ipAddress}
+
-
- {log.userAgent ? parser.setUA(log.userAgent).getBrowser().name : 'N/A'}
-
-
- ))}
-
-
+ {/* Details Section - Two column layout */}
+
+
+
+ {_(msg`User`)}
+
+
+
{log.email || 'N/A'}
+
+
+
+
+ {_(msg`IP Address`)}
+
+
+
{log.ipAddress || 'N/A'}
+
+
+
+
+ {_(msg`User Agent`)}
+
+
+
+ {_(formatUserAgent(log.userAgent, userAgentInfo))}
+
+
+
+
+
+ );
+ })}
+
);
};
diff --git a/apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx b/apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx
index e86800149..835bebf55 100644
--- a/apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx
+++ b/apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx
@@ -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`),
diff --git a/apps/remix/app/components/tables/settings-security-passkey-table.tsx b/apps/remix/app/components/tables/settings-security-passkey-table.tsx
index 3d202900a..b2fe09621 100644
--- a/apps/remix/app/components/tables/settings-security-passkey-table.tsx
+++ b/apps/remix/app/components/tables/settings-security-passkey-table.tsx
@@ -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,
diff --git a/apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx b/apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
index db1b4d0e8..623ac0938 100644
--- a/apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
+++ b/apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
@@ -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`),
diff --git a/apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx b/apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx
index 35640b28e..27b7509f2 100644
--- a/apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx
+++ b/apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx
@@ -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,
diff --git a/apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx b/apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx
index fb05128a6..3cd0f5853 100644
--- a/apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx
+++ b/apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx
@@ -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,
@@ -27,17 +27,18 @@ import { AdminOrganisationCreateDialog } from '~/components/dialogs/admin-organi
import { AdminUserDeleteDialog } from '~/components/dialogs/admin-user-delete-dialog';
import { AdminUserDisableDialog } from '~/components/dialogs/admin-user-disable-dialog';
import { AdminUserEnableDialog } from '~/components/dialogs/admin-user-enable-dialog';
+import { AdminUserResetTwoFactorDialog } from '~/components/dialogs/admin-user-reset-two-factor-dialog';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { AdminOrganisationsTable } from '~/components/tables/admin-organisations-table';
import { MultiSelectRoleCombobox } from '../../../components/general/multiselect-role-combobox';
-const ZUserFormSchema = ZAdminUpdateProfileMutationSchema.omit({ id: true });
+const ZUserFormSchema = ZUpdateUserRequestSchema.omit({ id: true });
type TUserFormSchema = z.infer
;
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),
},
@@ -77,14 +78,14 @@ export default function UserPage({ params }: { params: { id: number } }) {
return ;
}
-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({
resolver: zodResolver(ZUserFormSchema),
@@ -219,10 +220,11 @@ const AdminUserPage = ({ user }: { user: User }) => {
/>
-
- {user &&
}
+
+ {user && user.twoFactorEnabled &&
}
{user && user.disabled &&
}
{user && !user.disabled &&
}
+ {user &&
}
);
diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
index 87326a5fa..1ef247147 100644
--- a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
+++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
@@ -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;
});
diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.document.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.document.tsx
index 9c3cfb6ce..17969c628 100644
--- a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.document.tsx
+++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.document.tsx
@@ -46,6 +46,7 @@ export default function OrganisationSettingsDocumentPage() {
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
+ includeAuditLog,
signatureTypes,
} = data;
@@ -54,7 +55,8 @@ export default function OrganisationSettingsDocumentPage() {
documentLanguage === null ||
documentDateFormat === null ||
includeSenderDetails === null ||
- includeSigningCertificate === null
+ includeSigningCertificate === null ||
+ includeAuditLog === null
) {
throw new Error('Should not be possible.');
}
@@ -68,6 +70,7 @@ export default function OrganisationSettingsDocumentPage() {
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
+ includeAuditLog,
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx
index e495d82ea..4a61b6fed 100644
--- a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx
+++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx
@@ -171,7 +171,7 @@ export default function OrganisationEmailDomainSettingsPage({ params }: Route.Co
+
View DNS Records
}
diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
new file mode 100644
index 000000000..db6b7c38d
--- /dev/null
+++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
@@ -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;
+
+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 ;
+ }
+
+ return (
+
+
+
+
+
+ );
+}
+
+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({
+ 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 (
+
+
+ );
+};
diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl.support.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl.support.tsx
new file mode 100644
index 000000000..dc27400d3
--- /dev/null
+++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl.support.tsx
@@ -0,0 +1,125 @@
+import { useState } from 'react';
+
+import { Trans } from '@lingui/react/macro';
+import { BookIcon, HelpCircleIcon, Link2Icon } from 'lucide-react';
+import { Link, useSearchParams } from 'react-router';
+
+import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
+import { useSession } from '@documenso/lib/client-only/providers/session';
+import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
+import { Button } from '@documenso/ui/primitives/button';
+
+import { SupportTicketForm } from '~/components/forms/support-ticket-form';
+import { appMetaTags } from '~/utils/meta';
+
+export function meta() {
+ return appMetaTags('Support');
+}
+
+export default function SupportPage() {
+ const [showForm, setShowForm] = useState(false);
+ const { user } = useSession();
+ const organisation = useCurrentOrganisation();
+
+ const [searchParams] = useSearchParams();
+
+ const teamId = searchParams.get('team');
+
+ const subscriptionStatus = organisation.subscription?.status;
+
+ const handleSuccess = () => {
+ setShowForm(false);
+ };
+
+ const handleCloseForm = () => {
+ setShowForm(false);
+ };
+
+ return (
+
+
+
+
+ Support
+
+
+
+ Your current plan includes the following support channels:
+
+
+
+
+
+
+
+ Documentation
+
+
+
+ Read our documentation to get started with Documenso.
+
+
+
+
+
+
+ Discord
+
+
+
+
+ Join our community on{' '}
+
+ Discord
+ {' '}
+ for community support and discussion.
+
+
+
+ {organisation && IS_BILLING_ENABLED() && subscriptionStatus && (
+ <>
+
+
+
+ Contact us
+
+
+ We'll get back to you as soon as possible via email.
+
+
+ {!showForm ? (
+ setShowForm(true)}>
+ Create a support ticket
+
+ ) : (
+
+ )}
+
+
+ >
+ )}
+
+
+
+ );
+}
diff --git a/apps/remix/app/routes/_authenticated+/settings+/security._index.tsx b/apps/remix/app/routes/_authenticated+/settings+/security._index.tsx
index eacaaf4fc..5d878d971 100644
--- a/apps/remix/app/routes/_authenticated+/settings+/security._index.tsx
+++ b/apps/remix/app/routes/_authenticated+/settings+/security._index.tsx
@@ -192,6 +192,27 @@ export default function SettingsSecurity({ loaderData }: Route.ComponentProps) {
+
+
+
+
+ Linked Accounts
+
+
+
+ View and manage all login methods linked to your account.
+
+
+
+
+
+ Manage linked accounts
+
+
+
);
}
diff --git a/apps/remix/app/routes/_authenticated+/settings+/security.linked-accounts.tsx b/apps/remix/app/routes/_authenticated+/settings+/security.linked-accounts.tsx
new file mode 100644
index 000000000..1c9c57914
--- /dev/null
+++ b/apps/remix/app/routes/_authenticated+/settings+/security.linked-accounts.tsx
@@ -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 }) => (
+
+ ),
+ },
+ ] satisfies DataTableColumnDef<(typeof results)[number]>[];
+ }, []);
+
+ return (
+
+ );
+}
+
+type AccountUnlinkDialogProps = {
+ accountId: string;
+ provider: string;
+ onSuccess: () => Promise