diff --git a/.cursorrules b/.cursorrules index 61a306ff0..a40eec787 100644 --- a/.cursorrules +++ b/.cursorrules @@ -1,4 +1,7 @@ +You are an expert in TypeScript, Node.js, Remix, React, Shadcn UI and Tailwind. + Code Style and Structure: + - Write concise, technical TypeScript code with accurate examples - Use functional and declarative programming patterns; avoid classes - Prefer iteration and modularization over code duplication @@ -6,20 +9,25 @@ Code Style and Structure: - Structure files: exported component, subcomponents, helpers, static content, types Naming Conventions: + - Use lowercase with dashes for directories (e.g., components/auth-wizard) - Favor named exports for components TypeScript Usage: -- Use TypeScript for all code; prefer interfaces over types -- Avoid enums; use maps instead + +- Use TypeScript for all code; prefer types over interfaces - Use functional components with TypeScript interfaces Syntax and Formatting: -- Use the "function" keyword for pure functions + +- Create functions using `const fn = () => {}` - Avoid unnecessary curly braces in conditionals; use concise syntax for simple statements - Use declarative JSX +- Never use 'use client' +- Never use 1 line if statements Error Handling and Validation: + - Prioritize error handling: handle errors and edge cases early - Use early returns and guard clauses - Implement proper error logging and user-friendly messages @@ -28,21 +36,40 @@ Error Handling and Validation: - Use error boundaries for unexpected errors UI and Styling: + - Use Shadcn UI, Radix, and Tailwind Aria for components and styling - Implement responsive design with Tailwind CSS; use a mobile-first approach +- When using Lucide icons, prefer the longhand names, for example HomeIcon instead of Home -Performance Optimization: -- Minimize 'use client', 'useEffect', and 'setState'; favor React Server Components (RSC) -- Wrap client components in Suspense with fallback -- Use dynamic loading for non-critical components -- Optimize images: use WebP format, include size data, implement lazy loading +React forms -Key Conventions: -- Use 'nuqs' for URL search parameter state management -- Optimize Web Vitals (LCP, CLS, FID) -- Limit 'use client': - - Favor server components and Next.js SSR - - Use only for Web API access in small components - - Avoid for data fetching or state management +- Use zod for form validation react-hook-form for forms +- Look at TeamCreateDialog.tsx as an example of form usage +- Use
elements, and also wrap the contents of form in a fieldset which should have the :disabled attribute when the form is loading -Follow Next.js docs for Data Fetching, Rendering, and Routing \ No newline at end of file +TRPC Specifics + +- Every route should be in it's own file, example routers/teams/create-team.ts +- Every route should have a types file associated with it, example routers/teams/create-team.types.ts. These files should have the OpenAPI meta, and request/response zod schemas +- The request/response schemas should be named like Z[RouteName]RequestSchema and Z[RouteName]ResponseSchema +- Use create-team.ts and create-team.types.ts as an example when creating new routes. +- When creating the OpenAPI meta, only use GET and POST requests, do not use any other REST methods +- Deconstruct the input argument on it's one line of code. + +Toast usage + +- Use the t`string` macro from @lingui/react/macro to display toast messages + +Remix/ReactRouter Usage + +- Use (params: Route.Params) to get the params from the route +- Use (loaderData: Route.LoaderData) to get the loader data from the route +- When using loaderdata, deconstruct the data you need from the loader data inside the function body +- Do not use json() to return data, directly return the data + +Translations + +- Use string to display translations in jsx code, this should be imported from @lingui/react/macro +- Use the t`string` macro from @lingui/react/macro to display translations in typescript code +- t should be imported as const { t } = useLingui() where useLingui is imported from @lingui/react/macro +- String in constants should be using the t`string` macro diff --git a/apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx b/apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx new file mode 100644 index 000000000..840a4bb5d --- /dev/null +++ b/apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx @@ -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; + +const ZCreateAdminOrganisationFormSchema = ZCreateAdminOrganisationRequestSchema.shape.data.pick({ + name: true, +}); + +type TCreateOrganisationFormSchema = z.infer; + +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 ( + !form.formState.isSubmitting && setOpen(value)} + > + e.stopPropagation()} asChild={true}> + {trigger ?? ( + + )} + + + + + + Create organisation + + + + Create an organisation for this user + + + + + +
+ ( + + + Organisation Name + + + + + + + )} + /> + + + + + You will need to configure any claims or subscription after creating this + organisation + + + + + {/* ( + + + Default claim ID + + + + + + Leave blank to use the default free claim + + + + )} + /> */} + + + + + + +
+ + +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/claim-create-dialog.tsx b/apps/remix/app/components/dialogs/claim-create-dialog.tsx new file mode 100644 index 000000000..c3564e7e0 --- /dev/null +++ b/apps/remix/app/components/dialogs/claim-create-dialog.tsx @@ -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; + +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 ( + + e.stopPropagation()} asChild={true}> + + + + + + + Create Subscription Claim + + + Fill in the details to create a new subscription claim. + + + + + + + + + } + /> + + + ); +}; diff --git a/apps/remix/app/components/dialogs/claim-delete-dialog.tsx b/apps/remix/app/components/dialogs/claim-delete-dialog.tsx new file mode 100644 index 000000000..61124fa9d --- /dev/null +++ b/apps/remix/app/components/dialogs/claim-delete-dialog.tsx @@ -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 ( + !isPending && setOpen(value)}> + e.stopPropagation()}> + {trigger} + + + + + + Delete Subscription Claim + + + Are you sure you want to delete the following claim? + + + + + + {claimLocked ? This claim is locked and cannot be deleted. : claimName} + + + + + + + {!claimLocked && ( + + )} + + + + ); +}; diff --git a/apps/remix/app/components/dialogs/claim-update-dialog.tsx b/apps/remix/app/components/dialogs/claim-update-dialog.tsx new file mode 100644 index 000000000..539343d40 --- /dev/null +++ b/apps/remix/app/components/dialogs/claim-update-dialog.tsx @@ -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 ( + + e.stopPropagation()}> + {trigger} + + + + + + Update Subscription Claim + + + Modify the details of the subscription claim. + + + + + await updateClaim({ + id: claim.id, + data, + }) + } + formSubmitTrigger={ + + + + + + } + /> + + + ); +}; diff --git a/apps/remix/app/components/dialogs/document-delete-dialog.tsx b/apps/remix/app/components/dialogs/document-delete-dialog.tsx index c89e346a0..746ef1570 100644 --- a/apps/remix/app/components/dialogs/document-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-delete-dialog.tsx @@ -28,7 +28,6 @@ type DocumentDeleteDialogProps = { onDelete?: () => Promise | void; status: DocumentStatus; documentTitle: string; - teamId?: number; canManageDocument: boolean; }; diff --git a/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx b/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx index 754fa2596..bb87f99dc 100644 --- a/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx @@ -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({ diff --git a/apps/remix/app/components/dialogs/document-move-dialog.tsx b/apps/remix/app/components/dialogs/document-move-dialog.tsx deleted file mode 100644 index 1e0632531..000000000 --- a/apps/remix/app/components/dialogs/document-move-dialog.tsx +++ /dev/null @@ -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(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 ( - - - - - Move Document to Team - - - Select a team to move this document to. This action cannot be undone. - - - - - - - - - - - - ); -}; diff --git a/apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx b/apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx index e8aefd0c0..860230b5f 100644 --- a/apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx @@ -33,7 +33,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'; export type DocumentMoveToFolderDialogProps = { documentId: number; @@ -57,8 +57,9 @@ export const DocumentMoveToFolderDialog = ({ }: DocumentMoveToFolderDialogProps) => { const { _ } = useLingui(); const { toast } = useToast(); + const navigate = useNavigate(); - const team = useOptionalCurrentTeam(); + const team = useCurrentTeam(); const form = useForm({ resolver: zodResolver(ZMoveDocumentFormSchema), @@ -94,6 +95,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 +110,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); diff --git a/apps/remix/app/components/dialogs/document-resend-dialog.tsx b/apps/remix/app/components/dialogs/document-resend-dialog.tsx index 0847eca0f..b3dc69503 100644 --- a/apps/remix/app/components/dialogs/document-resend-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-resend-dialog.tsx @@ -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 { const { user } = useSession(); - const team = useOptionalCurrentTeam(); + const team = useCurrentTeam(); const { toast } = useToast(); const { _ } = useLingui(); diff --git a/apps/remix/app/components/dialogs/folder-create-dialog.tsx b/apps/remix/app/components/dialogs/folder-create-dialog.tsx index c0544ba3a..9451b57a1 100644 --- a/apps/remix/app/components/dialogs/folder-create-dialog.tsx +++ b/apps/remix/app/components/dialogs/folder-create-dialog.tsx @@ -34,7 +34,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'; const ZCreateFolderFormSchema = z.object({ name: z.string().min(1, { message: 'Folder name is required' }), @@ -52,7 +52,7 @@ export const CreateFolderDialog = ({ trigger, ...props }: CreateFolderDialogProp const { folderId } = useParams(); const navigate = useNavigate(); - const team = useOptionalCurrentTeam(); + const team = useCurrentTeam(); const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false); @@ -75,13 +75,13 @@ export const CreateFolderDialog = ({ trigger, ...props }: CreateFolderDialogProp setIsCreateFolderOpen(false); + const documentsPath = formatDocumentsPath(team.url); + + await navigate(`${documentsPath}/f/${newFolder.id}`); + toast({ description: 'Folder created successfully', }); - - const documentsPath = formatDocumentsPath(team?.url); - - void navigate(`${documentsPath}/f/${newFolder.id}`); } catch (err) { const error = AppError.parseError(err); @@ -124,7 +124,7 @@ export const CreateFolderDialog = ({ trigger, ...props }: CreateFolderDialogProp Create New Folder - Enter a name for your new folder. Folders help you organize your documents. + Enter a name for your new folder. Folders help you organise your documents. diff --git a/apps/remix/app/components/dialogs/organisation-create-dialog.tsx b/apps/remix/app/components/dialogs/organisation-create-dialog.tsx new file mode 100644 index 000000000..1e564ccaf --- /dev/null +++ b/apps/remix/app/components/dialogs/organisation-create-dialog.tsx @@ -0,0 +1,423 @@ +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; + +export const ZCreateOrganisationFormSchema = ZCreateOrganisationRequestSchema.pick({ + name: true, +}); + +export type TCreateOrganisationFormSchema = z.infer; + +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(''); + + 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(); + + 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 ( + !form.formState.isSubmitting && setOpen(value)} + > + e.stopPropagation()} asChild={true}> + {trigger ?? ( + + )} + + + + {match(step) + .with('billing', () => ( + <> + + + Select a plan + + + + Select a plan to continue + + +
+ {plansData ? ( + + ) : ( + + )} + + + + + + +
+ + )) + .with('create', () => ( + <> + + + Create organisation + + + + Create an organisation to collaborate with teams + + + +
+ +
+ ( + + + Organisation Name + + + + + + + )} + /> + + + {IS_BILLING_ENABLED() ? ( + + ) : ( + + )} + + + +
+
+ + + )) + + .exhaustive()} +
+
+ ); +}; + +// 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 ( +
+ onBillingPeriodChange(value as 'monthlyPrice' | 'yearlyPrice')} + > + + + Monthly + + + Yearly + + + + +
+ + + {dynamicPlans.map((plan) => ( + + ))} + + +
+

+ Enterprise +

+

+ Contact sales here + +

+
+ +
+ +
+ + Compare all plans and features in detail + + +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/organisation-delete-dialog.tsx b/apps/remix/app/components/dialogs/organisation-delete-dialog.tsx new file mode 100644 index 000000000..10b53833b --- /dev/null +++ b/apps/remix/app/components/dialogs/organisation-delete-dialog.tsx @@ -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 ( + !form.formState.isSubmitting && setOpen(value)}> + + {trigger ?? ( + + )} + + + + + + Are you sure you wish to delete this organisation? + + + + + You are about to delete {organisation.name}. + All data related to this organisation such as teams, documents, and all other + resources will be deleted. This action is irreversible. + + + + +
+ +
+ ( + + + + Confirm by typing {deleteMessage} + + + + + + + + )} + /> + + + + + + +
+
+ +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx b/apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx new file mode 100644 index 000000000..4f80b318a --- /dev/null +++ b/apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx @@ -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; + +const ZCreateOrganisationGroupFormSchema = ZCreateOrganisationGroupRequestSchema.pick({ + name: true, + memberIds: true, + organisationRole: true, +}); + +type TCreateOrganisationGroupFormSchema = z.infer; + +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 ( + !form.formState.isSubmitting && setOpen(value)} + > + e.stopPropagation()} asChild={true}> + {trigger ?? ( + + )} + + + + + + Create group + + + + Organise your members into groups which can be assigned to teams + + + +
+ +
+ ( + + + Group Name + + + + + + + )} + /> + + ( + + + Organisation role + + + + + + + + )} + /> + + ( + + + Members + + + + ({ + label: member.name, + value: member.id, + }))} + loading={isLoadingMembers} + selectedValues={field.value} + onChange={field.onChange} + className="bg-background w-full" + emptySelectionPlaceholder={t`Select members`} + /> + + + + Select the members to add to this group + + + )} + /> + + + + + + +
+
+ +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/organisation-group-delete-dialog.tsx b/apps/remix/app/components/dialogs/organisation-group-delete-dialog.tsx new file mode 100644 index 000000000..17a389622 --- /dev/null +++ b/apps/remix/app/components/dialogs/organisation-group-delete-dialog.tsx @@ -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 ( + !isDeleting && setOpen(value)}> + + {trigger ?? ( + + )} + + + + + + Are you sure? + + + + + You are about to remove the following group from{' '} + {organisation.name}. + + + + + + + {organisationGroupName} + + + +
+ + + + + +
+
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/organisation-leave-dialog.tsx b/apps/remix/app/components/dialogs/organisation-leave-dialog.tsx new file mode 100644 index 000000000..fb1c7a914 --- /dev/null +++ b/apps/remix/app/components/dialogs/organisation-leave-dialog.tsx @@ -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 ( + !isLeavingOrganisation && setOpen(value)}> + + {trigger ?? ( + + )} + + + + + + Are you sure? + + + + You are about to leave the following organisation. + + + + + + + +
+ + + + + +
+
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/organisation-member-delete-dialog.tsx b/apps/remix/app/components/dialogs/organisation-member-delete-dialog.tsx new file mode 100644 index 000000000..a46151d3b --- /dev/null +++ b/apps/remix/app/components/dialogs/organisation-member-delete-dialog.tsx @@ -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 ( + !isDeletingOrganisationMember && setOpen(value)}> + + {trigger ?? ( + + )} + + + + + + Are you sure? + + + + + You are about to remove the following user from{' '} + {organisation.name}. + + + + + + {organisationMemberName}} + secondaryText={organisationMemberEmail} + /> + + +
+ + + + + +
+
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/organisation-member-invite-dialog.tsx b/apps/remix/app/components/dialogs/organisation-member-invite-dialog.tsx new file mode 100644 index 000000000..1009ef4e6 --- /dev/null +++ b/apps/remix/app/components/dialogs/organisation-member-invite-dialog.tsx @@ -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; + +const ZInviteOrganisationMembersFormSchema = z + .object({ + invitations: ZCreateOrganisationMemberInvitesRequestSchema.shape.invitations, + }) + // Display exactly which rows are duplicates. + .superRefine((items, ctx) => { + const uniqueEmails = new Map(); + + 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; + +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(null); + const [invitationType, setInvitationType] = useState('INDIVIDUAL'); + + const { _ } = useLingui(); + const { toast } = useToast(); + + const organisation = useCurrentOrganisation(); + + const form = useForm({ + 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) => { + 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) => { + 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 ( + !form.formState.isSubmitting && setOpen(value)} + > + e.stopPropagation()} asChild> + {trigger ?? ( + + )} + + + + + + Invite organisation members + + + + An email containing an invitation will be sent to each member. + + + + {dialogState === 'loading' && } + + {dialogState === 'alert' && ( + <> + + + + Your plan does not support inviting members. Please upgrade or your plan or + contact sales at support@documenso.com{' '} + if you would like to discuss your options. + + + + + + + + + )} + + {dialogState === 'form' && ( + setInvitationType(value as TabTypes)} + > + + + + Invite Members + + + + Bulk Import + + + + +
+ +
+
+ {organisationMemberInvites.map((organisationMemberInvite, index) => ( +
+ ( + + {index === 0 && ( + + Email address + + )} + + + + + + )} + /> + + ( + + {index === 0 && ( + + Organisation Role + + )} + + + + + + )} + /> + + +
+ ))} +
+ + + + + + + + +
+
+ +
+ + +
+ + fileInputRef.current?.click()} + > + + +

+ Click here to upload +

+ + +
+
+ + + + +
+
+
+ )} +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx b/apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx new file mode 100644 index 000000000..07db162a2 --- /dev/null +++ b/apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx @@ -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; + +const ZUpdateOrganisationMemberFormSchema = z.object({ + role: z.nativeEnum(OrganisationMemberRole), +}); + +type ZUpdateOrganisationMemberSchema = z.infer; + +export const OrganisationMemberUpdateDialog = ({ + currentUserOrganisationRole, + trigger, + organisationId, + organisationMemberId, + organisationMemberName, + organisationMemberRole, + ...props +}: OrganisationMemberUpdateDialogProps) => { + const [open, setOpen] = useState(false); + + const { _ } = useLingui(); + const { toast } = useToast(); + + const form = useForm({ + 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 ( + !form.formState.isSubmitting && setOpen(value)} + > + e.stopPropagation()} asChild> + {trigger ?? ( + + )} + + + + + + Update organisation member + + + + + You are currently updating{' '} + {organisationMemberName}. + + + + +
+ +
+ ( + + + Role + + + + + + + )} + /> + + + + + + +
+
+ +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx b/apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx index d6a13f456..bcd624290 100644 --- a/apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx +++ b/apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx @@ -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(initialTemplateId); diff --git a/apps/remix/app/components/dialogs/team-checkout-create-dialog.tsx b/apps/remix/app/components/dialogs/team-checkout-create-dialog.tsx deleted file mode 100644 index 038c78504..000000000 --- a/apps/remix/app/components/dialogs/team-checkout-create-dialog.tsx +++ /dev/null @@ -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; - -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 ( - - - - - Team checkout - - - - Payment is required to finalise the creation of your team. - - - - {(isLoading || !data) && ( -
- {isLoading ? ( - - ) : ( -

- Something went wrong -

- )} -
- )} - - {data && selectedPrice && !isLoading && ( -
- setInterval(value as 'monthly' | 'yearly')} - value={interval} - className="mb-4" - > - - {[data.monthly, data.yearly].map((price) => ( - - {price.friendlyInterval} - - ))} - - - - - - - {selectedPrice.interval === 'monthly' ? ( -
- $50 USD per month -
- ) : ( -
- - $480 USD per year - -
- - 20% off -
-
- )} - -
-

- This price includes minimum 5 seats. -

- -

- Adding and removing seats will adjust your invoice accordingly. -

-
-
-
-
- - - - - - -
- )} -
-
- ); -}; diff --git a/apps/remix/app/components/dialogs/team-create-dialog.tsx b/apps/remix/app/components/dialogs/team-create-dialog.tsx index 0b49e9b6d..65740ce67 100644 --- a/apps/remix/app/components/dialogs/team-create-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-create-dialog.tsx @@ -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; } & Omit; -const ZCreateTeamFormSchema = ZCreateTeamMutationSchema.pick({ +const ZCreateTeamFormSchema = ZCreateTeamRequestSchema.pick({ teamName: true, teamUrl: true, + inheritMembers: true, }); type TCreateTeamFormSchema = z.infer; -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) = Create team - + Create a team to collaborate with your team members. -
- -
} + + {dialogState === 'alert' && ( + <> + - ( - - - Team Name - - - { - const oldGeneratedUrl = mapTextToUrl(field.value); - const newGeneratedUrl = mapTextToUrl(event.target.value); + + + You have reached the maximum number of teams for your plan. Please contact sales + at support@documenso.com if you would + like to adjust your plan. + + + - const urlField = form.getValues('teamUrl'); - if (urlField === oldGeneratedUrl) { - form.setValue('teamUrl', newGeneratedUrl); - } + + + + + )} - field.onChange(event); - }} - /> - - - - )} - /> + {dialogState === 'form' && ( + + +
+ ( + + + Team Name + + + { + const oldGeneratedUrl = mapTextToUrl(field.value); + const newGeneratedUrl = mapTextToUrl(event.target.value); - ( - - - Team URL - - - - - {!form.formState.errors.teamUrl && ( - - {field.value ? ( - `${NEXT_PUBLIC_WEBAPP_URL()}/t/${field.value}` - ) : ( - A unique URL to identify your team - )} - - )} + const urlField = form.getValues('teamUrl'); + if (urlField === oldGeneratedUrl) { + form.setValue('teamUrl', newGeneratedUrl); + } - - - )} - /> + field.onChange(event); + }} + /> + + + + )} + /> - - + ( + + + Team URL + + + + + {!form.formState.errors.teamUrl && ( + + {field.value ? ( + `${NEXT_PUBLIC_WEBAPP_URL()}/t/${field.value}` + ) : ( + A unique URL to identify your team + )} + + )} - - -
- - + + + )} + /> + + ( + + +
+ + + +
+
+
+ )} + /> + + + + + + +
+ + + )} ); diff --git a/apps/remix/app/components/dialogs/team-delete-dialog.tsx b/apps/remix/app/components/dialogs/team-delete-dialog.tsx index b297cbb2a..670ba1a2a 100644 --- a/apps/remix/app/components/dialogs/team-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-delete-dialog.tsx @@ -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 {trigger ?? ( )} diff --git a/apps/remix/app/components/dialogs/team-email-add-dialog.tsx b/apps/remix/app/components/dialogs/team-email-add-dialog.tsx index 161c2c0eb..56e54da72 100644 --- a/apps/remix/app/components/dialogs/team-email-add-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-email-add-dialog.tsx @@ -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, diff --git a/apps/remix/app/components/dialogs/team-email-delete-dialog.tsx b/apps/remix/app/components/dialogs/team-email-delete-dialog.tsx index ec050961c..d9b780657 100644 --- a/apps/remix/app/components/dialogs/team-email-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-email-delete-dialog.tsx @@ -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`), diff --git a/apps/remix/app/components/dialogs/team-email-update-dialog.tsx b/apps/remix/app/components/dialogs/team-email-update-dialog.tsx index bde700949..dd5411b59 100644 --- a/apps/remix/app/components/dialogs/team-email-update-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-email-update-dialog.tsx @@ -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 { diff --git a/apps/remix/app/components/dialogs/team-group-create-dialog.tsx b/apps/remix/app/components/dialogs/team-group-create-dialog.tsx new file mode 100644 index 000000000..dd79e9e05 --- /dev/null +++ b/apps/remix/app/components/dialogs/team-group-create-dialog.tsx @@ -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; + +const ZAddTeamMembersFormSchema = z.object({ + groups: z.array( + z.object({ + organisationGroupId: z.string(), + teamRole: z.nativeEnum(TeamMemberRole), + }), + ), +}); + +type TAddTeamMembersFormSchema = z.infer; + +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({ + 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 ( + + e.stopPropagation()} asChild> + + + + + {match(step) + .with('SELECT', () => ( + + + Add members + + + + Select members or groups of members to add to the team. + + + )) + .with('ROLES', () => ( + + + Add group roles + + + + Configure the team roles for each group + + + )) + .exhaustive()} + +
+ +
+ {step === 'SELECT' && ( + <> + ( + + + Groups + + + + ({ + 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`} + /> + + + + Select groups to add to this team + + + )} + /> + + + + + + + + )} + + {step === 'ROLES' && ( + <> +
+ {form.getValues('groups').map((group, index) => ( +
+
+ {index === 0 && ( + + Group + + )} + id === group.organisationGroupId, + )?.name || t`Untitled Group` + } + /> +
+ + ( + + {index === 0 && ( + + Team Role + + )} + + + + + + )} + /> +
+ ))} +
+ + + + + + + + )} +
+
+ +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/team-group-delete-dialog.tsx b/apps/remix/app/components/dialogs/team-group-delete-dialog.tsx new file mode 100644 index 000000000..f42fd4630 --- /dev/null +++ b/apps/remix/app/components/dialogs/team-group-delete-dialog.tsx @@ -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 ( + !isDeleting && setOpen(value)}> + + {trigger ?? ( + + )} + + + + + + Are you sure? + + + + + You are about to remove the following group from{' '} + {team.name}. + + + + + {isTeamRoleWithinUserHierarchy(team.currentTeamRole, teamGroupRole) ? ( + <> + + + {teamGroupName} + + + +
+ + + + + +
+ + ) : ( + <> + + + You cannot delete a group which has a higher role than you. + + + + + + + + )} +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/team-group-update-dialog.tsx b/apps/remix/app/components/dialogs/team-group-update-dialog.tsx new file mode 100644 index 000000000..c57755e87 --- /dev/null +++ b/apps/remix/app/components/dialogs/team-group-update-dialog.tsx @@ -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; + +const ZUpdateTeamGroupFormSchema = z.object({ + role: z.nativeEnum(TeamMemberRole), +}); + +type ZUpdateTeamGroupSchema = z.infer; + +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({ + 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 ( + !form.formState.isSubmitting && setOpen(value)} + > + e.stopPropagation()} asChild> + {trigger ?? ( + + )} + + + + + + Update team group + + + + + You are currently updating the {teamGroupName} team + group. + + + + + {isTeamRoleWithinUserHierarchy(team.currentTeamRole, teamGroupRole) ? ( +
+ +
+ ( + + + Role + + + + + + + )} + /> + + + + + + +
+
+ + ) : ( + <> + + + You cannot modify a group which has a higher role than you. + + + + + + + + )} +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/team-inherit-member-disable-dialog.tsx b/apps/remix/app/components/dialogs/team-inherit-member-disable-dialog.tsx new file mode 100644 index 000000000..c7352b7fe --- /dev/null +++ b/apps/remix/app/components/dialogs/team-inherit-member-disable-dialog.tsx @@ -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 ( + + + + + + + + + Are you sure? + + + + + 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. + + + + + + + + + + + + + + ); +}; diff --git a/apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx b/apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx new file mode 100644 index 000000000..1abece3fa --- /dev/null +++ b/apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx @@ -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 ( + + + + + + + + + Are you sure? + + + + + You are about to give all organisation members access to this team under their + organisation role. + + + + + + + + + + + + + + ); +}; diff --git a/apps/remix/app/components/dialogs/team-leave-dialog.tsx b/apps/remix/app/components/dialogs/team-leave-dialog.tsx deleted file mode 100644 index a6b6246a6..000000000 --- a/apps/remix/app/components/dialogs/team-leave-dialog.tsx +++ /dev/null @@ -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 ( - !isLeavingTeam && setOpen(value)}> - - {trigger ?? ( - - )} - - - - - - Are you sure? - - - - You are about to leave the following team. - - - - - - - -
- - - - - -
-
-
- ); -}; diff --git a/apps/remix/app/components/dialogs/team-member-create-dialog.tsx b/apps/remix/app/components/dialogs/team-member-create-dialog.tsx new file mode 100644 index 000000000..b691d910c --- /dev/null +++ b/apps/remix/app/components/dialogs/team-member-create-dialog.tsx @@ -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; + +const ZAddTeamMembersFormSchema = z.object({ + members: z.array( + z.object({ + organisationMemberId: z.string(), + teamRole: z.nativeEnum(TeamMemberRole), + }), + ), +}); + +type TAddTeamMembersFormSchema = z.infer; + +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({ + 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 ( + + e.stopPropagation()} asChild> + + + + + {match(step) + .with('SELECT', () => ( + + + Add members + + + + Select members or groups of members to add to the team. + + + )) + .with('MEMBERS', () => ( + + + Add members roles + + + + Configure the team roles for each member + + + )) + .exhaustive()} + +
+ +
+ {step === 'SELECT' && ( + <> + ( + + + Members + + + + ({ + 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`} + /> + + + + Select members to add to this team + + + )} + /> + + + + + + + + )} + + {step === 'MEMBERS' && ( + <> +
+ {form.getValues('members').map((member, index) => ( +
+
+ {index === 0 && ( + + Member + + )} + id === member.organisationMemberId, + )?.name || '' + } + /> +
+ + ( + + {index === 0 && ( + + Team Role + + )} + + + + + + )} + /> +
+ ))} +
+ + + + + + + + )} +
+
+ +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/team-member-delete-dialog.tsx b/apps/remix/app/components/dialogs/team-member-delete-dialog.tsx index dedda2003..82a4d7a04 100644 --- a/apps/remix/app/components/dialogs/team-member-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-member-delete-dialog.tsx @@ -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 = ({ {trigger ?? ( )} @@ -88,29 +90,42 @@ export const TeamMemberDeleteDialog = ({
- - {teamMemberName}} - secondaryText={teamMemberEmail} - /> - + {isInheritMemberEnabled ? ( + + + + You cannot remove members from this team if the inherit member feature is enabled. + + + + ) : ( + + {memberName}} + secondaryText={memberEmail} + /> + + )}
- + {!isInheritMemberEnabled && ( + + )}
diff --git a/apps/remix/app/components/dialogs/team-member-invite-dialog.tsx b/apps/remix/app/components/dialogs/team-member-invite-dialog.tsx deleted file mode 100644 index dac4f8fce..000000000 --- a/apps/remix/app/components/dialogs/team-member-invite-dialog.tsx +++ /dev/null @@ -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; - -const ZInviteTeamMembersFormSchema = z - .object({ - invitations: ZCreateTeamMemberInvitesMutationSchema.shape.invitations, - }) - // Display exactly which rows are duplicates. - .superRefine((items, ctx) => { - const uniqueEmails = new Map(); - - 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; - -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(null); - const [invitationType, setInvitationType] = useState('INDIVIDUAL'); - - const { _ } = useLingui(); - const { toast } = useToast(); - - const team = useCurrentTeam(); - - const form = useForm({ - 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) => { - 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) => { - 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 ( - !form.formState.isSubmitting && setOpen(value)} - > - e.stopPropagation()} asChild> - {trigger ?? ( - - )} - - - - - - Invite team members - - - - An email containing an invitation will be sent to each member. - - - - setInvitationType(value as TabTypes)} - > - - - - Invite Members - - - - Bulk Import - - - - -
- -
-
- {teamMemberInvites.map((teamMemberInvite, index) => ( -
- ( - - {index === 0 && ( - - Email address - - )} - - - - - - )} - /> - - ( - - {index === 0 && ( - - Role - - )} - - - - - - )} - /> - - -
- ))} -
- - - - - - - - -
-
- -
- - -
- - fileInputRef.current?.click()} - > - - -

- Click here to upload -

- - -
-
- - - - -
-
-
-
-
- ); -}; diff --git a/apps/remix/app/components/dialogs/team-member-update-dialog.tsx b/apps/remix/app/components/dialogs/team-member-update-dialog.tsx index e9c3a021b..b7729f734 100644 --- a/apps/remix/app/components/dialogs/team-member-update-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-member-update-dialog.tsx @@ -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; 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({ 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 ( Update team member - + - You are currently updating {teamMemberName}. + You are currently updating {memberName}. @@ -170,7 +171,7 @@ export const TeamMemberUpdateDialog = ({ {TEAM_MEMBER_ROLE_HIERARCHY[currentUserTeamRole].map((role) => ( - {_(TEAM_MEMBER_ROLE_MAP[role]) ?? role} + {_(EXTENDED_TEAM_MEMBER_ROLE_MAP[role]) ?? role} ))} diff --git a/apps/remix/app/components/dialogs/team-transfer-dialog.tsx b/apps/remix/app/components/dialogs/team-transfer-dialog.tsx deleted file mode 100644 index 4e46233cc..000000000 --- a/apps/remix/app/components/dialogs/team-transfer-dialog.tsx +++ /dev/null @@ -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>({ - resolver: zodResolver(ZTransferTeamFormSchema), - defaultValues: { - teamName: '', - clearPaymentMethods: false, - }, - }); - - const onFormSubmit = async ({ - newOwnerUserId, - clearPaymentMethods, - }: z.infer) => { - 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 ( - !form.formState.isSubmitting && setOpen(value)}> - - {trigger ?? ( - - )} - - - {teamMembers && teamMembers.length > 0 ? ( - - - - Transfer team - - - - Transfer ownership of this team to a selected team member. - - - -
- -
- ( - - - New team owner - - - - - - - )} - /> - - ( - - - - Confirm by typing{' '} - {confirmTransferMessage} - - - - - - - - )} - /> - - - -
    - {IS_BILLING_ENABLED() && ( -
  • - - Any payment methods attached to this team will remain attached to this - team. Please contact us if you need to update this information. - -
  • - )} -
  • - - The selected team member will receive an email which they must accept - before the team is transferred - -
  • -
-
-
- - - - - - -
-
- -
- ) : ( - - {loadingTeamMembers ? ( - - ) : ( -

- {loadingTeamMembersError ? ( - An error occurred while loading team members. Please try again later. - ) : ( - You must have at least one other team member to transfer ownership. - )} -

- )} -
- )} -
- ); -}; diff --git a/apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx b/apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx index cd700ac1e..610ce7ac3 100644 --- a/apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx +++ b/apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx @@ -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({ resolver: zodResolver(ZBulkSendFormSchema), diff --git a/apps/remix/app/components/dialogs/template-direct-link-dialog.tsx b/apps/remix/app/components/dialogs/template-direct-link-dialog.tsx index 8142eb848..d7e60b512 100644 --- a/apps/remix/app/components/dialogs/template-direct-link-dialog.tsx +++ b/apps/remix/app/components/dialogs/template-direct-link-dialog.tsx @@ -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.{' '} Upgrade your account to continue! diff --git a/apps/remix/app/components/dialogs/template-folder-create-dialog.tsx b/apps/remix/app/components/dialogs/template-folder-create-dialog.tsx index aaf0322f4..db1da14de 100644 --- a/apps/remix/app/components/dialogs/template-folder-create-dialog.tsx +++ b/apps/remix/app/components/dialogs/template-folder-create-dialog.tsx @@ -34,7 +34,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'; const ZCreateFolderFormSchema = z.object({ name: z.string().min(1, { message: 'Folder name is required' }), @@ -52,10 +52,11 @@ export const TemplateFolderCreateDialog = ({ }: TemplateFolderCreateDialogProps) => { const { toast } = useToast(); const { _ } = useLingui(); - const navigate = useNavigate(); - const team = useOptionalCurrentTeam(); const { folderId } = useParams(); + const navigate = useNavigate(); + const team = useCurrentTeam(); + const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false); const { mutateAsync: createFolder } = trpc.folder.createFolder.useMutation(); @@ -81,7 +82,7 @@ export const TemplateFolderCreateDialog = ({ description: _(msg`Folder created successfully`), }); - const templatesPath = formatTemplatesPath(team?.url); + const templatesPath = formatTemplatesPath(team.url); void navigate(`${templatesPath}/f/${newFolder.id}`); } catch (err) { @@ -126,7 +127,7 @@ export const TemplateFolderCreateDialog = ({ Create New Folder - Enter a name for your new folder. Folders help you organize your templates. + Enter a name for your new folder. Folders help you organise your templates. diff --git a/apps/remix/app/components/dialogs/template-move-dialog.tsx b/apps/remix/app/components/dialogs/template-move-dialog.tsx deleted file mode 100644 index f113317eb..000000000 --- a/apps/remix/app/components/dialogs/template-move-dialog.tsx +++ /dev/null @@ -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; -}; - -export const TemplateMoveDialog = ({ - templateId, - open, - onOpenChange, - onMove, -}: TemplateMoveDialogProps) => { - const { toast } = useToast(); - const { _ } = useLingui(); - - const [selectedTeamId, setSelectedTeamId] = useState(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 ( - - - - - Move Template to Team - - - Select a team to move this template to. This action cannot be undone. - - - - - - - - - - - - ); -}; diff --git a/apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx b/apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx index c0d068ac4..4838995b5 100644 --- a/apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx +++ b/apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx @@ -33,7 +33,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'; export type TemplateMoveToFolderDialogProps = { templateId: number; @@ -60,7 +60,7 @@ export function TemplateMoveToFolderDialog({ const { _ } = useLingui(); const { toast } = useToast(); const navigate = useNavigate(); - const team = useOptionalCurrentTeam(); + const team = useCurrentTeam(); const form = useForm({ resolver: zodResolver(ZMoveTemplateFormSchema), @@ -104,7 +104,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}`); diff --git a/apps/remix/app/components/dialogs/token-delete-dialog.tsx b/apps/remix/app/components/dialogs/token-delete-dialog.tsx index 511ce04db..aa557132b 100644 --- a/apps/remix/app/components/dialogs/token-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/token-delete-dialog.tsx @@ -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; @@ -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); diff --git a/apps/remix/app/components/dialogs/webhook-create-dialog.tsx b/apps/remix/app/components/dialogs/webhook-create-dialog.tsx index f8c5c94d2..ce1109322 100644 --- a/apps/remix/app/components/dialogs/webhook-create-dialog.tsx +++ b/apps/remix/app/components/dialogs/webhook-create-dialog.tsx @@ -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; @@ -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); diff --git a/apps/remix/app/components/dialogs/webhook-delete-dialog.tsx b/apps/remix/app/components/dialogs/webhook-delete-dialog.tsx index 5842f4fb7..6fb369577 100644 --- a/apps/remix/app/components/dialogs/webhook-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/webhook-delete-dialog.tsx @@ -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; @@ -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`), diff --git a/apps/remix/app/components/forms/avatar-image.tsx b/apps/remix/app/components/forms/avatar-image.tsx index 852063287..0d6dd4ddf 100644 --- a/apps/remix/app/components/forms/avatar-image.tsx +++ b/apps/remix/app/components/forms/avatar-image.tsx @@ -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; 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({ 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(); diff --git a/apps/remix/app/components/forms/team-branding-preferences-form.tsx b/apps/remix/app/components/forms/branding-preferences-form.tsx similarity index 64% rename from apps/remix/app/components/forms/team-branding-preferences-form.tsx rename to apps/remix/app/components/forms/branding-preferences-form.tsx index 5cc519960..85355a7b1 100644 --- a/apps/remix/app/components/forms/team-branding-preferences-form.tsx +++ b/apps/remix/app/components/forms/branding-preferences-form.tsx @@ -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; +export type TBrandingPreferencesFormSchema = z.infer; -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; + 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(''); const [hasLoadedPreview, setHasLoadedPreview] = useState(false); - const { mutateAsync: updateTeamBrandingSettings } = - trpc.team.updateTeamBrandingSettings.useMutation(); - - const form = useForm({ + const form = useForm({ 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 (
- -
+ +
( - Enable Custom Branding + + Enable Custom Branding + -
- - - -
+ + + - Enable custom branding for all documents in this team. + {context === 'Team' ? ( + Enable custom branding for all documents in this team + ) : ( + Enable custom branding for all documents in this organisation + )}
)} />
- {!isBrandingEnabled &&
} + {!isBrandingEnabled &&
} ( - Branding Logo + + Branding Logo +
@@ -192,7 +190,8 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref /> ) : (
- Please upload a logo + Please upload a logo + {!hasLoadedPreview && (
@@ -253,6 +252,13 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref Upload your brand logo (max 5MB, JPG, PNG, or WebP) + + {canInherit && ( + + {'. '} + Leave blank to inherit from the organisation. + + )}
@@ -264,7 +270,9 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref name="brandingUrl" render={({ field }) => ( - Brand Website + + Brand Website + Your brand website URL + + {canInherit && ( + + {'. '} + Leave blank to inherit from the organisation. + + )} )} @@ -287,11 +302,13 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref name="brandingCompanyDetails" render={({ field }) => ( - Brand Details + + Brand Details +