mirror of
https://github.com/documenso/documenso.git
synced 2026-07-09 20:45:00 +10:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e3c4a204fd | |||
| a676e92f03 | |||
| ef4384423d | |||
| db7ffc7461 | |||
| 1c12aed35e | |||
| 921e0a0de6 | |||
| 335fee09a9 |
@@ -1,4 +1,3 @@
|
||||
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
@@ -24,7 +23,7 @@ import { useParams } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
const ZCreateFolderFormSchema = z.object({
|
||||
name: ZNameSchema,
|
||||
name: z.string().min(1, { message: 'Folder name is required' }),
|
||||
});
|
||||
|
||||
type TCreateFolderFormSchema = z.infer<typeof ZCreateFolderFormSchema>;
|
||||
@@ -66,7 +65,7 @@ export const FolderCreateDialog = ({ type, trigger, parentFolderId, ...props }:
|
||||
toast({
|
||||
description: t`Folder created successfully`,
|
||||
});
|
||||
} catch (_err) {
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t`Failed to create folder`,
|
||||
description: t`An unknown error occurred while creating the folder.`,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
|
||||
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -24,6 +23,8 @@ import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type FolderUpdateDialogProps = {
|
||||
folder: TFolderWithSubfolders | null;
|
||||
isOpen: boolean;
|
||||
@@ -31,7 +32,7 @@ export type FolderUpdateDialogProps = {
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
export const ZUpdateFolderFormSchema = z.object({
|
||||
name: ZNameSchema,
|
||||
name: z.string().min(1),
|
||||
visibility: z.nativeEnum(DocumentVisibility).optional(),
|
||||
});
|
||||
|
||||
@@ -39,6 +40,7 @@ export type TUpdateFolderFormSchema = z.infer<typeof ZUpdateFolderFormSchema>;
|
||||
|
||||
export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdateDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
const team = useOptionalCurrentTeam();
|
||||
|
||||
const { toast } = useToast();
|
||||
const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation();
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -26,13 +25,14 @@ import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
import { z } from 'zod';
|
||||
|
||||
export type PasskeyCreateDialogProps = {
|
||||
trigger?: React.ReactNode;
|
||||
onSuccess?: () => void;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
const ZCreatePasskeyFormSchema = z.object({
|
||||
passkeyName: ZNameSchema,
|
||||
passkeyName: z.string().min(3),
|
||||
});
|
||||
|
||||
type TCreatePasskeyFormSchema = z.infer<typeof ZCreatePasskeyFormSchema>;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZUpdateTeamEmailMutationSchema } from '@documenso/trpc/server/team-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -20,16 +19,16 @@ import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useRevalidator } from 'react-router';
|
||||
import type { z } from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
export type TeamEmailUpdateDialogProps = {
|
||||
teamEmail: TeamEmail;
|
||||
trigger?: React.ReactNode;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
const ZUpdateTeamEmailFormSchema = ZUpdateTeamEmailMutationSchema.pick({
|
||||
data: true,
|
||||
}).shape.data;
|
||||
const ZUpdateTeamEmailFormSchema = z.object({
|
||||
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
|
||||
});
|
||||
|
||||
type TUpdateTeamEmailFormSchema = z.infer<typeof ZUpdateTeamEmailFormSchema>;
|
||||
|
||||
@@ -45,7 +44,6 @@ export const TeamEmailUpdateDialog = ({ teamEmail, trigger, ...props }: TeamEmai
|
||||
defaultValues: {
|
||||
name: teamEmail.name,
|
||||
},
|
||||
mode: 'onSubmit',
|
||||
});
|
||||
|
||||
const { mutateAsync: updateTeamEmail } = trpc.team.email.update.useMutation();
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import {
|
||||
BRANDING_LOGO_ALLOWED_TYPES,
|
||||
BRANDING_LOGO_MAX_SIZE_BYTES,
|
||||
BRANDING_LOGO_MAX_SIZE_MB,
|
||||
} from '@documenso/lib/constants/branding';
|
||||
import { DEFAULT_BRAND_COLORS, DEFAULT_BRAND_RADIUS } from '@documenso/lib/constants/theme';
|
||||
import { ZCssVarsSchema } from '@documenso/lib/types/css-vars';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -28,15 +23,15 @@ import { useCspNonce } from '~/utils/nonce';
|
||||
|
||||
import { FormStickySaveBar } from './form-sticky-save-bar';
|
||||
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
|
||||
const ZBrandingPreferencesFormSchema = z.object({
|
||||
brandingEnabled: z.boolean().nullable(),
|
||||
brandingLogo: z
|
||||
.instanceof(File)
|
||||
.refine(
|
||||
(file) => file.size <= BRANDING_LOGO_MAX_SIZE_BYTES,
|
||||
`File size must be less than ${BRANDING_LOGO_MAX_SIZE_MB}MB`,
|
||||
)
|
||||
.refine((file) => BRANDING_LOGO_ALLOWED_TYPES.includes(file.type), 'Only .jpg, .png, and .webp files are accepted')
|
||||
.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(),
|
||||
@@ -250,7 +245,7 @@ export function BrandingPreferencesForm({
|
||||
<FormControl className="relative">
|
||||
<Input
|
||||
type="file"
|
||||
accept={BRANDING_LOGO_ALLOWED_TYPES.join(',')}
|
||||
accept={ACCEPTED_FILE_TYPES.join(',')}
|
||||
disabled={!isBrandingEnabled}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -16,8 +15,8 @@ import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
const ZEmailTransportFormSchema = z.object({
|
||||
name: ZNameSchema,
|
||||
fromName: ZNameSchema,
|
||||
name: z.string().min(1),
|
||||
fromName: z.string().min(1),
|
||||
fromAddress: z.string().email(),
|
||||
type: z.enum(['SMTP_AUTH', 'SMTP_API', 'RESEND', 'MAILCHANNELS']),
|
||||
host: z.string().optional(),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||
import { ZNameSchema } from '@documenso/lib/constants/auth';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { Select, SelectContent, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
||||
import type React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router';
|
||||
|
||||
export type SearchParamSelector = {
|
||||
paramKey: string;
|
||||
isValueValid: (value: unknown) => boolean;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const SearchParamSelector = ({ children, paramKey, isValueValid }: SearchParamSelector) => {
|
||||
const { pathname } = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const value = useMemo(() => {
|
||||
const p = searchParams?.get(paramKey) ?? 'all';
|
||||
|
||||
return isValueValid(p) ? p : 'all';
|
||||
}, [searchParams]);
|
||||
|
||||
const onValueChange = (newValue: string) => {
|
||||
if (!pathname) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
|
||||
params.set(paramKey, newValue);
|
||||
|
||||
if (newValue === '' || newValue === 'all') {
|
||||
params.delete(paramKey);
|
||||
}
|
||||
|
||||
void navigate(`${pathname}?${params.toString()}`, { preventScrollReset: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<Select defaultValue={value} onValueChange={onValueChange}>
|
||||
<SelectTrigger className="max-w-[200px] text-muted-foreground">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent position="popper">{children}</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
import communityCardsImage from '@documenso/assets/images/community-cards.png';
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { ZNameSchema } from '@documenso/lib/constants/auth';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
||||
|
||||
@@ -5,7 +5,6 @@ import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { OrganisationGlobalSettings, TeamGlobalSettings } from '@prisma/client';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { DetailsCard, DetailsValue } from '~/components/general/admin-details';
|
||||
|
||||
@@ -26,72 +25,38 @@ const emailSettingsKeys = Object.keys(EMAIL_SETTINGS_LABELS) as (keyof TDocument
|
||||
type AdminGlobalSettingsSectionProps = {
|
||||
settings: TeamGlobalSettings | OrganisationGlobalSettings | null;
|
||||
isTeam?: boolean;
|
||||
/** When viewing a team, the parent organisation settings the team inherits from. */
|
||||
inheritedSettings?: OrganisationGlobalSettings | null;
|
||||
};
|
||||
|
||||
export const AdminGlobalSettingsSection = ({
|
||||
settings,
|
||||
isTeam = false,
|
||||
inheritedSettings,
|
||||
}: AdminGlobalSettingsSectionProps) => {
|
||||
export const AdminGlobalSettingsSection = ({ settings, isTeam = false }: AdminGlobalSettingsSectionProps) => {
|
||||
const { _ } = useLingui();
|
||||
const notSetLabel = isTeam ? <Trans>Inherited</Trans> : <Trans>Not set</Trans>;
|
||||
|
||||
if (!settings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const notSet = <Trans>Not set</Trans>;
|
||||
|
||||
const inheritedValue = (value: ReactNode) => {
|
||||
if (!isTeam || value === null) {
|
||||
return notSet;
|
||||
const textValue = (value: string | null | undefined) => {
|
||||
if (value === null || value === undefined) {
|
||||
return notSetLabel;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="text-muted-foreground">
|
||||
<Trans>Inherited</Trans>:
|
||||
</span>
|
||||
<span>{value}</span>
|
||||
</span>
|
||||
);
|
||||
return value;
|
||||
};
|
||||
|
||||
const textValue = (value: string | null | undefined, inherited?: string | null) => {
|
||||
if (value && value.trim() !== '') {
|
||||
return value;
|
||||
const brandingTextValue = (value: string | null | undefined) => {
|
||||
if (value === null || value === undefined || value.trim() === '') {
|
||||
return notSetLabel;
|
||||
}
|
||||
|
||||
if (inherited && inherited.trim() !== '') {
|
||||
return inheritedValue(inherited);
|
||||
}
|
||||
|
||||
return notSet;
|
||||
return value;
|
||||
};
|
||||
|
||||
const booleanLabel = (value: boolean) => (value ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>);
|
||||
|
||||
const booleanValue = (value: boolean | null | undefined, inherited?: boolean | null) => {
|
||||
if (value !== null && value !== undefined) {
|
||||
return booleanLabel(value);
|
||||
const booleanValue = (value: boolean | null | undefined) => {
|
||||
if (value === null || value === undefined) {
|
||||
return notSetLabel;
|
||||
}
|
||||
|
||||
return inherited !== null && inherited !== undefined ? inheritedValue(booleanLabel(inherited)) : notSet;
|
||||
};
|
||||
|
||||
const visibilityLabel = (value: string | null | undefined) => {
|
||||
return value && DOCUMENT_VISIBILITY[value] ? _(DOCUMENT_VISIBILITY[value].value) : null;
|
||||
};
|
||||
|
||||
const visibilityValue = (value: string | null | undefined, inherited?: string | null) => {
|
||||
const label = visibilityLabel(value);
|
||||
|
||||
if (label !== null) {
|
||||
return label;
|
||||
}
|
||||
|
||||
return inheritedValue(visibilityLabel(inherited));
|
||||
return value ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>;
|
||||
};
|
||||
|
||||
const parsedEmailSettings = ZDocumentEmailSettingsSchema.safeParse(settings.emailDocumentSettings);
|
||||
@@ -100,82 +65,70 @@ export const AdminGlobalSettingsSection = ({
|
||||
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2 lg:grid-cols-3">
|
||||
<DetailsCard label={<Trans>Document visibility</Trans>}>
|
||||
<DetailsValue>
|
||||
{visibilityValue(settings.documentVisibility, inheritedSettings?.documentVisibility)}
|
||||
{settings.documentVisibility != null
|
||||
? _(DOCUMENT_VISIBILITY[settings.documentVisibility].value)
|
||||
: notSetLabel}
|
||||
</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Document language</Trans>}>
|
||||
<DetailsValue>{textValue(settings.documentLanguage, inheritedSettings?.documentLanguage)}</DetailsValue>
|
||||
<DetailsValue>{textValue(settings.documentLanguage)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Document timezone</Trans>}>
|
||||
<DetailsValue>{textValue(settings.documentTimezone, inheritedSettings?.documentTimezone)}</DetailsValue>
|
||||
<DetailsValue>{textValue(settings.documentTimezone)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Date format</Trans>}>
|
||||
<DetailsValue>{textValue(settings.documentDateFormat, inheritedSettings?.documentDateFormat)}</DetailsValue>
|
||||
<DetailsValue>{textValue(settings.documentDateFormat)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Include sender details</Trans>}>
|
||||
<DetailsValue>
|
||||
{booleanValue(settings.includeSenderDetails, inheritedSettings?.includeSenderDetails)}
|
||||
</DetailsValue>
|
||||
<DetailsValue>{booleanValue(settings.includeSenderDetails)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Include signing certificate</Trans>}>
|
||||
<DetailsValue>
|
||||
{booleanValue(settings.includeSigningCertificate, inheritedSettings?.includeSigningCertificate)}
|
||||
</DetailsValue>
|
||||
<DetailsValue>{booleanValue(settings.includeSigningCertificate)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Include audit log</Trans>}>
|
||||
<DetailsValue>{booleanValue(settings.includeAuditLog, inheritedSettings?.includeAuditLog)}</DetailsValue>
|
||||
<DetailsValue>{booleanValue(settings.includeAuditLog)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Delegate document ownership</Trans>}>
|
||||
<DetailsValue>
|
||||
{booleanValue(settings.delegateDocumentOwnership, inheritedSettings?.delegateDocumentOwnership)}
|
||||
</DetailsValue>
|
||||
<DetailsValue>{booleanValue(settings.delegateDocumentOwnership)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Typed signature</Trans>}>
|
||||
<DetailsValue>
|
||||
{booleanValue(settings.typedSignatureEnabled, inheritedSettings?.typedSignatureEnabled)}
|
||||
</DetailsValue>
|
||||
<DetailsValue>{booleanValue(settings.typedSignatureEnabled)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Upload signature</Trans>}>
|
||||
<DetailsValue>
|
||||
{booleanValue(settings.uploadSignatureEnabled, inheritedSettings?.uploadSignatureEnabled)}
|
||||
</DetailsValue>
|
||||
<DetailsValue>{booleanValue(settings.uploadSignatureEnabled)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Draw signature</Trans>}>
|
||||
<DetailsValue>
|
||||
{booleanValue(settings.drawSignatureEnabled, inheritedSettings?.drawSignatureEnabled)}
|
||||
</DetailsValue>
|
||||
<DetailsValue>{booleanValue(settings.drawSignatureEnabled)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Branding</Trans>}>
|
||||
<DetailsValue>{booleanValue(settings.brandingEnabled, inheritedSettings?.brandingEnabled)}</DetailsValue>
|
||||
<DetailsValue>{booleanValue(settings.brandingEnabled)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Branding logo</Trans>}>
|
||||
<DetailsValue>{textValue(settings.brandingLogo, inheritedSettings?.brandingLogo)}</DetailsValue>
|
||||
<DetailsValue>{brandingTextValue(settings.brandingLogo)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Branding URL</Trans>}>
|
||||
<DetailsValue>{textValue(settings.brandingUrl, inheritedSettings?.brandingUrl)}</DetailsValue>
|
||||
<DetailsValue>{brandingTextValue(settings.brandingUrl)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Branding company details</Trans>}>
|
||||
<DetailsValue>
|
||||
{textValue(settings.brandingCompanyDetails, inheritedSettings?.brandingCompanyDetails)}
|
||||
</DetailsValue>
|
||||
<DetailsValue>{brandingTextValue(settings.brandingCompanyDetails)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Email reply-to</Trans>}>
|
||||
<DetailsValue>{textValue(settings.emailReplyTo, inheritedSettings?.emailReplyTo)}</DetailsValue>
|
||||
<DetailsValue>{textValue(settings.emailReplyTo)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
{isTeam && parsedEmailSettings.success && (
|
||||
@@ -192,7 +145,7 @@ export const AdminGlobalSettingsSection = ({
|
||||
)}
|
||||
|
||||
<DetailsCard label={<Trans>AI features</Trans>}>
|
||||
<DetailsValue>{booleanValue(settings.aiFeaturesEnabled, inheritedSettings?.aiFeaturesEnabled)}</DetailsValue>
|
||||
<DetailsValue>{booleanValue(settings.aiFeaturesEnabled)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
||||
@@ -20,6 +19,7 @@ import { useRef } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { SIGNUP_ERROR_MESSAGES } from '~/components/forms/signup';
|
||||
|
||||
export type ClaimAccountProps = {
|
||||
@@ -30,7 +30,7 @@ export type ClaimAccountProps = {
|
||||
|
||||
export const ZClaimAccountFormSchema = z
|
||||
.object({
|
||||
name: ZNameSchema,
|
||||
name: z.string().trim().min(1, { message: msg`Please enter a valid name.`.id }),
|
||||
email: zEmail().min(1),
|
||||
password: ZPasswordSchema,
|
||||
})
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import type { ReactNode } from 'react';
|
||||
@@ -6,13 +13,6 @@ import type { Control, FieldValues, Path } from 'react-hook-form';
|
||||
|
||||
import { RateLimitArrayInput } from './rate-limit-array-input';
|
||||
|
||||
/**
|
||||
* The rate-limit editor renders its own per-row inline errors, but a submit
|
||||
* attempt can still surface array-level Zod issues (e.g. a committed duplicate
|
||||
* window). Rendering the field's message here guarantees the form never fails
|
||||
* silently when those errors are not tied to a row the editor is showing.
|
||||
*/
|
||||
|
||||
type ClaimLimitFieldsProps<T extends FieldValues> = {
|
||||
control: Control<T>;
|
||||
/** e.g. '' for the claim form, 'claims.' for the org admin form. */
|
||||
@@ -20,12 +20,6 @@ type ClaimLimitFieldsProps<T extends FieldValues> = {
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type LimitGroup = {
|
||||
title: ReactNode;
|
||||
quotaKey: string;
|
||||
rateLimitKey: string;
|
||||
};
|
||||
|
||||
export const ClaimLimitFields = <T extends FieldValues>({
|
||||
control,
|
||||
prefix = '',
|
||||
@@ -36,33 +30,13 @@ export const ClaimLimitFields = <T extends FieldValues>({
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const name = (key: string) => `${prefix}${key}` as Path<T>;
|
||||
|
||||
const limitGroups: LimitGroup[] = [
|
||||
{
|
||||
title: <Trans>Documents</Trans>,
|
||||
quotaKey: 'documentQuota',
|
||||
rateLimitKey: 'documentRateLimits',
|
||||
},
|
||||
{
|
||||
title: <Trans>Emails</Trans>,
|
||||
quotaKey: 'emailQuota',
|
||||
rateLimitKey: 'emailRateLimits',
|
||||
},
|
||||
{
|
||||
title: <Trans>API</Trans>,
|
||||
quotaKey: 'apiQuota',
|
||||
rateLimitKey: 'apiRateLimits',
|
||||
},
|
||||
];
|
||||
|
||||
const renderQuotaField = (group: LimitGroup) => (
|
||||
const renderQuotaField = (key: string, label: ReactNode, description: ReactNode) => (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name(group.quotaKey)}
|
||||
name={name(key)}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-muted-foreground text-xs">
|
||||
<Trans>Monthly quota</Trans>
|
||||
</FormLabel>
|
||||
<FormLabel>{label}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
@@ -73,18 +47,20 @@ export const ClaimLimitFields = <T extends FieldValues>({
|
||||
onChange={(e) => field.onChange(e.target.value === '' ? null : parseInt(e.target.value, 10))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{description}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderRateLimitField = (group: LimitGroup) => (
|
||||
const renderRateLimitField = (key: string, label: ReactNode) => (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name(group.rateLimitKey)}
|
||||
name={name(key)}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{label}</FormLabel>
|
||||
<FormControl>
|
||||
<RateLimitArrayInput value={field.value ?? []} onChange={field.onChange} disabled={disabled} />
|
||||
</FormControl>
|
||||
@@ -95,30 +71,27 @@ export const ClaimLimitFields = <T extends FieldValues>({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="font-semibold text-base">
|
||||
<Trans>Limits</Trans>
|
||||
</h3>
|
||||
<p className="mt-1 text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
Empty quota means unlimited, 0 blocks the resource. Rate limit windows accept values like 5m, 1h or 24h.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4 rounded-md border p-4">
|
||||
<FormLabel>
|
||||
<Trans>Limits</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<div className="grid grid-cols-1 divide-y divide-border md:grid-cols-3 md:divide-x md:divide-y-0">
|
||||
{limitGroups.map((group) => (
|
||||
<div key={group.quotaKey} className="space-y-4 p-4">
|
||||
<h4 className="font-semibold text-sm">{group.title}</h4>
|
||||
{renderQuotaField(
|
||||
'documentQuota',
|
||||
<Trans>Monthly document quota</Trans>,
|
||||
<Trans>Empty = Unlimited, 0 = Blocked</Trans>,
|
||||
)}
|
||||
{renderRateLimitField('documentRateLimits', <Trans>Document rate limits</Trans>)}
|
||||
|
||||
{renderQuotaField(group)}
|
||||
{renderRateLimitField(group)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{renderQuotaField(
|
||||
'emailQuota',
|
||||
<Trans>Monthly email quota</Trans>,
|
||||
<Trans>Empty = Unlimited, 0 = Blocked</Trans>,
|
||||
)}
|
||||
{renderRateLimitField('emailRateLimits', <Trans>Email rate limits</Trans>)}
|
||||
|
||||
{renderQuotaField('apiQuota', <Trans>Monthly API quota</Trans>, <Trans>Empty = Unlimited, 0 = Blocked</Trans>)}
|
||||
{renderRateLimitField('apiRateLimits', <Trans>API rate limits</Trans>)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
export const DocumentSearch = ({ initialValue = '' }: { initialValue?: string }) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState(initialValue);
|
||||
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(term: string) => {
|
||||
const params = new URLSearchParams(searchParams?.toString() ?? '');
|
||||
if (term) {
|
||||
params.set('query', term);
|
||||
} else {
|
||||
params.delete('query');
|
||||
}
|
||||
|
||||
setSearchParams(params);
|
||||
},
|
||||
[searchParams],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const currentQueryParam = searchParams.get('query') || '';
|
||||
|
||||
if (debouncedSearchTerm !== currentQueryParam) {
|
||||
handleSearch(debouncedSearchTerm);
|
||||
}
|
||||
}, [debouncedSearchTerm, searchParams]);
|
||||
|
||||
return (
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={_(msg`Search documents...`)}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,38 +1,13 @@
|
||||
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
|
||||
import {
|
||||
getQuotaUsagePercent,
|
||||
isQuotaExceeded,
|
||||
isQuotaNearing,
|
||||
normalizeCapacityLimit,
|
||||
} from '@documenso/lib/universal/quota-usage';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import type { BadgeProps } from '@documenso/ui/primitives/badge';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { Progress } from '@documenso/ui/primitives/progress';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { OrganisationClaim, OrganisationMonthlyStat } from '@prisma/client';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { FileIcon, MailIcon, MailOpenIcon, PlugIcon, UsersIcon, UsersRoundIcon } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useId, useState } from 'react';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { match } from 'ts-pattern';
|
||||
import { OrganisationUsageResetButton } from './organisation-usage-reset-button';
|
||||
|
||||
type CapacityUsage = {
|
||||
members: number;
|
||||
teams: number;
|
||||
};
|
||||
|
||||
type UsageRow = {
|
||||
counter: 'document' | 'email' | 'api';
|
||||
label: ReactNode;
|
||||
icon: LucideIcon;
|
||||
used: number;
|
||||
effectiveLimit: number | null;
|
||||
};
|
||||
|
||||
type OrganisationUsagePanelProps = {
|
||||
organisationId: string;
|
||||
monthlyStats: Pick<
|
||||
@@ -40,151 +15,13 @@ type OrganisationUsagePanelProps = {
|
||||
'period' | 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports'
|
||||
>[];
|
||||
organisationClaim: OrganisationClaim;
|
||||
capacityUsage?: CapacityUsage;
|
||||
};
|
||||
|
||||
type UsageCardState = {
|
||||
status: {
|
||||
label: ReactNode;
|
||||
variant: NonNullable<BadgeProps['variant']>;
|
||||
};
|
||||
percent: number;
|
||||
hasFiniteLimit: boolean;
|
||||
progressClassName: string;
|
||||
subtext: ReactNode;
|
||||
};
|
||||
|
||||
type UsageCardStateOptions = {
|
||||
used: number;
|
||||
limit: number | null | undefined;
|
||||
footnote?: ReactNode;
|
||||
};
|
||||
|
||||
const getUsageCardState = ({ used, limit, footnote }: UsageCardStateOptions): UsageCardState => {
|
||||
const percent = getQuotaUsagePercent(used, limit ?? null);
|
||||
const hasFiniteLimit = Boolean(limit && limit > 0);
|
||||
|
||||
if (limit === null || limit === undefined) {
|
||||
return {
|
||||
status: { label: <Trans>Unlimited</Trans>, variant: 'neutral' },
|
||||
percent,
|
||||
hasFiniteLimit,
|
||||
progressClassName: '',
|
||||
subtext: footnote ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
if (limit === 0) {
|
||||
return {
|
||||
status: { label: <Trans>Blocked</Trans>, variant: 'destructive' },
|
||||
percent,
|
||||
hasFiniteLimit,
|
||||
progressClassName: '',
|
||||
subtext: footnote ?? <Trans>Resource blocked</Trans>,
|
||||
};
|
||||
}
|
||||
|
||||
if (used > limit) {
|
||||
return {
|
||||
status: { label: <Trans>Exceeded</Trans>, variant: 'destructive' },
|
||||
percent,
|
||||
hasFiniteLimit,
|
||||
progressClassName: '[&>div]:bg-destructive',
|
||||
subtext: footnote ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
if (isQuotaExceeded(limit, used)) {
|
||||
return {
|
||||
status: { label: <Trans>Limit reached</Trans>, variant: 'orange' },
|
||||
percent,
|
||||
hasFiniteLimit,
|
||||
progressClassName: '[&>div]:bg-orange-500 dark:[&>div]:bg-orange-400',
|
||||
subtext: footnote ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
if (isQuotaNearing(limit, used)) {
|
||||
return {
|
||||
status: { label: <Trans>Near limit</Trans>, variant: 'warning' },
|
||||
percent,
|
||||
hasFiniteLimit,
|
||||
progressClassName: '[&>div]:bg-yellow-500 dark:[&>div]:bg-yellow-400',
|
||||
subtext: footnote ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: { label: <Trans>Within limit</Trans>, variant: 'default' },
|
||||
percent,
|
||||
hasFiniteLimit,
|
||||
progressClassName: '',
|
||||
subtext: footnote ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
type UsageStatCardProps = {
|
||||
label: ReactNode;
|
||||
icon: LucideIcon;
|
||||
used: number;
|
||||
limit: number | null | undefined;
|
||||
/** When true the card is a plain counter with no limit, status or progress. */
|
||||
countOnly?: boolean;
|
||||
footnote?: ReactNode;
|
||||
action?: ReactNode;
|
||||
};
|
||||
|
||||
const UsageStatCard = ({ label, icon: Icon, used, limit, countOnly = false, footnote, action }: UsageStatCardProps) => {
|
||||
const { status, percent, hasFiniteLimit, progressClassName, subtext } = getUsageCardState({ used, limit, footnote });
|
||||
|
||||
return (
|
||||
<div className="flex flex-col rounded-lg border bg-background p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 font-medium text-foreground text-sm">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
|
||||
{!countOnly && (
|
||||
<Badge variant={status.variant} size="small">
|
||||
{status.label}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-1 flex-col">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="font-semibold text-3xl text-foreground tabular-nums tracking-tight">
|
||||
{used.toLocaleString()}
|
||||
</span>
|
||||
{hasFiniteLimit ? (
|
||||
<span className="text-base text-muted-foreground tabular-nums">/ {limit?.toLocaleString()}</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{hasFiniteLimit ? (
|
||||
<span className="font-medium text-muted-foreground text-sm tabular-nums">{percent}%</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{hasFiniteLimit ? <Progress className={cn('mt-3 h-2', progressClassName)} value={percent} /> : null}
|
||||
|
||||
{subtext ? <p className="mt-2 text-muted-foreground text-xs">{subtext}</p> : null}
|
||||
</div>
|
||||
|
||||
{action ? <div className="mt-4 flex justify-end border-t pt-4">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const OrganisationUsagePanel = ({
|
||||
organisationId,
|
||||
monthlyStats,
|
||||
organisationClaim,
|
||||
capacityUsage,
|
||||
}: OrganisationUsagePanelProps) => {
|
||||
const monthlyUsagePeriodId = useId();
|
||||
const [selectedPeriod, setSelectedPeriod] = useState<string | undefined>(() => monthlyStats[0]?.period);
|
||||
|
||||
const selectedStat = monthlyStats.find((stat) => stat.period === selectedPeriod) ?? monthlyStats[0];
|
||||
@@ -193,105 +30,86 @@ export const OrganisationUsagePanel = ({
|
||||
// current period), so only offer the reset action when viewing the current month.
|
||||
const isCurrentPeriod = selectedStat?.period === currentMonthlyPeriod();
|
||||
|
||||
const capacityRows = capacityUsage
|
||||
? [
|
||||
{
|
||||
key: 'members',
|
||||
label: <Trans>Members</Trans>,
|
||||
icon: UsersIcon,
|
||||
used: capacityUsage.members,
|
||||
limit: normalizeCapacityLimit(organisationClaim.memberCount),
|
||||
},
|
||||
{
|
||||
key: 'teams',
|
||||
label: <Trans>Teams</Trans>,
|
||||
icon: UsersRoundIcon,
|
||||
used: capacityUsage.teams,
|
||||
limit: normalizeCapacityLimit(organisationClaim.teamCount),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const monthlyRows: UsageRow[] = [
|
||||
const rows = [
|
||||
{
|
||||
counter: 'document',
|
||||
counter: 'document' as const,
|
||||
label: <Trans>Documents</Trans>,
|
||||
icon: FileIcon,
|
||||
used: selectedStat?.documentCount ?? 0,
|
||||
effectiveLimit: organisationClaim.documentQuota,
|
||||
},
|
||||
{
|
||||
counter: 'email',
|
||||
counter: 'email' as const,
|
||||
label: <Trans>Emails</Trans>,
|
||||
icon: MailIcon,
|
||||
used: selectedStat?.emailCount ?? 0,
|
||||
effectiveLimit: organisationClaim.emailQuota,
|
||||
},
|
||||
{
|
||||
counter: 'api',
|
||||
counter: 'api' as const,
|
||||
label: <Trans>API requests</Trans>,
|
||||
icon: PlugIcon,
|
||||
used: selectedStat?.apiCount ?? 0,
|
||||
effectiveLimit: organisationClaim.apiQuota,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-6">
|
||||
{capacityRows.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{capacityRows.map((row) => (
|
||||
<UsageStatCard key={row.key} label={row.label} icon={row.icon} used={row.used} limit={row.limit} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-4 rounded-md border p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="font-medium text-sm">
|
||||
<Trans>Usage for period: {selectedStat?.period || 'N/A'}</Trans>
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h3 id={monthlyUsagePeriodId} className="font-semibold text-base">
|
||||
<Trans>Monthly usage</Trans>
|
||||
</h3>
|
||||
{monthlyStats.length > 0 && (
|
||||
<Select value={selectedStat?.period} onValueChange={setSelectedPeriod}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{monthlyStats.map((stat) => (
|
||||
<SelectItem key={stat.period} value={stat.period}>
|
||||
{stat.period}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{monthlyStats.length > 0 ? (
|
||||
<Select value={selectedStat?.period} onValueChange={setSelectedPeriod}>
|
||||
<SelectTrigger className="h-9 w-full sm:w-44" aria-labelledby={monthlyUsagePeriodId}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{monthlyStats.map((stat) => (
|
||||
<SelectItem key={stat.period} value={stat.period}>
|
||||
{stat.period}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : null}
|
||||
</div>
|
||||
{rows.map((row) => {
|
||||
const percent =
|
||||
row.effectiveLimit && row.effectiveLimit > 0
|
||||
? Math.min(100, Math.round((row.used / row.effectiveLimit) * 100))
|
||||
: 0;
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{monthlyRows.map((row) => (
|
||||
<UsageStatCard
|
||||
key={row.counter}
|
||||
label={row.label}
|
||||
icon={row.icon}
|
||||
used={row.used}
|
||||
limit={row.effectiveLimit}
|
||||
action={
|
||||
selectedStat && isCurrentPeriod ? (
|
||||
<OrganisationUsageResetButton organisationId={organisationId} counter={row.counter} />
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
return (
|
||||
<div key={row.counter} className="space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>{row.label}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{row.used} /{' '}
|
||||
{match(row.effectiveLimit)
|
||||
.with(null, () => <Trans>Unlimited</Trans>)
|
||||
.with(0, () => <Trans>Blocked</Trans>)
|
||||
.otherwise(String)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<UsageStatCard
|
||||
label={<Trans>Reports</Trans>}
|
||||
icon={MailOpenIcon}
|
||||
used={selectedStat?.emailReports ?? 0}
|
||||
limit={null}
|
||||
countOnly
|
||||
footnote={<Trans>Sent this period</Trans>}
|
||||
/>
|
||||
{row.effectiveLimit && row.effectiveLimit > 0 ? <Progress className="h-2 w-full" value={percent} /> : null}
|
||||
|
||||
{selectedStat && isCurrentPeriod && (
|
||||
<div className="flex w-full justify-end pt-1">
|
||||
<OrganisationUsageResetButton organisationId={organisationId} counter={row.counter} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>
|
||||
<Trans>Reports</Trans>
|
||||
</span>
|
||||
<span className="text-muted-foreground">{selectedStat?.emailReports ?? 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { RotateCcwIcon } from 'lucide-react';
|
||||
import { useRevalidator } from 'react-router';
|
||||
|
||||
type OrganisationUsageResetButtonProps = {
|
||||
@@ -33,7 +32,6 @@ export const OrganisationUsageResetButton = ({ organisationId, counter }: Organi
|
||||
loading={isPending}
|
||||
onClick={() => reset({ organisationId, counter })}
|
||||
>
|
||||
<RotateCcwIcon className="mr-2 h-3.5 w-3.5" />
|
||||
<Trans>Reset</Trans>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useMemo } from 'react';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router';
|
||||
|
||||
const isPeriodSelectorValue = (value: unknown): value is PeriodSelectorValue => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
return ['', '7d', '14d', '30d'].includes(value as string);
|
||||
};
|
||||
|
||||
export const PeriodSelector = () => {
|
||||
const { pathname } = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const period = useMemo(() => {
|
||||
const p = searchParams?.get('period') ?? 'all';
|
||||
|
||||
return isPeriodSelectorValue(p) ? p : 'all';
|
||||
}, [searchParams]);
|
||||
|
||||
const onPeriodChange = (newPeriod: string) => {
|
||||
if (!pathname) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
|
||||
params.set('period', newPeriod);
|
||||
|
||||
if (newPeriod === '' || newPeriod === 'all') {
|
||||
params.delete('period');
|
||||
}
|
||||
|
||||
void navigate(`${pathname}?${params.toString()}`, { preventScrollReset: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<Select defaultValue={period} onValueChange={onPeriodChange}>
|
||||
<SelectTrigger className="max-w-[200px] text-muted-foreground">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent position="popper">
|
||||
<SelectItem value="all">
|
||||
<Trans>All Time</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value="7d">
|
||||
<Trans>Last 7 days</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value="14d">
|
||||
<Trans>Last 14 days</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value="30d">
|
||||
<Trans>Last 30 days</Trans>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +1,7 @@
|
||||
import { RATE_LIMIT_WINDOW_REGEX } from '@documenso/lib/types/subscription';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { PlusIcon, Trash2Icon } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
type RateLimitEntryValue = { window: string; max: number };
|
||||
|
||||
@@ -13,153 +11,50 @@ type RateLimitArrayInputProps = {
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const EMPTY_ENTRY: RateLimitEntryValue = { window: '', max: 0 };
|
||||
|
||||
/** A row counts as "started" once either field has input; fully-empty rows are dropped on commit. */
|
||||
const hasEntryInput = (entry: RateLimitEntryValue) => entry.window.trim() !== '' || entry.max > 0;
|
||||
|
||||
/** Keep in-progress rows; drop rows that are completely empty. */
|
||||
const persistEntries = (entries: RateLimitEntryValue[]) => {
|
||||
return entries.map((entry) => ({ ...entry, window: entry.window.trim() })).filter(hasEntryInput);
|
||||
};
|
||||
|
||||
export const RateLimitArrayInput = ({ value, onChange, disabled }: RateLimitArrayInputProps) => {
|
||||
const { t } = useLingui();
|
||||
const [draftEntry, setDraftEntry] = useState<RateLimitEntryValue | null>(null);
|
||||
|
||||
const entries = draftEntry ? [...value, draftEntry] : value.length ? value : [EMPTY_ENTRY];
|
||||
|
||||
const getWindowError = (entry: RateLimitEntryValue, index: number) => {
|
||||
const window = entry.window.trim();
|
||||
|
||||
if (!hasEntryInput(entry)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (window === '') {
|
||||
return t`Enter a window, e.g. 5m`;
|
||||
}
|
||||
|
||||
if (!RATE_LIMIT_WINDOW_REGEX.test(window)) {
|
||||
return t`Use a duration with a unit, e.g. 5m, 1h, or 24h`;
|
||||
}
|
||||
|
||||
const isDuplicateWindow = entries.some((otherEntry, otherIndex) => {
|
||||
return otherIndex !== index && otherEntry.window.trim() === window;
|
||||
});
|
||||
|
||||
return isDuplicateWindow ? t`Use a unique window for each rate limit` : null;
|
||||
};
|
||||
|
||||
const getMaxError = (entry: RateLimitEntryValue) => {
|
||||
if (!hasEntryInput(entry)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.max > 0 ? null : t`Enter a max request count greater than 0`;
|
||||
};
|
||||
const entries = value ?? [];
|
||||
|
||||
const updateEntry = (index: number, patch: Partial<RateLimitEntryValue>) => {
|
||||
if (index >= value.length) {
|
||||
const nextDraftEntry = { ...(draftEntry ?? EMPTY_ENTRY), ...patch };
|
||||
|
||||
if (hasEntryInput(nextDraftEntry)) {
|
||||
onChange(persistEntries([...value, nextDraftEntry]));
|
||||
setDraftEntry(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setDraftEntry(nextDraftEntry);
|
||||
return;
|
||||
}
|
||||
|
||||
const next = value.map((entry, i) => (i === index ? { ...entry, ...patch } : entry));
|
||||
onChange(persistEntries(next));
|
||||
const next = entries.map((entry, i) => (i === index ? { ...entry, ...patch } : entry));
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const removeEntry = (index: number) => {
|
||||
if (index >= value.length) {
|
||||
setDraftEntry(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const next = value.filter((_, i) => i !== index);
|
||||
onChange(persistEntries(next));
|
||||
onChange(entries.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const addEntry = () => {
|
||||
setDraftEntry(EMPTY_ENTRY);
|
||||
onChange([...entries, { window: '5m', max: 100 }]);
|
||||
};
|
||||
|
||||
const hasErrors = entries.some((entry, index) => getWindowError(entry, index) || getMaxError(entry));
|
||||
const isAddDisabled = disabled || value.length === 0 || Boolean(draftEntry) || hasErrors;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-xs">
|
||||
<span className="w-20 shrink-0">
|
||||
<Trans>Window</Trans>
|
||||
</span>
|
||||
<span className="flex-1">
|
||||
<Trans>Max requests</Trans>
|
||||
</span>
|
||||
<span className="w-9 shrink-0" aria-hidden="true" />
|
||||
</div>
|
||||
{entries.map((entry, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Input
|
||||
className="w-24"
|
||||
placeholder="5m"
|
||||
value={entry.window}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateEntry(index, { window: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
className="w-32"
|
||||
type="number"
|
||||
min={1}
|
||||
value={entry.max}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateEntry(index, { max: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
<Button type="button" variant="ghost" size="sm" disabled={disabled} onClick={() => removeEntry(index)}>
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{entries.map((entry, index) => {
|
||||
const windowError = getWindowError(entry, index);
|
||||
const maxError = getMaxError(entry);
|
||||
|
||||
return (
|
||||
<div key={index} className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
className="w-20 shrink-0"
|
||||
placeholder="5m"
|
||||
value={entry.window}
|
||||
disabled={disabled}
|
||||
aria-invalid={Boolean(windowError)}
|
||||
onChange={(e) => updateEntry(index, { window: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
className="flex-1"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="100"
|
||||
value={entry.max || ''}
|
||||
disabled={disabled}
|
||||
aria-invalid={Boolean(maxError)}
|
||||
onChange={(e) => updateEntry(index, { max: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9 w-9 shrink-0 p-0 text-muted-foreground hover:text-foreground"
|
||||
disabled={disabled}
|
||||
aria-label={t`Remove rate limit`}
|
||||
onClick={() => removeEntry(index)}
|
||||
>
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{windowError ? <p className="text-destructive text-xs">{windowError}</p> : null}
|
||||
{maxError ? <p className="text-destructive text-xs">{maxError}</p> : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full border-dashed"
|
||||
disabled={isAddDisabled}
|
||||
onClick={addEntry}
|
||||
>
|
||||
<Button type="button" variant="secondary" size="sm" disabled={disabled} onClick={addEntry}>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Add rate limit window</Trans>
|
||||
<Trans>Add rate limit</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZFindDocumentsInternalRequestSchema } from '@documenso/trpc/server/document-router/find-documents-internal.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';
|
||||
import { SelectItem } from '@documenso/ui/primitives/select';
|
||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||
import { TableCell } from '@documenso/ui/primitives/table';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
@@ -12,39 +11,33 @@ import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentSource, DocumentStatus as DocumentStatusEnum } from '@prisma/client';
|
||||
import type { DocumentSource } from '@prisma/client';
|
||||
import { InfoIcon } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { SearchParamSelector } from '~/components/forms/search-param-selector';
|
||||
import { DocumentSearch } from '~/components/general/document/document-search';
|
||||
import { DocumentStatus } from '~/components/general/document/document-status';
|
||||
import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip';
|
||||
import { DocumentsTableActionButton } from '~/components/tables/documents-table-action-button';
|
||||
import { DocumentsTableActionDropdown } from '~/components/tables/documents-table-action-dropdown';
|
||||
import { DataTableTitle } from '~/components/tables/documents-table-title';
|
||||
import { TemplateDocumentsTableToolbar } from '~/components/tables/template-documents-table-toolbar';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
import { PeriodSelector } from '../period-selector';
|
||||
|
||||
const DOCUMENT_SOURCE_LABELS: { [key in DocumentSource]: MessageDescriptor } = {
|
||||
DOCUMENT: msg`Document`,
|
||||
TEMPLATE: msg`Template`,
|
||||
TEMPLATE_DIRECT_LINK: msg`Direct link`,
|
||||
};
|
||||
|
||||
const ZDocumentSearchParamsSchema = ZUrlSearchParamsSchema.extend({
|
||||
source: z
|
||||
.nativeEnum(DocumentSource)
|
||||
.optional()
|
||||
.catch(() => undefined),
|
||||
status: z
|
||||
.nativeEnum(DocumentStatusEnum)
|
||||
.optional()
|
||||
.catch(() => undefined),
|
||||
const ZDocumentSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({
|
||||
page: true,
|
||||
perPage: true,
|
||||
query: true,
|
||||
period: true,
|
||||
status: true,
|
||||
source: true,
|
||||
});
|
||||
|
||||
type TemplatePageViewDocumentsTableProps = {
|
||||
@@ -59,9 +52,14 @@ export const TemplatePageViewDocumentsTable = ({ templateId }: TemplatePageViewD
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const parsedSearchParams = ZDocumentSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
|
||||
const searchParamsString = searchParams.toString();
|
||||
|
||||
const { data, isLoading, isLoadingError } = trpc.document.find.useQuery(
|
||||
const parsedSearchParams = useMemo(
|
||||
() => ZDocumentSearchParamsSchema.parse(Object.fromEntries(searchParams)),
|
||||
[searchParamsString],
|
||||
);
|
||||
|
||||
const { data, isLoading, isLoadingError } = trpc.document.findDocumentsInternal.useQuery(
|
||||
{
|
||||
templateId,
|
||||
page: parsedSearchParams.page,
|
||||
@@ -69,6 +67,7 @@ export const TemplatePageViewDocumentsTable = ({ templateId }: TemplatePageViewD
|
||||
query: parsedSearchParams.query,
|
||||
source: parsedSearchParams.source,
|
||||
status: parsedSearchParams.status,
|
||||
period: parsedSearchParams.period,
|
||||
},
|
||||
{
|
||||
placeholderData: (previousData) => previousData,
|
||||
@@ -166,48 +165,11 @@ export const TemplatePageViewDocumentsTable = ({ templateId }: TemplatePageViewD
|
||||
),
|
||||
},
|
||||
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
||||
}, []);
|
||||
}, [_, i18n, team?.url]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex flex-row space-x-4">
|
||||
<DocumentSearch />
|
||||
|
||||
<SearchParamSelector
|
||||
paramKey="status"
|
||||
isValueValid={(value) => [...DocumentStatusEnum.COMPLETED].includes(value as unknown as string)}
|
||||
>
|
||||
<SelectItem value="all">
|
||||
<Trans>Any Status</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentStatusEnum.COMPLETED}>
|
||||
<Trans>Completed</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentStatusEnum.PENDING}>
|
||||
<Trans>Pending</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentStatusEnum.DRAFT}>
|
||||
<Trans>Draft</Trans>
|
||||
</SelectItem>
|
||||
</SearchParamSelector>
|
||||
|
||||
<SearchParamSelector
|
||||
paramKey="source"
|
||||
isValueValid={(value) => [...DocumentSource.TEMPLATE].includes(value as unknown as string)}
|
||||
>
|
||||
<SelectItem value="all">
|
||||
<Trans>Any Source</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentSource.TEMPLATE}>
|
||||
<Trans>Template</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentSource.TEMPLATE_DIRECT_LINK}>
|
||||
<Trans>Direct Link</Trans>
|
||||
</SelectItem>
|
||||
</SearchParamSelector>
|
||||
|
||||
<PeriodSelector />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<TemplateDocumentsTableToolbar />
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
|
||||
@@ -38,16 +38,11 @@ export const AdminClaimsTable = ({ licenseFlags }: AdminClaimsTableProps) => {
|
||||
|
||||
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
|
||||
|
||||
const { data, isLoading, isLoadingError } = trpc.admin.claims.find.useQuery(
|
||||
{
|
||||
query: parsedSearchParams.query,
|
||||
page: parsedSearchParams.page,
|
||||
perPage: parsedSearchParams.perPage,
|
||||
},
|
||||
{
|
||||
placeholderData: (previousData) => previousData,
|
||||
},
|
||||
);
|
||||
const { data, isLoading, isLoadingError } = trpc.admin.claims.find.useQuery({
|
||||
query: parsedSearchParams.query,
|
||||
page: parsedSearchParams.page,
|
||||
perPage: parsedSearchParams.perPage,
|
||||
});
|
||||
|
||||
const onPaginationChange = (page: number, perPage: number) => {
|
||||
updateSearchParams({
|
||||
|
||||
@@ -19,16 +19,11 @@ export const AdminDocumentJobsTable = ({ envelopeId }: { envelopeId: string }) =
|
||||
|
||||
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
|
||||
|
||||
const { data, isLoading, isLoadingError, refetch, isFetching } = trpc.admin.document.findJobs.useQuery(
|
||||
{
|
||||
envelopeId: envelopeId,
|
||||
page: parsedSearchParams.page,
|
||||
perPage: parsedSearchParams.perPage,
|
||||
},
|
||||
{
|
||||
placeholderData: (previousData) => previousData,
|
||||
},
|
||||
);
|
||||
const { data, isLoading, isLoadingError, refetch, isFetching } = trpc.admin.document.findJobs.useQuery({
|
||||
envelopeId: envelopeId,
|
||||
page: parsedSearchParams.page,
|
||||
perPage: parsedSearchParams.perPage,
|
||||
});
|
||||
|
||||
const onPaginationChange = (page: number, perPage: number) => {
|
||||
updateSearchParams({
|
||||
|
||||
@@ -30,16 +30,11 @@ export const AdminEmailTransportsTable = () => {
|
||||
|
||||
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
|
||||
|
||||
const { data, isLoading, isLoadingError } = trpc.admin.emailTransport.find.useQuery(
|
||||
{
|
||||
query: parsedSearchParams.query,
|
||||
page: parsedSearchParams.page,
|
||||
perPage: parsedSearchParams.perPage,
|
||||
},
|
||||
{
|
||||
placeholderData: (previousData) => previousData,
|
||||
},
|
||||
);
|
||||
const { data, isLoading, isLoadingError } = trpc.admin.emailTransport.find.useQuery({
|
||||
query: parsedSearchParams.query,
|
||||
page: parsedSearchParams.page,
|
||||
perPage: parsedSearchParams.perPage,
|
||||
});
|
||||
|
||||
const onPaginationChange = (page: number, perPage: number) => {
|
||||
updateSearchParams({
|
||||
|
||||
@@ -77,20 +77,15 @@ export const AdminOrganisationStatsTable = ({ displayMode = 'usage' }: AdminOrga
|
||||
const orderByColumn = parseOrderByColumn(searchParams?.get('orderByColumn') ?? undefined);
|
||||
const orderByDirection = parseOrderByDirection(searchParams?.get('orderByDirection') ?? undefined);
|
||||
|
||||
const { data, isLoading, isLoadingError } = trpc.admin.organisation.stats.find.useQuery(
|
||||
{
|
||||
query: parsedSearchParams.query,
|
||||
page: parsedSearchParams.page,
|
||||
perPage: parsedSearchParams.perPage,
|
||||
period,
|
||||
claimId,
|
||||
orderByColumn,
|
||||
orderByDirection,
|
||||
},
|
||||
{
|
||||
placeholderData: (previousData) => previousData,
|
||||
},
|
||||
);
|
||||
const { data, isLoading, isLoadingError } = trpc.admin.organisation.stats.find.useQuery({
|
||||
query: parsedSearchParams.query,
|
||||
page: parsedSearchParams.page,
|
||||
perPage: parsedSearchParams.perPage,
|
||||
period,
|
||||
claimId,
|
||||
orderByColumn,
|
||||
orderByDirection,
|
||||
});
|
||||
|
||||
const onPaginationChange = (page: number, perPage: number) => {
|
||||
updateSearchParams({
|
||||
|
||||
@@ -55,18 +55,13 @@ export const AdminOrganisationsTable = ({
|
||||
|
||||
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
|
||||
|
||||
const { data, isLoading, isLoadingError } = trpc.admin.organisation.find.useQuery(
|
||||
{
|
||||
query: parsedSearchParams.query,
|
||||
page: parsedSearchParams.page,
|
||||
perPage: parsedSearchParams.perPage,
|
||||
ownerUserId,
|
||||
memberUserId,
|
||||
},
|
||||
{
|
||||
placeholderData: (previousData) => previousData,
|
||||
},
|
||||
);
|
||||
const { data, isLoading, isLoadingError } = trpc.admin.organisation.find.useQuery({
|
||||
query: parsedSearchParams.query,
|
||||
page: parsedSearchParams.page,
|
||||
perPage: parsedSearchParams.perPage,
|
||||
ownerUserId,
|
||||
memberUserId,
|
||||
});
|
||||
|
||||
const onPaginationChange = (page: number, perPage: number) => {
|
||||
updateSearchParams({
|
||||
|
||||
@@ -28,17 +28,12 @@ export const AdminUserTeamsTable = ({ userId }: AdminUserTeamsTableProps) => {
|
||||
|
||||
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
|
||||
|
||||
const { data, isLoading, isLoadingError } = trpc.admin.user.findTeams.useQuery(
|
||||
{
|
||||
userId,
|
||||
query: parsedSearchParams.query,
|
||||
page: parsedSearchParams.page,
|
||||
perPage: parsedSearchParams.perPage,
|
||||
},
|
||||
{
|
||||
placeholderData: (previousData) => previousData,
|
||||
},
|
||||
);
|
||||
const { data, isLoading, isLoadingError } = trpc.admin.user.findTeams.useQuery({
|
||||
userId,
|
||||
query: parsedSearchParams.query,
|
||||
page: parsedSearchParams.page,
|
||||
perPage: parsedSearchParams.perPage,
|
||||
});
|
||||
|
||||
const onPaginationChange = (page: number, perPage: number) => {
|
||||
updateSearchParams({
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router';
|
||||
|
||||
type DocumentsTableSenderFilterProps = {
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const DocumentsTableSenderFilter = ({ teamId }: DocumentsTableSenderFilterProps) => {
|
||||
const { pathname } = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
const senderIds = (searchParams?.get('senderIds') ?? '').split(',').filter((value) => value !== '');
|
||||
|
||||
const { data, isLoading } = trpc.team.member.getMany.useQuery({
|
||||
teamId,
|
||||
});
|
||||
|
||||
const comboBoxOptions = (data ?? []).map((member) => ({
|
||||
label: member.name ?? member.email,
|
||||
value: member.userId.toString(),
|
||||
}));
|
||||
|
||||
const onChange = (newSenderIds: string[]) => {
|
||||
if (!pathname) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
|
||||
params.set('senderIds', newSenderIds.join(','));
|
||||
|
||||
if (newSenderIds.length === 0) {
|
||||
params.delete('senderIds');
|
||||
}
|
||||
|
||||
void navigate(`${pathname}?${params.toString()}`, { preventScrollReset: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<MultiSelectCombobox
|
||||
emptySelectionPlaceholder={
|
||||
<p className="font-normal text-muted-foreground">
|
||||
<Trans>
|
||||
<span className="text-muted-foreground/70">Sender:</span> All
|
||||
</Trans>
|
||||
</p>
|
||||
}
|
||||
enableClearAllButton={true}
|
||||
inputPlaceholder={msg`Search`}
|
||||
loading={!isMounted || isLoading}
|
||||
options={comboBoxOptions}
|
||||
selectedValues={senderIds}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { XIcon } from 'lucide-react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { parseToStringArray, toCommaSeparatedSearchParam } from '@documenso/lib/utils/params';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/find-documents-internal.types';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import type { DataTableFacetedFilterOption } from '@documenso/ui/primitives/data-table-faceted-filter';
|
||||
import { DataTableFacetedFilter } from '@documenso/ui/primitives/data-table-faceted-filter';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
|
||||
import { PERIOD_OPTIONS } from './table-toolbar.constants';
|
||||
|
||||
type DocumentsTableToolbarProps = {
|
||||
teamId?: number;
|
||||
statusOptions: DataTableFacetedFilterOption[];
|
||||
statusCounts: TFindDocumentsInternalResponse['stats'];
|
||||
};
|
||||
|
||||
export const DocumentsTableToolbar = ({
|
||||
teamId,
|
||||
statusOptions,
|
||||
statusCounts,
|
||||
}: DocumentsTableToolbarProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
|
||||
const query = searchParams.get('query') ?? '';
|
||||
const period = searchParams.get('period') ?? '';
|
||||
const statusParam = searchParams.get('status');
|
||||
const senderIdsParam = searchParams.get('senderIds');
|
||||
|
||||
const selectedStatusValues = useMemo(() => parseToStringArray(statusParam), [statusParam]);
|
||||
const selectedSenderValues = useMemo(() => parseToStringArray(senderIdsParam), [senderIdsParam]);
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState(query);
|
||||
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchTerm(query);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedSearchTerm !== searchTerm) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (debouncedSearchTerm === query) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateSearchParams(
|
||||
{ query: debouncedSearchTerm || undefined, page: undefined },
|
||||
{ replace: true },
|
||||
);
|
||||
}, [debouncedSearchTerm, query, searchTerm, updateSearchParams]);
|
||||
|
||||
const { data: members } = trpc.team.member.getMany.useQuery(
|
||||
{
|
||||
teamId: teamId ?? 0,
|
||||
},
|
||||
{
|
||||
enabled: teamId !== undefined,
|
||||
},
|
||||
);
|
||||
|
||||
const senderOptions = useMemo(() => {
|
||||
return (members ?? []).map((member) => ({
|
||||
label: member.name ?? member.email,
|
||||
value: member.userId.toString(),
|
||||
}));
|
||||
}, [members]);
|
||||
|
||||
const periodOptions = useMemo<DataTableFacetedFilterOption[]>(() => {
|
||||
return PERIOD_OPTIONS.map((option) => ({
|
||||
label: _(option.label),
|
||||
value: option.value,
|
||||
}));
|
||||
}, [_]);
|
||||
|
||||
const hasActiveFilters =
|
||||
query.length > 0 ||
|
||||
selectedStatusValues.length > 0 ||
|
||||
selectedSenderValues.length > 0 ||
|
||||
(period.length > 0 && period !== 'all');
|
||||
|
||||
const onResetFilters = () => {
|
||||
setSearchTerm('');
|
||||
|
||||
updateSearchParams({
|
||||
query: undefined,
|
||||
status: undefined,
|
||||
senderIds: undefined,
|
||||
period: undefined,
|
||||
page: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative min-w-[286px] max-w-[494px]">
|
||||
<Input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
placeholder={_(msg`Search documents...`)}
|
||||
className="h-9 w-full pe-9"
|
||||
/>
|
||||
|
||||
{searchTerm.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={_(msg`Clear search`)}
|
||||
className="absolute inset-y-0 end-0 flex w-9 items-center justify-center text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
setSearchTerm('');
|
||||
updateSearchParams({ query: undefined, page: undefined }, { replace: true });
|
||||
}}
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DataTableFacetedFilter
|
||||
title={_(msg`Status`)}
|
||||
options={statusOptions}
|
||||
selectedValues={selectedStatusValues}
|
||||
counts={statusCounts}
|
||||
showSearch={false}
|
||||
onSelectedValuesChange={(values) => {
|
||||
updateSearchParams(
|
||||
{
|
||||
status: toCommaSeparatedSearchParam(values),
|
||||
page: undefined,
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
{teamId !== undefined && (
|
||||
<DataTableFacetedFilter
|
||||
title={_(msg`Sender`)}
|
||||
options={senderOptions}
|
||||
selectedValues={selectedSenderValues}
|
||||
showSearch
|
||||
onSelectedValuesChange={(values) => {
|
||||
updateSearchParams(
|
||||
{
|
||||
senderIds: toCommaSeparatedSearchParam(values),
|
||||
page: undefined,
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DataTableFacetedFilter
|
||||
title={_(msg`Time`)}
|
||||
options={periodOptions}
|
||||
selectedValues={period ? [period] : []}
|
||||
singleSelect
|
||||
showSearch={false}
|
||||
onSelectedValuesChange={(values) => {
|
||||
const nextPeriod = values[0];
|
||||
|
||||
updateSearchParams(
|
||||
{
|
||||
period: nextPeriod ?? undefined,
|
||||
page: undefined,
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<Button variant="ghost" size="sm" onClick={onResetFilters}>
|
||||
<Trans>Reset</Trans>
|
||||
<XIcon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -46,15 +46,10 @@ export const InboxTable = () => {
|
||||
const page = searchParams?.get?.('page') ? Number(searchParams.get('page')) : undefined;
|
||||
const perPage = searchParams?.get?.('perPage') ? Number(searchParams.get('perPage')) : undefined;
|
||||
|
||||
const { data, isLoading, isLoadingError } = trpc.document.inbox.find.useQuery(
|
||||
{
|
||||
page: page || 1,
|
||||
perPage: perPage || 10,
|
||||
},
|
||||
{
|
||||
placeholderData: (previousData) => previousData,
|
||||
},
|
||||
);
|
||||
const { data, isLoading, isLoadingError } = trpc.document.inbox.find.useQuery({
|
||||
page: page || 1,
|
||||
perPage: perPage || 10,
|
||||
});
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
|
||||
@@ -28,17 +28,12 @@ export const OrganisationTeamsTable = () => {
|
||||
|
||||
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
|
||||
|
||||
const { data, isLoading, isLoadingError } = trpc.team.find.useQuery(
|
||||
{
|
||||
organisationId: organisation.id,
|
||||
query: parsedSearchParams.query,
|
||||
page: parsedSearchParams.page,
|
||||
perPage: parsedSearchParams.perPage,
|
||||
},
|
||||
{
|
||||
placeholderData: (previousData) => previousData,
|
||||
},
|
||||
);
|
||||
const { data, isLoading, isLoadingError } = trpc.team.find.useQuery({
|
||||
organisationId: organisation.id,
|
||||
query: parsedSearchParams.query,
|
||||
page: parsedSearchParams.page,
|
||||
perPage: parsedSearchParams.perPage,
|
||||
});
|
||||
|
||||
const onPaginationChange = (page: number, perPage: number) => {
|
||||
updateSearchParams({
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -30,7 +29,7 @@ export type SettingsSecurityPasskeyTableActionsProps = {
|
||||
};
|
||||
|
||||
const ZUpdatePasskeySchema = z.object({
|
||||
name: ZNameSchema,
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
type TUpdatePasskeySchema = z.infer<typeof ZUpdatePasskeySchema>;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
||||
export const PERIOD_OPTIONS: Array<{ label: MessageDescriptor; value: string }> = [
|
||||
{
|
||||
label: msg`All Time`,
|
||||
value: 'all',
|
||||
},
|
||||
{
|
||||
label: msg`Last 7 days`,
|
||||
value: '7d',
|
||||
},
|
||||
{
|
||||
label: msg`Last 14 days`,
|
||||
value: '14d',
|
||||
},
|
||||
{
|
||||
label: msg`Last 30 days`,
|
||||
value: '30d',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,206 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentSource, DocumentStatus as DocumentStatusEnum } from '@prisma/client';
|
||||
import { CheckCircle2, Clock, File, FileText, LinkIcon, XIcon } from 'lucide-react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { parseToStringArray, toCommaSeparatedSearchParam } from '@documenso/lib/utils/params';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import type { DataTableFacetedFilterOption } from '@documenso/ui/primitives/data-table-faceted-filter';
|
||||
import { DataTableFacetedFilter } from '@documenso/ui/primitives/data-table-faceted-filter';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
|
||||
import { PERIOD_OPTIONS } from './table-toolbar.constants';
|
||||
|
||||
export const TemplateDocumentsTableToolbar = () => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const updateSearchParams = useUpdateSearchParams({ preventScrollReset: true });
|
||||
|
||||
const query = searchParams.get('query') ?? '';
|
||||
const period = searchParams.get('period') ?? '';
|
||||
const statusParam = searchParams.get('status');
|
||||
const sourceParam = searchParams.get('source');
|
||||
|
||||
const selectedStatusValues = useMemo(() => parseToStringArray(statusParam), [statusParam]);
|
||||
const selectedSourceValues = useMemo(() => parseToStringArray(sourceParam), [sourceParam]);
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState(query);
|
||||
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchTerm(query);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedSearchTerm !== searchTerm) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (debouncedSearchTerm === query) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateSearchParams(
|
||||
{ query: debouncedSearchTerm || undefined, page: undefined },
|
||||
{ replace: true },
|
||||
);
|
||||
}, [debouncedSearchTerm, query, searchTerm, updateSearchParams]);
|
||||
|
||||
const statusOptions = useMemo<DataTableFacetedFilterOption[]>(
|
||||
() => [
|
||||
{
|
||||
label: _(msg`Completed`),
|
||||
value: DocumentStatusEnum.COMPLETED,
|
||||
icon: CheckCircle2,
|
||||
iconClassName: 'text-green-500 dark:text-green-300',
|
||||
},
|
||||
{
|
||||
label: _(msg`Pending`),
|
||||
value: DocumentStatusEnum.PENDING,
|
||||
icon: Clock,
|
||||
iconClassName: 'text-blue-600 dark:text-blue-300',
|
||||
},
|
||||
{
|
||||
label: _(msg`Draft`),
|
||||
value: DocumentStatusEnum.DRAFT,
|
||||
icon: File,
|
||||
iconClassName: 'text-yellow-500 dark:text-yellow-200',
|
||||
},
|
||||
],
|
||||
[_],
|
||||
);
|
||||
|
||||
const sourceOptions = useMemo<DataTableFacetedFilterOption[]>(
|
||||
() => [
|
||||
{
|
||||
label: _(msg`Template`),
|
||||
value: DocumentSource.TEMPLATE,
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
label: _(msg`Direct Link`),
|
||||
value: DocumentSource.TEMPLATE_DIRECT_LINK,
|
||||
icon: LinkIcon,
|
||||
},
|
||||
],
|
||||
[_],
|
||||
);
|
||||
|
||||
const periodOptions = useMemo<DataTableFacetedFilterOption[]>(() => {
|
||||
return PERIOD_OPTIONS.map((option) => ({
|
||||
label: _(option.label),
|
||||
value: option.value,
|
||||
}));
|
||||
}, [_]);
|
||||
|
||||
const hasActiveFilters =
|
||||
query.length > 0 ||
|
||||
selectedStatusValues.length > 0 ||
|
||||
selectedSourceValues.length > 0 ||
|
||||
(period.length > 0 && period !== 'all');
|
||||
|
||||
const onResetFilters = () => {
|
||||
setSearchTerm('');
|
||||
|
||||
updateSearchParams({
|
||||
query: undefined,
|
||||
status: undefined,
|
||||
source: undefined,
|
||||
period: undefined,
|
||||
page: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative min-w-[286px] max-w-[494px]">
|
||||
<Input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
placeholder={_(msg`Search documents...`)}
|
||||
className="h-9 w-full pe-9"
|
||||
/>
|
||||
|
||||
{searchTerm.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={_(msg`Clear search`)}
|
||||
className="absolute inset-y-0 end-0 flex w-9 items-center justify-center text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
setSearchTerm('');
|
||||
updateSearchParams({ query: undefined, page: undefined }, { replace: true });
|
||||
}}
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DataTableFacetedFilter
|
||||
title={_(msg`Status`)}
|
||||
options={statusOptions}
|
||||
selectedValues={selectedStatusValues}
|
||||
showSearch={false}
|
||||
onSelectedValuesChange={(values) => {
|
||||
updateSearchParams(
|
||||
{
|
||||
status: toCommaSeparatedSearchParam(values),
|
||||
page: undefined,
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<DataTableFacetedFilter
|
||||
title={_(msg`Source`)}
|
||||
options={sourceOptions}
|
||||
selectedValues={selectedSourceValues}
|
||||
showSearch={false}
|
||||
onSelectedValuesChange={(values) => {
|
||||
updateSearchParams(
|
||||
{
|
||||
source: toCommaSeparatedSearchParam(values),
|
||||
page: undefined,
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<DataTableFacetedFilter
|
||||
title={_(msg`Time`)}
|
||||
options={periodOptions}
|
||||
selectedValues={period ? [period] : []}
|
||||
singleSelect
|
||||
showSearch={false}
|
||||
onSelectedValuesChange={(values) => {
|
||||
const nextPeriod = values[0];
|
||||
|
||||
updateSearchParams(
|
||||
{
|
||||
period: nextPeriod ?? undefined,
|
||||
page: undefined,
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<Button variant="ghost" size="sm" onClick={onResetFilters}>
|
||||
<Trans>Reset</Trans>
|
||||
<XIcon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { TemplateType } from '@prisma/client';
|
||||
import { Globe2Icon, LockIcon, XIcon } from 'lucide-react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { parseToStringArray, toCommaSeparatedSearchParam } from '@documenso/lib/utils/params';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import type { DataTableFacetedFilterOption } from '@documenso/ui/primitives/data-table-faceted-filter';
|
||||
import { DataTableFacetedFilter } from '@documenso/ui/primitives/data-table-faceted-filter';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
|
||||
export const TemplatesTableToolbar = () => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
|
||||
const query = searchParams.get('query') ?? '';
|
||||
const typeParam = searchParams.get('type');
|
||||
|
||||
const selectedTypeValues = useMemo(() => parseToStringArray(typeParam), [typeParam]);
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState(query);
|
||||
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchTerm(query);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedSearchTerm !== searchTerm) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (debouncedSearchTerm === query) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateSearchParams(
|
||||
{ query: debouncedSearchTerm || undefined, page: undefined },
|
||||
{ replace: true },
|
||||
);
|
||||
}, [debouncedSearchTerm, query, searchTerm, updateSearchParams]);
|
||||
|
||||
const typeOptions = useMemo<DataTableFacetedFilterOption[]>(
|
||||
() => [
|
||||
{
|
||||
label: _(msg`Public`),
|
||||
value: TemplateType.PUBLIC,
|
||||
icon: Globe2Icon,
|
||||
iconClassName: 'text-green-500 dark:text-green-300',
|
||||
},
|
||||
{
|
||||
label: _(msg`Private`),
|
||||
value: TemplateType.PRIVATE,
|
||||
icon: LockIcon,
|
||||
iconClassName: 'text-blue-600 dark:text-blue-300',
|
||||
},
|
||||
],
|
||||
[_],
|
||||
);
|
||||
|
||||
const hasActiveFilters = query.length > 0 || selectedTypeValues.length > 0;
|
||||
|
||||
const onResetFilters = () => {
|
||||
setSearchTerm('');
|
||||
|
||||
updateSearchParams({
|
||||
query: undefined,
|
||||
type: undefined,
|
||||
page: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative min-w-[286px] max-w-[494px]">
|
||||
<Input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
placeholder={_(msg`Search templates...`)}
|
||||
className="h-9 w-full pe-9"
|
||||
/>
|
||||
|
||||
{searchTerm.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={_(msg`Clear search`)}
|
||||
className="absolute inset-y-0 end-0 flex w-9 items-center justify-center text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
setSearchTerm('');
|
||||
updateSearchParams({ query: undefined, page: undefined }, { replace: true });
|
||||
}}
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DataTableFacetedFilter
|
||||
title={_(msg`Type`)}
|
||||
options={typeOptions}
|
||||
selectedValues={selectedTypeValues}
|
||||
showSearch={false}
|
||||
onSelectedValuesChange={(values) => {
|
||||
updateSearchParams(
|
||||
{
|
||||
type: toCommaSeparatedSearchParam(values),
|
||||
page: undefined,
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<Button variant="ghost" size="sm" onClick={onResetFilters}>
|
||||
<Trans>Reset</Trans>
|
||||
<XIcon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -8,7 +8,6 @@ import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisa
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TGetAdminOrganisationResponse } from '@documenso/trpc/server/admin-router/get-admin-organisation.types';
|
||||
import { ZUpdateAdminOrganisationRequestSchema } from '@documenso/trpc/server/admin-router/update-admin-organisation.types';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@documenso/ui/primitives/accordion';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
@@ -31,7 +30,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { OrganisationMemberRole, SubscriptionStatus } from '@prisma/client';
|
||||
import { OrganisationMemberRole } from '@prisma/client';
|
||||
import { ExternalLinkIcon, InfoIcon, Loader } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
@@ -43,6 +42,7 @@ import { AdminOrganisationDeleteDialog } from '~/components/dialogs/admin-organi
|
||||
import { AdminOrganisationMemberDeleteDialog } from '~/components/dialogs/admin-organisation-member-delete-dialog';
|
||||
import { AdminOrganisationMemberUpdateDialog } from '~/components/dialogs/admin-organisation-member-update-dialog';
|
||||
import { AdminOrganisationSyncSubscriptionDialog } from '~/components/dialogs/admin-organisation-sync-subscription-dialog';
|
||||
import { DetailsCard, DetailsValue } from '~/components/general/admin-details';
|
||||
import { AdminGlobalSettingsSection } from '~/components/general/admin-global-settings-section';
|
||||
import { ClaimLimitFields } from '~/components/general/claim-limit-fields';
|
||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||
@@ -268,32 +268,54 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
||||
|
||||
<GenericOrganisationAdminForm organisation={organisation} />
|
||||
|
||||
<SettingsHeader
|
||||
title={t`Organisation usage`}
|
||||
subtitle={t`Current usage against organisation limits.`}
|
||||
className="mt-6"
|
||||
hideDivider
|
||||
/>
|
||||
<div className="mt-6 rounded-lg border p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-sm">
|
||||
<Trans>Organisation usage</Trans>
|
||||
</p>
|
||||
<p className="mt-1 text-muted-foreground text-sm">
|
||||
<Trans>Current usage against organisation limits.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OrganisationUsagePanel
|
||||
organisationId={organisation.id}
|
||||
monthlyStats={organisation.monthlyStats}
|
||||
organisationClaim={organisation.organisationClaim}
|
||||
capacityUsage={{
|
||||
members: organisation.members.length,
|
||||
teams: organisation.teams.length,
|
||||
}}
|
||||
/>
|
||||
<div className="mt-4 grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
|
||||
<DetailsCard label={<Trans>Members</Trans>}>
|
||||
<DetailsValue>
|
||||
{organisation.members.length} /{' '}
|
||||
{organisation.organisationClaim.memberCount === 0
|
||||
? t`Unlimited`
|
||||
: organisation.organisationClaim.memberCount}
|
||||
</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Teams</Trans>}>
|
||||
<DetailsValue>
|
||||
{organisation.teams.length} /{' '}
|
||||
{organisation.organisationClaim.teamCount === 0 ? t`Unlimited` : organisation.organisationClaim.teamCount}
|
||||
</DetailsValue>
|
||||
</DetailsCard>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<OrganisationUsagePanel
|
||||
organisationId={organisation.id}
|
||||
monthlyStats={organisation.monthlyStats}
|
||||
organisationClaim={organisation.organisationClaim}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-lg border p-4">
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="global-settings" className="border-b-0">
|
||||
<AccordionTrigger className="py-0">
|
||||
<div className="text-left">
|
||||
<p className="font-semibold text-base">
|
||||
<p className="font-medium text-sm">
|
||||
<Trans>Global Settings</Trans>
|
||||
</p>
|
||||
<p className="mt-1 text-muted-foreground text-sm">
|
||||
<p className="mt-1 font-normal text-muted-foreground text-sm">
|
||||
<Trans>Default settings applied to this organisation.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
@@ -313,15 +335,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
||||
className="mt-16"
|
||||
/>
|
||||
|
||||
<Alert
|
||||
className={cn(
|
||||
'my-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center',
|
||||
organisation.subscription?.status === SubscriptionStatus.ACTIVE &&
|
||||
'border border-green-600/20 bg-green-50 dark:border-green-500/20 dark:bg-green-500/10',
|
||||
organisation.subscription?.status === SubscriptionStatus.INACTIVE && 'opacity-60',
|
||||
)}
|
||||
variant="neutral"
|
||||
>
|
||||
<Alert className="my-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<AlertTitle>
|
||||
<Trans>Subscription</Trans>
|
||||
@@ -329,12 +343,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
||||
|
||||
<AlertDescription className="mr-2">
|
||||
{organisation.subscription ? (
|
||||
<span className="flex items-center gap-2">
|
||||
{organisation.subscription.status === SubscriptionStatus.ACTIVE && (
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-green-600 dark:bg-green-400" aria-hidden="true" />
|
||||
)}
|
||||
<span>{i18n._(SUBSCRIPTION_STATUS_MAP[organisation.subscription.status])} subscription found</span>
|
||||
</span>
|
||||
<span>{i18n._(SUBSCRIPTION_STATUS_MAP[organisation.subscription.status])} subscription found</span>
|
||||
) : (
|
||||
<span>
|
||||
<Trans>No subscription found</Trans>
|
||||
@@ -347,7 +356,6 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
||||
<div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="bg-background"
|
||||
loading={isCreatingStripeCustomer}
|
||||
onClick={async () => createStripeCustomer({ organisationId })}
|
||||
>
|
||||
@@ -358,7 +366,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
||||
|
||||
{organisation.customerId && !organisation.subscription && (
|
||||
<div>
|
||||
<Button variant="outline" className="bg-background" asChild>
|
||||
<Button variant="outline" asChild>
|
||||
<Link
|
||||
target="_blank"
|
||||
to={`https://dashboard.stripe.com/customers/${organisation.customerId}?create=subscription&subscription_default_customer=${organisation.customerId}`}
|
||||
@@ -375,13 +383,13 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
||||
<AdminOrganisationSyncSubscriptionDialog
|
||||
organisationId={organisationId}
|
||||
trigger={
|
||||
<Button variant="outline" className="bg-background">
|
||||
<Button variant="outline">
|
||||
<Trans>Sync Stripe subscription</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button variant="outline" className="bg-background" asChild>
|
||||
<Button variant="outline" asChild>
|
||||
<Link
|
||||
target="_blank"
|
||||
to={`https://dashboard.stripe.com/subscriptions/${organisation.subscription.planId}`}
|
||||
@@ -398,27 +406,21 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
||||
|
||||
<div className="mt-16 space-y-10">
|
||||
<div>
|
||||
<h3 className="font-semibold text-base">
|
||||
<label className="font-medium text-sm leading-none">
|
||||
<Trans>Organisation Members</Trans>
|
||||
</h3>
|
||||
<p className="mt-1 text-muted-foreground text-sm">
|
||||
<Trans>People with access to this organisation.</Trans>
|
||||
</p>
|
||||
</label>
|
||||
|
||||
<div className="mt-3">
|
||||
<div className="my-2">
|
||||
<DataTable columns={organisationMembersColumns} data={organisation.members} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold text-base">
|
||||
<label className="font-medium text-sm leading-none">
|
||||
<Trans>Organisation Teams</Trans>
|
||||
</h3>
|
||||
<p className="mt-1 text-muted-foreground text-sm">
|
||||
<Trans>Teams that belong to this organisation.</Trans>
|
||||
</p>
|
||||
</label>
|
||||
|
||||
<div className="mt-3">
|
||||
<div className="my-2">
|
||||
<DataTable columns={teamsColumns} data={organisation.teams} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -646,7 +648,7 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
|
||||
<FormLabel className="flex items-center">
|
||||
<Trans>Inherited subscription claim</Trans>
|
||||
<Tooltip>
|
||||
<TooltipTrigger type="button">
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
@@ -679,15 +681,10 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</FormLabel>
|
||||
<div className="rounded-lg border bg-muted/40 px-3 py-2.5 text-sm">
|
||||
{field.value ? (
|
||||
<span className="font-mono text-foreground">{field.value}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
<Trans>No inherited claim</Trans>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input disabled {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -718,113 +715,108 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="claims.teamCount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Team Count</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<Trans>Number of teams allowed. 0 = Unlimited</Trans>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="claims.teamCount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Team Count</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<Trans>Number of teams allowed. 0 = Unlimited</Trans>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="claims.memberCount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Member Count</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<Trans>Number of members allowed. 0 = Unlimited</Trans>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="claims.memberCount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Member Count</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<Trans>Number of members allowed. 0 = Unlimited</Trans>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="claims.envelopeItemCount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Envelope Item Count</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<Trans>Maximum number of uploaded files per envelope allowed</Trans>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="claims.envelopeItemCount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Envelope Item Count</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<Trans>Maximum number of uploaded files per envelope allowed</Trans>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="claims.recipientCount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Recipient Count</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<Trans>Maximum number of recipients per document allowed. 0 = Unlimited</Trans>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="claims.recipientCount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Recipient Count</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<Trans>Maximum number of recipients per document allowed. 0 = Unlimited</Trans>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold text-base">
|
||||
<FormLabel>
|
||||
<Trans>Feature Flags</Trans>
|
||||
</h3>
|
||||
<p className="mt-1 text-muted-foreground text-sm">
|
||||
<Trans>Capabilities enabled for this organisation.</Trans>
|
||||
</p>
|
||||
</FormLabel>
|
||||
|
||||
<div className="mt-3 space-y-2 rounded-md border p-4">
|
||||
<div className="mt-2 space-y-2 rounded-md border p-4">
|
||||
{Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(({ key, label, isEnterprise }) => {
|
||||
const isRestrictedFeature = isEnterprise && !licenseFlags?.[key as keyof TLicenseClaim]; // eslint-disable-line @typescript-eslint/consistent-type-assertions
|
||||
|
||||
|
||||
@@ -287,11 +287,7 @@ export default function AdminTeamPage({ params }: Route.ComponentProps) {
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="mt-4">
|
||||
<AdminGlobalSettingsSection
|
||||
settings={team.teamGlobalSettings}
|
||||
inheritedSettings={team.organisation.organisationGlobalSettings}
|
||||
isTeam
|
||||
/>
|
||||
<AdminGlobalSettingsSection settings={team.teamGlobalSettings} isTeam />
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@@ -48,29 +49,26 @@ export default function OrganisationSettingsBrandingPage() {
|
||||
|
||||
const { mutateAsync: updateOrganisationSettings } = trpc.organisation.settings.update.useMutation();
|
||||
|
||||
const { mutateAsync: updateOrganisationBrandingLogo } = trpc.organisation.settings.updateBrandingLogo.useMutation();
|
||||
|
||||
const onBrandingPreferencesFormSubmit = async (data: TBrandingPreferencesFormSchema) => {
|
||||
try {
|
||||
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, brandingCss } = data;
|
||||
|
||||
// Upload (or clear) the logo through the dedicated, server-validated route.
|
||||
if (brandingLogo instanceof File || brandingLogo === null) {
|
||||
const formData = new FormData();
|
||||
let uploadedBrandingLogo: string | undefined;
|
||||
|
||||
formData.append('payload', JSON.stringify({ organisationId: organisation.id }));
|
||||
if (brandingLogo) {
|
||||
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
|
||||
}
|
||||
|
||||
if (brandingLogo instanceof File) {
|
||||
formData.append('brandingLogo', brandingLogo);
|
||||
}
|
||||
|
||||
await updateOrganisationBrandingLogo(formData);
|
||||
// Empty the branding logo if the user unsets it.
|
||||
if (brandingLogo === null) {
|
||||
uploadedBrandingLogo = '';
|
||||
}
|
||||
|
||||
const result = await updateOrganisationSettings({
|
||||
organisationId: organisation.id,
|
||||
data: {
|
||||
brandingEnabled: brandingEnabled ?? undefined,
|
||||
brandingLogo: uploadedBrandingLogo,
|
||||
brandingUrl,
|
||||
brandingCompanyDetails,
|
||||
brandingColors,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/org
|
||||
import { EXTENDED_ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
|
||||
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TFindOrganisationGroupsResponse } from '@documenso/trpc/server/organisation-router/find-organisation-groups.types';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -29,6 +28,7 @@ import { useMemo, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Link } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { OrganisationGroupDeleteDialog } from '~/components/dialogs/organisation-group-delete-dialog';
|
||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||
import {
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
OrganisationMembersMultiSelectCombobox,
|
||||
} from '~/components/general/organisation-members-multiselect-combobox';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
|
||||
import type { Route } from './+types/o.$orgUrl.settings.groups.$id';
|
||||
|
||||
export default function OrganisationGroupSettingsPage({ params }: Route.ComponentProps) {
|
||||
@@ -112,7 +113,7 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
|
||||
}
|
||||
|
||||
const ZUpdateOrganisationGroupFormSchema = z.object({
|
||||
name: ZNameSchema,
|
||||
name: z.string().min(1, msg`Name is required`.id),
|
||||
organisationRole: z.nativeEnum(OrganisationMemberRole),
|
||||
memberIds: z.array(z.string()),
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useSessionStorage } from '@documenso/lib/client-only/hooks/use-session-storage';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { STATS_COUNT_CAP } from '@documenso/lib/constants/document';
|
||||
import { SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { parseToIntegerArray } from '@documenso/lib/utils/params';
|
||||
@@ -11,25 +10,24 @@ import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/docu
|
||||
import { ZFindDocumentsInternalRequestSchema } from '@documenso/trpc/server/document-router/find-documents-internal.types';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
import type { RowSelectionState } from '@documenso/ui/primitives/data-table';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
import type { DataTableFacetedFilterOption } from '@documenso/ui/primitives/data-table-faceted-filter';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType, FolderType, OrganisationType } from '@prisma/client';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { EnvelopesBulkCancelDialog } from '~/components/dialogs/envelopes-bulk-cancel-dialog';
|
||||
import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog';
|
||||
import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog';
|
||||
import { DocumentSearch } from '~/components/general/document/document-search';
|
||||
import { DocumentStatus } from '~/components/general/document/document-status';
|
||||
import { FRIENDLY_STATUS_MAP } from '~/components/general/document/document-status';
|
||||
import { EnvelopeDropZoneWrapper } from '~/components/general/envelope/envelope-drop-zone-wrapper';
|
||||
import { FolderGrid } from '~/components/general/folder/folder-grid';
|
||||
import { PeriodSelector } from '~/components/general/period-selector';
|
||||
import { DocumentsTable } from '~/components/tables/documents-table';
|
||||
import { DocumentsTableEmptyState } from '~/components/tables/documents-table-empty-state';
|
||||
import { DocumentsTableSenderFilter } from '~/components/tables/documents-table-sender-filter';
|
||||
import { DocumentsTableToolbar } from '~/components/tables/documents-table-toolbar';
|
||||
import { EnvelopesTableBulkActionBar } from '~/components/tables/envelopes-table-bulk-action-bar';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
@@ -49,6 +47,8 @@ const ZSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({
|
||||
});
|
||||
|
||||
export default function DocumentsPage() {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
const team = useCurrentTeam();
|
||||
|
||||
@@ -91,40 +91,41 @@ export default function DocumentsPage() {
|
||||
folderId,
|
||||
},
|
||||
{
|
||||
placeholderData: (previousData) => previousData,
|
||||
...SKIP_QUERY_BATCH_META,
|
||||
},
|
||||
);
|
||||
|
||||
const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
const statusOptions = useMemo<DataTableFacetedFilterOption[]>(() => {
|
||||
return [
|
||||
ExtendedDocumentStatus.INBOX,
|
||||
ExtendedDocumentStatus.PENDING,
|
||||
ExtendedDocumentStatus.COMPLETED,
|
||||
ExtendedDocumentStatus.CANCELLED,
|
||||
ExtendedDocumentStatus.DRAFT,
|
||||
ExtendedDocumentStatus.REJECTED,
|
||||
]
|
||||
.filter((status) => {
|
||||
if (organisation.type === OrganisationType.PERSONAL) {
|
||||
return status !== ExtendedDocumentStatus.INBOX;
|
||||
}
|
||||
|
||||
params.set('status', value);
|
||||
return true;
|
||||
})
|
||||
.map((status) => {
|
||||
const { label, icon, color } = FRIENDLY_STATUS_MAP[status];
|
||||
|
||||
if (value === ExtendedDocumentStatus.ALL) {
|
||||
params.delete('status');
|
||||
}
|
||||
return {
|
||||
label: _(label),
|
||||
value: status,
|
||||
icon,
|
||||
iconClassName: color,
|
||||
};
|
||||
});
|
||||
}, [organisation.type, _]);
|
||||
|
||||
if (value === ExtendedDocumentStatus.INBOX && organisation.type === OrganisationType.PERSONAL) {
|
||||
params.delete('status');
|
||||
}
|
||||
const selectedStatuses = findDocumentSearchParams.status ?? [];
|
||||
|
||||
if (params.has('page')) {
|
||||
params.delete('page');
|
||||
}
|
||||
|
||||
let path = formatDocumentsPath(team.url);
|
||||
|
||||
if (folderId) {
|
||||
path += `/f/${folderId}`;
|
||||
}
|
||||
|
||||
if (params.toString()) {
|
||||
path += `?${params.toString()}`;
|
||||
}
|
||||
|
||||
return path;
|
||||
};
|
||||
const selectedStatus = selectedStatuses.length === 1 ? selectedStatuses[0] : ExtendedDocumentStatus.ALL;
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.stats) {
|
||||
@@ -148,56 +149,16 @@ export default function DocumentsPage() {
|
||||
<Trans>Documents</Trans>
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
|
||||
<Tabs value={findDocumentSearchParams.status || 'ALL'} className="overflow-x-auto">
|
||||
<TabsList>
|
||||
{[
|
||||
ExtendedDocumentStatus.INBOX,
|
||||
ExtendedDocumentStatus.PENDING,
|
||||
ExtendedDocumentStatus.COMPLETED,
|
||||
ExtendedDocumentStatus.CANCELLED,
|
||||
ExtendedDocumentStatus.DRAFT,
|
||||
ExtendedDocumentStatus.ALL,
|
||||
]
|
||||
.filter((value) => {
|
||||
if (organisation.type === OrganisationType.PERSONAL) {
|
||||
return value !== ExtendedDocumentStatus.INBOX;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.map((value) => (
|
||||
<TabsTrigger key={value} className="min-w-[60px] hover:text-foreground" value={value} asChild>
|
||||
<Link to={getTabHref(value)} preventScrollReset>
|
||||
<DocumentStatus status={value} />
|
||||
|
||||
{value !== ExtendedDocumentStatus.ALL && (
|
||||
<span className="ml-1 inline-block opacity-50">
|
||||
{stats[value] >= STATS_COUNT_CAP ? `${STATS_COUNT_CAP.toLocaleString()}+` : stats[value]}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{team && <DocumentsTableSenderFilter teamId={team.id} />}
|
||||
|
||||
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
||||
<PeriodSelector />
|
||||
</div>
|
||||
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
||||
<DocumentSearch initialValue={findDocumentSearchParams.query} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<DocumentsTableToolbar teamId={team?.id} statusOptions={statusOptions} statusCounts={stats} />
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<div>
|
||||
{data && data.count === 0 ? (
|
||||
<DocumentsTableEmptyState status={findDocumentSearchParams.status || ExtendedDocumentStatus.ALL} />
|
||||
<DocumentsTableEmptyState status={selectedStatus} />
|
||||
) : (
|
||||
<DocumentsTable
|
||||
data={data}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@@ -37,7 +38,6 @@ export default function TeamsSettingsPage() {
|
||||
});
|
||||
|
||||
const { mutateAsync: updateTeamSettings } = trpc.team.settings.update.useMutation();
|
||||
const { mutateAsync: updateTeamBrandingLogo } = trpc.team.settings.updateBrandingLogo.useMutation();
|
||||
|
||||
const canConfigureBranding = organisation.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED();
|
||||
|
||||
@@ -48,23 +48,22 @@ export default function TeamsSettingsPage() {
|
||||
try {
|
||||
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, brandingCss } = data;
|
||||
|
||||
// Upload (or clear) the logo through the dedicated, server-validated route.
|
||||
if (brandingLogo instanceof File || brandingLogo === null) {
|
||||
const formData = new FormData();
|
||||
let uploadedBrandingLogo: string | undefined;
|
||||
|
||||
formData.append('payload', JSON.stringify({ teamId: team.id }));
|
||||
if (brandingLogo) {
|
||||
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
|
||||
}
|
||||
|
||||
if (brandingLogo instanceof File) {
|
||||
formData.append('brandingLogo', brandingLogo);
|
||||
}
|
||||
|
||||
await updateTeamBrandingLogo(formData);
|
||||
// Empty the branding logo if the user unsets it.
|
||||
if (brandingLogo === null) {
|
||||
uploadedBrandingLogo = '';
|
||||
}
|
||||
|
||||
const result = await updateTeamSettings({
|
||||
teamId: team.id,
|
||||
data: {
|
||||
brandingEnabled,
|
||||
brandingLogo: uploadedBrandingLogo,
|
||||
brandingUrl: brandingUrl || null,
|
||||
brandingCompanyDetails: brandingCompanyDetails || null,
|
||||
brandingColors,
|
||||
|
||||
@@ -72,19 +72,14 @@ export default function WebhookPage({ params }: Route.ComponentProps) {
|
||||
data,
|
||||
isLoading: isLogsLoading,
|
||||
isLoadingError: isLogsLoadingError,
|
||||
} = trpc.webhook.calls.find.useQuery(
|
||||
{
|
||||
webhookId: params.id,
|
||||
page: parsedSearchParams.page,
|
||||
perPage: parsedSearchParams.perPage,
|
||||
status: parsedSearchParams.status,
|
||||
events: parsedSearchParams.events,
|
||||
query: parsedSearchParams.query,
|
||||
},
|
||||
{
|
||||
placeholderData: (previousData) => previousData,
|
||||
},
|
||||
);
|
||||
} = trpc.webhook.calls.find.useQuery({
|
||||
webhookId: params.id,
|
||||
page: parsedSearchParams.page,
|
||||
perPage: parsedSearchParams.perPage,
|
||||
status: parsedSearchParams.status,
|
||||
events: parsedSearchParams.events,
|
||||
query: parsedSearchParams.query,
|
||||
});
|
||||
|
||||
/**
|
||||
* Handle debouncing the search query.
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useSessionStorage } from '@documenso/lib/client-only/hooks/use-session-storage';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { FolderType } from '@documenso/lib/types/folder-type';
|
||||
import { ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { parseToStringArray } from '@documenso/lib/utils/params';
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
@@ -9,6 +11,7 @@ import type { RowSelectionState } from '@documenso/ui/primitives/data-table';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { TemplateType } from '@prisma/client';
|
||||
import { EnvelopeType, OrganisationType } from '@prisma/client';
|
||||
import { Bird } from 'lucide-react';
|
||||
import { parseAsStringLiteral, useQueryState } from 'nuqs';
|
||||
@@ -21,6 +24,7 @@ import { EnvelopeDropZoneWrapper } from '~/components/general/envelope/envelope-
|
||||
import { FolderGrid } from '~/components/general/folder/folder-grid';
|
||||
import { EnvelopesTableBulkActionBar } from '~/components/tables/envelopes-table-bulk-action-bar';
|
||||
import { TemplatesTable } from '~/components/tables/templates-table';
|
||||
import { TemplatesTableToolbar } from '~/components/tables/templates-table-toolbar';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
@@ -32,6 +36,12 @@ export function meta() {
|
||||
return appMetaTags(msg`Templates`);
|
||||
}
|
||||
|
||||
const ZTemplatesSearchParamsSchema = ZFindSearchParamsSchema.pick({
|
||||
query: true,
|
||||
page: true,
|
||||
perPage: true,
|
||||
});
|
||||
|
||||
export default function TemplatesPage() {
|
||||
const team = useCurrentTeam();
|
||||
const organisation = useCurrentOrganisation();
|
||||
@@ -39,8 +49,15 @@ export default function TemplatesPage() {
|
||||
const { folderId } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const page = Number(searchParams.get('page')) || 1;
|
||||
const perPage = Number(searchParams.get('perPage')) || 10;
|
||||
const findTemplatesSearchParams = useMemo(
|
||||
() => ZTemplatesSearchParamsSchema.safeParse(Object.fromEntries(searchParams.entries())).data || {},
|
||||
[searchParams],
|
||||
);
|
||||
|
||||
const typeFilter = useMemo(() => {
|
||||
const selected = parseToStringArray(searchParams.get('type'));
|
||||
return selected.length === 1 ? (selected[0] as TemplateType) : undefined;
|
||||
}, [searchParams]);
|
||||
|
||||
const [view, setView] = useQueryState('view', parseAsStringLiteral(TEMPLATE_VIEWS).withDefault('team'));
|
||||
|
||||
@@ -60,24 +77,22 @@ export default function TemplatesPage() {
|
||||
|
||||
const teamTemplatesQuery = trpc.template.findTemplates.useQuery(
|
||||
{
|
||||
page,
|
||||
perPage,
|
||||
...findTemplatesSearchParams,
|
||||
type: typeFilter,
|
||||
folderId,
|
||||
},
|
||||
{
|
||||
enabled: !isOrgView,
|
||||
placeholderData: (previousData) => previousData,
|
||||
},
|
||||
);
|
||||
|
||||
const orgTemplatesQuery = trpc.template.findOrganisationTemplates.useQuery(
|
||||
{
|
||||
page,
|
||||
perPage,
|
||||
page: findTemplatesSearchParams.page,
|
||||
perPage: findTemplatesSearchParams.perPage,
|
||||
},
|
||||
{
|
||||
enabled: isOrgView,
|
||||
placeholderData: (previousData) => previousData,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -131,6 +146,12 @@ export default function TemplatesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isOrgView && (
|
||||
<div className="mt-8">
|
||||
<TemplatesTableToolbar />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8">
|
||||
{activeQuery.data && activeQuery.data.count === 0 ? (
|
||||
<div className="flex h-96 flex-col items-center justify-center gap-y-4 text-muted-foreground/60">
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
||||
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { sValidator } from '@hono/standard-validator';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
@@ -11,11 +12,14 @@ import { Hono } from 'hono';
|
||||
import type { HonoEnv } from '../../router';
|
||||
import { checkEnvelopeFileAccess, handleEnvelopeItemFileRequest, resolveFileUploadUserId } from './files.helpers';
|
||||
import {
|
||||
isAllowedUploadContentType,
|
||||
type TGetPresignedPostUrlResponse,
|
||||
ZGetEnvelopeItemFileDownloadRequestParamsSchema,
|
||||
ZGetEnvelopeItemFileRequestParamsSchema,
|
||||
ZGetEnvelopeItemFileRequestQuerySchema,
|
||||
ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema,
|
||||
ZGetEnvelopeItemFileTokenRequestParamsSchema,
|
||||
ZGetPresignedPostUrlRequestSchema,
|
||||
ZUploadPdfRequestSchema,
|
||||
} from './files.types';
|
||||
import getEnvelopeItemPdfRoute from './routes/get-envelope-item-pdf';
|
||||
@@ -57,6 +61,29 @@ export const filesRoute = new Hono<HonoEnv>()
|
||||
return c.json({ error: 'Upload failed' }, 500);
|
||||
}
|
||||
})
|
||||
.post('/presigned-post-url', sValidator('json', ZGetPresignedPostUrlRequestSchema), async (c) => {
|
||||
const userId = await resolveFileUploadUserId(c);
|
||||
|
||||
if (!userId) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const { fileName, contentType } = c.req.valid('json');
|
||||
|
||||
if (!isAllowedUploadContentType(contentType)) {
|
||||
return c.json({ error: 'Unsupported content type' }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const { key, url } = await getPresignPostUrl(fileName, contentType, userId);
|
||||
|
||||
return c.json({ key, url } satisfies TGetPresignedPostUrlResponse);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR);
|
||||
}
|
||||
})
|
||||
.get(
|
||||
'/envelope/:envelopeId/envelopeItem/:envelopeItemId',
|
||||
sValidator('param', ZGetEnvelopeItemFileRequestParamsSchema),
|
||||
|
||||
@@ -13,6 +13,27 @@ export const ZUploadPdfResponseSchema = DocumentDataSchema.pick({
|
||||
export type TUploadPdfRequest = z.infer<typeof ZUploadPdfRequestSchema>;
|
||||
export type TUploadPdfResponse = z.infer<typeof ZUploadPdfResponseSchema>;
|
||||
|
||||
export const ALLOWED_UPLOAD_CONTENT_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'] as const;
|
||||
|
||||
export const isAllowedUploadContentType = (contentType: string): boolean => {
|
||||
const normalizedContentType = contentType.split(';').at(0)?.trim().toLowerCase();
|
||||
|
||||
return ALLOWED_UPLOAD_CONTENT_TYPES.some((allowed) => allowed === normalizedContentType);
|
||||
};
|
||||
|
||||
export const ZGetPresignedPostUrlRequestSchema = z.object({
|
||||
fileName: z.string().min(1),
|
||||
contentType: z.string().min(1),
|
||||
});
|
||||
|
||||
export const ZGetPresignedPostUrlResponseSchema = z.object({
|
||||
key: z.string().min(1),
|
||||
url: z.string().min(1),
|
||||
});
|
||||
|
||||
export type TGetPresignedPostUrlRequest = z.infer<typeof ZGetPresignedPostUrlRequestSchema>;
|
||||
export type TGetPresignedPostUrlResponse = z.infer<typeof ZGetPresignedPostUrlResponseSchema>;
|
||||
|
||||
export const ZGetEnvelopeItemFileRequestParamsSchema = z.object({
|
||||
envelopeId: z.string().min(1),
|
||||
envelopeItemId: z.string().min(1),
|
||||
|
||||
@@ -105,6 +105,7 @@ app.route('/api/auth', auth);
|
||||
|
||||
// Files route.
|
||||
app.use('/api/files/upload-pdf', fileRateLimitMiddleware);
|
||||
app.use('/api/files/presigned-post-url', fileRateLimitMiddleware);
|
||||
app.route('/api/files', filesRoute);
|
||||
|
||||
// AI route.
|
||||
|
||||
@@ -44,6 +44,46 @@ test.describe('File upload endpoint authorization', () => {
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('rejects an unauthenticated presigned-post-url request', async ({ request }) => {
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: { fileName: 'test.pdf', contentType: 'application/pdf' },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('rejects a presigned-post-url request with an invalid presign token', async ({ request }) => {
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer not-a-real-token',
|
||||
},
|
||||
data: { fileName: 'test.pdf', contentType: 'application/pdf' },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('rejects a presigned-post-url request with a disallowed content type', async ({ request }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const presignToken = await createPresignTokenForUser(user.id, team.id);
|
||||
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${presignToken}`,
|
||||
},
|
||||
data: { fileName: 'malware.exe', contentType: 'application/x-msdownload' },
|
||||
});
|
||||
|
||||
// Authenticated, but the content type is not on the allow-list.
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(400);
|
||||
});
|
||||
|
||||
test('allows an upload-pdf request authorized by a valid presign token', async ({ request }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const presignToken = await createPresignTokenForUser(user.id, team.id);
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { optimiseBrandingLogo } from '@documenso/lib/utils/images/logo';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const makePng = async (width = 1200, height = 1200) =>
|
||||
sharp({
|
||||
create: { width, height, channels: 3, background: { r: 10, g: 20, b: 30 } },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
test.describe('optimiseBrandingLogo', () => {
|
||||
test('re-encodes a valid image to a PNG buffer', async () => {
|
||||
const input = await makePng();
|
||||
|
||||
const output = await optimiseBrandingLogo(input);
|
||||
|
||||
const metadata = await sharp(output).metadata();
|
||||
|
||||
expect(metadata.format).toBe('png');
|
||||
});
|
||||
|
||||
test('bounds the image to a maximum of 512px on its largest side', async () => {
|
||||
const input = await makePng(2000, 1000);
|
||||
|
||||
const output = await optimiseBrandingLogo(input);
|
||||
|
||||
const metadata = await sharp(output).metadata();
|
||||
|
||||
expect(metadata.width).toBeLessThanOrEqual(512);
|
||||
expect(metadata.height).toBeLessThanOrEqual(512);
|
||||
});
|
||||
|
||||
test('rejects input that is not a valid image', async () => {
|
||||
await expect(optimiseBrandingLogo(Buffer.from('this is not an image'))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,225 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from './fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
const LOGO_PATH = path.join(__dirname, '../../assets/logo.png');
|
||||
|
||||
type MultipartFile = { name: string; mimeType: string; buffer: Buffer };
|
||||
|
||||
const enableBrandingAndUpload = async (page: Page) => {
|
||||
// Enable custom branding so the file input is no longer disabled.
|
||||
await page.getByTestId('enable-branding').click();
|
||||
await page.getByRole('option', { name: 'Yes' }).click();
|
||||
|
||||
// Upload the logo file through the real multipart route.
|
||||
await page.locator('input[type="file"]').setInputFiles(LOGO_PATH);
|
||||
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
|
||||
};
|
||||
|
||||
/**
|
||||
* POST a logo straight to the dedicated multipart tRPC route using the
|
||||
* authenticated browser cookies. This bypasses the client-side form validation,
|
||||
* which is the only way to exercise the server-side image validation /
|
||||
* sanitisation (`zfdBrandingImageFile` + `optimiseBrandingLogo`) and the entitlement gate.
|
||||
*/
|
||||
const postOrganisationBrandingLogo = async (page: Page, organisationId: string, file: MultipartFile | null) => {
|
||||
const multipart: Record<string, string | MultipartFile> = {
|
||||
payload: JSON.stringify({ organisationId }),
|
||||
};
|
||||
|
||||
if (file) {
|
||||
multipart.brandingLogo = file;
|
||||
}
|
||||
|
||||
return await page
|
||||
.context()
|
||||
.request.post(`${NEXT_PUBLIC_WEBAPP_URL()}/api/trpc/organisation.settings.updateBrandingLogo`, { multipart });
|
||||
};
|
||||
|
||||
/**
|
||||
* Grant the organisation the custom-branding entitlement. The positive branding
|
||||
* flows require it whenever billing is enabled; with billing disabled the gate is
|
||||
* bypassed, so this keeps these tests valid in both modes.
|
||||
*/
|
||||
const grantCustomBranding = async (organisationClaimId: string) => {
|
||||
await prisma.organisationClaim.update({
|
||||
where: { id: organisationClaimId },
|
||||
data: { flags: { allowLegacyEnvelopes: true, allowCustomBranding: true } },
|
||||
});
|
||||
};
|
||||
|
||||
test('[BRANDING_LOGO]: uploads an organisation branding logo via the dedicated route', async ({ page }) => {
|
||||
const { user, organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
await grantCustomBranding(organisation.organisationClaim.id);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/o/${organisation.url}/settings/branding`,
|
||||
});
|
||||
|
||||
await enableBrandingAndUpload(page);
|
||||
|
||||
const settings = await prisma.organisationGlobalSettings.findUniqueOrThrow({
|
||||
where: { id: organisation.organisationGlobalSettingsId },
|
||||
});
|
||||
|
||||
expect(settings.brandingLogo).toBeTruthy();
|
||||
|
||||
const parsed = JSON.parse(settings.brandingLogo);
|
||||
expect(parsed).toHaveProperty('type');
|
||||
expect(parsed).toHaveProperty('data');
|
||||
});
|
||||
|
||||
test('[BRANDING_LOGO]: uploads a team branding logo via the dedicated route', async ({ page }) => {
|
||||
const { user, team, organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
await grantCustomBranding(organisation.organisationClaim.id);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/settings/branding`,
|
||||
});
|
||||
|
||||
await enableBrandingAndUpload(page);
|
||||
|
||||
// TeamGlobalSettings has no `teamId` column (the FK lives on Team), so read it
|
||||
// through the team relation.
|
||||
const teamWithSettings = await prisma.team.findUniqueOrThrow({
|
||||
where: { id: team.id },
|
||||
include: { teamGlobalSettings: true },
|
||||
});
|
||||
|
||||
expect(teamWithSettings.teamGlobalSettings?.brandingLogo).toBeTruthy();
|
||||
|
||||
const parsed = JSON.parse(teamWithSettings.teamGlobalSettings?.brandingLogo ?? '');
|
||||
expect(parsed).toHaveProperty('type');
|
||||
expect(parsed).toHaveProperty('data');
|
||||
});
|
||||
|
||||
test('[BRANDING_LOGO]: clears the organisation branding logo when the user removes it', async ({ page }) => {
|
||||
const { user, organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
await grantCustomBranding(organisation.organisationClaim.id);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/o/${organisation.url}/settings/branding`,
|
||||
});
|
||||
|
||||
await enableBrandingAndUpload(page);
|
||||
|
||||
// Confirm the logo was stored before we clear it.
|
||||
const settings = await prisma.organisationGlobalSettings.findUniqueOrThrow({
|
||||
where: { id: organisation.organisationGlobalSettingsId },
|
||||
});
|
||||
|
||||
expect(settings.brandingLogo).toBeTruthy();
|
||||
|
||||
// Remove the logo and save again.
|
||||
await page.getByRole('button', { name: 'Remove' }).click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
|
||||
// Clearing the logo persists an empty string via the dedicated route.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const updated = await prisma.organisationGlobalSettings.findUniqueOrThrow({
|
||||
where: { id: organisation.organisationGlobalSettingsId },
|
||||
});
|
||||
|
||||
return updated.brandingLogo;
|
||||
})
|
||||
.toBe('');
|
||||
});
|
||||
|
||||
test('[BRANDING_LOGO]: validates and sanitises the logo on the server', async ({ page }) => {
|
||||
const { user, organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
await grantCustomBranding(organisation.organisationClaim.id);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/o/${organisation.url}/settings/branding`,
|
||||
});
|
||||
|
||||
// Positive control: a genuine PNG is accepted and stored. This also proves the
|
||||
// direct multipart request shape matches what the route expects.
|
||||
const validResponse = await postOrganisationBrandingLogo(page, organisation.id, {
|
||||
name: 'logo.png',
|
||||
mimeType: 'image/png',
|
||||
buffer: fs.readFileSync(LOGO_PATH),
|
||||
});
|
||||
|
||||
expect(validResponse.ok()).toBeTruthy();
|
||||
|
||||
const afterValid = await prisma.organisationGlobalSettings.findUniqueOrThrow({
|
||||
where: { id: organisation.organisationGlobalSettingsId },
|
||||
});
|
||||
|
||||
expect(afterValid.brandingLogo).toBeTruthy();
|
||||
|
||||
// Bytes that pass the MIME/size allowlist but are not a real image must be
|
||||
// rejected by the server (the `sharp` re-encode) without changing stored state.
|
||||
const invalidResponse = await postOrganisationBrandingLogo(page, organisation.id, {
|
||||
name: 'fake.png',
|
||||
mimeType: 'image/png',
|
||||
buffer: Buffer.from('this is definitely not a valid png'),
|
||||
});
|
||||
|
||||
expect(invalidResponse.ok()).toBeFalsy();
|
||||
expect(invalidResponse.status()).toBeGreaterThanOrEqual(400);
|
||||
expect(invalidResponse.status()).toBeLessThan(500);
|
||||
|
||||
const afterInvalid = await prisma.organisationGlobalSettings.findUniqueOrThrow({
|
||||
where: { id: organisation.organisationGlobalSettingsId },
|
||||
});
|
||||
|
||||
// The previously stored, valid logo is left untouched by the rejected upload.
|
||||
expect(afterInvalid.brandingLogo).toBe(afterValid.brandingLogo);
|
||||
});
|
||||
|
||||
test('[BRANDING_LOGO]: rejects setting a logo without the custom-branding entitlement', async ({ page }) => {
|
||||
// The entitlement is only enforced when billing is enabled; with billing off
|
||||
// the check is intentionally skipped server-side, so this can't be exercised.
|
||||
test.skip(
|
||||
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED !== 'true',
|
||||
'Entitlement is only enforced when billing is enabled.',
|
||||
);
|
||||
|
||||
// Seeded organisations have no `allowCustomBranding` claim flag.
|
||||
const { user, organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/o/${organisation.url}/settings/branding`,
|
||||
});
|
||||
|
||||
const response = await postOrganisationBrandingLogo(page, organisation.id, {
|
||||
name: 'logo.png',
|
||||
mimeType: 'image/png',
|
||||
buffer: fs.readFileSync(LOGO_PATH),
|
||||
});
|
||||
|
||||
expect(response.ok()).toBeFalsy();
|
||||
|
||||
const settings = await prisma.organisationGlobalSettings.findUniqueOrThrow({
|
||||
where: { id: organisation.organisationGlobalSettingsId },
|
||||
});
|
||||
|
||||
expect(settings.brandingLogo).toBeFalsy();
|
||||
});
|
||||
@@ -67,7 +67,9 @@ test('[DOCUMENTS]: cancelling a pending document keeps it in the owner dashboard
|
||||
await checkDocumentTabCount(page, 'All', 1);
|
||||
|
||||
// The cancelled document is still listed.
|
||||
await page.getByRole('tab', { name: 'Cancelled' }).click();
|
||||
await page.getByRole('button', { name: /Status/ }).click();
|
||||
await page.getByRole('option', { name: 'Cancelled' }).click();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
|
||||
|
||||
// The envelope status is persisted as CANCELLED.
|
||||
@@ -131,7 +133,9 @@ test('[DOCUMENTS]: a cancelled document can be deleted, hiding it from the owner
|
||||
await expectToastTextToBeVisible(page, 'Document cancelled');
|
||||
|
||||
// Delete the now-cancelled document. Being terminal, it should soft delete (hide).
|
||||
await page.getByRole('tab', { name: 'Cancelled' }).click();
|
||||
await page.getByRole('button', { name: /Status/ }).click();
|
||||
await page.getByRole('option', { name: 'Cancelled' }).click();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
const documentActionBtn = page
|
||||
.locator('tr', { hasText: 'Document 1 - Pending' })
|
||||
|
||||
@@ -807,8 +807,8 @@ test.describe('Find Documents UI - Data Isolation & No Leaking', () => {
|
||||
await checkDocumentTabCount(page, 'Draft', 1);
|
||||
await checkDocumentTabCount(page, 'Completed', 1);
|
||||
|
||||
// Verify no B docs leaked
|
||||
await page.getByRole('tab', { name: 'All' }).click();
|
||||
// Verify no B docs leaked (default view shows all statuses)
|
||||
await page.goto(`/t/${teamA.url}/documents`);
|
||||
await expect(page.getByRole('link', { name: 'A Own Draft' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'B Draft Private', exact: true })).not.toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'B Pending Private', exact: true })).not.toBeVisible();
|
||||
@@ -1156,7 +1156,7 @@ test.describe('Find Documents UI - Sender Filter', () => {
|
||||
await checkDocumentTabCount(page, 'All', 3);
|
||||
|
||||
// Filter by member1
|
||||
await page.locator('button').filter({ hasText: 'Sender: All' }).click();
|
||||
await page.getByRole('button', { name: /Sender/ }).click();
|
||||
await page.getByRole('option', { name: member1.name ?? '' }).click();
|
||||
await page.waitForURL(/senderIds/);
|
||||
|
||||
|
||||
@@ -2,12 +2,29 @@ import type { Page } from '@playwright/test';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
export const checkDocumentTabCount = async (page: Page, tabName: string, count: number) => {
|
||||
await page.getByRole('tab', { name: tabName }).click();
|
||||
const statusMap: Record<string, string | undefined> = {
|
||||
Inbox: 'INBOX',
|
||||
Pending: 'PENDING',
|
||||
Completed: 'COMPLETED',
|
||||
Draft: 'DRAFT',
|
||||
Cancelled: 'CANCELLED',
|
||||
Rejected: 'REJECTED',
|
||||
All: undefined,
|
||||
};
|
||||
|
||||
if (tabName !== 'All') {
|
||||
await expect(page.getByRole('tab', { name: tabName })).toContainText(count.toString());
|
||||
const currentUrl = new URL(page.url());
|
||||
const status = statusMap[tabName];
|
||||
|
||||
if (status) {
|
||||
currentUrl.searchParams.set('status', status);
|
||||
} else {
|
||||
currentUrl.searchParams.delete('status');
|
||||
}
|
||||
|
||||
currentUrl.searchParams.delete('page');
|
||||
|
||||
await page.goto(currentUrl.toString());
|
||||
|
||||
if (count === 0) {
|
||||
await expect(page.getByTestId('empty-document-state')).toBeVisible();
|
||||
return;
|
||||
|
||||
@@ -142,38 +142,3 @@ test('[SIGNING_BRANDING]: embedded signing does not render custom logo Brand Web
|
||||
await expect(page.locator(`a[href="${BRANDING_URL}"]`)).toHaveCount(0);
|
||||
await expect(page.getByRole('link', { name: `${team.name}'s Logo` })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('[SIGNING_BRANDING]: custom logo renders when branding is enabled and is hidden when disabled', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user, team, organisation } = await seedUser();
|
||||
|
||||
await enableOrganisationBranding({
|
||||
organisationGlobalSettingsId: organisation.organisationGlobalSettingsId,
|
||||
});
|
||||
|
||||
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: ['enabled-disabled-branding-signer@test.documenso.com'],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
updateDocumentOptions: { internalVersion: 2 },
|
||||
});
|
||||
|
||||
// Branding enabled → the custom logo is rendered on the signing page.
|
||||
await page.goto(`/sign/${recipients[0].token}`);
|
||||
await expectPlainBrandingLogo(page, `${team.name}'s Logo`);
|
||||
|
||||
// Disable branding while keeping the stored logo (the team inherits this).
|
||||
await prisma.organisationGlobalSettings.update({
|
||||
where: { id: organisation.organisationGlobalSettingsId },
|
||||
data: { brandingEnabled: false },
|
||||
});
|
||||
|
||||
// Branding disabled → the custom logo is gone and the Documenso fallback
|
||||
// (an internal link to "/") is shown instead.
|
||||
await page.goto(`/sign/${recipients[0].token}`);
|
||||
|
||||
await expect(page.getByRole('img', { name: `${team.name}'s Logo` })).toHaveCount(0);
|
||||
await expect(page.locator('a[href="/"]').first()).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ test('[TEAMS]: check team documents count', async ({ page }) => {
|
||||
await checkDocumentTabCount(page, 'All', 5);
|
||||
|
||||
// Apply filter.
|
||||
await page.locator('button').filter({ hasText: 'Sender: All' }).click();
|
||||
await page.getByRole('button', { name: /Sender/ }).click();
|
||||
await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
|
||||
await page.waitForURL(/senderIds/);
|
||||
|
||||
@@ -42,6 +42,21 @@ test('[TEAMS]: check team documents count', async ({ page }) => {
|
||||
}
|
||||
});
|
||||
|
||||
test('[TEAMS]: supports filtering documents by multiple statuses', async ({ page }) => {
|
||||
const { team, teamOwner } = await seedTeamDocuments();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: teamOwner.email,
|
||||
redirectPath: `/t/${team.url}/documents?status=PENDING,DRAFT`,
|
||||
});
|
||||
|
||||
await expect(page).toHaveURL(/status=PENDING,DRAFT/);
|
||||
await expect(page.getByTestId('data-table-count')).toContainText('Showing 4');
|
||||
|
||||
await apiSignout({ page });
|
||||
});
|
||||
|
||||
test('[TEAMS]: check team documents count with internal team email', async ({ page }) => {
|
||||
const { team, teamOwner, teamMember2, teamMember4 } = await seedTeamDocuments();
|
||||
const { team: team2, teamOwner: team2Owner, teamMember2: team2Member2 } = await seedTeamDocuments();
|
||||
@@ -122,7 +137,7 @@ test('[TEAMS]: check team documents count with internal team email', async ({ pa
|
||||
await checkDocumentTabCount(page, 'All', 11);
|
||||
|
||||
// Apply filter.
|
||||
await page.locator('button').filter({ hasText: 'Sender: All' }).click();
|
||||
await page.getByRole('button', { name: /Sender/ }).click();
|
||||
await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
|
||||
await page.waitForURL(/senderIds/);
|
||||
|
||||
@@ -209,7 +224,7 @@ test('[TEAMS]: check team documents count with external team email', async ({ pa
|
||||
await checkDocumentTabCount(page, 'All', 9);
|
||||
|
||||
// Apply filter.
|
||||
await page.locator('button').filter({ hasText: 'Sender: All' }).click();
|
||||
await page.getByRole('button', { name: /Sender/ }).click();
|
||||
await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
|
||||
await page.waitForURL(/senderIds/);
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
import { TeamMemberRole, TemplateType } from '@prisma/client';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { openDropdownMenu } from '../fixtures/generic';
|
||||
@@ -40,6 +41,56 @@ test('[TEMPLATES]: view templates', async ({ page }) => {
|
||||
await expect(page.getByTestId('data-table-count')).toContainText('Showing 2 results');
|
||||
});
|
||||
|
||||
test('[TEMPLATES]: supports search and multi-type filtering', async ({ page }) => {
|
||||
const { team, owner } = await seedTeam({
|
||||
createTeamMembers: 1,
|
||||
});
|
||||
|
||||
const publicTemplate = await seedTemplate({
|
||||
title: 'Public Team Template',
|
||||
userId: owner.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const privateTemplate = await seedTemplate({
|
||||
title: 'Private Team Template',
|
||||
userId: owner.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await prisma.envelope.update({
|
||||
where: {
|
||||
id: publicTemplate.id,
|
||||
},
|
||||
data: {
|
||||
templateType: TemplateType.PUBLIC,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.envelope.update({
|
||||
where: {
|
||||
id: privateTemplate.id,
|
||||
},
|
||||
data: {
|
||||
templateType: TemplateType.PRIVATE,
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: owner.email,
|
||||
redirectPath: `/t/${team.url}/templates?query=Public&type=PUBLIC`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Public Team Template' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Private Team Template' })).not.toBeVisible();
|
||||
|
||||
await page.goto(`/t/${team.url}/templates?type=PUBLIC,PRIVATE`);
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Public Team Template' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Private Team Template' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[TEMPLATES]: delete template', async ({ page }) => {
|
||||
const { team, owner, organisation } = await seedTeam({
|
||||
createTeamMembers: 1,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||
import { ZNameSchema } from '@documenso/lib/constants/auth';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
|
||||
@@ -1,19 +1,37 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
|
||||
import type { NavigateOptions } from 'react-router';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
export const useUpdateSearchParams = () => {
|
||||
type SearchParamValues = Record<string, string | number | boolean | null | undefined>;
|
||||
type UpdateSearchParamsOptions = Pick<NavigateOptions, 'preventScrollReset' | 'replace' | 'state'>;
|
||||
|
||||
export const useUpdateSearchParams = (defaultOptions: UpdateSearchParamsOptions = {}) => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
return (params: Record<string, string | number | boolean | null | undefined>) => {
|
||||
const nextSearchParams = new URLSearchParams(searchParams?.toString() ?? '');
|
||||
const searchParamsRef = useRef(searchParams);
|
||||
searchParamsRef.current = searchParams;
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null) {
|
||||
nextSearchParams.delete(key);
|
||||
} else {
|
||||
nextSearchParams.set(key, String(value));
|
||||
}
|
||||
});
|
||||
const defaultOptionsRef = useRef(defaultOptions);
|
||||
defaultOptionsRef.current = defaultOptions;
|
||||
|
||||
setSearchParams(nextSearchParams);
|
||||
};
|
||||
return useCallback(
|
||||
(params: SearchParamValues, options?: UpdateSearchParamsOptions) => {
|
||||
const nextSearchParams = new URLSearchParams(searchParamsRef.current?.toString() ?? '');
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null) {
|
||||
nextSearchParams.delete(key);
|
||||
} else {
|
||||
nextSearchParams.set(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
setSearchParams(nextSearchParams, {
|
||||
...defaultOptionsRef.current,
|
||||
...options,
|
||||
});
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
import MailChecker from 'mailchecker';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { env } from '../utils/env';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from './app';
|
||||
|
||||
export const SALT_ROUNDS = 12;
|
||||
|
||||
export const URL_PATTERN = /https?:\/\/|www\./i;
|
||||
|
||||
/**
|
||||
* Shared name schema that disallows URLs to prevent phishing via email rendering.
|
||||
*/
|
||||
export const ZNameSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(3, { message: 'Please enter a valid name.' })
|
||||
.max(255, { message: 'Name cannot be more than 255 characters.' })
|
||||
.refine((value) => !URL_PATTERN.test(value), {
|
||||
message: 'Name cannot contain URLs.',
|
||||
});
|
||||
|
||||
export const IDENTITY_PROVIDER_NAME: Record<string, string> = {
|
||||
DOCUMENSO: 'Documenso',
|
||||
GOOGLE: 'Google',
|
||||
|
||||
@@ -9,13 +9,3 @@
|
||||
* cap so a malicious or runaway payload can't exhaust PostCSS/server memory.
|
||||
*/
|
||||
export const BRANDING_CSS_MAX_LENGTH = 256 * 1024;
|
||||
|
||||
/**
|
||||
* Branding logo upload constraints. Enforced server-side at the TRPC request
|
||||
* boundary (`zfdBrandingImageFile`) and reused by the client form for matching UX.
|
||||
*/
|
||||
export const BRANDING_LOGO_MAX_SIZE_MB = 5;
|
||||
|
||||
export const BRANDING_LOGO_MAX_SIZE_BYTES = BRANDING_LOGO_MAX_SIZE_MB * 1024 * 1024;
|
||||
|
||||
export const BRANDING_LOGO_ALLOWED_TYPES: string[] = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { putFileServerSide } from '../../universal/upload/put-file.server';
|
||||
import { optimiseBrandingLogo } from '../../utils/images/logo';
|
||||
|
||||
/**
|
||||
* Validate, sanitise and store an uploaded branding logo. Returns the
|
||||
* `JSON.stringify({ type, data })` reference persisted in the `brandingLogo`
|
||||
* column (the same format the serving endpoints already expect).
|
||||
*/
|
||||
export const buildBrandingLogoData = async (file: File): Promise<string> => {
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
const optimised = await optimiseBrandingLogo(buffer).catch(() => {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'The branding logo must be a valid image file.',
|
||||
});
|
||||
});
|
||||
|
||||
const documentData = await putFileServerSide({
|
||||
name: 'branding-logo.png',
|
||||
type: 'image/png',
|
||||
arrayBuffer: async () => Promise.resolve(optimised),
|
||||
});
|
||||
|
||||
return JSON.stringify(documentData);
|
||||
};
|
||||
@@ -18,14 +18,31 @@ import type { FindResultResponse } from '../../types/search-params';
|
||||
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
|
||||
export type PeriodSelectorValue = '' | '7d' | '14d' | '30d';
|
||||
export type PeriodSelectorValue = '' | 'all' | '7d' | '14d' | '30d';
|
||||
|
||||
const normalizeStatuses = (
|
||||
status: ExtendedDocumentStatus | ExtendedDocumentStatus[] | undefined,
|
||||
): ExtendedDocumentStatus[] => {
|
||||
if (!status) {
|
||||
return [ExtendedDocumentStatus.ALL];
|
||||
}
|
||||
|
||||
const arr = Array.isArray(status) ? status : [status];
|
||||
const deduped = Array.from(new Set(arr));
|
||||
|
||||
if (deduped.length === 0 || deduped.includes(ExtendedDocumentStatus.ALL)) {
|
||||
return [ExtendedDocumentStatus.ALL];
|
||||
}
|
||||
|
||||
return deduped;
|
||||
};
|
||||
|
||||
export type FindDocumentsOptions = {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
templateId?: number;
|
||||
source?: DocumentSource;
|
||||
status?: ExtendedDocumentStatus;
|
||||
source?: DocumentSource | DocumentSource[];
|
||||
status?: ExtendedDocumentStatus | ExtendedDocumentStatus[];
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
orderBy?: {
|
||||
@@ -107,7 +124,7 @@ export const findDocuments = async ({
|
||||
teamId,
|
||||
templateId,
|
||||
source,
|
||||
status = ExtendedDocumentStatus.ALL,
|
||||
status,
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
orderBy,
|
||||
@@ -135,6 +152,8 @@ export const findDocuments = async ({
|
||||
const hasSearch = searchQuery.length > 0;
|
||||
const searchPattern = `%${searchQuery}%`;
|
||||
|
||||
const normalizedStatuses = normalizeStatuses(status);
|
||||
|
||||
// ─── Base query with common filters ──────────────────────────────────
|
||||
//
|
||||
// Every code path starts from this base: Envelope rows filtered by type,
|
||||
@@ -151,7 +170,7 @@ export const findDocuments = async ({
|
||||
folderId !== undefined ? qb.where('Envelope.folderId', '=', folderId) : qb.where('Envelope.folderId', 'is', null);
|
||||
|
||||
// Period filter
|
||||
if (period) {
|
||||
if (period && period !== 'all') {
|
||||
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
|
||||
const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day');
|
||||
|
||||
@@ -165,7 +184,15 @@ export const findDocuments = async ({
|
||||
|
||||
// Source filter (enum cast)
|
||||
if (source) {
|
||||
qb = qb.where('Envelope.source', '=', sql.lit(source));
|
||||
const sources = Array.isArray(source) ? source : [source];
|
||||
|
||||
if (sources.length > 0) {
|
||||
qb = qb.where(
|
||||
'Envelope.source',
|
||||
'in',
|
||||
sources.map((s) => sql.lit(s)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Template filter
|
||||
@@ -204,39 +231,36 @@ export const findDocuments = async ({
|
||||
|
||||
// ─── Personal path filters ───────────────────────────────────────────
|
||||
|
||||
const applyPersonalFilters = (qb: EnvelopeQueryBuilder): EnvelopeQueryBuilder | null => {
|
||||
const buildPersonalStatusPredicate = (
|
||||
eb: EnvelopeExpressionBuilder,
|
||||
s: ExtendedDocumentStatus,
|
||||
): Expression<SqlBool> => {
|
||||
// Deleted filter: owned → deletedAt IS NULL, received → documentDeletedAt IS NULL
|
||||
const personalDeletedFilter = (eb: EnvelopeExpressionBuilder) =>
|
||||
eb.or([
|
||||
eb.and([eb('Envelope.userId', '=', user.id), eb('Envelope.deletedAt', 'is', null)]),
|
||||
recipientExists(eb, user.email, (reb) => reb('Recipient.documentDeletedAt', 'is', null)),
|
||||
]);
|
||||
const personalDeletedFilter = eb.or([
|
||||
eb.and([eb('Envelope.userId', '=', user.id), eb('Envelope.deletedAt', 'is', null)]),
|
||||
recipientExists(eb, user.email, (reb) => reb('Recipient.documentDeletedAt', 'is', null)),
|
||||
]);
|
||||
|
||||
return match<ExtendedDocumentStatus, EnvelopeQueryBuilder | null>(status)
|
||||
return match<ExtendedDocumentStatus, Expression<SqlBool>>(s)
|
||||
.with(ExtendedDocumentStatus.ALL, () =>
|
||||
qb.where((eb) =>
|
||||
eb.and([
|
||||
personalDeletedFilter(eb),
|
||||
eb.or([
|
||||
eb('Envelope.userId', '=', user.id),
|
||||
eb.and([
|
||||
eb('Envelope.status', 'in', [
|
||||
sql.lit(DocumentStatus.COMPLETED),
|
||||
sql.lit(DocumentStatus.PENDING),
|
||||
sql.lit(DocumentStatus.CANCELLED),
|
||||
]),
|
||||
recipientExists(eb, user.email),
|
||||
eb.and([
|
||||
personalDeletedFilter,
|
||||
eb.or([
|
||||
eb('Envelope.userId', '=', user.id),
|
||||
eb.and([
|
||||
eb('Envelope.status', 'in', [
|
||||
sql.lit(DocumentStatus.COMPLETED),
|
||||
sql.lit(DocumentStatus.PENDING),
|
||||
sql.lit(DocumentStatus.CANCELLED),
|
||||
]),
|
||||
recipientExists(eb, user.email),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
)
|
||||
.with(ExtendedDocumentStatus.INBOX, () =>
|
||||
qb.where('Envelope.status', '!=', sql.lit(ExtendedDocumentStatus.DRAFT)).where((eb) =>
|
||||
// Single EXISTS check: the recipient must be NOT_SIGNED, non-CC, and
|
||||
// not soft-deleted. This replaces the previous personalDeletedFilter +
|
||||
// separate recipientExists pair, eliminating a hashed SubPlan that
|
||||
// materialised all recipient rows for this email (~125k for heavy users).
|
||||
eb.and([
|
||||
eb('Envelope.status', '!=', sql.lit(DocumentStatus.DRAFT)),
|
||||
recipientExists(eb, user.email, (reb) =>
|
||||
reb.and([
|
||||
reb('Recipient.documentDeletedAt', 'is', null),
|
||||
@@ -244,76 +268,69 @@ export const findDocuments = async ({
|
||||
reb('role', '!=', sql.lit(RecipientRole.CC)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
]),
|
||||
)
|
||||
.with(ExtendedDocumentStatus.DRAFT, () =>
|
||||
qb
|
||||
.where('Envelope.userId', '=', user.id)
|
||||
.where('Envelope.deletedAt', 'is', null)
|
||||
.where('Envelope.status', '=', sql.lit(DocumentStatus.DRAFT)),
|
||||
eb.and([
|
||||
eb('Envelope.userId', '=', user.id),
|
||||
eb('Envelope.deletedAt', 'is', null),
|
||||
eb('Envelope.status', '=', sql.lit(DocumentStatus.DRAFT)),
|
||||
]),
|
||||
)
|
||||
.with(ExtendedDocumentStatus.PENDING, () =>
|
||||
qb
|
||||
.where('Envelope.status', '=', sql.lit(DocumentStatus.PENDING))
|
||||
.where((eb) =>
|
||||
eb.and([
|
||||
personalDeletedFilter(eb),
|
||||
eb.or([
|
||||
eb('Envelope.userId', '=', user.id),
|
||||
recipientExists(eb, user.email, (reb) =>
|
||||
reb.and([
|
||||
reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.SIGNED)),
|
||||
reb('Recipient.role', '!=', sql.lit(RecipientRole.CC)),
|
||||
]),
|
||||
),
|
||||
eb.and([
|
||||
eb('Envelope.status', '=', sql.lit(DocumentStatus.PENDING)),
|
||||
personalDeletedFilter,
|
||||
eb.or([
|
||||
eb('Envelope.userId', '=', user.id),
|
||||
recipientExists(eb, user.email, (reb) =>
|
||||
reb.and([
|
||||
reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.SIGNED)),
|
||||
reb('Recipient.role', '!=', sql.lit(RecipientRole.CC)),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
)
|
||||
.with(ExtendedDocumentStatus.COMPLETED, () =>
|
||||
qb
|
||||
.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.COMPLETED))
|
||||
.where((eb) =>
|
||||
eb.and([
|
||||
personalDeletedFilter(eb),
|
||||
eb.or([eb('Envelope.userId', '=', user.id), recipientExists(eb, user.email)]),
|
||||
]),
|
||||
),
|
||||
eb.and([
|
||||
eb('Envelope.status', '=', sql.lit(DocumentStatus.COMPLETED)),
|
||||
personalDeletedFilter,
|
||||
eb.or([eb('Envelope.userId', '=', user.id), recipientExists(eb, user.email)]),
|
||||
]),
|
||||
)
|
||||
.with(ExtendedDocumentStatus.REJECTED, () =>
|
||||
qb
|
||||
.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.REJECTED))
|
||||
.where((eb) =>
|
||||
eb.and([
|
||||
personalDeletedFilter(eb),
|
||||
eb.or([
|
||||
eb('Envelope.userId', '=', user.id),
|
||||
recipientExists(eb, user.email, (reb) =>
|
||||
reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.REJECTED)),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
eb.and([
|
||||
eb('Envelope.status', '=', sql.lit(DocumentStatus.REJECTED)),
|
||||
personalDeletedFilter,
|
||||
eb.or([
|
||||
eb('Envelope.userId', '=', user.id),
|
||||
recipientExists(eb, user.email, (reb) =>
|
||||
reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.REJECTED)),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
)
|
||||
.with(ExtendedDocumentStatus.CANCELLED, () =>
|
||||
qb
|
||||
.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.CANCELLED))
|
||||
.where((eb) =>
|
||||
eb.and([
|
||||
personalDeletedFilter(eb),
|
||||
eb.or([eb('Envelope.userId', '=', user.id), recipientExists(eb, user.email)]),
|
||||
]),
|
||||
),
|
||||
eb.and([
|
||||
eb('Envelope.status', '=', sql.lit(DocumentStatus.CANCELLED)),
|
||||
personalDeletedFilter,
|
||||
eb.or([eb('Envelope.userId', '=', user.id), recipientExists(eb, user.email)]),
|
||||
]),
|
||||
)
|
||||
.exhaustive();
|
||||
};
|
||||
|
||||
const applyPersonalFilters = (qb: EnvelopeQueryBuilder): EnvelopeQueryBuilder =>
|
||||
qb.where((eb) => eb.or(normalizedStatuses.map((s) => buildPersonalStatusPredicate(eb, s))));
|
||||
|
||||
// ─── Team path filters ───────────────────────────────────────────────
|
||||
|
||||
const applyTeamFilters = (
|
||||
qb: EnvelopeQueryBuilder,
|
||||
const buildTeamStatusPredicate = (
|
||||
eb: EnvelopeExpressionBuilder,
|
||||
teamData: Team & { teamEmail: TeamEmail | null; currentTeamRole: TeamMemberRole },
|
||||
): EnvelopeQueryBuilder | null => {
|
||||
s: ExtendedDocumentStatus,
|
||||
): Expression<SqlBool> | null => {
|
||||
const teamEmail = teamData.teamEmail?.email ?? null;
|
||||
|
||||
const allowedVisibilities = match(teamData.currentTeamRole)
|
||||
@@ -325,139 +342,167 @@ export const findDocuments = async ({
|
||||
.with(TeamMemberRole.MANAGER, () => [DocumentVisibility.EVERYONE, DocumentVisibility.MANAGER_AND_ABOVE])
|
||||
.otherwise(() => [DocumentVisibility.EVERYONE]);
|
||||
|
||||
// Visibility: meets role threshold OR directly involved
|
||||
const visibilityFilter = (eb: EnvelopeExpressionBuilder) =>
|
||||
eb.or([
|
||||
eb(
|
||||
'Envelope.visibility',
|
||||
'in',
|
||||
allowedVisibilities.map((v) => sql.lit(v)),
|
||||
),
|
||||
eb('Envelope.userId', '=', user.id),
|
||||
recipientExists(eb, user.email),
|
||||
]);
|
||||
const visibilityFilter = eb.or([
|
||||
eb(
|
||||
'Envelope.visibility',
|
||||
'in',
|
||||
allowedVisibilities.map((v) => sql.lit(v)),
|
||||
),
|
||||
eb('Envelope.userId', '=', user.id),
|
||||
recipientExists(eb, user.email),
|
||||
]);
|
||||
|
||||
// Deleted filter for team path
|
||||
const teamDeletedFilter = (eb: EnvelopeExpressionBuilder) => {
|
||||
const branches = [eb.and([eb('Envelope.teamId', '=', teamData.id), eb('Envelope.deletedAt', 'is', null)])];
|
||||
const teamDeletedBranches = [
|
||||
eb.and([eb('Envelope.teamId', '=', teamData.id), eb('Envelope.deletedAt', 'is', null)]),
|
||||
];
|
||||
|
||||
if (teamEmail) {
|
||||
branches.push(eb.and([senderEmailIs(eb, teamEmail), eb('Envelope.deletedAt', 'is', null)]));
|
||||
branches.push(recipientExists(eb, teamEmail, (reb) => reb('Recipient.documentDeletedAt', 'is', null)));
|
||||
}
|
||||
if (teamEmail) {
|
||||
teamDeletedBranches.push(eb.and([senderEmailIs(eb, teamEmail), eb('Envelope.deletedAt', 'is', null)]));
|
||||
teamDeletedBranches.push(recipientExists(eb, teamEmail, (reb) => reb('Recipient.documentDeletedAt', 'is', null)));
|
||||
}
|
||||
|
||||
return eb.or(branches);
|
||||
};
|
||||
const teamDeletedFilter = eb.or(teamDeletedBranches);
|
||||
|
||||
return match<ExtendedDocumentStatus, EnvelopeQueryBuilder | null>(status)
|
||||
.with(ExtendedDocumentStatus.ALL, () =>
|
||||
qb.where((eb) => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
|
||||
return match<ExtendedDocumentStatus, Expression<SqlBool> | null>(s)
|
||||
.with(ExtendedDocumentStatus.ALL, () => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
|
||||
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
accessBranches.push(
|
||||
eb.and([eb('status', '!=', sql.lit(ExtendedDocumentStatus.DRAFT)), recipientExists(eb, teamEmail)]),
|
||||
);
|
||||
}
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
accessBranches.push(
|
||||
eb.and([eb('Envelope.status', '!=', sql.lit(DocumentStatus.DRAFT)), recipientExists(eb, teamEmail)]),
|
||||
);
|
||||
}
|
||||
|
||||
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
|
||||
}),
|
||||
)
|
||||
return eb.and([teamDeletedFilter, visibilityFilter, eb.or(accessBranches)]);
|
||||
})
|
||||
.with(ExtendedDocumentStatus.INBOX, () => {
|
||||
if (!teamEmail) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return qb.where('Envelope.status', '!=', sql.lit(ExtendedDocumentStatus.DRAFT)).where((eb) =>
|
||||
eb.and([
|
||||
visibilityFilter(eb),
|
||||
// Single EXISTS check: the team-email recipient must be NOT_SIGNED,
|
||||
// non-CC, and not soft-deleted. Replaces teamDeletedFilter + separate
|
||||
// recipientExists, eliminating a hashed SubPlan (~79k rows).
|
||||
return eb.and([
|
||||
eb('Envelope.status', '!=', sql.lit(DocumentStatus.DRAFT)),
|
||||
visibilityFilter,
|
||||
recipientExists(eb, teamEmail, (reb) =>
|
||||
reb.and([
|
||||
reb('Recipient.documentDeletedAt', 'is', null),
|
||||
reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.NOT_SIGNED)),
|
||||
reb('Recipient.role', '!=', sql.lit(RecipientRole.CC)),
|
||||
]),
|
||||
),
|
||||
]);
|
||||
})
|
||||
.with(ExtendedDocumentStatus.DRAFT, () => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
|
||||
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
}
|
||||
|
||||
return eb.and([
|
||||
eb('Envelope.status', '=', sql.lit(DocumentStatus.DRAFT)),
|
||||
teamDeletedFilter,
|
||||
visibilityFilter,
|
||||
eb.or(accessBranches),
|
||||
]);
|
||||
})
|
||||
.with(ExtendedDocumentStatus.PENDING, () => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
|
||||
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
accessBranches.push(
|
||||
recipientExists(eb, teamEmail, (reb) =>
|
||||
reb.and([
|
||||
reb('Recipient.documentDeletedAt', 'is', null),
|
||||
reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.NOT_SIGNED)),
|
||||
reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.SIGNED)),
|
||||
reb('Recipient.role', '!=', sql.lit(RecipientRole.CC)),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
return eb.and([
|
||||
eb('Envelope.status', '=', sql.lit(DocumentStatus.PENDING)),
|
||||
teamDeletedFilter,
|
||||
visibilityFilter,
|
||||
eb.or(accessBranches),
|
||||
]);
|
||||
})
|
||||
.with(ExtendedDocumentStatus.DRAFT, () =>
|
||||
qb.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.DRAFT)).where((eb) => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
|
||||
.with(ExtendedDocumentStatus.COMPLETED, () => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
|
||||
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
}
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
accessBranches.push(recipientExists(eb, teamEmail));
|
||||
}
|
||||
|
||||
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
|
||||
}),
|
||||
)
|
||||
.with(ExtendedDocumentStatus.PENDING, () =>
|
||||
qb.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.PENDING)).where((eb) => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
|
||||
return eb.and([
|
||||
eb('Envelope.status', '=', sql.lit(DocumentStatus.COMPLETED)),
|
||||
teamDeletedFilter,
|
||||
visibilityFilter,
|
||||
eb.or(accessBranches),
|
||||
]);
|
||||
})
|
||||
.with(ExtendedDocumentStatus.REJECTED, () => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
|
||||
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
accessBranches.push(
|
||||
recipientExists(eb, teamEmail, (reb) =>
|
||||
reb.and([
|
||||
reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.SIGNED)),
|
||||
reb('Recipient.role', '!=', sql.lit(RecipientRole.CC)),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
accessBranches.push(
|
||||
recipientExists(eb, teamEmail, (reb) =>
|
||||
reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.REJECTED)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
|
||||
}),
|
||||
)
|
||||
.with(ExtendedDocumentStatus.COMPLETED, () =>
|
||||
qb.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.COMPLETED)).where((eb) => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
|
||||
return eb.and([
|
||||
eb('Envelope.status', '=', sql.lit(DocumentStatus.REJECTED)),
|
||||
teamDeletedFilter,
|
||||
visibilityFilter,
|
||||
eb.or(accessBranches),
|
||||
]);
|
||||
})
|
||||
.with(ExtendedDocumentStatus.CANCELLED, () => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
|
||||
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
accessBranches.push(recipientExists(eb, teamEmail));
|
||||
}
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
accessBranches.push(recipientExists(eb, teamEmail));
|
||||
}
|
||||
|
||||
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
|
||||
}),
|
||||
)
|
||||
.with(ExtendedDocumentStatus.REJECTED, () =>
|
||||
qb.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.REJECTED)).where((eb) => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
|
||||
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
accessBranches.push(
|
||||
recipientExists(eb, teamEmail, (reb) =>
|
||||
reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.REJECTED)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
|
||||
}),
|
||||
)
|
||||
.with(ExtendedDocumentStatus.CANCELLED, () =>
|
||||
qb.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.CANCELLED)).where((eb) => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
|
||||
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
accessBranches.push(recipientExists(eb, teamEmail));
|
||||
}
|
||||
|
||||
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
|
||||
}),
|
||||
)
|
||||
return eb.and([
|
||||
eb('Envelope.status', '=', sql.lit(DocumentStatus.CANCELLED)),
|
||||
teamDeletedFilter,
|
||||
visibilityFilter,
|
||||
eb.or(accessBranches),
|
||||
]);
|
||||
})
|
||||
.exhaustive();
|
||||
};
|
||||
|
||||
const applyTeamFilters = (
|
||||
qb: EnvelopeQueryBuilder,
|
||||
teamData: Team & { teamEmail: TeamEmail | null; currentTeamRole: TeamMemberRole },
|
||||
): EnvelopeQueryBuilder | null => {
|
||||
const teamEmail = teamData.teamEmail?.email ?? null;
|
||||
|
||||
// INBOX requires a team email; drop statuses that produce no predicate.
|
||||
const validStatuses = normalizedStatuses.filter((s) => !(s === ExtendedDocumentStatus.INBOX && !teamEmail));
|
||||
|
||||
if (validStatuses.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return qb.where((eb) => {
|
||||
const predicates = validStatuses
|
||||
.map((s) => buildTeamStatusPredicate(eb, teamData, s))
|
||||
.filter((p): p is Expression<SqlBool> => p !== null);
|
||||
|
||||
return eb.or(predicates);
|
||||
});
|
||||
};
|
||||
|
||||
// ─── Assemble and execute ────────────────────────────────────────────
|
||||
|
||||
const baseQuery = buildBaseQuery();
|
||||
|
||||
@@ -109,7 +109,7 @@ export const getStats = async ({ userId, teamId, period, search = '', folderId,
|
||||
folderId !== undefined ? qb.where('Envelope.folderId', '=', folderId) : qb.where('Envelope.folderId', 'is', null);
|
||||
|
||||
// Period filter
|
||||
if (period) {
|
||||
if (period && period !== 'all') {
|
||||
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
|
||||
const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day');
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isQuotaExceeded, isQuotaNearing } from '../../universal/quota-usage';
|
||||
import { QUOTA_WARNING_THRESHOLD } from './get-quota-alert-kind';
|
||||
|
||||
export type QuotaFlags = {
|
||||
isDocumentQuotaExceeded: boolean;
|
||||
@@ -22,6 +22,39 @@ type ComputeQuotaFlagsOptions = {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* A quota of `null` means unlimited (never exceeded). A quota of `0` means
|
||||
* blocked (always exceeded). Otherwise usage `>=` quota is exceeded.
|
||||
*/
|
||||
const isQuotaExceeded = (quota: number | null, usage: number): boolean => {
|
||||
if (quota === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (quota === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return usage >= quota;
|
||||
};
|
||||
|
||||
/**
|
||||
* A counter is "nearing" its quota once usage reaches the warning threshold
|
||||
* (80% of the quota, rounded up) but has not yet been exceeded. Nearing and
|
||||
* exceeded are mutually exclusive per counter.
|
||||
*/
|
||||
const isQuotaNearing = (quota: number | null, usage: number): boolean => {
|
||||
if (quota === null || quota === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isQuotaExceeded(quota, usage)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return usage >= Math.ceil(quota * QUOTA_WARNING_THRESHOLD);
|
||||
};
|
||||
|
||||
export const computeQuotaFlags = ({ quotas, usage }: ComputeQuotaFlagsOptions): QuotaFlags => {
|
||||
return {
|
||||
isDocumentQuotaExceeded: isQuotaExceeded(quotas.documentQuota, usage?.documentCount ?? 0),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getQuotaWarningCount } from '../../universal/quota-usage';
|
||||
export const QUOTA_WARNING_THRESHOLD = 0.8;
|
||||
|
||||
export type QuotaAlertKind = 'quota' | 'quotaNearing';
|
||||
|
||||
@@ -32,7 +32,7 @@ export const getQuotaAlertKind = (opts: GetQuotaAlertKindOptions): QuotaAlertKin
|
||||
// From here newCount < quota, so for tiny quotas (1-4) where the rounded-up
|
||||
// warning threshold equals the quota itself, the warning can never fire — the
|
||||
// exhausting request is handled by the quota branch above.
|
||||
const warningCount = getQuotaWarningCount(quota);
|
||||
const warningCount = Math.ceil(quota * QUOTA_WARNING_THRESHOLD);
|
||||
|
||||
const didCrossWarning = newCount >= warningCount && previousCount < warningCount;
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ import { getMemberRoles } from '../team/get-member-roles';
|
||||
export type FindTemplatesOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
type?: TemplateType;
|
||||
type?: TemplateType | TemplateType[];
|
||||
query?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
folderId?: string;
|
||||
@@ -19,6 +20,7 @@ export const findTemplates = async ({
|
||||
userId,
|
||||
teamId,
|
||||
type,
|
||||
query = '',
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
folderId,
|
||||
@@ -31,9 +33,11 @@ export const findTemplates = async ({
|
||||
},
|
||||
});
|
||||
|
||||
const templateTypeFilter = type ? { in: Array.isArray(type) ? type : [type] } : undefined;
|
||||
|
||||
const where: Prisma.EnvelopeWhereInput = {
|
||||
type: EnvelopeType.TEMPLATE,
|
||||
templateType: type,
|
||||
templateType: templateTypeFilter,
|
||||
AND: [
|
||||
{ teamId },
|
||||
{
|
||||
@@ -47,6 +51,26 @@ export const findTemplates = async ({
|
||||
],
|
||||
},
|
||||
folderId ? { folderId } : { folderId: null },
|
||||
...(query
|
||||
? [
|
||||
{
|
||||
OR: [
|
||||
{
|
||||
title: {
|
||||
contains: query,
|
||||
mode: 'insensitive' as const,
|
||||
},
|
||||
},
|
||||
{
|
||||
externalId: {
|
||||
contains: query,
|
||||
mode: 'insensitive' as const,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { ZNameSchema } from './name';
|
||||
|
||||
describe('ZNameSchema', () => {
|
||||
describe('valid names', () => {
|
||||
it('accepts a normal name', () => {
|
||||
expect(ZNameSchema.safeParse('Example User')).toEqual({
|
||||
success: true,
|
||||
data: 'Example User',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts international characters', () => {
|
||||
expect(ZNameSchema.safeParse('Døcumensø Üser')).toEqual({
|
||||
success: true,
|
||||
data: 'Døcumensø Üser',
|
||||
});
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace', () => {
|
||||
expect(ZNameSchema.safeParse(' Documenso User ')).toEqual({
|
||||
success: true,
|
||||
data: 'Documenso User',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts names at the minimum length', () => {
|
||||
expect(ZNameSchema.safeParse('DU')).toEqual({
|
||||
success: true,
|
||||
data: 'DU',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts names at the maximum length', () => {
|
||||
const name =
|
||||
'DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser Do';
|
||||
|
||||
expect(name.length).toBe(100);
|
||||
expect(ZNameSchema.safeParse(name)).toEqual({
|
||||
success: true,
|
||||
data: name,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('length validation', () => {
|
||||
it('rejects names shorter than 2 characters', () => {
|
||||
expect(ZNameSchema.safeParse('D')).toMatchObject({
|
||||
success: false,
|
||||
error: {
|
||||
issues: [{ message: 'Please enter a valid name.' }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects names longer than 100 characters', () => {
|
||||
const name =
|
||||
'DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser Doc';
|
||||
|
||||
expect(name.length).toBe(101);
|
||||
expect(ZNameSchema.safeParse(name)).toMatchObject({
|
||||
success: false,
|
||||
error: {
|
||||
issues: [{ message: 'Name cannot be more than 100 characters.' }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects whitespace-only input after trim', () => {
|
||||
expect(ZNameSchema.safeParse(' ')).toMatchObject({
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL validation', () => {
|
||||
it.each([
|
||||
'https://example.com',
|
||||
'http://example.com',
|
||||
'HTTPS://EXAMPLE.COM',
|
||||
'Northwind www.example.com',
|
||||
'www.example.com',
|
||||
])('rejects URLs in names: %s', (value) => {
|
||||
expect(ZNameSchema.safeParse(value)).toMatchObject({
|
||||
success: false,
|
||||
error: {
|
||||
issues: expect.arrayContaining([expect.objectContaining({ message: 'Name cannot contain URLs.' })]),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid character validation', () => {
|
||||
it.each([
|
||||
['NUL character', 'Acme\u0000Corp'],
|
||||
['zero-width space', 'Acme\u200bCorp'],
|
||||
['bidi override', 'Acme\u202eCorp'],
|
||||
['byte order mark', 'Acme\ufeffCorp'],
|
||||
['lone surrogate', 'Acme\ud800Corp'],
|
||||
['tag character', `Acme${String.fromCodePoint(0xe0041)}Corp`],
|
||||
['noncharacter', 'Acme\ufffeCorp'],
|
||||
['private use character', 'Acme\ue000Corp'],
|
||||
['Hangul filler', 'Acme\u3164Corp'],
|
||||
['braille blank', 'Acme\u2800Corp'],
|
||||
['combining grapheme joiner', 'Acme\u034fCorp'],
|
||||
])('rejects names containing a %s', (_label, value) => {
|
||||
expect(ZNameSchema.safeParse(value)).toMatchObject({
|
||||
success: false,
|
||||
error: {
|
||||
issues: expect.arrayContaining([expect.objectContaining({ message: 'Name contains invalid characters.' })]),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['fixed form', String.raw`Acme\u200bCorp`],
|
||||
['uppercase U', String.raw`Acme\U200BCorp`],
|
||||
['braced form', String.raw`Acme\u{200b}Corp`],
|
||||
['braced form with leading zeros', String.raw`Acme\u{0000200b}Corp`],
|
||||
['lone surrogate', String.raw`Acme\ud800Corp`],
|
||||
])('rejects literal \\u escape sequences stored as text (%s)', (_label, value) => {
|
||||
expect(ZNameSchema.safeParse(value)).toMatchObject({
|
||||
success: false,
|
||||
error: {
|
||||
issues: expect.arrayContaining([expect.objectContaining({ message: 'Name contains invalid characters.' })]),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['escape of a valid code point', String.raw`Acme\u0041Corp`],
|
||||
['braced escape of a valid astral code point', String.raw`Acme\u{1F600}Corp`],
|
||||
['braced escape beyond the Unicode range', String.raw`Acme\u{FFFFFFF}Corp`],
|
||||
['incomplete escape sequence', String.raw`Acme\u00 Corp`],
|
||||
['unterminated braced escape', String.raw`Acme\u{200bCorp`],
|
||||
['astral characters such as emoji', 'Acme 😀 Corp'],
|
||||
['emoji with a variation selector', 'I ❤️ Docs'],
|
||||
])('accepts %s', (_label, value) => {
|
||||
expect(ZNameSchema.safeParse(value)).toMatchObject({
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const URL_PATTERN = /https?:\/\/|www\./i;
|
||||
|
||||
/**
|
||||
* Characters that render as empty/invisible or break text layout:
|
||||
*
|
||||
* - `\p{C}` - control, format, lone surrogate, private use and
|
||||
* unassigned code points (NUL, zero-width spaces, bidi
|
||||
* overrides, BOM, tag characters, noncharacters).
|
||||
* - `\p{Zl}\p{Zp}` - line and paragraph separators.
|
||||
* - `\u{034F}` - combining grapheme joiner (invisible). Kept outside the
|
||||
* character class because it is a combining mark, which
|
||||
* lint rules reject inside classes.
|
||||
* - remaining - letters that render as blank (Hangul fillers, braille blank).
|
||||
*
|
||||
* The `\p{...}` classes are maintained by the Unicode database, so newly
|
||||
* assigned characters in these categories are covered automatically.
|
||||
*/
|
||||
const INVALID_CHARACTER_REGEX = /[\p{C}\p{Zl}\p{Zp}\u{115F}\u{1160}\u{2800}\u{3164}\u{FFA0}]|\u{034F}/u;
|
||||
|
||||
const hasInvalidCharacter = (value: string) => INVALID_CHARACTER_REGEX.test(value);
|
||||
|
||||
/**
|
||||
* Matches literal `\uXXXX` and `\u{XXXX}` escape sequences stored verbatim as
|
||||
* text (e.g. the 6 characters `\`, `u`, `2`, `0`, `0`, `b`), which can still
|
||||
* break rendering downstream if anything decodes them.
|
||||
*/
|
||||
const ESCAPE_SEQUENCE_PATTERN = /\\u(?:([0-9a-f]{4})|\{([0-9a-f]+)\})/gi;
|
||||
|
||||
const hasInvalidEscapeSequence = (value: string) => {
|
||||
for (const [, fixedHex, bracedHex] of value.matchAll(ESCAPE_SEQUENCE_PATTERN)) {
|
||||
const codePoint = parseInt(fixedHex ?? bracedHex, 16);
|
||||
|
||||
if (codePoint > 0x10ffff) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Decode the escape and run it through the same character policy as the
|
||||
// unescaped check, so the two can never drift apart.
|
||||
if (hasInvalidCharacter(String.fromCodePoint(codePoint))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const hasInvalidTextCharacters = (value: string) =>
|
||||
hasInvalidCharacter(value) || hasInvalidEscapeSequence(value);
|
||||
|
||||
/**
|
||||
* Shared name schema that disallows URLs to prevent phishing via email rendering,
|
||||
* and invisible/control characters that render as empty or break the UI.
|
||||
*/
|
||||
export const ZNameSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(2, { message: 'Please enter a valid name.' })
|
||||
.max(100, { message: 'Name cannot be more than 100 characters.' })
|
||||
.refine((value) => !URL_PATTERN.test(value), {
|
||||
message: 'Name cannot contain URLs.',
|
||||
})
|
||||
.refine((value) => !hasInvalidTextCharacters(value), {
|
||||
message: 'Name contains invalid characters.',
|
||||
});
|
||||
|
||||
export type TName = z.infer<typeof ZNameSchema>;
|
||||
@@ -6,39 +6,14 @@ import { z } from 'zod';
|
||||
*
|
||||
* Example: "5m", "1h", "1d"
|
||||
*/
|
||||
export const RATE_LIMIT_WINDOW_REGEX = /^\d+[smhd]$/;
|
||||
export const ZRateLimitWindowSchema = z.string().regex(/^\d+[smhd]$/);
|
||||
|
||||
const RATE_LIMIT_WINDOW_ERROR_MESSAGE = 'Use a duration with a unit, e.g. 5m, 1h, or 24h';
|
||||
const RATE_LIMIT_DUPLICATE_WINDOW_ERROR_MESSAGE = 'Use a unique window for each rate limit';
|
||||
|
||||
export const ZRateLimitWindowSchema = z.string().trim().regex(RATE_LIMIT_WINDOW_REGEX, {
|
||||
message: RATE_LIMIT_WINDOW_ERROR_MESSAGE,
|
||||
});
|
||||
|
||||
export const ZRateLimitArraySchema = z
|
||||
.array(
|
||||
z.object({
|
||||
window: ZRateLimitWindowSchema,
|
||||
max: z.number().int().positive(),
|
||||
}),
|
||||
)
|
||||
.superRefine((entries, ctx) => {
|
||||
const windows = new Set<string>();
|
||||
|
||||
entries.forEach((entry, index) => {
|
||||
const window = entry.window.trim();
|
||||
|
||||
if (windows.has(window)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: RATE_LIMIT_DUPLICATE_WINDOW_ERROR_MESSAGE,
|
||||
path: [index, 'window'],
|
||||
});
|
||||
}
|
||||
|
||||
windows.add(window);
|
||||
});
|
||||
});
|
||||
export const ZRateLimitArraySchema = z.array(
|
||||
z.object({
|
||||
window: ZRateLimitWindowSchema,
|
||||
max: z.number().int().positive(),
|
||||
}),
|
||||
);
|
||||
|
||||
export type TRateLimitArray = z.infer<typeof ZRateLimitArraySchema>;
|
||||
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
getQuotaUsagePercent,
|
||||
getQuotaWarningCount,
|
||||
isQuotaExceeded,
|
||||
isQuotaNearing,
|
||||
normalizeCapacityLimit,
|
||||
} from './quota-usage';
|
||||
|
||||
describe('isQuotaExceeded', () => {
|
||||
it('treats null quota as unlimited (never exceeded)', () => {
|
||||
expect(isQuotaExceeded(null, 0)).toBe(false);
|
||||
expect(isQuotaExceeded(null, 1_000_000)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a zero quota as blocked (always exceeded)', () => {
|
||||
expect(isQuotaExceeded(0, 0)).toBe(true);
|
||||
expect(isQuotaExceeded(0, 5)).toBe(true);
|
||||
});
|
||||
|
||||
it('is exceeded once usage reaches the quota (>= boundary)', () => {
|
||||
expect(isQuotaExceeded(10, 9)).toBe(false);
|
||||
expect(isQuotaExceeded(10, 10)).toBe(true);
|
||||
expect(isQuotaExceeded(10, 11)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getQuotaWarningCount', () => {
|
||||
it('rounds the 80% threshold up', () => {
|
||||
expect(getQuotaWarningCount(10)).toBe(8);
|
||||
expect(getQuotaWarningCount(100)).toBe(80);
|
||||
// 5 * 0.8 = 4 exactly.
|
||||
expect(getQuotaWarningCount(5)).toBe(4);
|
||||
// 3 * 0.8 = 2.4 -> 3, so the warning count equals the quota itself.
|
||||
expect(getQuotaWarningCount(3)).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isQuotaNearing', () => {
|
||||
it('is never nearing for unlimited or blocked quotas', () => {
|
||||
expect(isQuotaNearing(null, 5)).toBe(false);
|
||||
expect(isQuotaNearing(0, 5)).toBe(false);
|
||||
});
|
||||
|
||||
it('is nearing from the warning threshold up to (but not including) the quota', () => {
|
||||
expect(isQuotaNearing(10, 7)).toBe(false);
|
||||
expect(isQuotaNearing(10, 8)).toBe(true);
|
||||
expect(isQuotaNearing(10, 9)).toBe(true);
|
||||
});
|
||||
|
||||
it('is not nearing once exceeded (nearing and exceeded are mutually exclusive)', () => {
|
||||
expect(isQuotaNearing(10, 10)).toBe(false);
|
||||
expect(isQuotaNearing(10, 11)).toBe(false);
|
||||
});
|
||||
|
||||
it('can never fire for tiny quotas where the warning count equals the quota', () => {
|
||||
// getQuotaWarningCount(3) === 3, so usage >= 3 is already exceeded.
|
||||
expect(isQuotaNearing(3, 2)).toBe(false);
|
||||
expect(isQuotaNearing(3, 3)).toBe(false);
|
||||
});
|
||||
|
||||
it('agrees with the warning-count helper at the boundary', () => {
|
||||
const quota = 250;
|
||||
const warningCount = getQuotaWarningCount(quota);
|
||||
|
||||
expect(isQuotaNearing(quota, warningCount - 1)).toBe(false);
|
||||
expect(isQuotaNearing(quota, warningCount)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getQuotaUsagePercent', () => {
|
||||
it('returns 0 for unlimited or non-positive quotas', () => {
|
||||
expect(getQuotaUsagePercent(5, null)).toBe(0);
|
||||
expect(getQuotaUsagePercent(5, 0)).toBe(0);
|
||||
expect(getQuotaUsagePercent(5, -10)).toBe(0);
|
||||
});
|
||||
|
||||
it('rounds the percentage to the nearest integer', () => {
|
||||
expect(getQuotaUsagePercent(1, 3)).toBe(33);
|
||||
expect(getQuotaUsagePercent(2, 3)).toBe(67);
|
||||
expect(getQuotaUsagePercent(50, 100)).toBe(50);
|
||||
});
|
||||
|
||||
it('clamps the percentage to 100 when usage exceeds the quota', () => {
|
||||
expect(getQuotaUsagePercent(150, 100)).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeCapacityLimit', () => {
|
||||
it('maps 0 (unlimited for capacity limits) to null', () => {
|
||||
expect(normalizeCapacityLimit(0)).toBeNull();
|
||||
});
|
||||
|
||||
it('passes positive limits through unchanged', () => {
|
||||
expect(normalizeCapacityLimit(1)).toBe(1);
|
||||
expect(normalizeCapacityLimit(25)).toBe(25);
|
||||
});
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
export const QUOTA_WARNING_THRESHOLD = 0.8;
|
||||
|
||||
/**
|
||||
* Monthly quotas: `null` = unlimited, `0` = blocked. Usage `>=` quota is exceeded.
|
||||
*/
|
||||
export const isQuotaExceeded = (quota: number | null, usage: number): boolean => {
|
||||
if (quota === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (quota === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return usage >= quota;
|
||||
};
|
||||
|
||||
/**
|
||||
* The usage count at which a positive quota starts "nearing" (80% rounded up).
|
||||
* The single source for the warning threshold math so the UI panel, quota flags,
|
||||
* and the per-request alert path can't drift apart.
|
||||
*/
|
||||
export const getQuotaWarningCount = (quota: number): number => {
|
||||
return Math.ceil(quota * QUOTA_WARNING_THRESHOLD);
|
||||
};
|
||||
|
||||
/**
|
||||
* Nearing once usage reaches the warning threshold (80% rounded up) but is not exceeded.
|
||||
*/
|
||||
export const isQuotaNearing = (quota: number | null, usage: number): boolean => {
|
||||
if (quota === null || quota === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isQuotaExceeded(quota, usage)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return usage >= getQuotaWarningCount(quota);
|
||||
};
|
||||
|
||||
export const getQuotaUsagePercent = (usage: number, quota: number | null): number => {
|
||||
if (quota === null || quota <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.min(100, Math.round((usage / quota) * 100));
|
||||
};
|
||||
|
||||
/** Member/team capacity limits use `0` for unlimited. */
|
||||
export const normalizeCapacityLimit = (limit: number): number | null => {
|
||||
if (limit === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return limit;
|
||||
};
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { TUploadPdfResponse } from '@documenso/remix/server/api/files/files.types';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import type { TGetPresignedPostUrlResponse, TUploadPdfResponse } from '@documenso/remix/server/api/files/files.types';
|
||||
import { DocumentDataType } from '@prisma/client';
|
||||
import { base64 } from '@scure/base';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { AppError } from '../../errors/app-error';
|
||||
|
||||
type File = {
|
||||
@@ -53,3 +58,68 @@ export const putPdfFile = async (file: File, options?: PutFileOptions) => {
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Uploads a file to the appropriate storage location.
|
||||
*/
|
||||
export const putFile = async (file: File, options?: PutFileOptions) => {
|
||||
const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT');
|
||||
|
||||
return await match(NEXT_PUBLIC_UPLOAD_TRANSPORT)
|
||||
.with('s3', async () => putFileInObjectStorage(file, {}, options))
|
||||
.with('azure-blob', async () => putFileInObjectStorage(file, { 'x-ms-blob-type': 'BlockBlob' }, options))
|
||||
.otherwise(async () => putFileInDatabase(file));
|
||||
};
|
||||
|
||||
const putFileInDatabase = async (file: File) => {
|
||||
const contents = await file.arrayBuffer();
|
||||
|
||||
const binaryData = new Uint8Array(contents);
|
||||
|
||||
const asciiData = base64.encode(binaryData);
|
||||
|
||||
return {
|
||||
type: DocumentDataType.BYTES_64,
|
||||
data: asciiData,
|
||||
};
|
||||
};
|
||||
|
||||
const putFileInObjectStorage = async (file: File, extraHeaders: Record<string, string>, options?: PutFileOptions) => {
|
||||
const getPresignedUrlResponse = await fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/api/files/presigned-post-url`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...buildUploadAuthHeaders(options),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
fileName: file.name,
|
||||
contentType: file.type,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!getPresignedUrlResponse.ok) {
|
||||
throw new Error(`Failed to get presigned post url, failed with status code ${getPresignedUrlResponse.status}`);
|
||||
}
|
||||
|
||||
const { url, key }: TGetPresignedPostUrlResponse = await getPresignedUrlResponse.json();
|
||||
|
||||
const body = await file.arrayBuffer();
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
...extraHeaders,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to upload file "${file.name}", failed with status code ${response.status}`);
|
||||
}
|
||||
|
||||
return {
|
||||
type: DocumentDataType.S3_PATH,
|
||||
data: key,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -8,15 +8,3 @@ export const loadLogo = async (file: Uint8Array) => {
|
||||
content,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate and sanitise an uploaded branding logo. Re-encoding through `sharp`
|
||||
* proves the bytes are a real raster image and strips any embedded payloads.
|
||||
* Throws if the input cannot be parsed as an image.
|
||||
*/
|
||||
export const optimiseBrandingLogo = async (input: Buffer | Uint8Array): Promise<Buffer> => {
|
||||
return await sharp(input)
|
||||
.resize(512, 512, { fit: 'inside', withoutEnlargement: true })
|
||||
.png({ quality: 80 })
|
||||
.toBuffer();
|
||||
};
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
/**
|
||||
* From an unknown string, parse it into an integer array.
|
||||
*
|
||||
* Filter out unknown values.
|
||||
*/
|
||||
export const parseToIntegerArray = (value: unknown): number[] => {
|
||||
if (typeof value !== 'string') {
|
||||
return [];
|
||||
@@ -14,6 +9,30 @@ export const parseToIntegerArray = (value: unknown): number[] => {
|
||||
.filter((value) => !isNaN(value));
|
||||
};
|
||||
|
||||
export const parseToStringArray = (value: unknown): string[] => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((item): item is string => typeof item === 'string');
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
export const parseCommaSeparatedValues = (value: unknown): string[] | undefined => {
|
||||
const parsed = parseToStringArray(value);
|
||||
return parsed.length > 0 ? parsed : undefined;
|
||||
};
|
||||
|
||||
export const toCommaSeparatedSearchParam = (values: string[]): string | undefined => {
|
||||
return values.length > 0 ? values.join(',') : undefined;
|
||||
};
|
||||
|
||||
type GetRootHrefOptions = {
|
||||
returnEmptyRootString?: boolean;
|
||||
};
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZOrganisationNameSchema } from '../organisation-router/create-organisation.types';
|
||||
|
||||
export const ZCreateAdminOrganisationRequestSchema = z.object({
|
||||
ownerUserId: z.number(),
|
||||
data: z.object({
|
||||
name: ZNameSchema,
|
||||
name: ZOrganisationNameSchema,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||
import { ZClaimFlagsSchema, ZRateLimitArraySchema } from '@documenso/lib/types/subscription';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZCreateSubscriptionClaimRequestSchema = z.object({
|
||||
name: ZNameSchema,
|
||||
name: z.string().min(1),
|
||||
teamCount: z.number().int().min(0),
|
||||
memberCount: z.number().int().min(0),
|
||||
envelopeItemCount: z.number().int().min(1),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||
import { ZNameSchema } from '@documenso/lib/constants/auth';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZCreateUserRequestSchema = z.object({
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { ZEmailTransportConfigSchema } from '@documenso/lib/server-only/email/email-transport-config';
|
||||
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZCreateEmailTransportRequestSchema = z.object({
|
||||
name: ZNameSchema,
|
||||
fromName: ZNameSchema,
|
||||
name: z.string().min(1),
|
||||
fromName: z.string().min(1),
|
||||
fromAddress: z.string().email(),
|
||||
config: ZEmailTransportConfigSchema,
|
||||
});
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
ZSmtpApiConfigSchema,
|
||||
ZSmtpAuthConfigSchema,
|
||||
} from '@documenso/lib/server-only/email/email-transport-config';
|
||||
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Reuses the canonical transport config schemas, but relaxes the secret field so
|
||||
@@ -22,8 +21,8 @@ const ZUpdateConfigSchema = z.discriminatedUnion('type', [
|
||||
export const ZUpdateEmailTransportRequestSchema = z.object({
|
||||
id: z.string(),
|
||||
data: z.object({
|
||||
name: ZNameSchema,
|
||||
fromName: ZNameSchema,
|
||||
name: z.string().min(1),
|
||||
fromName: z.string().min(1),
|
||||
fromAddress: z.string().email(),
|
||||
config: ZUpdateConfigSchema,
|
||||
}),
|
||||
|
||||
@@ -30,7 +30,6 @@ export const getAdminTeamRoute = adminProcedure
|
||||
name: true,
|
||||
url: true,
|
||||
ownerUserId: true,
|
||||
organisationGlobalSettings: true,
|
||||
},
|
||||
},
|
||||
teamEmail: true,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { OrganisationMemberRoleSchema } from '@documenso/prisma/generated/zod/inputTypeSchemas/OrganisationMemberRoleSchema';
|
||||
import { TeamMemberRoleSchema } from '@documenso/prisma/generated/zod/inputTypeSchemas/TeamMemberRoleSchema';
|
||||
import OrganisationGlobalSettingsSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationGlobalSettingsSchema';
|
||||
import OrganisationMemberInviteSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberInviteSchema';
|
||||
import OrganisationMemberSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberSchema';
|
||||
import OrganisationSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationSchema';
|
||||
@@ -20,8 +19,6 @@ export const ZGetAdminTeamResponseSchema = TeamSchema.extend({
|
||||
name: true,
|
||||
url: true,
|
||||
ownerUserId: true,
|
||||
}).extend({
|
||||
organisationGlobalSettings: OrganisationGlobalSettingsSchema,
|
||||
}),
|
||||
teamEmail: TeamEmailSchema.nullable(),
|
||||
teamGlobalSettings: TeamGlobalSettingsSchema.nullable(),
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZOrganisationNameSchema } from '../organisation-router/create-organisation.types';
|
||||
import { ZTeamUrlSchema } from '../team-router/schema';
|
||||
import { ZCreateSubscriptionClaimRequestSchema } from './create-subscription-claim.types';
|
||||
|
||||
export const ZUpdateAdminOrganisationRequestSchema = z.object({
|
||||
organisationId: z.string(),
|
||||
data: z.object({
|
||||
name: ZNameSchema.optional(),
|
||||
name: ZOrganisationNameSchema.optional(),
|
||||
url: ZTeamUrlSchema.optional(),
|
||||
claims: ZCreateSubscriptionClaimRequestSchema.pick({
|
||||
teamCount: true,
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { Role } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZUpdateUserRequestSchema = z.object({
|
||||
id: z.number().min(1),
|
||||
name: ZNameSchema.nullish(),
|
||||
name: z.string().nullish(),
|
||||
email: zEmail().optional(),
|
||||
roles: z.array(z.nativeEnum(Role)).optional(),
|
||||
});
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZCreateApiTokenRequestSchema = z.object({
|
||||
teamId: z.number(),
|
||||
tokenName: ZNameSchema,
|
||||
tokenName: z.string().min(3, { message: 'The token name should be 3 characters or longer' }),
|
||||
expirationDate: z.string().nullable(),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||
import { ZRegistrationResponseJSONSchema } from '@documenso/lib/types/webauthn';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZCreatePasskeyRequestSchema = z.object({
|
||||
passkeyName: ZNameSchema,
|
||||
passkeyName: z.string().trim().min(1),
|
||||
verificationResponse: ZRegistrationResponseJSONSchema,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZUpdatePasskeyRequestSchema = z.object({
|
||||
passkeyId: z.string().trim().min(1),
|
||||
name: ZNameSchema,
|
||||
name: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
export const ZUpdatePasskeyResponseSchema = z.void();
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { ZDocumentManySchema } from '@documenso/lib/types/document';
|
||||
import { ZFindResultResponse } from '@documenso/lib/types/search-params';
|
||||
import { parseCommaSeparatedValues } from '@documenso/lib/utils/params';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
import { DocumentSource } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZFindDocumentsRequestSchema } from './find-documents.types';
|
||||
|
||||
export const ZFindDocumentsInternalRequestSchema = ZFindDocumentsRequestSchema.extend({
|
||||
period: z.enum(['7d', '14d', '30d']).optional(),
|
||||
period: z.enum(['all', '7d', '14d', '30d']).optional(),
|
||||
senderIds: z.array(z.number()).optional(),
|
||||
status: z.nativeEnum(ExtendedDocumentStatus).optional(),
|
||||
source: z.preprocess(parseCommaSeparatedValues, z.array(z.nativeEnum(DocumentSource)).optional()),
|
||||
status: z.preprocess(parseCommaSeparatedValues, z.array(z.nativeEnum(ExtendedDocumentStatus)).optional()),
|
||||
folderId: z.string().optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZCreateOrganisationEmailRequestSchema = z.object({
|
||||
emailDomainId: z.string(),
|
||||
emailName: ZNameSchema,
|
||||
emailName: z.string().min(1).max(100),
|
||||
email: zEmail().toLowerCase(),
|
||||
|
||||
// This does not need to be validated to be part of the domain.
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ZFolderTypeSchema } from '@documenso/lib/types/folder-type';
|
||||
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||
import { DocumentVisibility } from '@documenso/prisma/generated/types';
|
||||
import FolderSchema from '@documenso/prisma/generated/zod/modelSchema/FolderSchema';
|
||||
@@ -43,7 +42,7 @@ const ZFolderParentIdSchema = z
|
||||
.describe('The folder ID to place this folder within. Leave empty to place folder at the root level.');
|
||||
|
||||
export const ZCreateFolderRequestSchema = z.object({
|
||||
name: ZNameSchema,
|
||||
name: z.string(),
|
||||
parentId: ZFolderParentIdSchema.optional(),
|
||||
type: ZFolderTypeSchema.optional(),
|
||||
});
|
||||
@@ -53,7 +52,7 @@ export const ZCreateFolderResponseSchema = ZFolderSchema;
|
||||
export const ZUpdateFolderRequestSchema = z.object({
|
||||
folderId: z.string().describe('The ID of the folder to update'),
|
||||
data: z.object({
|
||||
name: ZNameSchema.optional().describe('The name of the folder'),
|
||||
name: z.string().optional().describe('The name of the folder'),
|
||||
parentId: ZFolderParentIdSchema.optional().nullable(),
|
||||
visibility: z.nativeEnum(DocumentVisibility).optional().describe('The visibility of the folder'),
|
||||
pinned: z.boolean().optional().describe('Whether the folder should be pinned'),
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||
import { OrganisationMemberRole } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -15,7 +14,7 @@ import { z } from 'zod';
|
||||
export const ZCreateOrganisationGroupRequestSchema = z.object({
|
||||
organisationId: z.string(),
|
||||
organisationRole: z.nativeEnum(OrganisationMemberRole),
|
||||
name: ZNameSchema,
|
||||
name: z.string().max(100),
|
||||
memberIds: z.array(z.string()),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||
import { z } from 'zod';
|
||||
|
||||
// export const createOrganisationMeta: TrpcOpenApiMeta = {
|
||||
@@ -11,8 +10,13 @@ import { z } from 'zod';
|
||||
// },
|
||||
// };
|
||||
|
||||
export const ZOrganisationNameSchema = z
|
||||
.string()
|
||||
.min(3, { message: 'Minimum 3 characters' })
|
||||
.max(50, { message: 'Maximum 50 characters' });
|
||||
|
||||
export const ZCreateOrganisationRequestSchema = z.object({
|
||||
name: ZNameSchema,
|
||||
name: ZOrganisationNameSchema,
|
||||
priceId: z.string().optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ import { getOrganisationsRoute } from './get-organisations';
|
||||
import { leaveOrganisationRoute } from './leave-organisation';
|
||||
import { resendOrganisationMemberInviteRoute } from './resend-organisation-member-invite';
|
||||
import { updateOrganisationRoute } from './update-organisation';
|
||||
import { updateOrganisationBrandingLogoRoute } from './update-organisation-branding-logo';
|
||||
import { updateOrganisationGroupRoute } from './update-organisation-group';
|
||||
import { updateOrganisationMemberRoute } from './update-organisation-members';
|
||||
import { updateOrganisationSettingsRoute } from './update-organisation-settings';
|
||||
@@ -56,7 +55,6 @@ export const organisationRouter = router({
|
||||
},
|
||||
settings: {
|
||||
update: updateOrganisationSettingsRoute,
|
||||
updateBrandingLogo: updateOrganisationBrandingLogoRoute,
|
||||
},
|
||||
internal: {
|
||||
getOrganisationSession: getOrganisationSessionRoute,
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { buildBrandingLogoData } from '@documenso/lib/server-only/branding/store-branding-logo';
|
||||
import { getOrganisationClaim } from '@documenso/lib/server-only/organisation/get-organisation-claims';
|
||||
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
ZUpdateOrganisationBrandingLogoRequestSchema,
|
||||
ZUpdateOrganisationBrandingLogoResponseSchema,
|
||||
} from './update-organisation-branding-logo.types';
|
||||
|
||||
export const updateOrganisationBrandingLogoRoute = authenticatedProcedure
|
||||
.input(ZUpdateOrganisationBrandingLogoRequestSchema)
|
||||
.output(ZUpdateOrganisationBrandingLogoResponseSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { user } = ctx;
|
||||
const { payload, brandingLogo } = input;
|
||||
const { organisationId } = payload;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
organisationId,
|
||||
},
|
||||
});
|
||||
|
||||
const organisation = await prisma.organisation.findFirst({
|
||||
where: buildOrganisationWhereQuery({
|
||||
organisationId,
|
||||
userId: user.id,
|
||||
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!organisation) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to update this organisation.',
|
||||
});
|
||||
}
|
||||
|
||||
// Setting a logo requires the custom-branding entitlement; clearing it is
|
||||
// always allowed so a downgraded organisation can still remove its logo.
|
||||
if (brandingLogo && IS_BILLING_ENABLED()) {
|
||||
const claim = await getOrganisationClaim({ organisationId });
|
||||
|
||||
if (claim.flags?.allowCustomBranding !== true) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Your plan does not allow custom branding.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const brandingLogoValue = brandingLogo ? await buildBrandingLogoData(brandingLogo) : '';
|
||||
|
||||
await prisma.organisation.update({
|
||||
where: {
|
||||
id: organisation.id,
|
||||
},
|
||||
data: {
|
||||
organisationGlobalSettings: {
|
||||
update: {
|
||||
brandingLogo: brandingLogoValue,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
import { zfd } from 'zod-form-data';
|
||||
|
||||
import { zfdBrandingImageFile, zodFormData } from '../../utils/zod-form-data';
|
||||
|
||||
export const ZUpdateOrganisationBrandingLogoRequestSchema = zodFormData({
|
||||
payload: zfd.json(
|
||||
z.object({
|
||||
organisationId: z.string(),
|
||||
}),
|
||||
),
|
||||
brandingLogo: zfdBrandingImageFile().optional(),
|
||||
});
|
||||
|
||||
export const ZUpdateOrganisationBrandingLogoResponseSchema = z.void();
|
||||
|
||||
export type TUpdateOrganisationBrandingLogoRequest = z.infer<typeof ZUpdateOrganisationBrandingLogoRequestSchema>;
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||
import { OrganisationMemberRole } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -15,7 +14,7 @@ import { z } from 'zod';
|
||||
|
||||
export const ZUpdateOrganisationGroupRequestSchema = z.object({
|
||||
id: z.string(),
|
||||
name: ZNameSchema.nullable().optional(),
|
||||
name: z.string().nullable().optional(),
|
||||
organisationRole: z.nativeEnum(OrganisationMemberRole).optional(),
|
||||
memberIds: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
@@ -44,6 +44,7 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
|
||||
|
||||
// Branding related settings.
|
||||
brandingEnabled,
|
||||
brandingLogo,
|
||||
brandingUrl,
|
||||
brandingCompanyDetails,
|
||||
brandingColors,
|
||||
@@ -173,6 +174,7 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
|
||||
|
||||
// Branding related settings.
|
||||
brandingEnabled,
|
||||
brandingLogo,
|
||||
brandingUrl,
|
||||
brandingCompanyDetails,
|
||||
brandingColors: normalizedBrandingColors === null ? Prisma.DbNull : normalizedBrandingColors,
|
||||
|
||||
@@ -32,6 +32,7 @@ export const ZUpdateOrganisationSettingsRequestSchema = z.object({
|
||||
|
||||
// Branding related settings.
|
||||
brandingEnabled: z.boolean().optional(),
|
||||
brandingLogo: z.string().optional(),
|
||||
brandingUrl: z.string().optional(),
|
||||
brandingCompanyDetails: z.string().optional(),
|
||||
brandingColors: ZCssVarsSchema.nullish(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||
import { ZNameSchema } from '@documenso/lib/constants/auth';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZFindUserSecurityAuditLogsSchema = z.object({
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||
import { z } from 'zod';
|
||||
import { ZTeamUrlSchema } from './schema';
|
||||
import { ZTeamNameSchema, ZTeamUrlSchema } from './schema';
|
||||
|
||||
// export const createTeamMeta: TrpcOpenApiMeta = {
|
||||
// openapi: {
|
||||
@@ -14,7 +13,7 @@ import { ZTeamUrlSchema } from './schema';
|
||||
|
||||
export const ZCreateTeamRequestSchema = z.object({
|
||||
organisationId: z.string(),
|
||||
teamName: ZNameSchema,
|
||||
teamName: ZTeamNameSchema,
|
||||
teamUrl: ZTeamUrlSchema,
|
||||
inheritMembers: z
|
||||
.boolean()
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
ZUpdateTeamEmailMutationSchema,
|
||||
} from './schema';
|
||||
import { updateTeamRoute } from './update-team';
|
||||
import { updateTeamBrandingLogoRoute } from './update-team-branding-logo';
|
||||
import { updateTeamGroupRoute } from './update-team-group';
|
||||
import { updateTeamMemberRoute } from './update-team-member';
|
||||
import { updateTeamSettingsRoute } from './update-team-settings';
|
||||
@@ -51,7 +50,6 @@ export const teamRouter = router({
|
||||
},
|
||||
settings: {
|
||||
update: updateTeamSettingsRoute,
|
||||
updateBrandingLogo: updateTeamBrandingLogoRoute,
|
||||
},
|
||||
|
||||
// Old routes (to be migrated)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { URL_PATTERN, ZNameSchema } from '@documenso/lib/constants/auth';
|
||||
import { PROTECTED_TEAM_URLS } from '@documenso/lib/constants/teams';
|
||||
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
@@ -32,6 +32,15 @@ export const ZTeamUrlSchema = z
|
||||
message: 'This URL is already in use.',
|
||||
});
|
||||
|
||||
export const ZTeamNameSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(3, { message: 'Team name must be at least 3 characters long.' })
|
||||
.max(30, { message: 'Team name must not exceed 30 characters.' })
|
||||
.refine((value) => !URL_PATTERN.test(value), {
|
||||
message: 'Team name cannot contain URLs.',
|
||||
});
|
||||
|
||||
export const ZCreateTeamEmailVerificationMutationSchema = z.object({
|
||||
teamId: z.number(),
|
||||
name: ZNameSchema,
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { buildBrandingLogoData } from '@documenso/lib/server-only/branding/store-branding-logo';
|
||||
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
|
||||
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
ZUpdateTeamBrandingLogoRequestSchema,
|
||||
ZUpdateTeamBrandingLogoResponseSchema,
|
||||
} from './update-team-branding-logo.types';
|
||||
|
||||
export const updateTeamBrandingLogoRoute = authenticatedProcedure
|
||||
.input(ZUpdateTeamBrandingLogoRequestSchema)
|
||||
.output(ZUpdateTeamBrandingLogoResponseSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { user } = ctx;
|
||||
const { payload, brandingLogo } = input;
|
||||
const { teamId } = payload;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
const team = await prisma.team.findFirst({
|
||||
where: buildTeamWhereQuery({
|
||||
teamId,
|
||||
userId: user.id,
|
||||
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to update this team.',
|
||||
});
|
||||
}
|
||||
|
||||
// Setting a logo requires the custom-branding entitlement; clearing it is
|
||||
// always allowed so a downgraded team can still remove its logo.
|
||||
if (brandingLogo && IS_BILLING_ENABLED()) {
|
||||
const claim = await getOrganisationClaimByTeamId({ teamId });
|
||||
|
||||
if (claim.flags?.allowCustomBranding !== true) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Your plan does not allow custom branding.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const brandingLogoValue = brandingLogo ? await buildBrandingLogoData(brandingLogo) : '';
|
||||
|
||||
await prisma.team.update({
|
||||
where: {
|
||||
id: team.id,
|
||||
},
|
||||
data: {
|
||||
teamGlobalSettings: {
|
||||
update: {
|
||||
brandingLogo: brandingLogoValue,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
import { zfd } from 'zod-form-data';
|
||||
|
||||
import { zfdBrandingImageFile, zodFormData } from '../../utils/zod-form-data';
|
||||
|
||||
export const ZUpdateTeamBrandingLogoRequestSchema = zodFormData({
|
||||
payload: zfd.json(
|
||||
z.object({
|
||||
teamId: z.number(),
|
||||
}),
|
||||
),
|
||||
brandingLogo: zfdBrandingImageFile().optional(),
|
||||
});
|
||||
|
||||
export const ZUpdateTeamBrandingLogoResponseSchema = z.void();
|
||||
|
||||
export type TUpdateTeamBrandingLogoRequest = z.infer<typeof ZUpdateTeamBrandingLogoRequestSchema>;
|
||||
@@ -42,6 +42,7 @@ export const updateTeamSettingsRoute = authenticatedProcedure
|
||||
|
||||
// Branding related settings.
|
||||
brandingEnabled,
|
||||
brandingLogo,
|
||||
brandingUrl,
|
||||
brandingCompanyDetails,
|
||||
brandingColors,
|
||||
@@ -175,6 +176,7 @@ export const updateTeamSettingsRoute = authenticatedProcedure
|
||||
|
||||
// Branding related settings.
|
||||
brandingEnabled,
|
||||
brandingLogo,
|
||||
brandingUrl,
|
||||
brandingCompanyDetails,
|
||||
brandingColors: normalizedBrandingColors === null ? Prisma.DbNull : normalizedBrandingColors,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user