diff --git a/apps/documentation/pages/users/_meta.json b/apps/documentation/pages/users/_meta.json index 0d25e4a84..53733ea63 100644 --- a/apps/documentation/pages/users/_meta.json +++ b/apps/documentation/pages/users/_meta.json @@ -11,6 +11,7 @@ "templates": "Templates", "direct-links": "Direct Signing Links", "document-visibility": "Document Visibility", + "teams": "Teams", "-- Legal Overview": { "type": "separator", "title": "Legal Overview" diff --git a/apps/documentation/pages/users/document-visibility.mdx b/apps/documentation/pages/users/document-visibility.mdx deleted file mode 100644 index 8120f80bc..000000000 --- a/apps/documentation/pages/users/document-visibility.mdx +++ /dev/null @@ -1,18 +0,0 @@ ---- -title: Document Visibility -description: Learn how to control the visibility of your team documents. ---- - -# Team's Document Visibility - -By default, all documents created in a team are visible to all team members. However, you can control the visibility of your documents by changing the document's visibility settings. - -To set the visibility of a document, click on the **Document visibility** dropdown in the document's settings panel. - -![A screenshot of the Documenso's document editor page where you can update the document visibility](/document-visibility-settings.webp) - -The document visibility can be set to one of the following options: - -- **Everyone** - The document is visible to all team members. -- **Managers and above** - The document is visible to people with the role of Manager or above. -- **Admin only** - The document is only visible to the team's admins. diff --git a/apps/documentation/pages/users/teams/_meta.json b/apps/documentation/pages/users/teams/_meta.json new file mode 100644 index 000000000..b9548a39b --- /dev/null +++ b/apps/documentation/pages/users/teams/_meta.json @@ -0,0 +1,5 @@ +{ + "general-settings": "General Settings", + "document-visibility": "Document Visibility", + "sender-details": "Email Sender Details" +} diff --git a/apps/documentation/pages/users/teams/document-visibility.mdx b/apps/documentation/pages/users/teams/document-visibility.mdx new file mode 100644 index 000000000..8d2f82266 --- /dev/null +++ b/apps/documentation/pages/users/teams/document-visibility.mdx @@ -0,0 +1,45 @@ +--- +title: Document Visibility +description: Learn how to control the visibility of your team documents. +--- + +import { Callout } from 'nextra/components'; + +# Team's Document Visibility + +The default document visibility option allows you to control who can view and access the documents uploaded to your team account. The document visibility can be set to one of the following options: + +- **Everyone** - The document is visible to all team members. +- **Managers and above** - The document is visible to team members with the role of _Manager or above_ and _Admin_. +- **Admin only** - The document is only visible to the team's admins. + +![A screenshot of the document visibility selector from the team's general settings page](/teams/team-general-settings-document-visibility-select.webp) + +The default document visibility is set to "_EVERYONE_" by default. You can change this setting by going to the [team's general settings page](/users/teams/general-settings) and selecting a different visibility option. + + + If the team member uploading the document has a role lower than the default document visibility, + the document visibility will be set to a lower visibility level matching the team member's role. + + +Here's how it works: + +- If a user with the "_Member_" role creates a document and the default document visibility is set to "_Admin_" or "_Managers and above_", the document's visibility is set to "_Everyone_". +- If a user with the "_Manager_" role creates a document and the default document visibility is set to "_Admin_", the document's visibility is set to "_Managers and above_". +- Otherwise, the document's visibility is set to the default document visibility. + +You can change the visibility of a document at any time by editing the document and selecting a different visibility option. + +![A screenshot of the Documenso's document editor page where you can update the document visibility](/teams/document-visibility-settings.webp) + + + Updating the default document visibility in the team's general settings will not affect the + visibility of existing documents. You will need to update the visibility of each document + individually. + + +## A Note on Document Access + +The `document owner` (the user who created the document) always has access to the document, regardless of the document's visibility settings. This means that even if a document is set to "Admins only", the document owner can still view and edit the document. + +The `recipient` (the user who receives the document for signature, approval, etc.) also has access to the document, regardless of the document's visibility settings. This means that even if a document is set to "Admins only", the recipient can still view and sign the document. diff --git a/apps/documentation/pages/users/teams/general-settings.mdx b/apps/documentation/pages/users/teams/general-settings.mdx new file mode 100644 index 000000000..e10d379b0 --- /dev/null +++ b/apps/documentation/pages/users/teams/general-settings.mdx @@ -0,0 +1,15 @@ +--- +title: General Settings +description: Learn how to manage your team's General settings. +--- + +# General Settings + +You can manage your team's general settings by clicking on the **General Settings** tab in the team's settings dashboard. + +![A screenshot of team's General settings page](/teams/team-general-settings.webp) + +The general settings page allows you to update the following settings: + +- **Document Visibility** - Set the default visibility of the documents created by team members. Learn more about [document visibility](/users/teams/document-visibility). +- **Sender Details** - Set whether the sender's name should be included in the emails sent by the team. Learn more about [sender details](/users/teams/sender-details). diff --git a/apps/documentation/pages/users/teams/sender-details.mdx b/apps/documentation/pages/users/teams/sender-details.mdx new file mode 100644 index 000000000..196cd22e7 --- /dev/null +++ b/apps/documentation/pages/users/teams/sender-details.mdx @@ -0,0 +1,14 @@ +--- +title: Email Sender Details +description: Learn how to update the sender details for your team's email notifications. +--- + +## Sender Details + +If the **Sender Details** setting is enabled, the emails sent by the team will include the sender's name. The email will say: + +> "Example User" on behalf of "Example Team" has invited you to sign "document.pdf" + +If the **Sender Details** setting is disabled, the emails sent by the team will not include the sender's name. The email will say: + +> "Example Team" has invited you to sign "document.pdf" diff --git a/apps/documentation/public/document-visibility-settings.webp b/apps/documentation/public/teams/document-visibility-settings.webp similarity index 100% rename from apps/documentation/public/document-visibility-settings.webp rename to apps/documentation/public/teams/document-visibility-settings.webp diff --git a/apps/documentation/public/teams/team-general-settings-document-visibility-select.webp b/apps/documentation/public/teams/team-general-settings-document-visibility-select.webp new file mode 100644 index 000000000..ef312eeeb Binary files /dev/null and b/apps/documentation/public/teams/team-general-settings-document-visibility-select.webp differ diff --git a/apps/documentation/public/teams/team-general-settings.webp b/apps/documentation/public/teams/team-general-settings.webp new file mode 100644 index 000000000..3b5607e6a Binary files /dev/null and b/apps/documentation/public/teams/team-general-settings.webp differ diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx index d4af4ee62..d4b137aeb 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx @@ -74,7 +74,7 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) const isRecipient = document?.Recipient.find((recipient) => recipient.email === user.email); let canAccessDocument = true; - if (team && !isRecipient) { + if (team && !isRecipient && document?.userId !== user.id) { canAccessDocument = match([documentVisibility, currentTeamMemberRole]) .with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true) .with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true) diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx index 5c659ad46..3030794ba 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx @@ -12,6 +12,7 @@ import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META, } from '@documenso/lib/constants/trpc'; +import { DocumentDistributionMethod, DocumentStatus } from '@documenso/prisma/client'; import type { DocumentWithDetails } from '@documenso/prisma/types/document'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; @@ -177,8 +178,8 @@ export const EditDocumentForm = ({ stepIndex: 3, }, subject: { - title: msg`Add Subject`, - description: msg`Add the subject and message you wish to send to signers.`, + title: msg`Distribute Document`, + description: msg`Choose how the document will reach recipients`, stepIndex: 4, }, }; @@ -307,7 +308,7 @@ export const EditDocumentForm = ({ }; const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => { - const { subject, message } = data.meta; + const { subject, message, distributionMethod, emailSettings } = data.meta; try { await sendDocument({ @@ -316,16 +317,31 @@ export const EditDocumentForm = ({ meta: { subject, message, + distributionMethod, + emailSettings, }, }); - toast({ - title: _(msg`Document sent`), - description: _(msg`Your document has been sent successfully.`), - duration: 5000, - }); + if (distributionMethod === DocumentDistributionMethod.EMAIL) { + toast({ + title: _(msg`Document sent`), + description: _(msg`Your document has been sent successfully.`), + duration: 5000, + }); - router.push(documentRootPath); + router.push(documentRootPath); + return; + } + + if (document.status === DocumentStatus.DRAFT) { + toast({ + title: _(msg`Links Generated`), + description: _(msg`Signing links have been generated for this document.`), + duration: 5000, + }); + } else { + router.push(`${documentRootPath}/${document.id}`); + } } catch (err) { console.error(err); diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx index 42141e451..23357074a 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx @@ -55,7 +55,7 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie const isRecipient = document?.Recipient.find((recipient) => recipient.email === user.email); let canAccessDocument = true; - if (!isRecipient) { + if (!isRecipient && document?.userId !== user.id) { canAccessDocument = match([documentVisibility, currentTeamMemberRole]) .with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true) .with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true) diff --git a/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx b/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx index 277f9d36f..65213c777 100644 --- a/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx +++ b/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx @@ -145,6 +145,7 @@ export const TemplatesDataTable = ({ diff --git a/apps/web/src/app/(dashboard)/templates/use-template-dialog.tsx b/apps/web/src/app/(dashboard)/templates/use-template-dialog.tsx index 8ff5eea07..ee07d8b29 100644 --- a/apps/web/src/app/(dashboard)/templates/use-template-dialog.tsx +++ b/apps/web/src/app/(dashboard)/templates/use-template-dialog.tsx @@ -17,7 +17,7 @@ import { } from '@documenso/lib/constants/template'; import { AppError } from '@documenso/lib/errors/app-error'; import type { Recipient } from '@documenso/prisma/client'; -import { DocumentSigningOrder } from '@documenso/prisma/client'; +import { DocumentDistributionMethod, DocumentSigningOrder } from '@documenso/prisma/client'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; @@ -49,7 +49,7 @@ import { useOptionalCurrentTeam } from '~/providers/team'; const ZAddRecipientsForNewDocumentSchema = z .object({ - sendDocument: z.boolean(), + distributeDocument: z.boolean(), recipients: z.array( z.object({ id: z.number(), @@ -93,12 +93,14 @@ export type UseTemplateDialogProps = { templateId: number; templateSigningOrder?: DocumentSigningOrder | null; recipients: Recipient[]; + documentDistributionMethod?: DocumentDistributionMethod; documentRootPath: string; trigger?: React.ReactNode; }; export function UseTemplateDialog({ recipients, + documentDistributionMethod = DocumentDistributionMethod.EMAIL, documentRootPath, templateId, templateSigningOrder, @@ -116,7 +118,7 @@ export function UseTemplateDialog({ const form = useForm({ resolver: zodResolver(ZAddRecipientsForNewDocumentSchema), defaultValues: { - sendDocument: false, + distributeDocument: false, recipients: recipients .sort((a, b) => (a.signingOrder || 0) - (b.signingOrder || 0)) .map((recipient) => { @@ -147,7 +149,7 @@ export function UseTemplateDialog({ templateId, teamId: team?.id, recipients: data.recipients, - sendDocument: data.sendDocument, + distributeDocument: data.distributeDocument, }); toast({ @@ -156,7 +158,16 @@ export function UseTemplateDialog({ duration: 5000, }); - router.push(`${documentRootPath}/${id}`); + let documentPath = `${documentRootPath}/${id}`; + + if ( + data.distributeDocument && + documentDistributionMethod === DocumentDistributionMethod.NONE + ) { + documentPath += '?action=view-signing-links'; + } + + router.push(documentPath); } catch (err) { const error = AppError.parseError(err); @@ -295,43 +306,76 @@ export function UseTemplateDialog({
(
- + )} + + {documentDistributionMethod === DocumentDistributionMethod.NONE && ( + + )}
)} @@ -347,10 +391,12 @@ export function UseTemplateDialog({ diff --git a/apps/web/src/app/(recipient)/d/[token]/direct-template.tsx b/apps/web/src/app/(recipient)/d/[token]/direct-template.tsx index 4f9e99fb1..e1c38f3da 100644 --- a/apps/web/src/app/(recipient)/d/[token]/direct-template.tsx +++ b/apps/web/src/app/(recipient)/d/[token]/direct-template.tsx @@ -7,7 +7,7 @@ import { useRouter, useSearchParams } from 'next/navigation'; import { msg } from '@lingui/macro'; import { useLingui } from '@lingui/react'; -import { RECIPIENT_ROLES_DESCRIPTION_ENG } from '@documenso/lib/constants/recipient-roles'; +import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import type { Field } from '@documenso/prisma/client'; import { type Recipient } from '@documenso/prisma/client'; import type { TemplateWithDetails } from '@documenso/prisma/types/template'; @@ -53,7 +53,9 @@ export const DirectTemplatePageView = ({ const [step, setStep] = useState('configure'); const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false); - const recipientRoleDescription = RECIPIENT_ROLES_DESCRIPTION_ENG[directTemplateRecipient.role]; + const recipientActionVerb = _( + RECIPIENT_ROLES_DESCRIPTION[directTemplateRecipient.role].actionVerb, + ); const directTemplateFlow: Record = { configure: { @@ -62,9 +64,8 @@ export const DirectTemplatePageView = ({ stepIndex: 1, }, sign: { - // Todo: Translations - title: msg`${recipientRoleDescription.actionVerb} document`, - description: msg`${recipientRoleDescription.actionVerb} the document to complete the process.`, + title: msg`${recipientActionVerb} document`, + description: msg`${recipientActionVerb} the document to complete the process.`, stepIndex: 2, }, }; diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/page.tsx index 3a72cb255..ed29a5287 100644 --- a/apps/web/src/app/(teams)/t/[teamUrl]/settings/page.tsx +++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/page.tsx @@ -52,7 +52,13 @@ export default async function TeamsSettingsPage({ params }: TeamsSettingsPagePro - +
{(team.teamEmail || team.emailVerification) && ( diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/preferences/branding-preferences.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/preferences/branding-preferences.tsx new file mode 100644 index 000000000..3f937a0b8 --- /dev/null +++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/preferences/branding-preferences.tsx @@ -0,0 +1,319 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { Loader } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { getFile } from '@documenso/lib/universal/upload/get-file'; +import { putFile } from '@documenso/lib/universal/upload/put-file'; +import type { Team, TeamGlobalSettings } from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { Switch } from '@documenso/ui/primitives/switch'; +import { Textarea } from '@documenso/ui/primitives/textarea'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB +const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp']; + +const ZTeamBrandingPreferencesFormSchema = z.object({ + brandingEnabled: z.boolean(), + brandingLogo: z + .instanceof(File) + .refine((file) => file.size <= MAX_FILE_SIZE, 'File size must be less than 5MB') + .refine( + (file) => ACCEPTED_FILE_TYPES.includes(file.type), + 'Only .jpg, .png, and .webp files are accepted', + ) + .nullish(), + brandingUrl: z.string().url().optional().or(z.literal('')), + brandingCompanyDetails: z.string().max(500).optional(), +}); + +type TTeamBrandingPreferencesFormSchema = z.infer; + +export type TeamBrandingPreferencesFormProps = { + team: Team; + settings?: TeamGlobalSettings | null; +}; + +export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPreferencesFormProps) { + const { _ } = useLingui(); + const { toast } = useToast(); + + const [previewUrl, setPreviewUrl] = useState(''); + const [hasLoadedPreview, setHasLoadedPreview] = useState(false); + + const { mutateAsync: updateTeamBrandingSettings } = + trpc.team.updateTeamBrandingSettings.useMutation(); + + const form = useForm({ + defaultValues: { + brandingEnabled: settings?.brandingEnabled ?? false, + brandingUrl: settings?.brandingUrl ?? '', + brandingLogo: undefined, + brandingCompanyDetails: settings?.brandingCompanyDetails ?? '', + }, + resolver: zodResolver(ZTeamBrandingPreferencesFormSchema), + }); + + const isBrandingEnabled = form.watch('brandingEnabled'); + + const onSubmit = async (data: TTeamBrandingPreferencesFormSchema) => { + try { + const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails } = data; + + let uploadedBrandingLogo = settings?.brandingLogo; + + if (brandingLogo) { + uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo)); + } + + if (brandingLogo === null) { + uploadedBrandingLogo = ''; + } + + await updateTeamBrandingSettings({ + teamId: team.id, + settings: { + brandingEnabled, + brandingLogo: uploadedBrandingLogo, + brandingUrl, + brandingCompanyDetails, + }, + }); + + toast({ + title: _(msg`Branding preferences updated`), + description: _(msg`Your branding preferences have been updated`), + }); + } catch (err) { + toast({ + title: _(msg`Something went wrong`), + description: _( + msg`We were unable to update your branding preferences at this time, please try again later`, + ), + variant: 'destructive', + }); + } + }; + + useEffect(() => { + if (settings?.brandingLogo) { + const file = JSON.parse(settings.brandingLogo); + + if ('type' in file && 'data' in file) { + void getFile(file).then((binaryData) => { + const objectUrl = URL.createObjectURL(new Blob([binaryData])); + + setPreviewUrl(objectUrl); + setHasLoadedPreview(true); + }); + + return; + } + } + + setHasLoadedPreview(true); + }, [settings?.brandingLogo]); + + // Cleanup ObjectURL on unmount or when previewUrl changes + useEffect(() => { + return () => { + if (previewUrl.startsWith('blob:')) { + URL.revokeObjectURL(previewUrl); + } + }; + }, [previewUrl]); + + return ( +
+ +
+ ( + + Enable Custom Branding + +
+ + + +
+ + + Enable custom branding for all documents in this team. + +
+ )} + /> + +
+ {!isBrandingEnabled &&
} + + ( + + Branding Logo + +
+
+ {previewUrl ? ( + Logo preview + ) : ( +
+ Please upload a logo + {!hasLoadedPreview && ( +
+ +
+ )} +
+ )} +
+ +
+ + { + const file = e.target.files?.[0]; + + if (file) { + if (previewUrl.startsWith('blob:')) { + URL.revokeObjectURL(previewUrl); + } + + const objectUrl = URL.createObjectURL(file); + + setPreviewUrl(objectUrl); + + onChange(file); + } + }} + className={cn( + 'h-auto p-2', + 'file:text-primary hover:file:bg-primary/90', + 'file:mr-4 file:cursor-pointer file:rounded-md file:border-0', + 'file:p-2 file:py-2 file:font-medium', + 'file:bg-primary file:text-primary-foreground', + !isBrandingEnabled && 'cursor-not-allowed', + )} + {...field} + /> + + +
+ +
+
+ + + Upload your brand logo (max 5MB, JPG, PNG, or WebP) + +
+
+ )} + /> + + ( + + Brand Website + + + + + + + Your brand website URL + + + )} + /> + + ( + + Brand Details + + +