mirror of
https://github.com/documenso/documenso.git
synced 2025-11-19 19:21:39 +10:00
Merge branch "main"
This commit is contained in:
@ -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)
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -145,6 +145,7 @@ export const TemplatesDataTable = ({
|
||||
<UseTemplateDialog
|
||||
templateId={row.original.id}
|
||||
templateSigningOrder={row.original.templateMeta?.signingOrder}
|
||||
documentDistributionMethod={row.original.templateMeta?.distributionMethod}
|
||||
recipients={row.original.Recipient}
|
||||
documentRootPath={documentRootPath}
|
||||
/>
|
||||
|
||||
@ -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<TAddRecipientsForNewDocumentSchema>({
|
||||
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({
|
||||
<div className="mt-4 flex flex-row items-center">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sendDocument"
|
||||
name="distributeDocument"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex flex-row items-center">
|
||||
<Checkbox
|
||||
id="sendDocument"
|
||||
id="distributeDocument"
|
||||
className="h-5 w-5"
|
||||
checkClassName="dark:text-white text-primary"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
|
||||
<label
|
||||
className="text-muted-foreground ml-2 flex items-center text-sm"
|
||||
htmlFor="sendDocument"
|
||||
>
|
||||
<Trans>Send document</Trans>
|
||||
<Tooltip>
|
||||
<TooltipTrigger type="button">
|
||||
<InfoIcon className="mx-1 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
{documentDistributionMethod === DocumentDistributionMethod.EMAIL && (
|
||||
<label
|
||||
className="text-muted-foreground ml-2 flex items-center text-sm"
|
||||
htmlFor="distributeDocument"
|
||||
>
|
||||
<Trans>Send document</Trans>
|
||||
<Tooltip>
|
||||
<TooltipTrigger type="button">
|
||||
<InfoIcon className="mx-1 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
|
||||
<p>
|
||||
<Trans>
|
||||
{' '}
|
||||
The document will be immediately sent to recipients if this is
|
||||
checked.
|
||||
</Trans>
|
||||
</p>
|
||||
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
|
||||
<p>
|
||||
<Trans>
|
||||
The document will be immediately sent to recipients if this is
|
||||
checked.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<Trans>Otherwise, the document will be created as a draft.</Trans>
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</label>
|
||||
<p>
|
||||
<Trans>
|
||||
Otherwise, the document will be created as a draft.
|
||||
</Trans>
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{documentDistributionMethod === DocumentDistributionMethod.NONE && (
|
||||
<label
|
||||
className="text-muted-foreground ml-2 flex items-center text-sm"
|
||||
htmlFor="distributeDocument"
|
||||
>
|
||||
<Trans>Create as pending</Trans>
|
||||
<Tooltip>
|
||||
<TooltipTrigger type="button">
|
||||
<InfoIcon className="mx-1 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
|
||||
<p>
|
||||
<Trans>Create the document as pending and ready to sign.</Trans>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<Trans>We won't send anything to notify recipients.</Trans>
|
||||
</p>
|
||||
|
||||
<p className="mt-2">
|
||||
<Trans>
|
||||
We will generate signing links for you, which you can send to
|
||||
the recipients through your method of choice.
|
||||
</Trans>
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
@ -347,10 +391,12 @@ export function UseTemplateDialog({
|
||||
</DialogClose>
|
||||
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
{form.getValues('sendDocument') ? (
|
||||
{!form.getValues('distributeDocument') ? (
|
||||
<Trans>Create as draft</Trans>
|
||||
) : documentDistributionMethod === DocumentDistributionMethod.EMAIL ? (
|
||||
<Trans>Create and send</Trans>
|
||||
) : (
|
||||
<Trans>Create as draft</Trans>
|
||||
<Trans>Create signing links</Trans>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@ -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<DirectTemplateStep>('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<DirectTemplateStep, DocumentFlowStep> = {
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
@ -52,7 +52,13 @@ export default async function TeamsSettingsPage({ params }: TeamsSettingsPagePro
|
||||
|
||||
<AvatarImageForm className="mb-8" team={team} user={session.user} />
|
||||
|
||||
<UpdateTeamForm teamId={team.id} teamName={team.name} teamUrl={team.url} />
|
||||
<UpdateTeamForm
|
||||
teamId={team.id}
|
||||
teamName={team.name}
|
||||
teamUrl={team.url}
|
||||
documentVisibility={team.teamGlobalSettings?.documentVisibility}
|
||||
includeSenderDetails={team.teamGlobalSettings?.includeSenderDetails}
|
||||
/>
|
||||
|
||||
<section className="mt-6 space-y-6">
|
||||
{(team.teamEmail || team.emailVerification) && (
|
||||
|
||||
@ -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<typeof ZTeamBrandingPreferencesFormSchema>;
|
||||
|
||||
export type TeamBrandingPreferencesFormProps = {
|
||||
team: Team;
|
||||
settings?: TeamGlobalSettings | null;
|
||||
};
|
||||
|
||||
export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPreferencesFormProps) {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [previewUrl, setPreviewUrl] = useState<string>('');
|
||||
const [hasLoadedPreview, setHasLoadedPreview] = useState(false);
|
||||
|
||||
const { mutateAsync: updateTeamBrandingSettings } =
|
||||
trpc.team.updateTeamBrandingSettings.useMutation();
|
||||
|
||||
const form = useForm<TTeamBrandingPreferencesFormSchema>({
|
||||
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 (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<fieldset
|
||||
className="flex h-full max-w-xl flex-col gap-y-4"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="brandingEnabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>Enable Custom Branding</FormLabel>
|
||||
|
||||
<div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
ref={field.ref}
|
||||
name={field.name}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>Enable custom branding for all documents in this team.</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="relative flex w-full flex-col gap-y-4">
|
||||
{!isBrandingEnabled && <div className="bg-background/60 absolute inset-0 z-[9999]" />}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="brandingLogo"
|
||||
render={({ field: { value: _value, onChange, ...field } }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>Branding Logo</FormLabel>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="border-border bg-background relative h-48 w-full overflow-hidden rounded-lg border">
|
||||
{previewUrl ? (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Logo preview"
|
||||
className="h-full w-full object-contain p-4"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-muted/20 dark:bg-muted text-muted-foreground relative flex h-full w-full items-center justify-center text-sm">
|
||||
Please upload a logo
|
||||
{!hasLoadedPreview && (
|
||||
<div className="bg-muted dark:bg-muted absolute inset-0 z-[999] flex items-center justify-center">
|
||||
<Loader className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<FormControl className="relative">
|
||||
<Input
|
||||
type="file"
|
||||
accept={ACCEPTED_FILE_TYPES.join(',')}
|
||||
disabled={!isBrandingEnabled}
|
||||
onChange={(e) => {
|
||||
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}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<div className="absolute right-2 top-0 inline-flex h-full items-center justify-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="text-destructive text-xs"
|
||||
onClick={() => {
|
||||
setPreviewUrl('');
|
||||
onChange(null);
|
||||
}}
|
||||
>
|
||||
<Trans>Remove</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>Upload your brand logo (max 5MB, JPG, PNG, or WebP)</Trans>
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="brandingUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>Brand Website</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="https://example.com"
|
||||
disabled={!isBrandingEnabled}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>Your brand website URL</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="brandingCompanyDetails"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>Brand Details</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={_(msg`Enter your brand details`)}
|
||||
className="min-h-[100px] resize-y"
|
||||
disabled={!isBrandingEnabled}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>Additional brand information to display at the bottom of emails</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row justify-end space-x-4">
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Save</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,235 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
SUPPORTED_LANGUAGES,
|
||||
SUPPORTED_LANGUAGE_CODES,
|
||||
isValidLanguageCode,
|
||||
} from '@documenso/lib/constants/i18n';
|
||||
import type { Team, TeamGlobalSettings } from '@documenso/prisma/client';
|
||||
import { DocumentVisibility } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { Switch } from '@documenso/ui/primitives/switch';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
const ZTeamDocumentPreferencesFormSchema = z.object({
|
||||
documentVisibility: z.nativeEnum(DocumentVisibility),
|
||||
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES),
|
||||
includeSenderDetails: z.boolean(),
|
||||
});
|
||||
|
||||
type TTeamDocumentPreferencesFormSchema = z.infer<typeof ZTeamDocumentPreferencesFormSchema>;
|
||||
|
||||
export type TeamDocumentPreferencesFormProps = {
|
||||
team: Team;
|
||||
settings?: TeamGlobalSettings | null;
|
||||
};
|
||||
|
||||
export const TeamDocumentPreferencesForm = ({
|
||||
team,
|
||||
settings,
|
||||
}: TeamDocumentPreferencesFormProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { data } = useSession();
|
||||
|
||||
const placeholderEmail = data?.user.email ?? 'user@example.com';
|
||||
|
||||
const { mutateAsync: updateTeamDocumentPreferences } =
|
||||
trpc.team.updateTeamDocumentSettings.useMutation();
|
||||
|
||||
const form = useForm<TTeamDocumentPreferencesFormSchema>({
|
||||
defaultValues: {
|
||||
documentVisibility: settings?.documentVisibility ?? 'EVERYONE',
|
||||
documentLanguage: isValidLanguageCode(settings?.documentLanguage)
|
||||
? settings?.documentLanguage
|
||||
: 'en',
|
||||
includeSenderDetails: settings?.includeSenderDetails ?? false,
|
||||
},
|
||||
resolver: zodResolver(ZTeamDocumentPreferencesFormSchema),
|
||||
});
|
||||
|
||||
const includeSenderDetails = form.watch('includeSenderDetails');
|
||||
|
||||
const onSubmit = async (data: TTeamDocumentPreferencesFormSchema) => {
|
||||
try {
|
||||
const { documentVisibility, documentLanguage, includeSenderDetails } = data;
|
||||
|
||||
await updateTeamDocumentPreferences({
|
||||
teamId: team.id,
|
||||
settings: {
|
||||
documentVisibility,
|
||||
documentLanguage,
|
||||
includeSenderDetails,
|
||||
},
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Document preferences updated`),
|
||||
description: _(msg`Your document preferences have been updated`),
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`Something went wrong!`),
|
||||
description: _(
|
||||
msg`We were unable to update your document preferences at this time, please try again later`,
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<fieldset
|
||||
className="flex h-full max-w-xl flex-col gap-y-4"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="documentVisibility"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Default Document Visibility</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="bg-background text-muted-foreground">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value={DocumentVisibility.EVERYONE}>
|
||||
<Trans>Everyone can access and view the document</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE}>
|
||||
<Trans>Only managers and above can access and view the document</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentVisibility.ADMIN}>
|
||||
<Trans>Only admins can access and view the document</Trans>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>Controls the default visibility of an uploaded document.</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="documentLanguage"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Default Document Language</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="bg-background text-muted-foreground">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
{Object.entries(SUPPORTED_LANGUAGES).map(([code, language]) => (
|
||||
<SelectItem key={code} value={code}>
|
||||
{language.full}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Controls the default language of an uploaded document. This will be used as the
|
||||
language in email communications with the recipients.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="includeSenderDetails"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Send on Behalf of Team</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<div>
|
||||
<FormControl className="block">
|
||||
<Switch
|
||||
ref={field.ref}
|
||||
name={field.name}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<div className="text-muted-foreground text-xs font-medium">
|
||||
<Trans>Preview</Trans>
|
||||
</div>
|
||||
|
||||
<Alert variant="neutral" className="mt-1 px-2.5 py-1.5 text-sm">
|
||||
{includeSenderDetails
|
||||
? _(msg`"${placeholderEmail}" on behalf of "${team.name}" has invited you to sign "example
|
||||
document".`)
|
||||
: _(msg`"${team.name}" has invited you to sign "example document".`)}
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Controls the formatting of the message that will be sent when inviting a
|
||||
recipient to sign a document. If a custom message has been provided while
|
||||
configuring the document, it will be used instead.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-row justify-end space-x-4">
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Save</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,52 @@
|
||||
import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
|
||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||
|
||||
import { TeamBrandingPreferencesForm } from './branding-preferences';
|
||||
import { TeamDocumentPreferencesForm } from './document-preferences';
|
||||
|
||||
export type TeamsSettingsPageProps = {
|
||||
params: {
|
||||
teamUrl: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function TeamsSettingsPage({ params }: TeamsSettingsPageProps) {
|
||||
await setupI18nSSR();
|
||||
|
||||
const { _ } = useLingui();
|
||||
|
||||
const { teamUrl } = params;
|
||||
|
||||
const session = await getRequiredServerComponentSession();
|
||||
|
||||
const team = await getTeamByUrl({ userId: session.user.id, teamUrl });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title={_(msg`Team Preferences`)}
|
||||
subtitle={_(msg`Here you can set preferences and defaults for your team.`)}
|
||||
/>
|
||||
|
||||
<section>
|
||||
<TeamDocumentPreferencesForm team={team} settings={team.teamGlobalSettings} />
|
||||
</section>
|
||||
|
||||
<SettingsHeader
|
||||
title={_(msg`Branding Preferences`)}
|
||||
subtitle={_(msg`Here you can set preferences and defaults for branding.`)}
|
||||
className="mt-8"
|
||||
/>
|
||||
|
||||
<section>
|
||||
<TeamBrandingPreferencesForm team={team} settings={team.teamGlobalSettings} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -6,14 +6,22 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { DocumentVisibility } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZUpdateTeamMutationSchema } from '@documenso/trpc/server/team-router/schema';
|
||||
import {
|
||||
DocumentVisibilitySelect,
|
||||
DocumentVisibilityTooltip,
|
||||
} from '@documenso/ui/components/document/document-visibility-select';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@ -29,18 +37,29 @@ export type UpdateTeamDialogProps = {
|
||||
teamId: number;
|
||||
teamName: string;
|
||||
teamUrl: string;
|
||||
documentVisibility?: DocumentVisibility;
|
||||
includeSenderDetails?: boolean;
|
||||
};
|
||||
|
||||
const ZUpdateTeamFormSchema = ZUpdateTeamMutationSchema.shape.data.pick({
|
||||
name: true,
|
||||
url: true,
|
||||
documentVisibility: true,
|
||||
includeSenderDetails: true,
|
||||
});
|
||||
|
||||
type TUpdateTeamFormSchema = z.infer<typeof ZUpdateTeamFormSchema>;
|
||||
|
||||
export const UpdateTeamForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogProps) => {
|
||||
export const UpdateTeamForm = ({
|
||||
teamId,
|
||||
teamName,
|
||||
teamUrl,
|
||||
documentVisibility,
|
||||
includeSenderDetails,
|
||||
}: UpdateTeamDialogProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { data: session } = useSession();
|
||||
const email = session?.user?.email;
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
@ -49,17 +68,36 @@ export const UpdateTeamForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogPr
|
||||
defaultValues: {
|
||||
name: teamName,
|
||||
url: teamUrl,
|
||||
documentVisibility,
|
||||
includeSenderDetails,
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: updateTeam } = trpc.team.updateTeam.useMutation();
|
||||
const includeSenderDetailsCheck = form.watch('includeSenderDetails');
|
||||
|
||||
const onFormSubmit = async ({ name, url }: TUpdateTeamFormSchema) => {
|
||||
const mapVisibilityToRole = (visibility: DocumentVisibility): DocumentVisibility =>
|
||||
match(visibility)
|
||||
.with(DocumentVisibility.ADMIN, () => DocumentVisibility.ADMIN)
|
||||
.with(DocumentVisibility.MANAGER_AND_ABOVE, () => DocumentVisibility.MANAGER_AND_ABOVE)
|
||||
.otherwise(() => DocumentVisibility.EVERYONE);
|
||||
|
||||
const currentVisibilityRole = mapVisibilityToRole(
|
||||
documentVisibility ?? DocumentVisibility.EVERYONE,
|
||||
);
|
||||
const onFormSubmit = async ({
|
||||
name,
|
||||
url,
|
||||
documentVisibility,
|
||||
includeSenderDetails,
|
||||
}: TUpdateTeamFormSchema) => {
|
||||
try {
|
||||
await updateTeam({
|
||||
data: {
|
||||
name,
|
||||
url,
|
||||
documentVisibility,
|
||||
includeSenderDetails,
|
||||
},
|
||||
teamId,
|
||||
});
|
||||
@ -73,6 +111,8 @@ export const UpdateTeamForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogPr
|
||||
form.reset({
|
||||
name,
|
||||
url,
|
||||
documentVisibility,
|
||||
includeSenderDetails,
|
||||
});
|
||||
|
||||
if (url !== teamUrl) {
|
||||
@ -146,6 +186,68 @@ export const UpdateTeamForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogPr
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="documentVisibility"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="mt-4 flex flex-row items-center">
|
||||
<Trans>Default Document Visibility</Trans>
|
||||
<DocumentVisibilityTooltip />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<DocumentVisibilitySelect
|
||||
currentMemberRole={currentVisibilityRole}
|
||||
isTeamSettings={true}
|
||||
{...field}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mb-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="includeSenderDetails"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="mt-6 flex flex-row items-center gap-4">
|
||||
<FormLabel>
|
||||
<Trans>Send on Behalf of Team</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
className="h-5 w-5"
|
||||
checkClassName="text-white"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
{includeSenderDetailsCheck ? (
|
||||
<blockquote className="text-foreground/50 text-xs italic">
|
||||
<Trans>
|
||||
"{email}" on behalf of "{teamName}" has invited you to sign "example
|
||||
document".
|
||||
</Trans>
|
||||
</blockquote>
|
||||
) : (
|
||||
<blockquote className="text-foreground/50 text-xs italic">
|
||||
<Trans>"{teamUrl}" has invited you to sign "example document".</Trans>
|
||||
</blockquote>
|
||||
)}
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row justify-end space-x-4">
|
||||
<AnimatePresence>
|
||||
{form.formState.isDirty && (
|
||||
|
||||
@ -6,7 +6,7 @@ import Link from 'next/link';
|
||||
import { useParams, usePathname } from 'next/navigation';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { Braces, CreditCard, Globe2Icon, Settings, Users, Webhook } from 'lucide-react';
|
||||
import { Braces, CreditCard, Globe2Icon, Settings, Settings2, Users, Webhook } from 'lucide-react';
|
||||
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
@ -26,6 +26,7 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||
const teamUrl = typeof params?.teamUrl === 'string' ? params?.teamUrl : '';
|
||||
|
||||
const settingsPath = `/t/${teamUrl}/settings`;
|
||||
const preferencesPath = `/t/${teamUrl}/settings/preferences`;
|
||||
const publicProfilePath = `/t/${teamUrl}/settings/public-profile`;
|
||||
const membersPath = `/t/${teamUrl}/settings/members`;
|
||||
const tokensPath = `/t/${teamUrl}/settings/tokens`;
|
||||
@ -44,6 +45,20 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href={preferencesPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith(preferencesPath) && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Settings2 className="mr-2 h-5 w-5" />
|
||||
|
||||
<Trans>Preferences</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{isPublicProfileEnabled && (
|
||||
<Link href={publicProfilePath}>
|
||||
<Button
|
||||
|
||||
@ -6,7 +6,7 @@ import Link from 'next/link';
|
||||
import { useParams, usePathname } from 'next/navigation';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { Braces, CreditCard, Globe2Icon, Key, User, Webhook } from 'lucide-react';
|
||||
import { Braces, CreditCard, Globe2Icon, Key, Settings2, User, Webhook } from 'lucide-react';
|
||||
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
@ -26,6 +26,7 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
||||
const teamUrl = typeof params?.teamUrl === 'string' ? params?.teamUrl : '';
|
||||
|
||||
const settingsPath = `/t/${teamUrl}/settings`;
|
||||
const preferencesPath = `/t/${teamUrl}/preferences`;
|
||||
const publicProfilePath = `/t/${teamUrl}/settings/public-profile`;
|
||||
const membersPath = `/t/${teamUrl}/settings/members`;
|
||||
const tokensPath = `/t/${teamUrl}/settings/tokens`;
|
||||
@ -52,6 +53,21 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href={preferencesPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith(preferencesPath) &&
|
||||
pathname.split('/').length === 4 &&
|
||||
'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Settings2 className="mr-2 h-5 w-5" />
|
||||
<Trans>Preferences</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{isPublicProfileEnabled && (
|
||||
<Link href={publicProfilePath}>
|
||||
<Button
|
||||
|
||||
59
apps/web/src/pages/api/branding/logo/team/[teamId].ts
Normal file
59
apps/web/src/pages/api/branding/logo/team/[teamId].ts
Normal file
@ -0,0 +1,59 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import sharp from 'sharp';
|
||||
|
||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const teamId = Number(req.query['teamId']);
|
||||
|
||||
if (teamId === 0 || Number.isNaN(teamId)) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: 'Invalid team ID',
|
||||
});
|
||||
}
|
||||
|
||||
const settings = await prisma.teamGlobalSettings.findFirst({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!settings || !settings.brandingEnabled) {
|
||||
return res.status(404).json({
|
||||
status: 'error',
|
||||
message: 'Not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (!settings.brandingLogo) {
|
||||
return res.status(404).json({
|
||||
status: 'error',
|
||||
message: 'Not found',
|
||||
});
|
||||
}
|
||||
|
||||
const file = await getFile(JSON.parse(settings.brandingLogo)).catch(() => null);
|
||||
|
||||
if (!file) {
|
||||
return res.status(404).json({
|
||||
status: 'error',
|
||||
message: 'Not found',
|
||||
});
|
||||
}
|
||||
|
||||
const img = await sharp(file)
|
||||
.toFormat('png', {
|
||||
quality: 80,
|
||||
})
|
||||
.toBuffer();
|
||||
|
||||
res.setHeader('Content-Type', 'image/png');
|
||||
res.setHeader('Content-Length', img.length);
|
||||
// Stale while revalidate for 1 hours to 24 hours
|
||||
res.setHeader('Cache-Control', 'public, s-maxage=3600, stale-while-revalidate=86400');
|
||||
|
||||
res.status(200).send(img);
|
||||
}
|
||||
Reference in New Issue
Block a user