mirror of
https://github.com/documenso/documenso.git
synced 2025-11-19 11:12:06 +10:00
fix: chao nextjs
This commit is contained in:
@ -25,26 +25,45 @@
|
||||
"@hono/node-server": "^1.13.7",
|
||||
"@hono/trpc-server": "^0.3.4",
|
||||
"@hono/zod-validator": "^0.4.2",
|
||||
"@hookform/resolvers": "^3.1.0",
|
||||
"@lingui/core": "^4.11.3",
|
||||
"@lingui/detect-locale": "^4.11.1",
|
||||
"@lingui/macro": "^4.11.3",
|
||||
"@lingui/react": "^4.11.3",
|
||||
"@oslojs/crypto": "^1.0.1",
|
||||
"@oslojs/encoding": "^1.1.0",
|
||||
"@simplewebauthn/browser": "^9.0.1",
|
||||
"@simplewebauthn/server": "^9.0.3",
|
||||
"@react-router/node": "^7.1.5",
|
||||
"@react-router/serve": "^7.1.5",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"colord": "^2.9.3",
|
||||
"framer-motion": "^10.12.8",
|
||||
"hono": "4.6.15",
|
||||
"hono-react-router-adapter": "^0.6.2",
|
||||
"input-otp": "^1.2.4",
|
||||
"luxon": "^3.4.0",
|
||||
"lucide-react": "^0.279.0",
|
||||
"isbot": "^5.1.17",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"papaparse": "^5.4.1",
|
||||
"posthog-js": "^1.75.3",
|
||||
"posthog-node": "^3.1.1",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"react-router": "^7.1.5",
|
||||
"react-call": "^1.3.0",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-hook-form": "^7.43.9",
|
||||
"react-hotkeys-hook": "^4.4.1",
|
||||
"react-icons": "^4.11.0",
|
||||
"react-rnd": "^10.4.1",
|
||||
"recharts": "^2.7.2",
|
||||
"remeda": "^2.17.3",
|
||||
"remix-themes": "^2.0.4",
|
||||
"sharp": "0.32.6",
|
||||
"uqr": "^0.1.2",
|
||||
"ua-parser-js": "^1.0.37",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"ts-pattern": "^5.0.5"
|
||||
},
|
||||
@ -55,6 +74,10 @@
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@simplewebauthn/types": "^9.0.1",
|
||||
"@types/formidable": "^2.0.6",
|
||||
"@types/luxon": "^3.3.1",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
"cross-env": "^7.0.3",
|
||||
"remix-flat-routes": "^0.8.4",
|
||||
"tsx": "^4.11.0",
|
||||
@ -63,4 +86,4 @@
|
||||
"vite-plugin-babel-macros": "^1.0.6",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
# @documenso/web
|
||||
1
apps/web/ambient.d.ts
vendored
1
apps/web/ambient.d.ts
vendored
@ -1 +0,0 @@
|
||||
declare module '@documenso/tailwind-config';
|
||||
Binary file not shown.
6
apps/web/next-env.d.ts
vendored
6
apps/web/next-env.d.ts
vendored
@ -1,6 +0,0 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference types="next/navigation-types/compat/navigation" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
@ -1,108 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { version } = require('./package.json');
|
||||
const { withAxiom } = require('next-axiom');
|
||||
|
||||
const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`];
|
||||
|
||||
ENV_FILES.forEach((file) => {
|
||||
require('dotenv').config({
|
||||
path: path.join(__dirname, `../../${file}`),
|
||||
});
|
||||
});
|
||||
|
||||
// !: This is a temp hack to get caveat working without placing it back in the public directory.
|
||||
// !: By inlining this at build time we should be able to sign faster.
|
||||
const FONT_CAVEAT_BYTES = fs.readFileSync(
|
||||
path.join(__dirname, '../../packages/assets/fonts/caveat.ttf'),
|
||||
);
|
||||
|
||||
const FONT_NOTO_SANS_BYTES = fs.readFileSync(
|
||||
path.join(__dirname, '../../packages/assets/fonts/noto-sans.ttf'),
|
||||
);
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const config = {
|
||||
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
|
||||
experimental: {
|
||||
outputFileTracingRoot: path.join(__dirname, '../../'),
|
||||
serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign', 'playwright'],
|
||||
serverActions: {
|
||||
bodySizeLimit: '50mb',
|
||||
},
|
||||
swcPlugins: [['@lingui/swc-plugin', {}]],
|
||||
},
|
||||
reactStrictMode: true,
|
||||
transpilePackages: [
|
||||
'@documenso/assets',
|
||||
'@documenso/ee',
|
||||
'@documenso/lib',
|
||||
'@documenso/prisma',
|
||||
'@documenso/tailwind-config',
|
||||
'@documenso/trpc',
|
||||
'@documenso/ui',
|
||||
],
|
||||
env: {
|
||||
APP_VERSION: version,
|
||||
NEXT_PUBLIC_PROJECT: 'web',
|
||||
FONT_CAVEAT_URI: `data:font/ttf;base64,${FONT_CAVEAT_BYTES.toString('base64')}`,
|
||||
FONT_NOTO_SANS_URI: `data:font/ttf;base64,${FONT_NOTO_SANS_BYTES.toString('base64')}`,
|
||||
},
|
||||
modularizeImports: {
|
||||
'lucide-react': {
|
||||
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',
|
||||
},
|
||||
},
|
||||
webpack: (config, { isServer }) => {
|
||||
// fixes: Module not found: Can’t resolve ‘../build/Release/canvas.node’
|
||||
if (isServer) {
|
||||
config.resolve.alias.canvas = false;
|
||||
}
|
||||
|
||||
config.module.rules.push({
|
||||
test: /\.po$/,
|
||||
use: {
|
||||
loader: '@lingui/loader',
|
||||
},
|
||||
});
|
||||
|
||||
return config;
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/ingest/:path*',
|
||||
destination: 'https://eu.posthog.com/:path*',
|
||||
},
|
||||
];
|
||||
},
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
permanent: true,
|
||||
source: '/documents/:id/sign',
|
||||
destination: '/sign/:token',
|
||||
has: [
|
||||
{
|
||||
type: 'query',
|
||||
key: 'token',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/documents/:id/signed',
|
||||
destination: '/sign/:token',
|
||||
has: [
|
||||
{
|
||||
type: 'query',
|
||||
key: 'token',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = withAxiom(config);
|
||||
@ -1,77 +0,0 @@
|
||||
{
|
||||
"name": "@documenso/web",
|
||||
"version": "1.9.0-rc.11",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
"build": "npm run translate --prefix ../../ && next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"e2e:prepare": "next build && next start",
|
||||
"lint:fix": "next lint --fix",
|
||||
"clean": "rimraf .next && rimraf node_modules",
|
||||
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@documenso/api": "*",
|
||||
"@documenso/assets": "*",
|
||||
"@documenso/ee": "*",
|
||||
"@documenso/lib": "*",
|
||||
"@documenso/prisma": "*",
|
||||
"@documenso/trpc": "*",
|
||||
"@documenso/ui": "*",
|
||||
"@hookform/resolvers": "^3.1.0",
|
||||
"@lingui/macro": "^4.11.3",
|
||||
"@lingui/react": "^4.11.3",
|
||||
"@simplewebauthn/browser": "^9.0.1",
|
||||
"@simplewebauthn/server": "^9.0.3",
|
||||
"colord": "^2.9.3",
|
||||
"cookie-es": "^1.0.0",
|
||||
"formidable": "^2.1.1",
|
||||
"framer-motion": "^10.12.8",
|
||||
"input-otp": "^1.2.4",
|
||||
"lucide-react": "^0.279.0",
|
||||
"luxon": "^3.4.0",
|
||||
"micro": "^10.0.1",
|
||||
"next": "14.2.6",
|
||||
"next-auth": "4.24.5",
|
||||
"next-axiom": "^1.5.1",
|
||||
"next-plausible": "^3.10.1",
|
||||
"next-themes": "^0.2.1",
|
||||
"papaparse": "^5.4.1",
|
||||
"perfect-freehand": "^1.2.0",
|
||||
"posthog-js": "^1.75.3",
|
||||
"posthog-node": "^3.1.1",
|
||||
"react": "^18",
|
||||
"react-call": "^1.3.0",
|
||||
"react-dom": "^18",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-hook-form": "^7.43.9",
|
||||
"react-hotkeys-hook": "^4.4.1",
|
||||
"react-icons": "^4.11.0",
|
||||
"react-rnd": "^10.4.1",
|
||||
"recharts": "^2.7.2",
|
||||
"remeda": "^2.17.3",
|
||||
"sharp": "0.32.6",
|
||||
"trpc-to-openapi": "2.0.4",
|
||||
"ts-pattern": "^5.0.5",
|
||||
"ua-parser-js": "^1.0.37",
|
||||
"uqr": "^0.1.2",
|
||||
"zod": "3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@documenso/tailwind-config": "*",
|
||||
"@lingui/loader": "^4.11.3",
|
||||
"@lingui/swc-plugin": "4.0.8",
|
||||
"@simplewebauthn/types": "^9.0.1",
|
||||
"@types/formidable": "^2.0.6",
|
||||
"@types/luxon": "^3.3.1",
|
||||
"@types/node": "^20",
|
||||
"@types/papaparse": "^5.3.14",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
"typescript": "5.6.2"
|
||||
}
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
23
apps/web/process-env.d.ts
vendored
23
apps/web/process-env.d.ts
vendored
@ -1,23 +0,0 @@
|
||||
declare namespace NodeJS {
|
||||
export interface ProcessEnv {
|
||||
NEXT_PUBLIC_WEBAPP_URL?: string;
|
||||
NEXT_PUBLIC_MARKETING_URL?: string;
|
||||
NEXT_PRIVATE_INTERNAL_WEBAPP_URL?:string;
|
||||
|
||||
NEXT_PRIVATE_DATABASE_URL: string;
|
||||
|
||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
|
||||
|
||||
NEXT_PRIVATE_STRIPE_API_KEY: string;
|
||||
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;
|
||||
|
||||
NEXT_PRIVATE_GOOGLE_CLIENT_ID: string;
|
||||
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET: string;
|
||||
|
||||
NEXT_PRIVATE_OIDC_WELL_KNOWN: string;
|
||||
NEXT_PRIVATE_OIDC_CLIENT_ID: string;
|
||||
NEXT_PRIVATE_OIDC_CLIENT_SECRET: string;
|
||||
NEXT_PRIVATE_OIDC_ALLOW_SIGNUP?: string;
|
||||
NEXT_PRIVATE_OIDC_SKIP_VERIFY?: string;
|
||||
}
|
||||
}
|
||||
@ -1,81 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import { type Document, SigningStatus } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@documenso/ui/primitives/tooltip';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type AdminActionsProps = {
|
||||
className?: string;
|
||||
document: Document;
|
||||
recipients: Recipient[];
|
||||
};
|
||||
|
||||
export const AdminActions = ({ className, document, recipients }: AdminActionsProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutate: resealDocument, isPending: isResealDocumentLoading } =
|
||||
trpc.admin.resealDocument.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: _(msg`Success`),
|
||||
description: _(msg`Document resealed`),
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`Failed to reseal document`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={cn('flex gap-x-4', className)}>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
loading={isResealDocumentLoading}
|
||||
disabled={recipients.some(
|
||||
(recipient) => recipient.signingStatus !== SigningStatus.SIGNED,
|
||||
)}
|
||||
onClick={() => resealDocument({ id: document.id })}
|
||||
>
|
||||
<Trans>Reseal document</Trans>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="max-w-[40ch]">
|
||||
<Trans>
|
||||
Attempts sealing the document again, useful for after a code change has occurred to
|
||||
resolve an erroneous document.
|
||||
</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/admin/users/${document.userId}`}>
|
||||
<Trans>Go to owner</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,99 +0,0 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@documenso/ui/primitives/accordion';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
|
||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||
|
||||
import { AdminActions } from './admin-actions';
|
||||
import { RecipientItem } from './recipient-item';
|
||||
import { SuperDeleteDocumentDialog } from './super-delete-document-dialog';
|
||||
|
||||
type AdminDocumentDetailsPageProps = {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function AdminDocumentDetailsPage({ params }: AdminDocumentDetailsPageProps) {
|
||||
const { i18n } = await setupI18nSSR();
|
||||
|
||||
const document = await getEntireDocument({ id: Number(params.id) });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<h1 className="text-2xl font-semibold">{document.title}</h1>
|
||||
<DocumentStatus status={document.status} />
|
||||
</div>
|
||||
|
||||
{document.deletedAt && (
|
||||
<Badge size="large" variant="destructive">
|
||||
<Trans>Deleted</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground mt-4 text-sm">
|
||||
<div>
|
||||
<Trans>Created on</Trans>: {i18n.date(document.createdAt, DateTime.DATETIME_MED)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Trans>Last updated at</Trans>: {i18n.date(document.updatedAt, DateTime.DATETIME_MED)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
<h2 className="text-lg font-semibold">
|
||||
<Trans>Admin Actions</Trans>
|
||||
</h2>
|
||||
|
||||
<AdminActions className="mt-2" document={document} recipients={document.recipients} />
|
||||
|
||||
<hr className="my-4" />
|
||||
<h2 className="text-lg font-semibold">
|
||||
<Trans>Recipients</Trans>
|
||||
</h2>
|
||||
|
||||
<div className="mt-4">
|
||||
<Accordion type="multiple" className="space-y-4">
|
||||
{document.recipients.map((recipient) => (
|
||||
<AccordionItem
|
||||
key={recipient.id}
|
||||
value={recipient.id.toString()}
|
||||
className="rounded-lg border"
|
||||
>
|
||||
<AccordionTrigger className="px-4">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<h4 className="font-semibold">{recipient.name}</h4>
|
||||
<Badge size="small" variant="neutral">
|
||||
{recipient.email}
|
||||
</Badge>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
|
||||
<AccordionContent className="border-t px-4 pt-4">
|
||||
<RecipientItem recipient={recipient} />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
{document && <SuperDeleteDocumentDialog document={document} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,196 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
type Field,
|
||||
type Recipient,
|
||||
type Signature,
|
||||
SigningStatus,
|
||||
} from '@documenso/prisma/client';
|
||||
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 {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
const ZAdminUpdateRecipientFormSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
type TAdminUpdateRecipientFormSchema = z.infer<typeof ZAdminUpdateRecipientFormSchema>;
|
||||
|
||||
export type RecipientItemProps = {
|
||||
recipient: Recipient & {
|
||||
fields: Array<
|
||||
Field & {
|
||||
signature: Signature | null;
|
||||
}
|
||||
>;
|
||||
};
|
||||
};
|
||||
|
||||
export const RecipientItem = ({ recipient }: RecipientItemProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm<TAdminUpdateRecipientFormSchema>({
|
||||
defaultValues: {
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: updateRecipient } = trpc.admin.updateRecipient.useMutation();
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
header: 'ID',
|
||||
accessorKey: 'id',
|
||||
cell: ({ row }) => <div>{row.original.id}</div>,
|
||||
},
|
||||
{
|
||||
header: _(msg`Type`),
|
||||
accessorKey: 'type',
|
||||
cell: ({ row }) => <div>{row.original.type}</div>,
|
||||
},
|
||||
{
|
||||
header: _(msg`Inserted`),
|
||||
accessorKey: 'inserted',
|
||||
cell: ({ row }) => <div>{row.original.inserted ? 'True' : 'False'}</div>,
|
||||
},
|
||||
{
|
||||
header: _(msg`Value`),
|
||||
accessorKey: 'customText',
|
||||
cell: ({ row }) => <div>{row.original.customText}</div>,
|
||||
},
|
||||
{
|
||||
header: _(msg`Signature`),
|
||||
accessorKey: 'signature',
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
{row.original.signature?.typedSignature && (
|
||||
<span>{row.original.signature.typedSignature}</span>
|
||||
)}
|
||||
|
||||
{row.original.signature?.signatureImageAsBase64 && (
|
||||
<img
|
||||
src={row.original.signature.signatureImageAsBase64}
|
||||
alt="Signature"
|
||||
className="h-12 w-full dark:invert"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
] satisfies DataTableColumnDef<(typeof recipient)['fields'][number]>[];
|
||||
}, []);
|
||||
|
||||
const onUpdateRecipientFormSubmit = async ({ name, email }: TAdminUpdateRecipientFormSchema) => {
|
||||
try {
|
||||
await updateRecipient({
|
||||
id: recipient.id,
|
||||
name,
|
||||
email,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Recipient updated`),
|
||||
description: _(msg`The recipient has been updated successfully`),
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: _(msg`Failed to update recipient`),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onUpdateRecipientFormSubmit)}>
|
||||
<fieldset
|
||||
className="flex h-full max-w-xl flex-col gap-y-4"
|
||||
disabled={
|
||||
form.formState.isSubmitting || recipient.signingStatus === SigningStatus.SIGNED
|
||||
}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel required>
|
||||
<Trans>Name</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel required>
|
||||
<Trans>Email</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input type="email" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Update Recipient</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
<h2 className="mb-4 text-lg font-semibold">
|
||||
<Trans>Fields</Trans>
|
||||
</h2>
|
||||
|
||||
<DataTable columns={columns} data={recipient.fields} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,135 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import type { Document } from '@documenso/prisma/client';
|
||||
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 SuperDeleteDocumentDialogProps = {
|
||||
document: Document;
|
||||
};
|
||||
|
||||
export const SuperDeleteDocumentDialog = ({ document }: SuperDeleteDocumentDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
router.push('/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 (
|
||||
<div>
|
||||
<div>
|
||||
<Alert
|
||||
className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row"
|
||||
variant="neutral"
|
||||
>
|
||||
<div>
|
||||
<AlertTitle>
|
||||
<Trans>Delete Document</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription className="mr-2">
|
||||
<Trans>
|
||||
Delete the document. This action is irreversible so proceed with caution.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0">
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="destructive">
|
||||
<Trans>Delete Document</Trans>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader className="space-y-4">
|
||||
<DialogTitle>
|
||||
<Trans>Delete Document</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription className="selection:bg-red-100">
|
||||
<Trans>This action is not reversible. Please be certain.</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</DialogHeader>
|
||||
|
||||
<div>
|
||||
<DialogDescription>
|
||||
<Trans>To confirm, please enter the reason</Trans>
|
||||
</DialogDescription>
|
||||
|
||||
<Input
|
||||
className="mt-2"
|
||||
type="text"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={handleDeleteDocument}
|
||||
loading={isDeletingDocument}
|
||||
variant="destructive"
|
||||
disabled={!reason}
|
||||
>
|
||||
<Trans>Delete document</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,165 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Avatar, AvatarFallback } 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 { Input } from '@documenso/ui/primitives/input';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
|
||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||
|
||||
// export type AdminDocumentResultsProps = {};
|
||||
|
||||
export const AdminDocumentResults = () => {
|
||||
const { _, i18n } = useLingui();
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
|
||||
const [term, setTerm] = useState(() => searchParams?.get?.('term') ?? '');
|
||||
const debouncedTerm = useDebouncedValue(term, 500);
|
||||
|
||||
const page = searchParams?.get?.('page') ? Number(searchParams.get('page')) : undefined;
|
||||
const perPage = searchParams?.get?.('perPage') ? Number(searchParams.get('perPage')) : undefined;
|
||||
|
||||
const { data: findDocumentsData, isPending: isFindDocumentsLoading } =
|
||||
trpc.admin.findDocuments.useQuery(
|
||||
{
|
||||
query: debouncedTerm,
|
||||
page: page || 1,
|
||||
perPage: perPage || 20,
|
||||
},
|
||||
{
|
||||
placeholderData: (previousData) => previousData,
|
||||
},
|
||||
);
|
||||
|
||||
const results = findDocumentsData ?? {
|
||||
data: [],
|
||||
perPage: 20,
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
header: _(msg`Created`),
|
||||
accessorKey: 'createdAt',
|
||||
cell: ({ row }) => i18n.date(row.original.createdAt),
|
||||
},
|
||||
{
|
||||
header: _(msg`Title`),
|
||||
accessorKey: 'title',
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Link
|
||||
href={`/admin/documents/${row.original.id}`}
|
||||
className="block max-w-[5rem] truncate font-medium hover:underline md:max-w-[10rem]"
|
||||
>
|
||||
{row.original.title}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: _(msg`Status`),
|
||||
accessorKey: 'status',
|
||||
cell: ({ row }) => <DocumentStatus status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
header: _(msg`Owner`),
|
||||
accessorKey: 'owner',
|
||||
cell: ({ row }) => {
|
||||
const avatarFallbackText = row.original.user.name
|
||||
? extractInitials(row.original.user.name)
|
||||
: row.original.user.email.slice(0, 1).toUpperCase();
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger>
|
||||
<Link href={`/admin/users/${row.original.user.id}`}>
|
||||
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
|
||||
<AvatarFallback className="text-xs text-gray-400">
|
||||
{avatarFallbackText}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="flex max-w-xs items-center gap-2">
|
||||
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
|
||||
<AvatarFallback className="text-xs text-gray-400">
|
||||
{avatarFallbackText}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="text-muted-foreground flex flex-col text-sm">
|
||||
<span>{row.original.user.name}</span>
|
||||
<span>{row.original.user.email}</span>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Last updated',
|
||||
accessorKey: 'updatedAt',
|
||||
cell: ({ row }) => i18n.date(row.original.updatedAt),
|
||||
},
|
||||
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
||||
}, []);
|
||||
|
||||
const onPaginationChange = (newPage: number, newPerPage: number) => {
|
||||
updateSearchParams({
|
||||
page: newPage,
|
||||
perPage: newPerPage,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={_(msg`Search by document title`)}
|
||||
value={term}
|
||||
onChange={(e) => setTerm(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="relative mt-4">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={results.data}
|
||||
perPage={results.perPage ?? 20}
|
||||
currentPage={results.currentPage ?? 1}
|
||||
totalPages={results.totalPages ?? 1}
|
||||
onPaginationChange={onPaginationChange}
|
||||
>
|
||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||
</DataTable>
|
||||
|
||||
{isFindDocumentsLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
|
||||
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,21 +0,0 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
|
||||
import { AdminDocumentResults } from './document-results';
|
||||
|
||||
export default async function AdminDocumentsPage() {
|
||||
await setupI18nSSR();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-4xl font-semibold">
|
||||
<Trans>Manage documents</Trans>
|
||||
</h2>
|
||||
|
||||
<div className="mt-8">
|
||||
<AdminDocumentResults />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,33 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||
|
||||
import { AdminNav } from './nav';
|
||||
|
||||
export type AdminSectionLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export default async function AdminSectionLayout({ children }: AdminSectionLayoutProps) {
|
||||
await setupI18nSSR();
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
if (!isAdmin(user)) {
|
||||
redirect('/documents');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto mt-16 w-full max-w-screen-xl px-4 md:px-8">
|
||||
<div className="grid grid-cols-12 md:mt-8 md:gap-8">
|
||||
<AdminNav className="col-span-12 md:col-span-3 md:flex" />
|
||||
|
||||
<div className="col-span-12 mt-12 md:col-span-9 md:mt-0">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,169 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState, useTransition } from 'react';
|
||||
|
||||
import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { ChevronDownIcon as CaretSortIcon, Loader } from 'lucide-react';
|
||||
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
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 { Input } from '@documenso/ui/primitives/input';
|
||||
|
||||
export type SigningVolume = {
|
||||
id: number;
|
||||
name: string;
|
||||
signingVolume: number;
|
||||
createdAt: Date;
|
||||
planId: string;
|
||||
};
|
||||
|
||||
type LeaderboardTableProps = {
|
||||
signingVolume: SigningVolume[];
|
||||
totalPages: number;
|
||||
perPage: number;
|
||||
page: number;
|
||||
sortBy: 'name' | 'createdAt' | 'signingVolume';
|
||||
sortOrder: 'asc' | 'desc';
|
||||
};
|
||||
|
||||
export const LeaderboardTable = ({
|
||||
signingVolume,
|
||||
totalPages,
|
||||
perPage,
|
||||
page,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
}: LeaderboardTableProps) => {
|
||||
const { _, i18n } = useLingui();
|
||||
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
const [searchString, setSearchString] = useState('');
|
||||
const debouncedSearchString = useDebouncedValue(searchString, 1000);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
header: () => (
|
||||
<div
|
||||
className="flex cursor-pointer items-center"
|
||||
onClick={() => handleColumnSort('name')}
|
||||
>
|
||||
{_(msg`Name`)}
|
||||
<CaretSortIcon className="ml-2 h-4 w-4" />
|
||||
</div>
|
||||
),
|
||||
accessorKey: 'name',
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div>
|
||||
<a
|
||||
className="text-primary underline"
|
||||
href={`https://dashboard.stripe.com/subscriptions/${row.original.planId}`}
|
||||
target="_blank"
|
||||
>
|
||||
{row.getValue('name')}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
size: 250,
|
||||
},
|
||||
{
|
||||
header: () => (
|
||||
<div
|
||||
className="flex cursor-pointer items-center"
|
||||
onClick={() => handleColumnSort('signingVolume')}
|
||||
>
|
||||
{_(msg`Signing Volume`)}
|
||||
<CaretSortIcon className="ml-2 h-4 w-4" />
|
||||
</div>
|
||||
),
|
||||
accessorKey: 'signingVolume',
|
||||
cell: ({ row }) => <div>{Number(row.getValue('signingVolume'))}</div>,
|
||||
},
|
||||
{
|
||||
header: () => {
|
||||
return (
|
||||
<div
|
||||
className="flex cursor-pointer items-center"
|
||||
onClick={() => handleColumnSort('createdAt')}
|
||||
>
|
||||
{_(msg`Created`)}
|
||||
<CaretSortIcon className="ml-2 h-4 w-4" />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
accessorKey: 'createdAt',
|
||||
cell: ({ row }) => i18n.date(row.original.createdAt),
|
||||
},
|
||||
] satisfies DataTableColumnDef<SigningVolume>[];
|
||||
}, [sortOrder]);
|
||||
|
||||
useEffect(() => {
|
||||
startTransition(() => {
|
||||
updateSearchParams({
|
||||
search: debouncedSearchString,
|
||||
page: 1,
|
||||
perPage,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
});
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedSearchString]);
|
||||
|
||||
const onPaginationChange = (page: number, perPage: number) => {
|
||||
startTransition(() => {
|
||||
updateSearchParams({
|
||||
page,
|
||||
perPage,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchString(e.target.value);
|
||||
};
|
||||
|
||||
const handleColumnSort = (column: 'name' | 'createdAt' | 'signingVolume') => {
|
||||
startTransition(() => {
|
||||
updateSearchParams({
|
||||
sortBy: column,
|
||||
sortOrder: sortBy === column && sortOrder === 'asc' ? 'desc' : 'asc',
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="my-6 flex flex-row gap-4"
|
||||
type="text"
|
||||
placeholder={_(msg`Search by name or email`)}
|
||||
value={searchString}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={signingVolume}
|
||||
perPage={perPage}
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPaginationChange={onPaginationChange}
|
||||
>
|
||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||
</DataTable>
|
||||
|
||||
{isPending && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
|
||||
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,25 +0,0 @@
|
||||
'use server';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||
import { getSigningVolume } from '@documenso/lib/server-only/admin/get-signing-volume';
|
||||
|
||||
type SearchOptions = {
|
||||
search: string;
|
||||
page: number;
|
||||
perPage: number;
|
||||
sortBy: 'name' | 'createdAt' | 'signingVolume';
|
||||
sortOrder: 'asc' | 'desc';
|
||||
};
|
||||
|
||||
export async function search({ search, page, perPage, sortBy, sortOrder }: SearchOptions) {
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
if (!isAdmin(user)) {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
const results = await getSigningVolume({ search, page, perPage, sortBy, sortOrder });
|
||||
|
||||
return results;
|
||||
}
|
||||
@ -1,60 +0,0 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||
|
||||
import { LeaderboardTable } from './data-table-leaderboard';
|
||||
import { search } from './fetch-leaderboard.actions';
|
||||
|
||||
type AdminLeaderboardProps = {
|
||||
searchParams?: {
|
||||
search?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
sortBy?: 'name' | 'createdAt' | 'signingVolume';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
};
|
||||
};
|
||||
|
||||
export default async function Leaderboard({ searchParams = {} }: AdminLeaderboardProps) {
|
||||
await setupI18nSSR();
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
if (!isAdmin(user)) {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
const page = Number(searchParams.page) || 1;
|
||||
const perPage = Number(searchParams.perPage) || 10;
|
||||
const searchString = searchParams.search || '';
|
||||
const sortBy = searchParams.sortBy || 'signingVolume';
|
||||
const sortOrder = searchParams.sortOrder || 'desc';
|
||||
|
||||
const { leaderboard: signingVolume, totalPages } = await search({
|
||||
search: searchString,
|
||||
page,
|
||||
perPage,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-4xl font-semibold">
|
||||
<Trans>Signing Volume</Trans>
|
||||
</h2>
|
||||
<div className="mt-8">
|
||||
<LeaderboardTable
|
||||
signingVolume={signingVolume}
|
||||
totalPages={totalPages}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,112 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { BarChart3, FileStack, Settings, Trophy, Users, Wallet2 } from 'lucide-react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
export type AdminNavProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const AdminNav = ({ className, ...props }: AdminNavProps) => {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex gap-x-2.5 gap-y-2 overflow-hidden overflow-x-auto md:flex-col',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'justify-start md:w-full',
|
||||
pathname?.startsWith('/admin/stats') && 'bg-secondary',
|
||||
)}
|
||||
asChild
|
||||
>
|
||||
<Link href="/admin/stats">
|
||||
<BarChart3 className="mr-2 h-5 w-5" />
|
||||
<Trans>Stats</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'justify-start md:w-full',
|
||||
pathname?.startsWith('/admin/users') && 'bg-secondary',
|
||||
)}
|
||||
asChild
|
||||
>
|
||||
<Link href="/admin/users">
|
||||
<Users className="mr-2 h-5 w-5" />
|
||||
<Trans>Users</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'justify-start md:w-full',
|
||||
pathname?.startsWith('/admin/documents') && 'bg-secondary',
|
||||
)}
|
||||
asChild
|
||||
>
|
||||
<Link href="/admin/documents">
|
||||
<FileStack className="mr-2 h-5 w-5" />
|
||||
<Trans>Documents</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'justify-start md:w-full',
|
||||
pathname?.startsWith('/admin/subscriptions') && 'bg-secondary',
|
||||
)}
|
||||
asChild
|
||||
>
|
||||
<Link href="/admin/subscriptions">
|
||||
<Wallet2 className="mr-2 h-5 w-5" />
|
||||
<Trans>Subscriptions</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'justify-start md:w-full',
|
||||
pathname?.startsWith('/admin/leaderboard') && 'bg-secondary',
|
||||
)}
|
||||
asChild
|
||||
>
|
||||
<Link href="/admin/leaderboard">
|
||||
<Trophy className="mr-2 h-5 w-5" />
|
||||
<Trans>Leaderboard</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'justify-start md:w-full',
|
||||
pathname?.startsWith('/admin/banner') && 'bg-secondary',
|
||||
)}
|
||||
asChild
|
||||
>
|
||||
<Link href="/admin/site-settings">
|
||||
<Settings className="mr-2 h-5 w-5" />
|
||||
<Trans>Site Settings</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,5 +0,0 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Admin() {
|
||||
redirect('/admin/stats');
|
||||
}
|
||||
@ -1,208 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import type { TSiteSettingsBannerSchema } from '@documenso/lib/server-only/site-settings/schemas/banner';
|
||||
import {
|
||||
SITE_SETTINGS_BANNER_ID,
|
||||
ZSiteSettingsBannerSchema,
|
||||
} from '@documenso/lib/server-only/site-settings/schemas/banner';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { ColorPicker } from '@documenso/ui/primitives/color-picker';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Switch } from '@documenso/ui/primitives/switch';
|
||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
const ZBannerFormSchema = ZSiteSettingsBannerSchema;
|
||||
|
||||
type TBannerFormSchema = z.infer<typeof ZBannerFormSchema>;
|
||||
|
||||
export type BannerFormProps = {
|
||||
banner?: TSiteSettingsBannerSchema;
|
||||
};
|
||||
|
||||
export function BannerForm({ banner }: BannerFormProps) {
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm<TBannerFormSchema>({
|
||||
resolver: zodResolver(ZBannerFormSchema),
|
||||
defaultValues: {
|
||||
id: SITE_SETTINGS_BANNER_ID,
|
||||
enabled: banner?.enabled ?? false,
|
||||
data: {
|
||||
content: banner?.data?.content ?? '',
|
||||
bgColor: banner?.data?.bgColor ?? '#000000',
|
||||
textColor: banner?.data?.textColor ?? '#FFFFFF',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const enabled = form.watch('enabled');
|
||||
|
||||
const { mutateAsync: updateSiteSetting, isPending: isUpdateSiteSettingLoading } =
|
||||
trpcReact.admin.updateSiteSetting.useMutation();
|
||||
|
||||
const onBannerUpdate = async ({ id, enabled, data }: TBannerFormSchema) => {
|
||||
try {
|
||||
await updateSiteSetting({
|
||||
id,
|
||||
enabled,
|
||||
data,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Banner Updated`),
|
||||
description: _(msg`Your banner has been updated successfully.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`An unknown error occurred`),
|
||||
variant: 'destructive',
|
||||
description: _(
|
||||
msg`We encountered an unknown error while attempting to update the banner. Please try again later.`,
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="font-semibold">
|
||||
<Trans>Site Banner</Trans>
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
<Trans>
|
||||
The site banner is a message that is shown at the top of the site. It can be used to
|
||||
display important information to your users.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="mt-4 flex flex-col rounded-md"
|
||||
onSubmit={form.handleSubmit(onBannerUpdate)}
|
||||
>
|
||||
<div className="mt-4 flex flex-col gap-4 md:flex-row">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Enabled</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<div>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<fieldset
|
||||
className="flex flex-col gap-4 md:flex-row"
|
||||
disabled={!enabled}
|
||||
aria-disabled={!enabled}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="data.bgColor"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Background Color</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<div>
|
||||
<ColorPicker {...field} />
|
||||
</div>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="data.textColor"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Text Color</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<div>
|
||||
<ColorPicker {...field} />
|
||||
</div>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<fieldset disabled={!enabled} aria-disabled={!enabled}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="data.content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Content</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Textarea className="h-32 resize-none" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>The content to show in the banner, HTML is allowed</Trans>
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
loading={isUpdateSiteSettingLoading}
|
||||
className="mt-4 justify-end self-end"
|
||||
>
|
||||
<Trans>Update Banner</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,35 +0,0 @@
|
||||
import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
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';
|
||||
|
||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||
|
||||
import { BannerForm } from './banner-form';
|
||||
|
||||
// import { BannerForm } from './banner-form';
|
||||
|
||||
export default async function AdminBannerPage() {
|
||||
await setupI18nSSR();
|
||||
|
||||
const { _ } = useLingui();
|
||||
|
||||
const banner = await getSiteSettings().then((settings) =>
|
||||
settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title={_(msg`Site Settings`)}
|
||||
subtitle={_(msg`Manage your site settings here`)}
|
||||
/>
|
||||
|
||||
<div className="mt-8">
|
||||
<BannerForm banner={banner} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,158 +0,0 @@
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import {
|
||||
File,
|
||||
FileCheck,
|
||||
FileClock,
|
||||
FileCog,
|
||||
FileEdit,
|
||||
Mail,
|
||||
MailOpen,
|
||||
PenTool,
|
||||
UserPlus,
|
||||
UserSquare2,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
import { getDocumentStats } from '@documenso/lib/server-only/admin/get-documents-stats';
|
||||
import { getRecipientsStats } from '@documenso/lib/server-only/admin/get-recipients-stats';
|
||||
import {
|
||||
getUserWithSignedDocumentMonthlyGrowth,
|
||||
getUsersCount,
|
||||
getUsersWithSubscriptionsCount,
|
||||
} from '@documenso/lib/server-only/admin/get-users-stats';
|
||||
import { getSignerConversionMonthly } from '@documenso/lib/server-only/user/get-signer-conversion';
|
||||
|
||||
import { CardMetric } from '~/components/(dashboard)/metric-card/metric-card';
|
||||
|
||||
import { SignerConversionChart } from './signer-conversion-chart';
|
||||
import { UserWithDocumentChart } from './user-with-document';
|
||||
|
||||
export default async function AdminStatsPage() {
|
||||
await setupI18nSSR();
|
||||
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [
|
||||
usersCount,
|
||||
usersWithSubscriptionsCount,
|
||||
docStats,
|
||||
recipientStats,
|
||||
signerConversionMonthly,
|
||||
// userWithAtLeastOneDocumentPerMonth,
|
||||
// userWithAtLeastOneDocumentSignedPerMonth,
|
||||
MONTHLY_USERS_SIGNED,
|
||||
] = await Promise.all([
|
||||
getUsersCount(),
|
||||
getUsersWithSubscriptionsCount(),
|
||||
getDocumentStats(),
|
||||
getRecipientsStats(),
|
||||
getSignerConversionMonthly(),
|
||||
// getUserWithAtLeastOneDocumentPerMonth(),
|
||||
// getUserWithAtLeastOneDocumentSignedPerMonth(),
|
||||
getUserWithSignedDocumentMonthlyGrowth(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-4xl font-semibold">
|
||||
<Trans>Instance Stats</Trans>
|
||||
</h2>
|
||||
|
||||
<div className="mt-8 grid flex-1 grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<CardMetric icon={Users} title={_(msg`Total Users`)} value={usersCount} />
|
||||
<CardMetric icon={File} title={_(msg`Total Documents`)} value={docStats.ALL} />
|
||||
<CardMetric
|
||||
icon={UserPlus}
|
||||
title={_(msg`Active Subscriptions`)}
|
||||
value={usersWithSubscriptionsCount}
|
||||
/>
|
||||
|
||||
<CardMetric
|
||||
icon={FileCog}
|
||||
title={_(msg`App Version`)}
|
||||
value={`v${process.env.APP_VERSION}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 gap-8">
|
||||
<div>
|
||||
<h3 className="text-3xl font-semibold">
|
||||
<Trans>Document metrics</Trans>
|
||||
</h3>
|
||||
|
||||
<div className="mb-8 mt-4 grid flex-1 grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<CardMetric icon={FileEdit} title={_(msg`Drafted Documents`)} value={docStats.DRAFT} />
|
||||
<CardMetric
|
||||
icon={FileClock}
|
||||
title={_(msg`Pending Documents`)}
|
||||
value={docStats.PENDING}
|
||||
/>
|
||||
<CardMetric
|
||||
icon={FileCheck}
|
||||
title={_(msg`Completed Documents`)}
|
||||
value={docStats.COMPLETED}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-3xl font-semibold">
|
||||
<Trans>Recipients metrics</Trans>
|
||||
</h3>
|
||||
|
||||
<div className="mb-8 mt-4 grid flex-1 grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<CardMetric
|
||||
icon={UserSquare2}
|
||||
title={_(msg`Total Recipients`)}
|
||||
value={recipientStats.TOTAL_RECIPIENTS}
|
||||
/>
|
||||
<CardMetric
|
||||
icon={Mail}
|
||||
title={_(msg`Documents Received`)}
|
||||
value={recipientStats.SENT}
|
||||
/>
|
||||
<CardMetric
|
||||
icon={MailOpen}
|
||||
title={_(msg`Documents Viewed`)}
|
||||
value={recipientStats.OPENED}
|
||||
/>
|
||||
<CardMetric
|
||||
icon={PenTool}
|
||||
title={_(msg`Signatures Collected`)}
|
||||
value={recipientStats.SIGNED}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-16">
|
||||
<h3 className="text-3xl font-semibold">
|
||||
<Trans>Charts</Trans>
|
||||
</h3>
|
||||
<div className="mt-5 grid grid-cols-2 gap-8">
|
||||
<UserWithDocumentChart
|
||||
data={MONTHLY_USERS_SIGNED}
|
||||
title={_(msg`MAU (created document)`)}
|
||||
tooltip={_(msg`Monthly Active Users: Users that created at least one Document`)}
|
||||
/>
|
||||
<UserWithDocumentChart
|
||||
data={MONTHLY_USERS_SIGNED}
|
||||
completed
|
||||
title={_(msg`MAU (had document completed)`)}
|
||||
tooltip={_(
|
||||
msg`Monthly Active Users: Users that had at least one of their documents completed`,
|
||||
)}
|
||||
/>
|
||||
<SignerConversionChart title="Signers that Signed Up" data={signerConversionMonthly} />
|
||||
<SignerConversionChart
|
||||
title={_(msg`Total Signers that Signed Up`)}
|
||||
data={signerConversionMonthly}
|
||||
cummulative
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,64 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { DateTime } from 'luxon';
|
||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
|
||||
import type { GetSignerConversionMonthlyResult } from '@documenso/lib/server-only/user/get-signer-conversion';
|
||||
|
||||
export type SignerConversionChartProps = {
|
||||
className?: string;
|
||||
title: string;
|
||||
cummulative?: boolean;
|
||||
data: GetSignerConversionMonthlyResult;
|
||||
};
|
||||
|
||||
export const SignerConversionChart = ({
|
||||
className,
|
||||
data,
|
||||
title,
|
||||
cummulative = false,
|
||||
}: SignerConversionChartProps) => {
|
||||
const formattedData = [...data].reverse().map(({ month, count, cume_count }) => {
|
||||
return {
|
||||
month: DateTime.fromFormat(month, 'yyyy-MM').toFormat('MMM yyyy'),
|
||||
count: Number(count),
|
||||
signed_count: Number(cume_count),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="border-border flex flex-1 flex-col justify-center rounded-2xl border p-6 pl-2">
|
||||
<div className="mb-6 flex px-4">
|
||||
<h3 className="text-lg font-semibold">{title}</h3>
|
||||
</div>
|
||||
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<BarChart data={formattedData}>
|
||||
<XAxis dataKey="month" />
|
||||
<YAxis />
|
||||
|
||||
<Tooltip
|
||||
labelStyle={{
|
||||
color: 'hsl(var(--primary-foreground))',
|
||||
}}
|
||||
formatter={(value, name) => [
|
||||
Number(value).toLocaleString('en-US'),
|
||||
name === 'Recipients',
|
||||
]}
|
||||
cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
|
||||
/>
|
||||
|
||||
<Bar
|
||||
dataKey={cummulative ? 'signed_count' : 'count'}
|
||||
fill="hsl(var(--primary))"
|
||||
radius={[4, 4, 0, 0]}
|
||||
maxBarSize={60}
|
||||
label="Recipients"
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,95 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { DateTime } from 'luxon';
|
||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
import type { TooltipProps } from 'recharts';
|
||||
import type { NameType, ValueType } from 'recharts/types/component/DefaultTooltipContent';
|
||||
|
||||
import type { GetUserWithDocumentMonthlyGrowth } from '@documenso/lib/server-only/admin/get-users-stats';
|
||||
|
||||
export type UserWithDocumentChartProps = {
|
||||
className?: string;
|
||||
title: string;
|
||||
data: GetUserWithDocumentMonthlyGrowth;
|
||||
completed?: boolean;
|
||||
tooltip?: string;
|
||||
};
|
||||
|
||||
const CustomTooltip = ({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
tooltip,
|
||||
}: TooltipProps<ValueType, NameType> & { tooltip?: string }) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="z-100 w-60 space-y-1 rounded-md border border-solid bg-white p-2 px-3">
|
||||
<p className="">{label}</p>
|
||||
<p className="text-documenso">
|
||||
{`${tooltip} : `}
|
||||
<span className="text-black">{payload[0].value}</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const UserWithDocumentChart = ({
|
||||
className,
|
||||
data,
|
||||
title,
|
||||
completed = false,
|
||||
tooltip,
|
||||
}: UserWithDocumentChartProps) => {
|
||||
const formattedData = (data: GetUserWithDocumentMonthlyGrowth, completed: boolean) => {
|
||||
return [...data].reverse().map(({ month, count, signed_count }) => {
|
||||
const formattedMonth = DateTime.fromFormat(month, 'yyyy-MM').toFormat('LLL');
|
||||
if (completed) {
|
||||
return {
|
||||
month: formattedMonth,
|
||||
count: Number(signed_count),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
month: formattedMonth,
|
||||
count: Number(count),
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="border-border flex flex-1 flex-col justify-center rounded-2xl border p-6 pl-2">
|
||||
<div className="mb-6 flex h-12 px-4">
|
||||
<h3 className="text-lg font-semibold">{title}</h3>
|
||||
</div>
|
||||
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<BarChart data={formattedData(data, completed)}>
|
||||
<XAxis dataKey="month" />
|
||||
<YAxis />
|
||||
|
||||
<Tooltip
|
||||
content={<CustomTooltip tooltip={tooltip} />}
|
||||
labelStyle={{
|
||||
color: 'hsl(var(--primary-foreground))',
|
||||
}}
|
||||
cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
|
||||
/>
|
||||
|
||||
<Bar
|
||||
dataKey="count"
|
||||
fill="hsl(var(--primary))"
|
||||
radius={[4, 4, 0, 0]}
|
||||
maxBarSize={60}
|
||||
label={tooltip}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,80 +0,0 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
import { findSubscriptions } from '@documenso/lib/server-only/admin/get-all-subscriptions';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@documenso/ui/primitives/table';
|
||||
|
||||
export default async function Subscriptions() {
|
||||
await setupI18nSSR();
|
||||
|
||||
const subscriptions = await findSubscriptions();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-4xl font-semibold">
|
||||
<Trans>Manage subscriptions</Trans>
|
||||
</h2>
|
||||
<div className="mt-8">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>
|
||||
<Trans>Status</Trans>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Trans>Created At</Trans>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Trans>Ends On</Trans>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Trans>User ID</Trans>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{subscriptions.map((subscription, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>{subscription.id}</TableCell>
|
||||
<TableCell>{subscription.status}</TableCell>
|
||||
<TableCell>
|
||||
{subscription.createdAt
|
||||
? new Date(subscription.createdAt).toLocaleDateString(undefined, {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
: 'N/A'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{subscription.periodEnd
|
||||
? new Date(subscription.periodEnd).toLocaleDateString(undefined, {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
: 'N/A'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Link href={`/admin/users/${subscription.userId}`}>{subscription.userId}</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,142 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { User } from '@documenso/prisma/client';
|
||||
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 DeleteUserDialogProps = {
|
||||
className?: string;
|
||||
user: User;
|
||||
};
|
||||
|
||||
export const DeleteUserDialog = ({ className, user }: DeleteUserDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
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 (
|
||||
<div className={className}>
|
||||
<Alert
|
||||
className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row"
|
||||
variant="neutral"
|
||||
>
|
||||
<div>
|
||||
<AlertTitle>Delete Account</AlertTitle>
|
||||
<AlertDescription className="mr-2">
|
||||
<Trans>
|
||||
Delete the users account and all its contents. This action is irreversible and will
|
||||
cancel their subscription, so proceed with caution.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0">
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="destructive">
|
||||
<Trans>Delete Account</Trans>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader className="space-y-4">
|
||||
<DialogTitle>
|
||||
<Trans>Delete Account</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription className="selection:bg-red-100">
|
||||
<Trans>This action is not reversible. Please be certain.</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</DialogHeader>
|
||||
|
||||
<div>
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
To confirm, please enter the accounts email address <br />({user.email}).
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
|
||||
<Input
|
||||
className="mt-2"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={onDeleteAccount}
|
||||
loading={isDeletingUser}
|
||||
variant="destructive"
|
||||
disabled={email !== user.email}
|
||||
>
|
||||
<Trans>Delete account</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,141 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { User } from '@documenso/prisma/client';
|
||||
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 DisableUserDialogProps = {
|
||||
className?: string;
|
||||
userToDisable: User;
|
||||
};
|
||||
|
||||
export const DisableUserDialog = ({ className, userToDisable }: DisableUserDialogProps) => {
|
||||
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 (
|
||||
<div className={className}>
|
||||
<Alert
|
||||
className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row"
|
||||
variant="neutral"
|
||||
>
|
||||
<div>
|
||||
<AlertTitle>Disable Account</AlertTitle>
|
||||
<AlertDescription className="mr-2">
|
||||
<Trans>
|
||||
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.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0">
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="destructive">
|
||||
<Trans>Disable Account</Trans>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader className="space-y-4">
|
||||
<DialogTitle>
|
||||
<Trans>Disable Account</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription className="selection:bg-red-100">
|
||||
<Trans>
|
||||
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).
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</DialogHeader>
|
||||
|
||||
<div>
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
To confirm, please enter the accounts email address <br />({userToDisable.email}
|
||||
).
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
|
||||
<Input
|
||||
className="mt-2"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={onDisableAccount}
|
||||
loading={isDisablingUser}
|
||||
variant="destructive"
|
||||
disabled={email !== userToDisable.email}
|
||||
>
|
||||
<Trans>Disable account</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,130 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { User } from '@documenso/prisma/client';
|
||||
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 EnableUserDialogProps = {
|
||||
className?: string;
|
||||
userToEnable: User;
|
||||
};
|
||||
|
||||
export const EnableUserDialog = ({ className, userToEnable }: EnableUserDialogProps) => {
|
||||
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 (
|
||||
<div className={className}>
|
||||
<Alert
|
||||
className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row"
|
||||
variant="neutral"
|
||||
>
|
||||
<div>
|
||||
<AlertTitle>Enable Account</AlertTitle>
|
||||
<AlertDescription className="mr-2">
|
||||
<Trans>
|
||||
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.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0">
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Trans>Enable Account</Trans>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader className="space-y-4">
|
||||
<DialogTitle>
|
||||
<Trans>Enable Account</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div>
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
To confirm, please enter the accounts email address <br />({userToEnable.email}
|
||||
).
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
|
||||
<Input
|
||||
className="mt-2"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={onEnableAccount}
|
||||
loading={isEnablingUser}
|
||||
disabled={email !== userToEnable.email}
|
||||
>
|
||||
<Trans>Enable account</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,85 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||
|
||||
import { Role } from '@documenso/prisma/client';
|
||||
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';
|
||||
|
||||
type ComboboxProps = {
|
||||
listValues: string[];
|
||||
onChange: (_values: string[]) => void;
|
||||
};
|
||||
|
||||
const MultiSelectRoleCombobox = ({ listValues, onChange }: ComboboxProps) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [selectedValues, setSelectedValues] = React.useState<string[]>([]);
|
||||
const dbRoles = Object.values(Role);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedValues(listValues);
|
||||
}, [listValues]);
|
||||
|
||||
const allRoles = [...new Set([...dbRoles, ...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);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-[200px] justify-between"
|
||||
>
|
||||
{selectedValues.length > 0 ? selectedValues.join(', ') : 'Select values...'}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder={selectedValues.join(', ')} />
|
||||
<CommandEmpty>
|
||||
<Trans>No value found.</Trans>
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{allRoles.map((value: string, i: number) => (
|
||||
<CommandItem key={i} onSelect={() => handleSelect(value)}>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
selectedValues.includes(value) ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
{value}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export { MultiSelectRoleCombobox };
|
||||
@ -1,165 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZAdminUpdateProfileMutationSchema } from '@documenso/trpc/server/admin-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';
|
||||
|
||||
import { DeleteUserDialog } from './delete-user-dialog';
|
||||
import { DisableUserDialog } from './disable-user-dialog';
|
||||
import { EnableUserDialog } from './enable-user-dialog';
|
||||
import { MultiSelectRoleCombobox } from './multiselect-role-combobox';
|
||||
|
||||
const ZUserFormSchema = ZAdminUpdateProfileMutationSchema.omit({ id: true });
|
||||
|
||||
type TUserFormSchema = z.infer<typeof ZUserFormSchema>;
|
||||
|
||||
export default function UserPage({ params }: { params: { id: number } }) {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { data: user } = trpc.profile.getUser.useQuery(
|
||||
{
|
||||
id: Number(params.id),
|
||||
},
|
||||
{
|
||||
enabled: !!params.id,
|
||||
},
|
||||
);
|
||||
|
||||
const roles = user?.roles ?? [];
|
||||
|
||||
const { mutateAsync: updateUserMutation } = trpc.admin.updateUser.useMutation();
|
||||
|
||||
const form = useForm<TUserFormSchema>({
|
||||
resolver: zodResolver(ZUserFormSchema),
|
||||
values: {
|
||||
name: user?.name ?? '',
|
||||
email: user?.email ?? '',
|
||||
roles: user?.roles ?? [],
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async ({ name, email, roles }: TUserFormSchema) => {
|
||||
try {
|
||||
await updateUserMutation({
|
||||
id: Number(user?.id),
|
||||
name,
|
||||
email,
|
||||
roles,
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
|
||||
toast({
|
||||
title: _(msg`Profile updated`),
|
||||
description: _(msg`Your profile has been updated.`),
|
||||
duration: 5000,
|
||||
});
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while updating your profile.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-4xl font-semibold">
|
||||
<Trans>Manage {user?.name}'s profile</Trans>
|
||||
</h2>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<fieldset className="mt-6 flex w-full flex-col gap-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-muted-foreground">
|
||||
<Trans>Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" {...field} value={field.value ?? ''} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-muted-foreground">
|
||||
<Trans>Email</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="roles"
|
||||
render={({ field: { onChange } }) => (
|
||||
<FormItem>
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<FormLabel className="text-muted-foreground">
|
||||
<Trans>Roles</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<MultiSelectRoleCombobox
|
||||
listValues={roles}
|
||||
onChange={(values: string[]) => onChange(values)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</fieldset>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Update user</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
{user && <DeleteUserDialog user={user} />}
|
||||
{user && user.disabled && <EnableUserDialog userToEnable={user} />}
|
||||
{user && !user.disabled && <DisableUserDialog userToDisable={user} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,166 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState, useTransition } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Edit, Loader } from 'lucide-react';
|
||||
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import type { Document, Role, Subscription } from '@documenso/prisma/client';
|
||||
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 { Input } from '@documenso/ui/primitives/input';
|
||||
|
||||
type UserData = {
|
||||
id: number;
|
||||
name: string | null;
|
||||
email: string;
|
||||
roles: Role[];
|
||||
subscriptions?: SubscriptionLite[] | null;
|
||||
documents: DocumentLite[];
|
||||
};
|
||||
|
||||
type SubscriptionLite = Pick<
|
||||
Subscription,
|
||||
'id' | 'status' | 'planId' | 'priceId' | 'createdAt' | 'periodEnd'
|
||||
>;
|
||||
|
||||
type DocumentLite = Pick<Document, 'id'>;
|
||||
|
||||
type UsersDataTableProps = {
|
||||
users: UserData[];
|
||||
totalPages: number;
|
||||
perPage: number;
|
||||
page: number;
|
||||
individualPriceIds: string[];
|
||||
};
|
||||
|
||||
export const UsersDataTable = ({
|
||||
users,
|
||||
totalPages,
|
||||
perPage,
|
||||
page,
|
||||
individualPriceIds,
|
||||
}: UsersDataTableProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
const [searchString, setSearchString] = useState('');
|
||||
const debouncedSearchString = useDebouncedValue(searchString, 1000);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
header: 'ID',
|
||||
accessorKey: 'id',
|
||||
cell: ({ row }) => <div>{row.original.id}</div>,
|
||||
},
|
||||
{
|
||||
header: _(msg`Name`),
|
||||
accessorKey: 'name',
|
||||
cell: ({ row }) => <div>{row.original.name}</div>,
|
||||
},
|
||||
{
|
||||
header: _(msg`Email`),
|
||||
accessorKey: 'email',
|
||||
cell: ({ row }) => <div>{row.original.email}</div>,
|
||||
},
|
||||
{
|
||||
header: _(msg`Roles`),
|
||||
accessorKey: 'roles',
|
||||
cell: ({ row }) => row.original.roles.join(', '),
|
||||
},
|
||||
{
|
||||
header: _(msg`Subscription`),
|
||||
accessorKey: 'subscription',
|
||||
cell: ({ row }) => {
|
||||
const foundIndividualSubscription = (row.original.subscriptions ?? []).find((sub) =>
|
||||
individualPriceIds.includes(sub.priceId),
|
||||
);
|
||||
|
||||
return foundIndividualSubscription?.status ?? 'NONE';
|
||||
},
|
||||
},
|
||||
{
|
||||
header: _(msg`Documents`),
|
||||
accessorKey: 'documents',
|
||||
cell: ({ row }) => {
|
||||
return <div>{row.original.documents?.length}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: '',
|
||||
accessorKey: 'edit',
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Button className="w-24" asChild>
|
||||
<Link href={`/admin/users/${row.original.id}`}>
|
||||
<Edit className="-ml-1 mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
] satisfies DataTableColumnDef<(typeof users)[number]>[];
|
||||
}, [individualPriceIds]);
|
||||
|
||||
useEffect(() => {
|
||||
startTransition(() => {
|
||||
updateSearchParams({
|
||||
search: debouncedSearchString,
|
||||
page: 1,
|
||||
perPage,
|
||||
});
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedSearchString]);
|
||||
|
||||
const onPaginationChange = (page: number, perPage: number) => {
|
||||
startTransition(() => {
|
||||
updateSearchParams({
|
||||
page,
|
||||
perPage,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchString(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="my-6 flex flex-row gap-4"
|
||||
type="text"
|
||||
placeholder={_(msg`Search by name or email`)}
|
||||
value={searchString}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={users}
|
||||
perPage={perPage}
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPaginationChange={onPaginationChange}
|
||||
>
|
||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||
</DataTable>
|
||||
|
||||
{isPending && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
|
||||
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,17 +0,0 @@
|
||||
'use server';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||
import { findUsers } from '@documenso/lib/server-only/user/get-all-users';
|
||||
|
||||
export async function search(search: string, page: number, perPage: number) {
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
if (!isAdmin(user)) {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
const results = await findUsers({ username: search, email: search, page, perPage });
|
||||
|
||||
return results;
|
||||
}
|
||||
@ -1,47 +0,0 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
|
||||
import { getPricesByPlan } from '@documenso/ee/server-only/stripe/get-prices-by-plan';
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||
|
||||
import { UsersDataTable } from './data-table-users';
|
||||
import { search } from './fetch-users.actions';
|
||||
|
||||
type AdminManageUsersProps = {
|
||||
searchParams?: {
|
||||
search?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function AdminManageUsers({ searchParams = {} }: AdminManageUsersProps) {
|
||||
await setupI18nSSR();
|
||||
|
||||
const page = Number(searchParams.page) || 1;
|
||||
const perPage = Number(searchParams.perPage) || 10;
|
||||
const searchString = searchParams.search || '';
|
||||
|
||||
const [{ users, totalPages }, individualPrices] = await Promise.all([
|
||||
search(searchString, page, perPage),
|
||||
getPricesByPlan([STRIPE_PLAN_TYPE.REGULAR, STRIPE_PLAN_TYPE.COMMUNITY]).catch(() => []),
|
||||
]);
|
||||
|
||||
const individualPriceIds = individualPrices.map((price) => price.id);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-4xl font-semibold">
|
||||
<Trans>Manage users</Trans>
|
||||
</h2>
|
||||
|
||||
<UsersDataTable
|
||||
users={users}
|
||||
individualPriceIds={individualPriceIds}
|
||||
totalPages={totalPages}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,121 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
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 type { Document, Recipient, Team, User } from '@documenso/prisma/client';
|
||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||
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<User, 'id' | 'name' | 'email'>;
|
||||
recipients: Recipient[];
|
||||
team: Pick<Team, 'id' | 'url'> | null;
|
||||
};
|
||||
team?: Pick<Team, 'id' | 'url'>;
|
||||
};
|
||||
|
||||
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 }, () => (
|
||||
<Button className="w-full" asChild>
|
||||
<Link href={`/sign/${recipient?.token}`}>
|
||||
{match(role)
|
||||
.with(RecipientRole.SIGNER, () => (
|
||||
<>
|
||||
<Pencil className="-ml-1 mr-2 h-4 w-4" />
|
||||
<Trans>Sign</Trans>
|
||||
</>
|
||||
))
|
||||
.with(RecipientRole.APPROVER, () => (
|
||||
<>
|
||||
<CheckCircle className="-ml-1 mr-2 h-4 w-4" />
|
||||
<Trans>Approve</Trans>
|
||||
</>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<>
|
||||
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
|
||||
<Trans>View</Trans>
|
||||
</>
|
||||
))}
|
||||
</Link>
|
||||
</Button>
|
||||
))
|
||||
.with({ isComplete: false }, () => (
|
||||
<Button className="w-full" asChild>
|
||||
<Link href={`${documentsPath}/${document.id}/edit`}>
|
||||
<Trans>Edit</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
))
|
||||
.with({ isComplete: true }, () => (
|
||||
<Button className="w-full" onClick={onDownloadClick}>
|
||||
<Download className="-ml-1 mr-2 inline h-4 w-4" />
|
||||
<Trans>Download</Trans>
|
||||
</Button>
|
||||
))
|
||||
.otherwise(() => null);
|
||||
};
|
||||
@ -1,211 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
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 { DocumentStatus } from '@documenso/prisma/client';
|
||||
import type { Document, Recipient, Team, TeamEmail, User } from '@documenso/prisma/client';
|
||||
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 { ResendDocumentActionItem } from '../_action-items/resend-document';
|
||||
import { DeleteDocumentDialog } from '../delete-document-dialog';
|
||||
import { DuplicateDocumentDialog } from '../duplicate-document-dialog';
|
||||
|
||||
export type DocumentPageViewDropdownProps = {
|
||||
document: Document & {
|
||||
user: Pick<User, 'id' | 'name' | 'email'>;
|
||||
recipients: Recipient[];
|
||||
team: Pick<Team, 'id' | 'url'> | null;
|
||||
};
|
||||
team?: Pick<Team, 'id' | 'url'> & { 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 (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className="w-52" align="end" forceMount>
|
||||
<DropdownMenuLabel>
|
||||
<Trans>Action</Trans>
|
||||
</DropdownMenuLabel>
|
||||
|
||||
{(isOwner || isCurrentTeamDocument) && !isComplete && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`${documentsPath}/${document.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
<Trans>Edit</Trans>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{isComplete && (
|
||||
<DropdownMenuItem onClick={onDownloadClick}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
<Trans>Download</Trans>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`${documentsPath}/${document.id}/logs`}>
|
||||
<ScrollTextIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Audit Log</Trans>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
<Trans>Duplicate</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
disabled={Boolean(!canManageDocument && team?.teamEmail) || isDeleted}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<Trans>Delete</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuLabel>
|
||||
<Trans>Share</Trans>
|
||||
</DropdownMenuLabel>
|
||||
|
||||
{canManageDocument && (
|
||||
<DocumentRecipientLinkCopyDialog
|
||||
recipients={document.recipients}
|
||||
trigger={
|
||||
<DropdownMenuItem
|
||||
disabled={!isPending || isDeleted}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
<Trans>Signing Links</Trans>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ResendDocumentActionItem
|
||||
document={document}
|
||||
recipients={nonSignedRecipients}
|
||||
team={team}
|
||||
/>
|
||||
|
||||
<DocumentShareButton
|
||||
documentId={document.id}
|
||||
token={isOwner ? undefined : recipient?.token}
|
||||
trigger={({ loading, disabled }) => (
|
||||
<DropdownMenuItem disabled={disabled || isDraft} onSelect={(e) => e.preventDefault()}>
|
||||
<div className="flex items-center">
|
||||
{loading ? <Loader className="mr-2 h-4 w-4" /> : <Share className="mr-2 h-4 w-4" />}
|
||||
<Trans>Share Signing Card</Trans>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
|
||||
<DeleteDocumentDialog
|
||||
id={document.id}
|
||||
status={document.status}
|
||||
documentTitle={document.title}
|
||||
open={isDeleteDialogOpen}
|
||||
canManageDocument={canManageDocument}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
/>
|
||||
|
||||
{isDuplicateDialogOpen && (
|
||||
<DuplicateDocumentDialog
|
||||
id={document.id}
|
||||
open={isDuplicateDialogOpen}
|
||||
onOpenChange={setDuplicateDialogOpen}
|
||||
team={team}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@ -1,70 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
||||
|
||||
export type DocumentPageViewInformationProps = {
|
||||
userId: number;
|
||||
document: Document & {
|
||||
user: Pick<User, 'id' | 'name' | 'email'>;
|
||||
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 (
|
||||
<section className="dark:bg-background text-foreground border-border bg-widget flex flex-col rounded-xl border">
|
||||
<h1 className="px-4 py-3 font-medium">
|
||||
<Trans>Information</Trans>
|
||||
</h1>
|
||||
|
||||
<ul className="divide-y border-t">
|
||||
{documentInformation.map((item, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="flex items-center justify-between px-4 py-2.5 text-sm last:border-b"
|
||||
>
|
||||
<span className="text-muted-foreground">{_(item.description)}</span>
|
||||
<span>{item.value}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@ -1,166 +0,0 @@
|
||||
'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 (
|
||||
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
|
||||
<div className="flex flex-row items-center justify-between border-b px-4 py-3">
|
||||
<h1 className="text-foreground font-medium">
|
||||
<Trans>Recent activity</Trans>
|
||||
</h1>
|
||||
|
||||
{/* Can add dropdown menu here for additional options. */}
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex h-full items-center justify-center py-16">
|
||||
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoadingError && (
|
||||
<div className="flex h-full flex-col items-center justify-center py-16">
|
||||
<p className="text-foreground/80 text-sm">
|
||||
<Trans>Unable to load document history</Trans>
|
||||
</p>
|
||||
<button
|
||||
onClick={async () => refetch()}
|
||||
className="text-foreground/70 hover:text-muted-foreground mt-2 text-sm"
|
||||
>
|
||||
<Trans>Click here to retry</Trans>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AnimateGenericFadeInOut>
|
||||
{data && (
|
||||
<ul role="list" className="space-y-6 p-4">
|
||||
{hasNextPage && (
|
||||
<li className="relative flex gap-x-4">
|
||||
<div className="absolute -bottom-6 left-0 top-0 flex w-6 justify-center">
|
||||
<div className="bg-border w-px" />
|
||||
</div>
|
||||
|
||||
<div className="bg-widget relative flex h-6 w-6 flex-none items-center justify-center">
|
||||
<div className="bg-widget h-1.5 w-1.5 rounded-full ring-1 ring-gray-300 dark:ring-neutral-600" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={async () => fetchNextPage()}
|
||||
className="text-foreground/70 hover:text-muted-foreground text-xs"
|
||||
>
|
||||
{isFetchingNextPage ? _(msg`Loading...`) : _(msg`Load older activity`)}
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
|
||||
{documentAuditLogs.length === 0 && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<p className="text-muted-foreground/70 text-sm">
|
||||
<Trans>No recent activity</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{documentAuditLogs.map((auditLog, auditLogIndex) => (
|
||||
<li key={auditLog.id} className="relative flex gap-x-4">
|
||||
<div
|
||||
className={cn(
|
||||
auditLogIndex === documentAuditLogs.length - 1 ? 'h-6' : '-bottom-6',
|
||||
'absolute left-0 top-0 flex w-6 justify-center',
|
||||
)}
|
||||
>
|
||||
<div className="bg-border w-px" />
|
||||
</div>
|
||||
|
||||
<div className="bg-widget text-foreground/40 relative flex h-6 w-6 flex-none items-center justify-center">
|
||||
{match(auditLog.type)
|
||||
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED, () => (
|
||||
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
|
||||
<CheckCheckIcon className="h-3 w-3" aria-hidden="true" />
|
||||
</div>
|
||||
))
|
||||
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, () => (
|
||||
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
|
||||
<CheckIcon className="h-3 w-3" aria-hidden="true" />
|
||||
</div>
|
||||
))
|
||||
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED, () => (
|
||||
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
|
||||
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
|
||||
</div>
|
||||
))
|
||||
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED, () => (
|
||||
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
|
||||
<MailOpen className="h-3 w-3" aria-hidden="true" />
|
||||
</div>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<div className="bg-widget h-1.5 w-1.5 rounded-full ring-1 ring-gray-300 dark:ring-neutral-600" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p
|
||||
className="text-muted-foreground dark:text-muted-foreground/70 flex-auto truncate py-0.5 text-xs leading-5"
|
||||
title={formatDocumentAuditLogAction(_, auditLog, userId).description}
|
||||
>
|
||||
{formatDocumentAuditLogAction(_, auditLog, userId).description}
|
||||
</p>
|
||||
|
||||
<time className="text-muted-foreground dark:text-muted-foreground/70 flex-none py-0.5 text-xs leading-5">
|
||||
{DateTime.fromJSDate(auditLog.createdAt).toRelative({ style: 'short' })}
|
||||
</time>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</AnimateGenericFadeInOut>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@ -1,174 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
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 { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||
import type { Document, Recipient } from '@documenso/prisma/client';
|
||||
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 (
|
||||
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
|
||||
<div className="flex flex-row items-center justify-between px-4 py-3">
|
||||
<h1 className="text-foreground font-medium">
|
||||
<Trans>Recipients</Trans>
|
||||
</h1>
|
||||
|
||||
{document.status !== DocumentStatus.COMPLETED && (
|
||||
<Link
|
||||
href={`${documentRootPath}/${document.id}/edit?step=signers`}
|
||||
title={_(msg`Modify recipients`)}
|
||||
className="flex flex-row items-center justify-between"
|
||||
>
|
||||
{recipients.length === 0 ? (
|
||||
<PlusIcon className="ml-2 h-4 w-4" />
|
||||
) : (
|
||||
<PenIcon className="ml-2 h-3 w-3" />
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className="text-muted-foreground divide-y border-t">
|
||||
{recipients.length === 0 && (
|
||||
<li className="flex flex-col items-center justify-center py-6 text-sm">
|
||||
<Trans>No recipients</Trans>
|
||||
</li>
|
||||
)}
|
||||
|
||||
{recipients.map((recipient) => (
|
||||
<li key={recipient.id} className="flex items-center justify-between px-4 py-2.5 text-sm">
|
||||
<AvatarWithText
|
||||
avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
|
||||
primaryText={<p className="text-muted-foreground text-sm">{recipient.email}</p>}
|
||||
secondaryText={
|
||||
<p className="text-muted-foreground/70 text-xs">
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-row items-center">
|
||||
{document.status !== DocumentStatus.DRAFT &&
|
||||
recipient.signingStatus === SigningStatus.SIGNED && (
|
||||
<Badge variant="default">
|
||||
{match(recipient.role)
|
||||
.with(RecipientRole.APPROVER, () => (
|
||||
<>
|
||||
<CheckIcon className="mr-1 h-3 w-3" />
|
||||
<Trans>Approved</Trans>
|
||||
</>
|
||||
))
|
||||
.with(RecipientRole.CC, () =>
|
||||
document.status === DocumentStatus.COMPLETED ? (
|
||||
<>
|
||||
<MailIcon className="mr-1 h-3 w-3" />
|
||||
<Trans>Sent</Trans>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckIcon className="mr-1 h-3 w-3" />
|
||||
<Trans>Ready</Trans>
|
||||
</>
|
||||
),
|
||||
)
|
||||
|
||||
.with(RecipientRole.SIGNER, () => (
|
||||
<>
|
||||
<SignatureIcon className="mr-1 h-3 w-3" />
|
||||
<Trans>Signed</Trans>
|
||||
</>
|
||||
))
|
||||
.with(RecipientRole.VIEWER, () => (
|
||||
<>
|
||||
<MailOpenIcon className="mr-1 h-3 w-3" />
|
||||
<Trans>Viewed</Trans>
|
||||
</>
|
||||
))
|
||||
.exhaustive()}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{document.status !== DocumentStatus.DRAFT &&
|
||||
recipient.signingStatus === SigningStatus.NOT_SIGNED && (
|
||||
<Badge variant="secondary">
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
<Trans>Pending</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{document.status !== DocumentStatus.DRAFT &&
|
||||
recipient.signingStatus === SigningStatus.REJECTED && (
|
||||
<PopoverHover
|
||||
trigger={
|
||||
<Badge variant="destructive">
|
||||
<AlertTriangle className="mr-1 h-3 w-3" />
|
||||
<Trans>Rejected</Trans>
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
<p className="text-sm">
|
||||
<Trans>Reason for rejection: </Trans>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{recipient.rejectionReason}
|
||||
</p>
|
||||
</PopoverHover>
|
||||
)}
|
||||
|
||||
{document.status === DocumentStatus.PENDING &&
|
||||
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
|
||||
recipient.role !== RecipientRole.CC && (
|
||||
<CopyTextButton
|
||||
value={formatSigningLink(recipient.token)}
|
||||
onCopySuccess={() => {
|
||||
toast({
|
||||
title: _(msg`Copied to clipboard`),
|
||||
description: _(msg`The signing link has been copied to your clipboard.`),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@ -1,270 +0,0 @@
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { Plural, Trans } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
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 { DocumentStatus } from '@documenso/prisma/client';
|
||||
import type { Team, TeamEmail } from '@documenso/prisma/client';
|
||||
import { TeamMemberRole } from '@documenso/prisma/client';
|
||||
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 = {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
team?: Team & { teamEmail: TeamEmail | null } & { currentTeamMember: { role: TeamMemberRole } };
|
||||
};
|
||||
|
||||
export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) => {
|
||||
const { id } = params;
|
||||
const { _ } = useLingui();
|
||||
|
||||
const documentId = Number(id);
|
||||
|
||||
const documentRootPath = formatDocumentsPath(team?.url);
|
||||
|
||||
if (!documentId || Number.isNaN(documentId)) {
|
||||
redirect(documentRootPath);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||
{document.status === DocumentStatus.PENDING && (
|
||||
<DocumentRecipientLinkCopyDialog recipients={recipients} />
|
||||
)}
|
||||
|
||||
<Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
<Trans>Documents</Trans>
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-row justify-between truncate">
|
||||
<div>
|
||||
<h1
|
||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||
title={document.title}
|
||||
>
|
||||
{document.title}
|
||||
</h1>
|
||||
|
||||
<div className="mt-2.5 flex items-center gap-x-6">
|
||||
<DocumentStatusComponent
|
||||
inheritColor
|
||||
status={document.status}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
|
||||
{recipients.length > 0 && (
|
||||
<div className="text-muted-foreground flex items-center">
|
||||
<Users2 className="mr-2 h-5 w-5" />
|
||||
|
||||
<StackAvatarsWithTooltip
|
||||
recipients={recipients}
|
||||
documentStatus={document.status}
|
||||
position="bottom"
|
||||
>
|
||||
<span>
|
||||
<Trans>{recipients.length} Recipient(s)</Trans>
|
||||
</span>
|
||||
</StackAvatarsWithTooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{document.deletedAt && (
|
||||
<Badge variant="destructive">
|
||||
<Trans>Document deleted</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isDocumentHistoryEnabled && (
|
||||
<div className="self-end">
|
||||
<DocumentHistorySheet documentId={document.id} userId={user.id}>
|
||||
<Button variant="outline">
|
||||
<Clock9 className="mr-1.5 h-4 w-4" />
|
||||
<Trans>Document history</Trans>
|
||||
</Button>
|
||||
</DocumentHistorySheet>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid w-full grid-cols-12 gap-8">
|
||||
<Card
|
||||
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
|
||||
gradient
|
||||
>
|
||||
<CardContent className="p-2">
|
||||
<LazyPDFViewer document={document} key={documentData.id} documentData={documentData} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{document.status === DocumentStatus.PENDING && (
|
||||
<DocumentReadOnlyFields fields={fields} documentMeta={documentMeta || undefined} />
|
||||
)}
|
||||
|
||||
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||
<div className="space-y-6">
|
||||
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
|
||||
<div className="flex flex-row items-center justify-between px-4">
|
||||
<h3 className="text-foreground text-2xl font-semibold">
|
||||
{_(FRIENDLY_STATUS_MAP[document.status].labelExtended)}
|
||||
</h3>
|
||||
|
||||
<DocumentPageViewDropdown document={documentWithRecipients} team={team} />
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground mt-2 px-4 text-sm">
|
||||
{match(document.status)
|
||||
.with(DocumentStatus.COMPLETED, () => (
|
||||
<Trans>This document has been signed by all recipients</Trans>
|
||||
))
|
||||
.with(DocumentStatus.DRAFT, () => (
|
||||
<Trans>This document is currently a draft and has not been sent</Trans>
|
||||
))
|
||||
.with(DocumentStatus.PENDING, () => {
|
||||
const pendingRecipients = recipients.filter(
|
||||
(recipient) => recipient.signingStatus === 'NOT_SIGNED',
|
||||
);
|
||||
|
||||
return (
|
||||
<Plural
|
||||
value={pendingRecipients.length}
|
||||
one="Waiting on 1 recipient"
|
||||
other="Waiting on # recipients"
|
||||
/>
|
||||
);
|
||||
})
|
||||
.exhaustive()}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 border-t px-4 pt-4">
|
||||
<DocumentPageViewButton document={documentWithRecipients} team={team} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Document information section. */}
|
||||
<DocumentPageViewInformation document={documentWithRecipients} userId={user.id} />
|
||||
|
||||
{/* Recipients section. */}
|
||||
<DocumentPageViewRecipients
|
||||
document={documentWithRecipients}
|
||||
documentRootPath={documentRootPath}
|
||||
/>
|
||||
|
||||
{/* Recent activity section. */}
|
||||
<DocumentPageViewRecentActivity documentId={document.id} userId={user.id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,425 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
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 { DocumentDistributionMethod, DocumentStatus } from '@documenso/prisma/client';
|
||||
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<EditDocumentStep, DocumentFlowStep> = {
|
||||
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<EditDocumentStep>(() => {
|
||||
// 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 (
|
||||
<div className={cn('grid w-full grid-cols-12 gap-8', className)}>
|
||||
<Card
|
||||
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
|
||||
gradient
|
||||
>
|
||||
<CardContent className="p-2">
|
||||
<LazyPDFViewer
|
||||
key={document.documentData.id}
|
||||
documentData={document.documentData}
|
||||
document={document}
|
||||
password={document.documentMeta?.password}
|
||||
onPasswordSubmit={onPasswordSubmit}
|
||||
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||
<DocumentFlowFormContainer
|
||||
className="lg:h-[calc(100vh-6rem)]"
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
>
|
||||
<Stepper
|
||||
currentStep={currentDocumentFlow.stepIndex}
|
||||
setCurrentStep={(step) => setStep(EditDocumentSteps[step - 1])}
|
||||
>
|
||||
<AddSettingsFormPartial
|
||||
key={recipients.length}
|
||||
documentFlow={documentFlow.settings}
|
||||
document={document}
|
||||
currentTeamMemberRole={team?.currentTeamMember?.role}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
isDocumentEnterprise={isDocumentEnterprise}
|
||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||
onSubmit={onAddSettingsFormSubmit}
|
||||
/>
|
||||
|
||||
<AddSignersFormPartial
|
||||
key={recipients.length}
|
||||
documentFlow={documentFlow.signers}
|
||||
recipients={recipients}
|
||||
signingOrder={document.documentMeta?.signingOrder}
|
||||
fields={fields}
|
||||
isDocumentEnterprise={isDocumentEnterprise}
|
||||
onSubmit={onAddSignersFormSubmit}
|
||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||
/>
|
||||
|
||||
<AddFieldsFormPartial
|
||||
key={fields.length}
|
||||
documentFlow={documentFlow.fields}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
onSubmit={onAddFieldsFormSubmit}
|
||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
||||
teamId={team?.id}
|
||||
/>
|
||||
|
||||
<AddSubjectFormPartial
|
||||
key={recipients.length}
|
||||
documentFlow={documentFlow.subject}
|
||||
document={document}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
onSubmit={onAddSubjectFormSubmit}
|
||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||
/>
|
||||
</Stepper>
|
||||
</DocumentFlowFormContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,147 +0,0 @@
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { Plural, Trans } from '@lingui/macro';
|
||||
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 type { Team } from '@documenso/prisma/client';
|
||||
import { TeamMemberRole } from '@documenso/prisma/client';
|
||||
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document';
|
||||
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||
|
||||
export type DocumentEditPageViewProps = {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
team?: Team & { currentTeamMember: { role: TeamMemberRole } };
|
||||
};
|
||||
|
||||
export const DocumentEditPageView = async ({ params, team }: DocumentEditPageViewProps) => {
|
||||
const { id } = params;
|
||||
|
||||
const documentId = Number(id);
|
||||
|
||||
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 (
|
||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||
<Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
<Trans>Documents</Trans>
|
||||
</Link>
|
||||
|
||||
<h1
|
||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||
title={document.title}
|
||||
>
|
||||
{document.title}
|
||||
</h1>
|
||||
|
||||
<div className="mt-2.5 flex items-center gap-x-6">
|
||||
<DocumentStatus inheritColor status={document.status} className="text-muted-foreground" />
|
||||
|
||||
{recipients.length > 0 && (
|
||||
<div className="text-muted-foreground flex items-center">
|
||||
<Users2 className="mr-2 h-5 w-5" />
|
||||
|
||||
<StackAvatarsWithTooltip
|
||||
recipients={recipients}
|
||||
documentStatus={document.status}
|
||||
position="bottom"
|
||||
>
|
||||
<span>
|
||||
<Plural one="1 Recipient" other="# Recipients" value={recipients.length} />
|
||||
</span>
|
||||
</StackAvatarsWithTooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<EditDocumentForm
|
||||
className="mt-6"
|
||||
initialDocument={document}
|
||||
documentRootPath={documentRootPath}
|
||||
isDocumentEnterprise={isDocumentEnterprise}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,15 +0,0 @@
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
|
||||
import { DocumentEditPageView } from './document-edit-page-view';
|
||||
|
||||
export type DocumentPageProps = {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function DocumentEditPage({ params }: DocumentPageProps) {
|
||||
await setupI18nSSR();
|
||||
|
||||
return <DocumentEditPageView params={params} />;
|
||||
}
|
||||
@ -1,42 +0,0 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { ChevronLeft, Loader } from 'lucide-react';
|
||||
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||
|
||||
export default async function Loading() {
|
||||
await setupI18nSSR();
|
||||
|
||||
return (
|
||||
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
|
||||
<Link href="/documents" className="flex grow-0 items-center text-[#7AC455] hover:opacity-80">
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
<Trans>Documents</Trans>
|
||||
</Link>
|
||||
|
||||
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
|
||||
<Trans>Loading Document...</Trans>
|
||||
</h1>
|
||||
|
||||
<div className="flex h-10 items-center">
|
||||
<Skeleton className="my-6 h-4 w-24 rounded-2xl" />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid h-[80vh] max-h-[60rem] w-full grid-cols-12 gap-x-8">
|
||||
<div className="dark:bg-background border-border col-span-12 rounded-xl border-2 bg-white/50 p-2 before:rounded-xl lg:col-span-6 xl:col-span-7">
|
||||
<div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center">
|
||||
<Loader className="text-documenso h-12 w-12 animate-spin" />
|
||||
|
||||
<p className="text-muted-foreground mt-4">
|
||||
<Trans>Loading document...</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-background border-border col-span-12 rounded-xl border-2 before:rounded-xl lg:col-span-6 xl:col-span-5" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,163 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
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 ? (
|
||||
<div>
|
||||
{row.original.name && (
|
||||
<p className="truncate" title={row.original.name}>
|
||||
{row.original.name}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{row.original.email && (
|
||||
<p className="truncate" title={row.original.email}>
|
||||
{row.original.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p>N/A</p>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: _(msg`Action`),
|
||||
accessorKey: 'type',
|
||||
cell: ({ row }) => <span>{formatDocumentAuditLogAction(_, row.original).description}</span>,
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={results.data}
|
||||
perPage={results.perPage}
|
||||
currentPage={results.currentPage}
|
||||
totalPages={results.totalPages}
|
||||
onPaginationChange={onPaginationChange}
|
||||
error={{
|
||||
enable: isLoadingError,
|
||||
}}
|
||||
skeleton={{
|
||||
enable: isLoading,
|
||||
rows: 3,
|
||||
component: (
|
||||
<>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-12 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell className="w-1/2 py-4 pr-4">
|
||||
<div className="ml-2 flex flex-grow flex-col">
|
||||
<Skeleton className="h-4 w-1/3 max-w-[8rem]" />
|
||||
<Skeleton className="mt-1 h-4 w-1/2 max-w-[12rem]" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-12 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-10 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-10 rounded-full" />
|
||||
</TableCell>
|
||||
</>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||
</DataTable>
|
||||
);
|
||||
};
|
||||
@ -1,179 +0,0 @@
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
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 type { Recipient, Team } from '@documenso/prisma/client';
|
||||
import { Card } from '@documenso/ui/primitives/card';
|
||||
|
||||
import {
|
||||
DocumentStatus as DocumentStatusComponent,
|
||||
FRIENDLY_STATUS_MAP,
|
||||
} from '~/components/formatter/document-status';
|
||||
|
||||
import { DocumentLogsDataTable } from './document-logs-data-table';
|
||||
import { DownloadAuditLogButton } from './download-audit-log-button';
|
||||
import { DownloadCertificateButton } from './download-certificate-button';
|
||||
|
||||
export type DocumentLogsPageViewProps = {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
team?: Team;
|
||||
};
|
||||
|
||||
export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageViewProps) => {
|
||||
const { _, i18n } = useLingui();
|
||||
|
||||
const { id } = params;
|
||||
|
||||
const documentId = Number(id);
|
||||
|
||||
const documentRootPath = formatDocumentsPath(team?.url);
|
||||
|
||||
if (!documentId || Number.isNaN(documentId)) {
|
||||
redirect(documentRootPath);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||
<Link
|
||||
href={`${documentRootPath}/${document.id}`}
|
||||
className="flex items-center text-[#7AC455] hover:opacity-80"
|
||||
>
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
<Trans>Document</Trans>
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-col justify-between truncate sm:flex-row">
|
||||
<div>
|
||||
<h1
|
||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||
title={document.title}
|
||||
>
|
||||
{document.title}
|
||||
</h1>
|
||||
|
||||
<div className="mt-2.5 flex items-center gap-x-6">
|
||||
<DocumentStatusComponent
|
||||
inheritColor
|
||||
status={document.status}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end">
|
||||
<DownloadCertificateButton
|
||||
className="mr-2"
|
||||
documentId={document.id}
|
||||
documentStatus={document.status}
|
||||
teamId={team?.id}
|
||||
/>
|
||||
|
||||
<DownloadAuditLogButton teamId={team?.id} documentId={document.id} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="mt-6">
|
||||
<Card className="grid grid-cols-1 gap-4 p-4 sm:grid-cols-2" degrees={45} gradient>
|
||||
{documentInformation.map((info, i) => (
|
||||
<div className="text-foreground text-sm" key={i}>
|
||||
<h3 className="font-semibold">{_(info.description)}</h3>
|
||||
<p className="text-muted-foreground">{info.value}</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="text-foreground text-sm">
|
||||
<h3 className="font-semibold">Recipients</h3>
|
||||
<ul className="text-muted-foreground list-inside list-disc">
|
||||
{recipients.map((recipient) => (
|
||||
<li key={`recipient-${recipient.id}`}>
|
||||
<span className="-ml-2">{formatRecipientText(recipient)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="mt-6">
|
||||
<DocumentLogsDataTable documentId={document.id} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,80 +0,0 @@
|
||||
'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 (
|
||||
<Button
|
||||
className={cn('w-full sm:w-auto', className)}
|
||||
loading={isPending}
|
||||
onClick={() => void onDownloadAuditLogsClick()}
|
||||
>
|
||||
{!isPending && <DownloadIcon className="mr-1.5 h-4 w-4" />}
|
||||
<Trans>Download Audit Logs</Trans>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@ -1,89 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { DownloadIcon } from 'lucide-react';
|
||||
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
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 (
|
||||
<Button
|
||||
className={cn('w-full sm:w-auto', className)}
|
||||
loading={isPending}
|
||||
variant="outline"
|
||||
disabled={documentStatus !== DocumentStatus.COMPLETED}
|
||||
onClick={() => void onDownloadCertificatesClick()}
|
||||
>
|
||||
{!isPending && <DownloadIcon className="mr-1.5 h-4 w-4" />}
|
||||
<Trans>Download Certificate</Trans>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@ -1,15 +0,0 @@
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
|
||||
import { DocumentLogsPageView } from './document-logs-page-view';
|
||||
|
||||
export type DocumentsLogsPageProps = {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function DocumentsLogsPage({ params }: DocumentsLogsPageProps) {
|
||||
await setupI18nSSR();
|
||||
|
||||
return <DocumentLogsPageView params={params} />;
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
|
||||
import { DocumentPageView } from './document-page-view';
|
||||
|
||||
export type DocumentPageProps = {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function DocumentPage({ params }: DocumentPageProps) {
|
||||
await setupI18nSSR();
|
||||
|
||||
return <DocumentPageView params={params} />;
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
|
||||
export default async function DocumentSentPage() {
|
||||
await setupI18nSSR();
|
||||
|
||||
return (
|
||||
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
|
||||
<Link href="/documents" className="flex grow-0 items-center text-[#7AC455] hover:opacity-80">
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
<Trans>Documents</Trans>
|
||||
</Link>
|
||||
|
||||
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
|
||||
<Trans>Loading Document...</Trans>
|
||||
</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,197 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { History } from 'lucide-react';
|
||||
import { useSession } from 'next-auth/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 type { Team } from '@documenso/prisma/client';
|
||||
import { type Document, type Recipient, SigningStatus } from '@documenso/prisma/client';
|
||||
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';
|
||||
|
||||
const FORM_ID = 'resend-email';
|
||||
|
||||
export type ResendDocumentActionItemProps = {
|
||||
document: Document & {
|
||||
team: Pick<Team, 'id' | 'url'> | null;
|
||||
};
|
||||
recipients: Recipient[];
|
||||
team?: Pick<Team, 'id' | 'url'>;
|
||||
};
|
||||
|
||||
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<typeof ZResendDocumentFormSchema>;
|
||||
|
||||
export const ResendDocumentActionItem = ({
|
||||
document,
|
||||
recipients,
|
||||
team,
|
||||
}: ResendDocumentActionItemProps) => {
|
||||
const { data: session } = useSession();
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const isOwner = document.userId === session?.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<TResendDocumentFormSchema>({
|
||||
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 (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<DropdownMenuItem disabled={isDisabled} onSelect={(e) => e.preventDefault()}>
|
||||
<History className="mr-2 h-4 w-4" />
|
||||
<Trans>Resend</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="sm:max-w-sm" hideClose>
|
||||
<DialogHeader>
|
||||
<DialogTitle asChild>
|
||||
<h1 className="text-center text-xl">
|
||||
<Trans>Who do you want to remind?</Trans>
|
||||
</h1>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form id={FORM_ID} onSubmit={handleSubmit(onFormSubmit)} className="px-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="recipients"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
{recipients.map((recipient) => (
|
||||
<FormItem
|
||||
key={recipient.id}
|
||||
className="flex flex-row items-center justify-between gap-x-3"
|
||||
>
|
||||
<FormLabel
|
||||
className={cn('my-2 flex items-center gap-2 font-normal', {
|
||||
'opacity-50': !value.includes(recipient.id),
|
||||
})}
|
||||
>
|
||||
<StackAvatar
|
||||
key={recipient.id}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
{recipient.email}
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
className="h-5 w-5 rounded-full"
|
||||
value={recipient.id}
|
||||
checked={value.includes(recipient.id)}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
checked
|
||||
? onChange([...value, recipient.id])
|
||||
: onChange(value.filter((v) => v !== recipient.id))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
type="button"
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
||||
variant="secondary"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button className="flex-1" loading={isSubmitting} type="submit" form={FORM_ID}>
|
||||
<Trans>Send reminder</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -1,146 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { CheckCircle, Download, Edit, 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 type { Document, Recipient, Team, User } from '@documenso/prisma/client';
|
||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||
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 DataTableActionButtonProps = {
|
||||
row: Document & {
|
||||
user: Pick<User, 'id' | 'name' | 'email'>;
|
||||
recipients: Recipient[];
|
||||
team: Pick<Team, 'id' | 'url'> | null;
|
||||
};
|
||||
team?: Pick<Team, 'id' | 'url'>;
|
||||
};
|
||||
|
||||
export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps) => {
|
||||
const { data: session } = useSession();
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const recipient = row.recipients.find((recipient) => recipient.email === session.user.email);
|
||||
|
||||
const isOwner = row.user.id === session.user.id;
|
||||
const isRecipient = !!recipient;
|
||||
const isDraft = row.status === DocumentStatus.DRAFT;
|
||||
const isPending = row.status === DocumentStatus.PENDING;
|
||||
const isComplete = row.status === DocumentStatus.COMPLETED;
|
||||
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||
const role = recipient?.role;
|
||||
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
||||
|
||||
const documentsPath = formatDocumentsPath(team?.url);
|
||||
|
||||
const onDownloadClick = async () => {
|
||||
try {
|
||||
const document = !recipient
|
||||
? await trpcClient.document.getDocumentById.query(
|
||||
{
|
||||
documentId: row.id,
|
||||
},
|
||||
{
|
||||
context: {
|
||||
teamId: team?.id?.toString(),
|
||||
},
|
||||
},
|
||||
)
|
||||
: await trpcClient.document.getDocumentByToken.query({
|
||||
token: recipient.token,
|
||||
});
|
||||
|
||||
const documentData = document?.documentData;
|
||||
|
||||
if (!documentData) {
|
||||
throw Error('No document available');
|
||||
}
|
||||
|
||||
await downloadPDF({ documentData, fileName: row.title });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`An error occurred while downloading your document.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: Consider if want to keep this logic for hiding viewing for CC'ers
|
||||
if (recipient?.role === RecipientRole.CC && isComplete === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return match({
|
||||
isOwner,
|
||||
isRecipient,
|
||||
isDraft,
|
||||
isPending,
|
||||
isComplete,
|
||||
isSigned,
|
||||
isCurrentTeamDocument,
|
||||
})
|
||||
.with(
|
||||
isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true },
|
||||
() => (
|
||||
<Button className="w-32" asChild>
|
||||
<Link href={`${documentsPath}/${row.id}/edit`}>
|
||||
<Edit className="-ml-1 mr-2 h-4 w-4" />
|
||||
<Trans>Edit</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
),
|
||||
)
|
||||
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
|
||||
<Button className="w-32" asChild>
|
||||
<Link href={`/sign/${recipient?.token}`}>
|
||||
{match(role)
|
||||
.with(RecipientRole.SIGNER, () => (
|
||||
<>
|
||||
<Pencil className="-ml-1 mr-2 h-4 w-4" />
|
||||
<Trans>Sign</Trans>
|
||||
</>
|
||||
))
|
||||
.with(RecipientRole.APPROVER, () => (
|
||||
<>
|
||||
<CheckCircle className="-ml-1 mr-2 h-4 w-4" />
|
||||
<Trans>Approve</Trans>
|
||||
</>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<>
|
||||
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
|
||||
<Trans>View</Trans>
|
||||
</>
|
||||
))}
|
||||
</Link>
|
||||
</Button>
|
||||
))
|
||||
.with({ isPending: true, isSigned: true }, () => (
|
||||
<Button className="w-32" disabled={true}>
|
||||
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
|
||||
<Trans>View</Trans>
|
||||
</Button>
|
||||
))
|
||||
.with({ isComplete: true }, () => (
|
||||
<Button className="w-32" onClick={onDownloadClick}>
|
||||
<Download className="-ml-1 mr-2 inline h-4 w-4" />
|
||||
<Trans>Download</Trans>
|
||||
</Button>
|
||||
))
|
||||
.otherwise(() => <div></div>);
|
||||
};
|
||||
@ -1,244 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import {
|
||||
CheckCircle,
|
||||
Copy,
|
||||
Download,
|
||||
Edit,
|
||||
EyeIcon,
|
||||
Loader,
|
||||
MoreHorizontal,
|
||||
MoveRight,
|
||||
Pencil,
|
||||
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 type { Document, Recipient, Team, User } from '@documenso/prisma/client';
|
||||
import { DocumentStatus, RecipientRole } from '@documenso/prisma/client';
|
||||
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 { ResendDocumentActionItem } from './_action-items/resend-document';
|
||||
import { DeleteDocumentDialog } from './delete-document-dialog';
|
||||
import { DuplicateDocumentDialog } from './duplicate-document-dialog';
|
||||
import { MoveDocumentDialog } from './move-document-dialog';
|
||||
|
||||
export type DataTableActionDropdownProps = {
|
||||
row: Document & {
|
||||
user: Pick<User, 'id' | 'name' | 'email'>;
|
||||
recipients: Recipient[];
|
||||
team: Pick<Team, 'id' | 'url'> | null;
|
||||
};
|
||||
team?: Pick<Team, 'id' | 'url'> & { teamEmail?: string };
|
||||
};
|
||||
|
||||
export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownProps) => {
|
||||
const { data: session } = useSession();
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
||||
const [isMoveDialogOpen, setMoveDialogOpen] = useState(false);
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const recipient = row.recipients.find((recipient) => recipient.email === session.user.email);
|
||||
|
||||
const isOwner = row.user.id === session.user.id;
|
||||
// const isRecipient = !!recipient;
|
||||
const isDraft = row.status === DocumentStatus.DRAFT;
|
||||
const isPending = row.status === DocumentStatus.PENDING;
|
||||
const isComplete = row.status === DocumentStatus.COMPLETED;
|
||||
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
||||
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
|
||||
|
||||
const documentsPath = formatDocumentsPath(team?.url);
|
||||
|
||||
const onDownloadClick = async () => {
|
||||
try {
|
||||
const document = !recipient
|
||||
? await trpcClient.document.getDocumentById.query({
|
||||
documentId: row.id,
|
||||
})
|
||||
: await trpcClient.document.getDocumentByToken.query({
|
||||
token: recipient.token,
|
||||
});
|
||||
|
||||
const documentData = document?.documentData;
|
||||
|
||||
if (!documentData) {
|
||||
return;
|
||||
}
|
||||
|
||||
await downloadPDF({ documentData, fileName: row.title });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`An error occurred while downloading your document.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const nonSignedRecipients = row.recipients.filter((item) => item.signingStatus !== 'SIGNED');
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger data-testid="document-table-action-btn">
|
||||
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||
<DropdownMenuLabel>
|
||||
<Trans>Action</Trans>
|
||||
</DropdownMenuLabel>
|
||||
|
||||
{!isDraft && recipient && recipient?.role !== RecipientRole.CC && (
|
||||
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
|
||||
<Link href={`/sign/${recipient?.token}`}>
|
||||
{recipient?.role === RecipientRole.VIEWER && (
|
||||
<>
|
||||
<EyeIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>View</Trans>
|
||||
</>
|
||||
)}
|
||||
|
||||
{recipient?.role === RecipientRole.SIGNER && (
|
||||
<>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
<Trans>Sign</Trans>
|
||||
</>
|
||||
)}
|
||||
|
||||
{recipient?.role === RecipientRole.APPROVER && (
|
||||
<>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
<Trans>Approve</Trans>
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem disabled={!canManageDocument || isComplete} asChild>
|
||||
<Link href={`${documentsPath}/${row.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
<Trans>Edit</Trans>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem disabled={!isComplete} onClick={onDownloadClick}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
<Trans>Download</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
<Trans>Duplicate</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* We don't want to allow teams moving documents across at the moment. */}
|
||||
{!team && (
|
||||
<DropdownMenuItem onClick={() => setMoveDialogOpen(true)}>
|
||||
<MoveRight className="mr-2 h-4 w-4" />
|
||||
<Trans>Move to Team</Trans>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{/* No point displaying this if there's no functionality. */}
|
||||
{/* <DropdownMenuItem disabled>
|
||||
<XCircle className="mr-2 h-4 w-4" />
|
||||
Void
|
||||
</DropdownMenuItem> */}
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
disabled={Boolean(!canManageDocument && team?.teamEmail)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{canManageDocument ? _(msg`Delete`) : _(msg`Hide`)}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuLabel>
|
||||
<Trans>Share</Trans>
|
||||
</DropdownMenuLabel>
|
||||
|
||||
{canManageDocument && (
|
||||
<DocumentRecipientLinkCopyDialog
|
||||
recipients={row.recipients}
|
||||
trigger={
|
||||
<DropdownMenuItem disabled={!isPending} asChild onSelect={(e) => e.preventDefault()}>
|
||||
<div>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
<Trans>Signing Links</Trans>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ResendDocumentActionItem document={row} recipients={nonSignedRecipients} team={team} />
|
||||
|
||||
<DocumentShareButton
|
||||
documentId={row.id}
|
||||
token={isOwner ? undefined : recipient?.token}
|
||||
trigger={({ loading, disabled }) => (
|
||||
<DropdownMenuItem disabled={disabled || isDraft} onSelect={(e) => e.preventDefault()}>
|
||||
<div className="flex items-center">
|
||||
{loading ? <Loader className="mr-2 h-4 w-4" /> : <Share className="mr-2 h-4 w-4" />}
|
||||
<Trans>Share Signing Card</Trans>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
|
||||
<DeleteDocumentDialog
|
||||
id={row.id}
|
||||
status={row.status}
|
||||
documentTitle={row.title}
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
teamId={team?.id}
|
||||
canManageDocument={canManageDocument}
|
||||
/>
|
||||
|
||||
<MoveDocumentDialog
|
||||
documentId={row.id}
|
||||
open={isMoveDialogOpen}
|
||||
onOpenChange={setMoveDialogOpen}
|
||||
/>
|
||||
|
||||
<DuplicateDocumentDialog
|
||||
id={row.id}
|
||||
open={isDuplicateDialogOpen}
|
||||
onOpenChange={setDuplicateDialogOpen}
|
||||
team={team}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@ -1,70 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||
import { parseToIntegerArray } from '@documenso/lib/utils/params';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
|
||||
|
||||
type DataTableSenderFilterProps = {
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const DataTableSenderFilter = ({ teamId }: DataTableSenderFilterProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
const senderIds = parseToIntegerArray(searchParams?.get('senderIds') ?? '');
|
||||
|
||||
const { data, isLoading } = trpc.team.getTeamMembers.useQuery({
|
||||
teamId,
|
||||
});
|
||||
|
||||
const comboBoxOptions = (data ?? []).map((member) => ({
|
||||
label: member.user.name ?? member.user.email,
|
||||
value: member.user.id,
|
||||
}));
|
||||
|
||||
const onChange = (newSenderIds: number[]) => {
|
||||
if (!pathname) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
|
||||
params.set('senderIds', newSenderIds.join(','));
|
||||
|
||||
if (newSenderIds.length === 0) {
|
||||
params.delete('senderIds');
|
||||
}
|
||||
|
||||
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<MultiSelectCombobox
|
||||
emptySelectionPlaceholder={
|
||||
<p className="text-muted-foreground font-normal">
|
||||
<Trans>
|
||||
<span className="text-muted-foreground/70">Sender:</span> All
|
||||
</Trans>
|
||||
</p>
|
||||
}
|
||||
enableClearAllButton={true}
|
||||
inputPlaceholder={msg`Search`}
|
||||
loading={!isMounted || isLoading}
|
||||
options={comboBoxOptions}
|
||||
selectedValues={senderIds}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -1,63 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
|
||||
|
||||
export type DataTableTitleProps = {
|
||||
row: Document & {
|
||||
user: Pick<User, 'id' | 'name' | 'email'>;
|
||||
team: Pick<Team, 'url'> | null;
|
||||
recipients: Recipient[];
|
||||
};
|
||||
teamUrl?: string;
|
||||
};
|
||||
|
||||
export const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => {
|
||||
const { data: session } = useSession();
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const recipient = row.recipients.find((recipient) => recipient.email === session.user.email);
|
||||
|
||||
const isOwner = row.user.id === session.user.id;
|
||||
const isRecipient = !!recipient;
|
||||
const isCurrentTeamDocument = teamUrl && row.team?.url === teamUrl;
|
||||
|
||||
const documentsPath = formatDocumentsPath(isCurrentTeamDocument ? teamUrl : undefined);
|
||||
|
||||
return match({
|
||||
isOwner,
|
||||
isRecipient,
|
||||
isCurrentTeamDocument,
|
||||
})
|
||||
.with({ isOwner: true }, { isCurrentTeamDocument: true }, () => (
|
||||
<Link
|
||||
href={`${documentsPath}/${row.id}`}
|
||||
title={row.title}
|
||||
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
|
||||
>
|
||||
{row.title}
|
||||
</Link>
|
||||
))
|
||||
.with({ isRecipient: true }, () => (
|
||||
<Link
|
||||
href={`/sign/${recipient?.token}`}
|
||||
title={row.title}
|
||||
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
|
||||
>
|
||||
{row.title}
|
||||
</Link>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<span className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]">
|
||||
{row.title}
|
||||
</span>
|
||||
));
|
||||
};
|
||||
@ -1,127 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useTransition } from 'react';
|
||||
|
||||
import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import type { Team } from '@documenso/prisma/client';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/schema';
|
||||
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 { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||
|
||||
import { DataTableActionButton } from './data-table-action-button';
|
||||
import { DataTableActionDropdown } from './data-table-action-dropdown';
|
||||
import { DataTableTitle } from './data-table-title';
|
||||
|
||||
export type DocumentsDataTableProps = {
|
||||
results: TFindDocumentsResponse;
|
||||
showSenderColumn?: boolean;
|
||||
team?: Pick<Team, 'id' | 'url'> & { teamEmail?: string };
|
||||
};
|
||||
|
||||
export const DocumentsDataTable = ({
|
||||
results,
|
||||
showSenderColumn,
|
||||
team,
|
||||
}: DocumentsDataTableProps) => {
|
||||
const { _, i18n } = useLingui();
|
||||
|
||||
const { data: session } = useSession();
|
||||
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
header: _(msg`Created`),
|
||||
accessorKey: 'createdAt',
|
||||
cell: ({ row }) =>
|
||||
i18n.date(row.original.createdAt, { ...DateTime.DATETIME_SHORT, hourCycle: 'h12' }),
|
||||
},
|
||||
{
|
||||
header: _(msg`Title`),
|
||||
cell: ({ row }) => <DataTableTitle row={row.original} teamUrl={team?.url} />,
|
||||
},
|
||||
{
|
||||
id: 'sender',
|
||||
header: _(msg`Sender`),
|
||||
cell: ({ row }) => row.original.user.name ?? row.original.user.email,
|
||||
},
|
||||
{
|
||||
header: _(msg`Recipient`),
|
||||
accessorKey: 'recipient',
|
||||
cell: ({ row }) => (
|
||||
<StackAvatarsWithTooltip
|
||||
recipients={row.original.recipients}
|
||||
documentStatus={row.original.status}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: _(msg`Status`),
|
||||
accessorKey: 'status',
|
||||
cell: ({ row }) => <DocumentStatus status={row.original.status} />,
|
||||
size: 140,
|
||||
},
|
||||
{
|
||||
header: _(msg`Actions`),
|
||||
cell: ({ row }) =>
|
||||
(!row.original.deletedAt || row.original.status === ExtendedDocumentStatus.COMPLETED) && (
|
||||
<div className="flex items-center gap-x-4">
|
||||
<DataTableActionButton team={team} row={row.original} />
|
||||
<DataTableActionDropdown team={team} row={row.original} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
||||
}, [team]);
|
||||
|
||||
const onPaginationChange = (page: number, perPage: number) => {
|
||||
startTransition(() => {
|
||||
updateSearchParams({
|
||||
page,
|
||||
perPage,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={results.data}
|
||||
perPage={results.perPage}
|
||||
currentPage={results.currentPage}
|
||||
totalPages={results.totalPages}
|
||||
onPaginationChange={onPaginationChange}
|
||||
columnVisibility={{
|
||||
sender: Boolean(showSenderColumn),
|
||||
}}
|
||||
>
|
||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||
</DataTable>
|
||||
|
||||
{isPending && (
|
||||
<div className="bg-background/50 absolute inset-0 flex items-center justify-center">
|
||||
<Loader className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,207 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { DocumentStatus } from '@documenso/prisma/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 DeleteDocumentDialogProps = {
|
||||
id: number;
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
status: DocumentStatus;
|
||||
documentTitle: string;
|
||||
teamId?: number;
|
||||
canManageDocument: boolean;
|
||||
};
|
||||
|
||||
export const DeleteDocumentDialog = ({
|
||||
id,
|
||||
open,
|
||||
onOpenChange,
|
||||
status,
|
||||
documentTitle,
|
||||
canManageDocument,
|
||||
}: DeleteDocumentDialogProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
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: () => {
|
||||
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<HTMLInputElement>) => {
|
||||
setInputValue(event.target.value);
|
||||
setIsDeleteEnabled(event.target.value === _(deleteMessage));
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isPending && onOpenChange(value)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Are you sure?</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
{canManageDocument ? (
|
||||
<Trans>
|
||||
You are about to delete <strong>"{documentTitle}"</strong>
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
You are about to hide <strong>"{documentTitle}"</strong>
|
||||
</Trans>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{canManageDocument ? (
|
||||
<Alert variant="warning" className="-mt-1">
|
||||
{match(status)
|
||||
.with(DocumentStatus.DRAFT, () => (
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
Please note that this action is <strong>irreversible</strong>. Once confirmed,
|
||||
this document will be permanently deleted.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
))
|
||||
.with(DocumentStatus.PENDING, () => (
|
||||
<AlertDescription>
|
||||
<p>
|
||||
<Trans>
|
||||
Please note that this action is <strong>irreversible</strong>.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p className="mt-1">
|
||||
<Trans>Once confirmed, the following will occur:</Trans>
|
||||
</p>
|
||||
|
||||
<ul className="mt-0.5 list-inside list-disc">
|
||||
<li>
|
||||
<Trans>Document will be permanently deleted</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>Document signing process will be cancelled</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>All inserted signatures will be voided</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>All recipients will be notified</Trans>
|
||||
</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
))
|
||||
.with(DocumentStatus.COMPLETED, () => (
|
||||
<AlertDescription>
|
||||
<p>
|
||||
<Trans>By deleting this document, the following will occur:</Trans>
|
||||
</p>
|
||||
|
||||
<ul className="mt-0.5 list-inside list-disc">
|
||||
<li>
|
||||
<Trans>The document will be hidden from your account</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>Recipients will still retain their copy of the document</Trans>
|
||||
</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
))
|
||||
.exhaustive()}
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert variant="warning" className="-mt-1">
|
||||
<AlertDescription>
|
||||
<Trans>Please contact support if you would like to revert this action.</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{status !== DocumentStatus.DRAFT && canManageDocument && (
|
||||
<Input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={onInputChange}
|
||||
placeholder={_(msg`Please type ${`'${_(deleteMessage)}'`} to confirm`)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
loading={isPending}
|
||||
onClick={onDelete}
|
||||
disabled={!isDeleteEnabled && canManageDocument}
|
||||
variant="destructive"
|
||||
>
|
||||
{canManageDocument ? _(msg`Delete`) : _(msg`Hide`)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -1,174 +0,0 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
||||
import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
|
||||
import type { GetStatsInput } from '@documenso/lib/server-only/document/get-stats';
|
||||
import { getStats } from '@documenso/lib/server-only/document/get-stats';
|
||||
import { parseToIntegerArray } from '@documenso/lib/utils/params';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import type { Team, TeamEmail, TeamMemberRole } from '@documenso/prisma/client';
|
||||
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
|
||||
import { DocumentSearch } from '~/components/(dashboard)/document-search/document-search';
|
||||
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
|
||||
import { isPeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
|
||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||
|
||||
import { DocumentsDataTable } from './data-table';
|
||||
import { DataTableSenderFilter } from './data-table-sender-filter';
|
||||
import { EmptyDocumentState } from './empty-state';
|
||||
import { UploadDocument } from './upload-document';
|
||||
|
||||
export interface DocumentsPageViewProps {
|
||||
searchParams?: {
|
||||
status?: ExtendedDocumentStatus;
|
||||
period?: PeriodSelectorValue;
|
||||
page?: string;
|
||||
perPage?: string;
|
||||
senderIds?: string;
|
||||
search?: string;
|
||||
};
|
||||
team?: Team & { teamEmail?: TeamEmail | null } & { currentTeamMember?: { role: TeamMemberRole } };
|
||||
}
|
||||
|
||||
export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPageViewProps) => {
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
|
||||
const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : '';
|
||||
const page = Number(searchParams.page) || 1;
|
||||
const perPage = Number(searchParams.perPage) || 20;
|
||||
const senderIds = parseToIntegerArray(searchParams.senderIds ?? '');
|
||||
const search = searchParams.search || '';
|
||||
const currentTeam = team
|
||||
? { id: team.id, url: team.url, teamEmail: team.teamEmail?.email }
|
||||
: undefined;
|
||||
const currentTeamMemberRole = team?.currentTeamMember?.role;
|
||||
|
||||
const getStatOptions: GetStatsInput = {
|
||||
user,
|
||||
period,
|
||||
search,
|
||||
};
|
||||
|
||||
if (team) {
|
||||
getStatOptions.team = {
|
||||
teamId: team.id,
|
||||
teamEmail: team.teamEmail?.email,
|
||||
senderIds,
|
||||
currentTeamMemberRole,
|
||||
currentUserEmail: user.email,
|
||||
userId: user.id,
|
||||
};
|
||||
}
|
||||
|
||||
const stats = await getStats(getStatOptions);
|
||||
|
||||
const results = await findDocuments({
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
status,
|
||||
orderBy: {
|
||||
column: 'createdAt',
|
||||
direction: 'desc',
|
||||
},
|
||||
page,
|
||||
perPage,
|
||||
period,
|
||||
senderIds,
|
||||
query: search,
|
||||
});
|
||||
|
||||
const getTabHref = (value: typeof status) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
|
||||
params.set('status', value);
|
||||
|
||||
if (params.has('page')) {
|
||||
params.delete('page');
|
||||
}
|
||||
|
||||
return `${formatDocumentsPath(team?.url)}?${params.toString()}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||
<UploadDocument team={currentTeam} />
|
||||
|
||||
<div className="mt-12 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
|
||||
<div className="flex flex-row items-center">
|
||||
{team && (
|
||||
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
|
||||
{team.avatarImageId && (
|
||||
<AvatarImage src={`${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${team.avatarImageId}`} />
|
||||
)}
|
||||
<AvatarFallback className="text-xs text-gray-400">
|
||||
{team.name.slice(0, 1)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
|
||||
<h1 className="text-4xl font-semibold">
|
||||
<Trans>Documents</Trans>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
|
||||
<Tabs value={status} className="overflow-x-auto">
|
||||
<TabsList>
|
||||
{[
|
||||
ExtendedDocumentStatus.INBOX,
|
||||
ExtendedDocumentStatus.PENDING,
|
||||
ExtendedDocumentStatus.COMPLETED,
|
||||
ExtendedDocumentStatus.DRAFT,
|
||||
ExtendedDocumentStatus.ALL,
|
||||
].map((value) => (
|
||||
<TabsTrigger
|
||||
key={value}
|
||||
className="hover:text-foreground min-w-[60px]"
|
||||
value={value}
|
||||
asChild
|
||||
>
|
||||
<Link href={getTabHref(value)} scroll={false}>
|
||||
<DocumentStatus status={value} />
|
||||
|
||||
{value !== ExtendedDocumentStatus.ALL && (
|
||||
<span className="ml-1 inline-block opacity-50">{stats[value]}</span>
|
||||
)}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{team && <DataTableSenderFilter teamId={team.id} />}
|
||||
|
||||
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
||||
<PeriodSelector />
|
||||
</div>
|
||||
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
||||
<DocumentSearch initialValue={search} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
{results.count > 0 && (
|
||||
<DocumentsDataTable
|
||||
results={results}
|
||||
showSenderColumn={team !== undefined}
|
||||
team={currentTeam}
|
||||
/>
|
||||
)}
|
||||
{results.count === 0 && <EmptyDocumentState status={status} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,124 +0,0 @@
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import type { Team } from '@documenso/prisma/client';
|
||||
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 DuplicateDocumentDialogProps = {
|
||||
id: number;
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
team?: Pick<Team, 'id' | 'url'>;
|
||||
};
|
||||
|
||||
export const DuplicateDocumentDialog = ({
|
||||
id,
|
||||
open,
|
||||
onOpenChange,
|
||||
team,
|
||||
}: DuplicateDocumentDialogProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
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 }) => {
|
||||
router.push(`${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 (
|
||||
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Duplicate</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{!documentData || isLoading ? (
|
||||
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
|
||||
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
|
||||
<Trans>Loading Document...</Trans>
|
||||
</h1>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll">
|
||||
<LazyPDFViewer key={document?.id} documentData={documentData} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="flex-1"
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isDuplicateLoading || isLoading}
|
||||
loading={isDuplicateLoading}
|
||||
onClick={onDuplicate}
|
||||
className="flex-1"
|
||||
>
|
||||
<Trans>Duplicate</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -1,53 +0,0 @@
|
||||
import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Bird, CheckCircle2 } from 'lucide-react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
|
||||
export type EmptyDocumentProps = { status: ExtendedDocumentStatus };
|
||||
|
||||
export const EmptyDocumentState = ({ status }: EmptyDocumentProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const {
|
||||
title,
|
||||
message,
|
||||
icon: Icon,
|
||||
} = match(status)
|
||||
.with(ExtendedDocumentStatus.COMPLETED, () => ({
|
||||
title: msg`Nothing to do`,
|
||||
message: msg`There are no completed documents yet. Documents that you have created or received will appear here once completed.`,
|
||||
icon: CheckCircle2,
|
||||
}))
|
||||
.with(ExtendedDocumentStatus.DRAFT, () => ({
|
||||
title: msg`No active drafts`,
|
||||
message: msg`There are no active drafts at the current moment. You can upload a document to start drafting.`,
|
||||
icon: CheckCircle2,
|
||||
}))
|
||||
.with(ExtendedDocumentStatus.ALL, () => ({
|
||||
title: msg`We're all empty`,
|
||||
message: msg`You have not yet created or received any documents. To create a document please upload one.`,
|
||||
icon: Bird,
|
||||
}))
|
||||
.otherwise(() => ({
|
||||
title: msg`Nothing to do`,
|
||||
message: msg`All documents have been processed. Any new documents that are sent or received will show here.`,
|
||||
icon: CheckCircle2,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
className="text-muted-foreground/60 flex h-60 flex-col items-center justify-center gap-y-4"
|
||||
data-testid="empty-document-state"
|
||||
>
|
||||
<Icon className="h-12 w-12" strokeWidth={1.5} />
|
||||
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold">{_(title)}</h3>
|
||||
|
||||
<p className="mt-2 max-w-[60ch]">{_(message)}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,129 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
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 MoveDocumentDialogProps = {
|
||||
documentId: number;
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
};
|
||||
|
||||
export const MoveDocumentDialog = ({ documentId, open, onOpenChange }: MoveDocumentDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<number | null>(null);
|
||||
|
||||
const { data: teams, isLoading: isLoadingTeams } = trpc.team.getTeams.useQuery();
|
||||
|
||||
const { mutateAsync: moveDocument, isPending } = trpc.document.moveDocumentToTeam.useMutation({
|
||||
onSuccess: () => {
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Move Document to Team</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Select a team to move this document to. This action cannot be undone.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Select onValueChange={(value) => setSelectedTeamId(Number(value))}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={_(msg`Select a team`)} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{isLoadingTeams ? (
|
||||
<SelectItem value="loading" disabled>
|
||||
<Trans>Loading teams...</Trans>
|
||||
</SelectItem>
|
||||
) : (
|
||||
teams?.map((team) => (
|
||||
<SelectItem key={team.id} value={team.id.toString()}>
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="h-8 w-8">
|
||||
{team.avatarImageId && (
|
||||
<AvatarImage
|
||||
src={`${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${team.avatarImageId}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AvatarFallback className="text-sm text-gray-400">
|
||||
{team.name.slice(0, 1).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<span>{team.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button onClick={onMove} loading={isPending} disabled={!selectedTeamId || isPending}>
|
||||
{isPending ? <Trans>Moving...</Trans> : <Trans>Move</Trans>}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -1,29 +0,0 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
|
||||
import type { DocumentsPageViewProps } from './documents-page-view';
|
||||
import { DocumentsPageView } from './documents-page-view';
|
||||
import { UpcomingProfileClaimTeaser } from './upcoming-profile-claim-teaser';
|
||||
|
||||
export type DocumentsPageProps = {
|
||||
searchParams?: DocumentsPageViewProps['searchParams'];
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Documents',
|
||||
};
|
||||
|
||||
export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
|
||||
await setupI18nSSR();
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
return (
|
||||
<>
|
||||
<UpcomingProfileClaimTeaser user={user} />
|
||||
<DocumentsPageView searchParams={searchParams} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,59 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import type { User } from '@documenso/prisma/client';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { ClaimPublicProfileDialogForm } from '~/components/forms/public-profile-claim-dialog';
|
||||
|
||||
export type UpcomingProfileClaimTeaserProps = {
|
||||
user: User;
|
||||
};
|
||||
|
||||
export const UpcomingProfileClaimTeaser = ({ user }: UpcomingProfileClaimTeaserProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [claimed, setClaimed] = useState(false);
|
||||
|
||||
const onOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open && !claimed) {
|
||||
toast({
|
||||
title: _(msg`Claim your profile later`),
|
||||
description: _(
|
||||
msg`You can claim your profile later on by going to your profile settings!`,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
setOpen(open);
|
||||
localStorage.setItem('app.hasShownProfileClaimDialog', 'true');
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[claimed, toast],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const hasShownProfileClaimDialog =
|
||||
localStorage.getItem('app.hasShownProfileClaimDialog') === 'true';
|
||||
|
||||
if (!user.url && !hasShownProfileClaimDialog) {
|
||||
onOpenChange(true);
|
||||
}
|
||||
}, [onOpenChange, user.url]);
|
||||
|
||||
return (
|
||||
<ClaimPublicProfileDialogForm
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
onClaimed={() => setClaimed(true)}
|
||||
user={user}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -1,159 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { 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 { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||
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';
|
||||
|
||||
export type UploadDocumentProps = {
|
||||
className?: string;
|
||||
team?: {
|
||||
id: number;
|
||||
url: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
|
||||
const router = useRouter();
|
||||
const analytics = useAnalytics();
|
||||
const userTimezone =
|
||||
TIME_ZONES.find((timezone) => timezone === Intl.DateTimeFormat().resolvedOptions().timeZone) ??
|
||||
DEFAULT_DOCUMENT_TIME_ZONE;
|
||||
|
||||
const { data: session } = useSession();
|
||||
|
||||
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 (!session?.user.emailVerified) {
|
||||
return msg`Verify your email to upload documents.`;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [remaining.documents, session?.user.emailVerified, team]);
|
||||
|
||||
const onFileDrop = async (file: File) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
analytics.capture('App: Document Uploaded', {
|
||||
userId: session?.user.id,
|
||||
documentId: id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
router.push(`${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 (
|
||||
<div className={cn('relative', className)}>
|
||||
<DocumentDropzone
|
||||
className="h-[min(400px,50vh)]"
|
||||
disabled={remaining.documents === 0 || !session?.user.emailVerified}
|
||||
disabledMessage={disabledMessage}
|
||||
onDrop={onFileDrop}
|
||||
onDropRejected={onFileDropRejected}
|
||||
/>
|
||||
|
||||
<div className="absolute -bottom-6 right-0">
|
||||
{team?.id === undefined &&
|
||||
remaining.documents > 0 &&
|
||||
Number.isFinite(remaining.documents) && (
|
||||
<p className="text-muted-foreground/60 text-xs">
|
||||
<Trans>
|
||||
{remaining.documents} of {quota.documents} documents remaining this month.
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="bg-background/50 absolute inset-0 flex items-center justify-center rounded-lg">
|
||||
<Loader className="text-muted-foreground h-12 w-12 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,54 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { getServerSession } from 'next-auth';
|
||||
|
||||
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server';
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
|
||||
|
||||
import { Banner } from '~/components/(dashboard)/layout/banner';
|
||||
import { Header } from '~/components/(dashboard)/layout/header';
|
||||
import { VerifyEmailBanner } from '~/components/(dashboard)/layout/verify-email-banner';
|
||||
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
|
||||
import { NextAuthProvider } from '~/providers/next-auth';
|
||||
|
||||
export type AuthenticatedDashboardLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export default async function AuthenticatedDashboardLayout({
|
||||
children,
|
||||
}: AuthenticatedDashboardLayoutProps) {
|
||||
await setupI18nSSR();
|
||||
|
||||
const session = await getServerSession(NEXT_AUTH_OPTIONS);
|
||||
|
||||
if (!session) {
|
||||
redirect('/signin');
|
||||
}
|
||||
|
||||
const [{ user }, teams] = await Promise.all([
|
||||
getRequiredServerComponentSession(),
|
||||
getTeams({ userId: session.user.id }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<NextAuthProvider session={session}>
|
||||
<LimitsProvider>
|
||||
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
|
||||
|
||||
<Banner />
|
||||
|
||||
<Header user={user} teams={teams} />
|
||||
|
||||
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
||||
|
||||
<RefreshOnFocus />
|
||||
</LimitsProvider>
|
||||
</NextAuthProvider>
|
||||
);
|
||||
}
|
||||
@ -1,138 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
|
||||
import type { PriceIntervals } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||
import { toHumanPrice } from '@documenso/lib/universal/stripe/to-human-price';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent, CardTitle } from '@documenso/ui/primitives/card';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { createCheckout } from './create-checkout.action';
|
||||
|
||||
type Interval = keyof PriceIntervals;
|
||||
|
||||
const INTERVALS: Interval[] = ['day', 'week', 'month', 'year'];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const isInterval = (value: unknown): value is Interval => INTERVALS.includes(value as Interval);
|
||||
|
||||
const FRIENDLY_INTERVALS: Record<Interval, MessageDescriptor> = {
|
||||
day: msg`Daily`,
|
||||
week: msg`Weekly`,
|
||||
month: msg`Monthly`,
|
||||
year: msg`Yearly`,
|
||||
};
|
||||
|
||||
const MotionCard = motion(Card);
|
||||
|
||||
export type BillingPlansProps = {
|
||||
prices: PriceIntervals;
|
||||
};
|
||||
|
||||
export const BillingPlans = ({ prices }: BillingPlansProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
const [interval, setInterval] = useState<Interval>('month');
|
||||
const [checkoutSessionPriceId, setCheckoutSessionPriceId] = useState<string | null>(null);
|
||||
|
||||
const onSubscribeClick = async (priceId: string) => {
|
||||
try {
|
||||
setCheckoutSessionPriceId(priceId);
|
||||
|
||||
const url = await createCheckout({ priceId });
|
||||
|
||||
if (!url) {
|
||||
throw new Error('Unable to create session');
|
||||
}
|
||||
|
||||
window.open(url);
|
||||
} catch (_err) {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`An error occurred while trying to create a checkout session.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setCheckoutSessionPriceId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Tabs value={interval} onValueChange={(value) => isInterval(value) && setInterval(value)}>
|
||||
<TabsList>
|
||||
{INTERVALS.map(
|
||||
(interval) =>
|
||||
prices[interval].length > 0 && (
|
||||
<TabsTrigger key={interval} className="min-w-[150px]" value={interval}>
|
||||
{_(FRIENDLY_INTERVALS[interval])}
|
||||
</TabsTrigger>
|
||||
),
|
||||
)}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="mt-8 grid gap-8 lg:grid-cols-2 2xl:grid-cols-3">
|
||||
<AnimatePresence mode="wait">
|
||||
{prices[interval].map((price) => (
|
||||
<MotionCard
|
||||
key={price.id}
|
||||
initial={{ opacity: isMounted ? 0 : 1, y: isMounted ? 20 : 0 }}
|
||||
animate={{ opacity: 1, y: 0, transition: { duration: 0.3 } }}
|
||||
exit={{ opacity: 0, transition: { duration: 0.3 } }}
|
||||
>
|
||||
<CardContent className="flex h-full flex-col p-6">
|
||||
<CardTitle>{price.product.name}</CardTitle>
|
||||
|
||||
<div className="text-muted-foreground mt-2 text-lg font-medium">
|
||||
${toHumanPrice(price.unit_amount ?? 0)} {price.currency.toUpperCase()}{' '}
|
||||
<span className="text-xs">per {interval}</span>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground mt-1.5 text-sm">
|
||||
{price.product.description}
|
||||
</div>
|
||||
|
||||
{price.product.features && price.product.features.length > 0 && (
|
||||
<div className="text-muted-foreground mt-4">
|
||||
<div className="text-sm font-medium">Includes:</div>
|
||||
|
||||
<ul className="mt-1 divide-y text-sm">
|
||||
{price.product.features.map((feature, index) => (
|
||||
<li key={index} className="py-2">
|
||||
{feature.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<Button
|
||||
className="mt-4"
|
||||
disabled={checkoutSessionPriceId !== null}
|
||||
loading={checkoutSessionPriceId === price.id}
|
||||
onClick={() => void onSubscribeClick(price.id)}
|
||||
>
|
||||
<Trans>Subscribe</Trans>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</MotionCard>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,70 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { createBillingPortal } from './create-billing-portal.action';
|
||||
|
||||
export type BillingPortalButtonProps = {
|
||||
buttonProps?: React.ComponentProps<typeof Button>;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const BillingPortalButton = ({ buttonProps, children }: BillingPortalButtonProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isFetchingPortalUrl, setIsFetchingPortalUrl] = useState(false);
|
||||
|
||||
const handleFetchPortalUrl = async () => {
|
||||
if (isFetchingPortalUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsFetchingPortalUrl(true);
|
||||
|
||||
try {
|
||||
const sessionUrl = await createBillingPortal();
|
||||
|
||||
if (!sessionUrl) {
|
||||
throw new Error('NO_SESSION');
|
||||
}
|
||||
|
||||
window.open(sessionUrl, '_blank');
|
||||
} catch (e) {
|
||||
let description = _(
|
||||
msg`We are unable to proceed to the billing portal at this time. Please try again, or contact support.`,
|
||||
);
|
||||
|
||||
if (e.message === 'CUSTOMER_NOT_FOUND') {
|
||||
description = _(
|
||||
msg`You do not currently have a customer record, this should not happen. Please contact support for assistance.`,
|
||||
);
|
||||
}
|
||||
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description,
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
setIsFetchingPortalUrl(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
{...buttonProps}
|
||||
onClick={async () => handleFetchPortalUrl()}
|
||||
loading={isFetchingPortalUrl}
|
||||
>
|
||||
{children || <Trans>Manage Subscription</Trans>}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@ -1,17 +0,0 @@
|
||||
'use server';
|
||||
|
||||
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
|
||||
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
|
||||
export const createBillingPortal = async () => {
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
const { stripeCustomer } = await getStripeCustomerByUser(user);
|
||||
|
||||
return getPortalSession({
|
||||
customerId: stripeCustomer.id,
|
||||
returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/settings/billing`,
|
||||
});
|
||||
};
|
||||
@ -1,40 +0,0 @@
|
||||
'use server';
|
||||
|
||||
import { getCheckoutSession } from '@documenso/ee/server-only/stripe/get-checkout-session';
|
||||
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
|
||||
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscription/get-subscriptions-by-user-id';
|
||||
|
||||
export type CreateCheckoutOptions = {
|
||||
priceId: string;
|
||||
};
|
||||
|
||||
export const createCheckout = async ({ priceId }: CreateCheckoutOptions) => {
|
||||
const session = await getRequiredServerComponentSession();
|
||||
|
||||
const { user, stripeCustomer } = await getStripeCustomerByUser(session.user);
|
||||
|
||||
const existingSubscriptions = await getSubscriptionsByUserId({ userId: user.id });
|
||||
|
||||
const foundSubscription = existingSubscriptions.find(
|
||||
(subscription) =>
|
||||
subscription.priceId === priceId &&
|
||||
subscription.periodEnd &&
|
||||
subscription.periodEnd >= new Date(),
|
||||
);
|
||||
|
||||
if (foundSubscription) {
|
||||
return getPortalSession({
|
||||
customerId: stripeCustomer.id,
|
||||
returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/settings/billing`,
|
||||
});
|
||||
}
|
||||
|
||||
return getCheckoutSession({
|
||||
customerId: stripeCustomer.id,
|
||||
priceId,
|
||||
returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/settings/billing`,
|
||||
});
|
||||
};
|
||||
@ -1,146 +0,0 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
|
||||
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
||||
import { getPrimaryAccountPlanPrices } from '@documenso/ee/server-only/stripe/get-primary-account-plan-prices';
|
||||
import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
||||
import { type Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscription/get-subscriptions-by-user-id';
|
||||
import { SubscriptionStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { BillingPlans } from './billing-plans';
|
||||
import { BillingPortalButton } from './billing-portal-button';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Billing',
|
||||
};
|
||||
|
||||
export default async function BillingSettingsPage() {
|
||||
const { i18n } = await setupI18nSSR();
|
||||
|
||||
let { user } = await getRequiredServerComponentSession();
|
||||
|
||||
const isBillingEnabled = await getServerComponentFlag('app_billing');
|
||||
|
||||
// Redirect if subscriptions are not enabled.
|
||||
if (!isBillingEnabled) {
|
||||
redirect('/settings/profile');
|
||||
}
|
||||
|
||||
if (!user.customerId) {
|
||||
user = await getStripeCustomerByUser(user).then((result) => result.user);
|
||||
}
|
||||
|
||||
const [subscriptions, prices, primaryAccountPlanPrices] = await Promise.all([
|
||||
getSubscriptionsByUserId({ userId: user.id }),
|
||||
getPricesByInterval({ plans: [STRIPE_PLAN_TYPE.REGULAR, STRIPE_PLAN_TYPE.PLATFORM] }),
|
||||
getPrimaryAccountPlanPrices(),
|
||||
]);
|
||||
|
||||
const primaryAccountPlanPriceIds = primaryAccountPlanPrices.map(({ id }) => id);
|
||||
|
||||
let subscriptionProduct: Stripe.Product | null = null;
|
||||
|
||||
const primaryAccountPlanSubscriptions = subscriptions.filter(({ priceId }) =>
|
||||
primaryAccountPlanPriceIds.includes(priceId),
|
||||
);
|
||||
|
||||
const subscription =
|
||||
primaryAccountPlanSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ??
|
||||
primaryAccountPlanSubscriptions[0];
|
||||
|
||||
if (subscription?.priceId) {
|
||||
subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch(
|
||||
() => null,
|
||||
);
|
||||
}
|
||||
|
||||
const isMissingOrInactiveOrFreePlan =
|
||||
!subscription || subscription.status === SubscriptionStatus.INACTIVE;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-row items-end justify-between">
|
||||
<div>
|
||||
<h3 className="text-2xl font-semibold">
|
||||
<Trans>Billing</Trans>
|
||||
</h3>
|
||||
|
||||
<div className="text-muted-foreground mt-2 text-sm">
|
||||
{isMissingOrInactiveOrFreePlan && (
|
||||
<p>
|
||||
<Trans>
|
||||
You are currently on the <span className="font-semibold">Free Plan</span>.
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Todo: Translation */}
|
||||
{!isMissingOrInactiveOrFreePlan &&
|
||||
match(subscription.status)
|
||||
.with('ACTIVE', () => (
|
||||
<p>
|
||||
{subscriptionProduct ? (
|
||||
<span>
|
||||
You are currently subscribed to{' '}
|
||||
<span className="font-semibold">{subscriptionProduct.name}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>You currently have an active plan</span>
|
||||
)}
|
||||
|
||||
{subscription.periodEnd && (
|
||||
<span>
|
||||
{' '}
|
||||
which is set to{' '}
|
||||
{subscription.cancelAtPeriodEnd ? (
|
||||
<span>
|
||||
end on{' '}
|
||||
<span className="font-semibold">
|
||||
{i18n.date(subscription.periodEnd)}.
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
automatically renew on{' '}
|
||||
<span className="font-semibold">
|
||||
{i18n.date(subscription.periodEnd)}.
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
))
|
||||
.with('PAST_DUE', () => (
|
||||
<p>
|
||||
<Trans>
|
||||
Your current plan is past due. Please update your payment information.
|
||||
</Trans>
|
||||
</p>
|
||||
))
|
||||
.otherwise(() => null)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isMissingOrInactiveOrFreePlan && (
|
||||
<BillingPortalButton>
|
||||
<Trans>Manage billing</Trans>
|
||||
</BillingPortalButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
{isMissingOrInactiveOrFreePlan ? <BillingPlans prices={prices} /> : <BillingPortalButton />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,31 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
|
||||
import { DesktopNav } from '~/components/(dashboard)/settings/layout/desktop-nav';
|
||||
import { MobileNav } from '~/components/(dashboard)/settings/layout/mobile-nav';
|
||||
|
||||
export type DashboardSettingsLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export default async function DashboardSettingsLayout({ children }: DashboardSettingsLayoutProps) {
|
||||
await setupI18nSSR();
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||
<h1 className="text-4xl font-semibold">
|
||||
<Trans>Settings</Trans>
|
||||
</h1>
|
||||
|
||||
<div className="mt-4 grid grid-cols-12 gap-x-8 md:mt-8">
|
||||
<DesktopNav className="hidden md:col-span-3 md:flex" />
|
||||
<MobileNav className="col-span-12 mb-8 md:hidden" />
|
||||
|
||||
<div className="col-span-12 md:col-span-9">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function SettingsPage() {
|
||||
redirect('/settings/profile');
|
||||
|
||||
// Page is intentionally empty because it will be redirected to /settings/profile
|
||||
return <div />;
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function PasswordSettingsPage() {
|
||||
redirect('/settings/security');
|
||||
}
|
||||
@ -1,155 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { signOut } from 'next-auth/react';
|
||||
|
||||
import type { User } from '@documenso/prisma/client';
|
||||
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 { Label } from '@documenso/ui/primitives/label';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type DeleteAccountDialogProps = {
|
||||
className?: string;
|
||||
user: User;
|
||||
};
|
||||
|
||||
export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const hasTwoFactorAuthentication = user.twoFactorEnabled;
|
||||
|
||||
const [enteredEmail, setEnteredEmail] = useState<string>('');
|
||||
|
||||
const { mutateAsync: deleteAccount, isPending: isDeletingAccount } =
|
||||
trpc.profile.deleteAccount.useMutation();
|
||||
|
||||
const onDeleteAccount = async () => {
|
||||
try {
|
||||
await deleteAccount();
|
||||
|
||||
toast({
|
||||
title: _(msg`Account deleted`),
|
||||
description: _(msg`Your account has been deleted successfully.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
return await signOut({ callbackUrl: '/' });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`An unknown error occurred`),
|
||||
variant: 'destructive',
|
||||
description: _(
|
||||
msg`We encountered an unknown error while attempting to delete your account. Please try again later.`,
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Alert
|
||||
className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row"
|
||||
variant="neutral"
|
||||
>
|
||||
<div>
|
||||
<AlertTitle>
|
||||
<Trans>Delete Account</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription className="mr-2">
|
||||
<Trans>
|
||||
Delete your account and all its contents, including completed documents. This action
|
||||
is irreversible and will cancel your subscription, so proceed with caution.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0">
|
||||
<Dialog onOpenChange={() => setEnteredEmail('')}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="destructive">
|
||||
<Trans>Delete Account</Trans>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader className="space-y-4">
|
||||
<DialogTitle>
|
||||
<Trans>Delete Account</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription className="selection:bg-red-100">
|
||||
<Trans>This action is not reversible. Please be certain.</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{hasTwoFactorAuthentication && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription className="selection:bg-red-100">
|
||||
<Trans>Disable Two Factor Authentication before deleting your account.</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
Documenso will delete{' '}
|
||||
<span className="font-semibold">all of your documents</span>, along with all of
|
||||
your completed documents, signatures, and all other resources belonging to your
|
||||
Account.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{!hasTwoFactorAuthentication && (
|
||||
<div className="mt-4">
|
||||
<Label>
|
||||
<Trans>
|
||||
Please type{' '}
|
||||
<span className="text-muted-foreground font-semibold">{user.email}</span> to
|
||||
confirm.
|
||||
</Trans>
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
className="mt-2"
|
||||
aria-label="Confirm Email"
|
||||
value={enteredEmail}
|
||||
onChange={(e) => setEnteredEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={onDeleteAccount}
|
||||
loading={isDeletingAccount}
|
||||
variant="destructive"
|
||||
disabled={hasTwoFactorAuthentication || enteredEmail !== user.email}
|
||||
>
|
||||
{isDeletingAccount ? _(msg`Deleting account...`) : _(msg`Confirm Deletion`)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,40 +0,0 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
|
||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||
import { AvatarImageForm } from '~/components/forms/avatar-image';
|
||||
import { ProfileForm } from '~/components/forms/profile';
|
||||
|
||||
import { DeleteAccountDialog } from './delete-account-dialog';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Profile',
|
||||
};
|
||||
|
||||
export default async function ProfileSettingsPage() {
|
||||
await setupI18nSSR();
|
||||
|
||||
const { _ } = useLingui();
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title={_(msg`Profile`)}
|
||||
subtitle={_(msg`Here you can edit your personal details.`)}
|
||||
/>
|
||||
|
||||
<AvatarImageForm className="mb-8 max-w-xl" user={user} />
|
||||
<ProfileForm className="mb-8 max-w-xl" user={user} />
|
||||
|
||||
<hr className="my-4 max-w-xl" />
|
||||
|
||||
<DeleteAccountDialog className="max-w-xl" user={user} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,17 +0,0 @@
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getUserPublicProfile } from '@documenso/lib/server-only/user/get-user-public-profile';
|
||||
|
||||
import { PublicProfilePageView } from './public-profile-page-view';
|
||||
|
||||
export default async function Page() {
|
||||
await setupI18nSSR();
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
const { profile } = await getUserPublicProfile({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
return <PublicProfilePageView user={user} profile={profile} />;
|
||||
}
|
||||
@ -1,227 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import type {
|
||||
Team,
|
||||
TeamProfile,
|
||||
TemplateDirectLink,
|
||||
User,
|
||||
UserProfile,
|
||||
} from '@documenso/prisma/client';
|
||||
import { TemplateType } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { FindTemplateRow } from '@documenso/trpc/server/template-router/schema';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Switch } from '@documenso/ui/primitives/switch';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||
import type { TPublicProfileFormSchema } from '~/components/forms/public-profile-form';
|
||||
import { PublicProfileForm } from '~/components/forms/public-profile-form';
|
||||
import { ManagePublicTemplateDialog } from '~/components/templates/manage-public-template-dialog';
|
||||
|
||||
import { PublicTemplatesDataTable } from './public-templates-data-table';
|
||||
|
||||
export type PublicProfilePageViewOptions = {
|
||||
user: User;
|
||||
team?: Team;
|
||||
profile: UserProfile | TeamProfile;
|
||||
};
|
||||
|
||||
type DirectTemplate = FindTemplateRow & {
|
||||
directLink: Pick<TemplateDirectLink, 'token' | 'enabled'>;
|
||||
};
|
||||
|
||||
const userProfileText = {
|
||||
settingsTitle: msg`Public Profile`,
|
||||
settingsSubtitle: msg`You can choose to enable or disable your profile for public view.`,
|
||||
templatesTitle: msg`My templates`,
|
||||
templatesSubtitle: msg`Show templates in your public profile for your audience to sign and get started quickly`,
|
||||
};
|
||||
|
||||
const teamProfileText = {
|
||||
settingsTitle: msg`Team Public Profile`,
|
||||
settingsSubtitle: msg`You can choose to enable or disable your team profile for public view.`,
|
||||
templatesTitle: msg`Team templates`,
|
||||
templatesSubtitle: msg`Show templates in your team public profile for your audience to sign and get started quickly`,
|
||||
};
|
||||
|
||||
export const PublicProfilePageView = ({ user, team, profile }: PublicProfilePageViewOptions) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isPublicProfileVisible, setIsPublicProfileVisible] = useState(profile.enabled);
|
||||
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
|
||||
|
||||
const { data } = trpc.template.findTemplates.useQuery({
|
||||
perPage: 100,
|
||||
});
|
||||
|
||||
const { mutateAsync: updateUserProfile, isPending: isUpdatingUserProfile } =
|
||||
trpc.profile.updatePublicProfile.useMutation();
|
||||
|
||||
const { mutateAsync: updateTeamProfile, isPending: isUpdatingTeamProfile } =
|
||||
trpc.team.updateTeamPublicProfile.useMutation();
|
||||
|
||||
const isUpdating = isUpdatingUserProfile || isUpdatingTeamProfile;
|
||||
const profileText = team ? teamProfileText : userProfileText;
|
||||
|
||||
const enabledPrivateDirectTemplates = useMemo(
|
||||
() =>
|
||||
(data?.data ?? []).filter(
|
||||
(template): template is DirectTemplate =>
|
||||
template.directLink?.enabled === true && template.type !== TemplateType.PUBLIC,
|
||||
),
|
||||
[data],
|
||||
);
|
||||
|
||||
const onProfileUpdate = async (data: TPublicProfileFormSchema) => {
|
||||
if (team) {
|
||||
await updateTeamProfile({
|
||||
teamId: team.id,
|
||||
...data,
|
||||
});
|
||||
} else {
|
||||
await updateUserProfile(data);
|
||||
}
|
||||
|
||||
if (data.enabled === undefined && !isPublicProfileVisible) {
|
||||
setIsTooltipOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const togglePublicProfileVisibility = async (isVisible: boolean) => {
|
||||
setIsTooltipOpen(false);
|
||||
|
||||
if (isUpdating) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isVisible && !user.url) {
|
||||
toast({
|
||||
title: _(msg`You must set a profile URL before enabling your public profile.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPublicProfileVisible(isVisible);
|
||||
|
||||
try {
|
||||
await onProfileUpdate({
|
||||
enabled: isVisible,
|
||||
});
|
||||
} catch {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`We were unable to set your public profile to public. Please try again.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
setIsPublicProfileVisible(!isVisible);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setIsPublicProfileVisible(profile.enabled);
|
||||
}, [profile.enabled]);
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<SettingsHeader
|
||||
title={_(profileText.settingsTitle)}
|
||||
subtitle={_(profileText.settingsSubtitle)}
|
||||
>
|
||||
<Tooltip open={isTooltipOpen} onOpenChange={setIsTooltipOpen}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'text-muted-foreground/50 flex flex-row items-center justify-center space-x-2 text-xs',
|
||||
{
|
||||
'[&>*:first-child]:text-muted-foreground': !isPublicProfileVisible,
|
||||
'[&>*:last-child]:text-muted-foreground': isPublicProfileVisible,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<span>
|
||||
<Trans>Hide</Trans>
|
||||
</span>
|
||||
<Switch
|
||||
disabled={isUpdating}
|
||||
checked={isPublicProfileVisible}
|
||||
onCheckedChange={togglePublicProfileVisibility}
|
||||
/>
|
||||
<span>
|
||||
<Trans>Show</Trans>
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="text-muted-foreground max-w-[40ch] space-y-2 py-2">
|
||||
{isPublicProfileVisible ? (
|
||||
<>
|
||||
<p>
|
||||
<Trans>
|
||||
Profile is currently <strong>visible</strong>.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<Trans>Toggle the switch to hide your profile from the public.</Trans>
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
<Trans>
|
||||
Profile is currently <strong>hidden</strong>.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<Trans>Toggle the switch to show your profile to the public.</Trans>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</SettingsHeader>
|
||||
|
||||
<PublicProfileForm
|
||||
profileUrl={team ? team.url : user.url}
|
||||
teamUrl={team?.url}
|
||||
profile={profile}
|
||||
onProfileUpdate={onProfileUpdate}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<SettingsHeader
|
||||
title={_(profileText.templatesTitle)}
|
||||
subtitle={_(profileText.templatesSubtitle)}
|
||||
hideDivider={true}
|
||||
className="mt-8 [&>*>h3]:text-base"
|
||||
>
|
||||
<ManagePublicTemplateDialog
|
||||
directTemplates={enabledPrivateDirectTemplates}
|
||||
trigger={
|
||||
<Button variant="outline">
|
||||
<Trans>Link template</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</SettingsHeader>
|
||||
|
||||
<div className="mt-6">
|
||||
<PublicTemplatesDataTable />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,209 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { EditIcon, FileIcon, LinkIcon, MoreHorizontalIcon, Trash2Icon } from 'lucide-react';
|
||||
|
||||
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
|
||||
import type { TemplateDirectLink } from '@documenso/prisma/client';
|
||||
import { TemplateType } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { FindTemplateRow } from '@documenso/trpc/server/template-router/schema';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { ManagePublicTemplateDialog } from '~/components/templates/manage-public-template-dialog';
|
||||
|
||||
type DirectTemplate = FindTemplateRow & {
|
||||
directLink: Pick<TemplateDirectLink, 'token' | 'enabled'>;
|
||||
};
|
||||
|
||||
export const PublicTemplatesDataTable = () => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [, copy] = useCopyToClipboard();
|
||||
|
||||
const [publicTemplateDialogPayload, setPublicTemplateDialogPayload] = useState<{
|
||||
step: 'MANAGE' | 'CONFIRM_DISABLE';
|
||||
templateId: number;
|
||||
} | null>(null);
|
||||
|
||||
const { data, isLoading, isLoadingError, refetch } = trpc.template.findTemplates.useQuery(
|
||||
{},
|
||||
{
|
||||
placeholderData: (previousData) => previousData,
|
||||
},
|
||||
);
|
||||
|
||||
const { directTemplates, publicDirectTemplates, privateDirectTemplates } = useMemo(() => {
|
||||
const directTemplates = (data?.data ?? []).filter(
|
||||
(template): template is DirectTemplate => template.directLink?.enabled === true,
|
||||
);
|
||||
|
||||
const publicDirectTemplates = directTemplates.filter(
|
||||
(template) => template.directLink?.enabled === true && template.type === TemplateType.PUBLIC,
|
||||
);
|
||||
|
||||
const privateDirectTemplates = directTemplates.filter(
|
||||
(template) => template.directLink?.enabled === true && template.type === TemplateType.PRIVATE,
|
||||
);
|
||||
|
||||
return {
|
||||
directTemplates,
|
||||
publicDirectTemplates,
|
||||
privateDirectTemplates,
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
const onCopyClick = async (token: string) =>
|
||||
copy(formatDirectTemplatePath(token)).then(() => {
|
||||
toast({
|
||||
title: _(msg`Copied to clipboard`),
|
||||
description: _(msg`The direct link has been copied to your clipboard`),
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="dark:divide-foreground/30 dark:border-foreground/30 mt-6 divide-y divide-neutral-200 overflow-hidden rounded-lg border border-neutral-200">
|
||||
{/* Loading and error handling states. */}
|
||||
{publicDirectTemplates.length === 0 && (
|
||||
<>
|
||||
{isLoading &&
|
||||
Array(3)
|
||||
.fill(0)
|
||||
.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-background flex items-center justify-between gap-x-6 p-4"
|
||||
>
|
||||
<div className="flex gap-x-2">
|
||||
<FileIcon className="text-muted-foreground/40 h-8 w-8" strokeWidth={1.5} />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MoreHorizontalIcon className="text-muted-foreground h-5 w-5" />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isLoadingError && (
|
||||
<div className="text-muted-foreground flex h-32 flex-col items-center justify-center text-sm">
|
||||
<Trans>Unable to load your public profile templates at this time</Trans>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void refetch();
|
||||
}}
|
||||
>
|
||||
<Trans>Click here to retry</Trans>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && (
|
||||
<div className="text-muted-foreground flex h-32 flex-col items-center justify-center text-sm">
|
||||
<Trans>No public profile templates found</Trans>
|
||||
<ManagePublicTemplateDialog
|
||||
directTemplates={privateDirectTemplates}
|
||||
trigger={
|
||||
<button className="hover:text-muted-foreground/80 mt-1 text-xs">
|
||||
<Trans>Click here to get started</Trans>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Public templates list. */}
|
||||
{publicDirectTemplates.map((template) => (
|
||||
<div
|
||||
key={template.id}
|
||||
className="bg-background flex items-center justify-between gap-x-6 p-4"
|
||||
>
|
||||
<div className="flex gap-x-2">
|
||||
<FileIcon
|
||||
className="text-muted-foreground/40 h-8 w-8 flex-shrink-0"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<p className="text-sm">{template.publicTitle}</p>
|
||||
<p className="text-xs text-neutral-400">{template.publicDescription}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<MoreHorizontalIcon className="text-muted-foreground h-5 w-5" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className="w-52" align="center" side="left">
|
||||
<DropdownMenuLabel>
|
||||
<Trans>Action</Trans>
|
||||
</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuItem onClick={() => void onCopyClick(template.directLink.token)}>
|
||||
<LinkIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Copy sharable link</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setPublicTemplateDialogPayload({
|
||||
step: 'MANAGE',
|
||||
templateId: template.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<EditIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Update</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
setPublicTemplateDialogPayload({
|
||||
step: 'CONFIRM_DISABLE',
|
||||
templateId: template.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trash2Icon className="mr-2 h-4 w-4" />
|
||||
<Trans>Remove</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ManagePublicTemplateDialog
|
||||
directTemplates={directTemplates}
|
||||
initialTemplateId={publicTemplateDialogPayload?.templateId}
|
||||
initialStep={publicTemplateDialogPayload?.step}
|
||||
isOpen={publicTemplateDialogPayload !== null}
|
||||
onIsOpenChange={(value) => {
|
||||
if (!value) {
|
||||
setPublicTemplateDialogPayload(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,37 +0,0 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
|
||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||
|
||||
import { UserSecurityActivityDataTable } from './user-security-activity-data-table';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Security activity',
|
||||
};
|
||||
|
||||
export default async function SettingsSecurityActivityPage() {
|
||||
await setupI18nSSR();
|
||||
|
||||
const { _ } = useLingui();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title={_(msg`Security activity`)}
|
||||
subtitle={_(msg`View all security activity related to your account.`)}
|
||||
hideDivider={true}
|
||||
>
|
||||
{/* Todo */}
|
||||
{/* <ActivityPageBackButton /> */}
|
||||
</SettingsHeader>
|
||||
|
||||
<div className="mt-4">
|
||||
<UserSecurityActivityDataTable />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,162 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import type { DateTimeFormatOptions } from 'luxon';
|
||||
import { DateTime } from 'luxon';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { USER_SECURITY_AUDIT_LOG_MAP } from '@documenso/lib/constants/auth';
|
||||
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||
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';
|
||||
|
||||
const dateFormat: DateTimeFormatOptions = {
|
||||
...DateTime.DATETIME_SHORT,
|
||||
hourCycle: 'h12',
|
||||
};
|
||||
|
||||
export const UserSecurityActivityDataTable = () => {
|
||||
const { _, i18n } = useLingui();
|
||||
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
|
||||
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
|
||||
|
||||
const { data, isLoading, isLoadingError } = trpc.profile.findUserSecurityAuditLogs.useQuery(
|
||||
{
|
||||
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`Date`),
|
||||
accessorKey: 'createdAt',
|
||||
cell: ({ row }) => i18n.date(row.original.createdAt, dateFormat),
|
||||
},
|
||||
{
|
||||
header: _(msg`Device`),
|
||||
cell: ({ row }) => {
|
||||
if (!row.original.userAgent) {
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
parser.setUA(row.original.userAgent);
|
||||
|
||||
const result = parser.getResult();
|
||||
|
||||
let output = result.os.name;
|
||||
|
||||
if (!output) {
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
if (result.os.version) {
|
||||
output += ` (${result.os.version})`;
|
||||
}
|
||||
|
||||
return output;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: _(msg`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';
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'IP Address',
|
||||
accessorKey: 'ipAddress',
|
||||
cell: ({ row }) => row.original.ipAddress ?? 'N/A',
|
||||
},
|
||||
{
|
||||
header: _(msg`Action`),
|
||||
accessorKey: 'type',
|
||||
cell: ({ row }) => USER_SECURITY_AUDIT_LOG_MAP[row.original.type],
|
||||
},
|
||||
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={results.data}
|
||||
perPage={results.perPage}
|
||||
currentPage={results.currentPage}
|
||||
totalPages={results.totalPages}
|
||||
onPaginationChange={onPaginationChange}
|
||||
hasFilters={parsedSearchParams.page !== undefined || parsedSearchParams.perPage !== undefined}
|
||||
onClearFilters={() => router.push(pathname ?? '/')}
|
||||
error={{
|
||||
enable: isLoadingError,
|
||||
}}
|
||||
skeleton={{
|
||||
enable: isLoading,
|
||||
rows: 3,
|
||||
component: (
|
||||
<>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-20 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-12 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-12 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-12 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-12 rounded-full" />
|
||||
</TableCell>
|
||||
</>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{(table) => <DataTablePagination table={table} />}
|
||||
</DataTable>
|
||||
);
|
||||
};
|
||||
@ -1,146 +0,0 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||
import { DisableAuthenticatorAppDialog } from '~/components/forms/2fa/disable-authenticator-app-dialog';
|
||||
import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog';
|
||||
import { ViewRecoveryCodesDialog } from '~/components/forms/2fa/view-recovery-codes-dialog';
|
||||
import { PasswordForm } from '~/components/forms/password';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Security',
|
||||
};
|
||||
|
||||
export default async function SecuritySettingsPage() {
|
||||
await setupI18nSSR();
|
||||
|
||||
const { _ } = useLingui();
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
const isPasskeyEnabled = await getServerComponentFlag('app_passkey');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title={_(msg`Security`)}
|
||||
subtitle={_(msg`Here you can manage your password and security settings.`)}
|
||||
/>
|
||||
|
||||
{user.identityProvider === 'DOCUMENSO' && (
|
||||
<>
|
||||
<PasswordForm user={user} />
|
||||
|
||||
<hr className="border-border/50 mt-6" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Alert
|
||||
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||
variant="neutral"
|
||||
>
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<AlertTitle>
|
||||
<Trans>Two factor authentication</Trans>
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription className="mr-4">
|
||||
{user.identityProvider === 'DOCUMENSO' ? (
|
||||
<Trans>
|
||||
Add an authenticator to serve as a secondary authentication method when signing in,
|
||||
or when signing documents.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Add an authenticator to serve as a secondary authentication method for signing
|
||||
documents.
|
||||
</Trans>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
{user.twoFactorEnabled ? (
|
||||
<DisableAuthenticatorAppDialog />
|
||||
) : (
|
||||
<EnableAuthenticatorAppDialog />
|
||||
)}
|
||||
</Alert>
|
||||
|
||||
{user.twoFactorEnabled && (
|
||||
<Alert
|
||||
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||
variant="neutral"
|
||||
>
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<AlertTitle>
|
||||
<Trans>Recovery codes</Trans>
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription className="mr-4">
|
||||
<Trans>
|
||||
Two factor authentication recovery codes are used to access your account in the
|
||||
event that you lose access to your authenticator app.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<ViewRecoveryCodesDialog />
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isPasskeyEnabled && (
|
||||
<Alert
|
||||
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||
variant="neutral"
|
||||
>
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<AlertTitle>
|
||||
<Trans>Passkeys</Trans>
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription className="mr-4">
|
||||
<Trans>
|
||||
Allows authenticating using biometrics, password managers, hardware keys, etc.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<Button asChild variant="outline" className="bg-background">
|
||||
<Link href="/settings/security/passkeys">
|
||||
<Trans>Manage passkeys</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Alert
|
||||
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||
variant="neutral"
|
||||
>
|
||||
<div className="mb-4 mr-4 sm:mb-0">
|
||||
<AlertTitle>
|
||||
<Trans>Recent activity</Trans>
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription className="mr-2">
|
||||
<Trans>View all recent security activity related to your account.</Trans>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<Button asChild variant="outline" className="bg-background">
|
||||
<Link href="/settings/security/activity">
|
||||
<Trans>View activity</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,261 +0,0 @@
|
||||
'use client';
|
||||
|
||||
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<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
const ZCreatePasskeyFormSchema = z.object({
|
||||
passkeyName: z.string().min(3),
|
||||
});
|
||||
|
||||
type TCreatePasskeyFormSchema = z.infer<typeof ZCreatePasskeyFormSchema>;
|
||||
|
||||
const parser = new UAParser();
|
||||
|
||||
export const CreatePasskeyDialog = ({ trigger, onSuccess, ...props }: CreatePasskeyDialogProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm<TCreatePasskeyFormSchema>({
|
||||
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 (
|
||||
<Dialog
|
||||
{...props}
|
||||
open={open}
|
||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||
>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||
{trigger ?? (
|
||||
<Button variant="secondary" loading={isPending}>
|
||||
<KeyRoundIcon className="-ml-1 mr-1 h-5 w-5" />
|
||||
<Trans>Add passkey</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Add passkey</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
<Trans>
|
||||
Passkeys allow you to sign in and authenticate using biometrics, password managers,
|
||||
etc.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset
|
||||
className="flex h-full flex-col space-y-4"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="passkeyName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>
|
||||
<Trans>Passkey name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input className="bg-background" placeholder="eg. Mac" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Alert variant="neutral">
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
When you click continue, you will be prompted to add the first available
|
||||
authenticator on your system.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
|
||||
<AlertDescription className="mt-2">
|
||||
<Trans>
|
||||
If you do not want to use the authenticator prompted, you can close it, which
|
||||
will then display the next available authenticator.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{formError && (
|
||||
<Alert variant="destructive">
|
||||
{match(formError)
|
||||
.with('ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED', () => (
|
||||
<AlertDescription>
|
||||
<Trans>This passkey has already been registered.</Trans>
|
||||
</AlertDescription>
|
||||
))
|
||||
.with('TOO_MANY_PASSKEYS', () => (
|
||||
<AlertDescription>
|
||||
<Trans>You cannot have more than {MAXIMUM_PASSKEYS} passkeys.</Trans>
|
||||
</AlertDescription>
|
||||
))
|
||||
.with('InvalidStateError', () => (
|
||||
<>
|
||||
<AlertTitle className="text-sm">
|
||||
<Trans>
|
||||
Passkey creation cancelled due to one of the following reasons:
|
||||
</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<ul className="mt-1 list-inside list-disc">
|
||||
<li>
|
||||
<Trans>Cancelled by user</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>Passkey already exists for the provided authenticator</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>Exceeded timeout</Trans>
|
||||
</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<AlertDescription>
|
||||
<Trans>Something went wrong. Please try again or contact support.</Trans>
|
||||
</AlertDescription>
|
||||
))}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Continue</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -1,44 +0,0 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
||||
|
||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||
|
||||
import { CreatePasskeyDialog } from './create-passkey-dialog';
|
||||
import { UserPasskeysDataTable } from './user-passkeys-data-table';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Manage passkeys',
|
||||
};
|
||||
|
||||
export default async function SettingsManagePasskeysPage() {
|
||||
await setupI18nSSR();
|
||||
|
||||
const { _ } = useLingui();
|
||||
const isPasskeyEnabled = await getServerComponentFlag('app_passkey');
|
||||
|
||||
if (!isPasskeyEnabled) {
|
||||
redirect('/settings/security');
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title={_(msg`Passkeys`)}
|
||||
subtitle={_(msg`Manage your passkeys.`)}
|
||||
hideDivider={true}
|
||||
>
|
||||
<CreatePasskeyDialog />
|
||||
</SettingsHeader>
|
||||
|
||||
<div className="mt-4">
|
||||
<UserPasskeysDataTable />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,219 +0,0 @@
|
||||
import { 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 { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
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 { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type UserPasskeysDataTableActionsProps = {
|
||||
className?: string;
|
||||
passkeyId: string;
|
||||
passkeyName: string;
|
||||
};
|
||||
|
||||
const ZUpdatePasskeySchema = z.object({
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
type TUpdatePasskeySchema = z.infer<typeof ZUpdatePasskeySchema>;
|
||||
|
||||
export const UserPasskeysDataTableActions = ({
|
||||
className,
|
||||
passkeyId,
|
||||
passkeyName,
|
||||
}: UserPasskeysDataTableActionsProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isUpdateDialogOpen, setIsUpdateDialogOpen] = useState(false);
|
||||
|
||||
const form = useForm<TUpdatePasskeySchema>({
|
||||
resolver: zodResolver(ZUpdatePasskeySchema),
|
||||
defaultValues: {
|
||||
name: passkeyName,
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: updatePasskey, isPending: isUpdatingPasskey } =
|
||||
trpc.auth.updatePasskey.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: _(msg`Success`),
|
||||
description: _(msg`Passkey has been updated`),
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(
|
||||
msg`We are unable to update this passkey at the moment. Please try again later.`,
|
||||
),
|
||||
duration: 10000,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: deletePasskey, isPending: isDeletingPasskey } =
|
||||
trpc.auth.deletePasskey.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: _(msg`Success`),
|
||||
description: _(msg`Passkey has been removed`),
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(
|
||||
msg`We are unable to remove this passkey at the moment. Please try again later.`,
|
||||
),
|
||||
duration: 10000,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={cn('flex justify-end space-x-2', className)}>
|
||||
<Dialog
|
||||
open={isUpdateDialogOpen}
|
||||
onOpenChange={(value) => !isUpdatingPasskey && setIsUpdateDialogOpen(value)}
|
||||
>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||
<Button variant="outline">
|
||||
<Trans>Edit</Trans>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Update passkey</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
<Trans>
|
||||
You are currently updating the <strong>{passkeyName}</strong> passkey.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(async ({ name }) =>
|
||||
updatePasskey({
|
||||
passkeyId,
|
||||
name,
|
||||
}),
|
||||
)}
|
||||
>
|
||||
<fieldset className="flex h-full flex-col" disabled={isUpdatingPasskey}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormLabel required>
|
||||
<Trans>Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="secondary">
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button type="submit" loading={isUpdatingPasskey}>
|
||||
<Trans>Update</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={(value) => !isDeletingPasskey && setIsDeleteDialogOpen(value)}
|
||||
>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||
<Button variant="destructive">
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Delete passkey</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
<Trans>
|
||||
Are you sure you want to remove the <strong>{passkeyName}</strong> passkey.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<fieldset disabled={isDeletingPasskey}>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="secondary">
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button
|
||||
onClick={async () =>
|
||||
deletePasskey({
|
||||
passkeyId,
|
||||
})
|
||||
}
|
||||
variant="destructive"
|
||||
loading={isDeletingPasskey}
|
||||
>
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,129 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import 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 { UserPasskeysDataTableActions } from './user-passkeys-data-table-actions';
|
||||
|
||||
export const UserPasskeysDataTable = () => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
|
||||
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
|
||||
|
||||
const { data, isLoading, isLoadingError } = trpc.auth.findPasskeys.useQuery(
|
||||
{
|
||||
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`Name`),
|
||||
accessorKey: 'name',
|
||||
},
|
||||
{
|
||||
header: _(msg`Created`),
|
||||
accessorKey: 'createdAt',
|
||||
cell: ({ row }) => DateTime.fromJSDate(row.original.createdAt).toRelative(),
|
||||
},
|
||||
|
||||
{
|
||||
header: _(msg`Last used`),
|
||||
accessorKey: 'updatedAt',
|
||||
cell: ({ row }) =>
|
||||
row.original.lastUsedAt
|
||||
? DateTime.fromJSDate(row.original.lastUsedAt).toRelative()
|
||||
: _(msg`Never`),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
cell: ({ row }) => (
|
||||
<UserPasskeysDataTableActions
|
||||
className="justify-end"
|
||||
passkeyId={row.original.id}
|
||||
passkeyName={row.original.name}
|
||||
/>
|
||||
),
|
||||
},
|
||||
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={results.data}
|
||||
perPage={results.perPage}
|
||||
currentPage={results.currentPage}
|
||||
totalPages={results.totalPages}
|
||||
onPaginationChange={onPaginationChange}
|
||||
hasFilters={parsedSearchParams.page !== undefined || parsedSearchParams.perPage !== undefined}
|
||||
onClearFilters={() => router.push(pathname ?? '/')}
|
||||
error={{
|
||||
enable: isLoadingError,
|
||||
}}
|
||||
skeleton={{
|
||||
enable: isLoading,
|
||||
rows: 3,
|
||||
component: (
|
||||
<>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-20 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-12 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-12 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-row space-x-2">
|
||||
<Skeleton className="h-8 w-16 rounded" />
|
||||
<Skeleton className="h-8 w-16 rounded" />
|
||||
</div>
|
||||
</TableCell>
|
||||
</>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{(table) => <DataTablePagination table={table} />}
|
||||
</DataTable>
|
||||
);
|
||||
};
|
||||
@ -1,49 +0,0 @@
|
||||
'use client';
|
||||
|
||||
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 AcceptTeamInvitationButtonProps = {
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const AcceptTeamInvitationButton = ({ teamId }: AcceptTeamInvitationButtonProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const {
|
||||
mutateAsync: acceptTeamInvitation,
|
||||
isPending,
|
||||
isSuccess,
|
||||
} = trpc.team.acceptTeamInvitation.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: _(msg`Success`),
|
||||
description: _(msg`Accepted team invitation`),
|
||||
duration: 5000,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`Unable to join this team at this time.`),
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={async () => acceptTeamInvitation({ teamId })}
|
||||
loading={isPending}
|
||||
disabled={isPending || isSuccess}
|
||||
>
|
||||
<Trans>Accept</Trans>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@ -1,50 +0,0 @@
|
||||
'use client';
|
||||
|
||||
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 DeclineTeamInvitationButtonProps = {
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const DeclineTeamInvitationButton = ({ teamId }: DeclineTeamInvitationButtonProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const {
|
||||
mutateAsync: declineTeamInvitation,
|
||||
isPending,
|
||||
isSuccess,
|
||||
} = trpc.team.declineTeamInvitation.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: _(msg`Success`),
|
||||
description: _(msg`Declined team invitation`),
|
||||
duration: 5000,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`Unable to decline this team invitation at this time.`),
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={async () => declineTeamInvitation({ teamId })}
|
||||
loading={isPending}
|
||||
disabled={isPending || isSuccess}
|
||||
variant="ghost"
|
||||
>
|
||||
<Trans>Decline</Trans>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@ -1,46 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
|
||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||
import { CreateTeamDialog } from '~/components/(teams)/dialogs/create-team-dialog';
|
||||
import { UserSettingsTeamsPageDataTable } from '~/components/(teams)/tables/user-settings-teams-page-data-table';
|
||||
|
||||
import { TeamEmailUsage } from './team-email-usage';
|
||||
import { TeamInvitations } from './team-invitations';
|
||||
|
||||
export default function TeamsSettingsPage() {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const { data: teamEmail } = trpc.team.getTeamEmailByEmail.useQuery();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title={_(msg`Teams`)}
|
||||
subtitle={_(msg`Manage all teams you are currently associated with.`)}
|
||||
>
|
||||
<CreateTeamDialog />
|
||||
</SettingsHeader>
|
||||
|
||||
<UserSettingsTeamsPageDataTable />
|
||||
|
||||
<div className="mt-8 space-y-8">
|
||||
<AnimatePresence>
|
||||
{teamEmail && (
|
||||
<AnimateGenericFadeInOut>
|
||||
<TeamEmailUsage teamEmail={teamEmail} />
|
||||
</AnimateGenericFadeInOut>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<TeamInvitations />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,126 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import type { TeamEmail } from '@documenso/prisma/client';
|
||||
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 { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type TeamEmailUsageProps = {
|
||||
teamEmail: TeamEmail & { team: { name: string; url: string } };
|
||||
};
|
||||
|
||||
export const TeamEmailUsage = ({ teamEmail }: TeamEmailUsageProps) => {
|
||||
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`You have successfully revoked access.`),
|
||||
duration: 5000,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(
|
||||
msg`We encountered an unknown error while attempting to revoke access. Please try again or contact support.`,
|
||||
),
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Alert variant="neutral" className="flex flex-row items-center justify-between p-6">
|
||||
<div>
|
||||
<AlertTitle className="mb-0">
|
||||
<Trans>Team Email</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>
|
||||
<Trans>
|
||||
Your email is currently being used by team{' '}
|
||||
<span className="font-semibold">{teamEmail.team.name}</span> ({teamEmail.team.url}
|
||||
).
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p className="mt-1">
|
||||
<Trans>They have permission on your behalf to:</Trans>
|
||||
</p>
|
||||
|
||||
<ul className="mt-0.5 list-inside list-disc">
|
||||
<li>
|
||||
<Trans>Display your name and email in documents</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>View all documents sent to your account</Trans>
|
||||
</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<Dialog open={open} onOpenChange={(value) => !isDeletingTeamEmail && setOpen(value)}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="destructive">
|
||||
<Trans>Revoke access</Trans>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Are you sure?</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
<Trans>
|
||||
You are about to revoke access for team{' '}
|
||||
<span className="font-semibold">{teamEmail.team.name}</span> ({teamEmail.team.url})
|
||||
to use your email.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<fieldset disabled={isDeletingTeamEmail}>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
loading={isDeletingTeamEmail}
|
||||
onClick={async () => deleteTeamEmail({ teamId: teamEmail.teamId })}
|
||||
>
|
||||
<Trans>Revoke</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
@ -1,115 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Plural, Trans } from '@lingui/macro';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import { BellIcon } from 'lucide-react';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { formatTeamUrl } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
|
||||
import { AcceptTeamInvitationButton } from './accept-team-invitation-button';
|
||||
import { DeclineTeamInvitationButton } from './decline-team-invitation-button';
|
||||
|
||||
export const TeamInvitations = () => {
|
||||
const { data, isLoading } = trpc.team.getTeamInvitations.useQuery();
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{data && data.length > 0 && !isLoading && (
|
||||
<AnimateGenericFadeInOut>
|
||||
<Alert variant="secondary">
|
||||
<div className="flex h-full flex-row items-center p-2">
|
||||
<BellIcon className="mr-4 h-5 w-5 text-blue-800" />
|
||||
|
||||
<AlertDescription className="mr-2">
|
||||
<Plural
|
||||
value={data.length}
|
||||
one={
|
||||
<span>
|
||||
You have <strong>1</strong> pending team invitation
|
||||
</span>
|
||||
}
|
||||
other={
|
||||
<span>
|
||||
You have <strong>#</strong> pending team invitations
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</AlertDescription>
|
||||
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<button className="ml-auto text-sm font-medium text-blue-700 hover:text-blue-600">
|
||||
<Trans>View invites</Trans>
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Pending invitations</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
<Plural
|
||||
value={data.length}
|
||||
one={
|
||||
<span>
|
||||
You have <strong>1</strong> pending team invitation
|
||||
</span>
|
||||
}
|
||||
other={
|
||||
<span>
|
||||
You have <strong>#</strong> pending team invitations
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ul className="-mx-6 -mb-6 max-h-[80vh] divide-y overflow-auto px-6 pb-6 xl:max-h-[70vh]">
|
||||
{data.map((invitation) => (
|
||||
<li key={invitation.teamId}>
|
||||
<AvatarWithText
|
||||
avatarSrc={`${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${
|
||||
invitation.team.avatarImageId
|
||||
}`}
|
||||
className="w-full max-w-none py-4"
|
||||
avatarFallback={invitation.team.name.slice(0, 1)}
|
||||
primaryText={
|
||||
<span className="text-foreground/80 font-semibold">
|
||||
{invitation.team.name}
|
||||
</span>
|
||||
}
|
||||
secondaryText={formatTeamUrl(invitation.team.url)}
|
||||
rightSideComponent={
|
||||
<div className="ml-auto space-x-2">
|
||||
<DeclineTeamInvitationButton teamId={invitation.team.id} />
|
||||
<AcceptTeamInvitationButton teamId={invitation.team.id} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</Alert>
|
||||
</AnimateGenericFadeInOut>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
@ -1,94 +0,0 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getUserTokens } from '@documenso/lib/server-only/public-api/get-all-user-tokens';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import DeleteTokenDialog from '~/components/(dashboard)/settings/token/delete-token-dialog';
|
||||
import { ApiTokenForm } from '~/components/forms/token';
|
||||
|
||||
export default async function ApiTokensPage() {
|
||||
const { i18n } = await setupI18nSSR();
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
const tokens = await getUserTokens({ userId: user.id });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-2xl font-semibold">
|
||||
<Trans>API Tokens</Trans>
|
||||
</h3>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
<Trans>
|
||||
On this page, you can create new API tokens and manage the existing ones. <br />
|
||||
Also see our{' '}
|
||||
<a
|
||||
className="text-primary underline"
|
||||
href={'https://docs.documenso.com/developers/public-api'}
|
||||
target="_blank"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
<ApiTokenForm className="max-w-xl" tokens={tokens} />
|
||||
|
||||
<hr className="mb-4 mt-8" />
|
||||
|
||||
<h4 className="text-xl font-medium">
|
||||
<Trans>Your existing tokens</Trans>
|
||||
</h4>
|
||||
|
||||
{tokens.length === 0 && (
|
||||
<div className="mb-4">
|
||||
<p className="text-muted-foreground mt-2 text-sm italic">
|
||||
<Trans>Your tokens will be shown here once you create them.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tokens.length > 0 && (
|
||||
<div className="mt-4 flex max-w-xl flex-col gap-y-4">
|
||||
{tokens.map((token) => (
|
||||
<div key={token.id} className="border-border rounded-lg border p-4">
|
||||
<div className="flex items-center justify-between gap-x-4">
|
||||
<div>
|
||||
<h5 className="text-base">{token.name}</h5>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-xs">
|
||||
<Trans>Created on {i18n.date(token.createdAt, DateTime.DATETIME_FULL)}</Trans>
|
||||
</p>
|
||||
{token.expires ? (
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
<Trans>Expires on {i18n.date(token.expires, DateTime.DATETIME_FULL)}</Trans>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
<Trans>Token doesn't have an expiration date</Trans>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<DeleteTokenDialog token={token}>
|
||||
<Button variant="destructive">
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
</DeleteTokenDialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,216 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
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 type { z } from 'zod';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZEditWebhookMutationSchema } from '@documenso/trpc/server/webhook-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
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 { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||
import { TriggerMultiSelectCombobox } from '~/components/(dashboard)/settings/webhooks/trigger-multiselect-combobox';
|
||||
|
||||
const ZEditWebhookFormSchema = ZEditWebhookMutationSchema.omit({ id: true });
|
||||
|
||||
type TEditWebhookFormSchema = z.infer<typeof ZEditWebhookFormSchema>;
|
||||
|
||||
export type WebhookPageOptions = {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default function WebhookPage({ params }: WebhookPageOptions) {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const { data: webhook, isLoading } = trpc.webhook.getWebhookById.useQuery(
|
||||
{
|
||||
id: params.id,
|
||||
},
|
||||
{ enabled: !!params.id },
|
||||
);
|
||||
|
||||
const { mutateAsync: updateWebhook } = trpc.webhook.editWebhook.useMutation();
|
||||
|
||||
const form = useForm<TEditWebhookFormSchema>({
|
||||
resolver: zodResolver(ZEditWebhookFormSchema),
|
||||
values: {
|
||||
webhookUrl: webhook?.webhookUrl ?? '',
|
||||
eventTriggers: webhook?.eventTriggers ?? [],
|
||||
secret: webhook?.secret ?? '',
|
||||
enabled: webhook?.enabled ?? true,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: TEditWebhookFormSchema) => {
|
||||
try {
|
||||
await updateWebhook({
|
||||
id: params.id,
|
||||
...data,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Webhook updated`),
|
||||
description: _(msg`The webhook has been updated successfully.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`Failed to update webhook`),
|
||||
description: _(
|
||||
msg`We encountered an error while updating the webhook. Please try again later.`,
|
||||
),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title={_(msg`Edit webhook`)}
|
||||
subtitle={_(msg`On this page, you can edit the webhook and its settings.`)}
|
||||
/>
|
||||
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-white/50">
|
||||
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<fieldset
|
||||
className="flex h-full max-w-xl flex-col gap-y-6"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<div className="flex flex-col-reverse gap-4 md:flex-row">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="webhookUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel required>
|
||||
<Trans>Webhook URL</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input className="bg-background" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>The URL for Documenso to send webhook events to.</Trans>
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Enabled</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
className="bg-background"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="eventTriggers"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<FormItem className="flex flex-col gap-2">
|
||||
<FormLabel required>
|
||||
<Trans>Triggers</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<TriggerMultiSelectCombobox
|
||||
listValues={value}
|
||||
onChange={(values: string[]) => {
|
||||
onChange(values);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans> The events that will trigger a webhook to be sent to your URL.</Trans>
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="secret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Secret</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput className="bg-background" {...field} value={field.value ?? ''} />
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
A secret that will be sent to your URL so you can verify that the request has
|
||||
been sent by Documenso.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Update webhook</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,111 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { toFriendlyWebhookEventName } from '@documenso/lib/universal/webhook/to-friendly-webhook-event-name';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||
import { CreateWebhookDialog } from '~/components/(dashboard)/settings/webhooks/create-webhook-dialog';
|
||||
import { DeleteWebhookDialog } from '~/components/(dashboard)/settings/webhooks/delete-webhook-dialog';
|
||||
|
||||
export default function WebhookPage() {
|
||||
const { _, i18n } = useLingui();
|
||||
|
||||
const { data: webhooks, isLoading } = trpc.webhook.getWebhooks.useQuery();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title={_(msg`Webhooks`)}
|
||||
subtitle={_(msg`On this page, you can create new Webhooks and manage the existing ones.`)}
|
||||
>
|
||||
<CreateWebhookDialog />
|
||||
</SettingsHeader>
|
||||
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
|
||||
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{webhooks && webhooks.length === 0 && (
|
||||
// TODO: Perhaps add some illustrations here to make the page more engaging
|
||||
<div className="mb-4">
|
||||
<p className="text-muted-foreground mt-2 text-sm italic">
|
||||
<Trans>
|
||||
You have no webhooks yet. Your webhooks will be shown here once you create them.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{webhooks && webhooks.length > 0 && (
|
||||
<div className="mt-4 flex max-w-xl flex-col gap-y-4">
|
||||
{webhooks?.map((webhook) => (
|
||||
<div
|
||||
key={webhook.id}
|
||||
className={cn(
|
||||
'border-border rounded-lg border p-4',
|
||||
!webhook.enabled && 'bg-muted/40',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-x-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="truncate font-mono text-xs">{webhook.id}</div>
|
||||
|
||||
<div className="mt-1.5 flex items-center gap-4">
|
||||
<h5
|
||||
className="max-w-[30rem] truncate text-sm sm:max-w-[18rem]"
|
||||
title={webhook.webhookUrl}
|
||||
>
|
||||
{webhook.webhookUrl}
|
||||
</h5>
|
||||
|
||||
<Badge variant={webhook.enabled ? 'neutral' : 'warning'} size="small">
|
||||
{webhook.enabled ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-xs">
|
||||
<Trans>
|
||||
Listening to{' '}
|
||||
{webhook.eventTriggers
|
||||
.map((trigger) => toFriendlyWebhookEventName(trigger))
|
||||
.join(', ')}
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-xs">
|
||||
<Trans>Created on {i18n.date(webhook.createdAt, DateTime.DATETIME_FULL)}</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-shrink-0 gap-4 sm:mt-0">
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/settings/webhooks/${webhook.id}`}>
|
||||
<Trans>Edit</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
<DeleteWebhookDialog webhook={webhook}>
|
||||
<Button variant="destructive">
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
</DeleteWebhookDialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,304 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
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 { TTemplate } from '@documenso/lib/types/template';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
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 { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-fields';
|
||||
import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types';
|
||||
import { AddTemplatePlaceholderRecipientsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients';
|
||||
import type { TAddTemplatePlacholderRecipientsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients.types';
|
||||
import { AddTemplateSettingsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-settings';
|
||||
import type { TAddTemplateSettingsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-settings.types';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type EditTemplateFormProps = {
|
||||
className?: string;
|
||||
initialTemplate: TTemplate;
|
||||
isEnterprise: boolean;
|
||||
templateRootPath: string;
|
||||
};
|
||||
|
||||
type EditTemplateStep = 'settings' | 'signers' | 'fields';
|
||||
const EditTemplateSteps: EditTemplateStep[] = ['settings', 'signers', 'fields'];
|
||||
|
||||
export const EditTemplateForm = ({
|
||||
initialTemplate,
|
||||
className,
|
||||
isEnterprise,
|
||||
templateRootPath,
|
||||
}: EditTemplateFormProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const team = useOptionalCurrentTeam();
|
||||
|
||||
const [step, setStep] = useState<EditTemplateStep>('settings');
|
||||
|
||||
const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { data: template, refetch: refetchTemplate } = trpc.template.getTemplateById.useQuery(
|
||||
{
|
||||
templateId: initialTemplate.id,
|
||||
},
|
||||
{
|
||||
initialData: initialTemplate,
|
||||
...SKIP_QUERY_BATCH_META,
|
||||
},
|
||||
);
|
||||
|
||||
const { recipients, fields, templateDocumentData } = template;
|
||||
|
||||
const documentFlow: Record<EditTemplateStep, DocumentFlowStep> = {
|
||||
settings: {
|
||||
title: msg`General`,
|
||||
description: msg`Configure general settings for the template.`,
|
||||
stepIndex: 1,
|
||||
},
|
||||
signers: {
|
||||
title: msg`Add Placeholders`,
|
||||
description: msg`Add all relevant placeholders for each recipient.`,
|
||||
stepIndex: 2,
|
||||
},
|
||||
fields: {
|
||||
title: msg`Add Fields`,
|
||||
description: msg`Add all relevant fields for each recipient.`,
|
||||
stepIndex: 3,
|
||||
},
|
||||
};
|
||||
|
||||
const currentDocumentFlow = documentFlow[step];
|
||||
|
||||
const { mutateAsync: updateTemplateSettings } = trpc.template.updateTemplate.useMutation({
|
||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
onSuccess: (newData) => {
|
||||
utils.template.getTemplateById.setData(
|
||||
{
|
||||
templateId: initialTemplate.id,
|
||||
},
|
||||
(oldData) => ({ ...(oldData || initialTemplate), ...newData }),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: addTemplateFields } = trpc.field.addTemplateFields.useMutation({
|
||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
onSuccess: (newData) => {
|
||||
utils.template.getTemplateById.setData(
|
||||
{
|
||||
templateId: initialTemplate.id,
|
||||
},
|
||||
(oldData) => ({ ...(oldData || initialTemplate), ...newData }),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: setRecipients } = trpc.recipient.setTemplateRecipients.useMutation({
|
||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
onSuccess: (newData) => {
|
||||
utils.template.getTemplateById.setData(
|
||||
{
|
||||
templateId: initialTemplate.id,
|
||||
},
|
||||
(oldData) => ({ ...(oldData || initialTemplate), ...newData }),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => {
|
||||
try {
|
||||
await updateTemplateSettings({
|
||||
templateId: template.id,
|
||||
data: {
|
||||
title: data.title,
|
||||
externalId: data.externalId || null,
|
||||
visibility: data.visibility,
|
||||
globalAccessAuth: data.globalAccessAuth ?? null,
|
||||
globalActionAuth: data.globalActionAuth ?? null,
|
||||
},
|
||||
meta: {
|
||||
...data.meta,
|
||||
language: isValidLanguageCode(data.meta.language) ? data.meta.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 onAddTemplatePlaceholderFormSubmit = async (
|
||||
data: TAddTemplatePlacholderRecipientsFormSchema,
|
||||
) => {
|
||||
try {
|
||||
await Promise.all([
|
||||
updateTemplateSettings({
|
||||
templateId: template.id,
|
||||
meta: {
|
||||
signingOrder: data.signingOrder,
|
||||
},
|
||||
}),
|
||||
|
||||
setRecipients({
|
||||
templateId: template.id,
|
||||
recipients: data.signers,
|
||||
}),
|
||||
]);
|
||||
|
||||
// Router refresh is here to clear the router cache for when navigating to /documents.
|
||||
router.refresh();
|
||||
|
||||
setStep('fields');
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while adding signers.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onAddFieldsFormSubmit = async (data: TAddTemplateFieldsFormSchema) => {
|
||||
try {
|
||||
await addTemplateFields({
|
||||
templateId: template.id,
|
||||
fields: data.fields,
|
||||
});
|
||||
|
||||
await updateTemplateSettings({
|
||||
templateId: template.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);
|
||||
}
|
||||
}
|
||||
|
||||
toast({
|
||||
title: _(msg`Template saved`),
|
||||
description: _(msg`Your templates has been saved successfully.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
router.push(templateRootPath);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while adding fields.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Refresh the data in the background when steps change.
|
||||
*/
|
||||
useEffect(() => {
|
||||
void refetchTemplate();
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [step]);
|
||||
|
||||
return (
|
||||
<div className={cn('grid w-full grid-cols-12 gap-8', className)}>
|
||||
<Card
|
||||
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
|
||||
gradient
|
||||
>
|
||||
<CardContent className="p-2">
|
||||
<LazyPDFViewer
|
||||
key={templateDocumentData.id}
|
||||
documentData={templateDocumentData}
|
||||
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||
<DocumentFlowFormContainer
|
||||
className="lg:h-[calc(100vh-6rem)]"
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
>
|
||||
<Stepper
|
||||
currentStep={currentDocumentFlow.stepIndex}
|
||||
setCurrentStep={(step) => setStep(EditTemplateSteps[step - 1])}
|
||||
>
|
||||
<AddTemplateSettingsFormPartial
|
||||
key={recipients.length}
|
||||
template={template}
|
||||
currentTeamMemberRole={team?.currentTeamMember?.role}
|
||||
documentFlow={documentFlow.settings}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
onSubmit={onAddSettingsFormSubmit}
|
||||
isEnterprise={isEnterprise}
|
||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||
/>
|
||||
|
||||
<AddTemplatePlaceholderRecipientsFormPartial
|
||||
key={recipients.length}
|
||||
documentFlow={documentFlow.signers}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
signingOrder={template.templateMeta?.signingOrder}
|
||||
templateDirectLink={template.directLink}
|
||||
onSubmit={onAddTemplatePlaceholderFormSubmit}
|
||||
isEnterprise={isEnterprise}
|
||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||
/>
|
||||
|
||||
<AddTemplateFieldsFormPartial
|
||||
key={fields.length}
|
||||
documentFlow={documentFlow.fields}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
onSubmit={onAddFieldsFormSubmit}
|
||||
teamId={team?.id}
|
||||
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
||||
/>
|
||||
</Stepper>
|
||||
</DocumentFlowFormContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,14 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
|
||||
import type { TemplateEditPageViewProps } from './template-edit-page-view';
|
||||
import { TemplateEditPageView } from './template-edit-page-view';
|
||||
|
||||
type TemplateEditPageProps = Pick<TemplateEditPageViewProps, 'params'>;
|
||||
|
||||
export default async function TemplateEditPage({ params }: TemplateEditPageProps) {
|
||||
await setupI18nSSR();
|
||||
|
||||
return <TemplateEditPageView params={params} />;
|
||||
}
|
||||
@ -1,100 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import type { Team } from '@documenso/prisma/client';
|
||||
|
||||
import { TemplateType } from '~/components/formatter/template-type';
|
||||
|
||||
import { TemplateDirectLinkBadge } from '../../template-direct-link-badge';
|
||||
import { TemplateDirectLinkDialogWrapper } from '../template-direct-link-dialog-wrapper';
|
||||
import { EditTemplateForm } from './edit-template';
|
||||
|
||||
export type TemplateEditPageViewProps = {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
team?: Team;
|
||||
};
|
||||
|
||||
export const TemplateEditPageView = async ({ params, team }: TemplateEditPageViewProps) => {
|
||||
const { id } = params;
|
||||
|
||||
const templateId = Number(id);
|
||||
const templateRootPath = formatTemplatesPath(team?.url);
|
||||
|
||||
if (!templateId || Number.isNaN(templateId)) {
|
||||
redirect(templateRootPath);
|
||||
}
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
const template = await getTemplateById({
|
||||
id: templateId,
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!template || !template.templateDocumentData) {
|
||||
redirect(templateRootPath);
|
||||
}
|
||||
|
||||
const isTemplateEnterprise = await isUserEnterprise({
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto -mt-4 max-w-screen-xl px-4 md:px-8">
|
||||
<div className="flex flex-col justify-between sm:flex-row">
|
||||
<div>
|
||||
<Link
|
||||
href={`${templateRootPath}/${templateId}`}
|
||||
className="flex items-center text-[#7AC455] hover:opacity-80"
|
||||
>
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
<Trans>Template</Trans>
|
||||
</Link>
|
||||
|
||||
<h1
|
||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||
title={template.title}
|
||||
>
|
||||
{template.title}
|
||||
</h1>
|
||||
|
||||
<div className="mt-2.5 flex items-center">
|
||||
<TemplateType inheritColor className="text-muted-foreground" type={template.type} />
|
||||
|
||||
{template.directLink?.token && (
|
||||
<TemplateDirectLinkBadge
|
||||
className="ml-4"
|
||||
token={template.directLink.token}
|
||||
enabled={template.directLink.enabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 sm:mt-0 sm:self-end">
|
||||
<TemplateDirectLinkDialogWrapper template={template} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EditTemplateForm
|
||||
className="mt-6"
|
||||
initialTemplate={template}
|
||||
templateRootPath={templateRootPath}
|
||||
isEnterprise={isTemplateEnterprise}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,15 +0,0 @@
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
|
||||
import { TemplatePageView } from './template-page-view';
|
||||
|
||||
export type TemplatePageProps = {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function TemplatePage({ params }: TemplatePageProps) {
|
||||
await setupI18nSSR();
|
||||
|
||||
return <TemplatePageView params={params} />;
|
||||
}
|
||||
@ -1,48 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { LinkIcon } from 'lucide-react';
|
||||
|
||||
import type { Recipient, Template, TemplateDirectLink } from '@documenso/prisma/client';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { TemplateDirectLinkDialog } from '../template-direct-link-dialog';
|
||||
|
||||
export type TemplateDirectLinkDialogWrapperProps = {
|
||||
template: Template & { directLink?: TemplateDirectLink | null; recipients: Recipient[] };
|
||||
};
|
||||
|
||||
export const TemplateDirectLinkDialogWrapper = ({
|
||||
template,
|
||||
}: TemplateDirectLinkDialogWrapperProps) => {
|
||||
const [isTemplateDirectLinkOpen, setTemplateDirectLinkOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="px-3"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setTemplateDirectLinkOpen(true);
|
||||
}}
|
||||
>
|
||||
<LinkIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||
|
||||
{template.directLink ? (
|
||||
<Trans>Manage Direct Link</Trans>
|
||||
) : (
|
||||
<Trans>Create Direct Link</Trans>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<TemplateDirectLinkDialog
|
||||
template={template}
|
||||
open={isTemplateDirectLinkOpen}
|
||||
onOpenChange={setTemplateDirectLinkOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user