Merge branch 'main' into feat/add-pdf-image-renderer

This commit is contained in:
David Nguyen
2026-02-04 12:50:40 +11:00
committed by GitHub
125 changed files with 7880 additions and 938 deletions
@@ -3,6 +3,7 @@ import { useState } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import type { z } from 'zod';
import type { TLicenseClaim } from '@documenso/lib/types/license';
import { generateDefaultSubscriptionClaim } from '@documenso/lib/utils/organisations-claims';
import { trpc } from '@documenso/trpc/react';
import type { ZCreateSubscriptionClaimRequestSchema } from '@documenso/trpc/server/admin-router/create-subscription-claim.types';
@@ -22,7 +23,11 @@ import { SubscriptionClaimForm } from '../forms/subscription-claim-form';
export type CreateClaimFormValues = z.infer<typeof ZCreateSubscriptionClaimRequestSchema>;
export const ClaimCreateDialog = () => {
type ClaimCreateDialogProps = {
licenseFlags?: TLicenseClaim;
};
export const ClaimCreateDialog = ({ licenseFlags }: ClaimCreateDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
@@ -67,6 +72,7 @@ export const ClaimCreateDialog = () => {
...generateDefaultSubscriptionClaim(),
}}
onFormSubmit={createClaim}
licenseFlags={licenseFlags}
formSubmitTrigger={
<DialogFooter>
<Button
@@ -2,6 +2,7 @@ import { useState } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import type { TLicenseClaim } from '@documenso/lib/types/license';
import { trpc } from '@documenso/trpc/react';
import type { TFindSubscriptionClaimsResponse } from '@documenso/trpc/server/admin-router/find-subscription-claims.types';
import { Button } from '@documenso/ui/primitives/button';
@@ -21,9 +22,10 @@ import { SubscriptionClaimForm } from '../forms/subscription-claim-form';
export type ClaimUpdateDialogProps = {
claim: TFindSubscriptionClaimsResponse['data'][number];
trigger: React.ReactNode;
licenseFlags?: TLicenseClaim;
};
export const ClaimUpdateDialog = ({ claim, trigger }: ClaimUpdateDialogProps) => {
export const ClaimUpdateDialog = ({ claim, trigger, licenseFlags }: ClaimUpdateDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
@@ -69,6 +71,7 @@ export const ClaimUpdateDialog = ({ claim, trigger }: ClaimUpdateDialogProps) =>
data,
})
}
licenseFlags={licenseFlags}
formSubmitTrigger={
<DialogFooter>
<Button
@@ -0,0 +1,170 @@
import { plural } from '@lingui/core/macro';
import { Plural, useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type EnvelopesBulkDeleteDialogProps = {
envelopeIds: string[];
envelopeType: EnvelopeType;
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const EnvelopesBulkDeleteDialog = ({
envelopeIds,
envelopeType,
open,
onOpenChange,
onSuccess,
...props
}: EnvelopesBulkDeleteDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const trpcUtils = trpc.useUtils();
const isDocument = envelopeType === EnvelopeType.DOCUMENT;
const { mutateAsync: bulkDeleteEnvelopes, isPending } = trpc.envelope.bulk.delete.useMutation({
onSuccess: async (result) => {
// Invalidate the appropriate query based on envelope type.
if (isDocument) {
await trpcUtils.document.findDocumentsInternal.invalidate();
} else {
await trpcUtils.template.findTemplates.invalidate();
}
if (result.failedIds.length > 0) {
toast({
title: isDocument ? t`Documents partially deleted` : t`Templates partially deleted`,
description: t`${plural(result.deletedCount, {
one: '# item deleted.',
other: '# items deleted.',
})} ${plural(result.failedIds.length, {
one: '# item could not be deleted.',
other: '# items could not be deleted.',
})}`,
variant: 'destructive',
});
} else {
toast({
title: isDocument ? t`Documents deleted` : t`Templates deleted`,
description: plural(result.deletedCount, {
one: '# item has been deleted.',
other: '# items have been deleted.',
}),
variant: 'default',
});
}
onSuccess?.();
onOpenChange(false);
},
onError: () => {
toast({
title: t`Error`,
description: t`An error occurred while deleting the items.`,
variant: 'destructive',
});
},
});
return (
<Dialog {...props} open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{isDocument ? <Trans>Delete Documents</Trans> : <Trans>Delete Templates</Trans>}
</DialogTitle>
<DialogDescription>
{isDocument ? (
<Plural
value={envelopeIds.length}
one="You are about to delete the selected document."
other="You are about to delete # documents."
/>
) : (
<Plural
value={envelopeIds.length}
one="You are about to delete the selected template."
other="You are about to delete # templates."
/>
)}
</DialogDescription>
</DialogHeader>
<Alert variant="warning">
<AlertDescription>
<p>
<Trans>
Please note that this action is <strong>irreversible</strong>.
</Trans>
</p>
<p className="mt-1">
<Trans>Once confirmed, the following will occur:</Trans>
</p>
<ul className="mt-0.5 list-inside list-disc">
{isDocument ? (
<>
<li>
<Trans>Selected documents will be permanently deleted</Trans>
</li>
<li>
<Trans>Pending documents will have their signing process cancelled</Trans>
</li>
<li>
<Trans>All recipients will be notified</Trans>
</li>
</>
) : (
<>
<li>
<Trans>Selected templates will be permanently deleted</Trans>
</li>
<li>
<Trans>Direct links associated with templates will be removed</Trans>
</li>
</>
)}
</ul>
</AlertDescription>
</Alert>
<DialogFooter>
<Button type="button" variant="secondary" disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
<Button
onClick={(e) => {
e.preventDefault();
void bulkDeleteEnvelopes({ envelopeIds });
}}
loading={isPending}
variant="destructive"
>
<Trans>Delete</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,256 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Plural, useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderIcon, HomeIcon, Loader2, Search } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type EnvelopesBulkMoveDialogProps = {
envelopeIds: string[];
envelopeType: EnvelopeType;
open: boolean;
onOpenChange: (open: boolean) => void;
currentFolderId?: string;
onSuccess?: () => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZBulkMoveFormSchema = z.object({
folderId: z.string().nullable(),
});
type TBulkMoveFormSchema = z.infer<typeof ZBulkMoveFormSchema>;
export const EnvelopesBulkMoveDialog = ({
envelopeIds,
envelopeType,
open,
onOpenChange,
currentFolderId,
onSuccess,
...props
}: EnvelopesBulkMoveDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [searchTerm, setSearchTerm] = useState('');
const form = useForm<TBulkMoveFormSchema>({
resolver: zodResolver(ZBulkMoveFormSchema),
defaultValues: {
folderId: currentFolderId ?? null,
},
});
const isDocument = envelopeType === EnvelopeType.DOCUMENT;
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery(
{
parentId: currentFolderId,
type: envelopeType,
},
{
enabled: open,
},
);
const { mutateAsync: bulkMoveEnvelopes } = trpc.envelope.bulk.move.useMutation();
const trpcUtils = trpc.useUtils();
useEffect(() => {
if (open) {
setSearchTerm('');
form.reset({
folderId: currentFolderId,
});
}
}, [open, currentFolderId]);
const onSubmit = async (data: TBulkMoveFormSchema) => {
try {
await bulkMoveEnvelopes({
envelopeIds,
folderId: data.folderId,
envelopeType,
});
// Invalidate the appropriate query based on envelope type.
if (isDocument) {
await trpcUtils.document.findDocumentsInternal.invalidate();
} else {
await trpcUtils.template.findTemplates.invalidate();
}
toast({
description: t`Selected items have been moved.`,
});
onSuccess?.();
onOpenChange(false);
} catch (err) {
const error = AppError.parseError(err);
const errorMessage = match(error.code)
.with(
AppErrorCode.NOT_FOUND,
() => t`The folder you are trying to move the items to does not exist.`,
)
.with(AppErrorCode.UNAUTHORIZED, () => t`You are not allowed to move these items.`)
.with(AppErrorCode.INVALID_BODY, () => t`All items must be of the same type.`)
.otherwise(() => t`An error occurred while moving the items.`);
toast({
description: errorMessage,
variant: 'destructive',
});
}
};
const filteredFolders = folders?.data.filter((folder) =>
folder.name.toLowerCase().includes(searchTerm.toLowerCase()),
);
return (
<Dialog {...props} open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{isDocument ? (
<Trans>Move Documents to Folder</Trans>
) : (
<Trans>Move Templates to Folder</Trans>
)}
</DialogTitle>
<DialogDescription>
{isDocument ? (
<Plural
value={envelopeIds.length}
one="Select a folder to move the selected document to."
other="Select a folder to move the # selected documents to."
/>
) : (
<Plural
value={envelopeIds.length}
one="Select a folder to move the selected template to."
other="Select a folder to move the # selected templates to."
/>
)}
</DialogDescription>
</DialogHeader>
<div className="relative">
<Search className="absolute left-2 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t`Search folders...`}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8"
/>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
<FormField
control={form.control}
name="folderId"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Folder</Trans>
</FormLabel>
<FormControl>
<div className="max-h-96 space-y-2 overflow-y-auto">
{isFoldersLoading ? (
<div className="flex h-10 items-center justify-center">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : (
<>
<Button
type="button"
variant={field.value === null ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(null)}
disabled={currentFolderId === undefined}
>
<HomeIcon className="mr-2 h-4 w-4" />
<Trans>Home (No Folder)</Trans>
</Button>
{filteredFolders?.map((folder) => (
<Button
key={folder.id}
type="button"
variant={field.value === folder.id ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(folder.id)}
disabled={currentFolderId === folder.id}
>
<FolderIcon className="mr-2 h-4 w-4" />
{folder.name}
</Button>
))}
{searchTerm && filteredFolders?.length === 0 && (
<div className="px-2 py-2 text-center text-sm text-muted-foreground">
<Trans>No folders found</Trans>
</div>
)}
</>
)}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
disabled={isFoldersLoading || form.formState.isSubmitting}
loading={form.formState.isSubmitting}
>
<Trans>Move</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -1,10 +1,10 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { TeamMemberRole } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { InfoIcon } from 'lucide-react';
import { InfoIcon, UserPlusIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
@@ -13,6 +13,7 @@ import { z } from 'zod';
import { TEAM_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/teams';
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@@ -44,6 +45,7 @@ import {
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { OrganisationMemberInviteDialog } from '~/components/dialogs/organisation-member-invite-dialog';
import { useCurrentTeam } from '~/providers/team';
export type TeamMemberCreateDialogProps = {
@@ -64,11 +66,14 @@ type TAddTeamMembersFormSchema = z.infer<typeof ZAddTeamMembersFormSchema>;
export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDialogProps) => {
const [open, setOpen] = useState(false);
const [step, setStep] = useState<'SELECT' | 'MEMBERS'>('SELECT');
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
const prevInviteDialogOpenRef = useRef(false);
const { t } = useLingui();
const { toast } = useToast();
const team = useCurrentTeam();
const utils = trpc.useUtils();
const form = useForm<TAddTeamMembersFormSchema>({
resolver: zodResolver(ZAddTeamMembersFormSchema),
@@ -96,7 +101,29 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
);
}, [organisationMemberQuery, teamMemberQuery]);
const hasNoAvailableMembers =
!organisationMemberQuery.isLoading && avaliableOrganisationMembers.length === 0;
const onFormSubmit = async ({ members }: TAddTeamMembersFormSchema) => {
if (members.length === 0) {
if (hasNoAvailableMembers) {
setInviteDialogOpen(true);
return;
}
// Don't show error if on SELECT step - the disabled Next button already communicates this
if (step === 'SELECT') {
return;
}
toast({
title: t`No members selected`,
description: t`Please select at least one member to add to the team.`,
variant: 'destructive',
});
return;
}
try {
await createTeamMembers({
teamId: team.id,
@@ -123,9 +150,20 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
if (!open) {
form.reset();
setStep('SELECT');
setInviteDialogOpen(false);
}
}, [open, form]);
// Invalidate queries when invite dialog closes (transitions from true to false) to refresh available members
useEffect(() => {
if (prevInviteDialogOpenRef.current && !inviteDialogOpen) {
void utils.organisation.member.find.invalidate({
organisationId: team.organisationId,
});
}
prevInviteDialogOpenRef.current = inviteDialogOpen;
}, [inviteDialogOpen, utils, team.organisationId]);
return (
<Dialog
{...props}
@@ -134,9 +172,11 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
// Since it would be annoying to redo the whole process.
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
<Button variant="secondary" onClick={() => setOpen(true)}>
<Trans>Add members</Trans>
</Button>
{trigger ?? (
<Button variant="secondary" onClick={() => setOpen(true)}>
<Trans>Add members</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent hideClose={true} position="center">
@@ -149,7 +189,7 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
<TooltipTrigger asChild>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground z-[99999] max-w-xs">
<TooltipContent className="z-[99999] max-w-xs text-muted-foreground">
<Trans>
To be able to add members to a team, you must first add them to the
organisation. For more information, please see the{' '}
@@ -186,7 +226,18 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
.exhaustive()}
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<form
onSubmit={form.handleSubmit(onFormSubmit)}
onKeyDown={(e) => {
if (e.key === 'Enter' && form.getValues('members').length === 0) {
e.preventDefault();
if (hasNoAvailableMembers) {
setInviteDialogOpen(true);
}
// Don't show toast - the disabled Next button already communicates this
}
}}
>
<fieldset disabled={form.formState.isSubmitting}>
{step === 'SELECT' && (
<>
@@ -194,46 +245,102 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
control={form.control}
name="members"
render={({ field }) => (
<FormItem>
<FormItem className="space-y-2">
<FormLabel>
<Trans>Members</Trans>
</FormLabel>
<FormControl>
<MultiSelectCombobox
options={avaliableOrganisationMembers.map((member) => ({
label: member.name,
value: member.id,
}))}
loading={organisationMemberQuery.isLoading}
selectedValues={field.value.map(
(member) => member.organisationMemberId,
)}
onChange={(value) => {
field.onChange(
value.map((organisationMemberId) => ({
organisationMemberId,
teamRole:
field.value.find(
(member) =>
member.organisationMemberId === organisationMemberId,
)?.teamRole || TeamMemberRole.MEMBER,
})),
);
}}
className="bg-background w-full"
emptySelectionPlaceholder={t`Select members`}
/>
{hasNoAvailableMembers ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-muted-foreground/25 bg-muted/30 px-6 py-12 text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<UserPlusIcon className="h-6 w-6 text-muted-foreground" />
</div>
<h3 className="mb-2 text-sm font-semibold">
<Trans>No organisation members available</Trans>
</h3>
<p className="mb-6 max-w-sm text-sm text-muted-foreground">
<Trans>
To add members to this team, you must first add them to the
organisation.
</Trans>
</p>
<OrganisationMemberInviteDialog
open={inviteDialogOpen}
onOpenChange={setInviteDialogOpen}
trigger={
<Button type="button" variant="default">
<UserPlusIcon className="mr-2 h-4 w-4" />
<Trans>Invite organisation members</Trans>
</Button>
}
/>
</div>
) : (
<MultiSelectCombobox
options={avaliableOrganisationMembers.map((member) => ({
label: member.name,
value: member.id,
}))}
loading={organisationMemberQuery.isLoading}
selectedValues={field.value.map(
(member) => member.organisationMemberId,
)}
onChange={(value) => {
field.onChange(
value.map((organisationMemberId) => ({
organisationMemberId,
teamRole:
field.value.find(
(member) =>
member.organisationMemberId === organisationMemberId,
)?.teamRole || TeamMemberRole.MEMBER,
})),
);
}}
className="w-full bg-background"
emptySelectionPlaceholder={t`Select members`}
/>
)}
</FormControl>
<FormDescription>
<Trans>Select members to add to this team</Trans>
</FormDescription>
{!hasNoAvailableMembers && (
<>
<FormDescription>
<Trans>Select members to add to this team</Trans>
</FormDescription>
<Alert
variant="neutral"
className="mt-2 flex items-center gap-2 space-y-0"
>
<div>
<UserPlusIcon className="h-5 w-5 text-muted-foreground" />
</div>
<AlertDescription className="mt-0 flex-1">
<Trans>Can't find someone?</Trans>{' '}
<OrganisationMemberInviteDialog
open={inviteDialogOpen}
onOpenChange={setInviteDialogOpen}
trigger={
<Button
type="button"
variant="link"
className="h-auto p-0 text-sm font-medium text-documenso-700 hover:text-documenso-600"
>
<Trans>Invite them to the organisation first</Trans>
</Button>
}
/>
</AlertDescription>
</Alert>
</>
)}
</FormItem>
)}
/>
<DialogFooter>
<DialogFooter className="mt-4">
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
+1 -1
View File
@@ -402,7 +402,7 @@ export const SignUpForm = ({
size="lg"
className="mt-6 w-full"
>
<Trans>Complete</Trans>
<Trans>Create account</Trans>
</Button>
</form>
</Form>
@@ -2,10 +2,13 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import type { SubscriptionClaim } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { Link } from 'react-router';
import type { z } from 'zod';
import type { TLicenseClaim } from '@documenso/lib/types/license';
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
import { ZCreateSubscriptionClaimRequestSchema } from '@documenso/trpc/server/admin-router/create-subscription-claim.types';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Form,
@@ -24,15 +27,22 @@ type SubscriptionClaimFormProps = {
subscriptionClaim: Omit<SubscriptionClaim, 'id' | 'createdAt' | 'updatedAt'>;
onFormSubmit: (data: SubscriptionClaimFormValues) => Promise<void>;
formSubmitTrigger?: React.ReactNode;
licenseFlags?: TLicenseClaim;
};
export const SubscriptionClaimForm = ({
subscriptionClaim,
onFormSubmit,
formSubmitTrigger,
licenseFlags,
}: SubscriptionClaimFormProps) => {
const { t } = useLingui();
const hasRestrictedEnterpriseFeatures = Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).some(
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
(flag) => flag.isEnterprise && !licenseFlags?.[flag.key as keyof TLicenseClaim],
);
const form = useForm<SubscriptionClaimFormValues>({
resolver: zodResolver(ZCreateSubscriptionClaimRequestSchema),
defaultValues: {
@@ -142,34 +152,59 @@ export const SubscriptionClaimForm = ({
</FormLabel>
<div className="mt-2 space-y-2 rounded-md border p-4">
{Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(({ key, label }) => (
<FormField
key={key}
control={form.control}
name={`flags.${key}`}
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormControl>
<div className="flex items-center">
<Checkbox
id={`flag-${key}`}
checked={field.value}
onCheckedChange={field.onChange}
/>
{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
<label
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
htmlFor={`flag-${key}`}
>
{label}
</label>
</div>
</FormControl>
</FormItem>
)}
/>
))}
return (
<FormField
key={key}
control={form.control}
name={`flags.${key}`}
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormControl>
<div className="flex items-center">
<Checkbox
id={`flag-${key}`}
checked={field.value}
onCheckedChange={field.onChange}
disabled={isRestrictedFeature && !field.value} // Allow disabling of restricted features.
/>
<label
className="ml-2 flex flex-row items-center text-sm text-muted-foreground"
htmlFor={`flag-${key}`}
>
{label}
{isRestrictedFeature && ' ¹'}
</label>
</div>
</FormControl>
</FormItem>
)}
/>
);
},
)}
</div>
{hasRestrictedEnterpriseFeatures && (
<Alert variant="neutral" className="mt-4">
<AlertDescription>
<span>¹&nbsp;</span>
<Trans>Your current license does not include these features.</Trans>{' '}
<Link
to="https://docs.documenso.com/users/licenses/enterprise-edition"
target="_blank"
className="text-foreground underline hover:opacity-80"
>
<Trans>Learn more</Trans>
</Link>
</AlertDescription>
</Alert>
)}
</div>
{formSubmitTrigger}
@@ -0,0 +1,212 @@
import { Trans, useLingui } from '@lingui/react/macro';
import {
ArrowRightIcon,
CheckCircle2Icon,
KeyRoundIcon,
Loader2Icon,
RefreshCwIcon,
XCircleIcon,
} from 'lucide-react';
import { DateTime } from 'luxon';
import { Link, useRevalidator } from 'react-router';
import { match } from 'ts-pattern';
import type { TCachedLicense } from '@documenso/lib/types/license';
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
import { trpc } from '@documenso/trpc/react';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { CardMetric } from './metric-card';
type AdminLicenseCardProps = {
licenseData: TCachedLicense | null;
};
export const AdminLicenseCard = ({ licenseData }: AdminLicenseCardProps) => {
const { t, i18n } = useLingui();
const { license } = licenseData || {};
if (!license) {
return (
<div className="relative">
<div className="absolute right-3 top-3 z-10">
<AdminLicenseResyncButton />
</div>
<CardMetric icon={KeyRoundIcon} title={t`License`} className="h-fit max-h-fit">
<div className="mt-1 flex items-center justify-center gap-2">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-dashed border-muted-foreground/30 bg-muted/50">
<KeyRoundIcon className="h-5 w-5 text-muted-foreground/50" />
</div>
<div className="flex flex-col gap-0.5">
{licenseData?.requestedLicenseKey ? (
<>
<p className="text-sm font-medium text-destructive">
<Trans>Invalid License Key</Trans>
</p>
<p className="text-xs text-muted-foreground">{licenseData.requestedLicenseKey}</p>
</>
) : (
<p className="text-sm font-medium text-muted-foreground">
<Trans>No License Configured</Trans>
</p>
)}
<Link
to="https://docs.documenso.com/users/licenses/enterprise-edition"
target="_blank"
className="flex flex-row items-center text-xs text-muted-foreground hover:text-muted-foreground/80"
>
<Trans>Learn more</Trans> <ArrowRightIcon className="h-3 w-3" />
</Link>
</div>
</div>
</CardMetric>
</div>
);
}
const enabledFlags = Object.entries(license.flags).filter(([, enabled]) => enabled);
return (
<div className="relative max-w-full overflow-hidden rounded-lg border border-border bg-background px-4 pb-6 pt-4 shadow shadow-transparent duration-200 hover:shadow-border/80">
<div className="absolute right-3 top-3">
<AdminLicenseResyncButton />
</div>
<div className="flex items-start gap-2">
<div className="h-4 w-4">
<KeyRoundIcon className="h-4 w-4 text-muted-foreground" />
</div>
<h3 className="text-primary-forground mb-2 flex items-end text-sm font-medium leading-tight">
<Trans>Documenso License</Trans>
</h3>
{match(license.status)
.with('ACTIVE', () => (
<Badge variant="default" size="small">
<CheckCircle2Icon className="mr-1 h-3 w-3" />
<Trans context="Subscription status">Active</Trans>
</Badge>
))
.with('PAST_DUE', () => (
<Badge variant="warning" size="small">
<XCircleIcon className="mr-1 h-3 w-3" />
<Trans context="Subscription status">Past Due</Trans>
</Badge>
))
.with('EXPIRED', () => (
<Badge variant="destructive" size="small">
<XCircleIcon className="mr-1 h-3 w-3" />
<Trans context="Subscription status">Expired</Trans>
</Badge>
))
.otherwise(() => null)}
</div>
<div className="mt-4 grid grid-cols-2 gap-4">
<div>
<p className="text-sm font-medium text-foreground">
<Trans>License</Trans>
</p>
<p className="mt-0.5 text-xs text-muted-foreground">{license.name}</p>
</div>
<div>
<p className="text-sm font-medium text-foreground">
<Trans>Expires</Trans>
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{i18n.date(license.periodEnd, DateTime.DATE_MED)}
</p>
</div>
<div>
<p className="text-sm font-medium text-foreground">
<Trans>License Key</Trans>
</p>
<p className="mt-0.5 text-xs text-muted-foreground">{license.licenseKey}</p>
</div>
<div>
<p className="text-sm font-medium text-foreground">
<Trans>Features</Trans>
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{enabledFlags.length > 0 ? (
enabledFlags
.map(
([flag]) =>
SUBSCRIPTION_CLAIM_FEATURE_FLAGS[
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
flag as keyof typeof SUBSCRIPTION_CLAIM_FEATURE_FLAGS
]?.label || flag,
)
.join(', ')
) : (
<Trans>No features enabled</Trans>
)}
</p>
</div>
</div>
</div>
);
};
const AdminLicenseResyncButton = () => {
const { t } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const { mutate: resyncLicense, isPending: isResyncingLicense } =
trpc.admin.license.resync.useMutation({
onSuccess: async () => {
toast({
title: t`License synced`,
});
await revalidate();
},
onError: () => {
toast({
title: t`Failed to sync license`,
variant: 'destructive',
});
},
});
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
disabled={isResyncingLicense}
onClick={() => resyncLicense()}
>
{isResyncingLicense ? (
<Loader2Icon className="h-4 w-4 animate-spin" />
) : (
<RefreshCwIcon className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<Trans>Sync license from server</Trans>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
@@ -0,0 +1,78 @@
import { Trans } from '@lingui/react/macro';
import { AlertTriangleIcon, KeyRoundIcon } from 'lucide-react';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
import type { TCachedLicense } from '@documenso/lib/types/license';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
export type AdminLicenseStatusBannerProps = {
license: TCachedLicense | null;
};
export const AdminLicenseStatusBanner = ({ license }: AdminLicenseStatusBannerProps) => {
const licenseStatus = license?.derivedStatus;
if (!license || licenseStatus === 'ACTIVE' || licenseStatus === 'NOT_FOUND') {
return null;
}
return (
<div
className={cn('mb-8 rounded-lg bg-yellow-200 text-yellow-900 dark:bg-yellow-400', {
'bg-destructive text-destructive-foreground':
licenseStatus === 'EXPIRED' || licenseStatus === 'UNAUTHORIZED',
})}
>
<div className="flex items-center justify-between gap-x-4 px-4 py-3 text-sm font-medium">
<div className="flex items-center">
<AlertTriangleIcon className="mr-2.5 h-5 w-5" />
{match(licenseStatus)
.with('PAST_DUE', () => (
<Trans>
License Payment Overdue - Please update your payment to avoid service disruptions.
</Trans>
))
.with('EXPIRED', () => (
<Trans>
License Expired - Please renew your license to continue using enterprise features.
</Trans>
))
.with('UNAUTHORIZED', () =>
license ? (
<Trans>
Invalid License Type - Your Documenso instance is using features that are not part
of your license.
</Trans>
) : (
<Trans>
Missing License - Your Documenso instance is using features that require a
license.
</Trans>
),
)
.otherwise(() => null)}
</div>
<Button
variant="outline"
size="sm"
className={cn({
'border-yellow-900/30 text-yellow-900 hover:bg-yellow-100 dark:hover:bg-yellow-500':
licenseStatus === 'PAST_DUE',
'border-destructive-foreground/30 text-destructive-foreground hover:bg-destructive/80':
licenseStatus === 'EXPIRED' || licenseStatus === 'UNAUTHORIZED',
})}
asChild
>
<Link to="https://docs.documenso.com/users/licenses/enterprise-edition" target="_blank">
<KeyRoundIcon className="mr-1.5 h-4 w-4" />
<Trans>See Documentation</Trans>
</Link>
</Button>
</div>
</div>
);
};
@@ -1,10 +1,13 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans, useLingui as useLinguiMacro } from '@lingui/react/macro';
import { RecipientRole } from '@prisma/client';
import type { TDefaultRecipient } from '@documenso/lib/types/default-recipients';
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
import { trpc } from '@documenso/trpc/react';
import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect';
import { useToast } from '@documenso/ui/primitives/use-toast';
type DefaultRecipientsMultiSelectComboboxProps = {
listValues: TDefaultRecipient[];
@@ -20,6 +23,8 @@ export const DefaultRecipientsMultiSelectCombobox = ({
organisationId,
}: DefaultRecipientsMultiSelectComboboxProps) => {
const { _ } = useLingui();
const { t } = useLinguiMacro();
const { toast } = useToast();
const { data: organisationData, isLoading: isLoadingOrganisation } =
trpc.organisation.member.find.useQuery(
@@ -60,31 +65,56 @@ export const DefaultRecipientsMultiSelectCombobox = ({
}));
const onSelectionChange = (selected: Option[]) => {
const updatedRecipients = selected.map((option) => {
const existingRecipient = listValues.find((r) => r.email === option.value);
const member = members?.find((m) => m.email === option.value);
const invalidEmails = selected.filter(
(option) => !isRecipientEmailValidForSending({ email: option.value }),
);
return {
email: option.value,
name: member?.name || option.value,
role: existingRecipient?.role ?? RecipientRole.CC,
};
});
if (invalidEmails.length > 0) {
toast({
title: t`Invalid email`,
description: t`"${invalidEmails[0].value}" is not a valid email address.`,
variant: 'destructive',
});
}
const updatedRecipients = selected
.filter((option) => !invalidEmails.includes(option))
.map((option) => {
const existingRecipient = listValues.find((r) => r.email === option.value);
const member = members?.find((m) => m.email === option.value);
return {
email: option.value,
name: member?.name || option.value,
role: existingRecipient?.role ?? RecipientRole.CC,
};
});
onChange(updatedRecipients);
};
return (
<MultiSelect
commandProps={{ label: _(msg`Select recipients`) }}
commandProps={{ label: _(msg`Select or add recipients`) }}
options={options}
value={value}
onChange={onSelectionChange}
placeholder={_(msg`Select recipients`)}
placeholder={_(msg`Select or enter email address`)}
hideClearAllButton
hidePlaceholderWhenSelected
loadingIndicator={isLoading ? <p className="text-center text-sm">Loading...</p> : undefined}
emptyIndicator={<p className="text-center text-sm">No members found</p>}
creatable
loadingIndicator={
isLoading ? (
<p className="text-center text-sm">
<Trans>Loading...</Trans>
</p>
) : undefined
}
emptyIndicator={
<p className="text-center text-sm">
<Trans>Type an email address to add a recipient</Trans>
</p>
}
/>
);
};
@@ -3,6 +3,8 @@ import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DownloadIcon } from 'lucide-react';
import { downloadFile } from '@documenso/lib/client-only/download-file';
import { base64 } from '@documenso/lib/universal/base64';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -25,36 +27,15 @@ export const DocumentAuditLogDownloadButton = ({
const onDownloadAuditLogsClick = async () => {
try {
const { url } = await downloadAuditLogs({ documentId });
const { data, envelopeTitle } = await downloadAuditLogs({ documentId });
const iframe = Object.assign(document.createElement('iframe'), {
src: url,
const buffer = new Uint8Array(base64.decode(data));
const blob = new Blob([buffer], { type: 'application/pdf' });
downloadFile({
data: blob,
filename: `${envelopeTitle} - Audit Logs.pdf`,
});
Object.assign(iframe.style, {
position: 'fixed',
top: '0',
left: '0',
width: '0',
height: '0',
});
const onLoaded = () => {
if (iframe.contentDocument?.readyState === 'complete') {
iframe.contentWindow?.print();
iframe.contentWindow?.addEventListener('afterprint', () => {
document.body.removeChild(iframe);
});
}
};
// When the iframe has loaded, print the iframe and remove it from the dom
iframe.addEventListener('load', onLoaded);
document.body.appendChild(iframe);
onLoaded();
} catch (error) {
console.error(error);
@@ -4,6 +4,8 @@ import { Trans } from '@lingui/react/macro';
import type { DocumentStatus } from '@prisma/client';
import { DownloadIcon } from 'lucide-react';
import { downloadFile } from '@documenso/lib/client-only/download-file';
import { base64 } from '@documenso/lib/universal/base64';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@@ -29,36 +31,15 @@ export const DocumentCertificateDownloadButton = ({
const onDownloadCertificatesClick = async () => {
try {
const { url } = await downloadCertificate({ documentId });
const { data, envelopeTitle } = await downloadCertificate({ documentId });
const iframe = Object.assign(document.createElement('iframe'), {
src: url,
const buffer = new Uint8Array(base64.decode(data));
const blob = new Blob([buffer], { type: 'application/pdf' });
downloadFile({
data: blob,
filename: `${envelopeTitle} - Certificate.pdf`,
});
Object.assign(iframe.style, {
position: 'fixed',
top: '0',
left: '0',
width: '0',
height: '0',
});
const onLoaded = () => {
if (iframe.contentDocument?.readyState === 'complete') {
iframe.contentWindow?.print();
iframe.contentWindow?.addEventListener('afterprint', () => {
document.body.removeChild(iframe);
});
}
};
// When the iframe has loaded, print the iframe and remove it from the dom
iframe.addEventListener('load', onLoaded);
document.body.appendChild(iframe);
onLoaded();
} catch (error) {
console.error(error);
@@ -594,8 +594,12 @@ export const EnvelopeEditorRecipientForm = () => {
<Card backdropBlur={false} className="border">
<CardHeader className="flex flex-row justify-between">
<div>
<CardTitle>Recipients</CardTitle>
<CardDescription className="mt-1.5">Add recipients to your document</CardDescription>
<CardTitle>
<Trans>Recipients</Trans>
</CardTitle>
<CardDescription className="mt-1.5">
<Trans>Add recipients to your document</Trans>
</CardDescription>
</div>
<div className="flex flex-row items-center space-x-2">
@@ -5,15 +5,16 @@ import { cn } from '@documenso/ui/lib/utils';
export type CardMetricProps = {
icon?: LucideIcon;
title: string;
value: string | number;
value?: string | number;
className?: string;
children?: React.ReactNode;
};
export const CardMetric = ({ icon: Icon, title, value, className }: CardMetricProps) => {
export const CardMetric = ({ icon: Icon, title, value, className, children }: CardMetricProps) => {
return (
<div
className={cn(
'border-border bg-background hover:shadow-border/80 h-32 max-h-32 max-w-full overflow-hidden rounded-lg border shadow shadow-transparent duration-200',
'h-32 max-h-32 max-w-full overflow-hidden rounded-lg border border-border bg-background shadow shadow-transparent duration-200 hover:shadow-border/80',
className,
)}
>
@@ -21,7 +22,7 @@ export const CardMetric = ({ icon: Icon, title, value, className }: CardMetricPr
<div className="flex items-start">
{Icon && (
<div className="mr-2 h-4 w-4">
<Icon className="text-muted-foreground h-4 w-4" />
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
)}
@@ -30,9 +31,11 @@ export const CardMetric = ({ icon: Icon, title, value, className }: CardMetricPr
</h3>
</div>
<p className="text-foreground mt-auto text-4xl font-semibold leading-8">
{typeof value === 'number' ? value.toLocaleString('en-US') : value}
</p>
{children || (
<p className="mt-auto text-4xl font-semibold leading-8 text-foreground">
{typeof value === 'number' ? value.toLocaleString('en-US') : value}
</p>
)}
</div>
</div>
);
@@ -6,6 +6,7 @@ import { EditIcon, MoreHorizontalIcon, Trash2Icon } from 'lucide-react';
import { Link, useSearchParams } from 'react-router';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import type { TLicenseClaim } from '@documenso/lib/types/license';
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
import { trpc } from '@documenso/trpc/react';
@@ -27,7 +28,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { ClaimDeleteDialog } from '../dialogs/claim-delete-dialog';
import { ClaimUpdateDialog } from '../dialogs/claim-update-dialog';
export const AdminClaimsTable = () => {
type AdminClaimsTableProps = {
licenseFlags?: TLicenseClaim;
};
export const AdminClaimsTable = ({ licenseFlags }: AdminClaimsTableProps) => {
const { t } = useLingui();
const { toast } = useToast();
@@ -97,11 +102,11 @@ export const AdminClaimsTable = () => {
);
if (flags.length === 0) {
return <p className="text-muted-foreground text-xs">{t`None`}</p>;
return <p className="text-xs text-muted-foreground">{t`None`}</p>;
}
return (
<ul className="text-muted-foreground list-disc space-y-1 text-xs">
<ul className="list-disc space-y-1 text-xs text-muted-foreground">
{flags.map(({ key, label }) => (
<li key={key}>{label}</li>
))}
@@ -114,7 +119,7 @@ export const AdminClaimsTable = () => {
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger>
<MoreHorizontalIcon className="text-muted-foreground h-5 w-5" />
<MoreHorizontalIcon className="h-5 w-5 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="start" forceMount>
@@ -124,6 +129,7 @@ export const AdminClaimsTable = () => {
<ClaimUpdateDialog
claim={row.original}
licenseFlags={licenseFlags}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
@@ -12,7 +12,8 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/find-documents.types';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import type { DataTableColumnDef, RowSelectionState } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
@@ -30,6 +31,9 @@ export type DocumentsTableProps = {
isLoading?: boolean;
isLoadingError?: boolean;
onMoveDocument?: (documentId: number) => void;
enableSelection?: boolean;
rowSelection?: RowSelectionState;
onRowSelectionChange?: (selection: RowSelectionState) => void;
};
type DocumentsTableRow = TFindDocumentsResponse['data'][number];
@@ -39,6 +43,9 @@ export const DocumentsTable = ({
isLoading,
isLoadingError,
onMoveDocument,
enableSelection,
rowSelection,
onRowSelectionChange,
}: DocumentsTableProps) => {
const { _, i18n } = useLingui();
@@ -48,7 +55,34 @@ export const DocumentsTable = ({
const updateSearchParams = useUpdateSearchParams();
const columns = useMemo(() => {
return [
const cols: DataTableColumnDef<DocumentsTableRow>[] = [];
if (enableSelection) {
cols.push({
id: 'select',
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label={_(msg`Select all`)}
onClick={(e) => e.stopPropagation()}
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label={_(msg`Select row`)}
onClick={(e) => e.stopPropagation()}
/>
),
enableSorting: false,
enableHiding: false,
size: 40,
});
}
cols.push(
{
header: _(msg`Created`),
accessorKey: 'createdAt',
@@ -93,8 +127,10 @@ export const DocumentsTable = ({
</div>
),
},
] satisfies DataTableColumnDef<DocumentsTableRow>[];
}, [team, onMoveDocument]);
);
return cols;
}, [team, onMoveDocument, enableSelection]);
const onPaginationChange = (page: number, perPage: number) => {
startTransition(() => {
@@ -132,6 +168,11 @@ export const DocumentsTable = ({
rows: 5,
component: (
<>
{enableSelection && (
<TableCell>
<Skeleton className="h-4 w-4 rounded" />
</TableCell>
)}
<TableCell>
<Skeleton className="h-4 w-40 rounded-full" />
</TableCell>
@@ -152,13 +193,17 @@ export const DocumentsTable = ({
</>
),
}}
enableRowSelection={enableSelection}
rowSelection={rowSelection}
onRowSelectionChange={onRowSelectionChange}
getRowId={(row) => row.envelopeId}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>
{isPending && (
<div className="bg-background/50 absolute inset-0 flex items-center justify-center">
<Loader className="text-muted-foreground h-8 w-8 animate-spin" />
<div className="absolute inset-0 flex items-center justify-center bg-background/50">
<Loader className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)}
</div>
@@ -0,0 +1,55 @@
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { FolderInputIcon, Trash2Icon, XIcon } from 'lucide-react';
import { Button } from '@documenso/ui/primitives/button';
export type EnvelopesTableBulkActionBarProps = {
selectedCount: number;
onMoveClick: () => void;
onDeleteClick: () => void;
onClearSelection: () => void;
};
export const EnvelopesTableBulkActionBar = ({
selectedCount,
onMoveClick,
onDeleteClick,
onClearSelection,
}: EnvelopesTableBulkActionBarProps) => {
const { t } = useLingui();
if (selectedCount === 0) {
return null;
}
return (
<div className="fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-x-4 rounded-lg border border-border bg-widget px-4 py-3 shadow-lg">
<span className="text-sm font-medium">
<Trans>{selectedCount} selected</Trans>
</span>
<div className="h-6 w-px bg-border" />
<Button type="button" variant="outline" size="sm" onClick={onMoveClick}>
<FolderInputIcon className="mr-2 h-4 w-4" />
<Trans>Move to Folder</Trans>
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={onDeleteClick}
className="text-destructive hover:text-destructive"
>
<Trash2Icon className="mr-2 h-4 w-4" />
<Trans>Delete</Trans>
</Button>
<Button variant="ghost" size="sm" onClick={onClearSelection} aria-label={t`Clear selection`}>
<XIcon className="h-4 w-4" />
</Button>
</div>
);
};
@@ -12,7 +12,8 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import type { TFindTemplatesResponse } from '@documenso/trpc/server/template-router/schema';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import type { DataTableColumnDef, RowSelectionState } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
@@ -32,6 +33,9 @@ type TemplatesTableProps = {
isLoadingError?: boolean;
documentRootPath: string;
templateRootPath: string;
enableSelection?: boolean;
rowSelection?: RowSelectionState;
onRowSelectionChange?: (selection: RowSelectionState) => void;
};
type TemplatesTableRow = TFindTemplatesResponse['data'][number];
@@ -42,6 +46,9 @@ export const TemplatesTable = ({
isLoadingError,
documentRootPath,
templateRootPath,
enableSelection,
rowSelection,
onRowSelectionChange,
}: TemplatesTableProps) => {
const { _, i18n } = useLingui();
const { remaining } = useLimits();
@@ -60,7 +67,34 @@ export const TemplatesTable = ({
};
const columns = useMemo(() => {
return [
const cols: DataTableColumnDef<TemplatesTableRow>[] = [];
if (enableSelection) {
cols.push({
id: 'select',
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label={_(msg`Select all`)}
onClick={(e) => e.stopPropagation()}
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label={_(msg`Select row`)}
onClick={(e) => e.stopPropagation()}
/>
),
enableSorting: false,
enableHiding: false,
size: 40,
});
}
cols.push(
{
header: _(msg`Created`),
accessorKey: 'createdAt',
@@ -86,8 +120,8 @@ export const TemplatesTable = ({
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 !p-0">
<ul className="text-muted-foreground space-y-0.5 divide-y [&>li]:p-4">
<TooltipContent className="max-w-md space-y-2 !p-0 text-foreground">
<ul className="space-y-0.5 divide-y text-muted-foreground [&>li]:p-4">
<li>
<h2 className="mb-2 flex flex-row items-center font-semibold">
<Globe2Icon className="mr-2 h-5 w-5 text-green-500 dark:text-green-300" />
@@ -176,8 +210,10 @@ export const TemplatesTable = ({
);
},
},
] satisfies DataTableColumnDef<TemplatesTableRow>[];
}, [documentRootPath, team?.id, templateRootPath]);
);
return cols;
}, [documentRootPath, team?.id, templateRootPath, enableSelection]);
const onPaginationChange = (page: number, perPage: number) => {
startTransition(() => {
@@ -224,6 +260,10 @@ export const TemplatesTable = ({
currentPage={results.currentPage}
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
enableRowSelection={enableSelection}
rowSelection={rowSelection}
onRowSelectionChange={onRowSelectionChange}
getRowId={(row) => row.envelopeId}
error={{
enable: isLoadingError || false,
}}
@@ -232,6 +272,11 @@ export const TemplatesTable = ({
rows: 5,
component: (
<>
{enableSelection && (
<TableCell className="w-10">
<Skeleton className="h-4 w-4 rounded" />
</TableCell>
)}
<TableCell>
<Skeleton className="h-4 w-40 rounded-full" />
</TableCell>
+12 -3
View File
@@ -7,7 +7,6 @@ import {
data,
isRouteErrorResponse,
useLoaderData,
useLocation,
} from 'react-router';
import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from 'remix-themes';
@@ -87,8 +86,6 @@ export async function loader({ request }: Route.LoaderArgs) {
export function Layout({ children }: { children: React.ReactNode }) {
const { theme } = useLoaderData<typeof loader>() || {};
const location = useLocation();
return (
<ThemeProvider specifiedTheme={theme} themeAction="/api/theme">
<LayoutContent>{children}</LayoutContent>
@@ -129,6 +126,18 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
<script>0</script>
</head>
<body>
{/* Global license banner currently disabled. Need to wait until after a few releases. */}
{/* {licenseStatus === '?' && (
<div className="bg-destructive text-destructive-foreground">
<div className="mx-auto flex h-auto max-w-screen-xl items-center justify-center px-4 py-3 text-sm font-medium">
<div className="flex items-center">
<AlertTriangleIcon className="mr-2 h-4 w-4" />
<Trans>This is an expired license instance of Documenso</Trans>
</div>
</div>
</div>
)} */}
<SessionProvider initialSession={session}>
<TooltipProvider>
<TrpcProvider>
@@ -11,25 +11,37 @@ import {
import { Link, Outlet, redirect, useLocation } from 'react-router';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { LicenseClient } from '@documenso/lib/server-only/license/license-client';
import { isAdmin } from '@documenso/lib/utils/is-admin';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { AdminLicenseStatusBanner } from '~/components/general/admin-license-status-banner';
import type { Route } from './+types/_layout';
export async function loader({ request }: Route.LoaderArgs) {
const { user } = await getSession(request);
const license = await LicenseClient.getInstance()?.getCachedLicense();
if (!user || !isAdmin(user)) {
throw redirect('/');
}
return {
license: license || null,
};
}
export default function AdminLayout() {
export default function AdminLayout({ loaderData }: Route.ComponentProps) {
const { license } = loaderData;
const { pathname } = useLocation();
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<AdminLicenseStatusBanner license={license} />
<h1 className="text-4xl font-semibold">
<Trans>Admin Panel</Trans>
</h1>
@@ -4,13 +4,26 @@ import { useLingui } from '@lingui/react/macro';
import { useLocation, useSearchParams } from 'react-router';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { LicenseClient } from '@documenso/lib/server-only/license/license-client';
import { Input } from '@documenso/ui/primitives/input';
import { ClaimCreateDialog } from '~/components/dialogs/claim-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
import { AdminClaimsTable } from '~/components/tables/admin-claims-table';
export default function Claims() {
import type { Route } from './+types/claims';
export async function loader() {
const licenseData = await LicenseClient.getInstance()?.getCachedLicense();
return {
licenseFlags: licenseData?.license?.flags,
};
}
export default function Claims({ loaderData }: Route.ComponentProps) {
const { licenseFlags } = loaderData;
const { t } = useLingui();
const [searchParams, setSearchParams] = useSearchParams();
@@ -47,7 +60,7 @@ export default function Claims() {
subtitle={t`Manage all subscription claims`}
hideDivider
>
<ClaimCreateDialog />
<ClaimCreateDialog licenseFlags={licenseFlags} />
</SettingsHeader>
<div className="mt-4">
@@ -58,7 +71,7 @@ export default function Claims() {
className="mb-4"
/>
<AdminClaimsTable />
<AdminClaimsTable licenseFlags={licenseFlags} />
</div>
</div>
);
@@ -12,6 +12,8 @@ import type { z } from 'zod';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { SUBSCRIPTION_STATUS_MAP } from '@documenso/lib/constants/billing';
import { AppError } from '@documenso/lib/errors/app-error';
import { LicenseClient } from '@documenso/lib/server-only/license/license-client';
import type { TLicenseClaim } from '@documenso/lib/types/license';
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
import { trpc } from '@documenso/trpc/react';
import type { TGetAdminOrganisationResponse } from '@documenso/trpc/server/admin-router/get-admin-organisation.types';
@@ -40,7 +42,20 @@ import { SettingsHeader } from '~/components/general/settings-header';
import type { Route } from './+types/organisations.$id';
export default function OrganisationGroupSettingsPage({ params }: Route.ComponentProps) {
export async function loader() {
const licenseData = await LicenseClient.getInstance()?.getCachedLicense();
return {
licenseFlags: licenseData?.license?.flags,
};
}
export default function OrganisationGroupSettingsPage({
params,
loaderData,
}: Route.ComponentProps) {
const { licenseFlags } = loaderData;
const { t } = useLingui();
const { toast } = useToast();
@@ -129,7 +144,7 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
if (isLoadingOrganisation) {
return (
<div className="flex items-center justify-center rounded-lg py-32">
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
@@ -239,7 +254,7 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
)}
</Alert>
<OrganisationAdminForm organisation={organisation} />
<OrganisationAdminForm organisation={organisation} licenseFlags={licenseFlags} />
<div className="mt-16 space-y-10">
<div>
@@ -278,6 +293,7 @@ type TUpdateGenericOrganisationDataFormSchema = z.infer<
type OrganisationAdminFormOptions = {
organisation: TGetAdminOrganisationResponse;
licenseFlags?: TLicenseClaim;
};
const GenericOrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) => {
@@ -349,7 +365,7 @@ const GenericOrganisationAdminForm = ({ organisation }: OrganisationAdminFormOpt
<Input {...field} />
</FormControl>
{!form.formState.errors.url && (
<span className="text-foreground/50 text-xs font-normal">
<span className="text-xs font-normal text-foreground/50">
{field.value ? (
`${NEXT_PUBLIC_WEBAPP_URL()}/o/${field.value}`
) : (
@@ -381,12 +397,17 @@ const ZUpdateOrganisationBillingFormSchema = ZUpdateAdminOrganisationRequestSche
type TUpdateOrganisationBillingFormSchema = z.infer<typeof ZUpdateOrganisationBillingFormSchema>;
const OrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) => {
const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdminFormOptions) => {
const { toast } = useToast();
const { t } = useLingui();
const { mutateAsync: updateOrganisation } = trpc.admin.organisation.update.useMutation();
const hasRestrictedEnterpriseFeatures = Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).some(
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
(flag) => flag.isEnterprise && !licenseFlags?.[flag.key as keyof TLicenseClaim],
);
const form = useForm<TUpdateOrganisationBillingFormSchema>({
resolver: zodResolver(ZUpdateOrganisationBillingFormSchema),
defaultValues: {
@@ -440,7 +461,7 @@ const OrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) =
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<h2>
<strong>
<Trans>Inherited subscription claim</Trans>
@@ -493,7 +514,7 @@ const OrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) =
<Link
target="_blank"
to={`https://dashboard.stripe.com/customers/${field.value}`}
className="text-foreground/50 text-xs font-normal"
className="text-xs font-normal text-foreground/50"
>
{`https://dashboard.stripe.com/customers/${field.value}`}
</Link>
@@ -582,34 +603,57 @@ const OrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) =
</FormLabel>
<div className="mt-2 space-y-2 rounded-md border p-4">
{Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(({ key, label }) => (
<FormField
key={key}
control={form.control}
name={`claims.flags.${key}`}
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormControl>
<div className="flex items-center">
<Checkbox
id={`flag-${key}`}
checked={field.value}
onCheckedChange={field.onChange}
/>
{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
<label
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
htmlFor={`flag-${key}`}
>
{label}
</label>
</div>
</FormControl>
</FormItem>
)}
/>
))}
return (
<FormField
key={key}
control={form.control}
name={`claims.flags.${key}`}
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormControl>
<div className="flex items-center">
<Checkbox
id={`flag-${key}`}
checked={field.value}
onCheckedChange={field.onChange}
disabled={isRestrictedFeature && !field.value} // Allow disabling of restricted features.
/>
<label
className="ml-2 flex flex-row items-center text-sm text-muted-foreground"
htmlFor={`flag-${key}`}
>
{label}
{isRestrictedFeature && ' ¹'}
</label>
</div>
</FormControl>
</FormItem>
)}
/>
);
})}
</div>
{hasRestrictedEnterpriseFeatures && (
<Alert variant="neutral" className="mt-4">
<AlertDescription>
<span>¹&nbsp;</span>
<Trans>Your current license does not include these features.</Trans>{' '}
<Link
to="https://docs.documenso.com/users/licenses/enterprise-edition"
target="_blank"
className="text-foreground underline hover:opacity-80"
>
<Trans>Learn more</Trans>
</Link>
</AlertDescription>
</Alert>
)}
</div>
<div className="flex justify-end">
@@ -23,8 +23,10 @@ import {
getUserWithSignedDocumentMonthlyGrowth,
getUsersCount,
} from '@documenso/lib/server-only/admin/get-users-stats';
import { LicenseClient } from '@documenso/lib/server-only/license/license-client';
import { getSignerConversionMonthly } from '@documenso/lib/server-only/user/get-signer-conversion';
import { AdminLicenseCard } from '~/components/general/admin-license-card';
import { MonthlyActiveUsersChart } from '~/components/general/admin-monthly-active-user-charts';
import { AdminStatsSignerConversionChart } from '~/components/general/admin-stats-signer-conversion-chart';
import { AdminStatsUsersWithDocumentsChart } from '~/components/general/admin-stats-users-with-documents';
@@ -42,6 +44,7 @@ export async function loader() {
signerConversionMonthly,
monthlyUsersWithDocuments,
monthlyActiveUsers,
licenseData,
] = await Promise.all([
getUsersCount(),
getOrganisationsWithSubscriptionsCount(),
@@ -50,6 +53,7 @@ export async function loader() {
getSignerConversionMonthly(),
getUserWithSignedDocumentMonthlyGrowth(),
getMonthlyActiveUsers(),
LicenseClient.getInstance()?.getCachedLicense(),
]);
return {
@@ -60,6 +64,7 @@ export async function loader() {
signerConversionMonthly,
monthlyUsersWithDocuments,
monthlyActiveUsers,
licenseData: licenseData || null,
};
}
@@ -74,6 +79,7 @@ export default function AdminStatsPage({ loaderData }: Route.ComponentProps) {
signerConversionMonthly,
monthlyUsersWithDocuments,
monthlyActiveUsers,
licenseData,
} = loaderData;
return (
@@ -94,6 +100,10 @@ export default function AdminStatsPage({ loaderData }: Route.ComponentProps) {
<CardMetric icon={FileCog} title={_(msg`App Version`)} value={`v${version}`} />
</div>
<div className="mb-8 mt-4">
<AdminLicenseCard licenseData={licenseData} />
</div>
<div className="mt-16 gap-8">
<div>
<h3 className="text-3xl font-semibold">
@@ -179,7 +179,9 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps)
))}
<div className="text-sm text-foreground">
<h3 className="font-semibold">Recipients</h3>
<h3 className="font-semibold">
<Trans>Recipients</Trans>
</h3>
<ul className="list-inside list-disc text-muted-foreground">
{recipients.map((recipient) => (
<li key={`recipient-${recipient.id}`}>
@@ -16,9 +16,12 @@ import { trpc } from '@documenso/trpc/react';
import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/find-documents-internal.types';
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 { DocumentMoveToFolderDialog } from '~/components/dialogs/document-move-to-folder-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 { EnvelopeDropZoneWrapper } from '~/components/general/envelope/envelope-drop-zone-wrapper';
@@ -27,6 +30,7 @@ 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 { EnvelopesTableBulkActionBar } from '~/components/tables/envelopes-table-bulk-action-bar';
import { useCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
@@ -54,6 +58,14 @@ export default function DocumentsPage() {
const [isMovingDocument, setIsMovingDocument] = useState(false);
const [documentToMove, setDocumentToMove] = useState<number | null>(null);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const selectedEnvelopeIds = useMemo(() => {
return Object.keys(rowSelection).filter((id) => rowSelection[id]);
}, [rowSelection]);
const [stats, setStats] = useState<TFindDocumentsInternalResponse['stats']>({
[ExtendedDocumentStatus.DRAFT]: 0,
[ExtendedDocumentStatus.PENDING]: 0,
@@ -109,6 +121,11 @@ export default function DocumentsPage() {
}
}, [data?.stats]);
// Clear selection when navigation or filters change
useEffect(() => {
setRowSelection({});
}, [folderId, findDocumentSearchParams]);
return (
<EnvelopeDropZoneWrapper type={EnvelopeType.DOCUMENT}>
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
@@ -116,9 +133,9 @@ export default function DocumentsPage() {
<div className="mt-8 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
<div className="flex flex-row items-center">
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
<Avatar className="mr-3 h-12 w-12 border-2 border-solid border-white dark:border-border">
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
<AvatarFallback className="text-muted-foreground text-xs">
<AvatarFallback className="text-xs text-muted-foreground">
{team.name.slice(0, 1)}
</AvatarFallback>
</Avatar>
@@ -148,7 +165,7 @@ export default function DocumentsPage() {
.map((value) => (
<TabsTrigger
key={value}
className="hover:text-foreground min-w-[60px]"
className="min-w-[60px] hover:text-foreground"
value={value}
asChild
>
@@ -190,6 +207,9 @@ export default function DocumentsPage() {
setDocumentToMove(documentId);
setIsMovingDocument(true);
}}
enableSelection
rowSelection={rowSelection}
onRowSelectionChange={setRowSelection}
/>
)}
</div>
@@ -209,6 +229,30 @@ export default function DocumentsPage() {
}}
/>
)}
<EnvelopesTableBulkActionBar
selectedCount={selectedEnvelopeIds.length}
onMoveClick={() => setIsBulkMoveDialogOpen(true)}
onDeleteClick={() => setIsBulkDeleteDialogOpen(true)}
onClearSelection={() => setRowSelection({})}
/>
<EnvelopesBulkMoveDialog
envelopeIds={selectedEnvelopeIds}
envelopeType={EnvelopeType.DOCUMENT}
open={isBulkMoveDialogOpen}
currentFolderId={folderId}
onOpenChange={setIsBulkMoveDialogOpen}
onSuccess={() => setRowSelection({})}
/>
<EnvelopesBulkDeleteDialog
envelopeIds={selectedEnvelopeIds}
envelopeType={EnvelopeType.DOCUMENT}
open={isBulkDeleteDialogOpen}
onOpenChange={setIsBulkDeleteDialogOpen}
onSuccess={() => setRowSelection({})}
/>
</div>
</EnvelopeDropZoneWrapper>
);
@@ -1,3 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { Bird } from 'lucide-react';
@@ -8,9 +10,13 @@ import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import type { RowSelectionState } from '@documenso/ui/primitives/data-table';
import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog';
import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog';
import { EnvelopeDropZoneWrapper } from '~/components/general/envelope/envelope-drop-zone-wrapper';
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 { useCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
@@ -28,6 +34,14 @@ export default function TemplatesPage() {
const page = Number(searchParams.get('page')) || 1;
const perPage = Number(searchParams.get('perPage')) || 10;
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const selectedEnvelopeIds = useMemo(() => {
return Object.keys(rowSelection).filter((id) => rowSelection[id]);
}, [rowSelection]);
const documentRootPath = formatDocumentsPath(team.url);
const templateRootPath = formatTemplatesPath(team.url);
@@ -37,6 +51,11 @@ export default function TemplatesPage() {
folderId,
});
// Clear selection when navigation or filters change
useEffect(() => {
setRowSelection({});
}, [folderId, page, perPage]);
return (
<EnvelopeDropZoneWrapper type={EnvelopeType.TEMPLATE}>
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
@@ -44,9 +63,9 @@ export default function TemplatesPage() {
<div className="mt-8">
<div className="flex flex-row items-center">
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
<Avatar className="mr-3 h-12 w-12 border-2 border-solid border-white dark:border-border">
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
<AvatarFallback className="text-muted-foreground text-xs">
<AvatarFallback className="text-xs text-muted-foreground">
{team.name.slice(0, 1)}
</AvatarFallback>
</Avatar>
@@ -58,7 +77,7 @@ export default function TemplatesPage() {
<div className="mt-8">
{data && data.count === 0 ? (
<div className="text-muted-foreground/60 flex h-96 flex-col items-center justify-center gap-y-4">
<div className="flex h-96 flex-col items-center justify-center gap-y-4 text-muted-foreground/60">
<Bird className="h-12 w-12" strokeWidth={1.5} />
<div className="text-center">
@@ -81,10 +100,37 @@ export default function TemplatesPage() {
isLoadingError={isLoadingError}
documentRootPath={documentRootPath}
templateRootPath={templateRootPath}
enableSelection
rowSelection={rowSelection}
onRowSelectionChange={setRowSelection}
/>
)}
</div>
</div>
<EnvelopesTableBulkActionBar
selectedCount={selectedEnvelopeIds.length}
onMoveClick={() => setIsBulkMoveDialogOpen(true)}
onDeleteClick={() => setIsBulkDeleteDialogOpen(true)}
onClearSelection={() => setRowSelection({})}
/>
<EnvelopesBulkMoveDialog
envelopeIds={selectedEnvelopeIds}
envelopeType={EnvelopeType.TEMPLATE}
open={isBulkMoveDialogOpen}
currentFolderId={folderId}
onOpenChange={setIsBulkMoveDialogOpen}
onSuccess={() => setRowSelection({})}
/>
<EnvelopesBulkDeleteDialog
envelopeIds={selectedEnvelopeIds}
envelopeType={EnvelopeType.TEMPLATE}
open={isBulkDeleteDialogOpen}
onOpenChange={setIsBulkDeleteDialogOpen}
onSuccess={() => setRowSelection({})}
/>
</div>
</EnvelopeDropZoneWrapper>
);
@@ -1,5 +1,6 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType, FieldType, SigningStatus } from '@prisma/client';
import { DateTime } from 'luxon';
import { redirect } from 'react-router';
@@ -290,7 +291,9 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
</p>
</>
) : (
<p className="text-muted-foreground">N/A</p>
<p className="text-muted-foreground">
<Trans>N/A</Trans>
</p>
)}
<p className="mt-2 text-sm text-muted-foreground print:text-xs">
+1 -1
View File
@@ -106,5 +106,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "2.5.0"
"version": "2.6.0"
}
+1 -1
View File
@@ -17,7 +17,7 @@ server.use(
serveStatic({
root: 'build/client',
onFound: (path, c) => {
if (path.startsWith('./build/client/assets')) {
if (path.startsWith('build/client/assets')) {
// Hard cache assets with hashed file names.
c.header('Cache-Control', 'public, immutable, max-age=31536000');
} else {
+4
View File
@@ -10,6 +10,7 @@ import { tsRestHonoApp } from '@documenso/api/hono';
import { auth } from '@documenso/auth/server';
import { API_V2_BETA_URL, API_V2_URL } from '@documenso/lib/constants/app';
import { jobsClient } from '@documenso/lib/jobs/client';
import { LicenseClient } from '@documenso/lib/server-only/license/license-client';
import { TelemetryClient } from '@documenso/lib/server-only/telemetry/telemetry-client';
import { getIpAddress } from '@documenso/lib/universal/get-ip-address';
import { env } from '@documenso/lib/utils/env';
@@ -140,4 +141,7 @@ if (env('NODE_ENV') !== 'development') {
void TelemetryClient.start();
}
// Start license client to verify license on startup.
void LicenseClient.start();
export default app;