fix: merge conflicts

This commit is contained in:
Ephraim Atta-Duncan
2025-06-19 15:16:16 +00:00
750 changed files with 46465 additions and 29408 deletions

View File

@ -0,0 +1,197 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import type { z } from 'zod';
import { AppError } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { ZCreateAdminOrganisationRequestSchema } from '@documenso/trpc/server/admin-router/create-admin-organisation.types';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} 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 OrganisationCreateDialogProps = {
trigger?: React.ReactNode;
ownerUserId: number;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZCreateAdminOrganisationFormSchema = ZCreateAdminOrganisationRequestSchema.shape.data.pick({
name: true,
});
type TCreateOrganisationFormSchema = z.infer<typeof ZCreateAdminOrganisationFormSchema>;
export const AdminOrganisationCreateDialog = ({
trigger,
ownerUserId,
...props
}: OrganisationCreateDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const navigate = useNavigate();
const form = useForm({
resolver: zodResolver(ZCreateAdminOrganisationFormSchema),
defaultValues: {
name: '',
},
});
const { mutateAsync: createOrganisation } = trpc.admin.organisation.create.useMutation();
const onFormSubmit = async ({ name }: TCreateOrganisationFormSchema) => {
try {
const { organisationId } = await createOrganisation({
ownerUserId,
data: {
name,
},
});
await navigate(`/admin/organisations/${organisationId}`);
setOpen(false);
toast({
title: t`Success`,
description: t`Organisation created`,
duration: 5000,
});
} catch (err) {
const error = AppError.parseError(err);
console.error(error);
toast({
title: t`An unknown error occurred`,
description: t`We encountered an unknown error while attempting to create a organisation. Please try again later.`,
variant: 'destructive',
});
}
};
useEffect(() => {
form.reset();
}, [open, form]);
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? (
<Button className="flex-shrink-0" variant="secondary">
<Trans>Create organisation</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Create organisation</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Create an organisation for this user</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Organisation Name</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Alert variant="neutral">
<AlertDescription className="mt-0">
<Trans>
You will need to configure any claims or subscription after creating this
organisation
</Trans>
</AlertDescription>
</Alert>
{/* <FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Default claim ID</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
<Trans>Leave blank to use the default free claim</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/> */}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
data-testid="dialog-create-organisation-button"
loading={form.formState.isSubmitting}
>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,90 @@
import { useState } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import type { z } from 'zod';
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';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { SubscriptionClaimForm } from '../forms/subscription-claim-form';
export type CreateClaimFormValues = z.infer<typeof ZCreateSubscriptionClaimRequestSchema>;
export const ClaimCreateDialog = () => {
const { t } = useLingui();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const { mutateAsync: createClaim, isPending } = trpc.admin.claims.create.useMutation({
onSuccess: () => {
toast({
title: t`Subscription claim created successfully.`,
});
setOpen(false);
},
onError: () => {
toast({
title: t`Failed to create subscription claim.`,
variant: 'destructive',
});
},
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
<Button className="flex-shrink-0" variant="secondary">
<Trans>Create claim</Trans>
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
<Trans>Create Subscription Claim</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Fill in the details to create a new subscription claim.</Trans>
</DialogDescription>
</DialogHeader>
<SubscriptionClaimForm
subscriptionClaim={{
...generateDefaultSubscriptionClaim(),
}}
onFormSubmit={createClaim}
formSubmitTrigger={
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={isPending}
>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={isPending}>
<Trans>Create Claim</Trans>
</Button>
</DialogFooter>
}
/>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,96 @@
import { useState } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
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,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type ClaimDeleteDialogProps = {
claimId: string;
claimName: string;
claimLocked: boolean;
trigger: React.ReactNode;
};
export const ClaimDeleteDialog = ({
claimId,
claimName,
claimLocked,
trigger,
}: ClaimDeleteDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const { mutateAsync: deleteClaim, isPending } = trpc.admin.claims.delete.useMutation({
onSuccess: () => {
toast({
title: t`Subscription claim deleted successfully.`,
});
setOpen(false);
},
onError: (err) => {
console.error(err);
toast({
title: t`Failed to delete subscription claim.`,
variant: 'destructive',
});
},
});
return (
<Dialog open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
<DialogTrigger asChild onClick={(e) => e.stopPropagation()}>
{trigger}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Delete Subscription Claim</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Are you sure you want to delete the following claim?</Trans>
</DialogDescription>
</DialogHeader>
<Alert variant="neutral">
<AlertDescription className="text-center font-semibold">
{claimLocked ? <Trans>This claim is locked and cannot be deleted.</Trans> : claimName}
</AlertDescription>
</Alert>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
{!claimLocked && (
<Button
type="submit"
variant="destructive"
loading={isPending}
onClick={async () => deleteClaim({ id: claimId })}
>
<Trans>Delete</Trans>
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,92 @@
import { useState } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
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';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { SubscriptionClaimForm } from '../forms/subscription-claim-form';
export type ClaimUpdateDialogProps = {
claim: TFindSubscriptionClaimsResponse['data'][number];
trigger: React.ReactNode;
};
export const ClaimUpdateDialog = ({ claim, trigger }: ClaimUpdateDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const { mutateAsync: updateClaim, isPending } = trpc.admin.claims.update.useMutation({
onSuccess: () => {
toast({
title: t`Subscription claim updated successfully.`,
});
setOpen(false);
},
onError: () => {
toast({
title: t`Failed to update subscription claim.`,
variant: 'destructive',
});
},
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild onClick={(e) => e.stopPropagation()}>
{trigger}
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
<Trans>Update Subscription Claim</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Modify the details of the subscription claim.</Trans>
</DialogDescription>
</DialogHeader>
<SubscriptionClaimForm
subscriptionClaim={claim}
onFormSubmit={async (data) =>
await updateClaim({
id: claim.id,
data,
})
}
formSubmitTrigger={
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={isPending}
>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={isPending}>
<Trans>Update Claim</Trans>
</Button>
</DialogFooter>
}
/>
</DialogContent>
</Dialog>
);
};

View File

@ -28,7 +28,6 @@ type DocumentDeleteDialogProps = {
onDelete?: () => Promise<void> | void;
status: DocumentStatus;
documentTitle: string;
teamId?: number;
canManageDocument: boolean;
};

View File

@ -16,7 +16,7 @@ import {
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
import { useCurrentTeam } from '~/providers/team';
type DocumentDuplicateDialogProps = {
id: number;
@ -34,7 +34,7 @@ export const DocumentDuplicateDialog = ({
const { toast } = useToast();
const { _ } = useLingui();
const team = useOptionalCurrentTeam();
const team = useCurrentTeam();
const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery(
{
@ -52,7 +52,7 @@ export const DocumentDuplicateDialog = ({
}
: undefined;
const documentsPath = formatDocumentsPath(team?.url);
const documentsPath = formatDocumentsPath(team.url);
const { mutateAsync: duplicateDocument, isPending: isDuplicateLoading } =
trpcReact.document.duplicateDocument.useMutation({

View File

@ -1,124 +0,0 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { trpc } from '@documenso/trpc/react';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
type DocumentMoveDialogProps = {
documentId: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const DocumentMoveDialog = ({ documentId, open, onOpenChange }: DocumentMoveDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const [selectedTeamId, setSelectedTeamId] = useState<number | null>(null);
const { data: teams, isPending: isLoadingTeams } = trpc.team.getTeams.useQuery();
const { mutateAsync: moveDocument, isPending } = trpc.document.moveDocumentToTeam.useMutation({
onSuccess: () => {
toast({
title: _(msg`Document moved`),
description: _(msg`The document has been successfully moved to the selected team.`),
duration: 5000,
});
onOpenChange(false);
},
onError: (error) => {
toast({
title: _(msg`Error`),
description: error.message || _(msg`An error occurred while moving the document.`),
variant: 'destructive',
duration: 7500,
});
},
});
const onMove = async () => {
if (!selectedTeamId) {
return;
}
await moveDocument({ documentId, teamId: selectedTeamId });
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Move Document to Team</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Select a team to move this document to. This action cannot be undone.</Trans>
</DialogDescription>
</DialogHeader>
<Select onValueChange={(value) => setSelectedTeamId(Number(value))}>
<SelectTrigger>
<SelectValue placeholder={_(msg`Select a team`)} />
</SelectTrigger>
<SelectContent>
{isLoadingTeams ? (
<SelectItem value="loading" disabled>
<Trans>Loading teams...</Trans>
</SelectItem>
) : (
teams?.map((team) => (
<SelectItem key={team.id} value={team.id.toString()}>
<div className="flex items-center gap-4">
<Avatar className="h-8 w-8">
{team.avatarImageId && (
<AvatarImage src={formatAvatarUrl(team.avatarImageId)} />
)}
<AvatarFallback className="text-sm text-gray-400">
{team.name.slice(0, 1).toUpperCase()}
</AvatarFallback>
</Avatar>
<span>{team.name}</span>
</div>
</SelectItem>
))
)}
</SelectContent>
</Select>
<DialogFooter>
<Button variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button onClick={onMove} loading={isPending} disabled={!selectedTeamId || isPending}>
{isPending ? <Trans>Moving...</Trans> : <Trans>Move</Trans>}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -1,11 +1,11 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderIcon, HomeIcon, Loader2 } from 'lucide-react';
import { FolderIcon, HomeIcon, Loader2, Search } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
@ -31,9 +31,10 @@ import {
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
import { useCurrentTeam } from '~/providers/team';
export type DocumentMoveToFolderDialogProps = {
documentId: number;
@ -57,8 +58,11 @@ export const DocumentMoveToFolderDialog = ({
}: DocumentMoveToFolderDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const team = useOptionalCurrentTeam();
const team = useCurrentTeam();
const [searchTerm, setSearchTerm] = useState('');
const form = useForm<TMoveDocumentFormSchema>({
resolver: zodResolver(ZMoveDocumentFormSchema),
@ -82,6 +86,7 @@ export const DocumentMoveToFolderDialog = ({
useEffect(() => {
if (!open) {
form.reset();
setSearchTerm('');
} else {
form.reset({ folderId: currentFolderId });
}
@ -94,6 +99,14 @@ export const DocumentMoveToFolderDialog = ({
folderId: data.folderId ?? null,
});
const documentsPath = formatDocumentsPath(team.url);
if (data.folderId) {
await navigate(`${documentsPath}/f/${data.folderId}`);
} else {
await navigate(documentsPath);
}
toast({
title: _(msg`Document moved`),
description: _(msg`The document has been moved successfully.`),
@ -101,14 +114,6 @@ export const DocumentMoveToFolderDialog = ({
});
onOpenChange(false);
const documentsPath = formatDocumentsPath(team?.url);
if (data.folderId) {
void navigate(`${documentsPath}/f/${data.folderId}`);
} else {
void navigate(documentsPath);
}
} catch (err) {
const error = AppError.parseError(err);
@ -130,6 +135,10 @@ export const DocumentMoveToFolderDialog = ({
}
};
const filteredFolders = folders?.data.filter((folder) =>
folder.name.toLowerCase().includes(searchTerm.toLowerCase()),
);
return (
<Dialog {...props} open={open} onOpenChange={onOpenChange}>
<DialogContent>
@ -143,8 +152,18 @@ export const DocumentMoveToFolderDialog = ({
</DialogDescription>
</DialogHeader>
<div className="relative">
<Search className="text-muted-foreground absolute left-2 top-3 h-4 w-4" />
<Input
placeholder={_(msg`Search folders...`)}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8"
/>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-4 flex flex-col gap-y-4">
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
<FormField
control={form.control}
name="folderId"
@ -153,8 +172,9 @@ export const DocumentMoveToFolderDialog = ({
<FormLabel>
<Trans>Folder</Trans>
</FormLabel>
<FormControl>
<div className="space-y-2">
<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" />
@ -169,10 +189,10 @@ export const DocumentMoveToFolderDialog = ({
disabled={currentFolderId === null}
>
<HomeIcon className="mr-2 h-4 w-4" />
<Trans>Root (No Folder)</Trans>
<Trans>Home (No Folder)</Trans>
</Button>
{folders?.data.map((folder) => (
{filteredFolders?.map((folder) => (
<Button
key={folder.id}
type="button"
@ -185,6 +205,12 @@ export const DocumentMoveToFolderDialog = ({
{folder.name}
</Button>
))}
{searchTerm && filteredFolders?.length === 0 && (
<div className="text-muted-foreground px-2 py-2 text-center text-sm">
<Trans>No folders found</Trans>
</div>
)}
</>
)}
</div>

View File

@ -36,7 +36,7 @@ import {
} from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
import { useCurrentTeam } from '~/providers/team';
import { StackAvatar } from '../general/stack-avatar';
@ -57,7 +57,7 @@ export type TResendDocumentFormSchema = z.infer<typeof ZResendDocumentFormSchema
export const DocumentResendDialog = ({ document, recipients }: DocumentResendDialogProps) => {
const { user } = useSession();
const team = useOptionalCurrentTeam();
const team = useCurrentTeam();
const { toast } = useToast();
const { _ } = useLingui();

View File

@ -1,17 +1,14 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans, useLingui } from '@lingui/react/macro';
import type { FolderType } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderPlusIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useNavigate, useParams } from 'react-router';
import { useParams } from 'react-router';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { FolderType } from '@documenso/lib/types/folder-type';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
@ -34,26 +31,22 @@ import {
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
const ZCreateFolderFormSchema = z.object({
name: z.string().min(1, { message: 'Folder name is required' }),
});
type TCreateFolderFormSchema = z.infer<typeof ZCreateFolderFormSchema>;
export type CreateFolderDialogProps = {
export type FolderCreateDialogProps = {
type: FolderType;
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const CreateFolderDialog = ({ trigger, ...props }: CreateFolderDialogProps) => {
const { _ } = useLingui();
export const FolderCreateDialog = ({ type, trigger, ...props }: FolderCreateDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const { folderId } = useParams();
const navigate = useNavigate();
const team = useOptionalCurrentTeam();
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
const { mutateAsync: createFolder } = trpc.folder.createFolder.useMutation();
@ -67,37 +60,21 @@ export const CreateFolderDialog = ({ trigger, ...props }: CreateFolderDialogProp
const onSubmit = async (data: TCreateFolderFormSchema) => {
try {
const newFolder = await createFolder({
await createFolder({
name: data.name,
parentId: folderId,
type: FolderType.DOCUMENT,
type,
});
setIsCreateFolderOpen(false);
toast({
description: 'Folder created successfully',
description: t`Folder created successfully`,
});
const documentsPath = formatDocumentsPath(team?.url);
void navigate(`${documentsPath}/f/${newFolder.id}`);
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.ALREADY_EXISTS) {
toast({
title: 'Failed to create folder',
description: _(msg`This folder name is already taken.`),
variant: 'destructive',
});
return;
}
toast({
title: 'Failed to create folder',
description: _(msg`An unknown error occurred while creating the folder.`),
title: t`Failed to create folder`,
description: t`An unknown error occurred while creating the folder.`,
variant: 'destructive',
});
}
@ -113,48 +90,60 @@ export const CreateFolderDialog = ({ trigger, ...props }: CreateFolderDialogProp
<Dialog {...props} open={isCreateFolderOpen} onOpenChange={setIsCreateFolderOpen}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="outline" className="flex items-center space-x-2">
<FolderPlusIcon className="h-4 w-4" />
<span>Create Folder</span>
<Button
variant="outline"
className="flex items-center"
data-testid="folder-create-button"
>
<FolderPlusIcon className="mr-2 h-4 w-4" />
<Trans>Create Folder</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Folder</DialogTitle>
<DialogTitle>
<Trans>Create New Folder</Trans>
</DialogTitle>
<DialogDescription>
Enter a name for your new folder. Folders help you organize your documents.
<Trans>Enter a name for your new folder. Folders help you organise your items.</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Folder Name</FormLabel>
<FormControl>
<Input placeholder="My Folder" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Folder Name</Trans>
</FormLabel>
<FormControl>
<Input placeholder={t`My Folder`} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => setIsCreateFolderOpen(false)}
>
Cancel
</Button>
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => setIsCreateFolderOpen(false)}
>
<Trans>Cancel</Trans>
</Button>
<Button type="submit">Create</Button>
</DialogFooter>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>

View File

@ -1,8 +1,7 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
@ -11,6 +10,7 @@ import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@ -32,22 +32,22 @@ import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type FolderDeleteDialogProps = {
folder: TFolderWithSubfolders | null;
folder: TFolderWithSubfolders;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDeleteDialogProps) => {
const { _ } = useLingui();
const { t } = useLingui();
const { toast } = useToast();
const { mutateAsync: deleteFolder } = trpc.folder.deleteFolder.useMutation();
const deleteMessage = _(msg`delete ${folder?.name ?? 'folder'}`);
const deleteMessage = t`delete ${folder.name}`;
const ZDeleteFolderFormSchema = z.object({
confirmText: z.literal(deleteMessage, {
errorMap: () => ({ message: _(msg`You must type '${deleteMessage}' to confirm`) }),
errorMap: () => ({ message: t`You must type '${deleteMessage}' to confirm` }),
}),
});
@ -61,8 +61,6 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
});
const onFormSubmit = async () => {
if (!folder) return;
try {
await deleteFolder({
id: folder.id,
@ -71,15 +69,15 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
onOpenChange(false);
toast({
title: 'Folder deleted successfully',
title: t`Folder deleted successfully`,
});
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.NOT_FOUND) {
toast({
title: 'Folder not found',
description: _(msg`The folder you are trying to delete does not exist.`),
title: t`Folder not found`,
description: t`The folder you are trying to delete does not exist.`,
variant: 'destructive',
});
@ -87,8 +85,8 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
}
toast({
title: 'Failed to delete folder',
description: _(msg`An unknown error occurred while deleting the folder.`),
title: t`Failed to delete folder`,
description: t`An unknown error occurred while deleting the folder.`,
variant: 'destructive',
});
}
@ -104,53 +102,65 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Folder</DialogTitle>
<DialogTitle>
<Trans>Delete Folder</Trans>
</DialogTitle>
<DialogDescription>
Are you sure you want to delete this folder?
{folder && folder._count.documents > 0 && (
<span className="text-destructive mt-2 block">
This folder contains {folder._count.documents} document(s). Deleting it will also
delete all documents in the folder.
</span>
)}
{folder && folder._count.subfolders > 0 && (
<span className="text-destructive mt-2 block">
This folder contains {folder._count.subfolders} subfolder(s). Deleting it will
delete all subfolders and their contents.
</span>
)}
<Trans>Are you sure you want to delete this folder?</Trans>
</DialogDescription>
</DialogHeader>
{(folder._count.documents > 0 ||
folder._count.templates > 0 ||
folder._count.subfolders > 0) && (
<Alert variant="destructive">
<AlertDescription>
<Trans>
This folder contains multiple items. Deleting it will also delete all items in the
folder, including nested folders and their contents.
</Trans>
</AlertDescription>
</Alert>
)}
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4">
<FormField
control={form.control}
name="confirmText"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>
Confirm by typing:{' '}
<span className="font-sm text-destructive font-semibold">
{deleteMessage}
</span>
</Trans>
</FormLabel>
<FormControl>
<Input {...field} placeholder={deleteMessage} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button variant="destructive" type="submit" disabled={!form.formState.isValid}>
Delete
</Button>
</DialogFooter>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
<FormField
control={form.control}
name="confirmText"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>
Confirm by typing:{' '}
<span className="font-sm text-destructive font-semibold">
{deleteMessage}
</span>
</Trans>
</FormLabel>
<FormControl>
<Input placeholder={deleteMessage} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
variant="destructive"
type="submit"
disabled={!form.formState.isValid}
loading={form.formState.isSubmitting}
>
<Trans>Delete</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>

View File

@ -1,10 +1,10 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderIcon, HomeIcon } from 'lucide-react';
import { FolderIcon, HomeIcon, Search } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
@ -27,6 +27,7 @@ import {
FormItem,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type FolderMoveDialogProps = {
@ -48,9 +49,10 @@ export const FolderMoveDialog = ({
isOpen,
onOpenChange,
}: FolderMoveDialogProps) => {
const { _ } = useLingui();
const { t } = useLingui();
const { toast } = useToast();
const [searchTerm, setSearchTerm] = useState('');
const { mutateAsync: moveFolder } = trpc.folder.moveFolder.useMutation();
const form = useForm<TMoveFolderFormSchema>({
@ -72,15 +74,15 @@ export const FolderMoveDialog = ({
onOpenChange(false);
toast({
title: 'Folder moved successfully',
title: t`Folder moved successfully`,
});
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.NOT_FOUND) {
toast({
title: 'Folder not found',
description: _(msg`The folder you are trying to move does not exist.`),
title: t`Folder not found`,
description: t`The folder you are trying to move does not exist.`,
variant: 'destructive',
});
@ -88,8 +90,8 @@ export const FolderMoveDialog = ({
}
toast({
title: 'Failed to move folder',
description: _(msg`An unknown error occurred while moving the folder.`),
title: t`Failed to move folder`,
description: t`An unknown error occurred while moving the folder.`,
variant: 'destructive',
});
}
@ -98,69 +100,91 @@ export const FolderMoveDialog = ({
useEffect(() => {
if (!isOpen) {
form.reset();
setSearchTerm('');
}
}, [isOpen, form]);
// Filter out the current folder and only show folders of the same type
// Filter out the current folder, only show folders of the same type, and filter by search term
const filteredFolders = foldersData?.filter(
(f) => f.id !== folder?.id && f.type === folder?.type,
(f) =>
f.id !== folder?.id &&
f.type === folder?.type &&
(searchTerm === '' || f.name.toLowerCase().includes(searchTerm.toLowerCase())),
);
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Move Folder</DialogTitle>
<DialogDescription>Select a destination for this folder.</DialogDescription>
<DialogTitle>
<Trans>Move Folder</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Select a destination for this folder.</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4 py-4">
<FormField
control={form.control}
name="targetFolderId"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="space-y-2">
<Button
type="button"
variant={!field.value ? 'default' : 'outline'}
className="w-full justify-start"
disabled={!folder?.parentId}
onClick={() => field.onChange(null)}
>
<HomeIcon className="mr-2 h-4 w-4" />
Root
</Button>
{filteredFolders &&
filteredFolders.map((f) => (
<Button
key={f.id}
type="button"
disabled={f.id === folder?.parentId}
variant={field.value === f.id ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(f.id)}
>
<FolderIcon className="mr-2 h-4 w-4" />
{f.name}
</Button>
))}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
Move Folder
</Button>
</DialogFooter>
<div className="relative">
<Search className="text-muted-foreground absolute left-2 top-3 h-4 w-4" />
<Input
placeholder={t`Search folders...`}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8"
/>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
<FormField
control={form.control}
name="targetFolderId"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="max-h-96 space-y-2 overflow-y-auto">
<Button
type="button"
variant={!field.value ? 'default' : 'outline'}
className="w-full justify-start"
disabled={!folder?.parentId}
onClick={() => field.onChange(null)}
>
<HomeIcon className="mr-2 h-4 w-4" />
<Trans>Home</Trans>
</Button>
{filteredFolders &&
filteredFolders.map((f) => (
<Button
key={f.id}
type="button"
disabled={f.id === folder?.parentId}
variant={field.value === f.id ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(f.id)}
>
<FolderIcon className="mr-2 h-4 w-4" />
{f.name}
</Button>
))}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Move</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>

View File

@ -0,0 +1,425 @@
import { useEffect, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { ExternalLinkIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { Link, useSearchParams } from 'react-router';
import { match } from 'ts-pattern';
import type { z } from 'zod';
import type { InternalClaimPlans } from '@documenso/ee/server-only/stripe/get-internal-claim-plans';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { AppError } from '@documenso/lib/errors/app-error';
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
import { parseMessageDescriptorMacro } from '@documenso/lib/utils/i18n';
import { trpc } from '@documenso/trpc/react';
import { ZCreateOrganisationRequestSchema } from '@documenso/trpc/server/organisation-router/create-organisation.types';
import { cn } from '@documenso/ui/lib/utils';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} 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 { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type OrganisationCreateDialogProps = {
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const ZCreateOrganisationFormSchema = ZCreateOrganisationRequestSchema.pick({
name: true,
});
export type TCreateOrganisationFormSchema = z.infer<typeof ZCreateOrganisationFormSchema>;
export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCreateDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const { refreshSession } = useSession();
const [searchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const actionSearchParam = searchParams?.get('action');
const [step, setStep] = useState<'billing' | 'create'>(
IS_BILLING_ENABLED() ? 'billing' : 'create',
);
const [selectedPriceId, setSelectedPriceId] = useState<string>('');
const [open, setOpen] = useState(false);
const form = useForm({
resolver: zodResolver(ZCreateOrganisationFormSchema),
defaultValues: {
name: '',
},
});
const { mutateAsync: createOrganisation } = trpc.organisation.create.useMutation();
const { data: plansData } = trpc.billing.plans.get.useQuery(undefined, {
enabled: IS_BILLING_ENABLED(),
});
const onFormSubmit = async ({ name }: TCreateOrganisationFormSchema) => {
try {
const response = await createOrganisation({
name,
priceId: selectedPriceId,
});
if (response.paymentRequired) {
window.open(response.checkoutUrl, '_blank');
setOpen(false);
return;
}
await refreshSession();
setOpen(false);
toast({
title: t`Success`,
description: t`Your organisation has been created.`,
duration: 5000,
});
} catch (err) {
const error = AppError.parseError(err);
console.error(error);
toast({
title: t`An unknown error occurred`,
description: t`We encountered an unknown error while attempting to create a organisation. Please try again later.`,
variant: 'destructive',
});
}
};
useEffect(() => {
if (actionSearchParam === 'add-organisation') {
setOpen(true);
updateSearchParams({ action: null });
}
}, [actionSearchParam, open]);
useEffect(() => {
form.reset();
}, [open, form]);
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? (
<Button className="flex-shrink-0" variant="secondary">
<Trans>Create organisation</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center">
{match(step)
.with('billing', () => (
<>
<DialogHeader>
<DialogTitle>
<Trans>Select a plan</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Select a plan to continue</Trans>
</DialogDescription>
</DialogHeader>
<fieldset aria-label="Plan select">
{plansData ? (
<BillingPlanForm
value={selectedPriceId}
onChange={setSelectedPriceId}
plans={plansData.plans}
canCreateFreeOrganisation={plansData.canCreateFreeOrganisation}
/>
) : (
<SpinnerBox className="py-32" />
)}
<DialogFooter className="mt-4">
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" onClick={() => setStep('create')}>
<Trans>Continue</Trans>
</Button>
</DialogFooter>
</fieldset>
</>
))
.with('create', () => (
<>
<DialogHeader>
<DialogTitle>
<Trans>Create organisation</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Create an organisation to collaborate with teams</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Organisation Name</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
{IS_BILLING_ENABLED() ? (
<Button
type="button"
variant="secondary"
onClick={() => setStep('billing')}
>
<Trans>Back</Trans>
</Button>
) : (
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
)}
<Button
type="submit"
data-testid="dialog-create-organisation-button"
loading={form.formState.isSubmitting}
>
{selectedPriceId ? <Trans>Checkout</Trans> : <Trans>Create</Trans>}
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</>
))
.exhaustive()}
</DialogContent>
</Dialog>
);
};
// This is separated from the internal claims constant because we need to use the msg
// macro which would cause import issues.
const internalClaimsDescription: {
[key in INTERNAL_CLAIM_ID]: MessageDescriptor | string;
} = {
[INTERNAL_CLAIM_ID.FREE]: msg`5 Documents a month`,
[INTERNAL_CLAIM_ID.INDIVIDUAL]: msg`Unlimited documents, API and more`,
[INTERNAL_CLAIM_ID.TEAM]: msg`Embedding, 5 members included and more`,
[INTERNAL_CLAIM_ID.PLATFORM]: msg`Whitelabeling, unlimited members and more`,
[INTERNAL_CLAIM_ID.ENTERPRISE]: '',
[INTERNAL_CLAIM_ID.EARLY_ADOPTER]: '',
};
type BillingPlanFormProps = {
value: string;
onChange: (priceId: string) => void;
plans: InternalClaimPlans;
canCreateFreeOrganisation: boolean;
};
const BillingPlanForm = ({
value,
onChange,
plans,
canCreateFreeOrganisation,
}: BillingPlanFormProps) => {
const { t } = useLingui();
const [billingPeriod, setBillingPeriod] = useState<'monthlyPrice' | 'yearlyPrice'>('yearlyPrice');
const dynamicPlans = useMemo(() => {
return [INTERNAL_CLAIM_ID.INDIVIDUAL, INTERNAL_CLAIM_ID.TEAM, INTERNAL_CLAIM_ID.PLATFORM].map(
(planId) => {
const plan = plans[planId];
return {
id: planId,
name: plan.name,
description: parseMessageDescriptorMacro(t, internalClaimsDescription[planId]),
monthlyPrice: plan.monthlyPrice,
yearlyPrice: plan.yearlyPrice,
};
},
);
}, [plans]);
useEffect(() => {
if (value === '' && !canCreateFreeOrganisation) {
onChange(dynamicPlans[0][billingPeriod]?.id ?? '');
}
}, [value]);
const onBillingPeriodChange = (billingPeriod: 'monthlyPrice' | 'yearlyPrice') => {
const plan = dynamicPlans.find((plan) => plan[billingPeriod]?.id === value);
setBillingPeriod(billingPeriod);
onChange(plan?.[billingPeriod]?.id ?? Object.keys(plans)[0]);
};
return (
<div className="space-y-4">
<Tabs
className="flex w-full items-center justify-center"
defaultValue="monthlyPrice"
value={billingPeriod}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
onValueChange={(value) => onBillingPeriodChange(value as 'monthlyPrice' | 'yearlyPrice')}
>
<TabsList className="flex w-full justify-center">
<TabsTrigger className="w-full" value="monthlyPrice">
<Trans>Monthly</Trans>
</TabsTrigger>
<TabsTrigger className="w-full" value="yearlyPrice">
<Trans>Yearly</Trans>
</TabsTrigger>
</TabsList>
</Tabs>
<div className="mt-4 grid gap-4 text-sm">
<button
onClick={() => onChange('')}
className={cn(
'hover:border-primary flex cursor-pointer items-center space-x-2 rounded-md border p-4 transition-all hover:shadow-sm',
{
'ring-primary/10 border-primary ring-2 ring-offset-1': '' === value,
},
)}
disabled={!canCreateFreeOrganisation}
>
<div className="w-full text-left">
<div className="flex items-center justify-between">
<p className="text-medium">
<Trans>Free</Trans>
</p>
<Badge size="small" variant="neutral" className="ml-1.5">
{canCreateFreeOrganisation ? (
<Trans>1 Free organisations left</Trans>
) : (
<Trans>0 Free organisations left</Trans>
)}
</Badge>
</div>
<div className="text-muted-foreground">
<Trans>5 documents a month</Trans>
</div>
</div>
</button>
{dynamicPlans.map((plan) => (
<button
key={plan[billingPeriod]?.id}
onClick={() => onChange(plan[billingPeriod]?.id ?? '')}
className={cn(
'hover:border-primary flex cursor-pointer items-center space-x-2 rounded-md border p-4 transition-all hover:shadow-sm',
{
'ring-primary/10 border-primary ring-2 ring-offset-1':
plan[billingPeriod]?.id === value,
},
)}
>
<div className="w-full text-left">
<p className="font-medium">{plan.name}</p>
<p className="text-muted-foreground">{plan.description}</p>
</div>
<div className="whitespace-nowrap text-right text-sm font-medium">
<p>{plan[billingPeriod]?.friendlyPrice}</p>
<span className="text-muted-foreground text-xs">
{billingPeriod === 'monthlyPrice' ? (
<Trans>per month</Trans>
) : (
<Trans>per year</Trans>
)}
</span>
</div>
</button>
))}
<Link
to="https://documen.so/enterprise-cta"
target="_blank"
className="bg-muted/30 flex items-center space-x-2 rounded-md border p-4"
>
<div className="flex-1 font-normal">
<p className="text-muted-foreground font-medium">
<Trans>Enterprise</Trans>
</p>
<p className="text-muted-foreground flex flex-row items-center gap-1">
<Trans>Contact sales here</Trans>
<ExternalLinkIcon className="h-4 w-4" />
</p>
</div>
</Link>
</div>
<div className="mt-6 text-center">
<Link
to="https://documenso.com/pricing"
className="text-primary hover:text-primary/80 flex items-center justify-center gap-1 text-sm hover:underline"
target="_blank"
>
<Trans>Compare all plans and features in detail</Trans>
<ExternalLinkIcon className="h-4 w-4" />
</Link>
</div>
</div>
);
};

View File

@ -0,0 +1,166 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { AppError } 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,
DialogTrigger,
} 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 OrganisationDeleteDialogProps = {
trigger?: React.ReactNode;
};
export const OrganisationDeleteDialog = ({ trigger }: OrganisationDeleteDialogProps) => {
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const { _ } = useLingui();
const { toast } = useToast();
const { refreshSession } = useSession();
const organisation = useCurrentOrganisation();
const deleteMessage = _(msg`delete ${organisation.name}`);
const ZDeleteOrganisationFormSchema = z.object({
organisationName: z.literal(deleteMessage, {
errorMap: () => ({ message: _(msg`You must enter '${deleteMessage}' to proceed`) }),
}),
});
const form = useForm({
resolver: zodResolver(ZDeleteOrganisationFormSchema),
defaultValues: {
organisationName: '',
},
});
const { mutateAsync: deleteOrganisation } = trpc.organisation.delete.useMutation();
const onFormSubmit = async () => {
try {
await deleteOrganisation({ organisationId: organisation.id });
toast({
title: _(msg`Success`),
description: _(msg`Your organisation has been successfully deleted.`),
duration: 5000,
});
await navigate('/settings/organisations');
await refreshSession();
setOpen(false);
} catch (err) {
const error = AppError.parseError(err);
console.error(error);
toast({
title: _(msg`An unknown error occurred`),
description: _(
msg`We encountered an unknown error while attempting to delete this organisation. Please try again later.`,
),
variant: 'destructive',
duration: 10000,
});
}
};
useEffect(() => {
if (!open) {
form.reset();
}
}, [open, form]);
return (
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="destructive">
<Trans>Delete</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Are you sure you wish to delete this organisation?</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
You are about to delete <span className="font-semibold">{organisation.name}</span>.
All data related to this organisation such as teams, documents, and all other
resources will be deleted. This action is irreversible.
</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<FormField
control={form.control}
name="organisationName"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>
Confirm by typing <span className="text-destructive">{deleteMessage}</span>
</Trans>
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" variant="destructive" loading={form.formState.isSubmitting}>
<Trans>Delete</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,251 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { OrganisationMemberRole } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import type { z } from 'zod';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/organisations';
import { EXTENDED_ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import { AppError } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { ZCreateOrganisationGroupRequestSchema } from '@documenso/trpc/server/organisation-router/create-organisation-group.types';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type OrganisationGroupCreateDialogProps = {
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZCreateOrganisationGroupFormSchema = ZCreateOrganisationGroupRequestSchema.pick({
name: true,
memberIds: true,
organisationRole: true,
});
type TCreateOrganisationGroupFormSchema = z.infer<typeof ZCreateOrganisationGroupFormSchema>;
export const OrganisationGroupCreateDialog = ({
trigger,
...props
}: OrganisationGroupCreateDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const organisation = useCurrentOrganisation();
const form = useForm({
resolver: zodResolver(ZCreateOrganisationGroupFormSchema),
defaultValues: {
name: '',
organisationRole: OrganisationMemberRole.MEMBER,
memberIds: [],
},
});
const { mutateAsync: createOrganisationGroup } = trpc.organisation.group.create.useMutation();
const { data: membersFindResult, isLoading: isLoadingMembers } =
trpc.organisation.member.find.useQuery({
organisationId: organisation.id,
});
const members = membersFindResult?.data ?? [];
const onFormSubmit = async ({
name,
organisationRole,
memberIds,
}: TCreateOrganisationGroupFormSchema) => {
try {
await createOrganisationGroup({
organisationId: organisation.id,
name,
organisationRole,
memberIds,
});
setOpen(false);
toast({
title: t`Success`,
description: t`Group has been created.`,
duration: 5000,
});
} catch (err) {
const error = AppError.parseError(err);
console.error(error);
toast({
title: t`An unknown error occurred`,
description: t`We encountered an unknown error while attempting to create a group. Please try again later.`,
variant: 'destructive',
});
}
};
useEffect(() => {
form.reset();
}, [open, form]);
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? (
<Button className="flex-shrink-0" variant="secondary">
<Trans>Create group</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Create group</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Organise your members into groups which can be assigned to teams</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Group Name</Trans>
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="organisationRole"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Organisation role</Trans>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="text-muted-foreground w-full">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{ORGANISATION_MEMBER_ROLE_HIERARCHY[
organisation.currentOrganisationRole
].map((role) => (
<SelectItem key={role} value={role}>
{t(EXTENDED_ORGANISATION_MEMBER_ROLE_MAP[role]) ?? role}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="memberIds"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Members</Trans>
</FormLabel>
<FormControl>
<MultiSelectCombobox
options={members.map((member) => ({
label: member.name,
value: member.id,
}))}
loading={isLoadingMembers}
selectedValues={field.value}
onChange={field.onChange}
className="bg-background w-full"
emptySelectionPlaceholder={t`Select members`}
/>
</FormControl>
<FormDescription>
<Trans>Select the members to add to this group</Trans>
</FormDescription>
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
data-testid="dialog-create-organisation-button"
loading={form.formState.isSubmitting}
>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,117 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
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,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type OrganisationGroupDeleteDialogProps = {
organisationGroupId: string;
organisationGroupName: string;
trigger?: React.ReactNode;
};
export const OrganisationGroupDeleteDialog = ({
trigger,
organisationGroupId,
organisationGroupName,
}: OrganisationGroupDeleteDialogProps) => {
const [open, setOpen] = useState(false);
const { _ } = useLingui();
const { toast } = useToast();
const organisation = useCurrentOrganisation();
const { mutateAsync: deleteGroup, isPending: isDeleting } =
trpc.organisation.group.delete.useMutation({
onSuccess: () => {
toast({
title: _(msg`Success`),
description: _(msg`You have successfully removed this group from the organisation.`),
duration: 5000,
});
setOpen(false);
},
onError: () => {
toast({
title: _(msg`An unknown error occurred`),
description: _(
msg`We encountered an unknown error while attempting to remove this group. Please try again later.`,
),
variant: 'destructive',
duration: 10000,
});
},
});
return (
<Dialog open={open} onOpenChange={(value) => !isDeleting && setOpen(value)}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="secondary">
<Trans>Delete organisation group</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Are you sure?</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>
You are about to remove the following group from{' '}
<span className="font-semibold">{organisation.name}</span>.
</Trans>
</DialogDescription>
</DialogHeader>
<Alert variant="neutral">
<AlertDescription className="text-center font-semibold">
{organisationGroupName}
</AlertDescription>
</Alert>
<fieldset disabled={isDeleting}>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
variant="destructive"
loading={isDeleting}
onClick={async () =>
deleteGroup({
organisationId: organisation.id,
groupId: organisationGroupId,
})
}
>
<Trans>Delete</Trans>
</Button>
</DialogFooter>
</fieldset>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,115 @@
import { useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import type { OrganisationMemberRole } from '@prisma/client';
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { trpc } from '@documenso/trpc/react';
import { Alert } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type OrganisationLeaveDialogProps = {
organisationId: string;
organisationName: string;
organisationAvatarImageId?: string | null;
role: OrganisationMemberRole;
trigger?: React.ReactNode;
};
export const OrganisationLeaveDialog = ({
trigger,
organisationId,
organisationName,
organisationAvatarImageId,
role,
}: OrganisationLeaveDialogProps) => {
const [open, setOpen] = useState(false);
const { t } = useLingui();
const { toast } = useToast();
const { mutateAsync: leaveOrganisation, isPending: isLeavingOrganisation } =
trpc.organisation.leave.useMutation({
onSuccess: () => {
toast({
title: t`Success`,
description: t`You have successfully left this organisation.`,
duration: 5000,
});
setOpen(false);
},
onError: () => {
toast({
title: t`An unknown error occurred`,
description: t`We encountered an unknown error while attempting to leave this organisation. Please try again later.`,
variant: 'destructive',
duration: 10000,
});
},
});
return (
<Dialog open={open} onOpenChange={(value) => !isLeavingOrganisation && setOpen(value)}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="destructive">
<Trans>Leave organisation</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Are you sure?</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>You are about to leave the following organisation.</Trans>
</DialogDescription>
</DialogHeader>
<Alert variant="neutral" padding="tight">
<AvatarWithText
avatarClass="h-12 w-12"
avatarSrc={formatAvatarUrl(organisationAvatarImageId)}
avatarFallback={organisationName.slice(0, 1).toUpperCase()}
primaryText={organisationName}
secondaryText={t(ORGANISATION_MEMBER_ROLE_MAP[role])}
/>
</Alert>
<fieldset disabled={isLeavingOrganisation}>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
variant="destructive"
loading={isLeavingOrganisation}
onClick={async () => leaveOrganisation({ organisationId })}
>
<Trans>Leave</Trans>
</Button>
</DialogFooter>
</fieldset>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,123 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { trpc } from '@documenso/trpc/react';
import { Alert } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type OrganisationMemberDeleteDialogProps = {
organisationMemberId: string;
organisationMemberName: string;
organisationMemberEmail: string;
trigger?: React.ReactNode;
};
export const OrganisationMemberDeleteDialog = ({
trigger,
organisationMemberId,
organisationMemberName,
organisationMemberEmail,
}: OrganisationMemberDeleteDialogProps) => {
const [open, setOpen] = useState(false);
const { _ } = useLingui();
const { toast } = useToast();
const organisation = useCurrentOrganisation();
const { mutateAsync: deleteOrganisationMembers, isPending: isDeletingOrganisationMember } =
trpc.organisation.member.delete.useMutation({
onSuccess: () => {
toast({
title: _(msg`Success`),
description: _(msg`You have successfully removed this user from the organisation.`),
duration: 5000,
});
setOpen(false);
},
onError: () => {
toast({
title: _(msg`An unknown error occurred`),
description: _(
msg`We encountered an unknown error while attempting to remove this user. Please try again later.`,
),
variant: 'destructive',
duration: 10000,
});
},
});
return (
<Dialog open={open} onOpenChange={(value) => !isDeletingOrganisationMember && setOpen(value)}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="secondary">
<Trans>Delete organisation member</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Are you sure?</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>
You are about to remove the following user from{' '}
<span className="font-semibold">{organisation.name}</span>.
</Trans>
</DialogDescription>
</DialogHeader>
<Alert variant="neutral" padding="tight">
<AvatarWithText
avatarClass="h-12 w-12"
avatarFallback={organisationMemberName.slice(0, 1).toUpperCase()}
primaryText={<span className="font-semibold">{organisationMemberName}</span>}
secondaryText={organisationMemberEmail}
/>
</Alert>
<fieldset disabled={isDeletingOrganisationMember}>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
variant="destructive"
loading={isDeletingOrganisationMember}
onClick={async () =>
deleteOrganisationMembers({
organisationId: organisation.id,
organisationMemberId,
})
}
>
<Trans>Delete</Trans>
</Button>
</DialogFooter>
</fieldset>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,478 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { OrganisationMemberRole } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { Download, Mail, MailIcon, PlusCircle, Trash, Upload, UsersIcon } from 'lucide-react';
import Papa, { type ParseResult } from 'papaparse';
import { useFieldArray, useForm } from 'react-hook-form';
import { z } from 'zod';
import { downloadFile } from '@documenso/lib/client-only/download-file';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/organisations';
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
import { trpc } from '@documenso/trpc/react';
import { ZCreateOrganisationMemberInvitesRequestSchema } from '@documenso/trpc/server/organisation-router/create-organisation-member-invites.types';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type OrganisationMemberInviteDialogProps = {
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZInviteOrganisationMembersFormSchema = z
.object({
invitations: ZCreateOrganisationMemberInvitesRequestSchema.shape.invitations,
})
// Display exactly which rows are duplicates.
.superRefine((items, ctx) => {
const uniqueEmails = new Map<string, number>();
for (const [index, invitation] of items.invitations.entries()) {
const email = invitation.email.toLowerCase();
const firstFoundIndex = uniqueEmails.get(email);
if (firstFoundIndex === undefined) {
uniqueEmails.set(email, index);
continue;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Emails must be unique',
path: ['invitations', index, 'email'],
});
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Emails must be unique',
path: ['invitations', firstFoundIndex, 'email'],
});
}
});
type TInviteOrganisationMembersFormSchema = z.infer<typeof ZInviteOrganisationMembersFormSchema>;
type TabTypes = 'INDIVIDUAL' | 'BULK';
const ZImportOrganisationMemberSchema = z.array(
z.object({
email: z.string().email(),
organisationRole: z.nativeEnum(OrganisationMemberRole),
}),
);
export const OrganisationMemberInviteDialog = ({
trigger,
...props
}: OrganisationMemberInviteDialogProps) => {
const [open, setOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [invitationType, setInvitationType] = useState<TabTypes>('INDIVIDUAL');
const { _ } = useLingui();
const { toast } = useToast();
const organisation = useCurrentOrganisation();
const form = useForm<TInviteOrganisationMembersFormSchema>({
resolver: zodResolver(ZInviteOrganisationMembersFormSchema),
defaultValues: {
invitations: [
{
email: '',
organisationRole: OrganisationMemberRole.MEMBER,
},
],
},
});
const {
append: appendOrganisationMemberInvite,
fields: organisationMemberInvites,
remove: removeOrganisationMemberInvite,
} = useFieldArray({
control: form.control,
name: 'invitations',
});
const { mutateAsync: createOrganisationMemberInvites } =
trpc.organisation.member.invite.createMany.useMutation();
const { data: fullOrganisation } = trpc.organisation.get.useQuery({
organisationReference: organisation.id,
});
const onAddOrganisationMemberInvite = () => {
appendOrganisationMemberInvite({
email: '',
organisationRole: OrganisationMemberRole.MEMBER,
});
};
const onFormSubmit = async ({ invitations }: TInviteOrganisationMembersFormSchema) => {
try {
await createOrganisationMemberInvites({
organisationId: organisation.id,
invitations,
});
toast({
title: _(msg`Success`),
description: _(msg`Organisation invitations have been sent.`),
duration: 5000,
});
setOpen(false);
} catch {
toast({
title: _(msg`An unknown error occurred`),
description: _(
msg`We encountered an unknown error while attempting to invite organisation members. Please try again later.`,
),
variant: 'destructive',
});
}
};
const dialogState = useMemo(() => {
if (!fullOrganisation) {
return 'loading';
}
if (!IS_BILLING_ENABLED()) {
return 'form';
}
if (fullOrganisation.organisationClaim.memberCount === 0) {
return 'form';
}
// This is probably going to screw us over in the future.
if (fullOrganisation.organisationClaim.originalSubscriptionClaimId !== INTERNAL_CLAIM_ID.TEAM) {
return 'alert';
}
return 'form';
}, [fullOrganisation]);
useEffect(() => {
if (!open) {
form.reset();
setInvitationType('INDIVIDUAL');
}
}, [open, form]);
const onFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files?.length) {
return;
}
const csvFile = e.target.files[0];
Papa.parse(csvFile, {
skipEmptyLines: true,
comments: 'Work email,Job title',
complete: (results: ParseResult<string[]>) => {
const members = results.data.map((row) => {
const [email, role] = row;
return {
email: email.trim(),
organisationRole: role.trim().toUpperCase(),
};
});
// Remove the first row if it contains the headers.
if (members.length > 1 && members[0].organisationRole.toUpperCase() === 'ROLE') {
members.shift();
}
try {
const importedInvitations = ZImportOrganisationMemberSchema.parse(members);
form.setValue('invitations', importedInvitations);
form.clearErrors('invitations');
setInvitationType('INDIVIDUAL');
} catch (err) {
console.error(err);
toast({
title: _(msg`Something went wrong`),
description: _(
msg`Please check the CSV file and make sure it is according to our format`,
),
variant: 'destructive',
});
}
},
});
};
const downloadTemplate = () => {
const data = [
{ email: 'admin@documenso.com', role: 'Admin' },
{ email: 'manager@documenso.com', role: 'Manager' },
{ email: 'member@documenso.com', role: 'Member' },
];
const csvContent =
'Email address,Role\n' + data.map((row) => `${row.email},${row.role}`).join('\n');
const blob = new Blob([csvContent], {
type: 'text/csv',
});
downloadFile({
filename: 'documenso-organisation-member-invites-template.csv',
data: blob,
});
};
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger ?? (
<Button variant="secondary">
<Trans>Invite member</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Invite organisation members</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>An email containing an invitation will be sent to each member.</Trans>
</DialogDescription>
</DialogHeader>
{dialogState === 'loading' && <SpinnerBox className="py-32" />}
{dialogState === 'alert' && (
<>
<Alert
className="flex flex-col justify-between p-6 sm:flex-row sm:items-center"
variant="neutral"
>
<AlertDescription>
<Trans>
Your plan does not support inviting members. Please upgrade or your plan or
contact sales at <a href="mailto:support@documenso.com">support@documenso.com</a>{' '}
if you would like to discuss your options.
</Trans>
</AlertDescription>
</Alert>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
</DialogFooter>
</>
)}
{dialogState === 'form' && (
<Tabs
defaultValue="INDIVIDUAL"
value={invitationType}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
onValueChange={(value) => setInvitationType(value as TabTypes)}
>
<TabsList className="w-full">
<TabsTrigger value="INDIVIDUAL" className="hover:text-foreground w-full">
<MailIcon size={20} className="mr-2" />
<Trans>Invite Members</Trans>
</TabsTrigger>
<TabsTrigger value="BULK" className="hover:text-foreground w-full">
<UsersIcon size={20} className="mr-2" /> <Trans>Bulk Import</Trans>
</TabsTrigger>
</TabsList>
<TabsContent value="INDIVIDUAL">
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
{organisationMemberInvites.map((organisationMemberInvite, index) => (
<div
className="flex w-full flex-row space-x-4"
key={organisationMemberInvite.id}
>
<FormField
control={form.control}
name={`invitations.${index}.email`}
render={({ field }) => (
<FormItem className="w-full">
{index === 0 && (
<FormLabel required>
<Trans>Email address</Trans>
</FormLabel>
)}
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`invitations.${index}.organisationRole`}
render={({ field }) => (
<FormItem className="w-full">
{index === 0 && (
<FormLabel required>
<Trans>Organisation Role</Trans>
</FormLabel>
)}
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="text-muted-foreground max-w-[200px]">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{ORGANISATION_MEMBER_ROLE_HIERARCHY[
organisation.currentOrganisationRole
].map((role) => (
<SelectItem key={role} value={role}>
{_(ORGANISATION_MEMBER_ROLE_MAP[role]) ?? role}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<button
type="button"
className={cn(
'justify-left inline-flex h-10 w-10 items-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50',
index === 0 ? 'mt-8' : 'mt-0',
)}
disabled={organisationMemberInvites.length === 1}
onClick={() => removeOrganisationMemberInvite(index)}
>
<Trash className="h-5 w-5" />
</button>
</div>
))}
</div>
<Button
type="button"
size="sm"
variant="outline"
className="w-fit"
onClick={() => onAddOrganisationMemberInvite()}
>
<PlusCircle className="mr-2 h-4 w-4" />
<Trans>Add more</Trans>
</Button>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
{!form.formState.isSubmitting && <Mail className="mr-2 h-4 w-4" />}
<Trans>Invite</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</TabsContent>
<TabsContent value="BULK">
<div className="mt-4 space-y-4">
<Card gradient className="h-32">
<CardContent
className="text-muted-foreground/80 hover:text-muted-foreground/90 flex h-full cursor-pointer flex-col items-center justify-center rounded-lg p-0 transition-colors"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="h-5 w-5" />
<p className="mt-1 text-sm">
<Trans>Click here to upload</Trans>
</p>
<input
onChange={onFileInputChange}
type="file"
ref={fileInputRef}
accept=".csv"
hidden
/>
</CardContent>
</Card>
<DialogFooter>
<Button type="button" variant="secondary" onClick={downloadTemplate}>
<Download className="mr-2 h-4 w-4" />
<Trans>Template</Trans>
</Button>
</DialogFooter>
</div>
</TabsContent>
</Tabs>
)}
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,205 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { OrganisationMemberRole } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/organisations';
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import { isOrganisationRoleWithinUserHierarchy } from '@documenso/lib/utils/organisations';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type OrganisationMemberUpdateDialogProps = {
currentUserOrganisationRole: OrganisationMemberRole;
trigger?: React.ReactNode;
organisationId: string;
organisationMemberId: string;
organisationMemberName: string;
organisationMemberRole: OrganisationMemberRole;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZUpdateOrganisationMemberFormSchema = z.object({
role: z.nativeEnum(OrganisationMemberRole),
});
type ZUpdateOrganisationMemberSchema = z.infer<typeof ZUpdateOrganisationMemberFormSchema>;
export const OrganisationMemberUpdateDialog = ({
currentUserOrganisationRole,
trigger,
organisationId,
organisationMemberId,
organisationMemberName,
organisationMemberRole,
...props
}: OrganisationMemberUpdateDialogProps) => {
const [open, setOpen] = useState(false);
const { _ } = useLingui();
const { toast } = useToast();
const form = useForm<ZUpdateOrganisationMemberSchema>({
resolver: zodResolver(ZUpdateOrganisationMemberFormSchema),
defaultValues: {
role: organisationMemberRole,
},
});
const { mutateAsync: updateOrganisationMember } = trpc.organisation.member.update.useMutation();
const onFormSubmit = async ({ role }: ZUpdateOrganisationMemberSchema) => {
try {
await updateOrganisationMember({
organisationId,
organisationMemberId,
data: {
role,
},
});
toast({
title: _(msg`Success`),
description: _(msg`You have updated ${organisationMemberName}.`),
duration: 5000,
});
setOpen(false);
} catch {
toast({
title: _(msg`An unknown error occurred`),
description: _(
msg`We encountered an unknown error while attempting to update this organisation member. Please try again later.`,
),
variant: 'destructive',
});
}
};
useEffect(() => {
if (!open) {
return;
}
form.reset();
if (
!isOrganisationRoleWithinUserHierarchy(currentUserOrganisationRole, organisationMemberRole)
) {
setOpen(false);
toast({
title: _(msg`You cannot modify a organisation member who has a higher role than you.`),
variant: 'destructive',
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, currentUserOrganisationRole, organisationMemberRole, form, toast]);
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger ?? (
<Button variant="secondary">
<Trans>Update organisation member</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Update organisation member</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>
You are currently updating{' '}
<span className="font-bold">{organisationMemberName}.</span>
</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel required>
<Trans>Role</Trans>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="text-muted-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent className="w-full" position="popper">
{ORGANISATION_MEMBER_ROLE_HIERARCHY[currentUserOrganisationRole].map(
(role) => (
<SelectItem key={role} value={role}>
{_(ORGANISATION_MEMBER_ROLE_MAP[role]) ?? role}
</SelectItem>
),
)}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="mt-4">
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -49,7 +49,7 @@ import {
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
import { useCurrentTeam } from '~/providers/team';
export type ManagePublicTemplateDialogProps = {
directTemplates: (Template & {
@ -95,7 +95,7 @@ export const ManagePublicTemplateDialog = ({
const [open, onOpenChange] = useState(isOpen);
const team = useOptionalCurrentTeam();
const team = useCurrentTeam();
const [selectedTemplateId, setSelectedTemplateId] = useState<number | null>(initialTemplateId);

View File

@ -0,0 +1,94 @@
import { useState } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import { authClient } from '@documenso/auth/client';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
type SessionLogoutAllDialogProps = {
onSuccess?: () => Promise<unknown>;
disabled?: boolean;
};
export const SessionLogoutAllDialog = ({ onSuccess, disabled }: SessionLogoutAllDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const handleSignOutAllSessions = async () => {
setIsLoading(true);
try {
await authClient.signOutAllSessions();
if (onSuccess) {
await onSuccess();
}
toast({
title: t`Sessions have been revoked`,
});
setIsOpen(false);
} catch (error) {
console.error(error);
toast({
title: t`Error`,
description: t`Failed to sign out all sessions`,
variant: 'destructive',
});
}
setIsLoading(false);
};
return (
<Dialog open={isOpen} onOpenChange={(value) => (isLoading ? undefined : setIsOpen(value))}>
<DialogTrigger asChild>
<Button variant="secondary" disabled={disabled}>
<Trans>Revoke all sessions</Trans>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Revoke all sessions</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
This will sign you out of all other devices. You will need to sign in again on those
devices to continue using your account.
</Trans>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="secondary" disabled={isLoading}>
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button loading={isLoading} variant="destructive" onClick={handleSignOutAllSessions}>
<Trans>Revoke all sessions</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -1,188 +0,0 @@
import { useMemo, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { AnimatePresence, motion } from 'framer-motion';
import { Loader, TagIcon } from 'lucide-react';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type TeamCheckoutCreateDialogProps = {
pendingTeamId: number | null;
onClose: () => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const MotionCard = motion(Card);
export const TeamCheckoutCreateDialog = ({
pendingTeamId,
onClose,
...props
}: TeamCheckoutCreateDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const [interval, setInterval] = useState<'monthly' | 'yearly'>('monthly');
const { data, isLoading } = trpc.team.getTeamPrices.useQuery();
const { mutateAsync: createCheckout, isPending: isCreatingCheckout } =
trpc.team.createTeamPendingCheckout.useMutation({
onSuccess: (checkoutUrl) => {
window.open(checkoutUrl, '_blank');
onClose();
},
onError: () =>
toast({
title: _(msg`Something went wrong`),
description: _(
msg`We were unable to create a checkout session. Please try again, or contact support`,
),
variant: 'destructive',
}),
});
const selectedPrice = useMemo(() => {
if (!data) {
return null;
}
return data[interval];
}, [data, interval]);
const handleOnOpenChange = (open: boolean) => {
if (pendingTeamId === null) {
return;
}
if (!open) {
onClose();
}
};
if (pendingTeamId === null) {
return null;
}
return (
<Dialog {...props} open={pendingTeamId !== null} onOpenChange={handleOnOpenChange}>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Team checkout</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>Payment is required to finalise the creation of your team.</Trans>
</DialogDescription>
</DialogHeader>
{(isLoading || !data) && (
<div className="flex h-20 items-center justify-center text-sm">
{isLoading ? (
<Loader className="text-documenso h-6 w-6 animate-spin" />
) : (
<p>
<Trans>Something went wrong</Trans>
</p>
)}
</div>
)}
{data && selectedPrice && !isLoading && (
<div>
<Tabs
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
onValueChange={(value) => setInterval(value as 'monthly' | 'yearly')}
value={interval}
className="mb-4"
>
<TabsList className="w-full">
{[data.monthly, data.yearly].map((price) => (
<TabsTrigger key={price.priceId} className="w-full" value={price.interval}>
{price.friendlyInterval}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<AnimatePresence mode="wait">
<MotionCard
key={selectedPrice.priceId}
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0, transition: { duration: 0.3 } }}
exit={{ opacity: 0, transition: { duration: 0.15 } }}
>
<CardContent className="flex h-full flex-col p-6">
{selectedPrice.interval === 'monthly' ? (
<div className="text-muted-foreground text-lg font-medium">
$50 USD <span className="text-xs">per month</span>
</div>
) : (
<div className="text-muted-foreground flex items-center justify-between text-lg font-medium">
<span>
$480 USD <span className="text-xs">per year</span>
</span>
<div className="bg-primary text-primary-foreground ml-2 inline-flex flex-row items-center justify-center rounded px-2 py-1 text-xs">
<TagIcon className="mr-1 h-4 w-4" />
20% off
</div>
</div>
)}
<div className="text-muted-foreground mt-1.5 text-sm">
<p>
<Trans>This price includes minimum 5 seats.</Trans>
</p>
<p className="mt-1">
<Trans>Adding and removing seats will adjust your invoice accordingly.</Trans>
</p>
</div>
</CardContent>
</MotionCard>
</AnimatePresence>
<DialogFooter className="mt-4">
<Button
type="button"
variant="secondary"
disabled={isCreatingCheckout}
onClick={() => onClose()}
>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
loading={isCreatingCheckout}
onClick={async () =>
createCheckout({
interval: selectedPrice.interval,
pendingTeamId,
})
}
>
<Trans>Checkout</Trans>
</Button>
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>
);
};

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
@ -7,15 +7,18 @@ import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { useSearchParams } from 'react-router';
import { useNavigate } from 'react-router';
import type { z } from 'zod';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED, NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { ZCreateTeamMutationSchema } from '@documenso/trpc/server/team-router/schema';
import { ZCreateTeamRequestSchema } from '@documenso/trpc/server/team-router/create-team.types';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Dialog,
DialogContent,
@ -34,29 +37,37 @@ import {
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type TeamCreateDialogProps = {
trigger?: React.ReactNode;
onCreated?: () => Promise<void>;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZCreateTeamFormSchema = ZCreateTeamMutationSchema.pick({
const ZCreateTeamFormSchema = ZCreateTeamRequestSchema.pick({
teamName: true,
teamUrl: true,
inheritMembers: true,
});
type TCreateTeamFormSchema = z.infer<typeof ZCreateTeamFormSchema>;
export const TeamCreateDialog = ({ trigger, ...props }: TeamCreateDialogProps) => {
export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { refreshSession } = useSession();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const organisation = useCurrentOrganisation();
const [open, setOpen] = useState(false);
const { data: fullOrganisation } = trpc.organisation.get.useQuery({
organisationReference: organisation.id,
});
const actionSearchParam = searchParams?.get('action');
const form = useForm({
@ -64,24 +75,25 @@ export const TeamCreateDialog = ({ trigger, ...props }: TeamCreateDialogProps) =
defaultValues: {
teamName: '',
teamUrl: '',
inheritMembers: true,
},
});
const { mutateAsync: createTeam } = trpc.team.createTeam.useMutation();
const { mutateAsync: createTeam } = trpc.team.create.useMutation();
const onFormSubmit = async ({ teamName, teamUrl }: TCreateTeamFormSchema) => {
const onFormSubmit = async ({ teamName, teamUrl, inheritMembers }: TCreateTeamFormSchema) => {
try {
const response = await createTeam({
await createTeam({
organisationId: organisation.id,
teamName,
teamUrl,
inheritMembers,
});
setOpen(false);
if (response.paymentRequired) {
await navigate(`/settings/teams?tab=pending&checkout=${response.pendingTeamId}`);
return;
}
await onCreated?.();
await refreshSession();
toast({
title: _(msg`Success`),
@ -114,6 +126,26 @@ export const TeamCreateDialog = ({ trigger, ...props }: TeamCreateDialogProps) =
return text.toLowerCase().replace(/\s+/g, '-');
};
const dialogState = useMemo(() => {
if (!fullOrganisation) {
return 'loading';
}
if (!IS_BILLING_ENABLED()) {
return 'form';
}
if (fullOrganisation.organisationClaim.teamCount === 0) {
return 'form';
}
if (fullOrganisation.organisationClaim.teamCount <= fullOrganisation.teams.length) {
return 'alert';
}
return 'form';
}, [fullOrganisation]);
useEffect(() => {
if (actionSearchParam === 'add-team') {
setOpen(true);
@ -145,89 +177,141 @@ export const TeamCreateDialog = ({ trigger, ...props }: TeamCreateDialogProps) =
<Trans>Create team</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<DialogDescription>
<Trans>Create a team to collaborate with your team members.</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
{dialogState === 'loading' && <SpinnerBox className="py-32" />}
{dialogState === 'alert' && (
<>
<Alert
className="flex flex-col justify-between p-6 sm:flex-row sm:items-center"
variant="neutral"
>
<FormField
control={form.control}
name="teamName"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Team Name</Trans>
</FormLabel>
<FormControl>
<Input
className="bg-background"
{...field}
onChange={(event) => {
const oldGeneratedUrl = mapTextToUrl(field.value);
const newGeneratedUrl = mapTextToUrl(event.target.value);
<AlertDescription className="mt-0">
<Trans>
You have reached the maximum number of teams for your plan. Please contact sales
at <a href="mailto:support@documenso.com">support@documenso.com</a> if you would
like to adjust your plan.
</Trans>
</AlertDescription>
</Alert>
const urlField = form.getValues('teamUrl');
if (urlField === oldGeneratedUrl) {
form.setValue('teamUrl', newGeneratedUrl);
}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
</DialogFooter>
</>
)}
field.onChange(event);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{dialogState === 'form' && (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<FormField
control={form.control}
name="teamName"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Team Name</Trans>
</FormLabel>
<FormControl>
<Input
className="bg-background"
{...field}
onChange={(event) => {
const oldGeneratedUrl = mapTextToUrl(field.value);
const newGeneratedUrl = mapTextToUrl(event.target.value);
<FormField
control={form.control}
name="teamUrl"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Team URL</Trans>
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
{!form.formState.errors.teamUrl && (
<span className="text-foreground/50 text-xs font-normal">
{field.value ? (
`${NEXT_PUBLIC_WEBAPP_URL()}/t/${field.value}`
) : (
<Trans>A unique URL to identify your team</Trans>
)}
</span>
)}
const urlField = form.getValues('teamUrl');
if (urlField === oldGeneratedUrl) {
form.setValue('teamUrl', newGeneratedUrl);
}
<FormMessage />
</FormItem>
)}
/>
field.onChange(event);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<FormField
control={form.control}
name="teamUrl"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Team URL</Trans>
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
{!form.formState.errors.teamUrl && (
<span className="text-foreground/50 text-xs font-normal">
{field.value ? (
`${NEXT_PUBLIC_WEBAPP_URL()}/t/${field.value}`
) : (
<Trans>A unique URL to identify your team</Trans>
)}
</span>
)}
<Button
type="submit"
data-testid="dialog-create-team-button"
loading={form.formState.isSubmitting}
>
<Trans>Create Team</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="inheritMembers"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormControl>
<div className="flex items-center">
<Checkbox
id="inherit-members"
checked={field.value}
onCheckedChange={field.onChange}
/>
<label
className="text-muted-foreground ml-2 text-sm"
htmlFor="inherit-members"
>
<Trans>Allow all organisation members to access this team</Trans>
</label>
</div>
</FormControl>
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
data-testid="dialog-create-team-button"
loading={form.formState.isSubmitting}
>
<Trans>Create Team</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
)}
</DialogContent>
</Dialog>
);

View File

@ -8,6 +8,7 @@ import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { AppError } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
@ -35,15 +36,22 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export type TeamDeleteDialogProps = {
teamId: number;
teamName: string;
redirectTo?: string;
trigger?: React.ReactNode;
};
export const TeamDeleteDialog = ({ trigger, teamId, teamName }: TeamDeleteDialogProps) => {
export const TeamDeleteDialog = ({
trigger,
teamId,
teamName,
redirectTo,
}: TeamDeleteDialogProps) => {
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const { _ } = useLingui();
const { toast } = useToast();
const { refreshSession } = useSession();
const deleteMessage = _(msg`delete ${teamName}`);
@ -60,19 +68,23 @@ export const TeamDeleteDialog = ({ trigger, teamId, teamName }: TeamDeleteDialog
},
});
const { mutateAsync: deleteTeam } = trpc.team.deleteTeam.useMutation();
const { mutateAsync: deleteTeam } = trpc.team.delete.useMutation();
const onFormSubmit = async () => {
try {
await deleteTeam({ teamId });
await refreshSession();
toast({
title: _(msg`Success`),
description: _(msg`Your team has been successfully deleted.`),
duration: 5000,
});
await navigate('/settings/teams');
if (redirectTo) {
await navigate(redirectTo);
}
setOpen(false);
} catch (err) {
@ -113,7 +125,7 @@ export const TeamDeleteDialog = ({ trigger, teamId, teamName }: TeamDeleteDialog
<DialogTrigger asChild>
{trigger ?? (
<Button variant="destructive">
<Trans>Delete team</Trans>
<Trans>Delete</Trans>
</Button>
)}
</DialogTrigger>

View File

@ -61,12 +61,12 @@ export const TeamEmailAddDialog = ({ teamId, trigger, ...props }: TeamEmailAddDi
},
});
const { mutateAsync: createTeamEmailVerification, isPending } =
trpc.team.createTeamEmailVerification.useMutation();
const { mutateAsync: sendTeamEmailVerification, isPending } =
trpc.team.email.verification.send.useMutation();
const onFormSubmit = async ({ name, email }: TCreateTeamEmailFormSchema) => {
try {
await createTeamEmailVerification({
await sendTeamEmailVerification({
teamId,
name,
email,

View File

@ -48,7 +48,7 @@ export const TeamEmailDeleteDialog = ({ trigger, teamName, team }: TeamEmailDele
const { revalidate } = useRevalidator();
const { mutateAsync: deleteTeamEmail, isPending: isDeletingTeamEmail } =
trpc.team.deleteTeamEmail.useMutation({
trpc.team.email.delete.useMutation({
onSuccess: () => {
toast({
title: _(msg`Success`),
@ -67,7 +67,7 @@ export const TeamEmailDeleteDialog = ({ trigger, teamName, team }: TeamEmailDele
});
const { mutateAsync: deleteTeamEmailVerification, isPending: isDeletingTeamEmailVerification } =
trpc.team.deleteTeamEmailVerification.useMutation({
trpc.team.email.verification.delete.useMutation({
onSuccess: () => {
toast({
title: _(msg`Success`),

View File

@ -61,7 +61,7 @@ export const TeamEmailUpdateDialog = ({
},
});
const { mutateAsync: updateTeamEmail } = trpc.team.updateTeamEmail.useMutation();
const { mutateAsync: updateTeamEmail } = trpc.team.email.update.useMutation();
const onFormSubmit = async ({ name }: TUpdateTeamEmailFormSchema) => {
try {

View File

@ -0,0 +1,305 @@
import { useEffect, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { OrganisationGroupType, TeamMemberRole } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
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 { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
export type TeamGroupCreateDialogProps = Omit<DialogPrimitive.DialogProps, 'children'>;
const ZAddTeamMembersFormSchema = z.object({
groups: z.array(
z.object({
organisationGroupId: z.string(),
teamRole: z.nativeEnum(TeamMemberRole),
}),
),
});
type TAddTeamMembersFormSchema = z.infer<typeof ZAddTeamMembersFormSchema>;
export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps) => {
const [open, setOpen] = useState(false);
const [step, setStep] = useState<'SELECT' | 'ROLES'>('SELECT');
const { t } = useLingui();
const { toast } = useToast();
const team = useCurrentTeam();
const form = useForm<TAddTeamMembersFormSchema>({
resolver: zodResolver(ZAddTeamMembersFormSchema),
defaultValues: {
groups: [],
},
});
const { mutateAsync: createTeamGroups } = trpc.team.group.createMany.useMutation();
const organisationGroupQuery = trpc.organisation.group.find.useQuery({
organisationId: team.organisationId,
perPage: 100, // Won't really work if they somehow have more than 100 groups.
types: [OrganisationGroupType.CUSTOM],
});
const teamGroupQuery = trpc.team.group.find.useQuery({
teamId: team.id,
perPage: 100, // Won't really work if they somehow have more than 100 groups.
});
const avaliableOrganisationGroups = useMemo(() => {
const organisationGroups = organisationGroupQuery.data?.data ?? [];
const teamGroups = teamGroupQuery.data?.data ?? [];
return organisationGroups.filter(
(group) => !teamGroups.some((teamGroup) => teamGroup.organisationGroupId === group.id),
);
}, [organisationGroupQuery, teamGroupQuery]);
const onFormSubmit = async ({ groups }: TAddTeamMembersFormSchema) => {
try {
await createTeamGroups({
teamId: team.id,
groups,
});
toast({
title: t`Success`,
description: t`Team members have been added.`,
duration: 5000,
});
setOpen(false);
} catch {
toast({
title: t`An unknown error occurred`,
description: t`We encountered an unknown error while attempting to add team members. Please try again later.`,
variant: 'destructive',
});
}
};
useEffect(() => {
if (!open) {
form.reset();
setStep('SELECT');
}
}, [open, form]);
return (
<Dialog
{...props}
open={open}
// Disable automatic onOpenChange events to prevent dialog from closing if auser 'accidentally' clicks the overlay.
// Since it would be annoying to redo the whole process.
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
<Button variant="secondary" onClick={() => setOpen(true)}>
<Trans>Add groups</Trans>
</Button>
</DialogTrigger>
<DialogContent hideClose={true} position="center">
{match(step)
.with('SELECT', () => (
<DialogHeader>
<DialogTitle>
<Trans>Add groups</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Select groups of members to add to the team.</Trans>
</DialogDescription>
</DialogHeader>
))
.with('ROLES', () => (
<DialogHeader>
<DialogTitle>
<Trans>Add group roles</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Configure the team roles for each group</Trans>
</DialogDescription>
</DialogHeader>
))
.exhaustive()}
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={form.formState.isSubmitting}>
{step === 'SELECT' && (
<>
<FormField
control={form.control}
name="groups"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Groups</Trans>
</FormLabel>
<FormControl>
<MultiSelectCombobox
options={avaliableOrganisationGroups.map((group) => ({
label: group.name ?? group.organisationRole,
value: group.id,
}))}
loading={organisationGroupQuery.isLoading || teamGroupQuery.isLoading}
selectedValues={field.value.map(
({ organisationGroupId }) => organisationGroupId,
)}
onChange={(value) => {
field.onChange(
value.map((organisationGroupId) => ({
organisationGroupId,
teamRole:
field.value.find(
(value) => value.organisationGroupId === organisationGroupId,
)?.teamRole || TeamMemberRole.MEMBER,
})),
);
}}
className="bg-background w-full"
emptySelectionPlaceholder={t`Select groups`}
/>
</FormControl>
<FormDescription>
<Trans>Select groups to add to this team</Trans>
</FormDescription>
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
disabled={form.getValues('groups').length === 0}
onClick={() => {
setStep('ROLES');
}}
>
<Trans>Next</Trans>
</Button>
</DialogFooter>
</>
)}
{step === 'ROLES' && (
<>
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
{form.getValues('groups').map((group, index) => (
<div className="flex w-full flex-row space-x-4" key={index}>
<div className="w-full space-y-2">
{index === 0 && (
<FormLabel>
<Trans>Group</Trans>
</FormLabel>
)}
<Input
readOnly
className="bg-background"
value={
avaliableOrganisationGroups.find(
({ id }) => id === group.organisationGroupId,
)?.name || t`Untitled Group`
}
/>
</div>
<FormField
control={form.control}
name={`groups.${index}.teamRole`}
render={({ field }) => (
<FormItem className="w-full">
{index === 0 && (
<FormLabel required>
<Trans>Team Role</Trans>
</FormLabel>
)}
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="text-muted-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{TEAM_MEMBER_ROLE_HIERARCHY[team.currentTeamRole].map(
(role) => (
<SelectItem key={role} value={role}>
{t(TEAM_MEMBER_ROLE_MAP[role]) ?? role}
</SelectItem>
),
)}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
))}
</div>
<DialogFooter className="mt-4">
<Button type="button" variant="secondary" onClick={() => setStep('SELECT')}>
<Trans>Back</Trans>
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Create Groups</Trans>
</Button>
</DialogFooter>
</>
)}
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,139 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { TeamMemberRole } from '@prisma/client';
import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams';
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,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
export type TeamGroupDeleteDialogProps = {
trigger?: React.ReactNode;
teamGroupId: string;
teamGroupName: string;
teamGroupRole: TeamMemberRole;
};
export const TeamGroupDeleteDialog = ({
trigger,
teamGroupId,
teamGroupName,
teamGroupRole,
}: TeamGroupDeleteDialogProps) => {
const [open, setOpen] = useState(false);
const { _ } = useLingui();
const { toast } = useToast();
const team = useCurrentTeam();
const { mutateAsync: deleteGroup, isPending: isDeleting } = trpc.team.group.delete.useMutation({
onSuccess: () => {
toast({
title: _(msg`Success`),
description: _(msg`You have successfully removed this group from the team.`),
duration: 5000,
});
setOpen(false);
},
onError: () => {
toast({
title: _(msg`An unknown error occurred`),
description: _(
msg`We encountered an unknown error while attempting to remove this group. Please try again later.`,
),
variant: 'destructive',
duration: 10000,
});
},
});
return (
<Dialog open={open} onOpenChange={(value) => !isDeleting && setOpen(value)}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="secondary">
<Trans>Delete team group</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Are you sure?</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>
You are about to remove the following group from{' '}
<span className="font-semibold">{team.name}</span>.
</Trans>
</DialogDescription>
</DialogHeader>
{isTeamRoleWithinUserHierarchy(team.currentTeamRole, teamGroupRole) ? (
<>
<Alert variant="neutral">
<AlertDescription className="text-center font-semibold">
{teamGroupName}
</AlertDescription>
</Alert>
<fieldset disabled={isDeleting}>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
variant="destructive"
loading={isDeleting}
onClick={async () =>
deleteGroup({
teamId: team.id,
teamGroupId: teamGroupId,
})
}
>
<Trans>Delete</Trans>
</Button>
</DialogFooter>
</fieldset>
</>
) : (
<>
<Alert variant="neutral">
<AlertDescription className="text-center font-semibold">
<Trans>You cannot delete a group which has a higher role than you.</Trans>
</AlertDescription>
</Alert>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Close</Trans>
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,209 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { TeamMemberRole } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { TEAM_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/teams';
import { EXTENDED_TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams';
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,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
export type TeamGroupUpdateDialogProps = {
trigger?: React.ReactNode;
teamGroupId: string;
teamGroupName: string;
teamGroupRole: TeamMemberRole;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZUpdateTeamGroupFormSchema = z.object({
role: z.nativeEnum(TeamMemberRole),
});
type ZUpdateTeamGroupSchema = z.infer<typeof ZUpdateTeamGroupFormSchema>;
export const TeamGroupUpdateDialog = ({
trigger,
teamGroupId,
teamGroupName,
teamGroupRole,
...props
}: TeamGroupUpdateDialogProps) => {
const [open, setOpen] = useState(false);
const { _ } = useLingui();
const { toast } = useToast();
const team = useCurrentTeam();
const form = useForm<ZUpdateTeamGroupSchema>({
resolver: zodResolver(ZUpdateTeamGroupFormSchema),
defaultValues: {
role: teamGroupRole,
},
});
const { mutateAsync: updateTeamGroup } = trpc.team.group.update.useMutation();
const onFormSubmit = async ({ role }: ZUpdateTeamGroupSchema) => {
try {
await updateTeamGroup({
id: teamGroupId,
data: {
teamRole: role,
},
});
toast({
title: _(msg`Success`),
description: _(msg`You have updated the team group.`),
duration: 5000,
});
setOpen(false);
} catch {
toast({
title: _(msg`An unknown error occurred`),
description: _(
msg`We encountered an unknown error while attempting to update this team member. Please try again later.`,
),
variant: 'destructive',
});
}
};
useEffect(() => {
if (!open) {
return;
}
form.reset();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, team.currentTeamRole, teamGroupRole, form, toast]);
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger ?? (
<Button variant="secondary">
<Trans>Update team group</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Update team group</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
You are currently updating the <span className="font-bold">{teamGroupName}</span> team
group.
</Trans>
</DialogDescription>
</DialogHeader>
{isTeamRoleWithinUserHierarchy(team.currentTeamRole, teamGroupRole) ? (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel required>
<Trans>Role</Trans>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="text-muted-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent className="w-full" position="popper">
{TEAM_MEMBER_ROLE_HIERARCHY[team.currentTeamRole].map((role) => (
<SelectItem key={role} value={role}>
{_(EXTENDED_TEAM_MEMBER_ROLE_MAP[role]) ?? role}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="mt-4">
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
) : (
<>
<Alert variant="neutral">
<AlertDescription className="text-center font-semibold">
<Trans>You cannot modify a group which has a higher role than you.</Trans>
</AlertDescription>
</Alert>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Close</Trans>
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,93 @@
import { Trans, useLingui } from '@lingui/react/macro';
import type { TeamGroup } from '@prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
type TeamMemberInheritDisableDialogProps = {
group: TeamGroup;
};
export const TeamMemberInheritDisableDialog = ({ group }: TeamMemberInheritDisableDialogProps) => {
const { toast } = useToast();
const { t } = useLingui();
const team = useCurrentTeam();
const deleteGroupMutation = trpc.team.group.delete.useMutation({
onSuccess: () => {
toast({
title: t`Access disabled`,
duration: 5000,
});
},
onError: () => {
toast({
title: t`Something went wrong`,
description: t`We encountered an unknown error while attempting to disable access.`,
variant: 'destructive',
duration: 5000,
});
},
});
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">
<Trans>Disable access</Trans>
</Button>
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Are you sure?</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>
You are about to remove default access to this team for all organisation members. Any
members not explicitly added to this team will no longer have access.
</Trans>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary">
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button
type="submit"
variant="destructive"
loading={deleteGroupMutation.isPending}
onClick={() =>
deleteGroupMutation.mutate({
teamId: team.id,
teamGroupId: group.id,
})
}
>
<Trans>Disable</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,109 @@
import { Trans, useLingui } from '@lingui/react/macro';
import { OrganisationGroupType, OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
export const TeamMemberInheritEnableDialog = () => {
const organisation = useCurrentOrganisation();
const team = useCurrentTeam();
const { toast } = useToast();
const { t } = useLingui();
const { mutateAsync: createTeamGroups, isPending } = trpc.team.group.createMany.useMutation({
onSuccess: () => {
toast({
title: t`Access enabled`,
duration: 5000,
});
},
onError: () => {
toast({
title: t`Something went wrong`,
description: t`We encountered an unknown error while attempting to enable access.`,
variant: 'destructive',
duration: 5000,
});
},
});
const organisationGroupQuery = trpc.organisation.group.find.useQuery({
organisationId: organisation.id,
perPage: 1,
types: [OrganisationGroupType.INTERNAL_ORGANISATION],
organisationRoles: [OrganisationMemberRole.MEMBER],
});
const enableAccessGroup = async () => {
if (!organisationGroupQuery.data?.data[0]?.id) {
return;
}
await createTeamGroups({
teamId: team.id,
groups: [
{
organisationGroupId: organisationGroupQuery.data?.data[0]?.id,
teamRole: TeamMemberRole.MEMBER,
},
],
});
};
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">
<Trans>Enable access</Trans>
</Button>
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Are you sure?</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>
You are about to give all organisation members access to this team under their
organisation role.
</Trans>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary">
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button
type="submit"
disabled={organisationGroupQuery.isPending}
loading={isPending}
onClick={enableAccessGroup}
>
<Trans>Enable</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -1,117 +0,0 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { TeamMemberRole } from '@prisma/client';
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { trpc } from '@documenso/trpc/react';
import { Alert } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type TeamLeaveDialogProps = {
teamId: number;
teamName: string;
teamAvatarImageId?: string | null;
role: TeamMemberRole;
trigger?: React.ReactNode;
};
export const TeamLeaveDialog = ({
trigger,
teamId,
teamName,
teamAvatarImageId,
role,
}: TeamLeaveDialogProps) => {
const [open, setOpen] = useState(false);
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: leaveTeam, isPending: isLeavingTeam } = trpc.team.leaveTeam.useMutation({
onSuccess: () => {
toast({
title: _(msg`Success`),
description: _(msg`You have successfully left this team.`),
duration: 5000,
});
setOpen(false);
},
onError: () => {
toast({
title: _(msg`An unknown error occurred`),
description: _(
msg`We encountered an unknown error while attempting to leave this team. Please try again later.`,
),
variant: 'destructive',
duration: 10000,
});
},
});
return (
<Dialog open={open} onOpenChange={(value) => !isLeavingTeam && setOpen(value)}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="destructive">
<Trans>Leave team</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Are you sure?</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>You are about to leave the following team.</Trans>
</DialogDescription>
</DialogHeader>
<Alert variant="neutral" padding="tight">
<AvatarWithText
avatarClass="h-12 w-12"
avatarSrc={formatAvatarUrl(teamAvatarImageId)}
avatarFallback={teamName.slice(0, 1).toUpperCase()}
primaryText={teamName}
secondaryText={_(TEAM_MEMBER_ROLE_MAP[role])}
/>
</Alert>
<fieldset disabled={isLeavingTeam}>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
variant="destructive"
loading={isLeavingTeam}
onClick={async () => leaveTeam({ teamId })}
>
<Trans>Leave</Trans>
</Button>
</DialogFooter>
</fieldset>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,305 @@
import { useEffect, useMemo, 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 { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
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 { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
export type TeamMemberCreateDialogProps = {
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZAddTeamMembersFormSchema = z.object({
members: z.array(
z.object({
organisationMemberId: z.string(),
teamRole: z.nativeEnum(TeamMemberRole),
}),
),
});
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 { t } = useLingui();
const { toast } = useToast();
const team = useCurrentTeam();
const form = useForm<TAddTeamMembersFormSchema>({
resolver: zodResolver(ZAddTeamMembersFormSchema),
defaultValues: {
members: [],
},
});
const { mutateAsync: createTeamMembers } = trpc.team.member.createMany.useMutation();
const organisationMemberQuery = trpc.organisation.member.find.useQuery({
organisationId: team.organisationId,
});
const teamMemberQuery = trpc.team.member.find.useQuery({
teamId: team.id,
});
const avaliableOrganisationMembers = useMemo(() => {
const organisationMembers = organisationMemberQuery.data?.data ?? [];
const teamMembers = teamMemberQuery.data?.data ?? [];
return organisationMembers.filter(
(member) => !teamMembers.some((teamMember) => teamMember.id === member.id),
);
}, [organisationMemberQuery, teamMemberQuery]);
const onFormSubmit = async ({ members }: TAddTeamMembersFormSchema) => {
try {
await createTeamMembers({
teamId: team.id,
organisationMembers: members,
});
toast({
title: t`Success`,
description: t`Team members have been added.`,
duration: 5000,
});
setOpen(false);
} catch {
toast({
title: t`An unknown error occurred`,
description: t`We encountered an unknown error while attempting to add team members. Please try again later.`,
variant: 'destructive',
});
}
};
useEffect(() => {
if (!open) {
form.reset();
setStep('SELECT');
}
}, [open, form]);
return (
<Dialog
{...props}
open={open}
// Disable automatic onOpenChange events to prevent dialog from closing if auser 'accidentally' clicks the overlay.
// 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>
</DialogTrigger>
<DialogContent hideClose={true} position="center">
{match(step)
.with('SELECT', () => (
<DialogHeader>
<DialogTitle>
<Trans>Add members</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Select members or groups of members to add to the team.</Trans>
</DialogDescription>
</DialogHeader>
))
.with('MEMBERS', () => (
<DialogHeader>
<DialogTitle>
<Trans>Add members roles</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Configure the team roles for each member</Trans>
</DialogDescription>
</DialogHeader>
))
.exhaustive()}
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={form.formState.isSubmitting}>
{step === 'SELECT' && (
<>
<FormField
control={form.control}
name="members"
render={({ field }) => (
<FormItem>
<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`}
/>
</FormControl>
<FormDescription>
<Trans>Select members to add to this team</Trans>
</FormDescription>
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
disabled={form.getValues('members').length === 0}
onClick={() => {
setStep('MEMBERS');
}}
>
<Trans>Next</Trans>
</Button>
</DialogFooter>
</>
)}
{step === 'MEMBERS' && (
<>
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
{form.getValues('members').map((member, index) => (
<div className="flex w-full flex-row space-x-4" key={index}>
<div className="w-full space-y-2">
{index === 0 && (
<FormLabel>
<Trans>Member</Trans>
</FormLabel>
)}
<Input
readOnly
className="bg-background"
value={
organisationMemberQuery.data?.data.find(
({ id }) => id === member.organisationMemberId,
)?.name || ''
}
/>
</div>
<FormField
control={form.control}
name={`members.${index}.teamRole`}
render={({ field }) => (
<FormItem className="w-full">
{index === 0 && (
<FormLabel required>
<Trans>Team Role</Trans>
</FormLabel>
)}
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="text-muted-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{TEAM_MEMBER_ROLE_HIERARCHY[team.currentTeamRole].map(
(role) => (
<SelectItem key={role} value={role}>
{t(TEAM_MEMBER_ROLE_MAP[role]) ?? role}
</SelectItem>
),
)}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
))}
</div>
<DialogFooter className="mt-4">
<Button type="button" variant="secondary" onClick={() => setStep('SELECT')}>
<Trans>Back</Trans>
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Add Members</Trans>
</Button>
</DialogFooter>
</>
)}
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -5,7 +5,7 @@ import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { trpc } from '@documenso/trpc/react';
import { Alert } from '@documenso/ui/primitives/alert';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import {
@ -22,9 +22,10 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export type TeamMemberDeleteDialogProps = {
teamId: number;
teamName: string;
teamMemberId: number;
teamMemberName: string;
teamMemberEmail: string;
memberId: string;
memberName: string;
memberEmail: string;
isInheritMemberEnabled: boolean | null;
trigger?: React.ReactNode;
};
@ -32,17 +33,18 @@ export const TeamMemberDeleteDialog = ({
trigger,
teamId,
teamName,
teamMemberId,
teamMemberName,
teamMemberEmail,
memberId,
memberName,
memberEmail,
isInheritMemberEnabled,
}: TeamMemberDeleteDialogProps) => {
const [open, setOpen] = useState(false);
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: deleteTeamMembers, isPending: isDeletingTeamMember } =
trpc.team.deleteTeamMembers.useMutation({
const { mutateAsync: deleteTeamMember, isPending: isDeletingTeamMember } =
trpc.team.member.delete.useMutation({
onSuccess: () => {
toast({
title: _(msg`Success`),
@ -69,7 +71,7 @@ export const TeamMemberDeleteDialog = ({
<DialogTrigger asChild>
{trigger ?? (
<Button variant="secondary">
<Trans>Delete team member</Trans>
<Trans>Remove team member</Trans>
</Button>
)}
</DialogTrigger>
@ -88,29 +90,42 @@ export const TeamMemberDeleteDialog = ({
</DialogDescription>
</DialogHeader>
<Alert variant="neutral" padding="tight">
<AvatarWithText
avatarClass="h-12 w-12"
avatarFallback={teamMemberName.slice(0, 1).toUpperCase()}
primaryText={<span className="font-semibold">{teamMemberName}</span>}
secondaryText={teamMemberEmail}
/>
</Alert>
{isInheritMemberEnabled ? (
<Alert variant="neutral">
<AlertDescription>
<Trans>
You cannot remove members from this team if the inherit member feature is enabled.
</Trans>
</AlertDescription>
</Alert>
) : (
<Alert variant="neutral" padding="tight">
<AvatarWithText
avatarClass="h-12 w-12"
avatarFallback={memberName.slice(0, 1).toUpperCase()}
primaryText={<span className="font-semibold">{memberName}</span>}
secondaryText={memberEmail}
/>
</Alert>
)}
<fieldset disabled={isDeletingTeamMember}>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
<Trans>Close</Trans>
</Button>
<Button
type="submit"
variant="destructive"
loading={isDeletingTeamMember}
onClick={async () => deleteTeamMembers({ teamId, teamMemberIds: [teamMemberId] })}
>
<Trans>Delete</Trans>
</Button>
{!isInheritMemberEnabled && (
<Button
type="submit"
variant="destructive"
disabled={Boolean(isInheritMemberEnabled)}
loading={isDeletingTeamMember}
onClick={async () => deleteTeamMember({ teamId, memberId })}
>
<Trans>Remove</Trans>
</Button>
)}
</DialogFooter>
</fieldset>
</DialogContent>

View File

@ -1,415 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { TeamMemberRole } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { Download, Mail, MailIcon, PlusCircle, Trash, Upload, UsersIcon } from 'lucide-react';
import Papa, { type ParseResult } from 'papaparse';
import { useFieldArray, useForm } from 'react-hook-form';
import { z } from 'zod';
import { downloadFile } from '@documenso/lib/client-only/download-file';
import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
import { trpc } from '@documenso/trpc/react';
import { ZCreateTeamMemberInvitesMutationSchema } from '@documenso/trpc/server/team-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
export type TeamMemberInviteDialogProps = {
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZInviteTeamMembersFormSchema = z
.object({
invitations: ZCreateTeamMemberInvitesMutationSchema.shape.invitations,
})
// Display exactly which rows are duplicates.
.superRefine((items, ctx) => {
const uniqueEmails = new Map<string, number>();
for (const [index, invitation] of items.invitations.entries()) {
const email = invitation.email.toLowerCase();
const firstFoundIndex = uniqueEmails.get(email);
if (firstFoundIndex === undefined) {
uniqueEmails.set(email, index);
continue;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Emails must be unique',
path: ['invitations', index, 'email'],
});
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Emails must be unique',
path: ['invitations', firstFoundIndex, 'email'],
});
}
});
type TInviteTeamMembersFormSchema = z.infer<typeof ZInviteTeamMembersFormSchema>;
type TabTypes = 'INDIVIDUAL' | 'BULK';
const ZImportTeamMemberSchema = z.array(
z.object({
email: z.string().email(),
role: z.nativeEnum(TeamMemberRole),
}),
);
export const TeamMemberInviteDialog = ({ trigger, ...props }: TeamMemberInviteDialogProps) => {
const [open, setOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [invitationType, setInvitationType] = useState<TabTypes>('INDIVIDUAL');
const { _ } = useLingui();
const { toast } = useToast();
const team = useCurrentTeam();
const form = useForm<TInviteTeamMembersFormSchema>({
resolver: zodResolver(ZInviteTeamMembersFormSchema),
defaultValues: {
invitations: [
{
email: '',
role: TeamMemberRole.MEMBER,
},
],
},
});
const {
append: appendTeamMemberInvite,
fields: teamMemberInvites,
remove: removeTeamMemberInvite,
} = useFieldArray({
control: form.control,
name: 'invitations',
});
const { mutateAsync: createTeamMemberInvites } = trpc.team.createTeamMemberInvites.useMutation();
const onAddTeamMemberInvite = () => {
appendTeamMemberInvite({
email: '',
role: TeamMemberRole.MEMBER,
});
};
const onFormSubmit = async ({ invitations }: TInviteTeamMembersFormSchema) => {
try {
await createTeamMemberInvites({
teamId: team.id,
invitations,
});
toast({
title: _(msg`Success`),
description: _(msg`Team invitations have been sent.`),
duration: 5000,
});
setOpen(false);
} catch {
toast({
title: _(msg`An unknown error occurred`),
description: _(
msg`We encountered an unknown error while attempting to invite team members. Please try again later.`,
),
variant: 'destructive',
});
}
};
useEffect(() => {
if (!open) {
form.reset();
setInvitationType('INDIVIDUAL');
}
}, [open, form]);
const onFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files?.length) {
return;
}
const csvFile = e.target.files[0];
Papa.parse(csvFile, {
skipEmptyLines: true,
comments: 'Work email,Job title',
complete: (results: ParseResult<string[]>) => {
const members = results.data.map((row) => {
const [email, role] = row;
return {
email: email.trim(),
role: role.trim().toUpperCase(),
};
});
// Remove the first row if it contains the headers.
if (members.length > 1 && members[0].role.toUpperCase() === 'ROLE') {
members.shift();
}
try {
const importedInvitations = ZImportTeamMemberSchema.parse(members);
form.setValue('invitations', importedInvitations);
form.clearErrors('invitations');
setInvitationType('INDIVIDUAL');
} catch (err) {
console.error(err);
toast({
title: _(msg`Something went wrong`),
description: _(
msg`Please check the CSV file and make sure it is according to our format`,
),
variant: 'destructive',
});
}
},
});
};
const downloadTemplate = () => {
const data = [
{ email: 'admin@documenso.com', role: 'Admin' },
{ email: 'manager@documenso.com', role: 'Manager' },
{ email: 'member@documenso.com', role: 'Member' },
];
const csvContent =
'Email address,Role\n' + data.map((row) => `${row.email},${row.role}`).join('\n');
const blob = new Blob([csvContent], {
type: 'text/csv',
});
downloadFile({
filename: 'documenso-team-member-invites-template.csv',
data: blob,
});
};
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger ?? (
<Button variant="secondary">
<Trans>Invite member</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Invite team members</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>An email containing an invitation will be sent to each member.</Trans>
</DialogDescription>
</DialogHeader>
<Tabs
defaultValue="INDIVIDUAL"
value={invitationType}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
onValueChange={(value) => setInvitationType(value as TabTypes)}
>
<TabsList className="w-full">
<TabsTrigger value="INDIVIDUAL" className="hover:text-foreground w-full">
<MailIcon size={20} className="mr-2" />
<Trans>Invite Members</Trans>
</TabsTrigger>
<TabsTrigger value="BULK" className="hover:text-foreground w-full">
<UsersIcon size={20} className="mr-2" /> <Trans>Bulk Import</Trans>
</TabsTrigger>
</TabsList>
<TabsContent value="INDIVIDUAL">
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
{teamMemberInvites.map((teamMemberInvite, index) => (
<div className="flex w-full flex-row space-x-4" key={teamMemberInvite.id}>
<FormField
control={form.control}
name={`invitations.${index}.email`}
render={({ field }) => (
<FormItem className="w-full">
{index === 0 && (
<FormLabel required>
<Trans>Email address</Trans>
</FormLabel>
)}
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`invitations.${index}.role`}
render={({ field }) => (
<FormItem className="w-full">
{index === 0 && (
<FormLabel required>
<Trans>Role</Trans>
</FormLabel>
)}
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="text-muted-foreground max-w-[200px]">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{TEAM_MEMBER_ROLE_HIERARCHY[team.currentTeamMember.role].map(
(role) => (
<SelectItem key={role} value={role}>
{_(TEAM_MEMBER_ROLE_MAP[role]) ?? role}
</SelectItem>
),
)}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<button
type="button"
className={cn(
'justify-left inline-flex h-10 w-10 items-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50',
index === 0 ? 'mt-8' : 'mt-0',
)}
disabled={teamMemberInvites.length === 1}
onClick={() => removeTeamMemberInvite(index)}
>
<Trash className="h-5 w-5" />
</button>
</div>
))}
</div>
<Button
type="button"
size="sm"
variant="outline"
className="w-fit"
onClick={() => onAddTeamMemberInvite()}
>
<PlusCircle className="mr-2 h-4 w-4" />
<Trans>Add more</Trans>
</Button>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
{!form.formState.isSubmitting && <Mail className="mr-2 h-4 w-4" />}
<Trans>Invite</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</TabsContent>
<TabsContent value="BULK">
<div className="mt-4 space-y-4">
<Card gradient className="h-32">
<CardContent
className="text-muted-foreground/80 hover:text-muted-foreground/90 flex h-full cursor-pointer flex-col items-center justify-center rounded-lg p-0 transition-colors"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="h-5 w-5" />
<p className="mt-1 text-sm">
<Trans>Click here to upload</Trans>
</p>
<input
onChange={onFileInputChange}
type="file"
ref={fileInputRef}
accept=".csv"
hidden
/>
</CardContent>
</Card>
<DialogFooter>
<Button type="button" variant="secondary" onClick={downloadTemplate}>
<Download className="mr-2 h-4 w-4" />
<Trans>Template</Trans>
</Button>
</DialogFooter>
</div>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
};

View File

@ -9,7 +9,8 @@ import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
import { TEAM_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/teams';
import { EXTENDED_TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
@ -43,9 +44,9 @@ export type TeamMemberUpdateDialogProps = {
currentUserTeamRole: TeamMemberRole;
trigger?: React.ReactNode;
teamId: number;
teamMemberId: number;
teamMemberName: string;
teamMemberRole: TeamMemberRole;
memberId: string;
memberName: string;
memberTeamRole: TeamMemberRole;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZUpdateTeamMemberFormSchema = z.object({
@ -58,9 +59,9 @@ export const TeamMemberUpdateDialog = ({
currentUserTeamRole,
trigger,
teamId,
teamMemberId,
teamMemberName,
teamMemberRole,
memberId,
memberName,
memberTeamRole,
...props
}: TeamMemberUpdateDialogProps) => {
const [open, setOpen] = useState(false);
@ -71,17 +72,17 @@ export const TeamMemberUpdateDialog = ({
const form = useForm<ZUpdateTeamMemberSchema>({
resolver: zodResolver(ZUpdateTeamMemberFormSchema),
defaultValues: {
role: teamMemberRole,
role: memberTeamRole,
},
});
const { mutateAsync: updateTeamMember } = trpc.team.updateTeamMember.useMutation();
const { mutateAsync: updateTeamMember } = trpc.team.member.update.useMutation();
const onFormSubmit = async ({ role }: ZUpdateTeamMemberSchema) => {
try {
await updateTeamMember({
teamId,
teamMemberId,
memberId,
data: {
role,
},
@ -89,7 +90,7 @@ export const TeamMemberUpdateDialog = ({
toast({
title: _(msg`Success`),
description: _(msg`You have updated ${teamMemberName}.`),
description: _(msg`You have updated ${memberName}.`),
duration: 5000,
});
@ -112,7 +113,7 @@ export const TeamMemberUpdateDialog = ({
form.reset();
if (!isTeamRoleWithinUserHierarchy(currentUserTeamRole, teamMemberRole)) {
if (!isTeamRoleWithinUserHierarchy(currentUserTeamRole, memberTeamRole)) {
setOpen(false);
toast({
@ -121,7 +122,7 @@ export const TeamMemberUpdateDialog = ({
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, currentUserTeamRole, teamMemberRole, form, toast]);
}, [open, currentUserTeamRole, memberTeamRole, form, toast]);
return (
<Dialog
@ -143,9 +144,9 @@ export const TeamMemberUpdateDialog = ({
<Trans>Update team member</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<DialogDescription>
<Trans>
You are currently updating <span className="font-bold">{teamMemberName}.</span>
You are currently updating <span className="font-bold">{memberName}.</span>
</Trans>
</DialogDescription>
</DialogHeader>
@ -170,7 +171,7 @@ export const TeamMemberUpdateDialog = ({
<SelectContent className="w-full" position="popper">
{TEAM_MEMBER_ROLE_HIERARCHY[currentUserTeamRole].map((role) => (
<SelectItem key={role} value={role}>
{_(TEAM_MEMBER_ROLE_MAP[role]) ?? role}
{_(EXTENDED_TEAM_MEMBER_ROLE_MAP[role]) ?? role}
</SelectItem>
))}
</SelectContent>

View File

@ -1,272 +0,0 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import { z } from 'zod';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
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,
DialogTrigger,
} 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type TeamTransferDialogProps = {
teamId: number;
teamName: string;
ownerUserId: number;
trigger?: React.ReactNode;
};
export const TeamTransferDialog = ({
trigger,
teamId,
teamName,
ownerUserId,
}: TeamTransferDialogProps) => {
const [open, setOpen] = useState(false);
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const { mutateAsync: requestTeamOwnershipTransfer } =
trpc.team.requestTeamOwnershipTransfer.useMutation();
const {
data,
refetch: refetchTeamMembers,
isPending: loadingTeamMembers,
isLoadingError: loadingTeamMembersError,
} = trpc.team.getTeamMembers.useQuery({
teamId,
});
const confirmTransferMessage = _(msg`transfer ${teamName}`);
const ZTransferTeamFormSchema = z.object({
teamName: z.literal(confirmTransferMessage, {
errorMap: () => ({ message: `You must enter '${confirmTransferMessage}' to proceed` }),
}),
newOwnerUserId: z.string(),
clearPaymentMethods: z.boolean(),
});
const form = useForm<z.infer<typeof ZTransferTeamFormSchema>>({
resolver: zodResolver(ZTransferTeamFormSchema),
defaultValues: {
teamName: '',
clearPaymentMethods: false,
},
});
const onFormSubmit = async ({
newOwnerUserId,
clearPaymentMethods,
}: z.infer<typeof ZTransferTeamFormSchema>) => {
try {
await requestTeamOwnershipTransfer({
teamId,
newOwnerUserId: Number.parseInt(newOwnerUserId),
clearPaymentMethods,
});
await revalidate();
toast({
title: _(msg`Success`),
description: _(msg`An email requesting the transfer of this team has been sent.`),
duration: 5000,
});
setOpen(false);
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),
description: _(
msg`We encountered an unknown error while attempting to request a transfer of this team. Please try again later.`,
),
variant: 'destructive',
duration: 10000,
});
}
};
useEffect(() => {
if (!open) {
form.reset();
}
}, [open, form]);
useEffect(() => {
if (open && loadingTeamMembersError) {
void refetchTeamMembers();
}
}, [open, loadingTeamMembersError, refetchTeamMembers]);
const teamMembers = data
? data.filter((teamMember) => teamMember.userId !== ownerUserId)
: undefined;
return (
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="outline" className="bg-background">
<Trans>Transfer team</Trans>
</Button>
)}
</DialogTrigger>
{teamMembers && teamMembers.length > 0 ? (
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Transfer team</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>Transfer ownership of this team to a selected team member.</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<FormField
control={form.control}
name="newOwnerUserId"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel required>
<Trans>New team owner</Trans>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="text-muted-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{teamMembers.map((teamMember) => (
<SelectItem
key={teamMember.userId}
value={teamMember.userId.toString()}
>
{teamMember.user.name} ({teamMember.user.email})
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="teamName"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>
Confirm by typing{' '}
<span className="text-destructive">{confirmTransferMessage}</span>
</Trans>
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Alert variant="neutral">
<AlertDescription>
<ul className="list-outside list-disc space-y-2 pl-4">
{IS_BILLING_ENABLED() && (
<li>
<Trans>
Any payment methods attached to this team will remain attached to this
team. Please contact us if you need to update this information.
</Trans>
</li>
)}
<li>
<Trans>
The selected team member will receive an email which they must accept
before the team is transferred
</Trans>
</li>
</ul>
</AlertDescription>
</Alert>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" variant="destructive" loading={form.formState.isSubmitting}>
<Trans>Request transfer</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
) : (
<DialogContent
position="center"
className="text-muted-foreground flex items-center justify-center py-16 text-sm"
>
{loadingTeamMembers ? (
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
) : (
<p className="text-center text-sm">
{loadingTeamMembersError ? (
<Trans>An error occurred while loading team members. Please try again later.</Trans>
) : (
<Trans>You must have at least one other team member to transfer ownership.</Trans>
)}
</p>
)}
</DialogContent>
)}
</Dialog>
);
};

View File

@ -21,7 +21,7 @@ import {
import { Form, FormControl, FormField, FormItem } from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
import { useCurrentTeam } from '~/providers/team';
const ZBulkSendFormSchema = z.object({
file: z.instanceof(File),
@ -46,7 +46,7 @@ export const TemplateBulkSendDialog = ({
const { _ } = useLingui();
const { toast } = useToast();
const team = useOptionalCurrentTeam();
const team = useCurrentTeam();
const form = useForm<TBulkSendFormSchema>({
resolver: zodResolver(ZBulkSendFormSchema),

View File

@ -8,6 +8,7 @@ import { useNavigate } from 'react-router';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
@ -23,18 +24,21 @@ import {
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
type TemplateCreateDialogProps = {
templateRootPath: string;
folderId?: string;
};
export const TemplateCreateDialog = ({ templateRootPath, folderId }: TemplateCreateDialogProps) => {
export const TemplateCreateDialog = ({ folderId }: TemplateCreateDialogProps) => {
const navigate = useNavigate();
const { user } = useSession();
const { toast } = useToast();
const { _ } = useLingui();
const team = useCurrentTeam();
const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation();
const [showTemplateCreateDialog, setShowTemplateCreateDialog] = useState(false);
@ -66,7 +70,7 @@ export const TemplateCreateDialog = ({ templateRootPath, folderId }: TemplateCre
setShowTemplateCreateDialog(false);
await navigate(`${templateRootPath}/${id}/edit`);
await navigate(`${formatTemplatesPath(team.url)}/${id}/edit`);
} catch {
toast({
title: _(msg`Something went wrong`),

View File

@ -15,6 +15,7 @@ import { P, match } from 'ts-pattern';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { DIRECT_TEMPLATE_DOCUMENTATION } from '@documenso/lib/constants/template';
@ -75,6 +76,8 @@ export const TemplateDirectLinkDialog = ({
token ? 'MANAGE' : 'ONBOARD',
);
const organisation = useCurrentOrganisation();
const validDirectTemplateRecipients = useMemo(
() =>
template.recipients.filter(
@ -237,7 +240,7 @@ export const TemplateDirectLinkDialog = ({
templates.{' '}
<Link
className="mt-1 block underline underline-offset-4"
to="/settings/billing"
to={`/o/${organisation.url}/settings/billing`}
>
Upgrade your account to continue!
</Link>

View File

@ -1,164 +0,0 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderPlusIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useNavigate, useParams } from 'react-router';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { FolderType } from '@documenso/lib/types/folder-type';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} 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';
import { useOptionalCurrentTeam } from '~/providers/team';
const ZCreateFolderFormSchema = z.object({
name: z.string().min(1, { message: 'Folder name is required' }),
});
type TCreateFolderFormSchema = z.infer<typeof ZCreateFolderFormSchema>;
export type TemplateFolderCreateDialogProps = {
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const TemplateFolderCreateDialog = ({
trigger,
...props
}: TemplateFolderCreateDialogProps) => {
const { toast } = useToast();
const { _ } = useLingui();
const navigate = useNavigate();
const team = useOptionalCurrentTeam();
const { folderId } = useParams();
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
const { mutateAsync: createFolder } = trpc.folder.createFolder.useMutation();
const form = useForm<TCreateFolderFormSchema>({
resolver: zodResolver(ZCreateFolderFormSchema),
defaultValues: {
name: '',
},
});
const onSubmit = async (data: TCreateFolderFormSchema) => {
try {
const newFolder = await createFolder({
name: data.name,
parentId: folderId,
type: FolderType.TEMPLATE,
});
setIsCreateFolderOpen(false);
toast({
description: _(msg`Folder created successfully`),
});
const templatesPath = formatTemplatesPath(team?.url);
void navigate(`${templatesPath}/f/${newFolder.id}`);
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.ALREADY_EXISTS) {
toast({
title: _(msg`Failed to create folder`),
description: _(msg`This folder name is already taken.`),
variant: 'destructive',
});
return;
}
toast({
title: _(msg`Failed to create folder`),
description: _(msg`An unknown error occurred while creating the folder.`),
variant: 'destructive',
});
}
};
useEffect(() => {
if (!isCreateFolderOpen) {
form.reset();
}
}, [isCreateFolderOpen, form]);
return (
<Dialog {...props} open={isCreateFolderOpen} onOpenChange={setIsCreateFolderOpen}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="outline" className="flex items-center space-x-2">
<FolderPlusIcon className="h-4 w-4" />
<span>Create Folder</span>
</Button>
)}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Folder</DialogTitle>
<DialogDescription>
Enter a name for your new folder. Folders help you organize your templates.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Folder Name</FormLabel>
<FormControl>
<Input placeholder="My Folder" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => setIsCreateFolderOpen(false)}
>
Cancel
</Button>
<Button type="submit">Create</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -1,163 +0,0 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
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 TemplateFolderDeleteDialogProps = {
folder: TFolderWithSubfolders | null;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const TemplateFolderDeleteDialog = ({
folder,
isOpen,
onOpenChange,
}: TemplateFolderDeleteDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: deleteFolder } = trpc.folder.deleteFolder.useMutation();
const deleteMessage = _(msg`delete ${folder?.name ?? 'folder'}`);
const ZDeleteFolderFormSchema = z.object({
confirmText: z.literal(deleteMessage, {
errorMap: () => ({ message: _(msg`You must type '${deleteMessage}' to confirm`) }),
}),
});
type TDeleteFolderFormSchema = z.infer<typeof ZDeleteFolderFormSchema>;
const form = useForm<TDeleteFolderFormSchema>({
resolver: zodResolver(ZDeleteFolderFormSchema),
defaultValues: {
confirmText: '',
},
});
const onFormSubmit = async () => {
if (!folder) return;
try {
await deleteFolder({
id: folder.id,
});
onOpenChange(false);
toast({
title: 'Folder deleted successfully',
});
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.NOT_FOUND) {
toast({
title: 'Folder not found',
description: _(msg`The folder you are trying to delete does not exist.`),
variant: 'destructive',
});
return;
}
toast({
title: 'Failed to delete folder',
description: _(msg`An unknown error occurred while deleting the folder.`),
variant: 'destructive',
});
}
};
useEffect(() => {
if (!isOpen) {
form.reset();
}
}, [isOpen]);
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Folder</DialogTitle>
<DialogDescription>
Are you sure you want to delete this folder?
{folder && folder._count.documents > 0 && (
<span className="text-destructive mt-2 block">
This folder contains {folder._count.documents} document(s). Deleting it will also
delete all documents in the folder.
</span>
)}
{folder && folder._count.subfolders > 0 && (
<span className="text-destructive mt-2 block">
This folder contains {folder._count.subfolders} subfolder(s). Deleting it will
delete all subfolders and their contents.
</span>
)}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4">
<FormField
control={form.control}
name="confirmText"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>
Confirm by typing:{' '}
<span className="font-sm text-destructive font-semibold">
{deleteMessage}
</span>
</Trans>
</FormLabel>
<FormControl>
<Input {...field} placeholder={deleteMessage} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button variant="destructive" type="submit" disabled={!form.formState.isValid}>
Delete
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -1,175 +0,0 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderIcon, HomeIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type TemplateFolderMoveDialogProps = {
foldersData: TFolderWithSubfolders[] | undefined;
folder: TFolderWithSubfolders | null;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZMoveFolderFormSchema = z.object({
targetFolderId: z.string().optional(),
});
type TMoveFolderFormSchema = z.infer<typeof ZMoveFolderFormSchema>;
export const TemplateFolderMoveDialog = ({
foldersData,
folder,
isOpen,
onOpenChange,
}: TemplateFolderMoveDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: moveFolder } = trpc.folder.moveFolder.useMutation();
const form = useForm<TMoveFolderFormSchema>({
resolver: zodResolver(ZMoveFolderFormSchema),
defaultValues: {
targetFolderId: folder?.parentId ?? '',
},
});
const onFormSubmit = async ({ targetFolderId }: TMoveFolderFormSchema) => {
if (!folder) return;
try {
await moveFolder({
id: folder.id,
parentId: targetFolderId ?? '',
});
onOpenChange(false);
toast({
title: 'Folder moved successfully',
});
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.NOT_FOUND) {
toast({
title: 'Folder not found',
description: _(msg`The folder you are trying to move does not exist.`),
variant: 'destructive',
});
return;
}
toast({
title: 'Failed to move folder',
description: _(msg`An unknown error occurred while moving the folder.`),
variant: 'destructive',
});
}
};
useEffect(() => {
if (!isOpen) {
form.reset();
}
}, [isOpen, form]);
// Filter out the current folder and only show folders of the same type
const filteredFolders = foldersData?.filter(
(f) => f.id !== folder?.id && f.type === folder?.type,
);
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Move Folder</DialogTitle>
<DialogDescription>Select a destination for this folder.</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4 py-4">
<FormField
control={form.control}
name="targetFolderId"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="space-y-2">
<Button
type="button"
variant={!field.value ? 'default' : 'outline'}
className="w-full justify-start"
disabled={!folder?.parentId}
onClick={() => field.onChange(undefined)}
>
<HomeIcon className="mr-2 h-4 w-4" />
Root
</Button>
{filteredFolders &&
filteredFolders.map((f) => (
<Button
key={f.id}
type="button"
disabled={f.id === folder?.parentId}
variant={field.value === f.id ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(f.id)}
>
<FolderIcon className="mr-2 h-4 w-4" />
{f.name}
</Button>
))}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
type="submit"
disabled={
form.formState.isSubmitting ||
form.getValues('targetFolderId') === folder?.parentId
}
>
Move Folder
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -1,176 +0,0 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { FolderType } from '@documenso/lib/types/folder-type';
import { trpc } from '@documenso/trpc/react';
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
export type TemplateFolderSettingsDialogProps = {
folder: TFolderWithSubfolders | null;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const ZUpdateFolderFormSchema = z.object({
name: z.string().min(1),
visibility: z.nativeEnum(DocumentVisibility).optional(),
});
export type TUpdateFolderFormSchema = z.infer<typeof ZUpdateFolderFormSchema>;
export const TemplateFolderSettingsDialog = ({
folder,
isOpen,
onOpenChange,
}: TemplateFolderSettingsDialogProps) => {
const { _ } = useLingui();
const team = useOptionalCurrentTeam();
const { toast } = useToast();
const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation();
const isTeamContext = !!team;
const isTemplateFolder = folder?.type === FolderType.TEMPLATE;
const form = useForm<z.infer<typeof ZUpdateFolderFormSchema>>({
resolver: zodResolver(ZUpdateFolderFormSchema),
defaultValues: {
name: folder?.name ?? '',
visibility: folder?.visibility ?? DocumentVisibility.EVERYONE,
},
});
useEffect(() => {
if (folder) {
form.reset({
name: folder.name,
visibility: folder.visibility ?? DocumentVisibility.EVERYONE,
});
}
}, [folder, form]);
const onFormSubmit = async (data: TUpdateFolderFormSchema) => {
if (!folder) return;
try {
await updateFolder({
id: folder.id,
name: data.name,
visibility:
isTeamContext && !isTemplateFolder
? (data.visibility ?? DocumentVisibility.EVERYONE)
: DocumentVisibility.EVERYONE,
});
toast({
title: _(msg`Folder updated successfully`),
});
onOpenChange(false);
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.NOT_FOUND) {
toast({
title: _(msg`Folder not found`),
});
}
}
};
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Folder Settings</DialogTitle>
<DialogDescription>Manage the settings for this folder.</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{isTeamContext && !isTemplateFolder && (
<FormField
control={form.control}
name="visibility"
render={({ field }) => (
<FormItem>
<FormLabel>Visibility</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select visibility" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={DocumentVisibility.EVERYONE}>Everyone</SelectItem>
<SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE}>
Managers and above
</SelectItem>
<SelectItem value={DocumentVisibility.ADMIN}>Admins only</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
<DialogFooter>
<Button type="submit">Save Changes</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -1,158 +0,0 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { trpc } from '@documenso/trpc/react';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
type TemplateMoveDialogProps = {
templateId: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
onMove?: ({
templateId,
teamUrl,
}: {
templateId: number;
teamUrl: string;
}) => Promise<void> | void;
};
export const TemplateMoveDialog = ({
templateId,
open,
onOpenChange,
onMove,
}: TemplateMoveDialogProps) => {
const { toast } = useToast();
const { _ } = useLingui();
const [selectedTeamId, setSelectedTeamId] = useState<number | null>(null);
const { data: teams, isLoading: isLoadingTeams } = trpc.team.getTeams.useQuery();
const { mutateAsync: moveTemplate, isPending } = trpc.template.moveTemplateToTeam.useMutation({
onSuccess: async () => {
const team = teams?.find((team) => team.id === selectedTeamId);
if (team) {
await onMove?.({ templateId, teamUrl: team.url });
}
toast({
title: _(msg`Template moved`),
description: _(msg`The template has been successfully moved to the selected team.`),
duration: 5000,
});
onOpenChange(false);
},
onError: (err) => {
const error = AppError.parseError(err);
const errorMessage = match(error.code)
.with(
AppErrorCode.NOT_FOUND,
() => msg`Template not found or already associated with a team.`,
)
.with(AppErrorCode.UNAUTHORIZED, () => msg`You are not a member of this team.`)
.otherwise(() => msg`An error occurred while moving the template.`);
toast({
title: _(msg`Error`),
description: _(errorMessage),
variant: 'destructive',
duration: 7500,
});
},
});
const handleOnMove = async () => {
if (!selectedTeamId) {
return;
}
await moveTemplate({ templateId, teamId: selectedTeamId });
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Move Template to Team</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Select a team to move this template to. This action cannot be undone.</Trans>
</DialogDescription>
</DialogHeader>
<Select onValueChange={(value) => setSelectedTeamId(Number(value))}>
<SelectTrigger>
<SelectValue placeholder={_(msg`Select a team`)} />
</SelectTrigger>
<SelectContent>
{isLoadingTeams ? (
<SelectItem value="loading" disabled>
<Trans>Loading teams...</Trans>
</SelectItem>
) : (
teams?.map((team) => (
<SelectItem key={team.id} value={team.id.toString()}>
<div className="flex items-center gap-4">
<Avatar className="h-8 w-8">
{team.avatarImageId && (
<AvatarImage src={formatAvatarUrl(team.avatarImageId)} />
)}
<AvatarFallback className="text-sm text-gray-400">
{team.name.slice(0, 1).toUpperCase()}
</AvatarFallback>
</Avatar>
<span>{team.name}</span>
</div>
</SelectItem>
))
)}
</SelectContent>
</Select>
<DialogFooter>
<Button variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
onClick={handleOnMove}
loading={isPending}
disabled={!selectedTeamId || isPending}
>
{isPending ? <Trans>Moving...</Trans> : <Trans>Move</Trans>}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -1,11 +1,11 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderIcon, HomeIcon, Loader2 } from 'lucide-react';
import { FolderIcon, HomeIcon, Loader2, Search } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
@ -31,9 +31,10 @@ import {
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
import { useCurrentTeam } from '~/providers/team';
export type TemplateMoveToFolderDialogProps = {
templateId: number;
@ -59,8 +60,11 @@ export function TemplateMoveToFolderDialog({
}: TemplateMoveToFolderDialogProps) {
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const team = useOptionalCurrentTeam();
const team = useCurrentTeam();
const [searchTerm, setSearchTerm] = useState('');
const form = useForm<TMoveTemplateFormSchema>({
resolver: zodResolver(ZMoveTemplateFormSchema),
@ -84,6 +88,7 @@ export function TemplateMoveToFolderDialog({
useEffect(() => {
if (!isOpen) {
form.reset();
setSearchTerm('');
} else {
form.reset({ folderId: currentFolderId ?? null });
}
@ -104,7 +109,7 @@ export function TemplateMoveToFolderDialog({
onOpenChange(false);
const templatesPath = formatTemplatesPath(team?.url);
const templatesPath = formatTemplatesPath(team.url);
if (data.folderId) {
void navigate(`${templatesPath}/f/${data.folderId}`);
@ -132,6 +137,10 @@ export function TemplateMoveToFolderDialog({
}
};
const filteredFolders = folders?.data?.filter((folder) =>
folder.name.toLowerCase().includes(searchTerm.toLowerCase()),
);
return (
<Dialog {...props} open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
@ -145,6 +154,16 @@ export function TemplateMoveToFolderDialog({
</DialogDescription>
</DialogHeader>
<div className="relative">
<Search className="text-muted-foreground absolute left-2 top-3 h-4 w-4" />
<Input
placeholder={_(msg`Search folders...`)}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8"
/>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-4 flex flex-col gap-y-4">
<FormField
@ -155,8 +174,9 @@ export function TemplateMoveToFolderDialog({
<FormLabel>
<Trans>Folder</Trans>
</FormLabel>
<FormControl>
<div className="space-y-2">
<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" />
@ -171,10 +191,10 @@ export function TemplateMoveToFolderDialog({
disabled={currentFolderId === null}
>
<HomeIcon className="mr-2 h-4 w-4" />
<Trans>Root (No Folder)</Trans>
<Trans>Home (No Folder)</Trans>
</Button>
{folders?.data?.map((folder) => (
{filteredFolders?.map((folder) => (
<Button
key={folder.id}
type="button"
@ -187,6 +207,12 @@ export function TemplateMoveToFolderDialog({
{folder.name}
</Button>
))}
{searchTerm && filteredFolders?.length === 0 && (
<div className="text-muted-foreground px-2 py-2 text-center text-sm">
<Trans>No folders found</Trans>
</div>
)}
</>
)}
</div>

View File

@ -30,7 +30,7 @@ import {
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
import { useCurrentTeam } from '~/providers/team';
export type TokenDeleteDialogProps = {
token: Pick<ApiToken, 'id' | 'name'>;
@ -42,7 +42,7 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe
const { _ } = useLingui();
const { toast } = useToast();
const team = useOptionalCurrentTeam();
const team = useCurrentTeam();
const [isOpen, setIsOpen] = useState(false);

View File

@ -9,7 +9,7 @@ import { useForm } from 'react-hook-form';
import type { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { ZCreateWebhookMutationSchema } from '@documenso/trpc/server/webhook-router/schema';
import { ZCreateWebhookRequestSchema } from '@documenso/trpc/server/webhook-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@ -34,11 +34,11 @@ import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { Switch } from '@documenso/ui/primitives/switch';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
import { useCurrentTeam } from '~/providers/team';
import { WebhookMultiSelectCombobox } from '../general/webhook-multiselect-combobox';
const ZCreateWebhookFormSchema = ZCreateWebhookMutationSchema.omit({ teamId: true });
const ZCreateWebhookFormSchema = ZCreateWebhookRequestSchema.omit({ teamId: true });
type TCreateWebhookFormSchema = z.infer<typeof ZCreateWebhookFormSchema>;
@ -50,7 +50,7 @@ export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogPr
const { _ } = useLingui();
const { toast } = useToast();
const team = useOptionalCurrentTeam();
const team = useCurrentTeam();
const [open, setOpen] = useState(false);
@ -78,7 +78,7 @@ export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogPr
eventTriggers,
secret,
webhookUrl,
teamId: team?.id,
teamId: team.id,
});
setOpen(false);

View File

@ -30,7 +30,7 @@ import {
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
import { useCurrentTeam } from '~/providers/team';
export type WebhookDeleteDialogProps = {
webhook: Pick<Webhook, 'id' | 'webhookUrl'>;
@ -42,7 +42,7 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr
const { _ } = useLingui();
const { toast } = useToast();
const team = useOptionalCurrentTeam();
const team = useCurrentTeam();
const [open, setOpen] = useState(false);
@ -67,7 +67,7 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr
const onSubmit = async () => {
try {
await deleteWebhook({ id: webhook.id, teamId: team?.id });
await deleteWebhook({ id: webhook.id, teamId: team.id });
toast({
title: _(msg`Webhook deleted`),

View File

@ -92,6 +92,8 @@ export const MultiSignDocumentSigningView = ({
[],
];
const uninsertedFields = document?.fields.filter((field) => !field.inserted) ?? [];
const onSignField = async (payload: TSignFieldWithTokenMutationSchema) => {
try {
await signFieldWithToken(payload);
@ -335,7 +337,7 @@ export const MultiSignDocumentSigningView = ({
<div className="hidden flex-1 group-data-[expanded]/document-widget:block md:block" />
<div className="embed--DocumentWidgetFooter mt-4 hidden w-full grid-cols-2 items-center group-data-[expanded]/document-widget:grid md:grid">
{pendingFields.length > 0 ? (
{uninsertedFields.length > 0 ? (
<Button className="col-start-2" onClick={onNextFieldClick}>
<Trans>Next</Trans>
</Button>

View File

@ -6,7 +6,6 @@ import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { ErrorCode, useDropzone } from 'react-dropzone';
import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import { match } from 'ts-pattern';
import { z } from 'zod';
@ -29,8 +28,6 @@ import {
} from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
export const ZAvatarImageFormSchema = z.object({
bytes: z.string().nullish(),
});
@ -39,29 +36,44 @@ export type TAvatarImageFormSchema = z.infer<typeof ZAvatarImageFormSchema>;
export type AvatarImageFormProps = {
className?: string;
team?: {
id: number;
name: string;
avatarImageId: string | null;
};
organisation?: {
id: string;
name: string;
avatarImageId: string | null;
};
};
export const AvatarImageForm = ({ className }: AvatarImageFormProps) => {
export const AvatarImageForm = ({ className, team, organisation }: AvatarImageFormProps) => {
const { user, refreshSession } = useSession();
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const team = useOptionalCurrentTeam();
const { mutateAsync: setProfileImage } = trpc.profile.setProfileImage.useMutation();
const initials = extractInitials(team?.name || user.name || '');
const initials = extractInitials(team?.name || organisation?.name || user.name || '');
const hasAvatarImage = useMemo(() => {
if (team) {
return team.avatarImageId !== null;
}
return user.avatarImageId !== null;
}, [team, user.avatarImageId]);
if (organisation) {
return organisation.avatarImageId !== null;
}
const avatarImageId = team ? team.avatarImageId : user.avatarImageId;
return user.avatarImageId !== null;
}, [team, organisation, user.avatarImageId]);
const avatarImageId = team
? team.avatarImageId
: organisation
? organisation.avatarImageId
: user.avatarImageId;
const form = useForm<TAvatarImageFormSchema>({
values: {
@ -100,7 +112,8 @@ export const AvatarImageForm = ({ className }: AvatarImageFormProps) => {
try {
await setProfileImage({
bytes: data.bytes,
teamId: team?.id,
teamId: team?.id ?? null,
organisationId: organisation?.id ?? null,
});
await refreshSession();

View File

@ -1,17 +1,14 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import type { Team, TeamGlobalSettings } from '@prisma/client';
import type { TeamGlobalSettings } from '@prisma/client';
import { Loader } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
@ -23,15 +20,20 @@ import {
FormLabel,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Switch } from '@documenso/ui/primitives/switch';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
const ZTeamBrandingPreferencesFormSchema = z.object({
brandingEnabled: z.boolean(),
const ZBrandingPreferencesFormSchema = z.object({
brandingEnabled: z.boolean().nullable(),
brandingLogo: z
.instanceof(File)
.refine((file) => file.size <= MAX_FILE_SIZE, 'File size must be less than 5MB')
@ -44,76 +46,45 @@ const ZTeamBrandingPreferencesFormSchema = z.object({
brandingCompanyDetails: z.string().max(500).optional(),
});
type TTeamBrandingPreferencesFormSchema = z.infer<typeof ZTeamBrandingPreferencesFormSchema>;
export type TBrandingPreferencesFormSchema = z.infer<typeof ZBrandingPreferencesFormSchema>;
export type TeamBrandingPreferencesFormProps = {
team: Team;
settings?: TeamGlobalSettings | null;
type SettingsSubset = Pick<
TeamGlobalSettings,
'brandingEnabled' | 'brandingLogo' | 'brandingUrl' | 'brandingCompanyDetails'
>;
export type BrandingPreferencesFormProps = {
canInherit?: boolean;
settings: SettingsSubset;
onFormSubmit: (data: TBrandingPreferencesFormSchema) => Promise<void>;
context: 'Team' | 'Organisation';
};
export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPreferencesFormProps) {
const { _ } = useLingui();
const { toast } = useToast();
export function BrandingPreferencesForm({
canInherit = false,
settings,
onFormSubmit,
context,
}: BrandingPreferencesFormProps) {
const { t } = useLingui();
const [previewUrl, setPreviewUrl] = useState<string>('');
const [hasLoadedPreview, setHasLoadedPreview] = useState(false);
const { mutateAsync: updateTeamBrandingSettings } =
trpc.team.updateTeamBrandingSettings.useMutation();
const form = useForm<TTeamBrandingPreferencesFormSchema>({
const form = useForm<TBrandingPreferencesFormSchema>({
defaultValues: {
brandingEnabled: settings?.brandingEnabled ?? false,
brandingUrl: settings?.brandingUrl ?? '',
brandingEnabled: settings.brandingEnabled ?? null,
brandingUrl: settings.brandingUrl ?? '',
brandingLogo: undefined,
brandingCompanyDetails: settings?.brandingCompanyDetails ?? '',
brandingCompanyDetails: settings.brandingCompanyDetails ?? '',
},
resolver: zodResolver(ZTeamBrandingPreferencesFormSchema),
resolver: zodResolver(ZBrandingPreferencesFormSchema),
});
const isBrandingEnabled = form.watch('brandingEnabled');
const onSubmit = async (data: TTeamBrandingPreferencesFormSchema) => {
try {
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails } = data;
let uploadedBrandingLogo = settings?.brandingLogo;
if (brandingLogo) {
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
}
if (brandingLogo === null) {
uploadedBrandingLogo = '';
}
await updateTeamBrandingSettings({
teamId: team.id,
settings: {
brandingEnabled,
brandingLogo: uploadedBrandingLogo,
brandingUrl,
brandingCompanyDetails,
},
});
toast({
title: _(msg`Branding preferences updated`),
description: _(msg`Your branding preferences have been updated`),
});
} catch (err) {
toast({
title: _(msg`Something went wrong`),
description: _(
msg`We were unable to update your branding preferences at this time, please try again later`,
),
variant: 'destructive',
});
}
};
useEffect(() => {
if (settings?.brandingLogo) {
if (settings.brandingLogo) {
const file = JSON.parse(settings.brandingLogo);
if ('type' in file && 'data' in file) {
@ -129,7 +100,7 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref
}
setHasLoadedPreview(true);
}, [settings?.brandingLogo]);
}, [settings.brandingLogo]);
// Cleanup ObjectURL on unmount or when previewUrl changes
useEffect(() => {
@ -142,45 +113,72 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset
className="flex h-full max-w-xl flex-col gap-y-4"
disabled={form.formState.isSubmitting}
>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="flex h-full flex-col gap-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="brandingEnabled"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Enable Custom Branding</FormLabel>
<FormLabel>
<Trans>Enable Custom Branding</Trans>
</FormLabel>
<div>
<FormControl>
<Switch
ref={field.ref}
name={field.name}
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</div>
<FormControl>
<Select
{...field}
value={field.value === null ? '-1' : field.value.toString()}
onValueChange={(value) =>
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
}
>
<SelectTrigger
className="bg-background text-muted-foreground"
data-testid="enable-branding"
>
<SelectValue />
</SelectTrigger>
<SelectContent className="z-[9999]">
<SelectItem value="true">
<Trans>Yes</Trans>
</SelectItem>
<SelectItem value="false">
<Trans>No</Trans>
</SelectItem>
{canInherit && (
<SelectItem value={'-1'}>
<Trans>Inherit from organisation</Trans>
</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<Trans>Enable custom branding for all documents in this team.</Trans>
{context === 'Team' ? (
<Trans>Enable custom branding for all documents in this team</Trans>
) : (
<Trans>Enable custom branding for all documents in this organisation</Trans>
)}
</FormDescription>
</FormItem>
)}
/>
<div className="relative flex w-full flex-col gap-y-4">
{!isBrandingEnabled && <div className="bg-background/60 absolute inset-0 z-[9999]" />}
{!isBrandingEnabled && <div className="bg-background/60 absolute inset-0 z-[9998]" />}
<FormField
control={form.control}
name="brandingLogo"
render={({ field: { value: _value, onChange, ...field } }) => (
<FormItem className="flex-1">
<FormLabel>Branding Logo</FormLabel>
<FormLabel>
<Trans>Branding Logo</Trans>
</FormLabel>
<div className="flex flex-col gap-4">
<div className="border-border bg-background relative h-48 w-full overflow-hidden rounded-lg border">
@ -192,7 +190,8 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref
/>
) : (
<div className="bg-muted/20 dark:bg-muted text-muted-foreground relative flex h-full w-full items-center justify-center text-sm">
Please upload a logo
<Trans>Please upload a logo</Trans>
{!hasLoadedPreview && (
<div className="bg-muted dark:bg-muted absolute inset-0 z-[999] flex items-center justify-center">
<Loader className="text-muted-foreground h-8 w-8 animate-spin" />
@ -253,6 +252,13 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref
<FormDescription>
<Trans>Upload your brand logo (max 5MB, JPG, PNG, or WebP)</Trans>
{canInherit && (
<span>
{'. '}
<Trans>Leave blank to inherit from the organisation.</Trans>
</span>
)}
</FormDescription>
</div>
</FormItem>
@ -264,7 +270,9 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref
name="brandingUrl"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Brand Website</FormLabel>
<FormLabel>
<Trans>Brand Website</Trans>
</FormLabel>
<FormControl>
<Input
@ -277,6 +285,13 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref
<FormDescription>
<Trans>Your brand website URL</Trans>
{canInherit && (
<span>
{'. '}
<Trans>Leave blank to inherit from the organisation.</Trans>
</span>
)}
</FormDescription>
</FormItem>
)}
@ -287,11 +302,13 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref
name="brandingCompanyDetails"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Brand Details</FormLabel>
<FormLabel>
<Trans>Brand Details</Trans>
</FormLabel>
<FormControl>
<Textarea
placeholder={_(msg`Enter your brand details`)}
placeholder={t`Enter your brand details`}
className="min-h-[100px] resize-y"
disabled={!isBrandingEnabled}
{...field}
@ -300,6 +317,13 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref
<FormDescription>
<Trans>Additional brand information to display at the bottom of emails</Trans>
{canInherit && (
<span>
{'. '}
<Trans>Leave blank to inherit from the organisation.</Trans>
</span>
)}
</FormDescription>
</FormItem>
)}

View File

@ -0,0 +1,370 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import type { TeamGlobalSettings } from '@prisma/client';
import { DocumentVisibility } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { DOCUMENT_SIGNATURE_TYPES, DocumentSignatureType } from '@documenso/lib/constants/document';
import {
SUPPORTED_LANGUAGES,
SUPPORTED_LANGUAGE_CODES,
isValidLanguageCode,
} from '@documenso/lib/constants/i18n';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
import { Alert } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
/**
* Can't infer this from the schema since we need to keep the schema inside the component to allow
* it to be dynamic.
*/
export type TDocumentPreferencesFormSchema = {
documentVisibility: DocumentVisibility | null;
documentLanguage: (typeof SUPPORTED_LANGUAGE_CODES)[number] | null;
includeSenderDetails: boolean | null;
includeSigningCertificate: boolean | null;
signatureTypes: DocumentSignatureType[];
};
type SettingsSubset = Pick<
TeamGlobalSettings,
| 'documentVisibility'
| 'documentLanguage'
| 'includeSenderDetails'
| 'includeSigningCertificate'
| 'typedSignatureEnabled'
| 'uploadSignatureEnabled'
| 'drawSignatureEnabled'
>;
export type DocumentPreferencesFormProps = {
settings: SettingsSubset;
canInherit: boolean;
onFormSubmit: (data: TDocumentPreferencesFormSchema) => Promise<void>;
};
export const DocumentPreferencesForm = ({
settings,
onFormSubmit,
canInherit,
}: DocumentPreferencesFormProps) => {
const { t } = useLingui();
const { user, organisations } = useSession();
const isPersonalLayoutMode = isPersonalLayout(organisations);
const placeholderEmail = user.email ?? 'user@example.com';
const ZDocumentPreferencesFormSchema = z.object({
documentVisibility: z.nativeEnum(DocumentVisibility).nullable(),
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES).nullable(),
includeSenderDetails: z.boolean().nullable(),
includeSigningCertificate: z.boolean().nullable(),
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(canInherit ? 0 : 1, {
message: msg`At least one signature type must be enabled`.id,
}),
});
const form = useForm<TDocumentPreferencesFormSchema>({
defaultValues: {
documentVisibility: settings.documentVisibility,
documentLanguage: isValidLanguageCode(settings.documentLanguage)
? settings.documentLanguage
: null,
includeSenderDetails: settings.includeSenderDetails,
includeSigningCertificate: settings.includeSigningCertificate,
signatureTypes: extractTeamSignatureSettings({ ...settings }),
},
resolver: zodResolver(ZDocumentPreferencesFormSchema),
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset
className="flex h-full max-w-2xl flex-col gap-y-6"
disabled={form.formState.isSubmitting}
>
{!isPersonalLayoutMode && (
<FormField
control={form.control}
name="documentVisibility"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Default Document Visibility</Trans>
</FormLabel>
<FormControl>
<Select
{...field}
value={field.value === null ? '-1' : field.value}
onValueChange={(value) => field.onChange(value === '-1' ? null : value)}
>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={DocumentVisibility.EVERYONE}>
<Trans>Everyone can access and view the document</Trans>
</SelectItem>
<SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE}>
<Trans>Only managers and above can access and view the document</Trans>
</SelectItem>
<SelectItem value={DocumentVisibility.ADMIN}>
<Trans>Only admins can access and view the document</Trans>
</SelectItem>
{canInherit && (
<SelectItem value={'-1'}>
<Trans>Inherit from organisation</Trans>
</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<Trans>Controls the default visibility of an uploaded document.</Trans>
</FormDescription>
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="documentLanguage"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Default Document Language</Trans>
</FormLabel>
<FormControl>
<Select
{...field}
value={field.value === null ? '-1' : field.value}
onValueChange={(value) => field.onChange(value === '-1' ? null : value)}
>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(SUPPORTED_LANGUAGES).map(([code, language]) => (
<SelectItem key={code} value={code}>
{language.full}
</SelectItem>
))}
<SelectItem value={'-1'}>
<Trans>Inherit from organisation</Trans>
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<Trans>
Controls the default language of an uploaded document. This will be used as the
language in email communications with the recipients.
</Trans>
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="signatureTypes"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel className="flex flex-row items-center">
<Trans>Default Signature Settings</Trans>
<DocumentSignatureSettingsTooltip />
</FormLabel>
<FormControl>
<MultiSelectCombobox
options={Object.values(DOCUMENT_SIGNATURE_TYPES).map((option) => ({
label: t(option.label),
value: option.value,
}))}
selectedValues={field.value}
onChange={field.onChange}
className="bg-background w-full"
enableSearch={false}
emptySelectionPlaceholder={
canInherit ? t`Inherit from organisation` : t`Select signature types`
}
testId="signature-types-combobox"
/>
</FormControl>
{form.formState.errors.signatureTypes ? (
<FormMessage />
) : (
<FormDescription>
<Trans>
Controls which signatures are allowed to be used when signing a document.
</Trans>
</FormDescription>
)}
</FormItem>
)}
/>
{!isPersonalLayoutMode && (
<FormField
control={form.control}
name="includeSenderDetails"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Send on Behalf of Team</Trans>
</FormLabel>
<FormControl>
<Select
{...field}
value={field.value === null ? '-1' : field.value.toString()}
onValueChange={(value) =>
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
}
>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">
<Trans>Yes</Trans>
</SelectItem>
<SelectItem value="false">
<Trans>No</Trans>
</SelectItem>
{canInherit && (
<SelectItem value={'-1'}>
<Trans>Inherit from organisation</Trans>
</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
<div className="pt-2">
<div className="text-muted-foreground text-xs font-medium">
<Trans>Preview</Trans>
</div>
<Alert variant="neutral" className="mt-1 px-2.5 py-1.5 text-sm">
{field.value ? (
<Trans>
"{placeholderEmail}" on behalf of "Team Name" has invited you to sign
"example document".
</Trans>
) : (
<Trans>"Team Name" has invited you to sign "example document".</Trans>
)}
</Alert>
</div>
<FormDescription>
<Trans>
Controls the formatting of the message that will be sent when inviting a
recipient to sign a document. If a custom message has been provided while
configuring the document, it will be used instead.
</Trans>
</FormDescription>
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="includeSigningCertificate"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Include the Signing Certificate in the Document</Trans>
</FormLabel>
<FormControl>
<Select
{...field}
value={field.value === null ? '-1' : field.value.toString()}
onValueChange={(value) =>
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
}
>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">
<Trans>Yes</Trans>
</SelectItem>
<SelectItem value="false">
<Trans>No</Trans>
</SelectItem>
{canInherit && (
<SelectItem value={'-1'}>
<Trans>Inherit from organisation</Trans>
</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<Trans>
Controls whether the signing certificate will be included in the document when
it is downloaded. The signing certificate can still be downloaded from the logs
page separately.
</Trans>
</FormDescription>
</FormItem>
)}
/>
<div className="flex flex-row justify-end space-x-4">
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update</Trans>
</Button>
</div>
</fieldset>
</form>
</Form>
);
};

View File

@ -0,0 +1,182 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { AnimatePresence, motion } from 'framer-motion';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import type { z } from 'zod';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { ZUpdateOrganisationRequestSchema } from '@documenso/trpc/server/organisation-router/update-organisation.types';
import { Button } from '@documenso/ui/primitives/button';
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';
const ZOrganisationUpdateFormSchema = ZUpdateOrganisationRequestSchema.shape.data.pick({
name: true,
url: true,
});
type TOrganisationUpdateFormSchema = z.infer<typeof ZOrganisationUpdateFormSchema>;
export const OrganisationUpdateForm = () => {
const navigate = useNavigate();
const organisation = useCurrentOrganisation();
const { refreshSession } = useSession();
const { _ } = useLingui();
const { toast } = useToast();
const form = useForm({
resolver: zodResolver(ZOrganisationUpdateFormSchema),
defaultValues: {
name: organisation.name,
url: organisation.url,
},
});
const { mutateAsync: updateOrganisation } = trpc.organisation.update.useMutation();
const onFormSubmit = async ({ name, url }: TOrganisationUpdateFormSchema) => {
try {
await updateOrganisation({
data: {
name,
url,
},
organisationId: organisation.id,
});
await refreshSession();
if (url !== organisation.url) {
await navigate(`/o/${url}/settings`);
}
toast({
title: _(msg`Success`),
description: _(msg`Your organisation has been successfully updated.`),
duration: 5000,
});
form.reset({
name,
url,
});
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.ALREADY_EXISTS) {
form.setError('url', {
type: 'manual',
message: _(msg`This URL is already in use.`),
});
return;
}
toast({
title: _(msg`An unknown error occurred`),
description: _(
msg`We encountered an unknown error while attempting to update your organisation. Please try again later.`,
),
variant: 'destructive',
});
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Organisation Name</Trans>
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem className="mt-4">
<FormLabel required>
<Trans>Organisation URL</Trans>
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
{!form.formState.errors.url && (
<span className="text-foreground/50 text-xs font-normal">
{field.value ? (
`${NEXT_PUBLIC_WEBAPP_URL()}/o/${field.value}`
) : (
<Trans>A unique URL to identify your organisation</Trans>
)}
</span>
)}
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-row justify-end space-x-4">
<AnimatePresence>
{form.formState.isDirty && (
<motion.div
initial={{
opacity: 0,
}}
animate={{
opacity: 1,
}}
exit={{
opacity: 0,
}}
>
<Button type="button" variant="secondary" onClick={() => form.reset()}>
<Trans>Reset</Trans>
</Button>
</motion.div>
)}
</AnimatePresence>
<Button
type="submit"
className="transition-opacity"
disabled={!form.formState.isDirty}
loading={form.formState.isSubmitting}
>
<Trans>Update organisation</Trans>
</Button>
</div>
</fieldset>
</form>
</Form>
);
};

View File

@ -4,7 +4,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import type { TeamProfile, UserProfile } from '@prisma/client';
import type { TeamProfile } from '@prisma/client';
import { motion } from 'framer-motion';
import { AnimatePresence } from 'framer-motion';
import { CheckSquareIcon, CopyIcon } from 'lucide-react';
@ -12,12 +12,14 @@ import { useForm } from 'react-hook-form';
import type { z } from 'zod';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { AppError } from '@documenso/lib/errors/app-error';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { formatUserProfilePath } from '@documenso/lib/utils/public-profiles';
import {
MAX_PROFILE_BIO_LENGTH,
ZUpdatePublicProfileMutationSchema,
} from '@documenso/trpc/server/profile-router/schema';
ZUpdateTeamRequestSchema,
} from '@documenso/trpc/server/team-router/update-team.types';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
@ -32,9 +34,10 @@ import { Input } from '@documenso/ui/primitives/input';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZPublicProfileFormSchema = ZUpdatePublicProfileMutationSchema.pick({
bio: true,
enabled: true,
import { useCurrentTeam } from '~/providers/team';
export const ZPublicProfileFormSchema = ZUpdateTeamRequestSchema.shape.data.pick({
profileBio: true,
url: true,
});
@ -42,16 +45,12 @@ export type TPublicProfileFormSchema = z.infer<typeof ZPublicProfileFormSchema>;
export type PublicProfileFormProps = {
className?: string;
profileUrl?: string | null;
teamUrl?: string;
onProfileUpdate: (data: TPublicProfileFormSchema) => Promise<unknown>;
profile: UserProfile | TeamProfile;
profile: TeamProfile;
};
export const PublicProfileForm = ({
className,
profileUrl,
profile,
teamUrl,
onProfileUpdate,
}: PublicProfileFormProps) => {
const { _ } = useLingui();
@ -61,10 +60,16 @@ export const PublicProfileForm = ({
const [copiedTimeout, setCopiedTimeout] = useState<NodeJS.Timeout | null>(null);
const { organisations } = useSession();
const isPersonalLayoutMode = isPersonalLayout(organisations);
const team = useCurrentTeam();
const form = useForm<TPublicProfileFormSchema>({
values: {
url: profileUrl ?? '',
bio: profile?.bio ?? '',
url: team.url,
profileBio: profile?.bio ?? '',
},
resolver: zodResolver(ZPublicProfileFormSchema),
});
@ -83,12 +88,13 @@ export const PublicProfileForm = ({
form.reset({
url: data.url,
bio: data.bio,
profileBio: data.profileBio,
});
} catch (err) {
const error = AppError.parseError(err);
switch (error.code) {
case AppErrorCode.ALREADY_EXISTS:
case 'PREMIUM_PROFILE_URL':
case 'PROFILE_URL_TAKEN':
form.setError('url', {
@ -145,10 +151,10 @@ export const PublicProfileForm = ({
<Trans>Public profile URL</Trans>
</FormLabel>
<FormControl>
<Input {...field} disabled={field.disabled || teamUrl !== undefined} />
<Input {...field} disabled={field.disabled || !isPersonalLayoutMode} />
</FormControl>
{teamUrl && (
{!isPersonalLayoutMode && (
<p className="text-muted-foreground text-xs">
<Trans>
You can update the profile URL by updating the team URL in the general
@ -208,7 +214,7 @@ export const PublicProfileForm = ({
<FormField
control={form.control}
name="bio"
name="profileBio"
render={({ field }) => {
const remaningLength = MAX_PROFILE_BIO_LENGTH - (field.value || '').length;
@ -218,13 +224,11 @@ export const PublicProfileForm = ({
<FormControl>
<Textarea
{...field}
placeholder={
teamUrl ? _(msg`Write about the team`) : _(msg`Write about yourself`)
}
placeholder={_(msg`Write a description to display on your public profile`)}
/>
</FormControl>
{!form.formState.errors.bio && (
{!form.formState.errors.profileBio && (
<p className="text-muted-foreground text-sm">
{remaningLength >= 0 ? (
<Plural

View File

@ -55,7 +55,7 @@ const handleFallbackErrorMessages = (code: string) => {
return message;
};
const LOGIN_REDIRECT_PATH = '/documents';
const LOGIN_REDIRECT_PATH = '/';
export const ZSignInFormSchema = z.object({
email: z.string().email().min(1),

View File

@ -1,11 +1,10 @@
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { AnimatePresence, motion } from 'framer-motion';
import { useForm } from 'react-hook-form';
import { FaIdCardClip } from 'react-icons/fa6';
import { FcGoogle } from 'react-icons/fc';
@ -15,7 +14,6 @@ import { z } from 'zod';
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 { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
import { cn } from '@documenso/ui/lib/utils';
@ -33,11 +31,8 @@ import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { UserProfileSkeleton } from '~/components/general/user-profile-skeleton';
import { UserProfileTimur } from '~/components/general/user-profile-timur';
type SignUpStep = 'BASIC_DETAILS' | 'CLAIM_USERNAME';
export const ZSignUpFormSchema = z
.object({
name: z
@ -47,14 +42,6 @@ export const ZSignUpFormSchema = z
email: z.string().email().min(1),
password: ZPasswordSchema,
signature: z.string().min(1, { message: msg`We need your signature to sign documents`.id }),
url: z
.string()
.trim()
.toLowerCase()
.min(1, { message: msg`We need a username to create your profile`.id })
.regex(/^[a-z0-9-]+$/, {
message: msg`Username can only container alphanumeric characters and dashes.`.id,
}),
})
.refine(
(data) => {
@ -71,8 +58,6 @@ export const signupErrorMessages: Record<string, MessageDescriptor> = {
SIGNUP_DISABLED: msg`Signups are disabled.`,
[AppErrorCode.ALREADY_EXISTS]: msg`User with this email already exists. Please use a different email address.`,
[AppErrorCode.INVALID_REQUEST]: msg`We were unable to create your account. Please review the information you provided and try again.`,
PROFILE_URL_TAKEN: msg`This username has already been taken`,
PREMIUM_PROFILE_URL: msg`Only subscribers can have a username shorter than 6 characters`,
};
export type TSignUpFormSchema = z.infer<typeof ZSignUpFormSchema>;
@ -97,19 +82,14 @@ export const SignUpForm = ({
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [step, setStep] = useState<SignUpStep>('BASIC_DETAILS');
const utmSrc = searchParams.get('utm_source') ?? null;
const baseUrl = new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000');
const form = useForm<TSignUpFormSchema>({
values: {
name: '',
email: initialEmail ?? '',
password: '',
signature: '',
url: '',
},
mode: 'onBlur',
resolver: zodResolver(ZSignUpFormSchema),
@ -117,17 +97,13 @@ export const SignUpForm = ({
const isSubmitting = form.formState.isSubmitting;
const name = form.watch('name');
const url = form.watch('url');
const onFormSubmit = async ({ name, email, password, signature, url }: TSignUpFormSchema) => {
const onFormSubmit = async ({ name, email, password, signature }: TSignUpFormSchema) => {
try {
await authClient.emailPassword.signUp({
name,
email,
password,
signature,
url,
});
await navigate(`/unverified-account`);
@ -150,26 +126,11 @@ export const SignUpForm = ({
const errorMessage = signupErrorMessages[error.code] ?? signupErrorMessages.INVALID_REQUEST;
if (error.code === 'PROFILE_URL_TAKEN' || error.code === 'PREMIUM_PROFILE_URL') {
form.setError('url', {
type: 'manual',
message: _(errorMessage),
});
} else {
toast({
title: _(msg`An error occurred`),
description: _(errorMessage),
variant: 'destructive',
});
}
}
};
const onNextClick = async () => {
const valid = await form.trigger(['name', 'email', 'password', 'signature']);
if (valid) {
setStep('CLAIM_USERNAME');
toast({
title: _(msg`An error occurred`),
description: _(errorMessage),
variant: 'destructive',
});
}
};
@ -231,59 +192,30 @@ export const SignUpForm = ({
<Trans>User profiles are here!</Trans>
</div>
<AnimatePresence>
{step === 'BASIC_DETAILS' ? (
<motion.div className="w-full max-w-md" layoutId="user-profile">
<UserProfileTimur
rows={2}
className="bg-background border-border rounded-2xl border shadow-md"
/>
</motion.div>
) : (
<motion.div className="w-full max-w-md" layoutId="user-profile">
<UserProfileSkeleton
user={{ name, url }}
rows={2}
className="bg-background border-border rounded-2xl border shadow-md"
/>
</motion.div>
)}
</AnimatePresence>
<div className="w-full max-w-md">
<UserProfileTimur
rows={2}
className="bg-background border-border rounded-2xl border shadow-md"
/>
</div>
<div />
</div>
</div>
<div className="border-border dark:bg-background relative z-10 flex min-h-[min(850px,80vh)] w-full max-w-lg flex-col rounded-xl border bg-neutral-100 p-6">
{step === 'BASIC_DETAILS' && (
<div className="h-20">
<h1 className="text-xl font-semibold md:text-2xl">
<Trans>Create a new account</Trans>
</h1>
<div className="h-20">
<h1 className="text-xl font-semibold md:text-2xl">
<Trans>Create a new account</Trans>
</h1>
<p className="text-muted-foreground mt-2 text-xs md:text-sm">
<Trans>
Create your account and start using state-of-the-art document signing. Open and
beautiful signing is within your grasp.
</Trans>
</p>
</div>
)}
{step === 'CLAIM_USERNAME' && (
<div className="h-20">
<h1 className="text-xl font-semibold md:text-2xl">
<Trans>Claim your username now</Trans>
</h1>
<p className="text-muted-foreground mt-2 text-xs md:text-sm">
<Trans>
You will get notified & be able to set up your documenso public profile when we
launch the feature.
</Trans>
</p>
</div>
)}
<p className="text-muted-foreground mt-2 text-xs md:text-sm">
<Trans>
Create your account and start using state-of-the-art document signing. Open and
beautiful signing is within your grasp.
</Trans>
</p>
</div>
<hr className="-mx-6 my-4" />
@ -292,242 +224,146 @@ export const SignUpForm = ({
className="flex w-full flex-1 flex-col gap-y-4"
onSubmit={form.handleSubmit(onFormSubmit)}
>
{step === 'BASIC_DETAILS' && (
<fieldset
className={cn(
'flex h-[550px] w-full flex-col gap-y-4',
(isGoogleSSOEnabled || isOIDCSSOEnabled) && 'h-[650px]',
<fieldset
className={cn(
'flex h-[550px] w-full flex-col gap-y-4',
(isGoogleSSOEnabled || isOIDCSSOEnabled) && 'h-[650px]',
)}
disabled={isSubmitting}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Full Name</Trans>
</FormLabel>
<FormControl>
<Input type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
disabled={isSubmitting}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Full Name</Trans>
</FormLabel>
<FormControl>
<Input type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Email Address</Trans>
</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Password</Trans>
</FormLabel>
<FormControl>
<PasswordInput {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="signature"
render={({ field: { onChange, value } }) => (
<FormItem>
<FormLabel>
<Trans>Sign Here</Trans>
</FormLabel>
<FormControl>
<SignaturePadDialog
disabled={isSubmitting}
value={value}
onChange={(v) => onChange(v ?? '')}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{(isGoogleSSOEnabled || isOIDCSSOEnabled) && (
<>
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<div className="bg-border h-px flex-1" />
<span className="text-muted-foreground bg-transparent">
<Trans>Or</Trans>
</span>
<div className="bg-border h-px flex-1" />
</div>
</>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Email Address</Trans>
</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{isGoogleSSOEnabled && (
<>
<Button
type="button"
size="lg"
variant={'outline'}
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignUpWithGoogleClick}
>
<FcGoogle className="mr-2 h-5 w-5" />
<Trans>Sign Up with Google</Trans>
</Button>
</>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Password</Trans>
</FormLabel>
<FormControl>
<PasswordInput {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{isOIDCSSOEnabled && (
<>
<Button
type="button"
size="lg"
variant={'outline'}
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignUpWithOIDCClick}
>
<FaIdCardClip className="mr-2 h-5 w-5" />
<Trans>Sign Up with OIDC</Trans>
</Button>
</>
<FormField
control={form.control}
name="signature"
render={({ field: { onChange, value } }) => (
<FormItem>
<FormLabel>
<Trans>Sign Here</Trans>
</FormLabel>
<FormControl>
<SignaturePadDialog
disabled={isSubmitting}
value={value}
onChange={(v) => onChange(v ?? '')}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<p className="text-muted-foreground mt-4 text-sm">
<Trans>
Already have an account?{' '}
<Link to="/signin" className="text-documenso-700 duration-200 hover:opacity-70">
Sign in instead
</Link>
</Trans>
</p>
</fieldset>
)}
{step === 'CLAIM_USERNAME' && (
<fieldset
className={cn(
'flex h-[550px] w-full flex-col gap-y-4',
isGoogleSSOEnabled && 'h-[650px]',
)}
disabled={isSubmitting}
>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Public profile username</Trans>
</FormLabel>
<FormControl>
<Input type="text" className="mb-2 mt-2 lowercase" {...field} />
</FormControl>
<FormMessage />
<div className="bg-muted/50 border-border text-muted-foreground mt-2 inline-block max-w-[16rem] truncate rounded-md border px-2 py-1 text-sm lowercase">
{baseUrl.host}/u/{field.value || '<username>'}
</div>
</FormItem>
)}
/>
</fieldset>
)}
<div className="mt-6">
{step === 'BASIC_DETAILS' && (
<p className="text-muted-foreground text-sm">
<span className="font-medium">
<Trans>Basic details</Trans>
</span>{' '}
1/2
</p>
{(isGoogleSSOEnabled || isOIDCSSOEnabled) && (
<>
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<div className="bg-border h-px flex-1" />
<span className="text-muted-foreground bg-transparent">
<Trans>Or</Trans>
</span>
<div className="bg-border h-px flex-1" />
</div>
</>
)}
{step === 'CLAIM_USERNAME' && (
<p className="text-muted-foreground text-sm">
<span className="font-medium">
<Trans>Claim username</Trans>
</span>{' '}
2/2
</p>
{isGoogleSSOEnabled && (
<>
<Button
type="button"
size="lg"
variant={'outline'}
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignUpWithGoogleClick}
>
<FcGoogle className="mr-2 h-5 w-5" />
<Trans>Sign Up with Google</Trans>
</Button>
</>
)}
<div className="bg-foreground/40 relative mt-4 h-1.5 rounded-full">
<motion.div
layout="size"
layoutId="document-flow-container-step"
className="bg-documenso absolute inset-y-0 left-0 rounded-full"
style={{
width: step === 'BASIC_DETAILS' ? '50%' : '100%',
}}
/>
</div>
</div>
<div className="flex items-center gap-x-4">
{/* Go back button, disabled if step is basic details */}
<Button
type="button"
size="lg"
variant="secondary"
className="flex-1"
disabled={step === 'BASIC_DETAILS' || form.formState.isSubmitting}
onClick={() => setStep('BASIC_DETAILS')}
>
<Trans>Back</Trans>
</Button>
{/* Continue button */}
{step === 'BASIC_DETAILS' && (
<Button
type="button"
size="lg"
className="flex-1 disabled:cursor-not-allowed"
loading={form.formState.isSubmitting}
onClick={onNextClick}
>
<Trans>Next</Trans>
</Button>
{isOIDCSSOEnabled && (
<>
<Button
type="button"
size="lg"
variant={'outline'}
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignUpWithOIDCClick}
>
<FaIdCardClip className="mr-2 h-5 w-5" />
<Trans>Sign Up with OIDC</Trans>
</Button>
</>
)}
{/* Sign up button */}
{step === 'CLAIM_USERNAME' && (
<Button
loading={form.formState.isSubmitting}
disabled={!form.formState.isValid}
type="submit"
size="lg"
className="flex-1"
>
<Trans>Complete</Trans>
</Button>
)}
</div>
<p className="text-muted-foreground mt-4 text-sm">
<Trans>
Already have an account?{' '}
<Link to="/signin" className="text-documenso-700 duration-200 hover:opacity-70">
Sign in instead
</Link>
</Trans>
</p>
</fieldset>
<Button
loading={form.formState.isSubmitting}
type="submit"
size="lg"
className="mt-6 w-full"
>
<Trans>Complete</Trans>
</Button>
</form>
</Form>
<p className="text-muted-foreground mt-6 text-xs">

View File

@ -0,0 +1,155 @@
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 type { z } from 'zod';
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
import { ZCreateSubscriptionClaimRequestSchema } from '@documenso/trpc/server/admin-router/create-subscription-claim.types';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
export type SubscriptionClaimFormValues = z.infer<typeof ZCreateSubscriptionClaimRequestSchema>;
type SubscriptionClaimFormProps = {
subscriptionClaim: Omit<SubscriptionClaim, 'id' | 'createdAt' | 'updatedAt'>;
onFormSubmit: (data: SubscriptionClaimFormValues) => Promise<void>;
formSubmitTrigger?: React.ReactNode;
};
export const SubscriptionClaimForm = ({
subscriptionClaim,
onFormSubmit,
formSubmitTrigger,
}: SubscriptionClaimFormProps) => {
const { t } = useLingui();
const form = useForm<SubscriptionClaimFormValues>({
resolver: zodResolver(ZCreateSubscriptionClaimRequestSchema),
defaultValues: {
name: subscriptionClaim.name,
teamCount: subscriptionClaim.teamCount,
memberCount: subscriptionClaim.memberCount,
flags: subscriptionClaim.flags,
},
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input placeholder={t`Enter claim name`} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="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="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>
)}
/>
<div>
<FormLabel>
<Trans>Feature Flags</Trans>
</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}
/>
<label
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
htmlFor={`flag-${key}`}
>
{label}
</label>
</div>
</FormControl>
</FormItem>
)}
/>
))}
</div>
</div>
{formSubmitTrigger}
</fieldset>
</form>
</Form>
);
};

View File

@ -1,328 +0,0 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { Team, TeamGlobalSettings } from '@prisma/client';
import { DocumentVisibility } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { DOCUMENT_SIGNATURE_TYPES, DocumentSignatureType } from '@documenso/lib/constants/document';
import {
SUPPORTED_LANGUAGES,
SUPPORTED_LANGUAGE_CODES,
isValidLanguageCode,
} from '@documenso/lib/constants/i18n';
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
import { Alert } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Switch } from '@documenso/ui/primitives/switch';
import { useToast } from '@documenso/ui/primitives/use-toast';
const ZTeamDocumentPreferencesFormSchema = z.object({
documentVisibility: z.nativeEnum(DocumentVisibility),
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES),
includeSenderDetails: z.boolean(),
includeSigningCertificate: z.boolean(),
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, {
message: msg`At least one signature type must be enabled`.id,
}),
});
type TTeamDocumentPreferencesFormSchema = z.infer<typeof ZTeamDocumentPreferencesFormSchema>;
export type TeamDocumentPreferencesFormProps = {
team: Team;
settings?: TeamGlobalSettings | null;
};
export const TeamDocumentPreferencesForm = ({
team,
settings,
}: TeamDocumentPreferencesFormProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { user } = useSession();
const placeholderEmail = user.email ?? 'user@example.com';
const { mutateAsync: updateTeamDocumentPreferences } =
trpc.team.updateTeamDocumentSettings.useMutation();
const form = useForm<TTeamDocumentPreferencesFormSchema>({
defaultValues: {
documentVisibility: settings?.documentVisibility ?? 'EVERYONE',
documentLanguage: isValidLanguageCode(settings?.documentLanguage)
? settings?.documentLanguage
: 'en',
includeSenderDetails: settings?.includeSenderDetails ?? false,
includeSigningCertificate: settings?.includeSigningCertificate ?? true,
signatureTypes: extractTeamSignatureSettings(settings),
},
resolver: zodResolver(ZTeamDocumentPreferencesFormSchema),
});
const includeSenderDetails = form.watch('includeSenderDetails');
const onSubmit = async (data: TTeamDocumentPreferencesFormSchema) => {
try {
const {
documentVisibility,
documentLanguage,
includeSenderDetails,
includeSigningCertificate,
signatureTypes,
} = data;
await updateTeamDocumentPreferences({
teamId: team.id,
settings: {
documentVisibility,
documentLanguage,
includeSenderDetails,
includeSigningCertificate,
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
},
});
toast({
title: _(msg`Document preferences updated`),
description: _(msg`Your document preferences have been updated`),
});
} catch (err) {
toast({
title: _(msg`Something went wrong!`),
description: _(
msg`We were unable to update your document preferences at this time, please try again later`,
),
});
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset
className="flex h-full max-w-xl flex-col gap-y-6"
disabled={form.formState.isSubmitting}
>
<FormField
control={form.control}
name="documentVisibility"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Default Document Visibility</Trans>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={DocumentVisibility.EVERYONE}>
<Trans>Everyone can access and view the document</Trans>
</SelectItem>
<SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE}>
<Trans>Only managers and above can access and view the document</Trans>
</SelectItem>
<SelectItem value={DocumentVisibility.ADMIN}>
<Trans>Only admins can access and view the document</Trans>
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<Trans>Controls the default visibility of an uploaded document.</Trans>
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="documentLanguage"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Default Document Language</Trans>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(SUPPORTED_LANGUAGES).map(([code, language]) => (
<SelectItem key={code} value={code}>
{language.full}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<Trans>
Controls the default language of an uploaded document. This will be used as the
language in email communications with the recipients.
</Trans>
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="signatureTypes"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel className="flex flex-row items-center">
<Trans>Default Signature Settings</Trans>
<DocumentSignatureSettingsTooltip />
</FormLabel>
<FormControl>
<MultiSelectCombobox
options={Object.values(DOCUMENT_SIGNATURE_TYPES).map((option) => ({
label: _(option.label),
value: option.value,
}))}
selectedValues={field.value}
onChange={field.onChange}
className="bg-background w-full"
enableSearch={false}
emptySelectionPlaceholder="Select signature types"
testId="signature-types-combobox"
/>
</FormControl>
{form.formState.errors.signatureTypes ? (
<FormMessage />
) : (
<FormDescription>
<Trans>
Controls which signatures are allowed to be used when signing a document.
</Trans>
</FormDescription>
)}
</FormItem>
)}
/>
<FormField
control={form.control}
name="includeSenderDetails"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Send on Behalf of Team</Trans>
</FormLabel>
<div>
<FormControl className="block">
<Switch
ref={field.ref}
name={field.name}
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</div>
<div className="pt-2">
<div className="text-muted-foreground text-xs font-medium">
<Trans>Preview</Trans>
</div>
<Alert variant="neutral" className="mt-1 px-2.5 py-1.5 text-sm">
{includeSenderDetails ? (
<Trans>
"{placeholderEmail}" on behalf of "{team.name}" has invited you to sign
"example document".
</Trans>
) : (
<Trans>"{team.name}" has invited you to sign "example document".</Trans>
)}
</Alert>
</div>
<FormDescription>
<Trans>
Controls the formatting of the message that will be sent when inviting a
recipient to sign a document. If a custom message has been provided while
configuring the document, it will be used instead.
</Trans>
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="includeSigningCertificate"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Include the Signing Certificate in the Document</Trans>
</FormLabel>
<div>
<FormControl className="block">
<Switch
ref={field.ref}
name={field.name}
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</div>
<FormDescription>
<Trans>
Controls whether the signing certificate will be included in the document when
it is downloaded. The signing certificate can still be downloaded from the logs
page separately.
</Trans>
</FormDescription>
</FormItem>
)}
/>
<div className="flex flex-row justify-end space-x-4">
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update</Trans>
</Button>
</div>
</fieldset>
</form>
</Form>
);
};

View File

@ -10,7 +10,7 @@ import type { z } from 'zod';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { ZUpdateTeamMutationSchema } from '@documenso/trpc/server/team-router/schema';
import { ZUpdateTeamRequestSchema } from '@documenso/trpc/server/team-router/update-team.types';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
@ -29,7 +29,7 @@ export type UpdateTeamDialogProps = {
teamUrl: string;
};
const ZTeamUpdateFormSchema = ZUpdateTeamMutationSchema.shape.data.pick({
const ZTeamUpdateFormSchema = ZUpdateTeamRequestSchema.shape.data.pick({
name: true,
url: true,
});
@ -49,7 +49,7 @@ export const TeamUpdateForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogPr
},
});
const { mutateAsync: updateTeam } = trpc.team.updateTeam.useMutation();
const { mutateAsync: updateTeam } = trpc.team.update.useMutation();
const onFormSubmit = async ({ name, url }: TTeamUpdateFormSchema) => {
try {

View File

@ -8,12 +8,11 @@ import type { ApiToken } from '@prisma/client';
import { AnimatePresence, motion } from 'framer-motion';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import type { z } from 'zod';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import type { TCreateTokenMutationSchema } from '@documenso/trpc/server/api-token-router/schema';
import { ZCreateTokenMutationSchema } from '@documenso/trpc/server/api-token-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@ -38,7 +37,7 @@ import {
import { Switch } from '@documenso/ui/primitives/switch';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
import { useCurrentTeam } from '~/providers/team';
export const EXPIRATION_DATES = {
ONE_WEEK: msg`7 days`,
@ -48,8 +47,9 @@ export const EXPIRATION_DATES = {
ONE_YEAR: msg`12 months`,
} as const;
const ZCreateTokenFormSchema = ZCreateTokenMutationSchema.extend({
enabled: z.boolean(),
const ZCreateTokenFormSchema = ZCreateTokenMutationSchema.pick({
tokenName: true,
expirationDate: true,
});
type TCreateTokenFormSchema = z.infer<typeof ZCreateTokenFormSchema>;
@ -67,7 +67,7 @@ export type ApiTokenFormProps = {
export const ApiTokenForm = ({ className, tokens }: ApiTokenFormProps) => {
const [, copy] = useCopyToClipboard();
const team = useOptionalCurrentTeam();
const team = useCurrentTeam();
const { _ } = useLingui();
const { toast } = useToast();
@ -86,7 +86,6 @@ export const ApiTokenForm = ({ className, tokens }: ApiTokenFormProps) => {
defaultValues: {
tokenName: '',
expirationDate: '',
enabled: false,
},
});
@ -111,10 +110,10 @@ export const ApiTokenForm = ({ className, tokens }: ApiTokenFormProps) => {
}
};
const onSubmit = async ({ tokenName, expirationDate }: TCreateTokenMutationSchema) => {
const onSubmit = async ({ tokenName, expirationDate }: TCreateTokenFormSchema) => {
try {
await createTokenMutation({
teamId: team?.id,
teamId: team.id,
tokenName,
expirationDate: noExpirationDate ? null : expirationDate,
});
@ -149,7 +148,10 @@ export const ApiTokenForm = ({ className, tokens }: ApiTokenFormProps) => {
<div className={cn(className)}>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset className="mt-6 flex w-full flex-col gap-4">
<fieldset
className="mt-6 flex w-full flex-col gap-4"
disabled={form.formState.isSubmitting}
>
<FormField
control={form.control}
name="tokenName"
@ -209,47 +211,30 @@ export const ApiTokenForm = ({ className, tokens }: ApiTokenFormProps) => {
)}
/>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="">
<FormLabel className="text-muted-foreground mt-2">
<Trans>Never expire</Trans>
</FormLabel>
<FormControl>
<div className="block md:py-1.5">
<Switch
className="bg-background"
checked={field.value}
onCheckedChange={(val) => {
setNoExpirationDate((prev) => !prev);
field.onChange(val);
}}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div>
<FormLabel className="text-muted-foreground mt-2">
<Trans>Never expire</Trans>
</FormLabel>
<div className="block md:py-1.5">
<Switch
className="bg-background mt-2"
checked={noExpirationDate}
onCheckedChange={setNoExpirationDate}
/>
</div>
</div>
</div>
<Button
type="submit"
className="hidden md:inline-flex"
disabled={!form.formState.isDirty}
loading={form.formState.isSubmitting}
>
<Trans>Create token</Trans>
</Button>
<div className="md:hidden">
<Button
type="submit"
disabled={!form.formState.isDirty}
loading={form.formState.isSubmitting}
>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Create token</Trans>
</Button>
</div>

View File

@ -0,0 +1,73 @@
'use client';
import { DateTime } from 'luxon';
import type { TooltipProps } from 'recharts';
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import type { NameType, ValueType } from 'recharts/types/component/DefaultTooltipContent';
import type { GetMonthlyActiveUsersResult } from '@documenso/lib/server-only/admin/get-users-stats';
export type MonthlyActiveUsersChartProps = {
className?: string;
title: string;
cummulative?: boolean;
data: GetMonthlyActiveUsersResult;
};
const CustomTooltip = ({ active, payload, label }: TooltipProps<ValueType, NameType>) => {
if (active && payload && payload.length) {
return (
<div className="z-100 w-60 space-y-1 rounded-md border border-solid bg-white p-2 px-3">
<p>{label}</p>
<p className="text-documenso">
{payload[0].name === 'cume_count' ? 'Cumulative MAU' : 'Monthly Active Users'}:{' '}
<span className="text-black">{Number(payload[0].value).toLocaleString('en-US')}</span>
</p>
</div>
);
}
return null;
};
export const MonthlyActiveUsersChart = ({
className,
data,
title,
cummulative = false,
}: MonthlyActiveUsersChartProps) => {
const formattedData = [...data].reverse().map(({ month, count, cume_count }) => {
return {
month: DateTime.fromFormat(month, 'yyyy-MM').toFormat('MMM yyyy'),
count: Number(count),
cume_count: Number(cume_count),
};
});
return (
<div className={className}>
<div className="border-border flex flex-1 flex-col justify-center rounded-2xl border p-6 pl-2">
<div className="mb-6 flex px-4">
<h3 className="text-lg font-semibold">{title}</h3>
</div>
<ResponsiveContainer width="100%" height={400}>
<BarChart data={formattedData}>
<XAxis dataKey="month" />
<YAxis />
<Tooltip content={<CustomTooltip />} cursor={{ fill: 'hsl(var(--primary) / 10%)' }} />
<Bar
dataKey={cummulative ? 'cume_count' : 'count'}
fill="hsl(var(--primary))"
radius={[4, 4, 0, 0]}
maxBarSize={60}
label={cummulative ? 'Cumulative MAU' : 'Monthly Active Users'}
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
};

View File

@ -9,6 +9,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
import { useNavigate } from 'react-router';
import { Theme, useTheme } from 'remix-themes';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
import {
DOCUMENTS_PAGE_SHORTCUT,
@ -20,6 +21,7 @@ import {
SKIP_QUERY_BATCH_META,
} from '@documenso/lib/constants/trpc';
import { dynamicActivate } from '@documenso/lib/utils/i18n';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import {
@ -33,28 +35,7 @@ import {
} from '@documenso/ui/primitives/command';
import { useToast } from '@documenso/ui/primitives/use-toast';
const DOCUMENTS_PAGES = [
{
label: msg`All documents`,
path: '/documents?status=ALL',
shortcut: DOCUMENTS_PAGE_SHORTCUT.replace('+', ''),
},
{ label: msg`Draft documents`, path: '/documents?status=DRAFT' },
{
label: msg`Completed documents`,
path: '/documents?status=COMPLETED',
},
{ label: msg`Pending documents`, path: '/documents?status=PENDING' },
{ label: msg`Inbox documents`, path: '/documents?status=INBOX' },
];
const TEMPLATES_PAGES = [
{
label: msg`All templates`,
path: '/templates',
shortcut: TEMPLATES_PAGE_SHORTCUT.replace('+', ''),
},
];
import { useOptionalCurrentTeam } from '~/providers/team';
const SETTINGS_PAGES = [
{
@ -73,8 +54,10 @@ export type AppCommandMenuProps = {
export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
const { _ } = useLingui();
const { organisations } = useSession();
const navigate = useNavigate();
const currentTeam = useOptionalCurrentTeam();
const [isOpen, setIsOpen] = useState(() => open ?? false);
const [search, setSearch] = useState('');
@ -94,6 +77,60 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
},
);
const teamUrl = useMemo(() => {
let teamUrl = currentTeam?.url || null;
if (!teamUrl && isPersonalLayout(organisations)) {
teamUrl = organisations[0].teams[0]?.url || null;
}
return teamUrl;
}, [currentTeam, organisations]);
const documentPageLinks = useMemo(() => {
if (!teamUrl) {
return [];
}
return [
{
label: msg`All documents`,
path: `/t/${teamUrl}/documents?status=ALL`,
shortcut: DOCUMENTS_PAGE_SHORTCUT.replace('+', ''),
},
{
label: msg`Draft documents`,
path: `/t/${teamUrl}/documents?status=DRAFT`,
},
{
label: msg`Completed documents`,
path: `/t/${teamUrl}/documents?status=COMPLETED`,
},
{
label: msg`Pending documents`,
path: `/t/${teamUrl}/documents?status=PENDING`,
},
{
label: msg`Inbox documents`,
path: `/t/${teamUrl}/documents?status=INBOX`,
},
];
}, [currentTeam, organisations]);
const templatePageLinks = useMemo(() => {
if (!teamUrl) {
return [];
}
return [
{
label: msg`All templates`,
path: `/t/${teamUrl}/templates`,
shortcut: TEMPLATES_PAGE_SHORTCUT.replace('+', ''),
},
];
}, [currentTeam, organisations]);
const searchResults = useMemo(() => {
if (!searchDocumentsData) {
return [];
@ -145,8 +182,8 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
};
const goToSettings = useCallback(() => push(SETTINGS_PAGES[0].path), [push]);
const goToDocuments = useCallback(() => push(DOCUMENTS_PAGES[0].path), [push]);
const goToTemplates = useCallback(() => push(TEMPLATES_PAGES[0].path), [push]);
const goToDocuments = useCallback(() => push(documentPageLinks[0].path), [push]);
const goToTemplates = useCallback(() => push(templatePageLinks[0].path), [push]);
useHotkeys(['ctrl+k', 'meta+k'], toggleOpen, { preventDefault: true });
useHotkeys(SETTINGS_PAGE_SHORTCUT, goToSettings);
@ -197,12 +234,16 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
)}
{!currentPage && (
<>
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Documents`)}>
<Commands push={push} pages={DOCUMENTS_PAGES} />
</CommandGroup>
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Templates`)}>
<Commands push={push} pages={TEMPLATES_PAGES} />
</CommandGroup>
{documentPageLinks.length > 0 && (
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Documents`)}>
<Commands push={push} pages={documentPageLinks} />
</CommandGroup>
)}
{templatePageLinks.length > 0 && (
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Templates`)}>
<Commands push={push} pages={templatePageLinks} />
</CommandGroup>
)}
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Settings`)}>
<Commands push={push} pages={SETTINGS_PAGES} />
</CommandGroup>

View File

@ -1,12 +1,15 @@
import { type HTMLAttributes, useEffect, useState } from 'react';
import { MenuIcon, SearchIcon } from 'lucide-react';
import { Link, useLocation, useParams } from 'react-router';
import { ReadStatus } from '@prisma/client';
import { InboxIcon, MenuIcon, SearchIcon } from 'lucide-react';
import { Link, useParams } from 'react-router';
import type { SessionUser } from '@documenso/auth/server/lib/session/session';
import type { TGetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { getRootHref } from '@documenso/lib/utils/params';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { BrandingLogo } from '~/components/general/branding-logo';
@ -14,20 +17,28 @@ import { AppCommandMenu } from './app-command-menu';
import { AppNavDesktop } from './app-nav-desktop';
import { AppNavMobile } from './app-nav-mobile';
import { MenuSwitcher } from './menu-switcher';
import { OrgMenuSwitcher } from './org-menu-switcher';
export type HeaderProps = HTMLAttributes<HTMLDivElement> & {
user: SessionUser;
teams: TGetTeamsResponse;
};
export type HeaderProps = HTMLAttributes<HTMLDivElement>;
export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
export const Header = ({ className, ...props }: HeaderProps) => {
const params = useParams();
const { pathname } = useLocation();
const { organisations } = useSession();
const [isCommandMenuOpen, setIsCommandMenuOpen] = useState(false);
const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false);
const [scrollY, setScrollY] = useState(0);
const { data: unreadCountData } = trpc.document.inbox.getCount.useQuery(
{
readStatus: ReadStatus.NOT_OPENED,
},
{
// refetchInterval: 30000, // Refetch every 30 seconds
},
);
useEffect(() => {
const onScroll = () => {
setScrollY(window.scrollY);
@ -38,16 +49,6 @@ export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
return () => window.removeEventListener('scroll', onScroll);
}, []);
const isPathTeamUrl = (teamUrl: string) => {
if (!pathname || !pathname.startsWith(`/t/`)) {
return false;
}
return pathname.split('/')[2] === teamUrl;
};
const selectedTeam = teams?.find((team) => isPathTeamUrl(team.url));
return (
<header
className={cn(
@ -59,7 +60,7 @@ export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
>
<div className="mx-auto flex w-full max-w-screen-xl items-center justify-between gap-x-4 px-4 md:justify-normal md:px-8">
<Link
to={`${getRootHref(params, { returnEmptyRootString: true })}/documents`}
to={`${getRootHref(params, { returnEmptyRootString: true })}`}
className="focus-visible:ring-ring ring-offset-background hidden rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 md:inline"
>
<BrandingLogo className="h-6 w-auto" />
@ -67,11 +68,20 @@ export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
<AppNavDesktop setIsCommandMenuOpen={setIsCommandMenuOpen} />
<div
className="flex gap-x-4 md:ml-8"
title={selectedTeam ? selectedTeam.name : (user.name ?? '')}
>
<MenuSwitcher user={user} teams={teams} />
<Button asChild variant="outline" className="relative hidden h-10 w-10 rounded-lg md:flex">
<Link to="/inbox" className="relative block h-10 w-10">
<InboxIcon className="text-muted-foreground hover:text-foreground h-5 w-5 flex-shrink-0 transition-colors" />
{unreadCountData && unreadCountData.count > 0 && (
<span className="bg-primary text-primary-foreground absolute -right-1.5 -top-1.5 flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-semibold">
{unreadCountData.count > 99 ? '99+' : unreadCountData.count}
</span>
)}
</Link>
</Button>
<div className="md:ml-4">
{isPersonalLayout(organisations) ? <MenuSwitcher /> : <OrgMenuSwitcher />}
</div>
<div className="flex flex-row items-center space-x-4 md:hidden">

View File

@ -1,26 +1,20 @@
import type { HTMLAttributes } from 'react';
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { motion } from 'framer-motion';
import { AnimatePresence } from 'framer-motion';
import { Search } from 'lucide-react';
import { Link, useLocation, useParams } from 'react-router';
import { Link, useLocation } from 'react-router';
import { getRootHref } from '@documenso/lib/utils/params';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
const navigationLinks = [
{
href: '/documents',
label: msg`Documents`,
},
{
href: '/templates',
label: msg`Templates`,
},
];
import { useOptionalCurrentTeam } from '~/providers/team';
export type AppNavDesktopProps = HTMLAttributes<HTMLDivElement> & {
setIsCommandMenuOpen: (value: boolean) => void;
@ -32,13 +26,13 @@ export const AppNavDesktop = ({
...props
}: AppNavDesktopProps) => {
const { _ } = useLingui();
const { organisations } = useSession();
const { pathname } = useLocation();
const params = useParams();
const [modifierKey, setModifierKey] = useState(() => 'Ctrl');
const rootHref = getRootHref(params, { returnEmptyRootString: true });
const currentTeam = useOptionalCurrentTeam();
useEffect(() => {
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown';
@ -47,6 +41,29 @@ export const AppNavDesktop = ({
setModifierKey(isMacOS ? '⌘' : 'Ctrl');
}, []);
const menuNavigationLinks = useMemo(() => {
let teamUrl = currentTeam?.url || null;
if (!teamUrl && isPersonalLayout(organisations)) {
teamUrl = organisations[0].teams[0]?.url || null;
}
if (!teamUrl) {
return [];
}
return [
{
href: `/t/${teamUrl}/documents`,
label: msg`Documents`,
},
{
href: `/t/${teamUrl}/templates`,
label: msg`Templates`,
},
];
}, [currentTeam, organisations]);
return (
<div
className={cn(
@ -55,23 +72,32 @@ export const AppNavDesktop = ({
)}
{...props}
>
<div className="flex items-baseline gap-x-6">
{navigationLinks.map(({ href, label }) => (
<Link
key={href}
to={`${rootHref}${href}`}
className={cn(
'text-muted-foreground dark:text-muted-foreground/60 focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2',
{
'text-foreground dark:text-muted-foreground': pathname?.startsWith(
`${rootHref}${href}`,
),
},
)}
>
{_(label)}
</Link>
))}
<div>
<AnimatePresence>
{menuNavigationLinks.length > 0 && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex items-baseline gap-x-6"
>
{menuNavigationLinks.map(({ href, label }) => (
<Link
key={href}
to={href}
className={cn(
'text-muted-foreground dark:text-muted-foreground/60 focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2',
{
'text-foreground dark:text-muted-foreground': pathname?.startsWith(href),
},
)}
>
{_(label)}
</Link>
))}
</motion.div>
)}
</AnimatePresence>
</div>
<Button

View File

@ -1,48 +1,84 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { useMemo } from 'react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { Link, useParams } from 'react-router';
import { ReadStatus } from '@prisma/client';
import { Link } from 'react-router';
import LogoImage from '@documenso/assets/logo.png';
import { authClient } from '@documenso/auth/client';
import { getRootHref } from '@documenso/lib/utils/params';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { trpc } from '@documenso/trpc/react';
import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet';
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
import { useOptionalCurrentTeam } from '~/providers/team';
export type AppNavMobileProps = {
isMenuOpen: boolean;
onMenuOpenChange?: (_value: boolean) => void;
};
export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps) => {
const { _ } = useLingui();
const { t } = useLingui();
const params = useParams();
const { organisations } = useSession();
const currentTeam = useOptionalCurrentTeam();
const { data: unreadCountData } = trpc.document.inbox.getCount.useQuery(
{
readStatus: ReadStatus.NOT_OPENED,
},
{
// refetchInterval: 30000, // Refetch every 30 seconds
},
);
const handleMenuItemClick = () => {
onMenuOpenChange?.(false);
};
const rootHref = getRootHref(params, { returnEmptyRootString: true });
const menuNavigationLinks = useMemo(() => {
let teamUrl = currentTeam?.url || null;
const menuNavigationLinks = [
{
href: `${rootHref}/documents`,
text: msg`Documents`,
},
{
href: `${rootHref}/templates`,
text: msg`Templates`,
},
{
href: '/settings/teams',
text: msg`Teams`,
},
{
href: '/settings/profile',
text: msg`Settings`,
},
];
if (!teamUrl && isPersonalLayout(organisations)) {
teamUrl = organisations[0].teams[0]?.url || null;
}
if (!teamUrl) {
return [
{
href: '/inbox',
text: t`Inbox`,
},
{
href: '/settings/profile',
text: t`Settings`,
},
];
}
return [
{
href: `/t/${teamUrl}/documents`,
text: t`Documents`,
},
{
href: `/t/${teamUrl}/templates`,
text: t`Templates`,
},
{
href: '/inbox',
text: t`Inbox`,
},
{
href: '/settings/profile',
text: t`Settings`,
},
];
}, [currentTeam, organisations]);
return (
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
@ -61,11 +97,16 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
{menuNavigationLinks.map(({ href, text }) => (
<Link
key={href}
className="text-foreground hover:text-foreground/80 text-2xl font-semibold"
className="text-foreground hover:text-foreground/80 flex items-center gap-2 text-2xl font-semibold"
to={href}
onClick={() => handleMenuItemClick()}
>
{_(text)}
{text}
{href === '/inbox' && unreadCountData && unreadCountData.count > 0 && (
<span className="bg-primary text-primary-foreground flex h-6 min-w-[1.5rem] items-center justify-center rounded-full px-1.5 text-xs font-semibold">
{unreadCountData.count > 99 ? '99+' : unreadCountData.count}
</span>
)}
</Link>
))}

View File

@ -1,91 +1,91 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { AnimatePresence, motion } from 'framer-motion';
import { Building2Icon, PlusIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import type { PriceIntervals } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
import type { InternalClaimPlans } from '@documenso/ee/server-only/stripe/get-internal-claim-plans';
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
import { toHumanPrice } from '@documenso/lib/universal/stripe/to-human-price';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent, CardTitle } from '@documenso/ui/primitives/card';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} 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 { Label } from '@documenso/ui/primitives/label';
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { useToast } from '@documenso/ui/primitives/use-toast';
type Interval = keyof PriceIntervals;
const INTERVALS: Interval[] = ['day', 'week', 'month', 'year'];
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const isInterval = (value: unknown): value is Interval => INTERVALS.includes(value as Interval);
const FRIENDLY_INTERVALS: Record<Interval, MessageDescriptor> = {
day: msg`Daily`,
week: msg`Weekly`,
month: msg`Monthly`,
year: msg`Yearly`,
};
import { ZCreateOrganisationFormSchema } from '../dialogs/organisation-create-dialog';
const MotionCard = motion(Card);
export type BillingPlansProps = {
prices: PriceIntervals;
plans: InternalClaimPlans;
};
export const BillingPlans = ({ prices }: BillingPlansProps) => {
const { _ } = useLingui();
const { toast } = useToast();
export const BillingPlans = ({ plans }: BillingPlansProps) => {
const isMounted = useIsMounted();
const [interval, setInterval] = useState<Interval>('month');
const [checkoutSessionPriceId, setCheckoutSessionPriceId] = useState<string | null>(null);
const [interval, setInterval] = useState<'monthlyPrice' | 'yearlyPrice'>('yearlyPrice');
const { mutateAsync: createCheckoutSession } = trpc.profile.createCheckoutSession.useMutation();
const pricesToDisplay = useMemo(() => {
const prices = [];
const onSubscribeClick = async (priceId: string) => {
try {
setCheckoutSessionPriceId(priceId);
const url = await createCheckoutSession({ priceId });
if (!url) {
throw new Error('Unable to create session');
for (const plan of Object.values(plans)) {
if (plan[interval] && plan[interval].isVisibleInApp) {
prices.push({
...plan[interval],
memberCount: plan.memberCount,
claim: plan.id,
});
}
window.open(url);
} catch (_err) {
toast({
title: _(msg`Something went wrong`),
description: _(msg`An error occurred while trying to create a checkout session.`),
variant: 'destructive',
});
} finally {
setCheckoutSessionPriceId(null);
}
};
return prices;
}, [plans, interval]);
return (
<div>
<Tabs value={interval} onValueChange={(value) => isInterval(value) && setInterval(value)}>
<Tabs
value={interval}
onValueChange={(value) => setInterval(value as 'monthlyPrice' | 'yearlyPrice')}
>
<TabsList>
{INTERVALS.map(
(interval) =>
prices[interval].length > 0 && (
<TabsTrigger key={interval} className="min-w-[150px]" value={interval}>
{_(FRIENDLY_INTERVALS[interval])}
</TabsTrigger>
),
)}
<TabsTrigger className="min-w-[150px]" value="monthlyPrice">
<Trans>Monthly</Trans>
</TabsTrigger>
<TabsTrigger className="min-w-[150px]" value="yearlyPrice">
<Trans>Yearly</Trans>
</TabsTrigger>
</TabsList>
</Tabs>
<div className="mt-8 grid gap-8 lg:grid-cols-2 2xl:grid-cols-3">
<AnimatePresence mode="wait">
{prices[interval].map((price) => (
{pricesToDisplay.map((price) => (
<MotionCard
key={price.id}
initial={{ opacity: isMounted ? 0 : 1, y: isMounted ? 20 : 0 }}
@ -96,8 +96,14 @@ export const BillingPlans = ({ prices }: BillingPlansProps) => {
<CardTitle>{price.product.name}</CardTitle>
<div className="text-muted-foreground mt-2 text-lg font-medium">
${toHumanPrice(price.unit_amount ?? 0)} {price.currency.toUpperCase()}{' '}
<span className="text-xs">per {interval}</span>
{price.friendlyPrice + ' '}
<span className="text-xs">
{interval === 'monthlyPrice' ? (
<Trans>per month</Trans>
) : (
<Trans>per year</Trans>
)}
</span>
</div>
<div className="text-muted-foreground mt-1.5 text-sm">
@ -120,14 +126,12 @@ export const BillingPlans = ({ prices }: BillingPlansProps) => {
<div className="flex-1" />
<Button
className="mt-4"
disabled={checkoutSessionPriceId !== null}
loading={checkoutSessionPriceId === price.id}
onClick={() => void onSubscribeClick(price.id)}
>
<Trans>Subscribe</Trans>
</Button>
<BillingDialog
priceId={price.id}
planName={price.product.name}
memberCount={price.memberCount}
claim={price.claim}
/>
</CardContent>
</MotionCard>
))}
@ -136,3 +140,178 @@ export const BillingPlans = ({ prices }: BillingPlansProps) => {
</div>
);
};
const BillingDialog = ({
priceId,
planName,
claim,
}: {
priceId: string;
planName: string;
memberCount: number;
claim: string;
}) => {
const [isOpen, setIsOpen] = useState(false);
const { t } = useLingui();
const { toast } = useToast();
const organisation = useCurrentOrganisation();
const [subscriptionOption, setSubscriptionOption] = useState<'update' | 'create'>(
organisation.type === 'PERSONAL' && claim === INTERNAL_CLAIM_ID.INDIVIDUAL
? 'update'
: 'create',
);
const [step, setStep] = useState(0);
const form = useForm({
resolver: zodResolver(ZCreateOrganisationFormSchema),
defaultValues: {
name: '',
},
});
const { mutateAsync: createSubscription, isPending: isCreatingSubscription } =
trpc.billing.subscription.create.useMutation();
const { mutateAsync: createOrganisation, isPending: isCreatingOrganisation } =
trpc.organisation.create.useMutation();
const isPending = isCreatingSubscription || isCreatingOrganisation;
const onSubscribeClick = async () => {
try {
let redirectUrl = '';
if (subscriptionOption === 'update') {
const createSubscriptionResponse = await createSubscription({
organisationId: organisation.id,
priceId,
});
redirectUrl = createSubscriptionResponse.redirectUrl;
} else {
const createOrganisationResponse = await createOrganisation({
name: form.getValues('name'),
priceId,
});
if (!createOrganisationResponse.paymentRequired) {
setIsOpen(false);
return;
}
redirectUrl = createOrganisationResponse.checkoutUrl;
}
window.location.href = redirectUrl;
} catch (_err) {
toast({
title: t`Something went wrong`,
description: t`An error occurred while trying to create a checkout session.`,
variant: 'destructive',
});
}
};
return (
<Dialog open={isOpen} onOpenChange={(value) => !isPending && setIsOpen(value)}>
<DialogTrigger asChild>
<Button>
<Trans>Subscribe</Trans>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Subscribe</Trans>
</DialogTitle>
<DialogDescription>
<Trans>You are about to subscribe to the {planName}</Trans>
</DialogDescription>
</DialogHeader>
{step === 0 ? (
<div>
<RadioGroup
className="space-y-2"
value={subscriptionOption}
onValueChange={(value) => setSubscriptionOption(value as 'update' | 'create')}
>
<div className="flex items-start space-x-3 space-y-0">
<RadioGroupItem value="update" id="update" />
<div className="space-y-1.5 leading-none">
<Label htmlFor="update" className="flex items-center gap-2 font-medium">
<Building2Icon className="h-4 w-4" />
<Trans>Update current organisation</Trans>
</Label>
<p className="text-muted-foreground text-sm">
<Trans>
Upgrade <strong>{organisation.name}</strong> to {planName}
</Trans>
</p>
</div>
</div>
<div className="flex items-start space-x-3 space-y-0">
<RadioGroupItem value="create" id="create" />
<div className="space-y-1.5 leading-none">
<Label htmlFor="create" className="flex items-center gap-2 font-medium">
<PlusIcon className="h-4 w-4" />
<Trans>Create separate organisation</Trans>
</Label>
<p className="text-muted-foreground text-sm">
<Trans>
Create a new organisation with {planName} plan. Keep your current organisation
on it's current plan
</Trans>
</p>
</div>
</div>
</RadioGroup>
</div>
) : (
<Form {...form}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Organisation Name</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</Form>
)}
<DialogFooter>
<DialogClose>
<Button disabled={isPending} variant="secondary">
<Trans>Close</Trans>
</Button>
</DialogClose>
{subscriptionOption === 'create' && step === 0 ? (
<Button className="mt-4" loading={isPending} onClick={() => setStep(1)}>
<Trans>Continue</Trans>
</Button>
) : (
<Button className="mt-4" loading={isPending} onClick={() => void onSubscribeClick()}>
<Trans>Checkout</Trans>
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -1,48 +0,0 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type BillingPortalButtonProps = {
buttonProps?: React.ComponentProps<typeof Button>;
children?: React.ReactNode;
};
export const BillingPortalButton = ({ buttonProps, children }: BillingPortalButtonProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: createBillingPortal, isPending } =
trpc.profile.createBillingPortal.useMutation({
onSuccess: (sessionUrl) => {
window.open(sessionUrl, '_blank');
},
onError: (err) => {
let description = _(
msg`We are unable to proceed to the billing portal at this time. Please try again, or contact support.`,
);
if (err.message === 'CUSTOMER_NOT_FOUND') {
description = _(
msg`You do not currently have a customer record, this should not happen. Please contact support for assistance.`,
);
}
toast({
title: _(msg`Something went wrong`),
description,
variant: 'destructive',
duration: 10000,
});
},
});
return (
<Button {...buttonProps} onClick={async () => createBillingPortal()} loading={isPending}>
{children || <Trans>Manage Subscription</Trans>}
</Button>
);
};

View File

@ -130,7 +130,7 @@ export const DirectTemplateConfigureForm = ({
{...field}
disabled={
field.disabled ||
derivedRecipientAccessAuth !== null ||
derivedRecipientAccessAuth.length > 0 ||
user?.email !== undefined
}
placeholder="recipient@documenso.com"

View File

@ -1,5 +1,8 @@
import { useState } from 'react';
import { Trans } from '@lingui/react/macro';
import type { FieldType } from '@prisma/client';
import { ChevronLeftIcon } from 'lucide-react';
import { P, match } from 'ts-pattern';
import {
@ -7,6 +10,7 @@ import {
type TRecipientActionAuth,
type TRecipientActionAuthTypes,
} from '@documenso/lib/types/document-auth';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
@ -18,11 +22,12 @@ import {
import { DocumentSigningAuth2FA } from './document-signing-auth-2fa';
import { DocumentSigningAuthAccount } from './document-signing-auth-account';
import { DocumentSigningAuthPasskey } from './document-signing-auth-passkey';
import { DocumentSigningAuthPassword } from './document-signing-auth-password';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
export type DocumentSigningAuthDialogProps = {
title?: string;
documentAuthType: TRecipientActionAuthTypes;
availableAuthTypes: TRecipientActionAuthTypes[];
description?: string;
actionTarget: FieldType | 'DOCUMENT';
open: boolean;
@ -37,54 +42,158 @@ export type DocumentSigningAuthDialogProps = {
export const DocumentSigningAuthDialog = ({
title,
description,
documentAuthType,
availableAuthTypes,
open,
onOpenChange,
onReauthFormSubmit,
}: DocumentSigningAuthDialogProps) => {
const { recipient, user, isCurrentlyAuthenticating } = useRequiredDocumentSigningAuthContext();
// Filter out EXPLICIT_NONE from available auth types for the chooser
const validAuthTypes = availableAuthTypes.filter(
(authType) => authType !== DocumentAuth.EXPLICIT_NONE,
);
const [selectedAuthType, setSelectedAuthType] = useState<TRecipientActionAuthTypes | null>(() => {
// Auto-select if there's only one valid option
if (validAuthTypes.length === 1) {
return validAuthTypes[0];
}
// Return null if multiple options - show chooser
return null;
});
const handleOnOpenChange = (value: boolean) => {
if (isCurrentlyAuthenticating) {
return;
}
// Reset selected auth type when dialog closes
if (!value) {
setSelectedAuthType(() => {
if (validAuthTypes.length === 1) {
return validAuthTypes[0];
}
return null;
});
}
onOpenChange(value);
};
const handleBackToChooser = () => {
setSelectedAuthType(null);
};
// If no valid auth types available, don't render anything
if (validAuthTypes.length === 0) {
return null;
}
return (
<Dialog open={open} onOpenChange={handleOnOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title || <Trans>Sign field</Trans>}</DialogTitle>
<DialogTitle>
{selectedAuthType && validAuthTypes.length > 1 && (
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleBackToChooser}
className="h-6 w-6 p-0"
>
<ChevronLeftIcon className="h-4 w-4" />
</Button>
<span>{title || <Trans>Sign field</Trans>}</span>
</div>
)}
{(!selectedAuthType || validAuthTypes.length === 1) &&
(title || <Trans>Sign field</Trans>)}
</DialogTitle>
<DialogDescription>
{description || <Trans>Reauthentication is required to sign this field</Trans>}
</DialogDescription>
</DialogHeader>
{match({ documentAuthType, user })
.with(
{ documentAuthType: DocumentAuth.ACCOUNT },
{ user: P.when((user) => !user || user.email !== recipient.email) }, // Assume all current auth methods requires them to be logged in.
() => <DocumentSigningAuthAccount onOpenChange={onOpenChange} />,
)
.with({ documentAuthType: DocumentAuth.PASSKEY }, () => (
<DocumentSigningAuthPasskey
open={open}
onOpenChange={onOpenChange}
onReauthFormSubmit={onReauthFormSubmit}
/>
))
.with({ documentAuthType: DocumentAuth.TWO_FACTOR_AUTH }, () => (
<DocumentSigningAuth2FA
open={open}
onOpenChange={onOpenChange}
onReauthFormSubmit={onReauthFormSubmit}
/>
))
.with({ documentAuthType: DocumentAuth.EXPLICIT_NONE }, () => null)
.exhaustive()}
{/* Show chooser if no auth type is selected and there are multiple options */}
{!selectedAuthType && validAuthTypes.length > 1 && (
<div className="space-y-4">
<p className="text-muted-foreground text-sm">
<Trans>Choose your preferred authentication method:</Trans>
</p>
<div className="grid gap-2">
{validAuthTypes.map((authType) => (
<Button
key={authType}
type="button"
variant="outline"
className="h-auto justify-start p-4"
onClick={() => setSelectedAuthType(authType)}
>
<div className="text-left">
<div className="font-medium">
{match(authType)
.with(DocumentAuth.ACCOUNT, () => <Trans>Account</Trans>)
.with(DocumentAuth.PASSKEY, () => <Trans>Passkey</Trans>)
.with(DocumentAuth.TWO_FACTOR_AUTH, () => <Trans>2FA</Trans>)
.with(DocumentAuth.PASSWORD, () => <Trans>Password</Trans>)
.exhaustive()}
</div>
<div className="text-muted-foreground text-sm">
{match(authType)
.with(DocumentAuth.ACCOUNT, () => <Trans>Sign in to your account</Trans>)
.with(DocumentAuth.PASSKEY, () => (
<Trans>Use your passkey for authentication</Trans>
))
.with(DocumentAuth.TWO_FACTOR_AUTH, () => (
<Trans>Enter your 2FA code</Trans>
))
.with(DocumentAuth.PASSWORD, () => <Trans>Enter your password</Trans>)
.exhaustive()}
</div>
</div>
</Button>
))}
</div>
</div>
)}
{/* Show the selected auth component */}
{selectedAuthType &&
match({ documentAuthType: selectedAuthType, user })
.with(
{ documentAuthType: DocumentAuth.ACCOUNT },
{ user: P.when((user) => !user || user.email !== recipient.email) }, // Assume all current auth methods requires them to be logged in.
() => <DocumentSigningAuthAccount onOpenChange={onOpenChange} />,
)
.with({ documentAuthType: DocumentAuth.PASSKEY }, () => (
<DocumentSigningAuthPasskey
open={open}
onOpenChange={onOpenChange}
onReauthFormSubmit={onReauthFormSubmit}
/>
))
.with({ documentAuthType: DocumentAuth.TWO_FACTOR_AUTH }, () => (
<DocumentSigningAuth2FA
open={open}
onOpenChange={onOpenChange}
onReauthFormSubmit={onReauthFormSubmit}
/>
))
.with({ documentAuthType: DocumentAuth.PASSWORD }, () => (
<DocumentSigningAuthPassword
open={open}
onOpenChange={onOpenChange}
onReauthFormSubmit={onReauthFormSubmit}
/>
))
.with({ documentAuthType: DocumentAuth.EXPLICIT_NONE }, () => null)
.exhaustive()}
</DialogContent>
</Dialog>
);

View File

@ -0,0 +1,148 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { AppError } from '@documenso/lib/errors/app-error';
import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { DialogFooter } 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 { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
export type DocumentSigningAuthPasswordProps = {
actionTarget?: 'FIELD' | 'DOCUMENT';
actionVerb?: string;
open: boolean;
onOpenChange: (value: boolean) => void;
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
};
const ZPasswordAuthFormSchema = z.object({
password: z
.string()
.min(1, { message: 'Password is required' })
.max(72, { message: 'Password must be at most 72 characters long' }),
});
type TPasswordAuthFormSchema = z.infer<typeof ZPasswordAuthFormSchema>;
export const DocumentSigningAuthPassword = ({
actionTarget = 'FIELD',
actionVerb = 'sign',
onReauthFormSubmit,
open,
onOpenChange,
}: DocumentSigningAuthPasswordProps) => {
const { recipient, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } =
useRequiredDocumentSigningAuthContext();
const form = useForm<TPasswordAuthFormSchema>({
resolver: zodResolver(ZPasswordAuthFormSchema),
defaultValues: {
password: '',
},
});
const [formErrorCode, setFormErrorCode] = useState<string | null>(null);
const onFormSubmit = async ({ password }: TPasswordAuthFormSchema) => {
try {
setIsCurrentlyAuthenticating(true);
await onReauthFormSubmit({
type: DocumentAuth.PASSWORD,
password,
});
setIsCurrentlyAuthenticating(false);
onOpenChange(false);
} catch (err) {
setIsCurrentlyAuthenticating(false);
const error = AppError.parseError(err);
setFormErrorCode(error.code);
// Todo: Alert.
}
};
useEffect(() => {
form.reset({
password: '',
});
setFormErrorCode(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={isCurrentlyAuthenticating}>
<div className="space-y-4">
{formErrorCode && (
<Alert variant="destructive">
<AlertTitle>
<Trans>Unauthorized</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
We were unable to verify your details. Please try again or contact support
</Trans>
</AlertDescription>
</Alert>
)}
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Password</Trans>
</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter your password"
{...field}
autoComplete="current-password"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={isCurrentlyAuthenticating}>
<Trans>Sign</Trans>
</Button>
</DialogFooter>
</div>
</fieldset>
</form>
</Form>
);
};

View File

@ -1,7 +1,6 @@
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { type Document, FieldType, type Passkey, type Recipient } from '@prisma/client';
import { match } from 'ts-pattern';
import type { SessionUser } from '@documenso/auth/server/lib/session/session';
import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth';
@ -33,8 +32,8 @@ export type DocumentSigningAuthContextValue = {
recipient: Recipient;
recipientAuthOption: TRecipientAuthOptions;
setRecipient: (_value: Recipient) => void;
derivedRecipientAccessAuth: TRecipientAccessAuthTypes | null;
derivedRecipientActionAuth: TRecipientActionAuthTypes | null;
derivedRecipientAccessAuth: TRecipientAccessAuthTypes[];
derivedRecipientActionAuth: TRecipientActionAuthTypes[];
isAuthRedirectRequired: boolean;
isCurrentlyAuthenticating: boolean;
setIsCurrentlyAuthenticating: (_value: boolean) => void;
@ -100,7 +99,7 @@ export const DocumentSigningAuthProvider = ({
},
{
placeholderData: (previousData) => previousData,
enabled: derivedRecipientActionAuth === DocumentAuth.PASSKEY,
enabled: derivedRecipientActionAuth?.includes(DocumentAuth.PASSKEY) ?? false,
},
);
@ -121,21 +120,28 @@ export const DocumentSigningAuthProvider = ({
* Will be `null` if the user still requires authentication, or if they don't need
* authentication.
*/
const preCalculatedActionAuthOptions = match(derivedRecipientActionAuth)
.with(DocumentAuth.ACCOUNT, () => {
if (recipient.email !== user?.email) {
return null;
}
const preCalculatedActionAuthOptions = useMemo(() => {
if (
!derivedRecipientActionAuth ||
derivedRecipientActionAuth.length === 0 ||
derivedRecipientActionAuth.includes(DocumentAuth.EXPLICIT_NONE)
) {
return {
type: DocumentAuth.EXPLICIT_NONE,
};
}
if (
derivedRecipientActionAuth.includes(DocumentAuth.ACCOUNT) &&
user?.email == recipient.email
) {
return {
type: DocumentAuth.ACCOUNT,
};
})
.with(DocumentAuth.EXPLICIT_NONE, () => ({
type: DocumentAuth.EXPLICIT_NONE,
}))
.with(DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH, null, () => null)
.exhaustive();
}
return null;
}, [derivedRecipientActionAuth, user, recipient]);
const executeActionAuthProcedure = async (options: ExecuteActionAuthProcedureOptions) => {
// Directly run callback if no auth required.
@ -170,7 +176,8 @@ export const DocumentSigningAuthProvider = ({
// Assume that a user must be logged in for any auth requirements.
const isAuthRedirectRequired = Boolean(
derivedRecipientActionAuth &&
derivedRecipientActionAuth !== DocumentAuth.EXPLICIT_NONE &&
derivedRecipientActionAuth.length > 0 &&
!derivedRecipientActionAuth.includes(DocumentAuth.EXPLICIT_NONE) &&
user?.email !== recipient.email,
);
@ -208,7 +215,7 @@ export const DocumentSigningAuthProvider = ({
onOpenChange={() => setDocumentAuthDialogPayload(null)}
onReauthFormSubmit={documentAuthDialogPayload.onReauthFormSubmit}
actionTarget={documentAuthDialogPayload.actionTarget}
documentAuthType={derivedRecipientActionAuth}
availableAuthTypes={derivedRecipientActionAuth}
/>
)}
</DocumentSigningAuthContext.Provider>
@ -217,7 +224,7 @@ export const DocumentSigningAuthProvider = ({
type ExecuteActionAuthProcedureOptions = Omit<
DocumentSigningAuthDialogProps,
'open' | 'onOpenChange' | 'documentAuthType' | 'recipientRole'
'open' | 'onOpenChange' | 'documentAuthType' | 'recipientRole' | 'availableAuthTypes'
>;
DocumentSigningAuthProvider.displayName = 'DocumentSigningAuthProvider';

View File

@ -44,6 +44,7 @@ const AUTO_SIGNABLE_FIELD_TYPES: string[] = [
// other field types.
const NON_AUTO_SIGNABLE_ACTION_AUTH_TYPES: string[] = [
DocumentAuth.PASSKEY,
DocumentAuth.PASSWORD,
DocumentAuth.TWO_FACTOR_AUTH,
];
@ -96,8 +97,8 @@ export const DocumentSigningAutoSign = ({ recipient, fields }: DocumentSigningAu
return true;
});
const actionAuthAllowsAutoSign = !NON_AUTO_SIGNABLE_ACTION_AUTH_TYPES.includes(
derivedRecipientActionAuth ?? '',
const actionAuthAllowsAutoSign = derivedRecipientActionAuth.every(
(actionAuth) => !NON_AUTO_SIGNABLE_ACTION_AUTH_TYPES.includes(actionAuth),
);
const onSubmit = async () => {
@ -110,16 +111,16 @@ export const DocumentSigningAutoSign = ({ recipient, fields }: DocumentSigningAu
.with(FieldType.DATE, () => new Date().toISOString())
.otherwise(() => '');
const authOptions = match(derivedRecipientActionAuth)
const authOptions = match(derivedRecipientActionAuth.at(0))
.with(DocumentAuth.ACCOUNT, () => ({
type: DocumentAuth.ACCOUNT,
}))
.with(DocumentAuth.EXPLICIT_NONE, () => ({
type: DocumentAuth.EXPLICIT_NONE,
}))
.with(null, () => undefined)
.with(undefined, () => undefined)
.with(
P.union(DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH),
P.union(DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH, DocumentAuth.PASSWORD),
// This is a bit dirty, but the sentinel value used here is incredibly short-lived.
() => 'NOT_SUPPORTED' as const,
)

View File

@ -92,8 +92,6 @@ export const DocumentSigningCompleteDialog = ({
};
const onFormSubmit = async (data: TNextSignerFormSchema) => {
console.log('data', data);
console.log('form.formState.errors', form.formState.errors);
try {
if (allowDictateNextSigner && data.name && data.email) {
await onSignatureComplete({ name: data.name, email: data.email });

View File

@ -47,6 +47,7 @@ export type DocumentSigningPageViewProps = {
completedFields: CompletedField[];
isRecipientsTurn: boolean;
allRecipients?: RecipientWithFields[];
includeSenderDetails: boolean;
};
export const DocumentSigningPageView = ({
@ -56,18 +57,16 @@ export const DocumentSigningPageView = ({
completedFields,
isRecipientsTurn,
allRecipients = [],
includeSenderDetails,
}: DocumentSigningPageViewProps) => {
const { documentData, documentMeta } = document;
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
const shouldUseTeamDetails =
document.teamId && document.team?.teamGlobalSettings?.includeSenderDetails === false;
let senderName = document.user.name ?? '';
let senderEmail = `(${document.user.email})`;
if (shouldUseTeamDetails) {
if (includeSenderDetails) {
senderName = document.team?.name ?? '';
senderEmail = document.team?.teamEmail?.email ? `(${document.team.teamEmail.email})` : '';
}
@ -92,7 +91,7 @@ export const DocumentSigningPageView = ({
<span className="text-muted-foreground">
{match(recipient.role)
.with(RecipientRole.VIEWER, () =>
document.teamId && !shouldUseTeamDetails ? (
includeSenderDetails ? (
<Trans>
on behalf of "{document.team?.name}" has invited you to view this document
</Trans>
@ -101,7 +100,7 @@ export const DocumentSigningPageView = ({
),
)
.with(RecipientRole.SIGNER, () =>
document.teamId && !shouldUseTeamDetails ? (
includeSenderDetails ? (
<Trans>
on behalf of "{document.team?.name}" has invited you to sign this document
</Trans>
@ -110,7 +109,7 @@ export const DocumentSigningPageView = ({
),
)
.with(RecipientRole.APPROVER, () =>
document.teamId && !shouldUseTeamDetails ? (
includeSenderDetails ? (
<Trans>
on behalf of "{document.team?.name}" has invited you to approve this document
</Trans>
@ -119,7 +118,7 @@ export const DocumentSigningPageView = ({
),
)
.with(RecipientRole.ASSISTANT, () =>
document.teamId && !shouldUseTeamDetails ? (
includeSenderDetails ? (
<Trans>
on behalf of "{document.team?.name}" has invited you to assist this document
</Trans>

View File

@ -10,6 +10,7 @@ import { match } from 'ts-pattern';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT, IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
@ -21,7 +22,7 @@ import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
import { useCurrentTeam } from '~/providers/team';
export interface DocumentDropZoneWrapperProps {
children: ReactNode;
@ -34,10 +35,11 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
const { user } = useSession();
const { folderId } = useParams();
const team = useOptionalCurrentTeam();
const team = useCurrentTeam();
const navigate = useNavigate();
const analytics = useAnalytics();
const organisation = useCurrentOrganisation();
const [isLoading, setIsLoading] = useState(false);
@ -53,7 +55,7 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
const onFileDrop = async (file: File) => {
if (isUploadDisabled && IS_BILLING_ENABLED()) {
await navigate('/settings/billing');
await navigate(`/o/${organisation.url}/settings/billing`);
return;
}
@ -83,11 +85,7 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
timestamp: new Date().toISOString(),
});
await navigate(
folderId
? `${formatDocumentsPath(team?.url)}/f/${folderId}/${id}/edit`
: `${formatDocumentsPath(team?.url)}/${id}/edit`,
);
await navigate(`${formatDocumentsPath(team.url)}/${id}/edit`);
} catch (err) {
const error = AppError.parseError(err);
@ -156,7 +154,7 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
{isUploadDisabled && IS_BILLING_ENABLED() && (
<Link
to="/settings/billing"
to={`/o/${organisation.url}/settings/billing`}
className="mt-4 text-sm text-amber-500 hover:underline dark:text-amber-400"
>
<Trans>Upgrade your plan to upload more documents</Trans>

View File

@ -29,13 +29,12 @@ import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { Stepper } from '@documenso/ui/primitives/stepper';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
import { useCurrentTeam } from '~/providers/team';
export type DocumentEditFormProps = {
className?: string;
initialDocument: TDocument;
documentRootPath: string;
isDocumentEnterprise: boolean;
};
type EditDocumentStep = 'settings' | 'signers' | 'fields' | 'subject';
@ -45,7 +44,6 @@ export const DocumentEditForm = ({
className,
initialDocument,
documentRootPath,
isDocumentEnterprise,
}: DocumentEditFormProps) => {
const { toast } = useToast();
const { _ } = useLingui();
@ -53,7 +51,7 @@ export const DocumentEditForm = ({
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const team = useOptionalCurrentTeam();
const team = useCurrentTeam();
const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false);
@ -183,8 +181,8 @@ export const DocumentEditForm = ({
title: data.title,
externalId: data.externalId || null,
visibility: data.visibility,
globalAccessAuth: data.globalAccessAuth ?? null,
globalActionAuth: data.globalActionAuth ?? null,
globalAccessAuth: data.globalAccessAuth ?? [],
globalActionAuth: data.globalActionAuth ?? [],
},
meta: {
timezone,
@ -229,7 +227,7 @@ export const DocumentEditForm = ({
recipients: data.signers.map((signer) => ({
...signer,
// Explicitly set to null to indicate we want to remove auth if required.
actionAuth: signer.actionAuth || null,
actionAuth: signer.actionAuth ?? [],
})),
}),
]);
@ -355,10 +353,9 @@ export const DocumentEditForm = ({
key={recipients.length}
documentFlow={documentFlow.settings}
document={document}
currentTeamMemberRole={team?.currentTeamMember?.role}
currentTeamMemberRole={team.currentTeamRole}
recipients={recipients}
fields={fields}
isDocumentEnterprise={isDocumentEnterprise}
isDocumentPdfLoaded={isDocumentPdfLoaded}
onSubmit={onAddSettingsFormSubmit}
/>
@ -370,7 +367,6 @@ export const DocumentEditForm = ({
signingOrder={document.documentMeta?.signingOrder}
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
fields={fields}
isDocumentEnterprise={isDocumentEnterprise}
onSubmit={onAddSignersFormSubmit}
isDocumentPdfLoaded={isDocumentPdfLoaded}
/>
@ -382,7 +378,7 @@ export const DocumentEditForm = ({
fields={fields}
onSubmit={onAddFieldsFormSubmit}
isDocumentPdfLoaded={isDocumentPdfLoaded}
teamId={team?.id}
teamId={team.id}
/>
<AddSubjectFormPartial

View File

@ -81,11 +81,15 @@ export const DocumentHistorySheet = ({
* @param text The text to format
* @returns The formatted text
*/
const formatGenericText = (text?: string | null) => {
const formatGenericText = (text?: string | string[] | null): string => {
if (!text) {
return '';
}
if (Array.isArray(text)) {
return text.map((t) => formatGenericText(t)).join(', ');
}
return (text.charAt(0).toUpperCase() + text.slice(1).toLowerCase()).replaceAll('_', ' ');
};
@ -245,11 +249,19 @@ export const DocumentHistorySheet = ({
values={[
{
key: 'Old',
value: DOCUMENT_AUTH_TYPES[data.from || '']?.value || 'None',
value: Array.isArray(data.from)
? data.from
.map((f) => DOCUMENT_AUTH_TYPES[f]?.value || 'None')
.join(', ')
: DOCUMENT_AUTH_TYPES[data.from || '']?.value || 'None',
},
{
key: 'New',
value: DOCUMENT_AUTH_TYPES[data.to || '']?.value || 'None',
value: Array.isArray(data.to)
? data.to
.map((f) => DOCUMENT_AUTH_TYPES[f]?.value || 'None')
.join(', ')
: DOCUMENT_AUTH_TYPES[data.to || '']?.value || 'None',
},
]}
/>

View File

@ -19,7 +19,7 @@ export type DocumentPageViewButtonProps = {
document: Document & {
user: Pick<User, 'id' | 'name' | 'email'>;
recipients: Recipient[];
team: Pick<Team, 'id' | 'url'> | null;
team: Pick<Team, 'id' | 'url'>;
};
};
@ -37,10 +37,8 @@ export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const role = recipient?.role;
const documentsPath = formatDocumentsPath(document.team?.url);
const formatPath = document.folderId
? `${documentsPath}/f/${document.folderId}/${document.id}/edit`
: `${documentsPath}/${document.id}/edit`;
const documentsPath = formatDocumentsPath(document.team.url);
const formatPath = `${documentsPath}/${document.id}/edit`;
const onDownloadClick = async () => {
try {

View File

@ -36,7 +36,7 @@ import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialo
import { DocumentDuplicateDialog } from '~/components/dialogs/document-duplicate-dialog';
import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog';
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
import { useOptionalCurrentTeam } from '~/providers/team';
import { useCurrentTeam } from '~/providers/team';
export type DocumentPageViewDropdownProps = {
document: Document & {
@ -52,7 +52,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
const { _ } = useLingui();
const navigate = useNavigate();
const team = useOptionalCurrentTeam();
const team = useCurrentTeam();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
@ -67,7 +67,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
const isCurrentTeamDocument = team && document.team?.url === team.url;
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
const documentsPath = formatDocumentsPath(team?.url);
const documentsPath = formatDocumentsPath(team.url);
const onDownloadClick = async () => {
try {

View File

@ -30,8 +30,12 @@ export const DocumentSearch = ({ initialValue = '' }: { initialValue?: string })
);
useEffect(() => {
handleSearch(searchTerm);
}, [debouncedSearchTerm]);
const currentQueryParam = searchParams.get('query') || '';
if (debouncedSearchTerm !== currentQueryParam) {
handleSearch(debouncedSearchTerm);
}
}, [debouncedSearchTerm, searchParams]);
return (
<Input

View File

@ -3,12 +3,12 @@ import { useMemo, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useNavigate, useParams } from 'react-router';
import { match } from 'ts-pattern';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
@ -26,7 +26,7 @@ import {
} from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
import { useCurrentTeam } from '~/providers/team';
export type DocumentUploadDropzoneProps = {
className?: string;
@ -38,10 +38,11 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
const { user } = useSession();
const { folderId } = useParams();
const team = useOptionalCurrentTeam();
const team = useCurrentTeam();
const navigate = useNavigate();
const analytics = useAnalytics();
const organisation = useCurrentOrganisation();
const userTimezone =
TIME_ZONES.find((timezone) => timezone === Intl.DateTimeFormat().resolvedOptions().timeZone) ??
@ -54,10 +55,12 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
const { mutateAsync: createDocument } = trpc.document.createDocument.useMutation();
const disabledMessage = useMemo(() => {
if (organisation.subscription && remaining.documents === 0) {
return msg`Document upload disabled due to unpaid invoices`;
}
if (remaining.documents === 0) {
return team
? msg`Document upload disabled due to unpaid invoices`
: msg`You have reached your document limit.`;
return msg`You have reached your document limit.`;
}
if (!user.emailVerified) {
@ -81,6 +84,8 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
void refreshLimits();
await navigate(`${formatDocumentsPath(team.url)}/${id}/edit`);
toast({
title: _(msg`Document uploaded`),
description: _(msg`Your document has been uploaded successfully.`),
@ -92,12 +97,6 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
documentId: id,
timestamp: new Date().toISOString(),
});
await navigate(
folderId
? `${formatDocumentsPath(team?.url)}/f/${folderId}/${id}/edit`
: `${formatDocumentsPath(team?.url)}/${id}/edit`,
);
} catch (err) {
const error = AppError.parseError(err);
@ -138,6 +137,7 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
<TooltipTrigger asChild>
<div>
<DocumentDropzone
loading={isLoading}
disabled={remaining.documents === 0 || !user.emailVerified}
disabledMessage={disabledMessage}
onDrop={onFileDrop}
@ -145,6 +145,7 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
/>
</div>
</TooltipTrigger>
{team?.id === undefined &&
remaining.documents > 0 &&
Number.isFinite(remaining.documents) && (
@ -158,12 +159,6 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
)}
</Tooltip>
</TooltipProvider>
{isLoading && (
<div className="bg-background/50 absolute inset-0 flex items-center justify-center rounded-lg">
<Loader className="text-muted-foreground h-12 w-12 animate-spin" />
</div>
)}
</div>
);
};

View File

@ -1,19 +1,32 @@
import { FolderIcon, PinIcon } from 'lucide-react';
import { Plural, Trans } from '@lingui/react/macro';
import { FolderType } from '@prisma/client';
import {
ArrowRightIcon,
FolderIcon,
FolderPlusIcon,
MoreVerticalIcon,
PinIcon,
SettingsIcon,
TrashIcon,
} from 'lucide-react';
import { Link } from 'react-router';
import { FolderType } from '@documenso/lib/types/folder-type';
import { formatFolderCount } from '@documenso/lib/utils/format-folder-count';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { useCurrentTeam } from '~/providers/team';
export type FolderCardProps = {
folder: TFolderWithSubfolders;
onNavigate: (folderId: string) => void;
onMove: (folder: TFolderWithSubfolders) => void;
onPin: (folderId: string) => void;
onUnpin: (folderId: string) => void;
@ -23,66 +36,132 @@ export type FolderCardProps = {
export const FolderCard = ({
folder,
onNavigate,
onMove,
onPin,
onUnpin,
onSettings,
onDelete,
}: FolderCardProps) => {
const team = useCurrentTeam();
const formatPath = () => {
const rootPath =
folder.type === FolderType.DOCUMENT
? formatDocumentsPath(team.url)
: formatTemplatesPath(team.url);
return `${rootPath}/f/${folder.id}`;
};
return (
<div
key={folder.id}
className="border-border hover:border-muted-foreground/40 group relative flex flex-col rounded-lg border p-4 transition-all hover:shadow-sm"
>
<div className="flex items-start justify-between">
<button
className="flex items-center space-x-2 text-left"
onClick={() => onNavigate(folder.id)}
>
<FolderIcon className="text-documenso h-6 w-6" />
<div>
<div className="flex items-center gap-2">
<h3 className="font-medium">{folder.name}</h3>
{folder.pinned && <PinIcon className="text-documenso h-3 w-3" />}
</div>
<div className="mt-1 flex space-x-2 text-xs text-gray-500">
<span>
{formatFolderCount(
folder.type === FolderType.TEMPLATE
? folder._count.templates
: folder._count.documents,
folder.type === FolderType.TEMPLATE ? 'template' : 'document',
folder.type === FolderType.TEMPLATE ? 'templates' : 'documents',
)}
</span>
<span></span>
<span>{formatFolderCount(folder._count.subfolders, 'folder', 'folders')}</span>
<Link to={formatPath()} key={folder.id}>
<Card className="hover:bg-muted/50 border-border h-full border transition-all">
<CardContent className="p-4">
<div className="flex min-w-0 items-center gap-3">
<FolderIcon className="text-documenso h-6 w-6 flex-shrink-0" />
<div className="flex w-full min-w-0 items-center justify-between">
<div className="min-w-0 flex-1">
<h3 className="flex min-w-0 items-center gap-2 font-medium">
<span className="truncate">{folder.name}</span>
{folder.pinned && <PinIcon className="text-documenso h-3 w-3 flex-shrink-0" />}
</h3>
<div className="text-muted-foreground mt-1 flex space-x-2 truncate text-xs">
<span>
{folder.type === FolderType.TEMPLATE ? (
<Plural
value={folder._count.templates}
one={<Trans># template</Trans>}
other={<Trans># templates</Trans>}
/>
) : (
<Plural
value={folder._count.documents}
one={<Trans># document</Trans>}
other={<Trans># documents</Trans>}
/>
)}
</span>
<span></span>
<span>
<Plural
value={folder._count.subfolders}
one={<Trans># folder</Trans>}
other={<Trans># folders</Trans>}
/>
</span>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
data-testid="folder-card-more-button"
>
<MoreVerticalIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent onClick={(e) => e.stopPropagation()} align="end">
<DropdownMenuItem onClick={() => onMove(folder)}>
<ArrowRightIcon className="mr-2 h-4 w-4" />
<Trans>Move</Trans>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => (folder.pinned ? onUnpin(folder.id) : onPin(folder.id))}
>
<PinIcon className="mr-2 h-4 w-4" />
{folder.pinned ? <Trans>Unpin</Trans> : <Trans>Pin</Trans>}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onSettings(folder)}>
<SettingsIcon className="mr-2 h-4 w-4" />
<Trans>Settings</Trans>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onDelete(folder)}>
<TrashIcon className="mr-2 h-4 w-4" />
<Trans>Delete</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="opacity-0 group-hover:opacity-100">
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onMove(folder)}>Move</DropdownMenuItem>
{folder.pinned ? (
<DropdownMenuItem onClick={() => onUnpin(folder.id)}>Unpin</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => onPin(folder.id)}>Pin</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => onSettings(folder)}>Settings</DropdownMenuItem>
<DropdownMenuItem className="text-red-500" onClick={() => onDelete(folder)}>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</CardContent>
</Card>
</Link>
);
};
export const FolderCardEmpty = ({ type }: { type: FolderType }) => {
return (
<Card className="hover:bg-muted/50 border-border h-full border transition-all">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<FolderPlusIcon className="text-muted-foreground/60 h-6 w-6" />
<div>
<h3 className="text-muted-foreground flex items-center gap-2 font-medium">
<Trans>Create folder</Trans>
</h3>
<div className="text-muted-foreground/60 mt-1 flex space-x-2 truncate text-xs">
{type === FolderType.DOCUMENT ? (
<Trans>Organise your documents</Trans>
) : (
<Trans>Organise your templates</Trans>
)}
</div>
</div>
</div>
</CardContent>
</Card>
);
};

View File

@ -0,0 +1,249 @@
import { useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { FolderType } from '@prisma/client';
import { FolderIcon, HomeIcon } from 'lucide-react';
import { Link } from 'react-router';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { FolderCreateDialog } from '~/components/dialogs/folder-create-dialog';
import { FolderDeleteDialog } from '~/components/dialogs/folder-delete-dialog';
import { FolderMoveDialog } from '~/components/dialogs/folder-move-dialog';
import { FolderSettingsDialog } from '~/components/dialogs/folder-settings-dialog';
import { TemplateCreateDialog } from '~/components/dialogs/template-create-dialog';
import { DocumentUploadDropzone } from '~/components/general/document/document-upload';
import { FolderCard, FolderCardEmpty } from '~/components/general/folder/folder-card';
import { useCurrentTeam } from '~/providers/team';
export type FolderGridProps = {
type: FolderType;
parentId: string | null;
};
export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
const team = useCurrentTeam();
const [isMovingFolder, setIsMovingFolder] = useState(false);
const [folderToMove, setFolderToMove] = useState<TFolderWithSubfolders | null>(null);
const [isDeletingFolder, setIsDeletingFolder] = useState(false);
const [folderToDelete, setFolderToDelete] = useState<TFolderWithSubfolders | null>(null);
const [isSettingsFolderOpen, setIsSettingsFolderOpen] = useState(false);
const [folderToSettings, setFolderToSettings] = useState<TFolderWithSubfolders | null>(null);
const { mutateAsync: pinFolder } = trpc.folder.pinFolder.useMutation();
const { mutateAsync: unpinFolder } = trpc.folder.unpinFolder.useMutation();
const { data: foldersData, isPending } = trpc.folder.getFolders.useQuery({
type,
parentId,
});
const formatBreadCrumbPath = (folderId: string) => {
const rootPath =
type === FolderType.DOCUMENT ? formatDocumentsPath(team.url) : formatTemplatesPath(team.url);
return `${rootPath}/f/${folderId}`;
};
const formatViewAllFoldersPath = () => {
const rootPath =
type === FolderType.DOCUMENT ? formatDocumentsPath(team.url) : formatTemplatesPath(team.url);
return `${rootPath}/folders`;
};
const formatRootPath = () => {
return type === FolderType.DOCUMENT
? formatDocumentsPath(team.url)
: formatTemplatesPath(team.url);
};
const pinnedFolders = foldersData?.folders.filter((folder) => folder.pinned) || [];
const unpinnedFolders = foldersData?.folders.filter((folder) => !folder.pinned) || [];
return (
<div>
<div className="mb-4 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div
className="text-muted-foreground hover:text-muted-foreground/80 flex flex-1 items-center text-sm font-medium"
data-testid="folder-grid-breadcrumbs"
>
<Link to={formatRootPath()} className="flex items-center">
<HomeIcon className="mr-2 h-4 w-4" />
<Trans>Home</Trans>
</Link>
{isPending && parentId ? (
<div className="flex items-center">
<Skeleton className="mx-3 h-4 w-1 rotate-12" />
<Skeleton className="h-4 w-20" />
</div>
) : (
foldersData?.breadcrumbs.map((folder) => (
<div key={folder.id} className="flex items-center">
<span className="px-3">/</span>
<Link to={formatBreadCrumbPath(folder.id)} className="flex items-center">
<FolderIcon className="mr-2 h-4 w-4" />
<span>{folder.name}</span>
</Link>
</div>
))
)}
</div>
<div className="flex gap-4 sm:flex-row sm:justify-end">
{type === FolderType.DOCUMENT ? (
<DocumentUploadDropzone />
) : (
<TemplateCreateDialog folderId={parentId ?? undefined} />
)}
<FolderCreateDialog type={type} />
</div>
</div>
{isPending ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="border-border bg-card h-full rounded-lg border px-4 py-5">
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-8 rounded" />
<div className="flex w-full items-center justify-between">
<div className="flex-1">
<Skeleton className="mb-2 h-4 w-24" />
<div className="flex space-x-2">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-3" />
<Skeleton className="h-3 w-12" />
</div>
</div>
<Skeleton className="h-8 w-2 rounded" />
</div>
</div>
</div>
))}
</div>
) : foldersData && foldersData.folders.length === 0 ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
<FolderCreateDialog
type={type}
trigger={
<button>
<FolderCardEmpty type={type} />
</button>
}
/>
</div>
) : (
foldersData && (
<div key="content" className="space-y-4">
{pinnedFolders.length > 0 && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{pinnedFolders.map((folder) => (
<FolderCard
key={folder.id}
folder={folder}
onMove={(folder) => {
setFolderToMove(folder);
setIsMovingFolder(true);
}}
onPin={(folderId) => void pinFolder({ folderId })}
onUnpin={(folderId) => void unpinFolder({ folderId })}
onSettings={(folder) => {
setFolderToSettings(folder);
setIsSettingsFolderOpen(true);
}}
onDelete={(folder) => {
setFolderToDelete(folder);
setIsDeletingFolder(true);
}}
/>
))}
</div>
)}
{unpinnedFolders.length > 0 && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{unpinnedFolders.slice(0, 12).map((folder) => (
<FolderCard
key={folder.id}
folder={folder}
onMove={(folder) => {
setFolderToMove(folder);
setIsMovingFolder(true);
}}
onPin={(folderId) => void pinFolder({ folderId })}
onUnpin={(folderId) => void unpinFolder({ folderId })}
onSettings={(folder) => {
setFolderToSettings(folder);
setIsSettingsFolderOpen(true);
}}
onDelete={(folder) => {
setFolderToDelete(folder);
setIsDeletingFolder(true);
}}
/>
))}
</div>
)}
{foldersData.folders.length > 12 && (
<div className="mt-2 flex items-center justify-center">
<Link
className="text-muted-foreground hover:text-foreground text-sm font-medium"
to={formatViewAllFoldersPath()}
>
View all folders
</Link>
</div>
)}
</div>
)
)}
<FolderMoveDialog
foldersData={foldersData?.folders}
folder={folderToMove}
isOpen={isMovingFolder}
onOpenChange={(open) => {
setIsMovingFolder(open);
if (!open) {
setFolderToMove(null);
}
}}
/>
<FolderSettingsDialog
folder={folderToSettings}
isOpen={isSettingsFolderOpen}
onOpenChange={(open) => {
setIsSettingsFolderOpen(open);
if (!open) {
setFolderToSettings(null);
}
}}
/>
{folderToDelete && (
<FolderDeleteDialog
folder={folderToDelete}
isOpen={isDeletingFolder}
onOpenChange={(open) => {
setIsDeletingFolder(open);
if (!open) {
setFolderToDelete(null);
}
}}
/>
)}
</div>
);
};

View File

@ -7,11 +7,8 @@ import { ChevronLeft } from 'lucide-react';
import { Link, useNavigate } from 'react-router';
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { Button } from '@documenso/ui/primitives/button';
import { useOptionalCurrentTeam } from '~/providers/team';
type ErrorCodeMap = Record<
number,
{ subHeading: MessageDescriptor; heading: MessageDescriptor; message: MessageDescriptor }
@ -22,7 +19,7 @@ export type GenericErrorLayoutProps = {
errorCode?: number;
errorCodeMap?: ErrorCodeMap;
/**
* The primary button to display. If left as undefined, the default /documents link will be displayed.
* The primary button to display. If left as undefined, the default home link will be displayed.
*
* Set to null if you want no button.
*/
@ -59,8 +56,6 @@ export const GenericErrorLayout = ({
const navigate = useNavigate();
const { _ } = useLingui();
const team = useOptionalCurrentTeam();
const { subHeading, heading, message } =
errorCodeMap[errorCode || 500] ?? defaultErrorCodeMap[500];
@ -110,8 +105,8 @@ export const GenericErrorLayout = ({
{primaryButton ||
(primaryButton !== null && (
<Button asChild>
<Link to={formatDocumentsPath(team?.url)}>
<Trans>Documents</Trans>
<Link to={'/'}>
<Trans>Home</Trans>
</Link>
</Button>
))}

View File

@ -3,19 +3,14 @@ import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { motion } from 'framer-motion';
import { CheckCircle2, ChevronsUpDown, Plus, Settings2 } from 'lucide-react';
import { Link, useLocation } from 'react-router';
import { ChevronsUpDown, Plus } from 'lucide-react';
import { Link } from 'react-router';
import { authClient } from '@documenso/auth/client';
import type { SessionUser } from '@documenso/auth/server/lib/session/session';
import { TEAM_MEMBER_ROLE_MAP, TEAM_URL_REGEX } from '@documenso/lib/constants/teams';
import type { TGetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { isAdmin } from '@documenso/lib/utils/is-admin';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { LanguageSwitcherDialog } from '@documenso/ui/components/common/language-switcher-dialog';
import { cn } from '@documenso/ui/lib/utils';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
@ -24,79 +19,27 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
const MotionLink = motion(Link);
export type MenuSwitcherProps = {
user: SessionUser;
teams: TGetTeamsResponse;
};
export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProps) => {
export const MenuSwitcher = () => {
const { _ } = useLingui();
const { pathname } = useLocation();
const { user } = useSession();
const [languageSwitcherOpen, setLanguageSwitcherOpen] = useState(false);
const isUserAdmin = isAdmin(user);
const { data: teamsQueryResult } = trpc.team.getTeams.useQuery(undefined, {
initialData: initialTeamsData,
});
const teams = teamsQueryResult && teamsQueryResult.length > 0 ? teamsQueryResult : null;
const isPathTeamUrl = (teamUrl: string) => {
if (!pathname || !pathname.startsWith(`/t/`)) {
return false;
}
return pathname.split('/')[2] === teamUrl;
};
const selectedTeam = teams?.find((team) => isPathTeamUrl(team.url));
const formatAvatarFallback = (teamName?: string) => {
if (teamName !== undefined) {
return teamName.slice(0, 1).toUpperCase();
const formatAvatarFallback = (name?: string) => {
if (name !== undefined) {
return name.slice(0, 1).toUpperCase();
}
return user.name ? extractInitials(user.name) : user.email.slice(0, 1).toUpperCase();
};
const formatSecondaryAvatarText = (team?: typeof selectedTeam) => {
if (!team) {
return _(msg`Personal Account`);
}
if (team.ownerUserId === user.id) {
return _(msg`Owner`);
}
return _(TEAM_MEMBER_ROLE_MAP[team.currentTeamMember.role]);
};
/**
* Formats the redirect URL so we can switch between documents and templates page
* seemlessly between teams and personal accounts.
*/
const formatRedirectUrlOnSwitch = (teamUrl?: string) => {
const baseUrl = teamUrl ? `/t/${teamUrl}` : '';
const currentPathname = (pathname ?? '/').replace(TEAM_URL_REGEX, '');
if (currentPathname === '/templates') {
return `${baseUrl}/templates`;
}
return baseUrl;
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@ -106,12 +49,10 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
className="relative flex h-12 flex-row items-center px-0 py-2 ring-0 focus:outline-none focus-visible:border-0 focus-visible:ring-0 focus-visible:ring-transparent md:px-2"
>
<AvatarWithText
avatarSrc={formatAvatarUrl(
selectedTeam ? selectedTeam.avatarImageId : user.avatarImageId,
)}
avatarFallback={formatAvatarFallback(selectedTeam?.name)}
primaryText={selectedTeam ? selectedTeam.name : user.name}
secondaryText={formatSecondaryAvatarText(selectedTeam)}
avatarSrc={formatAvatarUrl(user.avatarImageId)}
avatarFallback={formatAvatarFallback(user.name || user.email)}
primaryText={user.name}
secondaryText={_(msg`Personal Account`)}
rightSideComponent={
<ChevronsUpDown className="text-muted-foreground ml-auto h-4 w-4" />
}
@ -121,128 +62,19 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
</DropdownMenuTrigger>
<DropdownMenuContent
className={cn('z-[60] ml-6 w-full md:ml-0', teams ? 'min-w-[20rem]' : 'min-w-[12rem]')}
className={cn('z-[60] ml-6 w-full min-w-[12rem] md:ml-0')}
align="end"
forceMount
>
{teams ? (
<>
<DropdownMenuLabel>
<Trans>Personal</Trans>
</DropdownMenuLabel>
<DropdownMenuItem asChild>
<Link to={formatRedirectUrlOnSwitch()}>
<AvatarWithText
avatarSrc={formatAvatarUrl(user.avatarImageId)}
avatarFallback={formatAvatarFallback()}
primaryText={user.name}
secondaryText={formatSecondaryAvatarText()}
rightSideComponent={
!pathname?.startsWith(`/t/`) && (
<CheckCircle2 className="ml-auto fill-black text-white dark:fill-white dark:text-black" />
)
}
/>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator className="mt-2" />
<DropdownMenuLabel>
<div className="flex flex-row items-center justify-between">
<p>
<Trans>Teams</Trans>
</p>
<div className="flex flex-row space-x-2">
<DropdownMenuItem asChild>
<Button
title={_(msg`Manage teams`)}
variant="ghost"
className="text-muted-foreground flex h-5 w-5 items-center justify-center p-0"
asChild
>
<Link to="/settings/teams">
<Settings2 className="h-4 w-4" />
</Link>
</Button>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Button
title={_(msg`Create team`)}
variant="ghost"
className="text-muted-foreground flex h-5 w-5 items-center justify-center p-0"
asChild
>
<Link to="/settings/teams?action=add-team">
<Plus className="h-4 w-4" />
</Link>
</Button>
</DropdownMenuItem>
</div>
</div>
</DropdownMenuLabel>
<div className="custom-scrollbar max-h-[40vh] overflow-auto">
{teams.map((team) => (
<DropdownMenuItem asChild key={team.id}>
<MotionLink
initial="initial"
animate="initial"
whileHover="animate"
to={formatRedirectUrlOnSwitch(team.url)}
>
<AvatarWithText
avatarSrc={formatAvatarUrl(team.avatarImageId)}
avatarFallback={formatAvatarFallback(team.name)}
primaryText={team.name}
textSectionClassName="w-[200px]"
secondaryText={
<div className="relative w-full">
<motion.span
className="overflow-hidden"
variants={{
initial: { opacity: 1, translateY: 0 },
animate: { opacity: 0, translateY: '100%' },
}}
>
{formatSecondaryAvatarText(team)}
</motion.span>
<motion.span
className="absolute inset-0"
variants={{
initial: { opacity: 0, translateY: '100%' },
animate: { opacity: 1, translateY: 0 },
}}
>{`/t/${team.url}`}</motion.span>
</div>
}
rightSideComponent={
isPathTeamUrl(team.url) && (
<CheckCircle2 className="ml-auto fill-black text-white dark:fill-white dark:text-black" />
)
}
/>
</MotionLink>
</DropdownMenuItem>
))}
</div>
</>
) : (
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<Link
to="/settings/teams?action=add-team"
className="flex items-center justify-between"
>
<Trans>Create team</Trans>
<Plus className="ml-2 h-4 w-4" />
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<Link
to="/settings/organisations?action=add-organisation"
className="flex items-center justify-between"
>
<Trans>Create Organisation</Trans>
<Plus className="ml-2 h-4 w-4" />
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
{isUserAdmin && (
@ -253,21 +85,18 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
</DropdownMenuItem>
)}
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<Link to="/inbox">
<Trans>Personal Inbox</Trans>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<Link to="/settings/profile">
<Trans>User settings</Trans>
</Link>
</DropdownMenuItem>
{selectedTeam &&
canExecuteTeamAction('MANAGE_TEAM', selectedTeam.currentTeamMember.role) && (
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<Link to={`/t/${selectedTeam.url}/settings`}>
<Trans>Team settings</Trans>
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-muted-foreground px-4 py-2"
onClick={() => setLanguageSwitcherOpen(true)}

View File

@ -0,0 +1,338 @@
import { useMemo, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import {
Building2Icon,
ChevronsUpDown,
Plus,
Settings2Icon,
SettingsIcon,
UsersIcon,
} from 'lucide-react';
import { Link, useLocation } from 'react-router';
import { authClient } from '@documenso/auth/client';
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { EXTENDED_ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import { EXTENDED_TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { isAdmin } from '@documenso/lib/utils/is-admin';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { LanguageSwitcherDialog } from '@documenso/ui/components/common/language-switcher-dialog';
import { cn } from '@documenso/ui/lib/utils';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { useOptionalCurrentTeam } from '~/providers/team';
export const OrgMenuSwitcher = () => {
const { _ } = useLingui();
const { user, organisations } = useSession();
const { pathname } = useLocation();
const [isOpen, setIsOpen] = useState(false);
const [languageSwitcherOpen, setLanguageSwitcherOpen] = useState(false);
const [hoveredOrgId, setHoveredOrgId] = useState<string | null>(null);
const isUserAdmin = isAdmin(user);
const isPathOrgUrl = (orgUrl: string) => {
if (!pathname || !pathname.startsWith(`/o/`)) {
return false;
}
return pathname.split('/')[2] === orgUrl;
};
const selectedOrg = organisations.find((org) => isPathOrgUrl(org.url));
const hoveredOrg = organisations.find(
(org) => org.id === hoveredOrgId || organisations.length === 1,
);
const currentOrganisation = useOptionalCurrentOrganisation();
const currentTeam = useOptionalCurrentTeam();
// Use hovered org for teams display if available,
// otherwise use current team's org if in a team,
// finally fallback to selected org
const displayedOrg = hoveredOrg || currentOrganisation || selectedOrg;
const formatAvatarFallback = (name?: string) => {
if (name !== undefined) {
return name.slice(0, 1).toUpperCase();
}
return user.name ? extractInitials(user.name) : user.email.slice(0, 1).toUpperCase();
};
const dropdownMenuAvatarText = useMemo(() => {
if (currentTeam) {
return {
avatarSrc: formatAvatarUrl(currentTeam.avatarImageId),
avatarFallback: formatAvatarFallback(currentTeam.name),
primaryText: currentTeam.name,
secondaryText: _(EXTENDED_TEAM_MEMBER_ROLE_MAP[currentTeam.currentTeamRole]),
};
}
if (currentOrganisation) {
return {
avatarSrc: formatAvatarUrl(currentOrganisation.avatarImageId),
avatarFallback: formatAvatarFallback(currentOrganisation.name),
primaryText: currentOrganisation.name,
secondaryText: _(
EXTENDED_ORGANISATION_MEMBER_ROLE_MAP[currentOrganisation.currentOrganisationRole],
),
};
}
return {
avatarSrc: formatAvatarUrl(user.avatarImageId),
avatarFallback: formatAvatarFallback(user.name ?? user.email),
primaryText: user.name,
secondaryText: _(msg`Personal Account`),
};
}, [currentTeam, currentOrganisation, user]);
const handleOpenChange = (open: boolean) => {
if (open) {
setHoveredOrgId(currentOrganisation?.id || null);
}
setIsOpen(open);
};
return (
<DropdownMenu open={isOpen} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>
<Button
data-testid="menu-switcher"
variant="none"
className="relative flex h-12 flex-row items-center px-0 py-2 ring-0 focus:outline-none focus-visible:border-0 focus-visible:ring-0 focus-visible:ring-transparent md:px-2"
>
<AvatarWithText
avatarSrc={dropdownMenuAvatarText.avatarSrc}
avatarFallback={dropdownMenuAvatarText.avatarFallback}
primaryText={dropdownMenuAvatarText.primaryText}
secondaryText={dropdownMenuAvatarText.secondaryText}
rightSideComponent={
<ChevronsUpDown className="text-muted-foreground ml-auto h-4 w-4" />
}
textSectionClassName="hidden lg:flex"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className={cn(
'divide-border z-[60] ml-6 flex w-full divide-x p-0 md:ml-0 md:min-w-[40rem]',
)}
align="end"
forceMount
>
<div className="flex h-[400px] w-full divide-x">
{/* Organisations column */}
<div className="flex w-full flex-col md:w-1/3">
<div className="flex h-12 items-center border-b p-2">
<h3 className="text-muted-foreground flex items-center px-2 text-sm font-medium">
<Building2Icon className="mr-2 h-3.5 w-3.5" />
<Trans>Organisations</Trans>
</h3>
</div>
<div className="flex-1 space-y-1 overflow-y-auto p-1.5">
{organisations.map((org) => (
<div
className="group relative"
key={org.id}
onMouseEnter={() => setHoveredOrgId(org.id)}
>
<DropdownMenuItem
className={cn(
'text-muted-foreground w-full px-4 py-2',
org.id === currentOrganisation?.id && !hoveredOrgId && 'bg-accent',
org.id === hoveredOrgId && 'bg-accent',
)}
asChild
>
<Link to={`/o/${org.url}`} className="flex items-center space-x-2 pr-8">
<span
className={cn('min-w-0 flex-1 truncate', {
'font-semibold': org.id === selectedOrg?.id,
})}
>
{org.name}
</span>
</Link>
</DropdownMenuItem>
{canExecuteOrganisationAction(
'MANAGE_ORGANISATION',
org.currentOrganisationRole,
) && (
<div className="absolute bottom-0 right-0 top-0 flex items-center justify-center">
<Link
to={`/o/${org.url}/settings`}
className="text-muted-foreground mr-2 rounded-sm border p-1 transition-opacity duration-200 group-hover:opacity-100 md:opacity-0"
>
<Settings2Icon className="h-3.5 w-3.5" />
</Link>
</div>
)}
</div>
))}
<Button variant="ghost" className="w-full justify-start" asChild>
<Link to="/settings/organisations?action=add-organisation">
<Plus className="mr-2 h-4 w-4" />
<Trans>Create Organisation</Trans>
</Link>
</Button>
</div>
</div>
{/* Teams column */}
<div className="hidden w-1/3 flex-col md:flex">
<div className="flex h-12 items-center border-b p-2">
<h3 className="text-muted-foreground flex items-center px-2 text-sm font-medium">
<UsersIcon className="mr-2 h-3.5 w-3.5" />
<Trans>Teams</Trans>
</h3>
</div>
<div className="flex-1 space-y-1 overflow-y-auto p-1.5">
<AnimateGenericFadeInOut key={displayedOrg ? 'displayed-org' : 'no-org'}>
{hoveredOrg ? (
hoveredOrg.teams.map((team) => (
<div className="group relative" key={team.id}>
<DropdownMenuItem
className={cn(
'text-muted-foreground w-full px-4 py-2',
team.id === currentTeam?.id && 'bg-accent',
)}
asChild
>
<Link to={`/t/${team.url}`} className="flex items-center space-x-2 pr-8">
<span
className={cn('min-w-0 flex-1 truncate', {
'font-semibold': team.id === currentTeam?.id,
})}
>
{team.name}
</span>
</Link>
</DropdownMenuItem>
{canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole) && (
<div className="absolute bottom-0 right-0 top-0 flex items-center justify-center">
<Link
to={`/t/${team.url}/settings`}
className="text-muted-foreground mr-2 rounded-sm border p-1 opacity-0 transition-opacity duration-200 group-hover:opacity-100"
>
<Settings2Icon className="h-3.5 w-3.5" />
</Link>
</div>
)}
</div>
))
) : (
<div className="text-muted-foreground my-12 flex items-center justify-center px-2 text-center text-sm">
<Trans>Select an organisation to view teams</Trans>
</div>
)}
{displayedOrg && (
<Button variant="ghost" className="w-full justify-start" asChild>
<Link to={`/o/${displayedOrg.url}/settings/teams?action=add-team`}>
<Plus className="mr-2 h-4 w-4" />
<Trans>Create Team</Trans>
</Link>
</Button>
)}
</AnimateGenericFadeInOut>
</div>
</div>
{/* Settings column */}
<div className="hidden w-1/3 flex-col md:flex">
<div className="flex h-12 items-center border-b p-2">
<h3 className="text-muted-foreground flex items-center px-2 text-sm font-medium">
<SettingsIcon className="mr-2 h-3.5 w-3.5" />
<Trans>Settings</Trans>
</h3>
</div>
<div className="flex-1 overflow-y-auto p-1.5">
{isUserAdmin && (
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<Link to="/admin">
<Trans>Admin panel</Trans>
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<Link to="/inbox">
<Trans>Personal Inbox</Trans>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<Link to="/settings/profile">
<Trans>Account</Trans>
</Link>
</DropdownMenuItem>
{currentOrganisation &&
canExecuteOrganisationAction(
'MANAGE_ORGANISATION',
currentOrganisation.currentOrganisationRole,
) && (
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<Link to={`/o/${currentOrganisation.url}/settings`}>
<Trans>Organisation settings</Trans>
</Link>
</DropdownMenuItem>
)}
{currentTeam && canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole) && (
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<Link to={`/t/${currentTeam.url}/settings`}>
<Trans>Team settings</Trans>
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-muted-foreground px-4 py-2"
onClick={() => setLanguageSwitcherOpen(true)}
>
<Trans>Language</Trans>
</DropdownMenuItem>
<DropdownMenuItem
className="text-muted-foreground hover:!text-muted-foreground px-4 py-2"
onSelect={async () => authClient.signOut()}
>
<Trans>Sign Out</Trans>
</DropdownMenuItem>
</div>
</div>
</div>
</DropdownMenuContent>
<LanguageSwitcherDialog open={languageSwitcherOpen} setOpen={setLanguageSwitcherOpen} />
</DropdownMenu>
);
};

View File

@ -3,12 +3,12 @@ import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { TeamMemberRole } from '@prisma/client';
import { SubscriptionStatus } from '@prisma/client';
import { AlertTriangle } from 'lucide-react';
import { match } from 'ts-pattern';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@ -21,30 +21,22 @@ import {
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type TeamLayoutBillingBannerProps = {
subscriptionStatus: SubscriptionStatus;
teamId: number;
userRole: TeamMemberRole;
};
export const TeamLayoutBillingBanner = ({
subscriptionStatus,
teamId,
userRole,
}: TeamLayoutBillingBannerProps) => {
export const OrganisationBillingBanner = () => {
const { _ } = useLingui();
const { toast } = useToast();
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync: createBillingPortal, isPending } =
trpc.team.createBillingPortal.useMutation();
const organisation = useOptionalCurrentOrganisation();
const handleCreatePortal = async () => {
const { mutateAsync: manageSubscription, isPending } =
trpc.billing.subscription.manage.useMutation();
const handleCreatePortal = async (organisationId: string) => {
try {
const sessionUrl = await createBillingPortal({ teamId });
const { redirectUrl } = await manageSubscription({ organisationId });
window.open(sessionUrl, '_blank');
window.open(redirectUrl, '_blank');
setIsOpen(false);
} catch (err) {
@ -59,7 +51,13 @@ export const TeamLayoutBillingBanner = ({
}
};
if (subscriptionStatus === SubscriptionStatus.ACTIVE) {
const subscriptionStatus = organisation?.subscription?.status;
if (
!organisation ||
subscriptionStatus === undefined ||
subscriptionStatus === SubscriptionStatus.ACTIVE
) {
return null;
}
@ -79,14 +77,14 @@ export const TeamLayoutBillingBanner = ({
{match(subscriptionStatus)
.with(SubscriptionStatus.PAST_DUE, () => <Trans>Payment overdue</Trans>)
.with(SubscriptionStatus.INACTIVE, () => <Trans>Teams restricted</Trans>)
.with(SubscriptionStatus.INACTIVE, () => <Trans>Restricted Access</Trans>)
.exhaustive()}
</div>
<Button
variant="ghost"
variant="outline"
className={cn({
'text-yellow-900 hover:bg-yellow-100 hover:text-yellow-900 dark:hover:bg-yellow-500':
'text-yellow-900 hover:bg-yellow-100 dark:hover:bg-yellow-500':
subscriptionStatus === SubscriptionStatus.PAST_DUE,
'text-destructive-foreground hover:bg-destructive-foreground hover:text-white':
subscriptionStatus === SubscriptionStatus.INACTIVE,
@ -125,9 +123,9 @@ export const TeamLayoutBillingBanner = ({
))
.otherwise(() => null)}
{canExecuteTeamAction('MANAGE_BILLING', userRole) && (
{canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole) && (
<DialogFooter>
<Button loading={isPending} onClick={handleCreatePortal}>
<Button loading={isPending} onClick={async () => handleCreatePortal(organisation.id)}>
<Trans>Resolve payment</Trans>
</Button>
</DialogFooter>

View File

@ -0,0 +1,58 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type OrganisationBillingPortalButtonProps = {
buttonProps?: React.ComponentProps<typeof Button>;
};
export const OrganisationBillingPortalButton = ({
buttonProps,
}: OrganisationBillingPortalButtonProps) => {
const organisation = useCurrentOrganisation();
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: manageSubscription, isPending } =
trpc.billing.subscription.manage.useMutation();
const canManageBilling = canExecuteOrganisationAction(
'MANAGE_BILLING',
organisation.currentOrganisationRole,
);
const handleCreatePortal = async () => {
try {
const { redirectUrl } = await manageSubscription({ organisationId: organisation.id });
window.open(redirectUrl, '_blank');
} catch (err) {
toast({
title: _(msg`Something went wrong`),
description: _(
msg`We are unable to proceed to the billing portal at this time. Please try again, or contact support.`,
),
variant: 'destructive',
duration: 10000,
});
}
};
return (
<Button
{...buttonProps}
onClick={async () => handleCreatePortal()}
loading={isPending}
disabled={!canManageBilling}
>
<Trans>Manage billing</Trans>
</Button>
);
};

View File

@ -1,11 +1,12 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import { OrganisationMemberInviteStatus } from '@prisma/client';
import { AnimatePresence } from 'framer-motion';
import { BellIcon } from 'lucide-react';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { formatTeamUrl } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
@ -21,14 +22,16 @@ import {
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
export const TeamInvitations = () => {
const { data, isLoading } = trpc.team.getTeamInvitations.useQuery();
export const OrganisationInvitations = ({ className }: { className?: string }) => {
const { data, isLoading } = trpc.organisation.member.invite.getMany.useQuery({
status: OrganisationMemberInviteStatus.PENDING,
});
return (
<AnimatePresence>
{data && data.length > 0 && !isLoading && (
<AnimateGenericFadeInOut>
<Alert variant="secondary">
<Alert variant="secondary" className={className}>
<div className="flex h-full flex-row items-center p-2">
<BellIcon className="mr-4 h-5 w-5 text-blue-800" />
@ -37,12 +40,12 @@ export const TeamInvitations = () => {
value={data.length}
one={
<span>
You have <strong>1</strong> pending team invitation
You have <strong>1</strong> pending invitation
</span>
}
other={
<span>
You have <strong>#</strong> pending team invitations
You have <strong>#</strong> pending invitations
</span>
}
/>
@ -66,12 +69,12 @@ export const TeamInvitations = () => {
value={data.length}
one={
<span>
You have <strong>1</strong> pending team invitation
You have <strong>1</strong> pending invitation
</span>
}
other={
<span>
You have <strong>#</strong> pending team invitations
You have <strong>#</strong> pending invitations
</span>
}
/>
@ -80,24 +83,26 @@ export const TeamInvitations = () => {
<ul className="-mx-6 -mb-6 max-h-[80vh] divide-y overflow-auto px-6 pb-6 xl:max-h-[70vh]">
{data.map((invitation) => (
<li key={invitation.teamId}>
<AvatarWithText
avatarSrc={formatAvatarUrl(invitation.team.avatarImageId)}
className="w-full max-w-none py-4"
avatarFallback={invitation.team.name.slice(0, 1)}
primaryText={
<span className="text-foreground/80 font-semibold">
{invitation.team.name}
</span>
}
secondaryText={formatTeamUrl(invitation.team.url)}
rightSideComponent={
<div className="ml-auto space-x-2">
<DeclineTeamInvitationButton teamId={invitation.team.id} />
<AcceptTeamInvitationButton teamId={invitation.team.id} />
</div>
}
/>
<li key={invitation.id}>
<Alert variant="neutral" className="p-0 px-4">
<AvatarWithText
avatarSrc={formatAvatarUrl(invitation.organisation.avatarImageId)}
className="w-full max-w-none py-4"
avatarFallback={invitation.organisation.name.slice(0, 1)}
primaryText={
<span className="text-foreground/80 font-semibold">
{invitation.organisation.name}
</span>
}
secondaryText={`/o/${invitation.organisation.url}`}
rightSideComponent={
<div className="ml-auto space-x-2">
<DeclineOrganisationInvitationButton token={invitation.token} />
<AcceptOrganisationInvitationButton token={invitation.token} />
</div>
}
/>
</Alert>
</li>
))}
</ul>
@ -111,26 +116,29 @@ export const TeamInvitations = () => {
);
};
const AcceptTeamInvitationButton = ({ teamId }: { teamId: number }) => {
const AcceptOrganisationInvitationButton = ({ token }: { token: string }) => {
const { _ } = useLingui();
const { toast } = useToast();
const { refreshSession } = useSession();
const {
mutateAsync: acceptTeamInvitation,
mutateAsync: acceptOrganisationInvitation,
isPending,
isSuccess,
} = trpc.team.acceptTeamInvitation.useMutation({
onSuccess: () => {
} = trpc.organisation.member.invite.accept.useMutation({
onSuccess: async () => {
await refreshSession();
toast({
title: _(msg`Success`),
description: _(msg`Accepted team invitation`),
description: _(msg`Invitation accepted`),
duration: 5000,
});
},
onError: () => {
toast({
title: _(msg`Something went wrong`),
description: _(msg`Unable to join this team at this time.`),
description: _(msg`Unable to join this organisation at this time.`),
variant: 'destructive',
duration: 10000,
});
@ -139,7 +147,7 @@ const AcceptTeamInvitationButton = ({ teamId }: { teamId: number }) => {
return (
<Button
onClick={async () => acceptTeamInvitation({ teamId })}
onClick={async () => acceptOrganisationInvitation({ token })}
loading={isPending}
disabled={isPending || isSuccess}
>
@ -148,26 +156,29 @@ const AcceptTeamInvitationButton = ({ teamId }: { teamId: number }) => {
);
};
const DeclineTeamInvitationButton = ({ teamId }: { teamId: number }) => {
const DeclineOrganisationInvitationButton = ({ token }: { token: string }) => {
const { _ } = useLingui();
const { toast } = useToast();
const { refreshSession } = useSession();
const {
mutateAsync: declineTeamInvitation,
mutateAsync: declineOrganisationInvitation,
isPending,
isSuccess,
} = trpc.team.declineTeamInvitation.useMutation({
onSuccess: () => {
} = trpc.organisation.member.invite.decline.useMutation({
onSuccess: async () => {
await refreshSession();
toast({
title: _(msg`Success`),
description: _(msg`Declined team invitation`),
description: _(msg`Invitation declined`),
duration: 5000,
});
},
onError: () => {
toast({
title: _(msg`Something went wrong`),
description: _(msg`Unable to decline this team invitation at this time.`),
description: _(msg`Unable to decline this invitation at this time.`),
variant: 'destructive',
duration: 10000,
});
@ -176,7 +187,7 @@ const DeclineTeamInvitationButton = ({ teamId }: { teamId: number }) => {
return (
<Button
onClick={async () => declineTeamInvitation({ teamId })}
onClick={async () => declineOrganisationInvitation({ token })}
loading={isPending}
disabled={isPending || isSuccess}
variant="ghost"

View File

@ -1,11 +1,22 @@
import type { HTMLAttributes } from 'react';
import { Trans } from '@lingui/react/macro';
import { Braces, CreditCard, Globe2Icon, Lock, User, Users, Webhook } from 'lucide-react';
import {
BracesIcon,
CreditCardIcon,
Globe2Icon,
Lock,
Settings2Icon,
User,
Users,
WebhookIcon,
} from 'lucide-react';
import { useLocation } from 'react-router';
import { Link } from 'react-router';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@ -14,7 +25,9 @@ export type SettingsDesktopNavProps = HTMLAttributes<HTMLDivElement>;
export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavProps) => {
const { pathname } = useLocation();
const isBillingEnabled = IS_BILLING_ENABLED();
const { organisations } = useSession();
const isPersonalLayoutMode = isPersonalLayout(organisations);
return (
<div className={cn('flex flex-col gap-y-2', className)} {...props}>
@ -31,29 +44,87 @@ export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavPr
</Button>
</Link>
<Link to="/settings/public-profile">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/public-profile') && 'bg-secondary',
)}
>
<Globe2Icon className="mr-2 h-5 w-5" />
<Trans>Public Profile</Trans>
</Button>
</Link>
{isPersonalLayoutMode && (
<>
<Link to="/settings/preferences">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/preferences') && 'bg-secondary',
)}
>
<Settings2Icon className="mr-2 h-5 w-5" />
<Trans>Preferences</Trans>
</Button>
</Link>
<Link to="/settings/teams">
<Link to="/settings/public-profile">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/public-profile') && 'bg-secondary',
)}
>
<Globe2Icon className="mr-2 h-5 w-5" />
<Trans>Public Profile</Trans>
</Button>
</Link>
<Link to="/settings/tokens">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/tokens') && 'bg-secondary',
)}
>
<BracesIcon className="mr-2 h-5 w-5" />
<Trans>API Tokens</Trans>
</Button>
</Link>
<Link to="/settings/webhooks">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/webhooks') && 'bg-secondary',
)}
>
<WebhookIcon className="mr-2 h-5 w-5" />
<Trans>Webhooks</Trans>
</Button>
</Link>
{IS_BILLING_ENABLED() && (
<Link to="/settings/billing">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/billing') && 'bg-secondary',
)}
>
<CreditCardIcon className="mr-2 h-5 w-5" />
<Trans>Billing</Trans>
</Button>
</Link>
)}
</>
)}
<Link to="/settings/organisations">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/teams') && 'bg-secondary',
pathname?.startsWith('/settings/organisations') && 'bg-secondary',
)}
>
<Users className="mr-2 h-5 w-5" />
<Trans>Teams</Trans>
<Trans>Organisations</Trans>
</Button>
</Link>
@ -69,47 +140,6 @@ export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavPr
<Trans>Security</Trans>
</Button>
</Link>
<Link to="/settings/tokens">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/tokens') && 'bg-secondary',
)}
>
<Braces className="mr-2 h-5 w-5" />
<Trans>API Tokens</Trans>
</Button>
</Link>
<Link to="/settings/webhooks">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/webhooks') && 'bg-secondary',
)}
>
<Webhook className="mr-2 h-5 w-5" />
<Trans>Webhooks</Trans>
</Button>
</Link>
{isBillingEnabled && (
<Link to="/settings/billing">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/billing') && 'bg-secondary',
)}
>
<CreditCard className="mr-2 h-5 w-5" />
<Trans>Billing</Trans>
</Button>
</Link>
)}
</div>
);
};

View File

@ -1,10 +1,21 @@
import type { HTMLAttributes } from 'react';
import { Trans } from '@lingui/react/macro';
import { Braces, CreditCard, Globe2Icon, Lock, User, Users, Webhook } from 'lucide-react';
import {
BracesIcon,
CreditCardIcon,
Globe2Icon,
Lock,
Settings2Icon,
User,
Users,
WebhookIcon,
} from 'lucide-react';
import { Link, useLocation } from 'react-router';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@ -13,7 +24,9 @@ export type SettingsMobileNavProps = HTMLAttributes<HTMLDivElement>;
export const SettingsMobileNav = ({ className, ...props }: SettingsMobileNavProps) => {
const { pathname } = useLocation();
const isBillingEnabled = IS_BILLING_ENABLED();
const { organisations } = useSession();
const isPersonalLayoutMode = isPersonalLayout(organisations);
return (
<div
@ -33,29 +46,87 @@ export const SettingsMobileNav = ({ className, ...props }: SettingsMobileNavProp
</Button>
</Link>
<Link to="/settings/public-profile">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/public-profile') && 'bg-secondary',
)}
>
<Globe2Icon className="mr-2 h-5 w-5" />
<Trans>Public Profile</Trans>
</Button>
</Link>
{isPersonalLayoutMode && (
<>
<Link to="/settings/preferences">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/preferences') && 'bg-secondary',
)}
>
<Settings2Icon className="mr-2 h-5 w-5" />
<Trans>Preferences</Trans>
</Button>
</Link>
<Link to="/settings/teams">
<Link to="/settings/public-profile">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/public-profile') && 'bg-secondary',
)}
>
<Globe2Icon className="mr-2 h-5 w-5" />
<Trans>Public Profile</Trans>
</Button>
</Link>
<Link to="/settings/tokens">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/tokens') && 'bg-secondary',
)}
>
<BracesIcon className="mr-2 h-5 w-5" />
<Trans>API Tokens</Trans>
</Button>
</Link>
<Link to="/settings/webhooks">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/webhooks') && 'bg-secondary',
)}
>
<WebhookIcon className="mr-2 h-5 w-5" />
<Trans>Webhooks</Trans>
</Button>
</Link>
{IS_BILLING_ENABLED() && (
<Link to="/settings/billing">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/billing') && 'bg-secondary',
)}
>
<CreditCardIcon className="mr-2 h-5 w-5" />
<Trans>Billing</Trans>
</Button>
</Link>
)}
</>
)}
<Link to="/settings/organisations">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/teams') && 'bg-secondary',
pathname?.startsWith('/settings/organisations') && 'bg-secondary',
)}
>
<Users className="mr-2 h-5 w-5" />
<Trans>Teams</Trans>
<Trans>Organisations</Trans>
</Button>
</Link>
@ -71,47 +142,6 @@ export const SettingsMobileNav = ({ className, ...props }: SettingsMobileNavProp
<Trans>Security</Trans>
</Button>
</Link>
<Link to="/settings/tokens">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/tokens') && 'bg-secondary',
)}
>
<Braces className="mr-2 h-5 w-5" />
<Trans>API Tokens</Trans>
</Button>
</Link>
<Link to="/settings/webhooks">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/webhooks') && 'bg-secondary',
)}
>
<Webhook className="mr-2 h-5 w-5" />
<Trans>Webhooks</Trans>
</Button>
</Link>
{isBillingEnabled && (
<Link to="/settings/billing">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/billing') && 'bg-secondary',
)}
>
<CreditCard className="mr-2 h-5 w-5" />
<Trans>Billing</Trans>
</Button>
</Link>
)}
</div>
);
};

View File

@ -7,7 +7,7 @@ import { Skeleton } from '@documenso/ui/primitives/skeleton';
export default function DocumentEditSkeleton() {
return (
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
<Link to="/documents" className="flex grow-0 items-center text-[#7AC455] hover:opacity-80">
<Link to="/" className="flex grow-0 items-center text-[#7AC455] hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
<Trans>Documents</Trans>
</Link>

View File

@ -1,43 +0,0 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type TeamBillingPortalButtonProps = {
buttonProps?: React.ComponentProps<typeof Button>;
teamId: number;
};
export const TeamBillingPortalButton = ({ buttonProps, teamId }: TeamBillingPortalButtonProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: createBillingPortal, isPending } =
trpc.team.createBillingPortal.useMutation();
const handleCreatePortal = async () => {
try {
const sessionUrl = await createBillingPortal({ teamId });
window.open(sessionUrl, '_blank');
} catch (err) {
toast({
title: _(msg`Something went wrong`),
description: _(
msg`We are unable to proceed to the billing portal at this time. Please try again, or contact support.`,
),
variant: 'destructive',
duration: 10000,
});
}
};
return (
<Button {...buttonProps} onClick={async () => handleCreatePortal()} loading={isPending}>
<Trans>Manage subscription</Trans>
</Button>
);
};

View File

@ -3,7 +3,7 @@ import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Edit, Loader, Mail, MoreHorizontal, X } from 'lucide-react';
import type { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import type { getTeamWithEmail } from '@documenso/lib/server-only/team/get-team-email-by-email';
import { trpc } from '@documenso/trpc/react';
import {
DropdownMenu,
@ -17,7 +17,7 @@ import { TeamEmailDeleteDialog } from '~/components/dialogs/team-email-delete-di
import { TeamEmailUpdateDialog } from '~/components/dialogs/team-email-update-dialog';
export type TeamEmailDropdownProps = {
team: Awaited<ReturnType<typeof getTeamByUrl>>;
team: Awaited<ReturnType<typeof getTeamWithEmail>>;
};
export const TeamEmailDropdown = ({ team }: TeamEmailDropdownProps) => {
@ -25,7 +25,7 @@ export const TeamEmailDropdown = ({ team }: TeamEmailDropdownProps) => {
const { toast } = useToast();
const { mutateAsync: resendEmailVerification, isPending: isResendingEmailVerification } =
trpc.team.resendTeamEmailVerification.useMutation({
trpc.team.email.verification.resend.useMutation({
onSuccess: () => {
toast({
title: _(msg`Success`),

View File

@ -30,7 +30,7 @@ export const TeamEmailUsage = ({ teamEmail }: TeamEmailUsageProps) => {
const { toast } = useToast();
const { mutateAsync: deleteTeamEmail, isPending: isDeletingTeamEmail } =
trpc.team.deleteTeamEmail.useMutation({
trpc.team.email.delete.useMutation({
onSuccess: () => {
toast({
title: _(msg`Success`),

View File

@ -0,0 +1,43 @@
import { Trans } from '@lingui/react/macro';
import type { TeamGroup } from '@prisma/client';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { TeamMemberInheritDisableDialog } from '~/components/dialogs/team-inherit-member-disable-dialog';
import { TeamMemberInheritEnableDialog } from '~/components/dialogs/team-inherit-member-enable-dialog';
type TeamInheritMemberAlertProps = {
memberAccessTeamGroup: TeamGroup | null;
};
export const TeamInheritMemberAlert = ({ memberAccessTeamGroup }: TeamInheritMemberAlertProps) => {
return (
<Alert
className="flex flex-col justify-between p-6 sm:flex-row sm:items-center"
variant="neutral"
>
<div className="mb-4 sm:mb-0">
<AlertTitle>
<Trans>Inherit organisation members</Trans>
</AlertTitle>
<AlertDescription className="mr-2">
{memberAccessTeamGroup ? (
<Trans>Currently all organisation members can access this team</Trans>
) : (
<Trans>
You can enable access to allow all organisation members to access this team by
default.
</Trans>
)}
</AlertDescription>
</div>
{memberAccessTeamGroup ? (
<TeamMemberInheritDisableDialog group={memberAccessTeamGroup} />
) : (
<TeamMemberInheritEnableDialog />
)}
</Alert>
);
};

View File

@ -1,10 +1,9 @@
import type { HTMLAttributes } from 'react';
import { Trans } from '@lingui/react/macro';
import { Braces, CreditCard, Globe2Icon, Settings, Settings2, Users, Webhook } from 'lucide-react';
import { Braces, Globe2Icon, GroupIcon, Settings, Settings2, Users, Webhook } from 'lucide-react';
import { Link, useLocation, useParams } from 'react-router';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@ -20,9 +19,9 @@ export const TeamSettingsNavDesktop = ({ className, ...props }: TeamSettingsNavD
const preferencesPath = `/t/${teamUrl}/settings/preferences`;
const publicProfilePath = `/t/${teamUrl}/settings/public-profile`;
const membersPath = `/t/${teamUrl}/settings/members`;
const groupsPath = `/t/${teamUrl}/settings/groups`;
const tokensPath = `/t/${teamUrl}/settings/tokens`;
const webhooksPath = `/t/${teamUrl}/settings/webhooks`;
const billingPath = `/t/${teamUrl}/settings/billing`;
return (
<div className={cn('flex flex-col gap-y-2', className)} {...props}>
@ -76,6 +75,16 @@ export const TeamSettingsNavDesktop = ({ className, ...props }: TeamSettingsNavD
</Button>
</Link>
<Link to={groupsPath}>
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith(groupsPath) && 'bg-secondary')}
>
<GroupIcon className="mr-2 h-5 w-5" />
<Trans>Groups</Trans>
</Button>
</Link>
<Link to={tokensPath}>
<Button
variant="ghost"
@ -98,21 +107,6 @@ export const TeamSettingsNavDesktop = ({ className, ...props }: TeamSettingsNavD
<Trans>Webhooks</Trans>
</Button>
</Link>
{IS_BILLING_ENABLED() && (
<Link to={billingPath}>
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith(billingPath) && 'bg-secondary',
)}
>
<CreditCard className="mr-2 h-5 w-5" />
<Trans>Billing</Trans>
</Button>
</Link>
)}
</div>
);
};

Some files were not shown because too many files have changed in this diff Show More