diff --git a/apps/remix/.dockerignore b/apps/remix/.dockerignore new file mode 100644 index 000000000..9b8d51471 --- /dev/null +++ b/apps/remix/.dockerignore @@ -0,0 +1,4 @@ +.react-router +build +node_modules +README.md \ No newline at end of file diff --git a/apps/remix/.gitignore b/apps/remix/.gitignore new file mode 100644 index 000000000..188507428 --- /dev/null +++ b/apps/remix/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/node_modules/ + +# React Router +/.react-router/ +/build/ + +# Vite +vite.config.*.timestamp* \ No newline at end of file diff --git a/apps/remix/Dockerfile b/apps/remix/Dockerfile new file mode 100644 index 000000000..207bf937e --- /dev/null +++ b/apps/remix/Dockerfile @@ -0,0 +1,22 @@ +FROM node:20-alpine AS development-dependencies-env +COPY . /app +WORKDIR /app +RUN npm ci + +FROM node:20-alpine AS production-dependencies-env +COPY ./package.json package-lock.json /app/ +WORKDIR /app +RUN npm ci --omit=dev + +FROM node:20-alpine AS build-env +COPY . /app/ +COPY --from=development-dependencies-env /app/node_modules /app/node_modules +WORKDIR /app +RUN npm run build + +FROM node:20-alpine +COPY ./package.json package-lock.json /app/ +COPY --from=production-dependencies-env /app/node_modules /app/node_modules +COPY --from=build-env /app/build /app/build +WORKDIR /app +CMD ["npm", "run", "start"] \ No newline at end of file diff --git a/apps/remix/Dockerfile.bun b/apps/remix/Dockerfile.bun new file mode 100644 index 000000000..973038e8a --- /dev/null +++ b/apps/remix/Dockerfile.bun @@ -0,0 +1,25 @@ +FROM oven/bun:1 AS dependencies-env +COPY . /app + +FROM dependencies-env AS development-dependencies-env +COPY ./package.json bun.lockb /app/ +WORKDIR /app +RUN bun i --frozen-lockfile + +FROM dependencies-env AS production-dependencies-env +COPY ./package.json bun.lockb /app/ +WORKDIR /app +RUN bun i --production + +FROM dependencies-env AS build-env +COPY ./package.json bun.lockb /app/ +COPY --from=development-dependencies-env /app/node_modules /app/node_modules +WORKDIR /app +RUN bun run build + +FROM dependencies-env +COPY ./package.json bun.lockb /app/ +COPY --from=production-dependencies-env /app/node_modules /app/node_modules +COPY --from=build-env /app/build /app/build +WORKDIR /app +CMD ["bun", "run", "start"] \ No newline at end of file diff --git a/apps/remix/Dockerfile.pnpm b/apps/remix/Dockerfile.pnpm new file mode 100644 index 000000000..57916afc2 --- /dev/null +++ b/apps/remix/Dockerfile.pnpm @@ -0,0 +1,26 @@ +FROM node:20-alpine AS dependencies-env +RUN npm i -g pnpm +COPY . /app + +FROM dependencies-env AS development-dependencies-env +COPY ./package.json pnpm-lock.yaml /app/ +WORKDIR /app +RUN pnpm i --frozen-lockfile + +FROM dependencies-env AS production-dependencies-env +COPY ./package.json pnpm-lock.yaml /app/ +WORKDIR /app +RUN pnpm i --prod --frozen-lockfile + +FROM dependencies-env AS build-env +COPY ./package.json pnpm-lock.yaml /app/ +COPY --from=development-dependencies-env /app/node_modules /app/node_modules +WORKDIR /app +RUN pnpm build + +FROM dependencies-env +COPY ./package.json pnpm-lock.yaml /app/ +COPY --from=production-dependencies-env /app/node_modules /app/node_modules +COPY --from=build-env /app/build /app/build +WORKDIR /app +CMD ["pnpm", "start"] \ No newline at end of file diff --git a/apps/remix/README.md b/apps/remix/README.md new file mode 100644 index 000000000..e0d20664e --- /dev/null +++ b/apps/remix/README.md @@ -0,0 +1,100 @@ +# Welcome to React Router! + +A modern, production-ready template for building full-stack React applications using React Router. + +[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default) + +## Features + +- 🚀 Server-side rendering +- ⚡️ Hot Module Replacement (HMR) +- 📦 Asset bundling and optimization +- 🔄 Data loading and mutations +- 🔒 TypeScript by default +- 🎉 TailwindCSS for styling +- 📖 [React Router docs](https://reactrouter.com/) + +## Getting Started + +### Installation + +Install the dependencies: + +```bash +npm install +``` + +### Development + +Start the development server with HMR: + +```bash +npm run dev +``` + +Your application will be available at `http://localhost:5173`. + +## Building for Production + +Create a production build: + +```bash +npm run build +``` + +## Deployment + +### Docker Deployment + +This template includes three Dockerfiles optimized for different package managers: + +- `Dockerfile` - for npm +- `Dockerfile.pnpm` - for pnpm +- `Dockerfile.bun` - for bun + +To build and run using Docker: + +```bash +# For npm +docker build -t my-app . + +# For pnpm +docker build -f Dockerfile.pnpm -t my-app . + +# For bun +docker build -f Dockerfile.bun -t my-app . + +# Run the container +docker run -p 3000:3000 my-app +``` + +The containerized application can be deployed to any platform that supports Docker, including: + +- AWS ECS +- Google Cloud Run +- Azure Container Apps +- Digital Ocean App Platform +- Fly.io +- Railway + +### DIY Deployment + +If you're familiar with deploying Node applications, the built-in app server is production-ready. + +Make sure to deploy the output of `npm run build` + +``` +├── package.json +├── package-lock.json (or pnpm-lock.yaml, or bun.lockb) +├── build/ +│ ├── client/ # Static assets +│ └── server/ # Server-side code +``` + +## Styling + +This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer. + +--- + +Built with ❤️ using React Router. diff --git a/apps/remix/app/_[id]/document-page-view-button.tsx b/apps/remix/app/_[id]/document-page-view-button.tsx new file mode 100644 index 000000000..ff38f285c --- /dev/null +++ b/apps/remix/app/_[id]/document-page-view-button.tsx @@ -0,0 +1,117 @@ +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import type { Document, Recipient, Team, User } from '@prisma/client'; +import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client'; +import { CheckCircle, Download, EyeIcon, Pencil } from 'lucide-react'; +import { useSession } from 'next-auth/react'; +import { match } from 'ts-pattern'; + +import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; +import { formatDocumentsPath } from '@documenso/lib/utils/teams'; +import { trpc as trpcClient } from '@documenso/trpc/client'; +import { Button } from '@documenso/ui/primitives/button'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type DocumentPageViewButtonProps = { + document: Document & { + user: Pick; + recipients: Recipient[]; + team: Pick | null; + }; + team?: Pick; +}; + +export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps) => { + const { data: session } = useSession(); + const { toast } = useToast(); + const { _ } = useLingui(); + + if (!session) { + return null; + } + + const recipient = document.recipients.find((recipient) => recipient.email === session.user.email); + + const isRecipient = !!recipient; + const isPending = document.status === DocumentStatus.PENDING; + const isComplete = document.status === DocumentStatus.COMPLETED; + const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; + const role = recipient?.role; + + const documentsPath = formatDocumentsPath(document.team?.url); + + const onDownloadClick = async () => { + try { + const documentWithData = await trpcClient.document.getDocumentById.query( + { + documentId: document.id, + }, + { + context: { + teamId: document.team?.id?.toString(), + }, + }, + ); + + const documentData = documentWithData?.documentData; + + if (!documentData) { + throw new Error('No document available'); + } + + await downloadPDF({ documentData, fileName: documentWithData.title }); + } catch (err) { + toast({ + title: _(msg`Something went wrong`), + description: _(msg`An error occurred while downloading your document.`), + variant: 'destructive', + }); + } + }; + + return match({ + isRecipient, + isPending, + isComplete, + isSigned, + }) + .with({ isRecipient: true, isPending: true, isSigned: false }, () => ( + + )) + .with({ isComplete: false }, () => ( + + )) + .with({ isComplete: true }, () => ( + + )) + .otherwise(() => null); +}; diff --git a/apps/remix/app/_[id]/document-page-view-dropdown.tsx b/apps/remix/app/_[id]/document-page-view-dropdown.tsx new file mode 100644 index 000000000..2b74460f9 --- /dev/null +++ b/apps/remix/app/_[id]/document-page-view-dropdown.tsx @@ -0,0 +1,207 @@ +import { useState } from 'react'; + +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { DocumentStatus } from '@prisma/client'; +import type { Document, Recipient, Team, TeamEmail, User } from '@prisma/client'; +import { + Copy, + Download, + Edit, + Loader, + MoreHorizontal, + ScrollTextIcon, + Share, + Trash2, +} from 'lucide-react'; +import { useSession } from 'next-auth/react'; + +import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; +import { formatDocumentsPath } from '@documenso/lib/utils/teams'; +import { trpc as trpcClient } from '@documenso/trpc/client'; +import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from '@documenso/ui/primitives/dropdown-menu'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { DocumentRecipientLinkCopyDialog } from '~/components/document/document-recipient-link-copy-dialog'; + +import { DeleteDocumentDialog } from '../delete-document-dialog'; +import { DuplicateDocumentDialog } from '../duplicate-document-dialog'; +import { ResendDocumentActionItem } from '../resend-document-dialog'; + +export type DocumentPageViewDropdownProps = { + document: Document & { + user: Pick; + recipients: Recipient[]; + team: Pick | null; + }; + team?: Pick & { teamEmail: TeamEmail | null }; +}; + +export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDropdownProps) => { + const { data: session } = useSession(); + const { toast } = useToast(); + const { _ } = useLingui(); + + const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false); + + if (!session) { + return null; + } + + const recipient = document.recipients.find((recipient) => recipient.email === session.user.email); + + const isOwner = document.user.id === session.user.id; + const isDraft = document.status === DocumentStatus.DRAFT; + const isPending = document.status === DocumentStatus.PENDING; + const isDeleted = document.deletedAt !== null; + const isComplete = document.status === DocumentStatus.COMPLETED; + const isCurrentTeamDocument = team && document.team?.url === team.url; + const canManageDocument = Boolean(isOwner || isCurrentTeamDocument); + + const documentsPath = formatDocumentsPath(team?.url); + + const onDownloadClick = async () => { + try { + const documentWithData = await trpcClient.document.getDocumentById.query( + { + documentId: document.id, + }, + { + context: { + teamId: team?.id?.toString(), + }, + }, + ); + + const documentData = documentWithData?.documentData; + + if (!documentData) { + return; + } + + await downloadPDF({ documentData, fileName: document.title }); + } catch (err) { + toast({ + title: _(msg`Something went wrong`), + description: _(msg`An error occurred while downloading your document.`), + variant: 'destructive', + }); + } + }; + + const nonSignedRecipients = document.recipients.filter((item) => item.signingStatus !== 'SIGNED'); + + return ( + + + + + + + + Action + + + {(isOwner || isCurrentTeamDocument) && !isComplete && ( + + + + Edit + + + )} + + {isComplete && ( + + + Download + + )} + + + + + Audit Log + + + + setDuplicateDialogOpen(true)}> + + Duplicate + + + setDeleteDialogOpen(true)} + disabled={Boolean(!canManageDocument && team?.teamEmail) || isDeleted} + > + + Delete + + + + Share + + + {canManageDocument && ( + e.preventDefault()} + > + + Signing Links + + } + /> + )} + + + + ( + e.preventDefault()}> +
+ {loading ? : } + Share Signing Card +
+
+ )} + /> +
+ + + + {isDuplicateDialogOpen && ( + + )} +
+ ); +}; diff --git a/apps/remix/app/_[id]/document-page-view-information.tsx b/apps/remix/app/_[id]/document-page-view-information.tsx new file mode 100644 index 000000000..5d98a89d0 --- /dev/null +++ b/apps/remix/app/_[id]/document-page-view-information.tsx @@ -0,0 +1,70 @@ +'use client'; + +import { useMemo } from 'react'; + +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import type { Document, Recipient, User } from '@prisma/client'; +import { DateTime } from 'luxon'; + +import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted'; + +export type DocumentPageViewInformationProps = { + userId: number; + document: Document & { + user: Pick; + recipients: Recipient[]; + }; +}; + +export const DocumentPageViewInformation = ({ + document, + userId, +}: DocumentPageViewInformationProps) => { + const isMounted = useIsMounted(); + + const { _, i18n } = useLingui(); + + const documentInformation = useMemo(() => { + return [ + { + description: msg`Uploaded by`, + value: + userId === document.userId ? _(msg`You`) : (document.user.name ?? document.user.email), + }, + { + description: msg`Created`, + value: DateTime.fromJSDate(document.createdAt) + .setLocale(i18n.locales?.[0] || i18n.locale) + .toFormat('MMMM d, yyyy'), + }, + { + description: msg`Last modified`, + value: DateTime.fromJSDate(document.updatedAt) + .setLocale(i18n.locales?.[0] || i18n.locale) + .toRelative(), + }, + ]; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isMounted, document, userId]); + + return ( +
+

+ Information +

+ +
    + {documentInformation.map((item, i) => ( +
  • + {_(item.description)} + {item.value} +
  • + ))} +
+
+ ); +}; diff --git a/apps/remix/app/_[id]/document-page-view-recent-activity.tsx b/apps/remix/app/_[id]/document-page-view-recent-activity.tsx new file mode 100644 index 000000000..c6e0787bb --- /dev/null +++ b/apps/remix/app/_[id]/document-page-view-recent-activity.tsx @@ -0,0 +1,166 @@ +'use client'; + +import { useMemo } from 'react'; + +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { AlertTriangle, CheckCheckIcon, CheckIcon, Loader, MailOpen } from 'lucide-react'; +import { DateTime } from 'luxon'; +import { match } from 'ts-pattern'; + +import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; +import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs'; +import { trpc } from '@documenso/trpc/react'; +import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; +import { cn } from '@documenso/ui/lib/utils'; + +export type DocumentPageViewRecentActivityProps = { + documentId: number; + userId: number; +}; + +export const DocumentPageViewRecentActivity = ({ + documentId, + userId, +}: DocumentPageViewRecentActivityProps) => { + const { _ } = useLingui(); + + const { + data, + isLoading, + isLoadingError, + refetch, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + } = trpc.document.findDocumentAuditLogs.useInfiniteQuery( + { + documentId, + filterForRecentActivity: true, + orderByColumn: 'createdAt', + orderByDirection: 'asc', + perPage: 10, + }, + { + getNextPageParam: (lastPage) => lastPage.nextCursor, + }, + ); + + const documentAuditLogs = useMemo(() => (data?.pages ?? []).flatMap((page) => page.data), [data]); + + return ( +
+
+

+ Recent activity +

+ + {/* Can add dropdown menu here for additional options. */} +
+ + {isLoading && ( +
+ +
+ )} + + {isLoadingError && ( +
+

+ Unable to load document history +

+ +
+ )} + + + {data && ( +
    + {hasNextPage && ( +
  • +
    +
    +
    + +
    +
    +
    + + +
  • + )} + + {documentAuditLogs.length === 0 && ( +
    +

    + No recent activity +

    +
    + )} + + {documentAuditLogs.map((auditLog, auditLogIndex) => ( +
  • +
    +
    +
    + +
    + {match(auditLog.type) + .with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED, () => ( +
    +
    + )) + .with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, () => ( +
    +
    + )) + .with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED, () => ( +
    +
    + )) + .with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED, () => ( +
    +
    + )) + .otherwise(() => ( +
    + ))} +
    + +

    + {formatDocumentAuditLogAction(_, auditLog, userId).description} +

    + + +
  • + ))} +
+ )} +
+
+ ); +}; diff --git a/apps/remix/app/_[id]/document-page-view-recipients.tsx b/apps/remix/app/_[id]/document-page-view-recipients.tsx new file mode 100644 index 000000000..4644e39d1 --- /dev/null +++ b/apps/remix/app/_[id]/document-page-view-recipients.tsx @@ -0,0 +1,170 @@ +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client'; +import type { Document, Recipient } from '@prisma/client'; +import { + AlertTriangle, + CheckIcon, + Clock, + MailIcon, + MailOpenIcon, + PenIcon, + PlusIcon, +} from 'lucide-react'; +import { match } from 'ts-pattern'; + +import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; +import { formatSigningLink } from '@documenso/lib/utils/recipients'; +import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button'; +import { SignatureIcon } from '@documenso/ui/icons/signature'; +import { AvatarWithText } from '@documenso/ui/primitives/avatar'; +import { Badge } from '@documenso/ui/primitives/badge'; +import { PopoverHover } from '@documenso/ui/primitives/popover'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type DocumentPageViewRecipientsProps = { + document: Document & { + recipients: Recipient[]; + }; + documentRootPath: string; +}; + +export const DocumentPageViewRecipients = ({ + document, + documentRootPath, +}: DocumentPageViewRecipientsProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const recipients = document.recipients; + + return ( +
+
+

+ Recipients +

+ + {document.status !== DocumentStatus.COMPLETED && ( + + {recipients.length === 0 ? ( + + ) : ( + + )} + + )} +
+ +
    + {recipients.length === 0 && ( +
  • + No recipients +
  • + )} + + {recipients.map((recipient) => ( +
  • + {recipient.email}

    } + secondaryText={ +

    + {_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)} +

    + } + /> + +
    + {document.status !== DocumentStatus.DRAFT && + recipient.signingStatus === SigningStatus.SIGNED && ( + + {match(recipient.role) + .with(RecipientRole.APPROVER, () => ( + <> + + Approved + + )) + .with(RecipientRole.CC, () => + document.status === DocumentStatus.COMPLETED ? ( + <> + + Sent + + ) : ( + <> + + Ready + + ), + ) + + .with(RecipientRole.SIGNER, () => ( + <> + + Signed + + )) + .with(RecipientRole.VIEWER, () => ( + <> + + Viewed + + )) + .exhaustive()} + + )} + + {document.status !== DocumentStatus.DRAFT && + recipient.signingStatus === SigningStatus.NOT_SIGNED && ( + + + Pending + + )} + + {document.status !== DocumentStatus.DRAFT && + recipient.signingStatus === SigningStatus.REJECTED && ( + + + Rejected + + } + > +

    + Reason for rejection: +

    + +

    + {recipient.rejectionReason} +

    +
    + )} + + {document.status === DocumentStatus.PENDING && + recipient.signingStatus === SigningStatus.NOT_SIGNED && + recipient.role !== RecipientRole.CC && ( + { + toast({ + title: _(msg`Copied to clipboard`), + description: _(msg`The signing link has been copied to your clipboard.`), + }); + }} + /> + )} +
    +
  • + ))} +
+
+ ); +}; diff --git a/apps/remix/app/_[id]/document-page-view.tsx b/apps/remix/app/_[id]/document-page-view.tsx new file mode 100644 index 000000000..97e19fbcc --- /dev/null +++ b/apps/remix/app/_[id]/document-page-view.tsx @@ -0,0 +1,258 @@ +import { Plural, Trans } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { DocumentStatus } from '@prisma/client'; +import type { Team, TeamEmail } from '@prisma/client'; +import { TeamMemberRole } from '@prisma/client'; +import { ChevronLeft, Clock9, Users2 } from 'lucide-react'; +import { match } from 'ts-pattern'; + +import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto'; +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; +import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag'; +import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document'; +import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; +import { DocumentVisibility } from '@documenso/lib/types/document-visibility'; +import { symmetricDecrypt } from '@documenso/lib/universal/crypto'; +import { formatDocumentsPath } from '@documenso/lib/utils/teams'; +import { Badge } from '@documenso/ui/primitives/badge'; +import { Button } from '@documenso/ui/primitives/button'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; +import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; + +import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip'; +import { DocumentHistorySheet } from '~/components/document/document-history-sheet'; +import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields'; +import { DocumentRecipientLinkCopyDialog } from '~/components/document/document-recipient-link-copy-dialog'; +import { + DocumentStatus as DocumentStatusComponent, + FRIENDLY_STATUS_MAP, +} from '~/components/formatter/document-status'; + +import { DocumentPageViewButton } from './document-page-view-button'; +import { DocumentPageViewDropdown } from './document-page-view-dropdown'; +import { DocumentPageViewInformation } from './document-page-view-information'; +import { DocumentPageViewRecentActivity } from './document-page-view-recent-activity'; +import { DocumentPageViewRecipients } from './document-page-view-recipients'; + +export type DocumentPageViewProps = { + documentId: number; + team?: Team & { teamEmail: TeamEmail | null } & { currentTeamMember: { role: TeamMemberRole } }; +}; + +export const DocumentPageView = async ({ documentId, team }: DocumentPageViewProps) => { + const { _ } = useLingui(); + + const documentRootPath = formatDocumentsPath(team?.url); + + const { user } = await getRequiredServerComponentSession(); + + const document = await getDocumentById({ + documentId, + userId: user.id, + teamId: team?.id, + }).catch(() => null); + + if (document?.teamId && !team?.url) { + redirect(documentRootPath); + } + + const documentVisibility = document?.visibility; + const currentTeamMemberRole = team?.currentTeamMember?.role; + const isRecipient = document?.recipients.find((recipient) => recipient.email === user.email); + let canAccessDocument = true; + + if (team && !isRecipient && document?.userId !== user.id) { + canAccessDocument = match([documentVisibility, currentTeamMemberRole]) + .with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true) + .with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true) + .with([DocumentVisibility.EVERYONE, TeamMemberRole.MEMBER], () => true) + .with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.ADMIN], () => true) + .with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.MANAGER], () => true) + .with([DocumentVisibility.ADMIN, TeamMemberRole.ADMIN], () => true) + .otherwise(() => false); + } + + const isDocumentHistoryEnabled = await getServerComponentFlag( + 'app_document_page_view_history_sheet', + ); + + if (!document || !document.documentData || (team && !canAccessDocument)) { + redirect(documentRootPath); + } + + if (team && !canAccessDocument) { + redirect(documentRootPath); + } + + const { documentData, documentMeta } = document; + + if (documentMeta?.password) { + const key = DOCUMENSO_ENCRYPTION_KEY; + + if (!key) { + throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY'); + } + + const securePassword = Buffer.from( + symmetricDecrypt({ + key, + data: documentMeta.password, + }), + ).toString('utf-8'); + + documentMeta.password = securePassword; + } + + const [recipients, fields] = await Promise.all([ + getRecipientsForDocument({ + documentId, + teamId: team?.id, + userId: user.id, + }), + getFieldsForDocument({ + documentId, + userId: user.id, + teamId: team?.id, + }), + ]); + + const documentWithRecipients = { + ...document, + recipients, + }; + + return ( +
+ {document.status === DocumentStatus.PENDING && ( + + )} + + + + Documents + + +
+
+

+ {document.title} +

+ +
+ + + {recipients.length > 0 && ( +
+ + + + + {recipients.length} Recipient(s) + + +
+ )} + + {document.deletedAt && ( + + Document deleted + + )} +
+
+ + {isDocumentHistoryEnabled && ( +
+ + + +
+ )} +
+ +
+ + + + + + + {document.status === DocumentStatus.PENDING && ( + + )} + +
+
+
+
+

+ {_(FRIENDLY_STATUS_MAP[document.status].labelExtended)} +

+ + +
+ +

+ {match(document.status) + .with(DocumentStatus.COMPLETED, () => ( + This document has been signed by all recipients + )) + .with(DocumentStatus.DRAFT, () => ( + This document is currently a draft and has not been sent + )) + .with(DocumentStatus.PENDING, () => { + const pendingRecipients = recipients.filter( + (recipient) => recipient.signingStatus === 'NOT_SIGNED', + ); + + return ( + + ); + }) + .exhaustive()} +

+ +
+ +
+
+ + {/* Document information section. */} + + + {/* Recipients section. */} + + + {/* Recent activity section. */} + +
+
+
+
+ ); +}; diff --git a/apps/remix/app/_[id]/edit-document.tsx b/apps/remix/app/_[id]/edit-document.tsx new file mode 100644 index 000000000..1e8f2cca3 --- /dev/null +++ b/apps/remix/app/_[id]/edit-document.tsx @@ -0,0 +1,421 @@ +import { useEffect, useState } from 'react'; + +import { msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { DocumentDistributionMethod, DocumentStatus } from '@prisma/client'; + +import { isValidLanguageCode } from '@documenso/lib/constants/i18n'; +import { + DO_NOT_INVALIDATE_QUERY_ON_MUTATION, + SKIP_QUERY_BATCH_META, +} from '@documenso/lib/constants/trpc'; +import type { TDocument } from '@documenso/lib/types/document'; +import { trpc } from '@documenso/trpc/react'; +import { cn } from '@documenso/ui/lib/utils'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; +import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields'; +import type { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types'; +import { AddSettingsFormPartial } from '@documenso/ui/primitives/document-flow/add-settings'; +import type { TAddSettingsFormSchema } from '@documenso/ui/primitives/document-flow/add-settings.types'; +import { AddSignersFormPartial } from '@documenso/ui/primitives/document-flow/add-signers'; +import type { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types'; +import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/add-subject'; +import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types'; +import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root'; +import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; +import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; +import { Stepper } from '@documenso/ui/primitives/stepper'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useOptionalCurrentTeam } from '~/providers/team'; + +export type EditDocumentFormProps = { + className?: string; + initialDocument: TDocument; + documentRootPath: string; + isDocumentEnterprise: boolean; +}; + +type EditDocumentStep = 'settings' | 'signers' | 'fields' | 'subject'; +const EditDocumentSteps: EditDocumentStep[] = ['settings', 'signers', 'fields', 'subject']; + +export const EditDocumentForm = ({ + className, + initialDocument, + documentRootPath, + isDocumentEnterprise, +}: EditDocumentFormProps) => { + const { toast } = useToast(); + const { _ } = useLingui(); + + const router = useRouter(); + const searchParams = useSearchParams(); + const team = useOptionalCurrentTeam(); + + const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false); + + const utils = trpc.useUtils(); + + const { data: document, refetch: refetchDocument } = + trpc.document.getDocumentWithDetailsById.useQuery( + { + documentId: initialDocument.id, + }, + { + initialData: initialDocument, + ...SKIP_QUERY_BATCH_META, + }, + ); + + const { recipients, fields } = document; + + const { mutateAsync: updateDocument } = trpc.document.setSettingsForDocument.useMutation({ + ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, + onSuccess: (newData) => { + utils.document.getDocumentWithDetailsById.setData( + { + documentId: initialDocument.id, + }, + (oldData) => ({ ...(oldData || initialDocument), ...newData }), + ); + }, + }); + + const { mutateAsync: setSigningOrderForDocument } = + trpc.document.setSigningOrderForDocument.useMutation({ + ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, + onSuccess: (newData) => { + utils.document.getDocumentWithDetailsById.setData( + { + documentId: initialDocument.id, + }, + (oldData) => ({ ...(oldData || initialDocument), ...newData, id: Number(newData.id) }), + ); + }, + }); + + const { mutateAsync: addFields } = trpc.field.addFields.useMutation({ + ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, + onSuccess: ({ fields: newFields }) => { + utils.document.getDocumentWithDetailsById.setData( + { + documentId: initialDocument.id, + }, + (oldData) => ({ ...(oldData || initialDocument), fields: newFields }), + ); + }, + }); + + const { mutateAsync: setRecipients } = trpc.recipient.setDocumentRecipients.useMutation({ + ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, + onSuccess: ({ recipients: newRecipients }) => { + utils.document.getDocumentWithDetailsById.setData( + { + documentId: initialDocument.id, + }, + (oldData) => ({ ...(oldData || initialDocument), recipients: newRecipients }), + ); + }, + }); + + const { mutateAsync: sendDocument } = trpc.document.sendDocument.useMutation({ + ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, + onSuccess: (newData) => { + utils.document.getDocumentWithDetailsById.setData( + { + documentId: initialDocument.id, + }, + (oldData) => ({ ...(oldData || initialDocument), ...newData }), + ); + }, + }); + + const { mutateAsync: setPasswordForDocument } = + trpc.document.setPasswordForDocument.useMutation(); + + const documentFlow: Record = { + settings: { + title: msg`General`, + description: msg`Configure general settings for the document.`, + stepIndex: 1, + }, + signers: { + title: msg`Add Signers`, + description: msg`Add the people who will sign the document.`, + stepIndex: 2, + }, + fields: { + title: msg`Add Fields`, + description: msg`Add all relevant fields for each recipient.`, + stepIndex: 3, + }, + subject: { + title: msg`Distribute Document`, + description: msg`Choose how the document will reach recipients`, + stepIndex: 4, + }, + }; + + const [step, setStep] = useState(() => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const searchParamStep = searchParams?.get('step') as EditDocumentStep | undefined; + + let initialStep: EditDocumentStep = 'settings'; + + if ( + searchParamStep && + documentFlow[searchParamStep] !== undefined && + !(recipients.length === 0 && (searchParamStep === 'subject' || searchParamStep === 'fields')) + ) { + initialStep = searchParamStep; + } + + return initialStep; + }); + + const onAddSettingsFormSubmit = async (data: TAddSettingsFormSchema) => { + try { + const { timezone, dateFormat, redirectUrl, language } = data.meta; + + await updateDocument({ + documentId: document.id, + data: { + title: data.title, + externalId: data.externalId || null, + visibility: data.visibility, + globalAccessAuth: data.globalAccessAuth ?? null, + globalActionAuth: data.globalActionAuth ?? null, + }, + meta: { + timezone, + dateFormat, + redirectUrl, + language: isValidLanguageCode(language) ? language : undefined, + }, + }); + + // Router refresh is here to clear the router cache for when navigating to /documents. + router.refresh(); + + setStep('signers'); + } catch (err) { + console.error(err); + + toast({ + title: _(msg`Error`), + description: _(msg`An error occurred while updating the document settings.`), + variant: 'destructive', + }); + } + }; + + const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => { + try { + await Promise.all([ + setSigningOrderForDocument({ + documentId: document.id, + signingOrder: data.signingOrder, + }), + + setRecipients({ + documentId: document.id, + recipients: data.signers.map((signer) => ({ + ...signer, + // Explicitly set to null to indicate we want to remove auth if required. + actionAuth: signer.actionAuth || null, + })), + }), + ]); + + // Router refresh is here to clear the router cache for when navigating to /documents. + router.refresh(); + + setStep('fields'); + } catch (err) { + console.error(err); + + toast({ + title: _(msg`Error`), + description: _(msg`An error occurred while adding signers.`), + variant: 'destructive', + }); + } + }; + + const onAddFieldsFormSubmit = async (data: TAddFieldsFormSchema) => { + try { + await addFields({ + documentId: document.id, + fields: data.fields, + }); + + await updateDocument({ + documentId: document.id, + + meta: { + typedSignatureEnabled: data.typedSignatureEnabled, + }, + }); + + // Clear all field data from localStorage + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith('field_')) { + localStorage.removeItem(key); + } + } + + // Router refresh is here to clear the router cache for when navigating to /documents. + router.refresh(); + + setStep('subject'); + } catch (err) { + console.error(err); + + toast({ + title: _(msg`Error`), + description: _(msg`An error occurred while adding the fields.`), + variant: 'destructive', + }); + } + }; + + const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => { + const { subject, message, distributionMethod, emailSettings } = data.meta; + + try { + await sendDocument({ + documentId: document.id, + meta: { + subject, + message, + distributionMethod, + emailSettings, + }, + }); + + if (distributionMethod === DocumentDistributionMethod.EMAIL) { + toast({ + title: _(msg`Document sent`), + description: _(msg`Your document has been sent successfully.`), + duration: 5000, + }); + + router.push(documentRootPath); + return; + } + + if (document.status === DocumentStatus.DRAFT) { + toast({ + title: _(msg`Links Generated`), + description: _(msg`Signing links have been generated for this document.`), + duration: 5000, + }); + } else { + router.push(`${documentRootPath}/${document.id}`); + } + } catch (err) { + console.error(err); + + toast({ + title: _(msg`Error`), + description: _(msg`An error occurred while sending the document.`), + variant: 'destructive', + }); + } + }; + + const onPasswordSubmit = async (password: string) => { + await setPasswordForDocument({ + documentId: document.id, + password, + }); + }; + + const currentDocumentFlow = documentFlow[step]; + + /** + * Refresh the data in the background when steps change. + */ + useEffect(() => { + void refetchDocument(); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [step]); + + return ( +
+ + + setIsDocumentPdfLoaded(true)} + /> + + + +
+ e.preventDefault()} + > + setStep(EditDocumentSteps[step - 1])} + > + + + + + + + + + +
+
+ ); +}; diff --git a/apps/remix/app/_[id]/edit/document-edit-page-view.tsx b/apps/remix/app/_[id]/edit/document-edit-page-view.tsx new file mode 100644 index 000000000..246e063d8 --- /dev/null +++ b/apps/remix/app/_[id]/edit/document-edit-page-view.tsx @@ -0,0 +1,139 @@ +import { Plural, Trans } from '@lingui/macro'; +import type { Team } from '@prisma/client'; +import { TeamMemberRole } from '@prisma/client'; +import { DocumentStatus as InternalDocumentStatus } from '@prisma/client'; +import { ChevronLeft, Users2 } from 'lucide-react'; +import { match } from 'ts-pattern'; + +import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise'; +import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto'; +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id'; +import { DocumentVisibility } from '@documenso/lib/types/document-visibility'; +import { symmetricDecrypt } from '@documenso/lib/universal/crypto'; +import { formatDocumentsPath } from '@documenso/lib/utils/teams'; + +import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip'; +import { DocumentStatus } from '~/components/formatter/document-status'; + +import { EditDocumentForm } from '../edit-document'; + +export type DocumentEditPageViewProps = { + documentId: number; + team?: Team & { currentTeamMember: { role: TeamMemberRole } }; +}; + +export const DocumentEditPageView = async ({ documentId, team }: DocumentEditPageViewProps) => { + const documentRootPath = formatDocumentsPath(team?.url); + + if (!documentId || Number.isNaN(documentId)) { + redirect(documentRootPath); + } + + const { user } = await getRequiredServerComponentSession(); + + const document = await getDocumentWithDetailsById({ + documentId, + userId: user.id, + teamId: team?.id, + }).catch(() => null); + + if (document?.teamId && !team?.url) { + redirect(documentRootPath); + } + + const documentVisibility = document?.visibility; + const currentTeamMemberRole = team?.currentTeamMember?.role; + const isRecipient = document?.recipients.find((recipient) => recipient.email === user.email); + let canAccessDocument = true; + + if (!isRecipient && document?.userId !== user.id) { + canAccessDocument = match([documentVisibility, currentTeamMemberRole]) + .with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true) + .with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true) + .with([DocumentVisibility.EVERYONE, TeamMemberRole.MEMBER], () => true) + .with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.ADMIN], () => true) + .with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.MANAGER], () => true) + .with([DocumentVisibility.ADMIN, TeamMemberRole.ADMIN], () => true) + .otherwise(() => false); + } + + if (!document) { + redirect(documentRootPath); + } + + if (team && !canAccessDocument) { + redirect(documentRootPath); + } + + if (document.status === InternalDocumentStatus.COMPLETED) { + redirect(`${documentRootPath}/${documentId}`); + } + + const { documentMeta, recipients } = document; + + if (documentMeta?.password) { + const key = DOCUMENSO_ENCRYPTION_KEY; + + if (!key) { + throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY'); + } + + const securePassword = Buffer.from( + symmetricDecrypt({ + key, + data: documentMeta.password, + }), + ).toString('utf-8'); + + documentMeta.password = securePassword; + } + + const isDocumentEnterprise = await isUserEnterprise({ + userId: user.id, + teamId: team?.id, + }); + + return ( +
+ + + Documents + + +

+ {document.title} +

+ +
+ + + {recipients.length > 0 && ( +
+ + + + + + + +
+ )} +
+ + +
+ ); +}; diff --git a/apps/remix/app/_[id]/edit/index.tsx b/apps/remix/app/_[id]/edit/index.tsx new file mode 100644 index 000000000..38531fbb9 --- /dev/null +++ b/apps/remix/app/_[id]/edit/index.tsx @@ -0,0 +1,21 @@ +import { redirect, useParams } from 'react-router'; + +import { formatDocumentsPath } from '@documenso/lib/utils/teams'; + +import { useOptionalCurrentTeam } from '~/providers/team'; + +import { DocumentEditPageView } from './document-edit-page-view'; + +export default function DocumentEditPage() { + const { id: documentId } = useParams(); + + const team = useOptionalCurrentTeam(); + + const documentRootPath = formatDocumentsPath(team?.url); + + if (!documentId || Number.isNaN(documentId)) { + redirect(documentRootPath); + } + + return ; +} diff --git a/apps/remix/app/_[id]/index.tsx b/apps/remix/app/_[id]/index.tsx new file mode 100644 index 000000000..ac60a5d91 --- /dev/null +++ b/apps/remix/app/_[id]/index.tsx @@ -0,0 +1,21 @@ +import { redirect, useParams } from 'react-router'; + +import { formatDocumentsPath } from '@documenso/lib/utils/teams'; + +import { useOptionalCurrentTeam } from '~/providers/team'; + +import { DocumentPageView } from './document-page-view'; + +export default function DocumentPage() { + const { id: documentId } = useParams(); + + const team = useOptionalCurrentTeam(); + + const documentRootPath = formatDocumentsPath(team?.url); + + if (!documentId || Number.isNaN(documentId)) { + redirect(documentRootPath); + } + + return ; +} diff --git a/apps/remix/app/_[id]/loading.tsx b/apps/remix/app/_[id]/loading.tsx new file mode 100644 index 000000000..0781bbd8a --- /dev/null +++ b/apps/remix/app/_[id]/loading.tsx @@ -0,0 +1,38 @@ +import { Trans } from '@lingui/macro'; +import { ChevronLeft, Loader } from 'lucide-react'; +import { Link } from 'react-router'; + +import { Skeleton } from '@documenso/ui/primitives/skeleton'; + +export default function Loading() { + return ( +
+ + + Documents + + +

+ Loading Document... +

+ +
+ +
+ +
+
+
+ + +

+ Loading document... +

+
+
+ +
+
+
+ ); +} diff --git a/apps/remix/app/_[id]/logs/document-logs-data-table.tsx b/apps/remix/app/_[id]/logs/document-logs-data-table.tsx new file mode 100644 index 000000000..68dbfbea3 --- /dev/null +++ b/apps/remix/app/_[id]/logs/document-logs-data-table.tsx @@ -0,0 +1,159 @@ +import { useMemo } from 'react'; + +import { msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { DateTime } from 'luxon'; +import type { DateTimeFormatOptions } from 'luxon'; +import { UAParser } from 'ua-parser-js'; + +import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params'; +import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs'; +import { trpc } from '@documenso/trpc/react'; +import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table'; +import { DataTable } from '@documenso/ui/primitives/data-table'; +import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; +import { Skeleton } from '@documenso/ui/primitives/skeleton'; +import { TableCell } from '@documenso/ui/primitives/table'; + +export type DocumentLogsDataTableProps = { + documentId: number; +}; + +const dateFormat: DateTimeFormatOptions = { + ...DateTime.DATETIME_SHORT, + hourCycle: 'h12', +}; + +export const DocumentLogsDataTable = ({ documentId }: DocumentLogsDataTableProps) => { + const { _, i18n } = useLingui(); + + const searchParams = useSearchParams(); + const updateSearchParams = useUpdateSearchParams(); + + const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? [])); + + const { data, isLoading, isLoadingError } = trpc.document.findDocumentAuditLogs.useQuery( + { + documentId, + page: parsedSearchParams.page, + perPage: parsedSearchParams.perPage, + }, + { + placeholderData: (previousData) => previousData, + }, + ); + + const onPaginationChange = (page: number, perPage: number) => { + updateSearchParams({ + page, + perPage, + }); + }; + + const results = data ?? { + data: [], + perPage: 10, + currentPage: 1, + totalPages: 1, + }; + + const columns = useMemo(() => { + const parser = new UAParser(); + + return [ + { + header: _(msg`Time`), + accessorKey: 'createdAt', + cell: ({ row }) => i18n.date(row.original.createdAt, dateFormat), + }, + { + header: _(msg`User`), + accessorKey: 'name', + cell: ({ row }) => + row.original.name || row.original.email ? ( +
+ {row.original.name && ( +

+ {row.original.name} +

+ )} + + {row.original.email && ( +

+ {row.original.email} +

+ )} +
+ ) : ( +

N/A

+ ), + }, + { + header: _(msg`Action`), + accessorKey: 'type', + cell: ({ row }) => {formatDocumentAuditLogAction(_, row.original).description}, + }, + { + header: 'IP Address', + accessorKey: 'ipAddress', + }, + { + header: 'Browser', + cell: ({ row }) => { + if (!row.original.userAgent) { + return 'N/A'; + } + + parser.setUA(row.original.userAgent); + + const result = parser.getResult(); + + return result.browser.name ?? 'N/A'; + }, + }, + ] satisfies DataTableColumnDef<(typeof results)['data'][number]>[]; + }, []); + + return ( + + + + + +
+ + +
+
+ + + + + + + + + + + ), + }} + > + {(table) => } +
+ ); +}; diff --git a/apps/remix/app/_[id]/logs/document-logs-page-view.tsx b/apps/remix/app/_[id]/logs/document-logs-page-view.tsx new file mode 100644 index 000000000..2ea11c039 --- /dev/null +++ b/apps/remix/app/_[id]/logs/document-logs-page-view.tsx @@ -0,0 +1,168 @@ +import type { MessageDescriptor } from '@lingui/core'; +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import type { Recipient } from '@prisma/client'; +import { ChevronLeft } from 'lucide-react'; +import { DateTime } from 'luxon'; + +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; +import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; +import { formatDocumentsPath } from '@documenso/lib/utils/teams'; +import { Card } from '@documenso/ui/primitives/card'; + +import { + DocumentStatus as DocumentStatusComponent, + FRIENDLY_STATUS_MAP, +} from '~/components/formatter/document-status'; +import { useOptionalCurrentTeam } from '~/providers/team'; + +import { DocumentLogsDataTable } from './document-logs-data-table'; +import { DownloadAuditLogButton } from './download-audit-log-button'; +import { DownloadCertificateButton } from './download-certificate-button'; + +export type DocumentLogsPageViewProps = { + documentId: number; +}; + +export const DocumentLogsPageView = async ({ documentId }: DocumentLogsPageViewProps) => { + const { _, i18n } = useLingui(); + + const team = useOptionalCurrentTeam(); + + const documentRootPath = formatDocumentsPath(team?.url); + + const { user } = await getRequiredServerComponentSession(); + + const [document, recipients] = await Promise.all([ + getDocumentById({ + documentId, + userId: user.id, + teamId: team?.id, + }).catch(() => null), + getRecipientsForDocument({ + documentId, + userId: user.id, + teamId: team?.id, + }), + ]); + + if (!document || !document.documentData) { + redirect(documentRootPath); + } + + const documentInformation: { description: MessageDescriptor; value: string }[] = [ + { + description: msg`Document title`, + value: document.title, + }, + { + description: msg`Document ID`, + value: document.id.toString(), + }, + { + description: msg`Document status`, + value: _(FRIENDLY_STATUS_MAP[document.status].label), + }, + { + description: msg`Created by`, + value: document.user.name + ? `${document.user.name} (${document.user.email})` + : document.user.email, + }, + { + description: msg`Date created`, + value: DateTime.fromJSDate(document.createdAt) + .setLocale(i18n.locales?.[0] || i18n.locale) + .toLocaleString(DateTime.DATETIME_MED_WITH_SECONDS), + }, + { + description: msg`Last updated`, + value: DateTime.fromJSDate(document.updatedAt) + .setLocale(i18n.locales?.[0] || i18n.locale) + .toLocaleString(DateTime.DATETIME_MED_WITH_SECONDS), + }, + { + description: msg`Time zone`, + value: document.documentMeta?.timezone ?? 'N/A', + }, + ]; + + const formatRecipientText = (recipient: Recipient) => { + let text = recipient.email; + + if (recipient.name) { + text = `${recipient.name} (${recipient.email})`; + } + + return `[${recipient.role}] ${text}`; + }; + + return ( +
+ + + Document + + +
+
+

+ {document.title} +

+ +
+ +
+
+ +
+ + + +
+
+ +
+ + {documentInformation.map((info, i) => ( +
+

{_(info.description)}

+

{info.value}

+
+ ))} + +
+

Recipients

+
    + {recipients.map((recipient) => ( +
  • + {formatRecipientText(recipient)} +
  • + ))} +
+
+
+
+ +
+ +
+
+ ); +}; diff --git a/apps/remix/app/_[id]/logs/download-audit-log-button.tsx b/apps/remix/app/_[id]/logs/download-audit-log-button.tsx new file mode 100644 index 000000000..d6be5318c --- /dev/null +++ b/apps/remix/app/_[id]/logs/download-audit-log-button.tsx @@ -0,0 +1,80 @@ +'use client'; + +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { DownloadIcon } from 'lucide-react'; + +import { trpc } from '@documenso/trpc/react'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type DownloadAuditLogButtonProps = { + className?: string; + teamId?: number; + documentId: number; +}; + +export const DownloadAuditLogButton = ({ className, documentId }: DownloadAuditLogButtonProps) => { + const { toast } = useToast(); + const { _ } = useLingui(); + + const { mutateAsync: downloadAuditLogs, isPending } = + trpc.document.downloadAuditLogs.useMutation(); + + const onDownloadAuditLogsClick = async () => { + try { + const { url } = await downloadAuditLogs({ documentId }); + + const iframe = Object.assign(document.createElement('iframe'), { + src: url, + }); + + Object.assign(iframe.style, { + position: 'fixed', + top: '0', + left: '0', + width: '0', + height: '0', + }); + + const onLoaded = () => { + if (iframe.contentDocument?.readyState === 'complete') { + iframe.contentWindow?.print(); + + iframe.contentWindow?.addEventListener('afterprint', () => { + document.body.removeChild(iframe); + }); + } + }; + + // When the iframe has loaded, print the iframe and remove it from the dom + iframe.addEventListener('load', onLoaded); + + document.body.appendChild(iframe); + + onLoaded(); + } catch (error) { + console.error(error); + + toast({ + title: _(msg`Something went wrong`), + description: _( + msg`Sorry, we were unable to download the audit logs. Please try again later.`, + ), + variant: 'destructive', + }); + } + }; + + return ( + + ); +}; diff --git a/apps/remix/app/_[id]/logs/download-certificate-button.tsx b/apps/remix/app/_[id]/logs/download-certificate-button.tsx new file mode 100644 index 000000000..45c78f3b6 --- /dev/null +++ b/apps/remix/app/_[id]/logs/download-certificate-button.tsx @@ -0,0 +1,89 @@ +'use client'; + +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { DocumentStatus } from '@prisma/client'; +import { DownloadIcon } from 'lucide-react'; + +import { trpc } from '@documenso/trpc/react'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type DownloadCertificateButtonProps = { + className?: string; + documentId: number; + documentStatus: DocumentStatus; + teamId?: number; +}; + +export const DownloadCertificateButton = ({ + className, + documentId, + documentStatus, + teamId, +}: DownloadCertificateButtonProps) => { + const { toast } = useToast(); + const { _ } = useLingui(); + + const { mutateAsync: downloadCertificate, isPending } = + trpc.document.downloadCertificate.useMutation(); + + const onDownloadCertificatesClick = async () => { + try { + const { url } = await downloadCertificate({ documentId }); + + const iframe = Object.assign(document.createElement('iframe'), { + src: url, + }); + + Object.assign(iframe.style, { + position: 'fixed', + top: '0', + left: '0', + width: '0', + height: '0', + }); + + const onLoaded = () => { + if (iframe.contentDocument?.readyState === 'complete') { + iframe.contentWindow?.print(); + + iframe.contentWindow?.addEventListener('afterprint', () => { + document.body.removeChild(iframe); + }); + } + }; + + // When the iframe has loaded, print the iframe and remove it from the dom + iframe.addEventListener('load', onLoaded); + + document.body.appendChild(iframe); + + onLoaded(); + } catch (error) { + console.error(error); + + toast({ + title: _(msg`Something went wrong`), + description: _( + msg`Sorry, we were unable to download the certificate. Please try again later.`, + ), + variant: 'destructive', + }); + } + }; + + return ( + + ); +}; diff --git a/apps/remix/app/_[id]/logs/index.tsx b/apps/remix/app/_[id]/logs/index.tsx new file mode 100644 index 000000000..bf26f35f5 --- /dev/null +++ b/apps/remix/app/_[id]/logs/index.tsx @@ -0,0 +1,21 @@ +import { redirect, useParams } from 'react-router'; + +import { formatDocumentsPath } from '@documenso/lib/utils/teams'; + +import { useOptionalCurrentTeam } from '~/providers/team'; + +import { DocumentLogsPageView } from './document-logs-page-view'; + +export default function DocumentsLogsPage() { + const { id: documentId } = useParams(); + + const team = useOptionalCurrentTeam(); + + const documentRootPath = formatDocumentsPath(team?.url); + + if (!documentId || Number.isNaN(documentId)) { + redirect(documentRootPath); + } + + return ; +} diff --git a/apps/remix/app/app.css b/apps/remix/app/app.css new file mode 100644 index 000000000..790ce4abb --- /dev/null +++ b/apps/remix/app/app.css @@ -0,0 +1,8 @@ +@import '@documenso/ui/styles/theme.css'; + +@layer base { + :root { + --font-sans: 'Inter'; + --font-signature: 'Caveat'; + } +} diff --git a/apps/remix/app/components/(dashboard)/avatar/avatar-with-recipient.tsx b/apps/remix/app/components/(dashboard)/avatar/avatar-with-recipient.tsx new file mode 100644 index 000000000..23aeeb0ca --- /dev/null +++ b/apps/remix/app/components/(dashboard)/avatar/avatar-with-recipient.tsx @@ -0,0 +1,71 @@ +import { msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import type { Recipient } from '@prisma/client'; +import { DocumentStatus } from '@prisma/client'; + +import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard'; +import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; +import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; +import { cn } from '@documenso/ui/lib/utils'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { StackAvatar } from './stack-avatar'; + +export type AvatarWithRecipientProps = { + recipient: Recipient; + documentStatus: DocumentStatus; +}; + +export function AvatarWithRecipient({ recipient, documentStatus }: AvatarWithRecipientProps) { + const [, copy] = useCopyToClipboard(); + + const { _ } = useLingui(); + const { toast } = useToast(); + + const signingToken = documentStatus === DocumentStatus.PENDING ? recipient.token : null; + + const onRecipientClick = () => { + if (!signingToken) { + return; + } + + void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${signingToken}`).then(() => { + toast({ + title: _(msg`Copied to clipboard`), + description: _(msg`The signing link has been copied to your clipboard.`), + }); + }); + }; + + return ( +
+ + +
+

{recipient.email}

+

+ {_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)} +

+
+
+ ); +} diff --git a/apps/remix/app/components/(dashboard)/avatar/stack-avatar.tsx b/apps/remix/app/components/(dashboard)/avatar/stack-avatar.tsx new file mode 100644 index 000000000..beafbebd5 --- /dev/null +++ b/apps/remix/app/components/(dashboard)/avatar/stack-avatar.tsx @@ -0,0 +1,55 @@ +import { RecipientStatusType } from '@documenso/lib/client-only/recipient-type'; +import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar'; + +const ZIndexes: { [key: string]: string } = { + '10': 'z-10', + '20': 'z-20', + '30': 'z-30', + '40': 'z-40', + '50': 'z-50', +}; + +export type StackAvatarProps = { + first?: boolean; + zIndex?: string; + fallbackText?: string; + type: RecipientStatusType; +}; + +export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAvatarProps) => { + let classes = ''; + let zIndexClass = ''; + const firstClass = first ? '' : '-ml-3'; + + if (zIndex) { + zIndexClass = ZIndexes[zIndex] ?? ''; + } + + switch (type) { + case RecipientStatusType.UNSIGNED: + classes = 'bg-dawn-200 text-dawn-900'; + break; + case RecipientStatusType.OPENED: + classes = 'bg-yellow-200 text-yellow-700'; + break; + case RecipientStatusType.WAITING: + classes = 'bg-water text-water-700'; + break; + case RecipientStatusType.COMPLETED: + classes = 'bg-documenso-200 text-documenso-800'; + break; + case RecipientStatusType.REJECTED: + classes = 'bg-red-200 text-red-800'; + break; + default: + break; + } + + return ( + + {fallbackText} + + ); +}; diff --git a/apps/remix/app/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx b/apps/remix/app/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx new file mode 100644 index 000000000..8bdafc933 --- /dev/null +++ b/apps/remix/app/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx @@ -0,0 +1,166 @@ +import { useMemo } from 'react'; + +import { Trans } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { type DocumentStatus, type Recipient } from '@prisma/client'; + +import { RecipientStatusType, getRecipientType } from '@documenso/lib/client-only/recipient-type'; +import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; +import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; +import { PopoverHover } from '@documenso/ui/primitives/popover'; + +import { AvatarWithRecipient } from './avatar-with-recipient'; +import { StackAvatar } from './stack-avatar'; +import { StackAvatars } from './stack-avatars'; + +export type StackAvatarsWithTooltipProps = { + documentStatus: DocumentStatus; + recipients: Recipient[]; + position?: 'top' | 'bottom'; + children?: React.ReactNode; +}; + +export const StackAvatarsWithTooltip = ({ + documentStatus, + recipients, + position, + children, +}: StackAvatarsWithTooltipProps) => { + const { _ } = useLingui(); + + const waitingRecipients = recipients.filter( + (recipient) => getRecipientType(recipient) === RecipientStatusType.WAITING, + ); + + const openedRecipients = recipients.filter( + (recipient) => getRecipientType(recipient) === RecipientStatusType.OPENED, + ); + + const completedRecipients = recipients.filter( + (recipient) => getRecipientType(recipient) === RecipientStatusType.COMPLETED, + ); + + const uncompletedRecipients = recipients.filter( + (recipient) => getRecipientType(recipient) === RecipientStatusType.UNSIGNED, + ); + + const rejectedRecipients = recipients.filter( + (recipient) => getRecipientType(recipient) === RecipientStatusType.REJECTED, + ); + + const sortedRecipients = useMemo(() => { + const otherRecipients = recipients.filter( + (recipient) => getRecipientType(recipient) !== RecipientStatusType.REJECTED, + ); + + return [ + ...rejectedRecipients.sort((a, b) => a.id - b.id), + ...otherRecipients.sort((a, b) => { + return a.id - b.id; + }), + ]; + }, [recipients]); + + return ( + } + contentProps={{ + className: 'flex flex-col gap-y-5 py-2', + side: position, + }} + > + {completedRecipients.length > 0 && ( +
+

+ Completed +

+ {completedRecipients.map((recipient: Recipient) => ( +
+ +
+

{recipient.email}

+

+ {_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)} +

+
+
+ ))} +
+ )} + + {rejectedRecipients.length > 0 && ( +
+

+ Rejected +

+ {rejectedRecipients.map((recipient: Recipient) => ( +
+ +
+

{recipient.email}

+

+ {_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)} +

+
+
+ ))} +
+ )} + + {waitingRecipients.length > 0 && ( +
+

+ Waiting +

+ {waitingRecipients.map((recipient: Recipient) => ( + + ))} +
+ )} + + {openedRecipients.length > 0 && ( +
+

+ Opened +

+ {openedRecipients.map((recipient: Recipient) => ( + + ))} +
+ )} + + {uncompletedRecipients.length > 0 && ( +
+

+ Uncompleted +

+ {uncompletedRecipients.map((recipient: Recipient) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/apps/remix/app/components/(dashboard)/avatar/stack-avatars.tsx b/apps/remix/app/components/(dashboard)/avatar/stack-avatars.tsx new file mode 100644 index 000000000..ccfd45bf1 --- /dev/null +++ b/apps/remix/app/components/(dashboard)/avatar/stack-avatars.tsx @@ -0,0 +1,47 @@ +import React from 'react'; + +import type { Recipient } from '@prisma/client'; + +import { + getExtraRecipientsType, + getRecipientType, +} from '@documenso/lib/client-only/recipient-type'; +import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; + +import { StackAvatar } from './stack-avatar'; + +export function StackAvatars({ recipients }: { recipients: Recipient[] }) { + const renderStackAvatars = (recipients: Recipient[]) => { + const zIndex = 50; + const itemsToRender = recipients.slice(0, 5); + const remainingItems = recipients.length - itemsToRender.length; + + return itemsToRender.map((recipient: Recipient, index: number) => { + const first = index === 0; + + if (index === 4 && remainingItems > 0) { + return ( + + ); + } + + return ( + + ); + }); + }; + + return <>{renderStackAvatars(recipients)}; +} diff --git a/apps/remix/app/components/(dashboard)/common/command-menu.tsx b/apps/remix/app/components/(dashboard)/common/command-menu.tsx new file mode 100644 index 000000000..b0f97fd57 --- /dev/null +++ b/apps/remix/app/components/(dashboard)/common/command-menu.tsx @@ -0,0 +1,321 @@ +import { useCallback, useMemo, useState } from 'react'; + +import type { MessageDescriptor } from '@lingui/core'; +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { CheckIcon, Loader, Monitor, Moon, Sun } from 'lucide-react'; +import { useTheme } from 'next-themes'; +import { useHotkeys } from 'react-hotkeys-hook'; +import { useNavigate } from 'react-router'; + +import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n'; +import { + DOCUMENTS_PAGE_SHORTCUT, + SETTINGS_PAGE_SHORTCUT, + TEMPLATES_PAGE_SHORTCUT, +} from '@documenso/lib/constants/keyboard-shortcuts'; +import { + DO_NOT_INVALIDATE_QUERY_ON_MUTATION, + SKIP_QUERY_BATCH_META, +} from '@documenso/lib/constants/trpc'; +import { switchI18NLanguage } from '@documenso/lib/server-only/i18n/switch-i18n-language'; +import { dynamicActivate } from '@documenso/lib/utils/i18n'; +import { trpc as trpcReact } from '@documenso/trpc/react'; +import { cn } from '@documenso/ui/lib/utils'; +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandShortcut, +} from '@documenso/ui/primitives/command'; +import { THEMES_TYPE } from '@documenso/ui/primitives/constants'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +const DOCUMENTS_PAGES = [ + { + label: msg`All documents`, + path: '/documents?status=ALL', + shortcut: DOCUMENTS_PAGE_SHORTCUT.replace('+', ''), + }, + { label: msg`Draft documents`, path: '/documents?status=DRAFT' }, + { + label: msg`Completed documents`, + path: '/documents?status=COMPLETED', + }, + { label: msg`Pending documents`, path: '/documents?status=PENDING' }, + { label: msg`Inbox documents`, path: '/documents?status=INBOX' }, +]; + +const TEMPLATES_PAGES = [ + { + label: msg`All templates`, + path: '/templates', + shortcut: TEMPLATES_PAGE_SHORTCUT.replace('+', ''), + }, +]; + +const SETTINGS_PAGES = [ + { + label: msg`Settings`, + path: '/settings', + shortcut: SETTINGS_PAGE_SHORTCUT.replace('+', ''), + }, + { label: msg`Profile`, path: '/settings/profile' }, + { label: msg`Password`, path: '/settings/password' }, +]; + +export type CommandMenuProps = { + open?: boolean; + onOpenChange?: (_open: boolean) => void; +}; + +export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { + const { _ } = useLingui(); + const { setTheme } = useTheme(); + + const navigate = useNavigate(); + + const [isOpen, setIsOpen] = useState(() => open ?? false); + const [search, setSearch] = useState(''); + const [pages, setPages] = useState([]); + + const { data: searchDocumentsData, isLoading: isSearchingDocuments } = + trpcReact.document.searchDocuments.useQuery( + { + query: search, + }, + { + placeholderData: (previousData) => previousData, + // Do not batch this due to relatively long request time compared to + // other queries which are generally batched with this. + ...SKIP_QUERY_BATCH_META, + ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, + }, + ); + + const searchResults = useMemo(() => { + if (!searchDocumentsData) { + return []; + } + + return searchDocumentsData.map((document) => ({ + label: document.title, + path: document.path, + value: document.value, + })); + }, [searchDocumentsData]); + + const currentPage = pages[pages.length - 1]; + + const toggleOpen = () => { + setIsOpen((isOpen) => !isOpen); + onOpenChange?.(!isOpen); + + if (isOpen) { + setPages([]); + setSearch(''); + } + }; + + const setOpen = useCallback( + (open: boolean) => { + setIsOpen(open); + onOpenChange?.(open); + + if (!open) { + setPages([]); + setSearch(''); + } + }, + [onOpenChange], + ); + + const push = useCallback( + (path: string) => { + void navigate(path); + setOpen(false); + }, + [setOpen], + ); + + const addPage = (page: string) => { + setPages((pages) => [...pages, page]); + setSearch(''); + }; + + const goToSettings = useCallback(() => push(SETTINGS_PAGES[0].path), [push]); + const goToDocuments = useCallback(() => push(DOCUMENTS_PAGES[0].path), [push]); + const goToTemplates = useCallback(() => push(TEMPLATES_PAGES[0].path), [push]); + + useHotkeys(['ctrl+k', 'meta+k'], toggleOpen, { preventDefault: true }); + useHotkeys(SETTINGS_PAGE_SHORTCUT, goToSettings); + useHotkeys(DOCUMENTS_PAGE_SHORTCUT, goToDocuments); + useHotkeys(TEMPLATES_PAGE_SHORTCUT, goToTemplates); + + const handleKeyDown = (e: React.KeyboardEvent) => { + // Escape goes to previous page + // Backspace goes to previous page when search is empty + if (e.key === 'Escape' || (e.key === 'Backspace' && !search)) { + e.preventDefault(); + + if (currentPage === undefined) { + setOpen(false); + } + + setPages((pages) => pages.slice(0, -1)); + } + }; + + return ( + + + + + {isSearchingDocuments ? ( + +
+ + + +
+
+ ) : ( + + No results found. + + )} + {!currentPage && ( + <> + + + + + + + + + + + addPage('language')}> + Change language + + addPage('theme')}> + Change theme + + + {searchResults.length > 0 && ( + + + + )} + + )} + + {currentPage === 'theme' && } + {currentPage === 'language' && } +
+
+ ); +} + +const Commands = ({ + push, + pages, +}: { + push: (_path: string) => void; + pages: { label: MessageDescriptor | string; path: string; shortcut?: string; value?: string }[]; +}) => { + const { _ } = useLingui(); + + return pages.map((page, idx) => ( + push(page.path)} + > + {typeof page.label === 'string' ? page.label : _(page.label)} + {page.shortcut && {page.shortcut}} + + )); +}; + +const ThemeCommands = ({ setTheme }: { setTheme: (_theme: string) => void }) => { + const { _ } = useLingui(); + + const THEMES = useMemo( + () => [ + { label: msg`Light Mode`, theme: THEMES_TYPE.LIGHT, icon: Sun }, + { label: msg`Dark Mode`, theme: THEMES_TYPE.DARK, icon: Moon }, + { label: msg`System Theme`, theme: THEMES_TYPE.SYSTEM, icon: Monitor }, + ], + [], + ); + + return THEMES.map((theme) => ( + setTheme(theme.theme)} + className="-my-1 mx-2 rounded-lg first:mt-2 last:mb-2" + > + + {_(theme.label)} + + )); +}; + +const LanguageCommands = () => { + const { i18n, _ } = useLingui(); + const { toast } = useToast(); + + const [isLoading, setIsLoading] = useState(false); + + const setLanguage = async (lang: string) => { + if (isLoading || lang === i18n.locale) { + return; + } + + setIsLoading(true); + + try { + await dynamicActivate(i18n, lang); + await switchI18NLanguage(lang); + } catch (err) { + toast({ + title: _(msg`An unknown error occurred`), + variant: 'destructive', + description: _(msg`Unable to change the language at this time. Please try again later.`), + }); + } + + setIsLoading(false); + }; + + return Object.values(SUPPORTED_LANGUAGES).map((language) => ( + setLanguage(language.short)} + className="-my-1 mx-2 rounded-lg first:mt-2 last:mb-2" + > + + + {language.full} + + )); +}; diff --git a/apps/remix/app/components/(dashboard)/document-search/document-search.tsx b/apps/remix/app/components/(dashboard)/document-search/document-search.tsx new file mode 100644 index 000000000..4d1344c75 --- /dev/null +++ b/apps/remix/app/components/(dashboard)/document-search/document-search.tsx @@ -0,0 +1,46 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { useNavigate, useSearchParams } from 'react-router'; + +import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; +import { Input } from '@documenso/ui/primitives/input'; + +export const DocumentSearch = ({ initialValue = '' }: { initialValue?: string }) => { + const { _ } = useLingui(); + + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + + const [searchTerm, setSearchTerm] = useState(initialValue); + const debouncedSearchTerm = useDebouncedValue(searchTerm, 500); + + const handleSearch = useCallback( + (term: string) => { + const params = new URLSearchParams(searchParams?.toString() ?? ''); + if (term) { + params.set('search', term); + } else { + params.delete('search'); + } + + // Todo: Test + void navigate(`/documents?${params.toString()}`); + }, + [searchParams], + ); + + useEffect(() => { + handleSearch(searchTerm); + }, [debouncedSearchTerm]); + + return ( + setSearchTerm(e.target.value)} + /> + ); +}; diff --git a/apps/remix/app/components/(dashboard)/layout/banner.tsx b/apps/remix/app/components/(dashboard)/layout/banner.tsx new file mode 100644 index 000000000..95a0de3dd --- /dev/null +++ b/apps/remix/app/components/(dashboard)/layout/banner.tsx @@ -0,0 +1,29 @@ +import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings'; +import { SITE_SETTINGS_BANNER_ID } from '@documenso/lib/server-only/site-settings/schemas/banner'; + +export const Banner = async () => { + const banner = await getSiteSettings().then((settings) => + settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID), + ); + + return ( + <> + {banner && banner.enabled && ( +
+
+
+ +
+
+
+ )} + + ); +}; + +// Banner +// Custom Text +// Custom Text with Custom Icon diff --git a/apps/remix/app/components/(dashboard)/layout/desktop-nav.tsx b/apps/remix/app/components/(dashboard)/layout/desktop-nav.tsx new file mode 100644 index 000000000..6ef43fddb --- /dev/null +++ b/apps/remix/app/components/(dashboard)/layout/desktop-nav.tsx @@ -0,0 +1,90 @@ +import type { HTMLAttributes } from 'react'; +import { useEffect, useState } from 'react'; + +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { Search } from 'lucide-react'; +import { Link, useLocation, useParams } from 'react-router'; + +import { getRootHref } from '@documenso/lib/utils/params'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; + +const navigationLinks = [ + { + href: '/documents', + label: msg`Documents`, + }, + { + href: '/templates', + label: msg`Templates`, + }, +]; + +export type DesktopNavProps = HTMLAttributes & { + setIsCommandMenuOpen: (value: boolean) => void; +}; + +export const DesktopNav = ({ className, setIsCommandMenuOpen, ...props }: DesktopNavProps) => { + const { _ } = useLingui(); + + const { pathname } = useLocation(); + const params = useParams(); + + const [modifierKey, setModifierKey] = useState(() => 'Ctrl'); + + const rootHref = getRootHref(params, { returnEmptyRootString: true }); + + useEffect(() => { + const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown'; + const isMacOS = /Macintosh|Mac\s+OS\s+X/i.test(userAgent); + + setModifierKey(isMacOS ? '⌘' : 'Ctrl'); + }, []); + + return ( + + ); +}; diff --git a/apps/remix/app/components/(dashboard)/layout/header.tsx b/apps/remix/app/components/(dashboard)/layout/header.tsx new file mode 100644 index 000000000..7b97a87a3 --- /dev/null +++ b/apps/remix/app/components/(dashboard)/layout/header.tsx @@ -0,0 +1,96 @@ +import { type HTMLAttributes, useEffect, useState } from 'react'; + +import type { User } from '@prisma/client'; +import { MenuIcon, SearchIcon } from 'lucide-react'; +import { Link, useLocation, useParams } from 'react-router'; + +import type { TGetTeamsResponse } from '@documenso/lib/server-only/team/get-teams'; +import { getRootHref } from '@documenso/lib/utils/params'; +import { cn } from '@documenso/ui/lib/utils'; + +import { Logo } from '~/components/branding/logo'; + +import { CommandMenu } from '../common/command-menu'; +import { DesktopNav } from './desktop-nav'; +import { MenuSwitcher } from './menu-switcher'; +import { MobileNavigation } from './mobile-navigation'; + +export type HeaderProps = HTMLAttributes & { + user: User; + teams: TGetTeamsResponse; +}; + +export const Header = ({ className, user, teams, ...props }: HeaderProps) => { + const params = useParams(); + const { pathname } = useLocation(); // Todo: Test + + const [isCommandMenuOpen, setIsCommandMenuOpen] = useState(false); + const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false); + const [scrollY, setScrollY] = useState(0); + + useEffect(() => { + const onScroll = () => { + setScrollY(window.scrollY); + }; + + window.addEventListener('scroll', onScroll); + + return () => window.removeEventListener('scroll', onScroll); + }, []); + + const isPathTeamUrl = (teamUrl: string) => { + if (!pathname || !pathname.startsWith(`/t/`)) { + return false; + } + + return pathname.split('/')[2] === teamUrl; + }; + + const selectedTeam = teams?.find((team) => isPathTeamUrl(team.url)); + + return ( +
5 && 'border-b-border', + className, + )} + {...props} + > +
+ + + + + + +
+ +
+ +
+ + + + + + + +
+
+
+ ); +}; diff --git a/apps/remix/app/components/(dashboard)/layout/menu-switcher.tsx b/apps/remix/app/components/(dashboard)/layout/menu-switcher.tsx new file mode 100644 index 000000000..0ad12dc94 --- /dev/null +++ b/apps/remix/app/components/(dashboard)/layout/menu-switcher.tsx @@ -0,0 +1,288 @@ +import { useState } from 'react'; + +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import type { User } from '@prisma/client'; +import { motion } from 'framer-motion'; +import { CheckCircle2, ChevronsUpDown, Plus, Settings2 } from 'lucide-react'; +import { Link, useLocation } from 'react-router'; + +import { authClient } from '@documenso/auth/client'; +import { TEAM_MEMBER_ROLE_MAP, TEAM_URL_REGEX } from '@documenso/lib/constants/teams'; +import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin'; +import type { TGetTeamsResponse } from '@documenso/lib/server-only/team/get-teams'; +import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; +import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; +import { canExecuteTeamAction } from '@documenso/lib/utils/teams'; +import { trpc } from '@documenso/trpc/react'; +import { LanguageSwitcherDialog } from '@documenso/ui/components/common/language-switcher-dialog'; +import { cn } from '@documenso/ui/lib/utils'; +import { AvatarWithText } from '@documenso/ui/primitives/avatar'; +import { Button } from '@documenso/ui/primitives/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@documenso/ui/primitives/dropdown-menu'; + +const MotionLink = motion(Link); + +export type MenuSwitcherProps = { + user: User; + teams: TGetTeamsResponse; +}; + +export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProps) => { + const { _ } = useLingui(); + + const { pathname } = useLocation(); + + const [languageSwitcherOpen, setLanguageSwitcherOpen] = useState(false); + + const isUserAdmin = isAdmin(user); + + const { data: teamsQueryResult } = trpc.team.getTeams.useQuery(undefined, { + initialData: initialTeamsData, + }); + + const teams = teamsQueryResult && teamsQueryResult.length > 0 ? teamsQueryResult : null; + + const isPathTeamUrl = (teamUrl: string) => { + if (!pathname || !pathname.startsWith(`/t/`)) { + return false; + } + + return pathname.split('/')[2] === teamUrl; + }; + + const selectedTeam = teams?.find((team) => isPathTeamUrl(team.url)); + + const formatAvatarFallback = (teamName?: string) => { + if (teamName !== undefined) { + return teamName.slice(0, 1).toUpperCase(); + } + + return user.name ? extractInitials(user.name) : user.email.slice(0, 1).toUpperCase(); + }; + + const formatSecondaryAvatarText = (team?: typeof selectedTeam) => { + if (!team) { + return _(msg`Personal Account`); + } + + if (team.ownerUserId === user.id) { + return _(msg`Owner`); + } + + return _(TEAM_MEMBER_ROLE_MAP[team.currentTeamMember.role]); + }; + + /** + * Formats the redirect URL so we can switch between documents and templates page + * seemlessly between teams and personal accounts. + */ + const formatRedirectUrlOnSwitch = (teamUrl?: string) => { + const baseUrl = teamUrl ? `/t/${teamUrl}/` : '/'; + + const currentPathname = (pathname ?? '/').replace(TEAM_URL_REGEX, ''); + + if (currentPathname === '/templates') { + return `${baseUrl}templates`; + } + + return baseUrl; + }; + + return ( + + + + + + + {teams ? ( + <> + + Personal + + + + + + ) + } + /> + + + + + + +
+

+ Teams +

+ +
+ + + + + + + +
+
+
+ +
+ {teams.map((team) => ( + + + + + {formatSecondaryAvatarText(team)} + + + {`/t/${team.url}`} +
+ } + rightSideComponent={ + isPathTeamUrl(team.url) && ( + + ) + } + /> + + + ))} +
+ + ) : ( + + + Create team + + + + )} + + + + {isUserAdmin && ( + + + Admin panel + + + )} + + + + User settings + + + + {selectedTeam && + canExecuteTeamAction('MANAGE_TEAM', selectedTeam.currentTeamMember.role) && ( + + + Team settings + + + )} + + setLanguageSwitcherOpen(true)} + > + Language + + + authClient.signOut()} + > + Sign Out + + + + + + ); +}; diff --git a/apps/remix/app/components/(dashboard)/layout/mobile-navigation.tsx b/apps/remix/app/components/(dashboard)/layout/mobile-navigation.tsx new file mode 100644 index 000000000..944c159c4 --- /dev/null +++ b/apps/remix/app/components/(dashboard)/layout/mobile-navigation.tsx @@ -0,0 +1,91 @@ +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { Link, useParams } from 'react-router'; + +import LogoImage from '@documenso/assets/logo.png'; +import { authClient } from '@documenso/auth/client'; +import { getRootHref } from '@documenso/lib/utils/params'; +import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet'; +import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher'; + +export type MobileNavigationProps = { + isMenuOpen: boolean; + onMenuOpenChange?: (_value: boolean) => void; +}; + +export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigationProps) => { + const { _ } = useLingui(); + + const params = useParams(); + + const handleMenuItemClick = () => { + onMenuOpenChange?.(false); + }; + + const rootHref = getRootHref(params, { returnEmptyRootString: true }); + + const menuNavigationLinks = [ + { + href: `${rootHref}/documents`, + text: msg`Documents`, + }, + { + href: `${rootHref}/templates`, + text: msg`Templates`, + }, + { + href: '/settings/teams', + text: msg`Teams`, + }, + { + href: '/settings/profile', + text: msg`Settings`, + }, + ]; + + return ( + + + + Documenso Logo + + +
+ {menuNavigationLinks.map(({ href, text }) => ( + handleMenuItemClick()} + > + {_(text)} + + ))} + + +
+ +
+
+ +
+ +

+ © {new Date().getFullYear()} Documenso, Inc.
All rights reserved. +

+
+
+
+ ); +}; diff --git a/apps/remix/app/components/(dashboard)/layout/verify-email-banner.tsx b/apps/remix/app/components/(dashboard)/layout/verify-email-banner.tsx new file mode 100644 index 000000000..fac702a97 --- /dev/null +++ b/apps/remix/app/components/(dashboard)/layout/verify-email-banner.tsx @@ -0,0 +1,133 @@ +import { useEffect, useState } from 'react'; + +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { AlertTriangle } from 'lucide-react'; + +import { ONE_DAY, ONE_SECOND } from '@documenso/lib/constants/time'; +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from '@documenso/ui/primitives/dialog'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type VerifyEmailBannerProps = { + email: string; +}; + +const RESEND_CONFIRMATION_EMAIL_TIMEOUT = 20 * ONE_SECOND; + +export const VerifyEmailBanner = ({ email }: VerifyEmailBannerProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const [isOpen, setIsOpen] = useState(false); + + const [isButtonDisabled, setIsButtonDisabled] = useState(false); + + const { mutateAsync: sendConfirmationEmail, isPending } = + trpc.profile.sendConfirmationEmail.useMutation(); + + const onResendConfirmationEmail = async () => { + try { + setIsButtonDisabled(true); + + await sendConfirmationEmail({ email: email }); + + toast({ + title: _(msg`Success`), + description: _(msg`Verification email sent successfully.`), + }); + + setIsOpen(false); + setTimeout(() => setIsButtonDisabled(false), RESEND_CONFIRMATION_EMAIL_TIMEOUT); + } catch (err) { + setIsButtonDisabled(false); + + toast({ + title: _(msg`Error`), + description: _(msg`Something went wrong while sending the confirmation email.`), + variant: 'destructive', + }); + } + }; + + useEffect(() => { + // Check localStorage to see if we've recently automatically displayed the dialog + // if it was within the past 24 hours, don't show it again + // otherwise, show it again and update the localStorage timestamp + const emailVerificationDialogLastShown = localStorage.getItem( + 'emailVerificationDialogLastShown', + ); + + if (emailVerificationDialogLastShown) { + const lastShownTimestamp = parseInt(emailVerificationDialogLastShown); + + if (Date.now() - lastShownTimestamp < ONE_DAY) { + return; + } + } + + setIsOpen(true); + + localStorage.setItem('emailVerificationDialogLastShown', Date.now().toString()); + }, []); + + return ( + <> +
+
+
+ + Verify your email address to unlock all features. +
+ +
+ +
+
+
+ + + + + Verify your email address + + + + + We've sent a confirmation email to {email}. Please check your inbox + and click the link in the email to verify your account. + + + +
+ +
+
+
+ + ); +}; diff --git a/apps/remix/app/components/(dashboard)/metric-card/metric-card.tsx b/apps/remix/app/components/(dashboard)/metric-card/metric-card.tsx new file mode 100644 index 000000000..67ecf17aa --- /dev/null +++ b/apps/remix/app/components/(dashboard)/metric-card/metric-card.tsx @@ -0,0 +1,39 @@ +import type { LucideIcon } from 'lucide-react/dist/lucide-react'; + +import { cn } from '@documenso/ui/lib/utils'; + +export type CardMetricProps = { + icon?: LucideIcon; + title: string; + value: string | number; + className?: string; +}; + +export const CardMetric = ({ icon: Icon, title, value, className }: CardMetricProps) => { + return ( +
+
+
+ {Icon && ( +
+ +
+ )} + +

+ {title} +

+
+ +

+ {typeof value === 'number' ? value.toLocaleString('en-US') : value} +

+
+
+ ); +}; diff --git a/apps/remix/app/components/(dashboard)/period-selector/period-selector.tsx b/apps/remix/app/components/(dashboard)/period-selector/period-selector.tsx new file mode 100644 index 000000000..6dadfba21 --- /dev/null +++ b/apps/remix/app/components/(dashboard)/period-selector/period-selector.tsx @@ -0,0 +1,66 @@ +import { useMemo } from 'react'; + +import { Trans } from '@lingui/macro'; +import { useLocation, useNavigate, useSearchParams } from 'react-router'; + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; + +import { isPeriodSelectorValue } from './types'; + +export const PeriodSelector = () => { + const { pathname } = useLocation(); + const [searchParams] = useSearchParams(); + + const navigate = useNavigate(); + + const period = useMemo(() => { + const p = searchParams?.get('period') ?? 'all'; + + return isPeriodSelectorValue(p) ? p : 'all'; + }, [searchParams]); + + const onPeriodChange = (newPeriod: string) => { + if (!pathname) { + return; + } + + const params = new URLSearchParams(searchParams?.toString()); + + params.set('period', newPeriod); + + if (newPeriod === '' || newPeriod === 'all') { + params.delete('period'); + } + + void navigate(`${pathname}?${params.toString()}`, { preventScrollReset: true }); + }; + + return ( + + ); +}; diff --git a/apps/remix/app/components/(dashboard)/period-selector/types.ts b/apps/remix/app/components/(dashboard)/period-selector/types.ts new file mode 100644 index 000000000..8ae1c5fbe --- /dev/null +++ b/apps/remix/app/components/(dashboard)/period-selector/types.ts @@ -0,0 +1,6 @@ +import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents'; + +export const isPeriodSelectorValue = (value: unknown): value is PeriodSelectorValue => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return ['', '7d', '14d', '30d'].includes(value as string); +}; diff --git a/apps/remix/app/components/(dashboard)/refresh-on-focus/refresh-on-focus.tsx b/apps/remix/app/components/(dashboard)/refresh-on-focus/refresh-on-focus.tsx new file mode 100644 index 000000000..bd4c27203 --- /dev/null +++ b/apps/remix/app/components/(dashboard)/refresh-on-focus/refresh-on-focus.tsx @@ -0,0 +1,18 @@ +export const RefreshOnFocus = () => { + // Todo: Would this still work? + // const { refresh } = useRouter(); + + // const onFocus = useCallback(() => { + // refresh(); + // }, [refresh]); + + // useEffect(() => { + // window.addEventListener('focus', onFocus); + + // return () => { + // window.removeEventListener('focus', onFocus); + // }; + // }, [onFocus]); + + return null; +}; diff --git a/apps/remix/app/components/(dashboard)/settings/layout/desktop-nav.tsx b/apps/remix/app/components/(dashboard)/settings/layout/desktop-nav.tsx new file mode 100644 index 000000000..5d55bf797 --- /dev/null +++ b/apps/remix/app/components/(dashboard)/settings/layout/desktop-nav.tsx @@ -0,0 +1,117 @@ +import type { HTMLAttributes } from 'react'; + +import { Trans } from '@lingui/macro'; +import { Braces, CreditCard, Globe2Icon, Lock, User, Users, Webhook } from 'lucide-react'; +import { useLocation } from 'react-router'; +import { Link } from 'react-router'; + +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; + +export type DesktopNavProps = HTMLAttributes; + +export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { + const { pathname } = useLocation(); + + const isBillingEnabled = false; // Todo getFlag('app_billing'); + const isPublicProfileEnabled = true; // Todo getFlag('app_public_profile'); + + return ( +
+ + + + + {isPublicProfileEnabled && ( + + + + )} + + + + + + + + + + + + + + + + + + {isBillingEnabled && ( + + + + )} +
+ ); +}; diff --git a/apps/remix/app/components/(dashboard)/settings/layout/header.tsx b/apps/remix/app/components/(dashboard)/settings/layout/header.tsx new file mode 100644 index 000000000..6f5ae28bc --- /dev/null +++ b/apps/remix/app/components/(dashboard)/settings/layout/header.tsx @@ -0,0 +1,35 @@ +import React from 'react'; + +import { cn } from '@documenso/ui/lib/utils'; + +export type SettingsHeaderProps = { + title: string; + subtitle: string; + hideDivider?: boolean; + children?: React.ReactNode; + className?: string; +}; + +export const SettingsHeader = ({ + children, + title, + subtitle, + className, + hideDivider, +}: SettingsHeaderProps) => { + return ( + <> +
+
+

{title}

+ +

{subtitle}

+
+ + {children} +
+ + {!hideDivider &&
} + + ); +}; diff --git a/apps/remix/app/components/(dashboard)/settings/layout/mobile-nav.tsx b/apps/remix/app/components/(dashboard)/settings/layout/mobile-nav.tsx new file mode 100644 index 000000000..27731b7d2 --- /dev/null +++ b/apps/remix/app/components/(dashboard)/settings/layout/mobile-nav.tsx @@ -0,0 +1,119 @@ +import type { HTMLAttributes } from 'react'; + +import { Trans } from '@lingui/macro'; +import { Braces, CreditCard, Globe2Icon, Lock, User, Users, Webhook } from 'lucide-react'; +import { Link, useLocation } from 'react-router'; + +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; + +export type MobileNavProps = HTMLAttributes; + +export const MobileNav = ({ className, ...props }: MobileNavProps) => { + const { pathname } = useLocation(); + + const isBillingEnabled = false; // Todo getFlag('app_billing'); + const isPublicProfileEnabled = true; // Todo getFlag('app_public_profile'); + + return ( +
+ + + + + {isPublicProfileEnabled && ( + + + + )} + + + + + + + + + + + + + + + + + + {isBillingEnabled && ( + + + + )} +
+ ); +}; diff --git a/apps/remix/app/components/(dashboard)/settings/token/contants.ts b/apps/remix/app/components/(dashboard)/settings/token/contants.ts new file mode 100644 index 000000000..414425b25 --- /dev/null +++ b/apps/remix/app/components/(dashboard)/settings/token/contants.ts @@ -0,0 +1,9 @@ +import { msg } from '@lingui/macro'; + +export const EXPIRATION_DATES = { + ONE_WEEK: msg`7 days`, + ONE_MONTH: msg`1 month`, + THREE_MONTHS: msg`3 months`, + SIX_MONTHS: msg`6 months`, + ONE_YEAR: msg`12 months`, +} as const; diff --git a/apps/remix/app/components/(dashboard)/settings/token/delete-token-dialog.tsx b/apps/remix/app/components/(dashboard)/settings/token/delete-token-dialog.tsx new file mode 100644 index 000000000..d420700f9 --- /dev/null +++ b/apps/remix/app/components/(dashboard)/settings/token/delete-token-dialog.tsx @@ -0,0 +1,190 @@ +import { useEffect, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import type { ApiToken } from '@prisma/client'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +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 DeleteTokenDialogProps = { + teamId?: number; + token: Pick; + onDelete?: () => void; + children?: React.ReactNode; +}; + +export default function DeleteTokenDialog({ + teamId, + token, + onDelete, + children, +}: DeleteTokenDialogProps) { + const { _ } = useLingui(); + const { toast } = useToast(); + + const [isOpen, setIsOpen] = useState(false); + + const deleteMessage = _(msg`delete ${token.name}`); + + const ZDeleteTokenDialogSchema = z.object({ + tokenName: z.literal(deleteMessage, { + errorMap: () => ({ message: _(msg`You must enter '${deleteMessage}' to proceed`) }), + }), + }); + + type TDeleteTokenByIdMutationSchema = z.infer; + + const { mutateAsync: deleteTokenMutation } = trpc.apiToken.deleteTokenById.useMutation({ + onSuccess() { + onDelete?.(); + }, + }); + + const form = useForm({ + resolver: zodResolver(ZDeleteTokenDialogSchema), + values: { + tokenName: '', + }, + }); + + const onSubmit = async () => { + try { + await deleteTokenMutation({ + id: token.id, + teamId, + }); + + toast({ + title: _(msg`Token deleted`), + description: _(msg`The token was deleted successfully.`), + duration: 5000, + }); + + setIsOpen(false); + + // router.refresh(); // Todo + } catch (error) { + toast({ + title: _(msg`An unknown error occurred`), + description: _( + msg`We encountered an unknown error while attempting to delete this token. Please try again later.`, + ), + variant: 'destructive', + duration: 5000, + }); + } + }; + + useEffect(() => { + if (!isOpen) { + form.reset(); + } + }, [isOpen, form]); + + return ( + !form.formState.isSubmitting && setIsOpen(value)} + > + + {children ?? ( + + )} + + + + + + Are you sure you want to delete this token? + + + + + Please note that this action is irreversible. Once confirmed, your token will be + permanently deleted. + + + + +
+ +
+ ( + + + + Confirm by typing:{' '} + + {deleteMessage} + + + + + + + + + + )} + /> + + +
+ + + +
+
+
+
+ +
+
+ ); +} diff --git a/apps/remix/app/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx b/apps/remix/app/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx new file mode 100644 index 000000000..724b6adbb --- /dev/null +++ b/apps/remix/app/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx @@ -0,0 +1,249 @@ +import { useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +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 { 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 { 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 { TriggerMultiSelectCombobox } from './trigger-multiselect-combobox'; + +const ZCreateWebhookFormSchema = ZCreateWebhookMutationSchema.omit({ teamId: true }); + +type TCreateWebhookFormSchema = z.infer; + +export type CreateWebhookDialogProps = { + trigger?: React.ReactNode; +} & Omit; + +export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const team = useOptionalCurrentTeam(); + + const [open, setOpen] = useState(false); + + const form = useForm({ + resolver: zodResolver(ZCreateWebhookFormSchema), + values: { + webhookUrl: '', + eventTriggers: [], + secret: '', + enabled: true, + }, + }); + + const { mutateAsync: createWebhook } = trpc.webhook.createWebhook.useMutation(); + + const onSubmit = async ({ + enabled, + eventTriggers, + secret, + webhookUrl, + }: TCreateWebhookFormSchema) => { + try { + await createWebhook({ + enabled, + eventTriggers, + secret, + webhookUrl, + teamId: team?.id, + }); + + setOpen(false); + + toast({ + title: _(msg`Webhook created`), + description: _(msg`The webhook was successfully created.`), + }); + + form.reset(); + + // router.refresh(); // Todo + } catch (err) { + toast({ + title: _(msg`Error`), + description: _(msg`An error occurred while creating the webhook. Please try again.`), + variant: 'destructive', + }); + } + }; + + return ( + !form.formState.isSubmitting && setOpen(value)} + {...props} + > + e.stopPropagation()} asChild> + {trigger ?? ( + + )} + + + + + + Create webhook + + + On this page, you can create a new webhook. + + + +
+ +
+
+ ( + + + Webhook URL + + + + + + + The URL for Documenso to send webhook events to. + + + + + )} + /> + + ( + + + Enabled + + +
+ + + +
+ + +
+ )} + /> +
+ + ( + + + Triggers + + + { + onChange(values); + }} + /> + + + + The events that will trigger a webhook to be sent to your URL. + + + + + )} + /> + + ( + + + Secret + + + + + + + + A secret that will be sent to your URL so you can verify that the request + has been sent by Documenso + + . + + + + )} + /> + + +
+ + +
+
+
+
+ +
+
+ ); +}; diff --git a/apps/remix/app/components/(dashboard)/settings/webhooks/delete-webhook-dialog.tsx b/apps/remix/app/components/(dashboard)/settings/webhooks/delete-webhook-dialog.tsx new file mode 100644 index 000000000..82cc61c22 --- /dev/null +++ b/apps/remix/app/components/(dashboard)/settings/webhooks/delete-webhook-dialog.tsx @@ -0,0 +1,179 @@ +'use effect'; + +import { useEffect, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import type { Webhook } from '@prisma/client'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useOptionalCurrentTeam } from '~/providers/team'; + +export type DeleteWebhookDialogProps = { + webhook: Pick; + onDelete?: () => void; + children: React.ReactNode; +}; + +export const DeleteWebhookDialog = ({ webhook, children }: DeleteWebhookDialogProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const team = useOptionalCurrentTeam(); + + const [open, setOpen] = useState(false); + + const deleteMessage = _(msg`delete ${webhook.webhookUrl}`); + + const ZDeleteWebhookFormSchema = z.object({ + webhookUrl: z.literal(deleteMessage, { + errorMap: () => ({ message: _(msg`You must enter '${deleteMessage}' to proceed`) }), + }), + }); + + type TDeleteWebhookFormSchema = z.infer; + + const { mutateAsync: deleteWebhook } = trpc.webhook.deleteWebhook.useMutation(); + + const form = useForm({ + resolver: zodResolver(ZDeleteWebhookFormSchema), + values: { + webhookUrl: '', + }, + }); + + const onSubmit = async () => { + try { + await deleteWebhook({ id: webhook.id, teamId: team?.id }); + + toast({ + title: _(msg`Webhook deleted`), + description: _(msg`The webhook has been successfully deleted.`), + duration: 5000, + }); + + setOpen(false); + + // router.refresh(); // Todo + } catch (error) { + toast({ + title: _(msg`An unknown error occurred`), + description: _( + msg`We encountered an unknown error while attempting to delete it. Please try again later.`, + ), + variant: 'destructive', + duration: 5000, + }); + } + }; + + useEffect(() => { + if (!open) { + form.reset(); + } + }, [open, form]); + + return ( + !form.formState.isSubmitting && setOpen(value)}> + + {children ?? ( + + )} + + + + + + Delete Webhook + + + + + Please note that this action is irreversible. Once confirmed, your webhook will be + permanently deleted. + + + + +
+ +
+ ( + + + + Confirm by typing:{' '} + + {deleteMessage} + + + + + + + + + )} + /> + + +
+ + + +
+
+
+
+ +
+
+ ); +}; diff --git a/apps/remix/app/components/(dashboard)/settings/webhooks/trigger-multiselect-combobox.tsx b/apps/remix/app/components/(dashboard)/settings/webhooks/trigger-multiselect-combobox.tsx new file mode 100644 index 000000000..43c883ad1 --- /dev/null +++ b/apps/remix/app/components/(dashboard)/settings/webhooks/trigger-multiselect-combobox.tsx @@ -0,0 +1,96 @@ +import { useEffect, useState } from 'react'; + +import { Plural, Trans } from '@lingui/macro'; +import { WebhookTriggerEvents } from '@prisma/client'; +import { Check, ChevronsUpDown } from 'lucide-react'; + +import { toFriendlyWebhookEventName } from '@documenso/lib/universal/webhook/to-friendly-webhook-event-name'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from '@documenso/ui/primitives/command'; +import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover'; + +import { truncateTitle } from '~/helpers/truncate-title'; + +type TriggerMultiSelectComboboxProps = { + listValues: string[]; + onChange: (_values: string[]) => void; +}; + +export const TriggerMultiSelectCombobox = ({ + listValues, + onChange, +}: TriggerMultiSelectComboboxProps) => { + const [isOpen, setIsOpen] = useState(false); + const [selectedValues, setSelectedValues] = useState([]); + + const triggerEvents = Object.values(WebhookTriggerEvents); + + useEffect(() => { + setSelectedValues(listValues); + }, [listValues]); + + const allEvents = [...new Set([...triggerEvents, ...selectedValues])]; + + const handleSelect = (currentValue: string) => { + let newSelectedValues; + + if (selectedValues.includes(currentValue)) { + newSelectedValues = selectedValues.filter((value) => value !== currentValue); + } else { + newSelectedValues = [...selectedValues, currentValue]; + } + + setSelectedValues(newSelectedValues); + onChange(newSelectedValues); + setIsOpen(false); + }; + + return ( + + + + + + + toFriendlyWebhookEventName(v)).join(', '), + 15, + )} + /> + + No value found. + + + {allEvents.map((value: string, i: number) => ( + handleSelect(value)}> + + {toFriendlyWebhookEventName(value)} + + ))} + + + + + ); +}; diff --git a/apps/remix/app/components/(teams)/dialogs/add-team-email-dialog.tsx b/apps/remix/app/components/(teams)/dialogs/add-team-email-dialog.tsx new file mode 100644 index 000000000..43033732c --- /dev/null +++ b/apps/remix/app/components/(teams)/dialogs/add-team-email-dialog.tsx @@ -0,0 +1,192 @@ +import { useEffect, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { Plus } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import type { z } from 'zod'; + +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { trpc } from '@documenso/trpc/react'; +import { ZCreateTeamEmailVerificationMutationSchema } from '@documenso/trpc/server/team-router/schema'; +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 AddTeamEmailDialogProps = { + teamId: number; + trigger?: React.ReactNode; +} & Omit; + +const ZCreateTeamEmailFormSchema = ZCreateTeamEmailVerificationMutationSchema.pick({ + name: true, + email: true, +}); + +type TCreateTeamEmailFormSchema = z.infer; + +export const AddTeamEmailDialog = ({ teamId, trigger, ...props }: AddTeamEmailDialogProps) => { + const [open, setOpen] = useState(false); + + const { _ } = useLingui(); + const { toast } = useToast(); + + const form = useForm({ + resolver: zodResolver(ZCreateTeamEmailFormSchema), + defaultValues: { + name: '', + email: '', + }, + }); + + const { mutateAsync: createTeamEmailVerification, isPending } = + trpc.team.createTeamEmailVerification.useMutation(); + + const onFormSubmit = async ({ name, email }: TCreateTeamEmailFormSchema) => { + try { + await createTeamEmailVerification({ + teamId, + name, + email, + }); + + toast({ + title: _(msg`Success`), + description: _(msg`We have sent a confirmation email for verification.`), + duration: 5000, + }); + + // router.refresh(); // Todo + + setOpen(false); + } catch (err) { + const error = AppError.parseError(err); + + if (error.code === AppErrorCode.ALREADY_EXISTS) { + form.setError('email', { + type: 'manual', + message: _(msg`This email is already being used by another team.`), + }); + + return; + } + + toast({ + title: _(msg`An unknown error occurred`), + description: _( + msg`We encountered an unknown error while attempting to add this email. Please try again later.`, + ), + variant: 'destructive', + }); + } + }; + + useEffect(() => { + if (!open) { + form.reset(); + } + }, [open, form]); + + return ( + !form.formState.isSubmitting && setOpen(value)} + > + e.stopPropagation()} asChild={true}> + {trigger ?? ( + + )} + + + + + + Add team email + + + + A verification email will be sent to the provided email. + + + +
+ +
+ ( + + + Name + + + + + + + )} + /> + + ( + + + Email + + + + + + + )} + /> + + + + + + +
+
+ +
+
+ ); +}; diff --git a/apps/remix/app/components/(teams)/dialogs/create-team-checkout-dialog.tsx b/apps/remix/app/components/(teams)/dialogs/create-team-checkout-dialog.tsx new file mode 100644 index 000000000..9a66c7073 --- /dev/null +++ b/apps/remix/app/components/(teams)/dialogs/create-team-checkout-dialog.tsx @@ -0,0 +1,187 @@ +import { useMemo, useState } from 'react'; + +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +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 CreateTeamCheckoutDialogProps = { + pendingTeamId: number | null; + onClose: () => void; +} & Omit; + +const MotionCard = motion(Card); + +export const CreateTeamCheckoutDialog = ({ + pendingTeamId, + onClose, + ...props +}: CreateTeamCheckoutDialogProps) => { + 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/(teams)/dialogs/create-team-dialog.tsx b/apps/remix/app/components/(teams)/dialogs/create-team-dialog.tsx new file mode 100644 index 000000000..1898c583a --- /dev/null +++ b/apps/remix/app/components/(teams)/dialogs/create-team-dialog.tsx @@ -0,0 +1,233 @@ +import { useEffect, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +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 { WEBAPP_BASE_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 { 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 CreateTeamDialogProps = { + trigger?: React.ReactNode; +} & Omit; + +const ZCreateTeamFormSchema = ZCreateTeamMutationSchema.pick({ + teamName: true, + teamUrl: true, +}); + +type TCreateTeamFormSchema = z.infer; + +export const CreateTeamDialog = ({ trigger, ...props }: CreateTeamDialogProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const updateSearchParams = useUpdateSearchParams(); + + const [open, setOpen] = useState(false); + + const actionSearchParam = searchParams?.get('action'); + + const form = useForm({ + resolver: zodResolver(ZCreateTeamFormSchema), + defaultValues: { + teamName: '', + teamUrl: '', + }, + }); + + const { mutateAsync: createTeam } = trpc.team.createTeam.useMutation(); + + const onFormSubmit = async ({ teamName, teamUrl }: TCreateTeamFormSchema) => { + try { + const response = await createTeam({ + teamName, + teamUrl, + }); + + setOpen(false); + + if (response.paymentRequired) { + void navigate(`/settings/teams?tab=pending&checkout=${response.pendingTeamId}`); + return; + } + + toast({ + title: _(msg`Success`), + description: _(msg`Your team has been created.`), + duration: 5000, + }); + } catch (err) { + const error = AppError.parseError(err); + + if (error.code === AppErrorCode.ALREADY_EXISTS) { + form.setError('teamUrl', { + type: 'manual', + message: _(msg`This URL is already in use.`), + }); + + return; + } + + toast({ + title: _(msg`An unknown error occurred`), + description: _( + msg`We encountered an unknown error while attempting to create a team. Please try again later.`, + ), + variant: 'destructive', + }); + } + }; + + const mapTextToUrl = (text: string) => { + return text.toLowerCase().replace(/\s+/g, '-'); + }; + + useEffect(() => { + if (actionSearchParam === 'add-team') { + setOpen(true); + updateSearchParams({ action: null }); + } + }, [actionSearchParam, open, setOpen, updateSearchParams]); + + useEffect(() => { + form.reset(); + }, [open, form]); + + return ( + !form.formState.isSubmitting && setOpen(value)} + > + e.stopPropagation()} asChild={true}> + {trigger ?? ( + + )} + + + + + + Create team + + + + Create a team to collaborate with your team members. + + + +
+ +
+ ( + + + Team Name + + + { + const oldGeneratedUrl = mapTextToUrl(field.value); + const newGeneratedUrl = mapTextToUrl(event.target.value); + + const urlField = form.getValues('teamUrl'); + if (urlField === oldGeneratedUrl) { + form.setValue('teamUrl', newGeneratedUrl); + } + + field.onChange(event); + }} + /> + + + + )} + /> + + ( + + + Team URL + + + + + {!form.formState.errors.teamUrl && ( + + {field.value ? ( + `${WEBAPP_BASE_URL}/t/${field.value}` + ) : ( + A unique URL to identify your team + )} + + )} + + + + )} + /> + + + + + + +
+
+ +
+
+ ); +}; diff --git a/apps/remix/app/components/(teams)/dialogs/delete-team-dialog.tsx b/apps/remix/app/components/(teams)/dialogs/delete-team-dialog.tsx new file mode 100644 index 000000000..24dcd2de6 --- /dev/null +++ b/apps/remix/app/components/(teams)/dialogs/delete-team-dialog.tsx @@ -0,0 +1,172 @@ +import { useEffect, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +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 type { Toast } from '@documenso/ui/primitives/use-toast'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type DeleteTeamDialogProps = { + teamId: number; + teamName: string; + trigger?: React.ReactNode; +}; + +export const DeleteTeamDialog = ({ trigger, teamId, teamName }: DeleteTeamDialogProps) => { + const navigate = useNavigate(); + const [open, setOpen] = useState(false); + + const { _ } = useLingui(); + const { toast } = useToast(); + + const deleteMessage = _(msg`delete ${teamName}`); + + const ZDeleteTeamFormSchema = z.object({ + teamName: z.literal(deleteMessage, { + errorMap: () => ({ message: _(msg`You must enter '${deleteMessage}' to proceed`) }), + }), + }); + + const form = useForm({ + resolver: zodResolver(ZDeleteTeamFormSchema), + defaultValues: { + teamName: '', + }, + }); + + const { mutateAsync: deleteTeam } = trpc.team.deleteTeam.useMutation(); + + const onFormSubmit = async () => { + try { + await deleteTeam({ teamId }); + + toast({ + title: _(msg`Success`), + description: _(msg`Your team has been successfully deleted.`), + duration: 5000, + }); + + setOpen(false); + + void navigate('/settings/teams'); + } catch (err) { + const error = AppError.parseError(err); + + let toastError: Toast = { + title: _(msg`An unknown error occurred`), + description: _( + msg`We encountered an unknown error while attempting to delete this team. Please try again later.`, + ), + variant: 'destructive', + duration: 10000, + }; + + if (error.code === 'resource_missing') { + toastError = { + title: _(msg`Unable to delete team`), + description: _( + msg`Something went wrong while updating the team billing subscription, please contact support.`, + ), + variant: 'destructive', + duration: 15000, + }; + } + + toast(toastError); + } + }; + + useEffect(() => { + if (!open) { + form.reset(); + } + }, [open, form]); + + return ( + !form.formState.isSubmitting && setOpen(value)}> + + {trigger ?? ( + + )} + + + + + + Are you sure you wish to delete this team? + + + + + Please note that you will lose access to all documents associated with this team & all + the members will be removed and notified + + + + +
+ +
+ ( + + + + Confirm by typing {deleteMessage} + + + + + + + + )} + /> + + + + + + +
+
+ +
+
+ ); +}; diff --git a/apps/remix/app/components/(teams)/dialogs/delete-team-member-dialog.tsx b/apps/remix/app/components/(teams)/dialogs/delete-team-member-dialog.tsx new file mode 100644 index 000000000..d375c520d --- /dev/null +++ b/apps/remix/app/components/(teams)/dialogs/delete-team-member-dialog.tsx @@ -0,0 +1,118 @@ +import { useState } from 'react'; + +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; + +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 DeleteTeamMemberDialogProps = { + teamId: number; + teamName: string; + teamMemberId: number; + teamMemberName: string; + teamMemberEmail: string; + trigger?: React.ReactNode; +}; + +export const DeleteTeamMemberDialog = ({ + trigger, + teamId, + teamName, + teamMemberId, + teamMemberName, + teamMemberEmail, +}: DeleteTeamMemberDialogProps) => { + const [open, setOpen] = useState(false); + + const { _ } = useLingui(); + const { toast } = useToast(); + + const { mutateAsync: deleteTeamMembers, isPending: isDeletingTeamMember } = + trpc.team.deleteTeamMembers.useMutation({ + onSuccess: () => { + toast({ + title: _(msg`Success`), + description: _(msg`You have successfully removed this user 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 user. Please try again later.`, + ), + variant: 'destructive', + duration: 10000, + }); + }, + }); + + return ( + !isDeletingTeamMember && setOpen(value)}> + + {trigger ?? ( + + )} + + + + + + Are you sure? + + + + + You are about to remove the following user from{' '} + {teamName}. + + + + + + {teamMemberName}} + secondaryText={teamMemberEmail} + /> + + +
+ + + + + +
+
+
+ ); +}; diff --git a/apps/remix/app/components/(teams)/dialogs/invite-team-member-dialog.tsx b/apps/remix/app/components/(teams)/dialogs/invite-team-member-dialog.tsx new file mode 100644 index 000000000..dbd2ed9d2 --- /dev/null +++ b/apps/remix/app/components/(teams)/dialogs/invite-team-member-dialog.tsx @@ -0,0 +1,415 @@ +import { useEffect, useRef, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +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'; + +export type InviteTeamMembersDialogProps = { + currentUserTeamRole: TeamMemberRole; + teamId: number; + 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 InviteTeamMembersDialog = ({ + currentUserTeamRole, + teamId, + trigger, + ...props +}: InviteTeamMembersDialogProps) => { + const [open, setOpen] = useState(false); + const fileInputRef = useRef(null); + const [invitationType, setInvitationType] = useState('INDIVIDUAL'); + + const { _ } = useLingui(); + const { toast } = useToast(); + + 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, + 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.message); + + 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/(teams)/dialogs/leave-team-dialog.tsx b/apps/remix/app/components/(teams)/dialogs/leave-team-dialog.tsx new file mode 100644 index 000000000..d5c07863b --- /dev/null +++ b/apps/remix/app/components/(teams)/dialogs/leave-team-dialog.tsx @@ -0,0 +1,116 @@ +import { useState } from 'react'; + +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +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 LeaveTeamDialogProps = { + teamId: number; + teamName: string; + teamAvatarImageId?: string | null; + role: TeamMemberRole; + trigger?: React.ReactNode; +}; + +export const LeaveTeamDialog = ({ + trigger, + teamId, + teamName, + teamAvatarImageId, + role, +}: LeaveTeamDialogProps) => { + 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/(teams)/dialogs/remove-team-email-dialog.tsx b/apps/remix/app/components/(teams)/dialogs/remove-team-email-dialog.tsx new file mode 100644 index 000000000..86ef5ce2b --- /dev/null +++ b/apps/remix/app/components/(teams)/dialogs/remove-team-email-dialog.tsx @@ -0,0 +1,160 @@ +import { useState } from 'react'; + +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import type { Prisma } from '@prisma/client'; + +import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; +import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; +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 RemoveTeamEmailDialogProps = { + trigger?: React.ReactNode; + teamName: string; + team: Prisma.TeamGetPayload<{ + include: { + teamEmail: true; + emailVerification: { + select: { + expiresAt: true; + name: true; + email: true; + }; + }; + }; + }>; +}; + +export const RemoveTeamEmailDialog = ({ trigger, teamName, team }: RemoveTeamEmailDialogProps) => { + const [open, setOpen] = useState(false); + + const { _ } = useLingui(); + const { toast } = useToast(); + + const { mutateAsync: deleteTeamEmail, isPending: isDeletingTeamEmail } = + trpc.team.deleteTeamEmail.useMutation({ + onSuccess: () => { + toast({ + title: _(msg`Success`), + description: _(msg`Team email has been removed`), + duration: 5000, + }); + }, + onError: () => { + toast({ + title: _(msg`Something went wrong`), + description: _(msg`Unable to remove team email at this time. Please try again.`), + variant: 'destructive', + duration: 10000, + }); + }, + }); + + const { mutateAsync: deleteTeamEmailVerification, isPending: isDeletingTeamEmailVerification } = + trpc.team.deleteTeamEmailVerification.useMutation({ + onSuccess: () => { + toast({ + title: _(msg`Success`), + description: _(msg`Email verification has been removed`), + duration: 5000, + }); + }, + onError: () => { + toast({ + title: _(msg`Something went wrong`), + description: _(msg`Unable to remove email verification at this time. Please try again.`), + variant: 'destructive', + duration: 10000, + }); + }, + }); + + const onRemove = async () => { + if (team.teamEmail) { + await deleteTeamEmail({ teamId: team.id }); + } + + if (team.emailVerification) { + await deleteTeamEmailVerification({ teamId: team.id }); + } + + // router.refresh(); // Todo + }; + + return ( + setOpen(value)}> + + {trigger ?? ( + + )} + + + + + + Are you sure? + + + + + You are about to delete the following team email from{' '} + {teamName}. + + + + + + + {team.teamEmail?.name || team.emailVerification?.name} + + } + secondaryText={ + + {team.teamEmail?.email || team.emailVerification?.email} + + } + /> + + +
+ + + + + +
+
+
+ ); +}; diff --git a/apps/remix/app/components/(teams)/dialogs/transfer-team-dialog.tsx b/apps/remix/app/components/(teams)/dialogs/transfer-team-dialog.tsx new file mode 100644 index 000000000..c3a3ef3d2 --- /dev/null +++ b/apps/remix/app/components/(teams)/dialogs/transfer-team-dialog.tsx @@ -0,0 +1,269 @@ +import { useEffect, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { Loader } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +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 TransferTeamDialogProps = { + teamId: number; + teamName: string; + ownerUserId: number; + trigger?: React.ReactNode; +}; + +export const TransferTeamDialog = ({ + trigger, + teamId, + teamName, + ownerUserId, +}: TransferTeamDialogProps) => { + const [open, setOpen] = useState(false); + + const { _ } = useLingui(); + const { toast } = useToast(); + + const { mutateAsync: requestTeamOwnershipTransfer } = + trpc.team.requestTeamOwnershipTransfer.useMutation(); + + const { + data, + refetch: refetchTeamMembers, + isLoading: 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, + }); + + // router.refresh(); // Todo + + 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/(teams)/dialogs/update-team-email-dialog.tsx b/apps/remix/app/components/(teams)/dialogs/update-team-email-dialog.tsx new file mode 100644 index 000000000..31fd2082b --- /dev/null +++ b/apps/remix/app/components/(teams)/dialogs/update-team-email-dialog.tsx @@ -0,0 +1,169 @@ +import { useEffect, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import type { TeamEmail } from '@prisma/client'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +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 UpdateTeamEmailDialogProps = { + teamEmail: TeamEmail; + trigger?: React.ReactNode; +} & Omit; + +const ZUpdateTeamEmailFormSchema = z.object({ + name: z.string().trim().min(1, { message: 'Please enter a valid name.' }), +}); + +type TUpdateTeamEmailFormSchema = z.infer; + +export const UpdateTeamEmailDialog = ({ + teamEmail, + trigger, + ...props +}: UpdateTeamEmailDialogProps) => { + const [open, setOpen] = useState(false); + + const { _ } = useLingui(); + const { toast } = useToast(); + + const form = useForm({ + resolver: zodResolver(ZUpdateTeamEmailFormSchema), + defaultValues: { + name: teamEmail.name, + }, + }); + + const { mutateAsync: updateTeamEmail } = trpc.team.updateTeamEmail.useMutation(); + + const onFormSubmit = async ({ name }: TUpdateTeamEmailFormSchema) => { + try { + await updateTeamEmail({ + teamId: teamEmail.teamId, + data: { + name, + }, + }); + + toast({ + title: _(msg`Success`), + description: _(msg`Team email was updated.`), + duration: 5000, + }); + + // router.refresh(); // Todo + + setOpen(false); + } catch (err) { + toast({ + title: _(msg`An unknown error occurred`), + description: _( + msg`We encountered an unknown error while attempting update the team email. Please try again later.`, + ), + variant: 'destructive', + }); + } + }; + + useEffect(() => { + if (!open) { + form.reset(); + } + }, [open, form]); + + return ( + !form.formState.isSubmitting && setOpen(value)} + > + e.stopPropagation()} asChild> + {trigger ?? ( + + )} + + + + + + Update team email + + + + To change the email you must remove and add a new email address. + + + +
+ +
+ ( + + + Name + + + + + + + )} + /> + + + + Email + + + + + + + + + + + +
+
+ +
+
+ ); +}; diff --git a/apps/remix/app/components/(teams)/dialogs/update-team-member-dialog.tsx b/apps/remix/app/components/(teams)/dialogs/update-team-member-dialog.tsx new file mode 100644 index 000000000..5f46cccb8 --- /dev/null +++ b/apps/remix/app/components/(teams)/dialogs/update-team-member-dialog.tsx @@ -0,0 +1,198 @@ +import { useEffect, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +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, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams'; +import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams'; +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type UpdateTeamMemberDialogProps = { + currentUserTeamRole: TeamMemberRole; + trigger?: React.ReactNode; + teamId: number; + teamMemberId: number; + teamMemberName: string; + teamMemberRole: TeamMemberRole; +} & Omit; + +const ZUpdateTeamMemberFormSchema = z.object({ + role: z.nativeEnum(TeamMemberRole), +}); + +type ZUpdateTeamMemberSchema = z.infer; + +export const UpdateTeamMemberDialog = ({ + currentUserTeamRole, + trigger, + teamId, + teamMemberId, + teamMemberName, + teamMemberRole, + ...props +}: UpdateTeamMemberDialogProps) => { + const [open, setOpen] = useState(false); + + const { _ } = useLingui(); + const { toast } = useToast(); + + const form = useForm({ + resolver: zodResolver(ZUpdateTeamMemberFormSchema), + defaultValues: { + role: teamMemberRole, + }, + }); + + const { mutateAsync: updateTeamMember } = trpc.team.updateTeamMember.useMutation(); + + const onFormSubmit = async ({ role }: ZUpdateTeamMemberSchema) => { + try { + await updateTeamMember({ + teamId, + teamMemberId, + data: { + role, + }, + }); + + toast({ + title: _(msg`Success`), + description: _(msg`You have updated ${teamMemberName}.`), + 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(); + + if (!isTeamRoleWithinUserHierarchy(currentUserTeamRole, teamMemberRole)) { + setOpen(false); + + toast({ + title: _(msg`You cannot modify a team member who has a higher role than you.`), + variant: 'destructive', + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, currentUserTeamRole, teamMemberRole, form, toast]); + + return ( + !form.formState.isSubmitting && setOpen(value)} + > + e.stopPropagation()} asChild> + {trigger ?? ( + + )} + + + + + + Update team member + + + + + You are currently updating {teamMemberName}. + + + + +
+ +
+ ( + + + Role + + + + + + + )} + /> + + + + + + +
+
+ +
+
+ ); +}; diff --git a/apps/remix/app/components/(teams)/forms/update-team-form.tsx b/apps/remix/app/components/(teams)/forms/update-team-form.tsx new file mode 100644 index 000000000..839b52f67 --- /dev/null +++ b/apps/remix/app/components/(teams)/forms/update-team-form.tsx @@ -0,0 +1,178 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { AnimatePresence, motion } from 'framer-motion'; +import { useForm } from 'react-hook-form'; +import type { z } from 'zod'; + +import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { trpc } from '@documenso/trpc/react'; +import { ZUpdateTeamMutationSchema } from '@documenso/trpc/server/team-router/schema'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type UpdateTeamDialogProps = { + teamId: number; + teamName: string; + teamUrl: string; +}; + +const ZUpdateTeamFormSchema = ZUpdateTeamMutationSchema.shape.data.pick({ + name: true, + url: true, +}); + +type TUpdateTeamFormSchema = z.infer; + +export const UpdateTeamForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogProps) => { + const navigate = useNavigate(); + const { _ } = useLingui(); + const { toast } = useToast(); + + const form = useForm({ + resolver: zodResolver(ZUpdateTeamFormSchema), + defaultValues: { + name: teamName, + url: teamUrl, + }, + }); + + const { mutateAsync: updateTeam } = trpc.team.updateTeam.useMutation(); + + const onFormSubmit = async ({ name, url }: TUpdateTeamFormSchema) => { + try { + await updateTeam({ + data: { + name, + url, + }, + teamId, + }); + + toast({ + title: _(msg`Success`), + description: _(msg`Your team has been successfully updated.`), + duration: 5000, + }); + + form.reset({ + name, + url, + }); + + if (url !== teamUrl) { + void navigate(`${WEBAPP_BASE_URL}/t/${url}/settings`); + } + } catch (err) { + const error = AppError.parseError(err); + + if (error.code === AppErrorCode.ALREADY_EXISTS) { + form.setError('url', { + type: 'manual', + message: _(msg`This URL is already in use.`), + }); + + return; + } + + toast({ + title: _(msg`An unknown error occurred`), + description: _( + msg`We encountered an unknown error while attempting to update your team. Please try again later.`, + ), + variant: 'destructive', + }); + } + }; + + return ( +
+ +
+ ( + + + Team Name + + + + + + + )} + /> + + ( + + + Team URL + + + + + {!form.formState.errors.url && ( + + {field.value ? ( + `${WEBAPP_BASE_URL}/t/${field.value}` + ) : ( + A unique URL to identify your team + )} + + )} + + + + )} + /> + +
+ + {form.formState.isDirty && ( + + + + )} + + + +
+
+
+ + ); +}; diff --git a/apps/remix/app/components/(teams)/settings/layout/desktop-nav.tsx b/apps/remix/app/components/(teams)/settings/layout/desktop-nav.tsx new file mode 100644 index 000000000..d84b51a3e --- /dev/null +++ b/apps/remix/app/components/(teams)/settings/layout/desktop-nav.tsx @@ -0,0 +1,125 @@ +import type { HTMLAttributes } from 'react'; + +import { Trans } from '@lingui/macro'; +import { Braces, CreditCard, Globe2Icon, Settings, Settings2, Users, Webhook } from 'lucide-react'; +import { Link, useLocation, useParams } from 'react-router'; + +import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; +import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; + +export type DesktopNavProps = HTMLAttributes; + +export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { + const { pathname } = useLocation(); + const params = useParams(); + + const { getFlag } = useFeatureFlags(); + + const isPublicProfileEnabled = getFlag('app_public_profile'); + + const teamUrl = typeof params?.teamUrl === 'string' ? params?.teamUrl : ''; + + const settingsPath = `/t/${teamUrl}/settings`; + const preferencesPath = `/t/${teamUrl}/settings/preferences`; + const publicProfilePath = `/t/${teamUrl}/settings/public-profile`; + const membersPath = `/t/${teamUrl}/settings/members`; + const tokensPath = `/t/${teamUrl}/settings/tokens`; + const webhooksPath = `/t/${teamUrl}/settings/webhooks`; + const billingPath = `/t/${teamUrl}/settings/billing`; + + return ( +
+ + + + + + + + + {isPublicProfileEnabled && ( + + + + )} + + + + + + + + + + + + + + {IS_BILLING_ENABLED() && ( + + + + )} +
+ ); +}; diff --git a/apps/remix/app/components/(teams)/settings/layout/mobile-nav.tsx b/apps/remix/app/components/(teams)/settings/layout/mobile-nav.tsx new file mode 100644 index 000000000..4cb5f436d --- /dev/null +++ b/apps/remix/app/components/(teams)/settings/layout/mobile-nav.tsx @@ -0,0 +1,134 @@ +import type { HTMLAttributes } from 'react'; + +import { Trans } from '@lingui/macro'; +import { Braces, CreditCard, Globe2Icon, Key, Settings2, User, Webhook } from 'lucide-react'; +import { Link, useLocation, useParams } from 'react-router'; + +import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; +import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; + +export type MobileNavProps = HTMLAttributes; + +export const MobileNav = ({ className, ...props }: MobileNavProps) => { + const { pathname } = useLocation(); + const params = useParams(); + + const { getFlag } = useFeatureFlags(); + + const isPublicProfileEnabled = getFlag('app_public_profile'); + + const teamUrl = typeof params?.teamUrl === 'string' ? params?.teamUrl : ''; + + const settingsPath = `/t/${teamUrl}/settings`; + const preferencesPath = `/t/${teamUrl}/preferences`; + const publicProfilePath = `/t/${teamUrl}/settings/public-profile`; + const membersPath = `/t/${teamUrl}/settings/members`; + const tokensPath = `/t/${teamUrl}/settings/tokens`; + const webhooksPath = `/t/${teamUrl}/settings/webhooks`; + const billingPath = `/t/${teamUrl}/settings/billing`; + + return ( +
+ + + + + + + + + {isPublicProfileEnabled && ( + + + + )} + + + + + + + + + + + + + + {IS_BILLING_ENABLED() && ( + + + + )} +
+ ); +}; diff --git a/apps/remix/app/components/(teams)/tables/current-user-teams-data-table.tsx b/apps/remix/app/components/(teams)/tables/current-user-teams-data-table.tsx new file mode 100644 index 000000000..643883939 --- /dev/null +++ b/apps/remix/app/components/(teams)/tables/current-user-teams-data-table.tsx @@ -0,0 +1,168 @@ +import { useMemo } from 'react'; + +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { useSearchParams } from 'react-router'; +import { Link } from 'react-router'; + +import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; +import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams'; +import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params'; +import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; +import { canExecuteTeamAction } from '@documenso/lib/utils/teams'; +import { trpc } from '@documenso/trpc/react'; +import { AvatarWithText } from '@documenso/ui/primitives/avatar'; +import { Button } from '@documenso/ui/primitives/button'; +import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table'; +import { DataTable } from '@documenso/ui/primitives/data-table'; +import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; +import { Skeleton } from '@documenso/ui/primitives/skeleton'; +import { TableCell } from '@documenso/ui/primitives/table'; + +import { LeaveTeamDialog } from '../dialogs/leave-team-dialog'; + +export const CurrentUserTeamsDataTable = () => { + const { _, i18n } = useLingui(); + + const [searchParams] = useSearchParams(); + const updateSearchParams = useUpdateSearchParams(); + + const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? [])); + + const { data, isLoading, isLoadingError } = trpc.team.findTeams.useQuery( + { + query: parsedSearchParams.query, + page: parsedSearchParams.page, + perPage: parsedSearchParams.perPage, + }, + { + placeholderData: (previousData) => previousData, + }, + ); + + const onPaginationChange = (page: number, perPage: number) => { + updateSearchParams({ + page, + perPage, + }); + }; + + const results = data ?? { + data: [], + perPage: 10, + currentPage: 1, + totalPages: 1, + }; + + const columns = useMemo(() => { + return [ + { + header: _(msg`Team`), + accessorKey: 'name', + cell: ({ row }) => ( + + {row.original.name} + } + secondaryText={`${WEBAPP_BASE_URL}/t/${row.original.url}`} + /> + + ), + }, + { + header: _(msg`Role`), + accessorKey: 'role', + cell: ({ row }) => + row.original.ownerUserId === row.original.currentTeamMember.userId + ? _(msg`Owner`) + : _(TEAM_MEMBER_ROLE_MAP[row.original.currentTeamMember.role]), + }, + { + header: _(msg`Member Since`), + accessorKey: 'createdAt', + cell: ({ row }) => i18n.date(row.original.createdAt), + }, + { + id: 'actions', + cell: ({ row }) => ( +
+ {canExecuteTeamAction('MANAGE_TEAM', row.original.currentTeamMember.role) && ( + + )} + + e.preventDefault()} + > + Leave + + } + /> +
+ ), + }, + ] satisfies DataTableColumnDef<(typeof results)['data'][number]>[]; + }, []); + + return ( + + +
+ + +
+ + +
+
+
+ + + + + + + +
+ + +
+
+ + ), + }} + > + {(table) => } +
+ ); +}; diff --git a/apps/remix/app/components/(teams)/tables/pending-user-teams-data-table-actions.tsx b/apps/remix/app/components/(teams)/tables/pending-user-teams-data-table-actions.tsx new file mode 100644 index 000000000..57c77dbe0 --- /dev/null +++ b/apps/remix/app/components/(teams)/tables/pending-user-teams-data-table-actions.tsx @@ -0,0 +1,58 @@ +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; + +import { trpc } from '@documenso/trpc/react'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type PendingUserTeamsDataTableActionsProps = { + className?: string; + pendingTeamId: number; + onPayClick: (pendingTeamId: number) => void; +}; + +export const PendingUserTeamsDataTableActions = ({ + className, + pendingTeamId, + onPayClick, +}: PendingUserTeamsDataTableActionsProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const { mutateAsync: deleteTeamPending, isPending: deletingTeam } = + trpc.team.deleteTeamPending.useMutation({ + onSuccess: () => { + toast({ + title: _(msg`Success`), + description: _(msg`Pending team deleted.`), + }); + }, + onError: () => { + toast({ + title: _(msg`Something went wrong`), + description: _( + msg`We encountered an unknown error while attempting to delete the pending team. Please try again later.`, + ), + duration: 10000, + variant: 'destructive', + }); + }, + }); + + return ( +
+ + + +
+ ); +}; diff --git a/apps/remix/app/components/(teams)/tables/pending-user-teams-data-table.tsx b/apps/remix/app/components/(teams)/tables/pending-user-teams-data-table.tsx new file mode 100644 index 000000000..e78dac291 --- /dev/null +++ b/apps/remix/app/components/(teams)/tables/pending-user-teams-data-table.tsx @@ -0,0 +1,148 @@ +import { useEffect, useMemo, useState } from 'react'; + +import { msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { useSearchParams } from 'react-router'; + +import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; +import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params'; +import { trpc } from '@documenso/trpc/react'; +import { AvatarWithText } from '@documenso/ui/primitives/avatar'; +import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table'; +import { DataTable } from '@documenso/ui/primitives/data-table'; +import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; +import { Skeleton } from '@documenso/ui/primitives/skeleton'; +import { TableCell } from '@documenso/ui/primitives/table'; + +import { CreateTeamCheckoutDialog } from '../dialogs/create-team-checkout-dialog'; +import { PendingUserTeamsDataTableActions } from './pending-user-teams-data-table-actions'; + +export const PendingUserTeamsDataTable = () => { + const { _, i18n } = useLingui(); + + const [searchParams] = useSearchParams(); + const updateSearchParams = useUpdateSearchParams(); + + const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? [])); + + const [checkoutPendingTeamId, setCheckoutPendingTeamId] = useState(null); + + const { data, isLoading, isLoadingError } = trpc.team.findTeamsPending.useQuery( + { + query: parsedSearchParams.query, + page: parsedSearchParams.page, + perPage: parsedSearchParams.perPage, + }, + { + placeholderData: (previousData) => previousData, + }, + ); + + const onPaginationChange = (page: number, perPage: number) => { + updateSearchParams({ + page, + perPage, + }); + }; + + const results = data ?? { + data: [], + perPage: 10, + currentPage: 1, + totalPages: 1, + }; + + const columns = useMemo(() => { + return [ + { + header: _(msg`Team`), + accessorKey: 'name', + cell: ({ row }) => ( + {row.original.name} + } + secondaryText={`${WEBAPP_BASE_URL}/t/${row.original.url}`} + /> + ), + }, + { + header: _(msg`Created on`), + accessorKey: 'createdAt', + cell: ({ row }) => i18n.date(row.original.createdAt), + }, + { + id: 'actions', + cell: ({ row }) => ( + + ), + }, + ] satisfies DataTableColumnDef<(typeof results)['data'][number]>[]; + }, []); + + useEffect(() => { + const searchParamCheckout = searchParams?.get('checkout'); + + if (searchParamCheckout && !isNaN(parseInt(searchParamCheckout))) { + setCheckoutPendingTeamId(parseInt(searchParamCheckout)); + updateSearchParams({ checkout: null }); + } + }, [searchParams, updateSearchParams]); + + return ( + <> + + +
+ + +
+ + +
+
+
+ + + + +
+ + +
+
+ + ), + }} + > + {(table) => } +
+ + setCheckoutPendingTeamId(null)} + /> + + ); +}; diff --git a/apps/remix/app/components/(teams)/tables/team-billing-invoices-data-table.tsx b/apps/remix/app/components/(teams)/tables/team-billing-invoices-data-table.tsx new file mode 100644 index 000000000..6c15f636c --- /dev/null +++ b/apps/remix/app/components/(teams)/tables/team-billing-invoices-data-table.tsx @@ -0,0 +1,160 @@ +import { useMemo } from 'react'; + +import { Plural, Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { File } from 'lucide-react'; +import { DateTime } from 'luxon'; +import { Link } from 'react-router'; + +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table'; +import { DataTable } from '@documenso/ui/primitives/data-table'; +import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; +import { Skeleton } from '@documenso/ui/primitives/skeleton'; +import { TableCell } from '@documenso/ui/primitives/table'; + +export type TeamBillingInvoicesDataTableProps = { + teamId: number; +}; + +export const TeamBillingInvoicesDataTable = ({ teamId }: TeamBillingInvoicesDataTableProps) => { + const { _ } = useLingui(); + + const { data, isLoading, isLoadingError } = trpc.team.findTeamInvoices.useQuery( + { + teamId, + }, + { + placeholderData: (previousData) => previousData, + }, + ); + + const formatCurrency = (currency: string, amount: number) => { + const formatter = new Intl.NumberFormat('en-US', { + style: 'currency', + currency, + }); + + return formatter.format(amount); + }; + + const results = { + data: data?.data ?? [], + perPage: 100, + currentPage: 1, + totalPages: 1, + }; + + const columns = useMemo(() => { + return [ + { + header: _(msg`Invoice`), + accessorKey: 'created', + cell: ({ row }) => ( +
+ + +
+ + {DateTime.fromSeconds(row.original.created).toFormat('MMMM yyyy')} + + + + +
+
+ ), + }, + { + header: _(msg`Status`), + accessorKey: 'status', + cell: ({ row }) => { + const { status, paid } = row.original; + + if (!status) { + return paid ? Paid : Unpaid; + } + + return status.charAt(0).toUpperCase() + status.slice(1); + }, + }, + { + header: _(msg`Amount`), + accessorKey: 'total', + cell: ({ row }) => formatCurrency(row.original.currency, row.original.total / 100), + }, + { + id: 'actions', + cell: ({ row }) => ( +
+ + + +
+ ), + }, + ] satisfies DataTableColumnDef<(typeof results)['data'][number]>[]; + }, []); + + return ( + + +
+ + +
+ + +
+
+
+ + + + + + + +
+ + +
+
+ + ), + }} + > + {(table) => } +
+ ); +}; diff --git a/apps/remix/app/components/(teams)/tables/team-member-invites-data-table.tsx b/apps/remix/app/components/(teams)/tables/team-member-invites-data-table.tsx new file mode 100644 index 000000000..15fa1e5b6 --- /dev/null +++ b/apps/remix/app/components/(teams)/tables/team-member-invites-data-table.tsx @@ -0,0 +1,207 @@ +import { useMemo } from 'react'; + +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { History, MoreHorizontal, Trash2 } from 'lucide-react'; +import { useSearchParams } from 'react-router'; + +import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams'; +import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params'; +import { trpc } from '@documenso/trpc/react'; +import { AvatarWithText } from '@documenso/ui/primitives/avatar'; +import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table'; +import { DataTable } from '@documenso/ui/primitives/data-table'; +import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from '@documenso/ui/primitives/dropdown-menu'; +import { Skeleton } from '@documenso/ui/primitives/skeleton'; +import { TableCell } from '@documenso/ui/primitives/table'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type TeamMemberInvitesDataTableProps = { + teamId: number; +}; + +export const TeamMemberInvitesDataTable = ({ teamId }: TeamMemberInvitesDataTableProps) => { + const [searchParams] = useSearchParams(); + const updateSearchParams = useUpdateSearchParams(); + + const { _, i18n } = useLingui(); + const { toast } = useToast(); + + const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? [])); + + const { data, isLoading, isLoadingError } = trpc.team.findTeamMemberInvites.useQuery( + { + teamId, + query: parsedSearchParams.query, + page: parsedSearchParams.page, + perPage: parsedSearchParams.perPage, + }, + { + placeholderData: (previousData) => previousData, + }, + ); + + const { mutateAsync: resendTeamMemberInvitation } = + trpc.team.resendTeamMemberInvitation.useMutation({ + onSuccess: () => { + toast({ + title: _(msg`Success`), + description: _(msg`Invitation has been resent`), + }); + }, + onError: () => { + toast({ + title: _(msg`Something went wrong`), + description: _(msg`Unable to resend invitation. Please try again.`), + variant: 'destructive', + }); + }, + }); + + const { mutateAsync: deleteTeamMemberInvitations } = + trpc.team.deleteTeamMemberInvitations.useMutation({ + onSuccess: () => { + toast({ + title: _(msg`Success`), + description: _(msg`Invitation has been deleted`), + }); + }, + onError: () => { + toast({ + title: _(msg`Something went wrong`), + description: _(msg`Unable to delete invitation. Please try again.`), + variant: 'destructive', + }); + }, + }); + + const onPaginationChange = (page: number, perPage: number) => { + updateSearchParams({ + page, + perPage, + }); + }; + + const results = data ?? { + data: [], + perPage: 10, + currentPage: 1, + totalPages: 1, + }; + + const columns = useMemo(() => { + return [ + { + header: _(msg`Team Member`), + cell: ({ row }) => { + return ( + {row.original.email} + } + /> + ); + }, + }, + { + header: _(msg`Role`), + accessorKey: 'role', + cell: ({ row }) => _(TEAM_MEMBER_ROLE_MAP[row.original.role]) ?? row.original.role, + }, + { + header: _(msg`Invited At`), + accessorKey: 'createdAt', + cell: ({ row }) => i18n.date(row.original.createdAt), + }, + { + header: _(msg`Actions`), + cell: ({ row }) => ( + + + + + + + + Actions + + + + resendTeamMemberInvitation({ + teamId, + invitationId: row.original.id, + }) + } + > + + Resend + + + + deleteTeamMemberInvitations({ + teamId, + invitationIds: [row.original.id], + }) + } + > + + Remove + + + + ), + }, + ] satisfies DataTableColumnDef<(typeof results)['data'][number]>[]; + }, []); + + return ( + + +
+ + +
+
+ + + + + + + + + + + ), + }} + > + {(table) => } +
+ ); +}; diff --git a/apps/remix/app/components/(teams)/tables/team-members-data-table.tsx b/apps/remix/app/components/(teams)/tables/team-members-data-table.tsx new file mode 100644 index 000000000..1af4caca0 --- /dev/null +++ b/apps/remix/app/components/(teams)/tables/team-members-data-table.tsx @@ -0,0 +1,215 @@ +import { useMemo } from 'react'; + +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import type { TeamMemberRole } from '@prisma/client'; +import { Edit, MoreHorizontal, Trash2 } from 'lucide-react'; +import { useSearchParams } from 'react-router'; + +import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams'; +import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params'; +import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; +import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams'; +import { trpc } from '@documenso/trpc/react'; +import { AvatarWithText } from '@documenso/ui/primitives/avatar'; +import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table'; +import { DataTable } from '@documenso/ui/primitives/data-table'; +import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from '@documenso/ui/primitives/dropdown-menu'; +import { Skeleton } from '@documenso/ui/primitives/skeleton'; +import { TableCell } from '@documenso/ui/primitives/table'; + +import { DeleteTeamMemberDialog } from '../dialogs/delete-team-member-dialog'; +import { UpdateTeamMemberDialog } from '../dialogs/update-team-member-dialog'; + +export type TeamMembersDataTableProps = { + currentUserTeamRole: TeamMemberRole; + teamOwnerUserId: number; + teamId: number; + teamName: string; +}; + +export const TeamMembersDataTable = ({ + currentUserTeamRole, + teamOwnerUserId, + teamId, + teamName, +}: TeamMembersDataTableProps) => { + const { _, i18n } = useLingui(); + + const [searchParams] = useSearchParams(); + const updateSearchParams = useUpdateSearchParams(); + + const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? [])); + + const { data, isLoading, isLoadingError } = trpc.team.findTeamMembers.useQuery( + { + teamId, + query: parsedSearchParams.query, + page: parsedSearchParams.page, + perPage: parsedSearchParams.perPage, + }, + { + placeholderData: (previousData) => previousData, + }, + ); + + const onPaginationChange = (page: number, perPage: number) => { + updateSearchParams({ + page, + perPage, + }); + }; + + const results = data ?? { + data: [], + perPage: 10, + currentPage: 1, + totalPages: 1, + }; + + const columns = useMemo(() => { + return [ + { + header: _(msg`Team Member`), + cell: ({ row }) => { + const avatarFallbackText = row.original.user.name + ? extractInitials(row.original.user.name) + : row.original.user.email.slice(0, 1).toUpperCase(); + + return ( + {row.original.user.name} + } + secondaryText={row.original.user.email} + /> + ); + }, + }, + { + header: _(msg`Role`), + accessorKey: 'role', + cell: ({ row }) => + teamOwnerUserId === row.original.userId + ? _(msg`Owner`) + : _(TEAM_MEMBER_ROLE_MAP[row.original.role]), + }, + { + header: _(msg`Member Since`), + accessorKey: 'createdAt', + cell: ({ row }) => i18n.date(row.original.createdAt), + }, + { + header: _(msg`Actions`), + cell: ({ row }) => ( + + + + + + + + Actions + + + e.preventDefault()} + title="Update team member role" + > + + Update role + + } + /> + + e.preventDefault()} + disabled={ + teamOwnerUserId === row.original.userId || + !isTeamRoleWithinUserHierarchy(currentUserTeamRole, row.original.role) + } + title={_(msg`Remove team member`)} + > + + Remove + + } + /> + + + ), + }, + ] satisfies DataTableColumnDef<(typeof results)['data'][number]>[]; + }, []); + + return ( + + +
+ + +
+ + +
+
+
+ + + + + + + + + + + ), + }} + > + {(table) => } +
+ ); +}; diff --git a/apps/remix/app/components/(teams)/tables/teams-member-page-data-table.tsx b/apps/remix/app/components/(teams)/tables/teams-member-page-data-table.tsx new file mode 100644 index 000000000..2aebb4579 --- /dev/null +++ b/apps/remix/app/components/(teams)/tables/teams-member-page-data-table.tsx @@ -0,0 +1,98 @@ +import { useEffect, useState } from 'react'; + +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import type { TeamMemberRole } from '@prisma/client'; +import { Link, useLocation, useNavigate, useSearchParams } from 'react-router'; + +import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; +import { Input } from '@documenso/ui/primitives/input'; +import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; + +import { TeamMemberInvitesDataTable } from '~/components/(teams)/tables/team-member-invites-data-table'; +import { TeamMembersDataTable } from '~/components/(teams)/tables/team-members-data-table'; + +export type TeamsMemberPageDataTableProps = { + currentUserTeamRole: TeamMemberRole; + teamId: number; + teamName: string; + teamOwnerUserId: number; +}; + +export const TeamsMemberPageDataTable = ({ + currentUserTeamRole, + teamId, + teamName, + teamOwnerUserId, +}: TeamsMemberPageDataTableProps) => { + const { _ } = useLingui(); + + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const { pathname } = useLocation(); + + const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? ''); + + const debouncedSearchQuery = useDebouncedValue(searchQuery, 500); + + const currentTab = searchParams?.get('tab') === 'invites' ? 'invites' : 'members'; + + /** + * Handle debouncing the search query. + */ + useEffect(() => { + if (!pathname) { + return; + } + + const params = new URLSearchParams(searchParams?.toString()); + + params.set('query', debouncedSearchQuery); + + if (debouncedSearchQuery === '') { + params.delete('query'); + } + + void navigate(`${pathname}?${params.toString()}`); + }, [debouncedSearchQuery, pathname, navigate, searchParams]); + + return ( +
+
+ setSearchQuery(e.target.value)} + placeholder={_(msg`Search`)} + /> + + + + + + Active + + + + + + Pending + + + + +
+ + {currentTab === 'invites' ? ( + + ) : ( + + )} +
+ ); +}; diff --git a/apps/remix/app/components/(teams)/tables/user-settings-teams-page-data-table.tsx b/apps/remix/app/components/(teams)/tables/user-settings-teams-page-data-table.tsx new file mode 100644 index 000000000..f13abd638 --- /dev/null +++ b/apps/remix/app/components/(teams)/tables/user-settings-teams-page-data-table.tsx @@ -0,0 +1,88 @@ +import { useEffect, useState } from 'react'; + +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { Link, useSearchParams } from 'react-router'; +import { useNavigate } from 'react-router'; +import { useLocation } from 'react-router'; + +import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; +import { trpc } from '@documenso/trpc/react'; +import { Input } from '@documenso/ui/primitives/input'; +import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; + +import { CurrentUserTeamsDataTable } from './current-user-teams-data-table'; +import { PendingUserTeamsDataTable } from './pending-user-teams-data-table'; + +export const UserSettingsTeamsPageDataTable = () => { + const { _ } = useLingui(); + + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const { pathname } = useLocation(); + + const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? ''); + + const debouncedSearchQuery = useDebouncedValue(searchQuery, 500); + + const currentTab = searchParams?.get('tab') === 'pending' ? 'pending' : 'active'; + + const { data } = trpc.team.findTeamsPending.useQuery( + {}, + { + placeholderData: (previousData) => previousData, + }, + ); + + /** + * Handle debouncing the search query. + */ + useEffect(() => { + if (!pathname) { + return; + } + + const params = new URLSearchParams(searchParams?.toString()); + + params.set('query', debouncedSearchQuery); + + if (debouncedSearchQuery === '') { + params.delete('query'); + } + + void navigate(`${pathname}?${params.toString()}`); + }, [debouncedSearchQuery, pathname, navigate, searchParams]); + + return ( +
+
+ setSearchQuery(e.target.value)} + placeholder={_(msg`Search`)} + /> + + + + + + Active + + + + + + Pending + {data && data.count > 0 && ( + {data.count} + )} + + + + +
+ + {currentTab === 'pending' ? : } +
+ ); +}; diff --git a/apps/remix/app/components/(teams)/team-billing-portal-button.tsx b/apps/remix/app/components/(teams)/team-billing-portal-button.tsx new file mode 100644 index 000000000..9ec7bce9c --- /dev/null +++ b/apps/remix/app/components/(teams)/team-billing-portal-button.tsx @@ -0,0 +1,42 @@ +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; + +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type TeamBillingPortalButtonProps = { + buttonProps?: React.ComponentProps; + teamId: number; +}; + +export const TeamBillingPortalButton = ({ buttonProps, teamId }: TeamBillingPortalButtonProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const { mutateAsync: createBillingPortal, isPending } = + trpc.team.createBillingPortal.useMutation(); + + const handleCreatePortal = async () => { + try { + const sessionUrl = await createBillingPortal({ teamId }); + + window.open(sessionUrl, '_blank'); + } catch (err) { + toast({ + title: _(msg`Something went wrong`), + description: _( + msg`We are unable to proceed to the billing portal at this time. Please try again, or contact support.`, + ), + variant: 'destructive', + duration: 10000, + }); + } + }; + + return ( + + ); +}; diff --git a/apps/remix/app/components/branding/logo.tsx b/apps/remix/app/components/branding/logo.tsx new file mode 100644 index 000000000..92087a149 --- /dev/null +++ b/apps/remix/app/components/branding/logo.tsx @@ -0,0 +1,30 @@ +import type { SVGAttributes } from 'react'; + +export type LogoProps = SVGAttributes; + +export const Logo = ({ ...props }: LogoProps) => { + return ( + + + + + + + + ); +}; diff --git a/apps/remix/app/components/dialogs/admin-document-delete-dialog.tsx b/apps/remix/app/components/dialogs/admin-document-delete-dialog.tsx new file mode 100644 index 000000000..59bc42ca3 --- /dev/null +++ b/apps/remix/app/components/dialogs/admin-document-delete-dialog.tsx @@ -0,0 +1,132 @@ +import { useState } from 'react'; + +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import type { Document } from '@prisma/client'; +import { useNavigation } from 'react-router'; + +import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription, AlertTitle } 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 { Input } from '@documenso/ui/primitives/input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type AdminDocumentDeleteDialogProps = { + document: Document; +}; + +export const AdminDocumentDeleteDialog = ({ document }: AdminDocumentDeleteDialogProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const navigate = useNavigation(); + + const [reason, setReason] = useState(''); + + const { mutateAsync: deleteDocument, isPending: isDeletingDocument } = + trpc.admin.deleteDocument.useMutation(); + + const handleDeleteDocument = async () => { + try { + if (!reason) { + return; + } + + await deleteDocument({ id: document.id, reason }); + + toast({ + title: _(msg`Document deleted`), + description: 'The Document has been deleted successfully.', + duration: 5000, + }); + + void navigate('/admin/documents'); + } catch (err) { + toast({ + title: _(msg`An unknown error occurred`), + variant: 'destructive', + description: + 'We encountered an unknown error while attempting to delete your document. Please try again later.', + }); + } + }; + + return ( +
+
+ +
+ + Delete Document + + + + Delete the document. This action is irreversible so proceed with caution. + + +
+ +
+ + + + + + + + + Delete Document + + + + + This action is not reversible. Please be certain. + + + + +
+ + To confirm, please enter the reason + + + setReason(e.target.value)} + /> +
+ + + + +
+
+
+
+
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/admin-user-delete-dialog.tsx b/apps/remix/app/components/dialogs/admin-user-delete-dialog.tsx new file mode 100644 index 000000000..883d69623 --- /dev/null +++ b/apps/remix/app/components/dialogs/admin-user-delete-dialog.tsx @@ -0,0 +1,137 @@ +import { useState } from 'react'; + +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import type { User } from '@prisma/client'; +import { match } from 'ts-pattern'; + +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription, AlertTitle } 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 { Input } from '@documenso/ui/primitives/input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type AdminUserDeleteDialogProps = { + className?: string; + user: User; +}; + +export const AdminUserDeleteDialog = ({ className, user }: AdminUserDeleteDialogProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const [email, setEmail] = useState(''); + + const { mutateAsync: deleteUser, isPending: isDeletingUser } = + trpc.admin.deleteUser.useMutation(); + + const onDeleteAccount = async () => { + try { + await deleteUser({ + id: user.id, + }); + + toast({ + title: _(msg`Account deleted`), + description: _(msg`The account has been deleted successfully.`), + duration: 5000, + }); + + // todo + // router.push('/admin/users'); + } catch (err) { + const error = AppError.parseError(err); + + const errorMessage = match(error.code) + .with(AppErrorCode.NOT_FOUND, () => msg`User not found.`) + .with(AppErrorCode.UNAUTHORIZED, () => msg`You are not authorized to delete this user.`) + .otherwise(() => msg`An error occurred while deleting the user.`); + + toast({ + title: _(msg`Error`), + description: _(errorMessage), + variant: 'destructive', + duration: 7500, + }); + } + }; + + return ( +
+ +
+ Delete Account + + + Delete the users account and all its contents. This action is irreversible and will + cancel their subscription, so proceed with caution. + + +
+ +
+ + + + + + + + + Delete Account + + + + + This action is not reversible. Please be certain. + + + + +
+ + + To confirm, please enter the accounts email address
({user.email}). +
+
+ + setEmail(e.target.value)} + /> +
+ + + + +
+
+
+
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/admin-user-disable-dialog.tsx b/apps/remix/app/components/dialogs/admin-user-disable-dialog.tsx new file mode 100644 index 000000000..9f822263d --- /dev/null +++ b/apps/remix/app/components/dialogs/admin-user-disable-dialog.tsx @@ -0,0 +1,142 @@ +import { useState } from 'react'; + +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import type { User } from '@prisma/client'; +import { match } from 'ts-pattern'; + +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription, AlertTitle } 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 { Input } from '@documenso/ui/primitives/input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type AdminUserDisableDialogProps = { + className?: string; + userToDisable: User; +}; + +export const AdminUserDisableDialog = ({ + className, + userToDisable, +}: AdminUserDisableDialogProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const [email, setEmail] = useState(''); + + const { mutateAsync: disableUser, isPending: isDisablingUser } = + trpc.admin.disableUser.useMutation(); + + const onDisableAccount = async () => { + try { + await disableUser({ + id: userToDisable.id, + }); + + toast({ + title: _(msg`Account disabled`), + description: _(msg`The account has been disabled successfully.`), + duration: 5000, + }); + } catch (err) { + const error = AppError.parseError(err); + + const errorMessage = match(error.code) + .with(AppErrorCode.NOT_FOUND, () => msg`User not found.`) + .with(AppErrorCode.UNAUTHORIZED, () => msg`You are not authorized to disable this user.`) + .otherwise(() => msg`An error occurred while disabling the user.`); + + toast({ + title: _(msg`Error`), + description: _(errorMessage), + variant: 'destructive', + duration: 7500, + }); + } + }; + + return ( +
+ +
+ Disable Account + + + Disabling the user results in the user not being able to use the account. It also + disables all the related contents such as subscription, webhooks, teams, and API keys. + + +
+ +
+ + + + + + + + + Disable Account + + + + + + This action is reversible, but please be careful as the account may be + affected permanently (e.g. their settings and contents not being restored + properly). + + + + + +
+ + + To confirm, please enter the accounts email address
({userToDisable.email} + ). +
+
+ + setEmail(e.target.value)} + /> +
+ + + + +
+
+
+
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/admin-user-enable-dialog.tsx b/apps/remix/app/components/dialogs/admin-user-enable-dialog.tsx new file mode 100644 index 000000000..8104d7b33 --- /dev/null +++ b/apps/remix/app/components/dialogs/admin-user-enable-dialog.tsx @@ -0,0 +1,128 @@ +import { useState } from 'react'; + +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import type { User } from '@prisma/client'; +import { match } from 'ts-pattern'; + +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription, AlertTitle } 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 { Input } from '@documenso/ui/primitives/input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type AdminUserEnableDialogProps = { + className?: string; + userToEnable: User; +}; + +export const AdminUserEnableDialog = ({ className, userToEnable }: AdminUserEnableDialogProps) => { + const { toast } = useToast(); + const { _ } = useLingui(); + + const [email, setEmail] = useState(''); + + const { mutateAsync: enableUser, isPending: isEnablingUser } = + trpc.admin.enableUser.useMutation(); + + const onEnableAccount = async () => { + try { + await enableUser({ + id: userToEnable.id, + }); + + toast({ + title: _(msg`Account enabled`), + description: _(msg`The account has been enabled successfully.`), + duration: 5000, + }); + } catch (err) { + const error = AppError.parseError(err); + + const errorMessage = match(error.code) + .with(AppErrorCode.NOT_FOUND, () => msg`User not found.`) + .with(AppErrorCode.UNAUTHORIZED, () => msg`You are not authorized to enable this user.`) + .otherwise(() => msg`An error occurred while enabling the user.`); + + toast({ + title: _(msg`Error`), + description: _(errorMessage), + variant: 'destructive', + duration: 7500, + }); + } + }; + + return ( +
+ +
+ Enable Account + + + Enabling the account results in the user being able to use the account again, and all + the related features such as webhooks, teams, and API keys for example. + + +
+ +
+ + + + + + + + + Enable Account + + + +
+ + + To confirm, please enter the accounts email address
({userToEnable.email} + ). +
+
+ + setEmail(e.target.value)} + /> +
+ + + + +
+
+
+
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/create-passkey-dialog.tsx b/apps/remix/app/components/dialogs/create-passkey-dialog.tsx new file mode 100644 index 000000000..2839dfa6a --- /dev/null +++ b/apps/remix/app/components/dialogs/create-passkey-dialog.tsx @@ -0,0 +1,259 @@ +import { useEffect, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { startRegistration } from '@simplewebauthn/browser'; +import { KeyRoundIcon } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import { match } from 'ts-pattern'; +import { UAParser } from 'ua-parser-js'; +import { z } from 'zod'; + +import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth'; +import { AppError } from '@documenso/lib/errors/app-error'; +import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription, AlertTitle } 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 CreatePasskeyDialogProps = { + trigger?: React.ReactNode; + onSuccess?: () => void; +} & Omit; + +const ZCreatePasskeyFormSchema = z.object({ + passkeyName: z.string().min(3), +}); + +type TCreatePasskeyFormSchema = z.infer; + +const parser = new UAParser(); + +export const CreatePasskeyDialog = ({ trigger, onSuccess, ...props }: CreatePasskeyDialogProps) => { + const [open, setOpen] = useState(false); + const [formError, setFormError] = useState(null); + + const { _ } = useLingui(); + const { toast } = useToast(); + + const form = useForm({ + resolver: zodResolver(ZCreatePasskeyFormSchema), + defaultValues: { + passkeyName: '', + }, + }); + + const { mutateAsync: createPasskeyRegistrationOptions, isPending } = + trpc.auth.createPasskeyRegistrationOptions.useMutation(); + + const { mutateAsync: createPasskey } = trpc.auth.createPasskey.useMutation(); + + const onFormSubmit = async ({ passkeyName }: TCreatePasskeyFormSchema) => { + setFormError(null); + + try { + const passkeyRegistrationOptions = await createPasskeyRegistrationOptions(); + + const registrationResult = await startRegistration(passkeyRegistrationOptions); + + await createPasskey({ + passkeyName, + verificationResponse: registrationResult, + }); + + toast({ + description: _(msg`Successfully created passkey`), + duration: 5000, + }); + + onSuccess?.(); + setOpen(false); + } catch (err) { + if (err.name === 'NotAllowedError') { + return; + } + + const error = AppError.parseError(err); + + setFormError(err.code || error.code); + } + }; + + const extractDefaultPasskeyName = () => { + if (!window || !window.navigator) { + return; + } + + parser.setUA(window.navigator.userAgent); + + const result = parser.getResult(); + const operatingSystem = result.os.name; + const browser = result.browser.name; + + let passkeyName = ''; + + if (operatingSystem && browser) { + passkeyName = `${browser} (${operatingSystem})`; + } + + return passkeyName; + }; + + useEffect(() => { + if (!open) { + const defaultPasskeyName = extractDefaultPasskeyName(); + + form.reset({ + passkeyName: defaultPasskeyName, + }); + + setFormError(null); + } + }, [open, form]); + + return ( + !form.formState.isSubmitting && setOpen(value)} + > + e.stopPropagation()} asChild={true}> + {trigger ?? ( + + )} + + + + + + Add passkey + + + + + Passkeys allow you to sign in and authenticate using biometrics, password managers, + etc. + + + + +
+ +
+ ( + + + Passkey name + + + + + + + )} + /> + + + + + When you click continue, you will be prompted to add the first available + authenticator on your system. + + + + + + If you do not want to use the authenticator prompted, you can close it, which + will then display the next available authenticator. + + + + + {formError && ( + + {match(formError) + .with('ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED', () => ( + + This passkey has already been registered. + + )) + .with('TOO_MANY_PASSKEYS', () => ( + + You cannot have more than {MAXIMUM_PASSKEYS} passkeys. + + )) + .with('InvalidStateError', () => ( + <> + + + Passkey creation cancelled due to one of the following reasons: + + + +
    +
  • + Cancelled by user +
  • +
  • + Passkey already exists for the provided authenticator +
  • +
  • + Exceeded timeout +
  • +
+
+ + )) + .otherwise(() => ( + + Something went wrong. Please try again or contact support. + + ))} +
+ )} + + + + + + +
+
+ +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/document-delete-dialog.tsx b/apps/remix/app/components/dialogs/document-delete-dialog.tsx new file mode 100644 index 000000000..81f163e3a --- /dev/null +++ b/apps/remix/app/components/dialogs/document-delete-dialog.tsx @@ -0,0 +1,204 @@ +import { useEffect, useState } from 'react'; + +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { DocumentStatus } from '@prisma/client'; +import { match } from 'ts-pattern'; + +import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; +import { trpc as trpcReact } from '@documenso/trpc/react'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@documenso/ui/primitives/dialog'; +import { Input } from '@documenso/ui/primitives/input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +type DocumentDeleteDialogProps = { + id: number; + open: boolean; + onOpenChange: (_open: boolean) => void; + status: DocumentStatus; + documentTitle: string; + teamId?: number; + canManageDocument: boolean; +}; + +export const DocumentDeleteDialog = ({ + id, + open, + onOpenChange, + status, + documentTitle, + canManageDocument, +}: DocumentDeleteDialogProps) => { + const { toast } = useToast(); + const { refreshLimits } = useLimits(); + const { _ } = useLingui(); + + const deleteMessage = msg`delete`; + + const [inputValue, setInputValue] = useState(''); + const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT); + + const { mutateAsync: deleteDocument, isPending } = trpcReact.document.deleteDocument.useMutation({ + onSuccess: () => { + // todo + // router.refresh(); + void refreshLimits(); + + toast({ + title: _(msg`Document deleted`), + description: _(msg`"${documentTitle}" has been successfully deleted`), + duration: 5000, + }); + + onOpenChange(false); + }, + }); + + useEffect(() => { + if (open) { + setInputValue(''); + setIsDeleteEnabled(status === DocumentStatus.DRAFT); + } + }, [open, status]); + + const onDelete = async () => { + try { + await deleteDocument({ documentId: id }); + } catch { + toast({ + title: _(msg`Something went wrong`), + description: _(msg`This document could not be deleted at this time. Please try again.`), + variant: 'destructive', + duration: 7500, + }); + } + }; + + const onInputChange = (event: React.ChangeEvent) => { + setInputValue(event.target.value); + setIsDeleteEnabled(event.target.value === _(deleteMessage)); + }; + + return ( + !isPending && onOpenChange(value)}> + + + + Are you sure? + + + + {canManageDocument ? ( + + You are about to delete "{documentTitle}" + + ) : ( + + You are about to hide "{documentTitle}" + + )} + + + + {canManageDocument ? ( + + {match(status) + .with(DocumentStatus.DRAFT, () => ( + + + Please note that this action is irreversible. Once confirmed, + this document will be permanently deleted. + + + )) + .with(DocumentStatus.PENDING, () => ( + +

+ + Please note that this action is irreversible. + +

+ +

+ Once confirmed, the following will occur: +

+ +
    +
  • + Document will be permanently deleted +
  • +
  • + Document signing process will be cancelled +
  • +
  • + All inserted signatures will be voided +
  • +
  • + All recipients will be notified +
  • +
+
+ )) + .with(DocumentStatus.COMPLETED, () => ( + +

+ By deleting this document, the following will occur: +

+ +
    +
  • + The document will be hidden from your account +
  • +
  • + Recipients will still retain their copy of the document +
  • +
+
+ )) + .exhaustive()} +
+ ) : ( + + + Please contact support if you would like to revert this action. + + + )} + + {status !== DocumentStatus.DRAFT && canManageDocument && ( + + )} + + + + + + +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx b/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx new file mode 100644 index 000000000..d52959957 --- /dev/null +++ b/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx @@ -0,0 +1,123 @@ +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import type { Team } from '@prisma/client'; +import { useNavigate } from 'react-router'; + +import { formatDocumentsPath } from '@documenso/lib/utils/teams'; +import { trpc as trpcReact } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@documenso/ui/primitives/dialog'; +import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +type DocumentDuplicateDialogProps = { + id: number; + open: boolean; + onOpenChange: (_open: boolean) => void; + team?: Pick; +}; + +export const DocumentDuplicateDialog = ({ + id, + open, + onOpenChange, + team, +}: DocumentDuplicateDialogProps) => { + const navigate = useNavigate(); + + const { toast } = useToast(); + const { _ } = useLingui(); + + const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery({ + documentId: id, + }); + + const documentData = document?.documentData + ? { + ...document.documentData, + data: document.documentData.initialData, + } + : undefined; + + const documentsPath = formatDocumentsPath(team?.url); + + const { mutateAsync: duplicateDocument, isPending: isDuplicateLoading } = + trpcReact.document.duplicateDocument.useMutation({ + onSuccess: ({ documentId }) => { + void navigate(`${documentsPath}/${documentId}/edit`); + + toast({ + title: _(msg`Document Duplicated`), + description: _(msg`Your document has been successfully duplicated.`), + duration: 5000, + }); + + onOpenChange(false); + }, + }); + + const onDuplicate = async () => { + try { + await duplicateDocument({ documentId: id }); + } catch { + toast({ + title: _(msg`Something went wrong`), + description: _(msg`This document could not be duplicated at this time. Please try again.`), + variant: 'destructive', + duration: 7500, + }); + } + }; + + return ( + !isLoading && onOpenChange(value)}> + + + + Duplicate + + + {!documentData || isLoading ? ( +
+

+ Loading Document... +

+
+ ) : ( +
+ +
+ )} + + +
+ + + +
+
+
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/document-move-dialog.tsx b/apps/remix/app/components/dialogs/document-move-dialog.tsx new file mode 100644 index 000000000..e7bfcce70 --- /dev/null +++ b/apps/remix/app/components/dialogs/document-move-dialog.tsx @@ -0,0 +1,127 @@ +import { useState } from 'react'; + +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; + +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +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, isLoading: isLoadingTeams } = trpc.team.getTeams.useQuery(); + + const { mutateAsync: moveDocument, isPending } = trpc.document.moveDocumentToTeam.useMutation({ + onSuccess: () => { + // todo + // router.refresh(); + + 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-resend-dialog.tsx b/apps/remix/app/components/dialogs/document-resend-dialog.tsx new file mode 100644 index 000000000..dacb52f2e --- /dev/null +++ b/apps/remix/app/components/dialogs/document-resend-dialog.tsx @@ -0,0 +1,192 @@ +import { useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import type { Team } from '@prisma/client'; +import { type Document, type Recipient, SigningStatus } from '@prisma/client'; +import { History } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import * as z from 'zod'; + +import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; +import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; +import { trpc as trpcReact } from '@documenso/trpc/react'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; +import { Checkbox } from '@documenso/ui/primitives/checkbox'; +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { DropdownMenuItem } from '@documenso/ui/primitives/dropdown-menu'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, +} from '@documenso/ui/primitives/form/form'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { StackAvatar } from '~/components/(dashboard)/avatar/stack-avatar'; +import { useAuth } from '~/providers/auth'; + +const FORM_ID = 'resend-email'; + +export type DocumentResendDialogProps = { + document: Document & { + team: Pick | null; + }; + recipients: Recipient[]; + team?: Pick; +}; + +export const ZResendDocumentFormSchema = z.object({ + recipients: z.array(z.number()).min(1, { + message: 'You must select at least one item.', + }), +}); + +export type TResendDocumentFormSchema = z.infer; + +export const DocumentResendDialog = ({ document, recipients, team }: DocumentResendDialogProps) => { + const { user } = useAuth(); + + const { toast } = useToast(); + const { _ } = useLingui(); + + const [isOpen, setIsOpen] = useState(false); + const isOwner = document.userId === user.id; + const isCurrentTeamDocument = team && document.team?.url === team.url; + + const isDisabled = + (!isOwner && !isCurrentTeamDocument) || + document.status !== 'PENDING' || + !recipients.some((r) => r.signingStatus === SigningStatus.NOT_SIGNED); + + const { mutateAsync: resendDocument } = trpcReact.document.resendDocument.useMutation(); + + const form = useForm({ + resolver: zodResolver(ZResendDocumentFormSchema), + defaultValues: { + recipients: [], + }, + }); + + const { + handleSubmit, + formState: { isSubmitting }, + } = form; + + const onFormSubmit = async ({ recipients }: TResendDocumentFormSchema) => { + try { + await resendDocument({ documentId: document.id, recipients }); + + toast({ + title: _(msg`Document re-sent`), + description: _(msg`Your document has been re-sent successfully.`), + duration: 5000, + }); + + setIsOpen(false); + } catch (err) { + toast({ + title: _(msg`Something went wrong`), + description: _(msg`This document could not be re-sent at this time. Please try again.`), + variant: 'destructive', + duration: 7500, + }); + } + }; + + return ( + + + e.preventDefault()}> + + Resend + + + + + + +

+ Who do you want to remind? +

+
+
+ +
+ + ( + <> + {recipients.map((recipient) => ( + + + + {recipient.email} + + + + + checked + ? onChange([...value, recipient.id]) + : onChange(value.filter((v) => v !== recipient.id)) + } + /> + + + ))} + + )} + /> + + + + +
+ + + + + +
+
+
+
+ ); +}; diff --git a/apps/remix/app/components/document/document-history-sheet-changes.tsx b/apps/remix/app/components/document/document-history-sheet-changes.tsx new file mode 100644 index 000000000..577dbc473 --- /dev/null +++ b/apps/remix/app/components/document/document-history-sheet-changes.tsx @@ -0,0 +1,26 @@ +import React from 'react'; + +import { Badge } from '@documenso/ui/primitives/badge'; + +export type DocumentHistorySheetChangesProps = { + values: { + key: string | React.ReactNode; + value: string | React.ReactNode; + }[]; +}; + +export const DocumentHistorySheetChanges = ({ values }: DocumentHistorySheetChangesProps) => { + return ( + + {values.map(({ key, value }, i) => ( +

+ {key}: + {value} +

+ ))} +
+ ); +}; diff --git a/apps/remix/app/components/document/document-history-sheet.tsx b/apps/remix/app/components/document/document-history-sheet.tsx new file mode 100644 index 000000000..aaa5eba59 --- /dev/null +++ b/apps/remix/app/components/document/document-history-sheet.tsx @@ -0,0 +1,388 @@ +import { useMemo, useState } from 'react'; + +import { Trans } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { ArrowRightIcon, Loader } from 'lucide-react'; +import { DateTime } from 'luxon'; +import { match } from 'ts-pattern'; +import { UAParser } from 'ua-parser-js'; + +import { DOCUMENT_AUDIT_LOG_EMAIL_FORMAT } from '@documenso/lib/constants/document-audit-logs'; +import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth'; +import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; +import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs'; +import { trpc } from '@documenso/trpc/react'; +import { cn } from '@documenso/ui/lib/utils'; +import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar'; +import { Badge } from '@documenso/ui/primitives/badge'; +import { Button } from '@documenso/ui/primitives/button'; +import { Sheet, SheetContent, SheetTrigger } from '@documenso/ui/primitives/sheet'; + +import { DocumentHistorySheetChanges } from './document-history-sheet-changes'; + +export type DocumentHistorySheetProps = { + documentId: number; + userId: number; + isMenuOpen?: boolean; + onMenuOpenChange?: (_value: boolean) => void; + children?: React.ReactNode; +}; + +export const DocumentHistorySheet = ({ + documentId, + userId, + isMenuOpen, + onMenuOpenChange, + children, +}: DocumentHistorySheetProps) => { + const { _, i18n } = useLingui(); + + const [isUserDetailsVisible, setIsUserDetailsVisible] = useState(false); + + const { + data, + isLoading, + isLoadingError, + refetch, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + } = trpc.document.findDocumentAuditLogs.useInfiniteQuery( + { + documentId, + }, + { + getNextPageParam: (lastPage) => lastPage.nextCursor, + placeholderData: (previousData) => previousData, + }, + ); + + const documentAuditLogs = useMemo(() => (data?.pages ?? []).flatMap((page) => page.data), [data]); + + const extractBrowser = (userAgent?: string | null) => { + if (!userAgent) { + return 'Unknown'; + } + + const parser = new UAParser(userAgent); + + parser.setUA(userAgent); + + const result = parser.getResult(); + + return result.browser.name; + }; + + /** + * Applies the following formatting for a given text: + * - Uppercase first lower, lowercase rest + * - Replace _ with spaces + * + * @param text The text to format + * @returns The formatted text + */ + const formatGenericText = (text?: string | null) => { + if (!text) { + return ''; + } + + return (text.charAt(0).toUpperCase() + text.slice(1).toLowerCase()).replaceAll('_', ' '); + }; + + return ( + + {children && {children}} + + +
+

+ Document history +

+ +
+ + {isLoading && ( +
+ +
+ )} + + {isLoadingError && ( +
+

+ Unable to load document history +

+ +
+ )} + + {data && ( +
    + {documentAuditLogs.map((auditLog) => ( +
  • +
    + + + {(auditLog?.email ?? auditLog?.name ?? '?').slice(0, 1).toUpperCase()} + + + +
    +

    + {formatDocumentAuditLogAction(_, auditLog, userId).description} +

    +

    + {DateTime.fromJSDate(auditLog.createdAt) + .setLocale(i18n.locales?.[0] || i18n.locale) + .toFormat('d MMM, yyyy HH:MM a')} +

    +
    +
    + + {match(auditLog) + .with( + { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED }, + { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED }, + { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED }, + { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED }, + { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED }, + { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED }, + { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT }, + { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_MOVED_TO_TEAM }, + () => null, + ) + .with( + { type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED }, + { type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED }, + ({ data }) => { + const values = [ + { + key: 'Email', + value: data.recipientEmail, + }, + { + key: 'Role', + value: formatGenericText(data.recipientRole), + }, + ]; + + // Insert the name to the start of the array if available. + if (data.recipientName) { + values.unshift({ + key: 'Name', + value: data.recipientName, + }); + } + + return ; + }, + ) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED }, ({ data }) => { + if (data.changes.length === 0) { + return null; + } + + return ( + ({ + key: formatGenericText(type), + value: ( + + {type === 'ROLE' ? formatGenericText(from) : from} + + {type === 'ROLE' ? formatGenericText(to) : to} + + ), + }))} + /> + ); + }) + .with( + { type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED }, + { type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_DELETED }, + { type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED }, + ({ data }) => ( + + ), + ) + .with( + { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED }, + { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED }, + ({ data }) => ( + + ), + ) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED }, ({ data }) => { + if (data.changes.length === 0) { + return null; + } + + return ( + ({ + key: formatGenericText(change.type), + value: change.type === 'PASSWORD' ? '*********' : change.to, + }))} + /> + ); + }) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED }, ({ data }) => ( + + )) + .with( + { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_EXTERNAL_ID_UPDATED }, + ({ data }) => ( + + ), + ) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED }, ({ data }) => ( + + )) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED }, ({ data }) => ( + + )) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT }, ({ data }) => ( + + )) + .with( + { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VISIBILITY_UPDATED }, + ({ data }) => ( + + ), + ) + .exhaustive()} + + {isUserDetailsVisible && ( + <> +
    + + IP: {auditLog.ipAddress ?? 'Unknown'} + + + + Browser: {extractBrowser(auditLog.userAgent)} + +
    + + )} +
  • + ))} + + {hasNextPage && ( +
    + +
    + )} +
+ )} +
+
+ ); +}; diff --git a/apps/remix/app/components/document/document-read-only-fields.tsx b/apps/remix/app/components/document/document-read-only-fields.tsx new file mode 100644 index 000000000..deb179a53 --- /dev/null +++ b/apps/remix/app/components/document/document-read-only-fields.tsx @@ -0,0 +1,171 @@ +import { useState } from 'react'; + +import { Trans } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import type { DocumentMeta } from '@prisma/client'; +import { FieldType, SigningStatus } from '@prisma/client'; +import { Clock, EyeOffIcon } from 'lucide-react'; +import { P, match } from 'ts-pattern'; + +import { + DEFAULT_DOCUMENT_DATE_FORMAT, + convertToLocalSystemFormat, +} from '@documenso/lib/constants/date-formats'; +import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; +import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; +import type { DocumentField } from '@documenso/lib/server-only/field/get-fields-for-document'; +import { parseMessageDescriptor } from '@documenso/lib/utils/i18n'; +import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; +import { FieldRootContainer } from '@documenso/ui/components/field/field'; +import { SignatureIcon } from '@documenso/ui/icons/signature'; +import { cn } from '@documenso/ui/lib/utils'; +import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar'; +import { Badge } from '@documenso/ui/primitives/badge'; +import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types'; +import { ElementVisible } from '@documenso/ui/primitives/element-visible'; +import { PopoverHover } from '@documenso/ui/primitives/popover'; + +export type DocumentReadOnlyFieldsProps = { + fields: DocumentField[]; + documentMeta?: DocumentMeta; + showFieldStatus?: boolean; +}; + +export const DocumentReadOnlyFields = ({ + documentMeta, + fields, + showFieldStatus = true, +}: DocumentReadOnlyFieldsProps) => { + const { _ } = useLingui(); + + const [hiddenFieldIds, setHiddenFieldIds] = useState>({}); + + const handleHideField = (fieldId: string) => { + setHiddenFieldIds((prev) => ({ ...prev, [fieldId]: true })); + }; + + return ( + + {fields.map( + (field) => + !hiddenFieldIds[field.secondaryId] && ( + +
+ + + {extractInitials(field.recipient.name || field.recipient.email)} + + + } + contentProps={{ + className: 'relative flex w-fit flex-col p-4 text-sm', + }} + > + {showFieldStatus && ( + + {field.recipient.signingStatus === SigningStatus.SIGNED ? ( + <> + + Signed + + ) : ( + <> + + Pending + + )} + + )} + +

+ {parseMessageDescriptor(_, FRIENDLY_FIELD_TYPE[field.type])} field +

+ +

+ {field.recipient.name + ? `${field.recipient.name} (${field.recipient.email})` + : field.recipient.email}{' '} +

+ + +
+
+ +
+ {field.recipient.signingStatus === SigningStatus.SIGNED && + match(field) + .with({ type: FieldType.SIGNATURE }, (field) => + field.signature?.signatureImageAsBase64 ? ( + Signature + ) : ( +

+ {field.signature?.typedSignature} +

+ ), + ) + .with( + { + type: P.union( + FieldType.NAME, + FieldType.INITIALS, + FieldType.EMAIL, + FieldType.NUMBER, + FieldType.RADIO, + FieldType.CHECKBOX, + FieldType.DROPDOWN, + ), + }, + () => field.customText, + ) + .with({ type: FieldType.TEXT }, () => field.customText.substring(0, 20) + '...') + .with({ type: FieldType.DATE }, () => + convertToLocalSystemFormat( + field.customText, + documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT, + documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE, + ), + ) + .with({ type: FieldType.FREE_SIGNATURE }, () => null) + .exhaustive()} + + {field.recipient.signingStatus === SigningStatus.NOT_SIGNED && ( +

+ {parseMessageDescriptor(_, FRIENDLY_FIELD_TYPE[field.type])} +

+ )} +
+
+ ), + )} +
+ ); +}; diff --git a/apps/remix/app/components/document/document-recipient-link-copy-dialog.tsx b/apps/remix/app/components/document/document-recipient-link-copy-dialog.tsx new file mode 100644 index 000000000..79464db26 --- /dev/null +++ b/apps/remix/app/components/document/document-recipient-link-copy-dialog.tsx @@ -0,0 +1,148 @@ +import { useEffect, useState } from 'react'; + +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import type { Recipient } from '@prisma/client'; +import { RecipientRole } from '@prisma/client'; +import { useSearchParams } from 'react-router'; + +import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard'; +import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; +import { formatSigningLink } from '@documenso/lib/utils/recipients'; +import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button'; +import { AvatarWithText } from '@documenso/ui/primitives/avatar'; +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'; + +export type DocumentRecipientLinkCopyDialogProps = { + trigger?: React.ReactNode; + recipients: Recipient[]; +}; + +export const DocumentRecipientLinkCopyDialog = ({ + trigger, + recipients, +}: DocumentRecipientLinkCopyDialogProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const [, copy] = useCopyToClipboard(); + + const [searchParams] = useSearchParams(); + const updateSearchParams = useUpdateSearchParams(); + + const [open, setOpen] = useState(false); + + const actionSearchParam = searchParams?.get('action'); + + const onBulkCopy = async () => { + const generatedString = recipients + .filter((recipient) => recipient.role !== RecipientRole.CC) + .map((recipient) => `${recipient.email}\n${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`) + .join('\n\n'); + + await copy(generatedString).then(() => { + toast({ + title: _(msg`Copied to clipboard`), + description: _(msg`All signing links have been copied to your clipboard.`), + }); + }); + }; + + useEffect(() => { + if (actionSearchParam === 'view-signing-links') { + setOpen(true); + updateSearchParams({ action: null }); + } + }, [actionSearchParam, open, setOpen, updateSearchParams]); + + return ( + setOpen(value)}> + e.stopPropagation()}> + {trigger} + + + + + + Copy Signing Links + + + + + You can copy and share these links to recipients so they can action the document. + + + + +
    + {recipients.length === 0 && ( +
  • + No recipients +
  • + )} + + {recipients.map((recipient) => ( +
  • + {recipient.email}

    } + secondaryText={ +

    + {_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)} +

    + } + /> + + {recipient.role !== RecipientRole.CC && ( + { + toast({ + title: _(msg`Copied to clipboard`), + description: _(msg`The signing link has been copied to your clipboard.`), + }); + }} + badgeContentUncopied={ +

    + Copy +

    + } + badgeContentCopied={ +

    + Copied +

    + } + /> + )} +
  • + ))} +
+ + + + + + + + +
+
+ ); +}; diff --git a/apps/remix/app/components/document/document-upload.tsx b/apps/remix/app/components/document/document-upload.tsx new file mode 100644 index 000000000..8fde62bcd --- /dev/null +++ b/apps/remix/app/components/document/document-upload.tsx @@ -0,0 +1,156 @@ +import { useMemo, useState } from 'react'; + +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { Loader } from 'lucide-react'; +import { useNavigate } from 'react-router'; +import { match } from 'ts-pattern'; + +import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; +import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; +import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { formatDocumentsPath } from '@documenso/lib/utils/teams'; +import { trpc } from '@documenso/trpc/react'; +import { cn } from '@documenso/ui/lib/utils'; +import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useAuth } from '~/providers/auth'; + +export type DocumentUploadDropzoneProps = { + className?: string; + team?: { + id: number; + url: string; + }; +}; + +export const DocumentUploadDropzone = ({ className, team }: DocumentUploadDropzoneProps) => { + const navigate = useNavigate(); + + const userTimezone = + TIME_ZONES.find((timezone) => timezone === Intl.DateTimeFormat().resolvedOptions().timeZone) ?? + DEFAULT_DOCUMENT_TIME_ZONE; + + const { user } = useAuth(); + + const { _ } = useLingui(); + const { toast } = useToast(); + + const { quota, remaining, refreshLimits } = useLimits(); + + const [isLoading, setIsLoading] = useState(false); + + const { mutateAsync: createDocument } = trpc.document.createDocument.useMutation(); + + const disabledMessage = useMemo(() => { + if (remaining.documents === 0) { + return team + ? msg`Document upload disabled due to unpaid invoices` + : msg`You have reached your document limit.`; + } + + if (!user.emailVerified) { + return msg`Verify your email to upload documents.`; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [remaining.documents, user.emailVerified, team]); + + const onFileDrop = async (file: File) => { + try { + setIsLoading(true); + + // Todo + // const { type, data } = await putPdfFile(file); + + // const { id: documentDataId } = await createDocumentData({ + // type, + // data, + // }); + + // const { id } = await createDocument({ + // title: file.name, + // documentDataId, + // timezone: userTimezone, + // }); + + void refreshLimits(); + + toast({ + title: _(msg`Document uploaded`), + description: _(msg`Your document has been uploaded successfully.`), + duration: 5000, + }); + + // Todo + // analytics.capture('App: Document Uploaded', { + // userId: session?.user.id, + // documentId: id, + // timestamp: new Date().toISOString(), + // }); + + void navigate(`${formatDocumentsPath(team?.url)}/${id}/edit`); + } catch (err) { + const error = AppError.parseError(err); + + console.error(err); + + const errorMessage = match(error.code) + .with('INVALID_DOCUMENT_FILE', () => msg`You cannot upload encrypted PDFs`) + .with( + AppErrorCode.LIMIT_EXCEEDED, + () => msg`You have reached your document limit for this month. Please upgrade your plan.`, + ) + .otherwise(() => msg`An error occurred while uploading your document.`); + + toast({ + title: _(msg`Error`), + description: _(errorMessage), + variant: 'destructive', + duration: 7500, + }); + } finally { + setIsLoading(false); + } + }; + + const onFileDropRejected = () => { + toast({ + title: _(msg`Your document failed to upload.`), + description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`), + duration: 5000, + variant: 'destructive', + }); + }; + + return ( +
+ + +
+ {team?.id === undefined && + remaining.documents > 0 && + Number.isFinite(remaining.documents) && ( +

+ + {remaining.documents} of {quota.documents} documents remaining this month. + +

+ )} +
+ + {isLoading && ( +
+ +
+ )} +
+ ); +}; diff --git a/apps/remix/app/components/formatter/document-status.tsx b/apps/remix/app/components/formatter/document-status.tsx new file mode 100644 index 000000000..d69c67ea1 --- /dev/null +++ b/apps/remix/app/components/formatter/document-status.tsx @@ -0,0 +1,79 @@ +import type { HTMLAttributes } from 'react'; + +import type { MessageDescriptor } from '@lingui/core'; +import { msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import type { ExtendedDocumentStatus } from '@prisma/types/extended-document-status'; +import { CheckCircle2, Clock, File } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react/dist/lucide-react'; + +import { SignatureIcon } from '@documenso/ui/icons/signature'; +import { cn } from '@documenso/ui/lib/utils'; + +type FriendlyStatus = { + label: MessageDescriptor; + labelExtended: MessageDescriptor; + icon?: LucideIcon; + color: string; +}; + +export const FRIENDLY_STATUS_MAP: Record = { + PENDING: { + label: msg`Pending`, + labelExtended: msg`Document pending`, + icon: Clock, + color: 'text-blue-600 dark:text-blue-300', + }, + COMPLETED: { + label: msg`Completed`, + labelExtended: msg`Document completed`, + icon: CheckCircle2, + color: 'text-green-500 dark:text-green-300', + }, + DRAFT: { + label: msg`Draft`, + labelExtended: msg`Document draft`, + icon: File, + color: 'text-yellow-500 dark:text-yellow-200', + }, + INBOX: { + label: msg`Inbox`, + labelExtended: msg`Document inbox`, + icon: SignatureIcon, + color: 'text-muted-foreground', + }, + ALL: { + label: msg`All`, + labelExtended: msg`Document All`, + color: 'text-muted-foreground', + }, +}; + +export type DocumentStatusProps = HTMLAttributes & { + status: ExtendedDocumentStatus; + inheritColor?: boolean; +}; + +export const DocumentStatus = ({ + className, + status, + inheritColor, + ...props +}: DocumentStatusProps) => { + const { _ } = useLingui(); + + const { label, icon: Icon, color } = FRIENDLY_STATUS_MAP[status]; + + return ( + + {Icon && ( + + )} + {_(label)} + + ); +}; diff --git a/apps/remix/app/components/formatter/template-type.tsx b/apps/remix/app/components/formatter/template-type.tsx new file mode 100644 index 000000000..be941db3f --- /dev/null +++ b/apps/remix/app/components/formatter/template-type.tsx @@ -0,0 +1,55 @@ +import type { HTMLAttributes } from 'react'; + +import type { MessageDescriptor } from '@lingui/core'; +import { msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import type { TemplateType as TemplateTypePrisma } from '@prisma/client'; +import { Globe2, Lock } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react/dist/lucide-react'; + +import { cn } from '@documenso/ui/lib/utils'; + +type TemplateTypeIcon = { + label: MessageDescriptor; + icon?: LucideIcon; + color: string; +}; + +type TemplateTypes = (typeof TemplateTypePrisma)[keyof typeof TemplateTypePrisma]; + +const TEMPLATE_TYPES: Record = { + PRIVATE: { + label: msg`Private`, + icon: Lock, + color: 'text-blue-600 dark:text-blue-300', + }, + PUBLIC: { + label: msg`Public`, + icon: Globe2, + color: 'text-green-500 dark:text-green-300', + }, +}; + +export type TemplateTypeProps = HTMLAttributes & { + type: TemplateTypes; + inheritColor?: boolean; +}; + +export const TemplateType = ({ className, type, inheritColor, ...props }: TemplateTypeProps) => { + const { _ } = useLingui(); + + const { label, icon: Icon, color } = TEMPLATE_TYPES[type]; + + return ( + + {Icon && ( + + )} + {_(label)} + + ); +}; diff --git a/apps/remix/app/components/forms/2fa/disable-authenticator-app-dialog.tsx b/apps/remix/app/components/forms/2fa/disable-authenticator-app-dialog.tsx new file mode 100644 index 000000000..1f72013eb --- /dev/null +++ b/apps/remix/app/components/forms/2fa/disable-authenticator-app-dialog.tsx @@ -0,0 +1,196 @@ +import { useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { flushSync } from 'react-dom'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +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 { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export const ZDisable2FAForm = z.object({ + totpCode: z.string().trim().optional(), + backupCode: z.string().trim().optional(), +}); + +export type TDisable2FAForm = z.infer; + +export const DisableAuthenticatorAppDialog = () => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const [isOpen, setIsOpen] = useState(false); + const [twoFactorDisableMethod, setTwoFactorDisableMethod] = useState<'totp' | 'backup'>('totp'); + + const { mutateAsync: disable2FA } = trpc.twoFactorAuthentication.disable.useMutation(); + + const disable2FAForm = useForm({ + defaultValues: { + totpCode: '', + backupCode: '', + }, + resolver: zodResolver(ZDisable2FAForm), + }); + + const onCloseTwoFactorDisableDialog = () => { + disable2FAForm.reset(); + + setIsOpen(!isOpen); + }; + + const onToggleTwoFactorDisableMethodClick = () => { + const method = twoFactorDisableMethod === 'totp' ? 'backup' : 'totp'; + + if (method === 'totp') { + disable2FAForm.setValue('backupCode', ''); + } + + if (method === 'backup') { + disable2FAForm.setValue('totpCode', ''); + } + + setTwoFactorDisableMethod(method); + }; + + const { isSubmitting: isDisable2FASubmitting } = disable2FAForm.formState; + + const onDisable2FAFormSubmit = async ({ totpCode, backupCode }: TDisable2FAForm) => { + try { + await disable2FA({ totpCode, backupCode }); + + toast({ + title: _(msg`Two-factor authentication disabled`), + description: _( + msg`Two-factor authentication has been disabled for your account. You will no longer be required to enter a code from your authenticator app when signing in.`, + ), + }); + + flushSync(() => { + onCloseTwoFactorDisableDialog(); + }); + + // Todo + // router.refresh(); + } catch (_err) { + toast({ + title: _(msg`Unable to disable two-factor authentication`), + description: _( + msg`We were unable to disable two-factor authentication for your account. Please ensure that you have entered your password and backup code correctly and try again.`, + ), + variant: 'destructive', + }); + } + }; + + return ( + + + + + + + + + Disable 2FA + + + + + Please provide a token from the authenticator, or a backup code. If you do not have a + backup code available, please contact support. + + + + +
+ +
+ {twoFactorDisableMethod === 'totp' && ( + ( + + + + {Array(6) + .fill(null) + .map((_, i) => ( + + + + ))} + + + + + )} + /> + )} + + {twoFactorDisableMethod === 'backup' && ( + ( + + + Backup Code + + + + + + + )} + /> + )} + + + + + + +
+
+ +
+
+ ); +}; diff --git a/apps/remix/app/components/forms/2fa/enable-authenticator-app-dialog.tsx b/apps/remix/app/components/forms/2fa/enable-authenticator-app-dialog.tsx new file mode 100644 index 000000000..67167771c --- /dev/null +++ b/apps/remix/app/components/forms/2fa/enable-authenticator-app-dialog.tsx @@ -0,0 +1,269 @@ +import { useEffect, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { useForm } from 'react-hook-form'; +import { renderSVG } from 'uqr'; +import { z } from 'zod'; + +import { downloadFile } from '@documenso/lib/client-only/download-file'; +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 { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { RecoveryCodeList } from './recovery-code-list'; + +export const ZEnable2FAForm = z.object({ + token: z.string(), +}); + +export type TEnable2FAForm = z.infer; + +export type EnableAuthenticatorAppDialogProps = { + onSuccess?: () => void; +}; + +export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorAppDialogProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const [isOpen, setIsOpen] = useState(false); + const [recoveryCodes, setRecoveryCodes] = useState(null); + + const { mutateAsync: enable2FA } = trpc.twoFactorAuthentication.enable.useMutation(); + + const { + mutateAsync: setup2FA, + data: setup2FAData, + isPending: isSettingUp2FA, + } = trpc.twoFactorAuthentication.setup.useMutation({ + onError: () => { + toast({ + title: _(msg`Unable to setup two-factor authentication`), + description: _( + msg`We were unable to setup two-factor authentication for your account. Please ensure that you have entered your code correctly and try again.`, + ), + variant: 'destructive', + }); + }, + }); + + const enable2FAForm = useForm({ + defaultValues: { + token: '', + }, + resolver: zodResolver(ZEnable2FAForm), + }); + + const { isSubmitting: isEnabling2FA } = enable2FAForm.formState; + + const onEnable2FAFormSubmit = async ({ token }: TEnable2FAForm) => { + try { + const data = await enable2FA({ code: token }); + + setRecoveryCodes(data.recoveryCodes); + onSuccess?.(); + + toast({ + title: _(msg`Two-factor authentication enabled`), + description: _( + msg`You will now be required to enter a code from your authenticator app when signing in.`, + ), + }); + } catch (_err) { + toast({ + title: _(msg`Unable to setup two-factor authentication`), + description: _( + msg`We were unable to setup two-factor authentication for your account. Please ensure that you have entered your code correctly and try again.`, + ), + variant: 'destructive', + }); + } + }; + + const downloadRecoveryCodes = () => { + if (recoveryCodes) { + const blob = new Blob([recoveryCodes.join('\n')], { + type: 'text/plain', + }); + + downloadFile({ + filename: 'documenso-2FA-recovery-codes.txt', + data: blob, + }); + } + }; + + const handleEnable2FA = async () => { + if (!setup2FAData) { + await setup2FA(); + } + + setIsOpen(true); + }; + + useEffect(() => { + enable2FAForm.reset(); + + if (!isOpen && recoveryCodes && recoveryCodes.length > 0) { + setRecoveryCodes(null); + // Todo + // router.refresh(); + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen]); + + return ( + + + + + + + {setup2FAData && ( + <> + {recoveryCodes ? ( +
+ + + Backup codes + + + + Your recovery codes are listed below. Please store them in a safe place. + + + + +
+ +
+ + + + + + + + +
+ ) : ( +
+ + + + Enable Authenticator App + + + + To enable two-factor authentication, scan the following QR code using your + authenticator app. + + + + +
+
+ +

+ + If your authenticator app does not support QR codes, you can use the + following code instead: + +

+ +

+ {setup2FAData?.secret} +

+ +

+ + Once you have scanned the QR code or entered the code manually, enter the + code provided by your authenticator app below. + +

+ + ( + + + Token + + + + {Array(6) + .fill(null) + .map((_, i) => ( + + + + ))} + + + + + )} + /> + + + + + + + + +
+
+ + )} + + )} +
+
+ ); +}; diff --git a/apps/remix/app/components/forms/2fa/recovery-code-list.tsx b/apps/remix/app/components/forms/2fa/recovery-code-list.tsx new file mode 100644 index 000000000..2b72883f2 --- /dev/null +++ b/apps/remix/app/components/forms/2fa/recovery-code-list.tsx @@ -0,0 +1,61 @@ +import { msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { Copy } from 'lucide-react'; + +import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type RecoveryCodeListProps = { + recoveryCodes: string[]; +}; + +export const RecoveryCodeList = ({ recoveryCodes }: RecoveryCodeListProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + const [, copyToClipboard] = useCopyToClipboard(); + + const onCopyRecoveryCodeClick = async (code: string) => { + try { + const result = await copyToClipboard(code); + + if (!result) { + throw new Error('Unable to copy recovery code'); + } + + toast({ + title: _(msg`Recovery code copied`), + description: _(msg`Your recovery code has been copied to your clipboard.`), + }); + } catch (_err) { + toast({ + title: _(msg`Unable to copy recovery code`), + description: _( + msg`We were unable to copy your recovery code to your clipboard. Please try again.`, + ), + variant: 'destructive', + }); + } + }; + + return ( +
+ {recoveryCodes.map((code) => ( +
+ {code} + +
+ +
+
+ ))} +
+ ); +}; diff --git a/apps/remix/app/components/forms/2fa/view-recovery-codes-dialog.tsx b/apps/remix/app/components/forms/2fa/view-recovery-codes-dialog.tsx new file mode 100644 index 000000000..8f933951b --- /dev/null +++ b/apps/remix/app/components/forms/2fa/view-recovery-codes-dialog.tsx @@ -0,0 +1,176 @@ +import { useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans } from '@lingui/macro'; +import { useForm } from 'react-hook-form'; +import { match } from 'ts-pattern'; +import { z } from 'zod'; + +import { downloadFile } from '@documenso/lib/client-only/download-file'; +import { AppError } from '@documenso/lib/errors/app-error'; +import { ErrorCode } from '@documenso/lib/next-auth/error-codes'; +import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input'; + +import { RecoveryCodeList } from './recovery-code-list'; + +export const ZViewRecoveryCodesForm = z.object({ + token: z.string().min(1, { message: 'Token is required' }), +}); + +export type TViewRecoveryCodesForm = z.infer; + +export const ViewRecoveryCodesDialog = () => { + const [isOpen, setIsOpen] = useState(false); + + const { + data: recoveryCodes, + mutate, + isPending, + error, + } = trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation(); + + const viewRecoveryCodesForm = useForm({ + defaultValues: { + token: '', + }, + resolver: zodResolver(ZViewRecoveryCodesForm), + }); + + const downloadRecoveryCodes = () => { + if (recoveryCodes) { + const blob = new Blob([recoveryCodes.join('\n')], { + type: 'text/plain', + }); + + downloadFile({ + filename: 'documenso-2FA-recovery-codes.txt', + data: blob, + }); + } + }; + + return ( + + + + + + + {recoveryCodes ? ( +
+ + + View Recovery Codes + + + + + Your recovery codes are listed below. Please store them in a safe place. + + + + + + + + + + + + + +
+ ) : ( +
+ mutate(value))}> + + + View Recovery Codes + + + + Please provide a token from your authenticator, or a backup code. + + + +
+ ( + + + + {Array(6) + .fill(null) + .map((_, i) => ( + + + + ))} + + + + + )} + /> + + {error && ( + + + {match(AppError.parseError(error).message) + .with(ErrorCode.INCORRECT_TWO_FACTOR_CODE, () => ( + Invalid code. Please try again. + )) + .otherwise(() => ( + Something went wrong. Please try again or contact support. + ))} + + + )} + + + + + + + + +
+
+ + )} +
+
+ ); +}; diff --git a/apps/remix/app/components/forms/avatar-image.tsx b/apps/remix/app/components/forms/avatar-image.tsx new file mode 100644 index 000000000..e7a9180ae --- /dev/null +++ b/apps/remix/app/components/forms/avatar-image.tsx @@ -0,0 +1,186 @@ +import { useMemo } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +// Todo +// import { ErrorCode, useDropzone } from 'react-dropzone'; +import { useForm } from 'react-hook-form'; +import { match } from 'ts-pattern'; +import { z } from 'zod'; + +import { AppError } from '@documenso/lib/errors/app-error'; +import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; +import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; +import { trpc } from '@documenso/trpc/react'; +import { cn } from '@documenso/ui/lib/utils'; +import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useAuth } from '~/providers/auth'; +import { useOptionalCurrentTeam } from '~/providers/team'; + +export const ZAvatarImageFormSchema = z.object({ + bytes: z.string().nullish(), +}); + +export type TAvatarImageFormSchema = z.infer; + +export type AvatarImageFormProps = { + className?: string; +}; + +export const AvatarImageForm = ({ className }: AvatarImageFormProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + const { user } = useAuth(); + + const team = useOptionalCurrentTeam(); + + const { mutateAsync: setProfileImage } = trpc.profile.setProfileImage.useMutation(); + + const initials = extractInitials(team?.name || user.name || ''); + + const hasAvatarImage = useMemo(() => { + if (team) { + return team.avatarImageId !== null; + } + + return user.avatarImageId !== null; + }, [team, user.avatarImageId]); + + const avatarImageId = team ? team.avatarImageId : user.avatarImageId; + + const form = useForm({ + values: { + bytes: null, + }, + resolver: zodResolver(ZAvatarImageFormSchema), + }); + + // const { getRootProps, getInputProps } = useDropzone({ + // maxSize: 1024 * 1024, + // accept: { + // 'image/*': ['.png', '.jpg', '.jpeg'], + // }, + // multiple: false, + // onDropAccepted: ([file]) => { + // void file.arrayBuffer().then((buffer) => { + // const contents = base64.encode(new Uint8Array(buffer)); + + // form.setValue('bytes', contents); + // void form.handleSubmit(onFormSubmit)(); + // }); + // }, + // onDropRejected: ([file]) => { + // form.setError('bytes', { + // type: 'onChange', + // message: match(file.errors[0].code) + // .with(ErrorCode.FileTooLarge, () => _(msg`Uploaded file is too large`)) + // .with(ErrorCode.FileTooSmall, () => _(msg`Uploaded file is too small`)) + // .with(ErrorCode.FileInvalidType, () => _(msg`Uploaded file not an allowed file type`)) + // .otherwise(() => _(msg`An unknown error occurred`)), + // }); + // }, + // }); + + const onFormSubmit = async (data: TAvatarImageFormSchema) => { + try { + await setProfileImage({ + bytes: data.bytes, + teamId: team?.id, + }); + + toast({ + title: _(msg`Avatar Updated`), + description: _(msg`Your avatar has been updated successfully.`), + duration: 5000, + }); + + // router.refresh(); // Todo + } catch (err) { + const error = AppError.parseError(err); + + const errorMessage = match(error.code).otherwise( + () => + msg`We encountered an unknown error while attempting to update your password. Please try again later.`, + ); + + toast({ + title: _(msg`An error occurred`), + description: _(errorMessage), + variant: 'destructive', + }); + } + }; + + return ( +
+ +
+ ( + + + Avatar + + + +
+
+ + {avatarImageId && } + + {initials} + + + + {hasAvatarImage && ( + + )} +
+ + +
+
+ + +
+ )} + /> +
+
+ + ); +}; diff --git a/apps/remix/app/components/forms/forgot-password.tsx b/apps/remix/app/components/forms/forgot-password.tsx new file mode 100644 index 000000000..09f99622c --- /dev/null +++ b/apps/remix/app/components/forms/forgot-password.tsx @@ -0,0 +1,95 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router'; +import { z } from 'zod'; + +import { trpc } from '@documenso/trpc/react'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export const ZForgotPasswordFormSchema = z.object({ + email: z.string().email().min(1), +}); + +export type TForgotPasswordFormSchema = z.infer; + +export type ForgotPasswordFormProps = { + className?: string; +}; + +export const ForgotPasswordForm = ({ className }: ForgotPasswordFormProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const navigate = useNavigate(); + + const form = useForm({ + values: { + email: '', + }, + resolver: zodResolver(ZForgotPasswordFormSchema), + }); + + const isSubmitting = form.formState.isSubmitting; + + const { mutateAsync: forgotPassword } = trpc.profile.forgotPassword.useMutation(); + + const onFormSubmit = async ({ email }: TForgotPasswordFormSchema) => { + await forgotPassword({ email }).catch(() => null); + + toast({ + title: _(msg`Reset email sent`), + description: _( + msg`A password reset email has been sent, if you have an account you should see it in your inbox shortly.`, + ), + duration: 5000, + }); + + form.reset(); + + navigate('/check-email'); + }; + + return ( +
+ +
+ ( + + + Email + + + + + + + )} + /> +
+ + +
+ + ); +}; diff --git a/apps/remix/app/components/forms/password.tsx b/apps/remix/app/components/forms/password.tsx new file mode 100644 index 000000000..eb02e2a20 --- /dev/null +++ b/apps/remix/app/components/forms/password.tsx @@ -0,0 +1,161 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import type { User } from '@prisma/client'; +import { useForm } from 'react-hook-form'; +import { match } from 'ts-pattern'; +import { z } from 'zod'; + +import { AppError } from '@documenso/lib/errors/app-error'; +import { trpc } from '@documenso/trpc/react'; +import { ZCurrentPasswordSchema, ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { PasswordInput } from '@documenso/ui/primitives/password-input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export const ZPasswordFormSchema = z + .object({ + currentPassword: ZCurrentPasswordSchema, + password: ZPasswordSchema, + repeatedPassword: ZPasswordSchema, + }) + .refine((data) => data.password === data.repeatedPassword, { + message: 'Passwords do not match', + path: ['repeatedPassword'], + }); + +export type TPasswordFormSchema = z.infer; + +export type PasswordFormProps = { + className?: string; + user: User; +}; + +export const PasswordForm = ({ className }: PasswordFormProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const form = useForm({ + values: { + currentPassword: '', + password: '', + repeatedPassword: '', + }, + resolver: zodResolver(ZPasswordFormSchema), + }); + + const isSubmitting = form.formState.isSubmitting; + + const { mutateAsync: updatePassword } = trpc.profile.updatePassword.useMutation(); + + const onFormSubmit = async ({ currentPassword, password }: TPasswordFormSchema) => { + try { + await updatePassword({ + currentPassword, + password, + }); + + form.reset(); + + toast({ + title: _(msg`Password updated`), + description: _(msg`Your password has been updated successfully.`), + duration: 5000, + }); + } catch (err) { + const error = AppError.parseError(err); + + const errorMessage = match(error.code) + .with('NO_PASSWORD', () => msg`User has no password.`) + .with('INCORRECT_PASSWORD', () => msg`Current password is incorrect.`) + .with( + 'SAME_PASSWORD', + () => msg`Your new password cannot be the same as your old password.`, + ) + .otherwise( + () => + msg`We encountered an unknown error while attempting to update your password. Please try again later.`, + ); + + toast({ + title: _(msg`An error occurred`), + description: _(errorMessage), + variant: 'destructive', + }); + } + }; + + return ( +
+ +
+ ( + + + Current Password + + + + + + + )} + /> + + ( + + + Password + + + + + + + )} + /> + + ( + + + Repeat Password + + + + + + + )} + /> +
+ +
+ +
+
+ + ); +}; diff --git a/apps/remix/app/components/forms/profile.tsx b/apps/remix/app/components/forms/profile.tsx new file mode 100644 index 000000000..16429fce1 --- /dev/null +++ b/apps/remix/app/components/forms/profile.tsx @@ -0,0 +1,142 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { trpc } from '@documenso/trpc/react'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { Label } from '@documenso/ui/primitives/label'; +import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useAuth } from '~/providers/auth'; + +export const ZProfileFormSchema = z.object({ + name: z.string().trim().min(1, { message: 'Please enter a valid name.' }), + signature: z.string().min(1, 'Signature Pad cannot be empty'), +}); + +export const ZTwoFactorAuthTokenSchema = z.object({ + token: z.string(), +}); + +export type TTwoFactorAuthTokenSchema = z.infer; +export type TProfileFormSchema = z.infer; + +export type ProfileFormProps = { + className?: string; +}; + +export const ProfileForm = ({ className }: ProfileFormProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + const { user } = useAuth(); + + const form = useForm({ + values: { + name: user.name ?? '', + signature: user.signature || '', + }, + resolver: zodResolver(ZProfileFormSchema), + }); + + const isSubmitting = form.formState.isSubmitting; + + const { mutateAsync: updateProfile } = trpc.profile.updateProfile.useMutation(); + + const onFormSubmit = async ({ name, signature }: TProfileFormSchema) => { + try { + await updateProfile({ + name, + signature, + }); + + toast({ + title: _(msg`Profile updated`), + description: _(msg`Your profile has been updated successfully.`), + duration: 5000, + }); + + // router.refresh(); // Todo + } catch (err) { + toast({ + title: _(msg`An unknown error occurred`), + description: _( + msg`We encountered an unknown error while attempting update your profile. Please try again later.`, + ), + variant: 'destructive', + }); + } + }; + + return ( +
+ +
+ ( + + + Full Name + + + + + + + )} + /> + +
+ + +
+ ( + + + Signature + + + onChange(v ?? '')} + allowTypedSignature={true} + /> + + + + )} + /> +
+ + +
+ + ); +}; diff --git a/apps/remix/app/components/forms/public-profile-claim-dialog.tsx b/apps/remix/app/components/forms/public-profile-claim-dialog.tsx new file mode 100644 index 000000000..57002c78b --- /dev/null +++ b/apps/remix/app/components/forms/public-profile-claim-dialog.tsx @@ -0,0 +1,197 @@ +import React, { useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import type { User } from '@prisma/client'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import profileClaimTeaserImage from '@documenso/assets/images/profile-claim-teaser.png'; +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { trpc } from '@documenso/trpc/react'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { UserProfileSkeleton } from '../ui/user-profile-skeleton'; + +export const ZClaimPublicProfileFormSchema = z.object({ + url: z + .string() + .trim() + .toLowerCase() + .min(1, { message: 'Please enter a valid username.' }) + .regex(/^[a-z0-9-]+$/, { + message: 'Username can only container alphanumeric characters and dashes.', + }), +}); + +export type TClaimPublicProfileFormSchema = z.infer; + +export type ClaimPublicProfileDialogFormProps = { + open: boolean; + onOpenChange?: (open: boolean) => void; + onClaimed?: () => void; + user: User; +}; + +export const ClaimPublicProfileDialogForm = ({ + open, + onOpenChange, + onClaimed, + user, +}: ClaimPublicProfileDialogFormProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const [claimed, setClaimed] = useState(false); + + const baseUrl = new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000'); + + const form = useForm({ + values: { + url: user.url || '', + }, + resolver: zodResolver(ZClaimPublicProfileFormSchema), + }); + + const { mutateAsync: updatePublicProfile } = trpc.profile.updatePublicProfile.useMutation(); + + const isSubmitting = form.formState.isSubmitting; + + const onFormSubmit = async ({ url }: TClaimPublicProfileFormSchema) => { + try { + await updatePublicProfile({ + url, + }); + + setClaimed(true); + onClaimed?.(); + } catch (err) { + const error = AppError.parseError(err); + + if (error.code === AppErrorCode.PROFILE_URL_TAKEN) { + form.setError('url', { + type: 'manual', + message: _(msg`This username is already taken`), + }); + } else if (error.code === AppErrorCode.PREMIUM_PROFILE_URL) { + form.setError('url', { + type: 'manual', + message: error.message, + }); + } else if (error.code !== AppErrorCode.UNKNOWN_ERROR) { + toast({ + title: 'An error occurred', + description: error.userMessage ?? error.message, + variant: 'destructive', + }); + } else { + toast({ + title: _(msg`An unknown error occurred`), + description: _( + msg`We encountered an unknown error while attempting to save your details. Please try again later.`, + ), + variant: 'destructive', + }); + } + } + }; + + return ( + + + {!claimed && ( + <> + + + Introducing public profiles! + + + + Reserve your Documenso public profile username + + + + profile claim teaser + +
+ +
+ ( + + Public profile username + + + + + + + +
+ {baseUrl.host}/u/{field.value || ''} +
+
+ )} + /> +
+ +
+ +
+
+ + + )} + + {claimed && ( + <> + + All set! + + + We will let you know as soon as this features is launched + + + + + +
+ +
+ + )} +
+
+ ); +}; diff --git a/apps/remix/app/components/forms/public-profile-form.tsx b/apps/remix/app/components/forms/public-profile-form.tsx new file mode 100644 index 000000000..9bb6e7521 --- /dev/null +++ b/apps/remix/app/components/forms/public-profile-form.tsx @@ -0,0 +1,284 @@ +import { useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Plural, Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import type { TeamProfile, UserProfile } from '@prisma/client'; +import { motion } from 'framer-motion'; +import { AnimatePresence } from 'framer-motion'; +import { CheckSquareIcon, CopyIcon } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import type { z } from 'zod'; + +import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { formatUserProfilePath } from '@documenso/lib/utils/public-profiles'; +import { + MAX_PROFILE_BIO_LENGTH, + ZUpdatePublicProfileMutationSchema, +} from '@documenso/trpc/server/profile-router/schema'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { Textarea } from '@documenso/ui/primitives/textarea'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export const ZPublicProfileFormSchema = ZUpdatePublicProfileMutationSchema.pick({ + bio: true, + enabled: true, + url: true, +}); + +export type TPublicProfileFormSchema = z.infer; + +export type PublicProfileFormProps = { + className?: string; + profileUrl?: string | null; + teamUrl?: string; + onProfileUpdate: (data: TPublicProfileFormSchema) => Promise; + profile: UserProfile | TeamProfile; +}; +export const PublicProfileForm = ({ + className, + profileUrl, + profile, + teamUrl, + onProfileUpdate, +}: PublicProfileFormProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const [, copy] = useCopyToClipboard(); + + const [copiedTimeout, setCopiedTimeout] = useState(null); + + const form = useForm({ + values: { + url: profileUrl ?? '', + bio: profile?.bio ?? '', + }, + resolver: zodResolver(ZPublicProfileFormSchema), + }); + + const isSubmitting = form.formState.isSubmitting; + + const onFormSubmit = async (data: TPublicProfileFormSchema) => { + try { + await onProfileUpdate(data); + + toast({ + title: _(msg`Success`), + description: _(msg`Your public profile has been updated.`), + duration: 5000, + }); + + form.reset({ + url: data.url, + bio: data.bio, + }); + } catch (err) { + const error = AppError.parseError(err); + + switch (error.code) { + case AppErrorCode.PREMIUM_PROFILE_URL: + case AppErrorCode.PROFILE_URL_TAKEN: + form.setError('url', { + type: 'manual', + message: error.message, + }); + + break; + + default: + toast({ + title: _(msg`An unknown error occurred`), + description: _( + msg`We encountered an unknown error while attempting to update your public profile. Please try again later.`, + ), + variant: 'destructive', + }); + } + } + }; + + const onCopy = async () => { + await copy(formatUserProfilePath(form.getValues('url') ?? '')).then(() => { + toast({ + title: _(msg`Copied to clipboard`), + description: _(msg`The profile link has been copied to your clipboard`), + }); + }); + + if (copiedTimeout) { + clearTimeout(copiedTimeout); + } + + setCopiedTimeout( + setTimeout(() => { + setCopiedTimeout(null); + }, 2000), + ); + }; + + return ( +
+ +
+ ( + + + Public profile URL + + + + + + {teamUrl && ( +

+ + You can update the profile URL by updating the team URL in the general + settings page. + +

+ )} + +
+ {!form.formState.errors.url && ( +
+ {field.value ? ( +
+ +
+ ) : ( +

+ A unique URL to access your profile +

+ )} +
+ )} + + +
+
+ )} + /> + + { + const remaningLength = MAX_PROFILE_BIO_LENGTH - (field.value || '').length; + + return ( + + Bio + +