Compare commits

..
12 changed files with 57 additions and 286 deletions
@@ -12,11 +12,20 @@ import { cn } from '@documenso/ui/lib/utils';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@documenso/ui/primitives/accordion';
import { Button } from '@documenso/ui/primitives/button';
import { ColorPicker } from '@documenso/ui/primitives/color-picker';
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel } from '@documenso/ui/primitives/form/form';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import type { TeamGlobalSettings } from '@prisma/client';
import { Loader } from 'lucide-react';
@@ -37,14 +46,17 @@ const ZBrandingPreferencesFormSchema = z.object({
.instanceof(File)
.refine(
(file) => file.size <= BRANDING_LOGO_MAX_SIZE_BYTES,
`File size must be less than ${BRANDING_LOGO_MAX_SIZE_MB}MB`,
msg`File size must be less than ${BRANDING_LOGO_MAX_SIZE_MB}MB`,
)
.refine(
(file) => BRANDING_LOGO_ALLOWED_TYPES.includes(file.type),
msg`Only .jpg, .png, and .webp files are accepted`,
)
.refine((file) => BRANDING_LOGO_ALLOWED_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(),
brandingUrl: z.string().url(msg`Please enter a valid URL`).optional().or(z.literal('')),
brandingCompanyDetails: z.string().max(500, msg`Brand details must be less than 500 characters`).optional(),
brandingColors: ZCssVarsSchema.default({}),
brandingCss: z.string().max(10_000).default(''),
brandingCss: z.string().max(10_000, msg`Custom CSS must be less than 10,000 characters`).default(''),
});
export type TBrandingPreferencesFormSchema = z.infer<typeof ZBrandingPreferencesFormSchema>;
@@ -349,6 +361,8 @@ export function BrandingPreferencesForm({
</span>
)}
</FormDescription>
<FormMessage />
</div>
</InheritableField>
)}
@@ -379,6 +393,8 @@ export function BrandingPreferencesForm({
</span>
)}
</FormDescription>
<FormMessage />
</InheritableField>
)}
/>
@@ -413,6 +429,8 @@ export function BrandingPreferencesForm({
</span>
)}
</FormDescription>
<FormMessage />
</InheritableField>
)}
/>
@@ -597,6 +615,8 @@ export function BrandingPreferencesForm({
<FormDescription>
<Trans>Border radius size in REM units (e.g. 0.5rem).</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
@@ -634,6 +654,8 @@ export function BrandingPreferencesForm({
shown after you save.
</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
@@ -37,7 +37,7 @@ import { useMemo } from 'react';
import { useForm } from 'react-hook-form';
import { Link, useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import { z } from 'zod';
import type { z } from 'zod';
import { AdminOrganisationDeleteDialog } from '~/components/dialogs/admin-organisation-delete-dialog';
import { AdminOrganisationMemberDeleteDialog } from '~/components/dialogs/admin-organisation-member-delete-dialog';
@@ -563,15 +563,11 @@ const GenericOrganisationAdminForm = ({ organisation }: OrganisationAdminFormOpt
);
};
const ZUpdateOrganisationBillingFormSchema = ZUpdateAdminOrganisationRequestSchema.shape.data
.pick({
claims: true,
customerId: true,
originalSubscriptionClaimId: true,
})
.extend({
notifyOrganisation: z.boolean(),
});
const ZUpdateOrganisationBillingFormSchema = ZUpdateAdminOrganisationRequestSchema.shape.data.pick({
claims: true,
customerId: true,
originalSubscriptionClaimId: true,
});
type TUpdateOrganisationBillingFormSchema = z.infer<typeof ZUpdateOrganisationBillingFormSchema>;
@@ -618,31 +614,19 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
emailTransportId: organisation.organisationClaim.emailTransportId ?? null,
},
originalSubscriptionClaimId: organisation.organisationClaim.originalSubscriptionClaimId || '',
notifyOrganisation: true,
},
});
const onSubmit = async ({ notifyOrganisation, ...values }: TUpdateOrganisationBillingFormSchema) => {
const onSubmit = async (values: TUpdateOrganisationBillingFormSchema) => {
try {
const { isNotificationSent } = await updateOrganisation({
await updateOrganisation({
organisationId: organisation.id,
data: values,
notifyOrganisation,
});
form.setValue('notifyOrganisation', false);
const description = match({ notifyOrganisation, isNotificationSent })
.with({ isNotificationSent: true }, () => t`Organisation has been updated and its owner has been notified`)
.with(
{ notifyOrganisation: true },
() => t`Organisation has been updated. No limits changed, so no notification was sent`,
)
.otherwise(() => t`Organisation has been updated successfully`);
toast({
title: t`Success`,
description,
description: t`Organisation has been updated successfully`,
duration: 5000,
});
} catch (err) {
@@ -935,31 +919,6 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
)}
/>
<FormField
control={form.control}
name="notifyOrganisation"
render={({ field }) => (
<FormItem>
<div className="flex items-center">
<FormControl>
<Checkbox id="notify-organisation" checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<label className="ml-2 text-muted-foreground text-sm" htmlFor="notify-organisation">
<Trans>Notify the organisation about limit changes</Trans>
</label>
</div>
<FormDescription>
<Trans>
Emails the organisation owner to let them know their limits have been updated. No email is sent if no
limits changed.
</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update</Trans>
@@ -1,70 +0,0 @@
import { SUPPORT_EMAIL } from '@documenso/lib/constants/app';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Body, Container, Head, Hr, Html, Preview, Section, Text } from '../components';
import { TemplateBrandingLogo } from '../template-components/template-branding-logo';
import { TemplateFooter } from '../template-components/template-footer';
export type OrganisationLimitsUpdatedEmailProps = {
assetBaseUrl: string;
organisationName: string;
};
export const OrganisationLimitsUpdatedEmailTemplate = ({
assetBaseUrl = 'http://localhost:3002',
organisationName = 'Organisation Name',
}: OrganisationLimitsUpdatedEmailProps) => {
const { _ } = useLingui();
return (
<Html>
<Head />
<Body className="mx-auto my-auto font-sans">
<Preview>{_(ORGANISATION_LIMITS_UPDATED_SUBJECT)}</Preview>
<Section className="bg-background text-muted-foreground">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
<Section className="p-2 text-muted-foreground">
<Text className="text-center font-medium text-foreground text-lg">
{_(ORGANISATION_LIMITS_UPDATED_SUBJECT)}
</Text>
<div className="mx-auto my-2 w-fit rounded-lg bg-muted px-4 py-2 font-medium text-base text-muted-foreground">
{organisationName}
</div>
<Text className="text-center text-base">
<Trans>We've updated the usage limits for your organisation.</Trans>
</Text>
<Text className="text-center text-base">
<Trans>
If you recently received an email regarding your usage limits, this change was made in response to
that.
</Trans>
</Text>
<Text className="text-center text-base">
<Trans>If you have any questions, please contact support at {SUPPORT_EMAIL}.</Trans>
</Text>
</Section>
</Container>
<Hr className="mx-auto mt-12 max-w-xl" />
<Container className="mx-auto max-w-xl">
<TemplateFooter isDocument={false} />
</Container>
</Section>
</Body>
</Html>
);
};
export const ORGANISATION_LIMITS_UPDATED_SUBJECT = msg`Your organisation limits have been updated`;
export default OrganisationLimitsUpdatedEmailTemplate;
-2
View File
@@ -7,7 +7,6 @@ import { SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION } from
import { SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-deleted-emails';
import { SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-document-pending-email';
import { SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-limit-alert-email';
import { SEND_ORGANISATION_LIMITS_UPDATED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-limits-updated-email';
import { SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-member-joined-email';
import { SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-member-left-email';
import { SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-owner-recipient-expired-email';
@@ -44,7 +43,6 @@ export const jobsClient = new JobClient([
SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION,
SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION,
SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION,
SEND_ORGANISATION_LIMITS_UPDATED_EMAIL_JOB_DEFINITION,
SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION,
SEAL_DOCUMENT_JOB_DEFINITION,
SEAL_DOCUMENT_SWEEP_JOB_DEFINITION,
@@ -129,7 +129,6 @@ export const run = async ({
text: `
Organisation: ${organisation.name}
Organisation ID: ${organisation.id}
Admin URL: ${NEXT_PUBLIC_WEBAPP_URL()}/admin/organisations/${organisation.id}
Organisation Claim Original ID: ${organisation.organisationClaim.originalSubscriptionClaimId}
Email Quota: ${organisation.monthlyStats[0]?.emailCount || 0}/${organisation.organisationClaim.emailQuota ?? 'Unlimited'}
API Quota: ${organisation.monthlyStats[0]?.apiCount || 0}/${organisation.organisationClaim.apiQuota ?? 'Unlimited'}
@@ -1,65 +0,0 @@
import OrganisationLimitsUpdatedEmailTemplate, {
ORGANISATION_LIMITS_UPDATED_SUBJECT,
} from '@documenso/email/templates/organisation-limits-updated';
import { prisma } from '@documenso/prisma';
import { createElement } from 'react';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendOrganisationLimitsUpdatedEmailJobDefinition } from './send-organisation-limits-updated-email';
export const run = async ({
payload,
io,
}: {
payload: TSendOrganisationLimitsUpdatedEmailJobDefinition;
io: JobRunIO;
}) => {
const organisation = await prisma.organisation.findFirstOrThrow({
where: {
id: payload.organisationId,
},
select: {
id: true,
name: true,
owner: {
select: {
email: true,
},
},
},
});
const { branding, emailLanguage, senderEmail, emailTransport } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'organisation',
organisationId: organisation.id,
},
});
await io.runTask('send-organisation-limits-updated-email', async () => {
const emailContent = createElement(OrganisationLimitsUpdatedEmailTemplate, {
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
organisationName: organisation.name,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(emailContent, { lang: emailLanguage, branding }),
renderEmailWithI18N(emailContent, { lang: emailLanguage, branding, plainText: true }),
]);
const i18n = await getI18nInstance(emailLanguage);
await emailTransport.sendMail({
to: organisation.owner.email,
from: senderEmail,
subject: i18n._(ORGANISATION_LIMITS_UPDATED_SUBJECT),
html,
text,
});
});
};
@@ -1,31 +0,0 @@
import { z } from 'zod';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_ORGANISATION_LIMITS_UPDATED_EMAIL_JOB_DEFINITION_ID = 'send.organisation-limits-updated.email';
const SEND_ORGANISATION_LIMITS_UPDATED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
organisationId: z.string(),
});
export type TSendOrganisationLimitsUpdatedEmailJobDefinition = z.infer<
typeof SEND_ORGANISATION_LIMITS_UPDATED_EMAIL_JOB_DEFINITION_SCHEMA
>;
export const SEND_ORGANISATION_LIMITS_UPDATED_EMAIL_JOB_DEFINITION = {
id: SEND_ORGANISATION_LIMITS_UPDATED_EMAIL_JOB_DEFINITION_ID,
name: 'Send Organisation Limits Updated Email',
version: '1.0.0',
trigger: {
name: SEND_ORGANISATION_LIMITS_UPDATED_EMAIL_JOB_DEFINITION_ID,
schema: SEND_ORGANISATION_LIMITS_UPDATED_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-organisation-limits-updated-email.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_ORGANISATION_LIMITS_UPDATED_EMAIL_JOB_DEFINITION_ID,
TSendOrganisationLimitsUpdatedEmailJobDefinition
>;
@@ -51,6 +51,12 @@ export async function rejectDocumentWithToken({ token, id, reason, requestMetada
});
}
if (recipient.signingStatus !== SigningStatus.NOT_SIGNED) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Recipient ${recipient.id} has already actioned this document`,
});
}
assertRecipientNotExpired(recipient);
// Update the recipient status to rejected
@@ -1,26 +0,0 @@
export type OrganisationLimits = {
documentQuota: number | null;
emailQuota: number | null;
apiQuota: number | null;
teamCount: number;
memberCount: number;
recipientCount: number;
envelopeItemCount: number;
};
const ORGANISATION_LIMIT_KEYS = [
'documentQuota',
'emailQuota',
'apiQuota',
'teamCount',
'memberCount',
'recipientCount',
'envelopeItemCount',
] as const satisfies (keyof OrganisationLimits)[];
export const hasOrganisationLimitsChanged = (
previous: OrganisationLimits,
next: Partial<OrganisationLimits>,
): boolean => {
return ORGANISATION_LIMIT_KEYS.some((key) => next[key] !== undefined && next[key] !== previous[key]);
};
@@ -1,6 +1,4 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { jobs } from '@documenso/lib/jobs/client';
import { hasOrganisationLimitsChanged } from '@documenso/lib/universal/organisation-limit-changes';
import { prisma } from '@documenso/prisma';
import { adminProcedure } from '../trpc';
@@ -13,12 +11,11 @@ export const updateAdminOrganisationRoute = adminProcedure
.input(ZUpdateAdminOrganisationRequestSchema)
.output(ZUpdateAdminOrganisationResponseSchema)
.mutation(async ({ input, ctx }) => {
const { organisationId, data, notifyOrganisation } = input;
const { organisationId, data } = input;
ctx.logger.info({
input: {
organisationId,
notifyOrganisation,
},
});
@@ -37,8 +34,6 @@ export const updateAdminOrganisationRoute = adminProcedure
const { name, url, customerId, claims, originalSubscriptionClaimId } = data;
const isLimitsChanged = claims ? hasOrganisationLimitsChanged(organisation.organisationClaim, claims) : false;
await prisma.organisation.update({
where: {
id: organisationId,
@@ -59,26 +54,4 @@ export const updateAdminOrganisationRoute = adminProcedure
originalSubscriptionClaimId,
},
});
if (!notifyOrganisation) {
return { isNotificationSent: false };
}
if (!isLimitsChanged) {
ctx.logger.info({
msg: 'Skipping organisation limits updated email, no limits changed',
organisationId,
});
return { isNotificationSent: false };
}
await jobs.triggerJob({
name: 'send.organisation-limits-updated.email',
payload: {
organisationId,
},
});
return { isNotificationSent: true };
});
@@ -26,11 +26,8 @@ export const ZUpdateAdminOrganisationRequestSchema = z.object({
customerId: z.string().optional(),
originalSubscriptionClaimId: z.string().optional(),
}),
notifyOrganisation: z.boolean().optional(),
});
export const ZUpdateAdminOrganisationResponseSchema = z.object({
isNotificationSent: z.boolean(),
});
export const ZUpdateAdminOrganisationResponseSchema = z.void();
export type TUpdateAdminOrganisationRequest = z.infer<typeof ZUpdateAdminOrganisationRequestSchema>;
@@ -41,6 +41,15 @@ export const SignaturePadDialog = ({
const [showSignatureModal, setShowSignatureModal] = useState(false);
const [signature, setSignature] = useState<string>(value ?? '');
const onOpenChange = (open: boolean) => {
if (disabled) {
return;
}
setSignature(value ?? '');
setShowSignatureModal(open);
};
return (
<div
className={cn(
@@ -62,7 +71,7 @@ export const SignaturePadDialog = ({
type="button"
disabled={disabled}
className="absolute inset-0 flex items-center justify-center bg-transparent"
onClick={() => setShowSignatureModal(true)}
onClick={() => onOpenChange(true)}
whileHover="onHover"
>
{!value && !disableAnimation && (
@@ -109,7 +118,7 @@ export const SignaturePadDialog = ({
)}
</motion.button>
<Dialog open={showSignatureModal} onOpenChange={disabled ? undefined : setShowSignatureModal}>
<Dialog open={showSignatureModal} onOpenChange={onOpenChange}>
<DialogContent hideClose={true} className="p-6 pt-4">
<SignaturePad
id="signature"
@@ -135,7 +144,7 @@ export const SignaturePadDialog = ({
disabled={!signature}
onClick={() => {
onChange(signature);
setShowSignatureModal(false);
onOpenChange(false);
}}
>
{dialogConfirmText ? parseMessageDescriptor(i18n._, dialogConfirmText) : <Trans>Next</Trans>}