Compare commits
54 Commits
chore/remo
...
fix/buildi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a58178ea5 | ||
|
|
3c36eedfba | ||
|
|
46dfaa70a3 | ||
|
|
61da354a48 | ||
|
|
fbb332fb35 | ||
|
|
7e1cce9155 | ||
|
|
599e857a1e | ||
|
|
581f08c59b | ||
|
|
24a2e9e6d4 | ||
|
|
e8796a7d86 | ||
|
|
db3f75c42f | ||
|
|
e8b5b3b24a | ||
|
|
00574325b9 | ||
|
|
99706e0ed6 | ||
|
|
326743d8a1 | ||
|
|
3f67b0f27e | ||
|
|
24036b0f24 | ||
|
|
fbf32404a6 | ||
|
|
975d52a07e | ||
|
|
f8a193c0f8 | ||
|
|
9186cb4d7b | ||
|
|
898f5a629c | ||
|
|
933076fa3f | ||
|
|
27edcebef6 | ||
|
|
abc91f7eac | ||
|
|
5862af3034 | ||
|
|
35acf05997 | ||
|
|
5969f148c8 | ||
|
|
660f5894a6 | ||
|
|
77058220a8 | ||
|
|
6cdba45396 | ||
|
|
67571158e8 | ||
|
|
171a5ba4ee | ||
|
|
ff957a2f82 | ||
|
|
6640f0496a | ||
|
|
de3ebe16ee | ||
|
|
84a2d3baf6 | ||
|
|
74180defd1 | ||
|
|
aeeaaf0d8d | ||
|
|
2b84293c4e | ||
|
|
b38ef6c0a7 | ||
|
|
17af4d25bd | ||
|
|
6e095921e6 | ||
|
|
150c42b246 | ||
|
|
b3291c65bc | ||
|
|
4b849e286c | ||
|
|
7bcc26a987 | ||
|
|
692722d32e | ||
|
|
e4f06d8e30 | ||
|
|
c799380787 | ||
|
|
5540fcf0d2 | ||
|
|
d9da09c1e7 | ||
|
|
fe90aa3b7b | ||
|
|
0c680e0111 |
20
.devcontainer/devcontainer.json
Normal file
20
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "Documenso",
|
||||
"image": "mcr.microsoft.com/devcontainers/base:bullseye",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {
|
||||
"version": "latest",
|
||||
"enableNonRootDocker": "true",
|
||||
"moby": "true"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/node:1": {}
|
||||
},
|
||||
"onCreateCommand": "./.devcontainer/on-create.sh",
|
||||
"forwardPorts": [
|
||||
3000,
|
||||
54320,
|
||||
9000,
|
||||
2500,
|
||||
1100
|
||||
]
|
||||
}
|
||||
18
.devcontainer/on-create.sh
Executable file
18
.devcontainer/on-create.sh
Executable file
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Start the database and mailserver
|
||||
docker compose -f ./docker/compose-without-app.yml up -d
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Copy the env file
|
||||
cp .env.example .env
|
||||
|
||||
# Source the env file, export the variables
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
|
||||
# Run the migrations
|
||||
npm run -w @documenso/prisma prisma:migrate-dev
|
||||
3
.devcontainer/post-start.sh
Executable file
3
.devcontainer/post-start.sh
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
npm run dev
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: 'Building Documenso — Part 1: Certificates'
|
||||
description: In today's fast-paced world, productivity and efficiency are crucial for success, both in personal and professional endeavors. We all strive to make the most of our time and energy to achieve our goals effectively. However, it's not always easy to stay on track and maintain peak performance. In this blog post, we'll explore 10 valuable tips to help you boost productivity and efficiency in your daily life.
|
||||
description: This is the first part of the new Building Documenso series, where I describe the challenges and design choices that we make while building the world’s most open signing platform.
|
||||
authorName: 'Timur Ercan'
|
||||
authorImage: '/blog/blog-author-timur.jpeg'
|
||||
authorRole: 'Co-Founder'
|
||||
@@ -79,7 +79,7 @@ There weren’t any deeper reasons we choose WiseKey, other than they offered wh
|
||||
Do you have questions or thoughts about this? As always, let me know in the comments, on <a href="http://twitter.com/eltimuro" target="_blank">twitter.com/eltimuro</a>
|
||||
or directly: <a href="https://documen.so/timur" target="_blank">documen.so/timur</a>
|
||||
|
||||
Join the self-hoster community here: <a href="https://documenso.slack.com/" target="_blank">https://documenso.slack.com/</a>
|
||||
Join the self-hoster community here: <a href="https://documen.so/discord" target="_blank">https://documen.so/discord</a>
|
||||
|
||||
Best from Hamburg
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ tags:
|
||||
|
||||
Since we launched [Documenso 0.9 on Product Hunt](https://producthunt.com/products/documenso#documenso) last May, the team's been hard at work behind the scenes to ramp up development and design to deliver an excellent next version.
|
||||
|
||||
Last week, Lucas shared the reasoning how [why we're doing a rewrite](https://documenso.com/blog/why-were-doing-a-rewrite).
|
||||
Last week, Lucas shared the reasoning on [why we're doing a rewrite](https://documenso.com/blog/why-were-doing-a-rewrite).
|
||||
|
||||
Today, I'm pleased to share with you a preview of the next Documenso.
|
||||
|
||||
|
||||
@@ -22,6 +22,10 @@ export const MENU_NAVIGATION_LINKS = [
|
||||
href: '/pricing',
|
||||
text: 'Pricing',
|
||||
},
|
||||
{
|
||||
href: '/open',
|
||||
text: 'Open',
|
||||
},
|
||||
{
|
||||
href: 'https://status.documenso.com',
|
||||
text: 'Status',
|
||||
@@ -59,7 +63,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
transition={{
|
||||
staggerChildren: 0.2,
|
||||
staggerChildren: 0.03,
|
||||
}}
|
||||
>
|
||||
{MENU_NAVIGATION_LINKS.map(({ href, text }) => (
|
||||
@@ -75,6 +79,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
|
||||
x: 0,
|
||||
transition: {
|
||||
duration: 0.5,
|
||||
ease: 'backInOut',
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const path = require('path');
|
||||
const { version } = require('./package.json');
|
||||
|
||||
const { parsed: env } = require('dotenv').config({
|
||||
path: path.join(__dirname, '../../.env.local'),
|
||||
@@ -18,7 +19,10 @@ const config = {
|
||||
'@documenso/ui',
|
||||
'@documenso/email',
|
||||
],
|
||||
env,
|
||||
env: {
|
||||
...env,
|
||||
APP_VERSION: version,
|
||||
},
|
||||
modularizeImports: {
|
||||
'lucide-react': {
|
||||
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',
|
||||
|
||||
30
apps/web/src/app/(dashboard)/admin/layout.tsx
Normal file
30
apps/web/src/app/(dashboard)/admin/layout.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-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) {
|
||||
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 gap-x-8 md:mt-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>
|
||||
);
|
||||
}
|
||||
47
apps/web/src/app/(dashboard)/admin/nav.tsx
Normal file
47
apps/web/src/app/(dashboard)/admin/nav.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import { HTMLAttributes } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { BarChart3, User2 } 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 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" />
|
||||
Stats
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'justify-start md:w-full',
|
||||
pathname?.startsWith('/admin/users') && 'bg-secondary',
|
||||
)}
|
||||
disabled
|
||||
>
|
||||
<User2 className="mr-2 h-5 w-5" />
|
||||
Users (Coming Soon)
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
5
apps/web/src/app/(dashboard)/admin/page.tsx
Normal file
5
apps/web/src/app/(dashboard)/admin/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Admin() {
|
||||
redirect('/admin/stats');
|
||||
}
|
||||
75
apps/web/src/app/(dashboard)/admin/stats/page.tsx
Normal file
75
apps/web/src/app/(dashboard)/admin/stats/page.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
File,
|
||||
FileCheck,
|
||||
FileClock,
|
||||
FileEdit,
|
||||
Mail,
|
||||
MailOpen,
|
||||
PenTool,
|
||||
User as UserIcon,
|
||||
UserPlus2,
|
||||
UserSquare2,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { getDocumentStats } from '@documenso/lib/server-only/admin/get-documents-stats';
|
||||
import { getRecipientsStats } from '@documenso/lib/server-only/admin/get-recipients-stats';
|
||||
import {
|
||||
getUsersCount,
|
||||
getUsersWithSubscriptionsCount,
|
||||
} from '@documenso/lib/server-only/admin/get-users-stats';
|
||||
|
||||
import { CardMetric } from '~/components/(dashboard)/metric-card/metric-card';
|
||||
|
||||
export default async function AdminStatsPage() {
|
||||
const [usersCount, usersWithSubscriptionsCount, docStats, recipientStats] = await Promise.all([
|
||||
getUsersCount(),
|
||||
getUsersWithSubscriptionsCount(),
|
||||
getDocumentStats(),
|
||||
getRecipientsStats(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-4xl font-semibold">Instance Stats</h2>
|
||||
|
||||
<div className="mt-8 grid flex-1 grid-cols-1 gap-4 md:grid-cols-4">
|
||||
<CardMetric icon={UserIcon} title="Total Users" value={usersCount} />
|
||||
<CardMetric icon={File} title="Total Documents" value={docStats.ALL} />
|
||||
<CardMetric
|
||||
icon={UserPlus2}
|
||||
title="Active Subscriptions"
|
||||
value={usersWithSubscriptionsCount}
|
||||
/>
|
||||
<CardMetric icon={UserPlus2} title="App Version" value={`v${process.env.APP_VERSION}`} />
|
||||
</div>
|
||||
|
||||
<div className="mt-16 grid grid-cols-1 gap-8 md:grid-cols-2">
|
||||
<div>
|
||||
<h3 className="text-3xl font-semibold">Document metrics</h3>
|
||||
|
||||
<div className="mt-8 grid flex-1 grid-cols-2 gap-4">
|
||||
<CardMetric icon={File} title="Total Documents" value={docStats.ALL} />
|
||||
<CardMetric icon={FileEdit} title="Drafted Documents" value={docStats.DRAFT} />
|
||||
<CardMetric icon={FileClock} title="Pending Documents" value={docStats.PENDING} />
|
||||
<CardMetric icon={FileCheck} title="Completed Documents" value={docStats.COMPLETED} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-3xl font-semibold">Recipients metrics</h3>
|
||||
|
||||
<div className="mt-8 grid flex-1 grid-cols-2 gap-4">
|
||||
<CardMetric
|
||||
icon={UserSquare2}
|
||||
title="Total Recipients"
|
||||
value={recipientStats.TOTAL_RECIPIENTS}
|
||||
/>
|
||||
<CardMetric icon={Mail} title="Documents Received" value={recipientStats.SENT} />
|
||||
<CardMetric icon={MailOpen} title="Documents Viewed" value={recipientStats.OPENED} />
|
||||
<CardMetric icon={PenTool} title="Signatures Collected" value={recipientStats.SIGNED} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Clock, File, FileCheck } from 'lucide-react';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
||||
import { getStats } from '@documenso/lib/server-only/document/get-stats';
|
||||
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@documenso/ui/primitives/table';
|
||||
|
||||
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
||||
import { CardMetric } from '~/components/(dashboard)/metric-card/metric-card';
|
||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||
|
||||
import { UploadDocument } from './upload-document';
|
||||
|
||||
const CARD_DATA = [
|
||||
{
|
||||
icon: FileCheck,
|
||||
title: 'Completed',
|
||||
status: InternalDocumentStatus.COMPLETED,
|
||||
},
|
||||
{
|
||||
icon: File,
|
||||
title: 'Drafts',
|
||||
status: InternalDocumentStatus.DRAFT,
|
||||
},
|
||||
{
|
||||
icon: Clock,
|
||||
title: 'Pending',
|
||||
status: InternalDocumentStatus.PENDING,
|
||||
},
|
||||
];
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const user = await getRequiredServerComponentSession();
|
||||
|
||||
const [stats, results] = await Promise.all([
|
||||
getStats({
|
||||
user,
|
||||
}),
|
||||
findDocuments({
|
||||
userId: user.id,
|
||||
perPage: 10,
|
||||
}),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||
<h1 className="text-4xl font-semibold">Dashboard</h1>
|
||||
|
||||
<div className="mt-8 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{CARD_DATA.map((card) => (
|
||||
<Link key={card.status} href={`/documents?status=${card.status}`}>
|
||||
<CardMetric icon={card.icon} title={card.title} value={stats[card.status]} />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-12">
|
||||
<UploadDocument />
|
||||
|
||||
<h2 className="mt-8 text-2xl font-semibold">Recent Documents</h2>
|
||||
|
||||
<div className="border-border mt-8 overflow-x-auto rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">ID</TableHead>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Reciepient</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Created</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{results.data.map((document) => {
|
||||
return (
|
||||
<TableRow key={document.id}>
|
||||
<TableCell className="font-medium">{document.id}</TableCell>
|
||||
<TableCell>
|
||||
<Link
|
||||
href={`/documents/${document.id}`}
|
||||
className="focus-visible:ring-ring ring-offset-background rounded-md font-medium hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
{document.title}
|
||||
</Link>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<StackAvatarsWithTooltip recipients={document.Recipient} />
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<DocumentStatus status={document.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<LocaleDate date={document.created} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
{results.data.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -136,7 +136,7 @@ export const EditDocumentForm = ({
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
router.push('/dashboard');
|
||||
router.push('/documents');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
|
||||
@@ -82,14 +82,14 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuItem disabled={!recipient} asChild>
|
||||
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
|
||||
<Link href={`/sign/${recipient?.token}`}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Sign
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem disabled={!isOwner} asChild>
|
||||
<DropdownMenuItem disabled={!isOwner || isComplete} asChild>
|
||||
<Link href={`/documents/${row.id}`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
|
||||
56
apps/web/src/app/(dashboard)/documents/data-table-title.tsx
Normal file
56
apps/web/src/app/(dashboard)/documents/data-table-title.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { Document, Recipient, User } from '@documenso/prisma/client';
|
||||
|
||||
export type DataTableTitleProps = {
|
||||
row: Document & {
|
||||
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||
Recipient: Recipient[];
|
||||
};
|
||||
};
|
||||
|
||||
export const DataTableTitle = ({ row }: DataTableTitleProps) => {
|
||||
const { data: session } = useSession();
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email);
|
||||
|
||||
const isOwner = row.User.id === session.user.id;
|
||||
const isRecipient = !!recipient;
|
||||
|
||||
return match({
|
||||
isOwner,
|
||||
isRecipient,
|
||||
})
|
||||
.with({ isOwner: true }, () => (
|
||||
<Link
|
||||
href={`/documents/${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>
|
||||
));
|
||||
};
|
||||
@@ -2,9 +2,8 @@
|
||||
|
||||
import { useTransition } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Loader } from 'lucide-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { FindResultSet } from '@documenso/lib/types/find-result-set';
|
||||
@@ -18,6 +17,7 @@ import { LocaleDate } from '~/components/formatter/locale-date';
|
||||
|
||||
import { DataTableActionButton } from './data-table-action-button';
|
||||
import { DataTableActionDropdown } from './data-table-action-dropdown';
|
||||
import { DataTableTitle } from './data-table-title';
|
||||
|
||||
export type DocumentsDataTableProps = {
|
||||
results: FindResultSet<
|
||||
@@ -29,6 +29,7 @@ export type DocumentsDataTableProps = {
|
||||
};
|
||||
|
||||
export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
||||
const { data: session } = useSession();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
@@ -42,25 +43,22 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
||||
});
|
||||
};
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<DataTable
|
||||
columns={[
|
||||
{
|
||||
header: 'ID',
|
||||
accessorKey: 'id',
|
||||
header: 'Created',
|
||||
accessorKey: 'created',
|
||||
cell: ({ row }) => <LocaleDate date={row.getValue('created')} />,
|
||||
},
|
||||
{
|
||||
header: 'Title',
|
||||
cell: ({ row }) => (
|
||||
<Link
|
||||
href={`/documents/${row.original.id}`}
|
||||
title={row.original.title}
|
||||
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
|
||||
>
|
||||
{row.original.title}
|
||||
</Link>
|
||||
),
|
||||
cell: ({ row }) => <DataTableTitle row={row.original} />,
|
||||
},
|
||||
{
|
||||
header: 'Recipient',
|
||||
@@ -74,11 +72,6 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
||||
accessorKey: 'status',
|
||||
cell: ({ row }) => <DocumentStatus status={row.getValue('status')} />,
|
||||
},
|
||||
{
|
||||
header: 'Created',
|
||||
accessorKey: 'created',
|
||||
cell: ({ row }) => <LocaleDate date={row.getValue('created')} />,
|
||||
},
|
||||
{
|
||||
header: 'Actions',
|
||||
cell: ({ row }) => (
|
||||
@@ -95,7 +88,7 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
||||
totalPages={results.totalPages}
|
||||
onPaginationChange={onPaginationChange}
|
||||
>
|
||||
{(table) => <DataTablePagination table={table} />}
|
||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||
</DataTable>
|
||||
|
||||
{isPending && (
|
||||
|
||||
@@ -11,8 +11,8 @@ import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-
|
||||
import { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
|
||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||
|
||||
import { UploadDocument } from '../dashboard/upload-document';
|
||||
import { DocumentsDataTable } from './data-table';
|
||||
import { UploadDocument } from './upload-document';
|
||||
|
||||
export type DocumentsPageProps = {
|
||||
searchParams?: {
|
||||
@@ -81,6 +81,7 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
|
||||
{value !== ExtendedDocumentStatus.ALL && (
|
||||
<span className="ml-1 hidden opacity-50 md:inline-block">
|
||||
{Math.min(stats[value], 99)}
|
||||
{stats[value] > 99 && '+'}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
96
apps/web/src/app/(signing)/sign/[token]/email-field.tsx
Normal file
96
apps/web/src/app/(signing)/sign/[token]/email-field.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
'use client';
|
||||
|
||||
import { useTransition } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import { Recipient } from '@documenso/prisma/client';
|
||||
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useRequiredSigningContext } from './provider';
|
||||
import { SigningFieldContainer } from './signing-field-container';
|
||||
|
||||
export type EmailFieldProps = {
|
||||
field: FieldWithSignature;
|
||||
recipient: Recipient;
|
||||
};
|
||||
|
||||
export const EmailField = ({ field, recipient }: EmailFieldProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const { email: providedEmail } = useRequiredSigningContext();
|
||||
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
||||
trpc.field.signFieldWithToken.useMutation();
|
||||
|
||||
const {
|
||||
mutateAsync: removeSignedFieldWithToken,
|
||||
isLoading: isRemoveSignedFieldWithTokenLoading,
|
||||
} = trpc.field.removeSignedFieldWithToken.useMutation();
|
||||
|
||||
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
||||
|
||||
const onSign = async () => {
|
||||
try {
|
||||
await signFieldWithToken({
|
||||
token: recipient.token,
|
||||
fieldId: field.id,
|
||||
value: providedEmail ?? '',
|
||||
isBase64: false,
|
||||
});
|
||||
|
||||
startTransition(() => router.refresh());
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while signing the document.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onRemove = async () => {
|
||||
try {
|
||||
await removeSignedFieldWithToken({
|
||||
token: recipient.token,
|
||||
fieldId: field.id,
|
||||
});
|
||||
|
||||
startTransition(() => router.refresh());
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while removing the signature.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
|
||||
{isLoading && (
|
||||
<div className="bg-background absolute inset-0 flex items-center justify-center">
|
||||
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!field.inserted && (
|
||||
<p className="group-hover:text-primary text-muted-foreground text-lg duration-200">Email</p>
|
||||
)}
|
||||
|
||||
{field.inserted && <p className="text-muted-foreground duration-200">{field.customText}</p>}
|
||||
</SigningFieldContainer>
|
||||
);
|
||||
};
|
||||
@@ -14,6 +14,7 @@ import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
|
||||
import { DateField } from './date-field';
|
||||
import { EmailField } from './email-field';
|
||||
import { SigningForm } from './form';
|
||||
import { NameField } from './name-field';
|
||||
import { SigningProvider } from './provider';
|
||||
@@ -87,6 +88,9 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
||||
.with(FieldType.DATE, () => (
|
||||
<DateField key={field.id} field={field} recipient={recipient} />
|
||||
))
|
||||
.with(FieldType.EMAIL, () => (
|
||||
<EmailField key={field.id} field={field} recipient={recipient} />
|
||||
))
|
||||
.otherwise(() => null),
|
||||
)}
|
||||
</ElementVisible>
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Suspense } from 'react';
|
||||
|
||||
import { Caveat, Inter } from 'next/font/google';
|
||||
|
||||
import { LocaleProvider } from '@documenso/lib/client-only/providers/locale';
|
||||
import { getLocale } from '@documenso/lib/server-only/headers/get-locale';
|
||||
import { TrpcProvider } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Toaster } from '@documenso/ui/primitives/toaster';
|
||||
@@ -45,6 +47,8 @@ export const metadata = {
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const flags = await getServerComponentAllFlags();
|
||||
|
||||
const locale = getLocale();
|
||||
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
@@ -63,6 +67,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
</Suspense>
|
||||
|
||||
<body>
|
||||
<LocaleProvider locale={locale}>
|
||||
<FeatureFlagProvider initialFlags={flags}>
|
||||
<PlausibleProvider>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
@@ -73,6 +78,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
</PlausibleProvider>
|
||||
<Toaster />
|
||||
</FeatureFlagProvider>
|
||||
</LocaleProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -15,7 +15,7 @@ export type StackAvatarProps = {
|
||||
type: 'unsigned' | 'waiting' | 'opened' | 'completed';
|
||||
};
|
||||
|
||||
export const StackAvatar = ({ first, zIndex, fallbackText, type }: StackAvatarProps) => {
|
||||
export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAvatarProps) => {
|
||||
let classes = '';
|
||||
let zIndexClass = '';
|
||||
const firstClass = first ? '' : '-ml-3';
|
||||
@@ -48,7 +48,7 @@ export const StackAvatar = ({ first, zIndex, fallbackText, type }: StackAvatarPr
|
||||
${firstClass}
|
||||
dark:border-border h-10 w-10 border-2 border-solid border-white`}
|
||||
>
|
||||
<AvatarFallback className={classes}>{fallbackText ?? 'UK'}</AvatarFallback>
|
||||
<AvatarFallback className={classes}>{fallbackText}</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { initials } from '@documenso/lib/client-only/recipient-initials';
|
||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { Recipient } from '@documenso/prisma/client';
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -56,7 +56,7 @@ export const StackAvatarsWithTooltip = ({
|
||||
first={true}
|
||||
key={recipient.id}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={initials(recipient.name)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
<span className="text-sm text-gray-500">{recipient.email}</span>
|
||||
</div>
|
||||
@@ -73,7 +73,7 @@ export const StackAvatarsWithTooltip = ({
|
||||
first={true}
|
||||
key={recipient.id}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={initials(recipient.name)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
<span className="text-sm text-gray-500">{recipient.email}</span>
|
||||
</div>
|
||||
@@ -90,7 +90,7 @@ export const StackAvatarsWithTooltip = ({
|
||||
first={true}
|
||||
key={recipient.id}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={initials(recipient.name)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
<span className="text-sm text-gray-500">{recipient.email}</span>
|
||||
</div>
|
||||
@@ -107,7 +107,7 @@ export const StackAvatarsWithTooltip = ({
|
||||
first={true}
|
||||
key={recipient.id}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={initials(recipient.name)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
<span className="text-sm text-gray-500">{recipient.email}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
import { initials } from '@documenso/lib/client-only/recipient-initials';
|
||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { Recipient } from '@documenso/prisma/client';
|
||||
|
||||
import { StackAvatar } from './stack-avatar';
|
||||
@@ -26,7 +26,7 @@ export function StackAvatars({ recipients }: { recipients: Recipient[] }) {
|
||||
first={first}
|
||||
zIndex={String(zIndex - index * 10)}
|
||||
type={lastItemText && index === 4 ? 'unsigned' : getRecipientType(recipient)}
|
||||
fallbackText={lastItemText ? lastItemText : initials(recipient.name)}
|
||||
fallbackText={lastItemText ? lastItemText : recipientAbbreviation(recipient)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -11,10 +11,13 @@ import {
|
||||
Monitor,
|
||||
Moon,
|
||||
Sun,
|
||||
UserCog,
|
||||
} from 'lucide-react';
|
||||
import { signOut } from 'next-auth/react';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||
import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { User } from '@documenso/prisma/client';
|
||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -35,24 +38,21 @@ export type ProfileDropdownProps = {
|
||||
|
||||
export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
const { getFlag } = useFeatureFlags();
|
||||
const isUserAdmin = isAdmin(user);
|
||||
|
||||
const isBillingEnabled = getFlag('app_billing');
|
||||
|
||||
const initials =
|
||||
user.name
|
||||
?.split(' ')
|
||||
.map((name: string) => name.slice(0, 1).toUpperCase())
|
||||
.slice(0, 2)
|
||||
.join('') ?? 'UK';
|
||||
const avatarFallback = user.name
|
||||
? recipientInitials(user.name)
|
||||
: user.email.slice(0, 1).toUpperCase();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="relative h-10 w-10 rounded-full">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarFallback>{initials}</AvatarFallback>
|
||||
<AvatarFallback>{avatarFallback}</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -60,6 +60,19 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
||||
<DropdownMenuContent className="w-56" align="end" forceMount>
|
||||
<DropdownMenuLabel>Account</DropdownMenuLabel>
|
||||
|
||||
{isUserAdmin && (
|
||||
<>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/admin" className="cursor-pointer">
|
||||
<UserCog className="mr-2 h-4 w-4" />
|
||||
Admin
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/settings/profile" className="cursor-pointer">
|
||||
<LucideUser className="mr-2 h-4 w-4" />
|
||||
|
||||
@@ -18,10 +18,10 @@ export const CardMetric = ({ icon: Icon, title, value, className }: CardMetricPr
|
||||
)}
|
||||
>
|
||||
<div className="px-4 pb-6 pt-4 sm:px-4 sm:pb-8 sm:pt-4">
|
||||
<div className="flex items-start">
|
||||
{Icon && <Icon className="mr-2 h-4 w-4 text-slate-500" />}
|
||||
<div className="flex items-center">
|
||||
{Icon && <Icon className="text-muted-foreground mr-2 h-4 w-4" />}
|
||||
|
||||
<h3 className="flex items-end text-sm font-medium text-slate-500">{title}</h3>
|
||||
<h3 className="text-primary-forground flex items-end text-sm font-medium">{title}</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-foreground mt-6 text-4xl font-semibold leading-8 md:mt-8">
|
||||
|
||||
@@ -2,16 +2,31 @@
|
||||
|
||||
import { HTMLAttributes, useEffect, useState } from 'react';
|
||||
|
||||
import { DateTime, DateTimeFormatOptions } from 'luxon';
|
||||
|
||||
import { useLocale } from '@documenso/lib/client-only/providers/locale';
|
||||
|
||||
export type LocaleDateProps = HTMLAttributes<HTMLSpanElement> & {
|
||||
date: string | number | Date;
|
||||
format?: DateTimeFormatOptions;
|
||||
};
|
||||
|
||||
export const LocaleDate = ({ className, date, ...props }: LocaleDateProps) => {
|
||||
const [localeDate, setLocaleDate] = useState(() => new Date(date).toISOString());
|
||||
/**
|
||||
* Formats the date based on the user locale.
|
||||
*
|
||||
* Will use the estimated locale from the user headers on SSR, then will use
|
||||
* the client browser locale once mounted.
|
||||
*/
|
||||
export const LocaleDate = ({ className, date, format, ...props }: LocaleDateProps) => {
|
||||
const { locale } = useLocale();
|
||||
|
||||
const [localeDate, setLocaleDate] = useState(() =>
|
||||
DateTime.fromJSDate(new Date(date)).setLocale(locale).toLocaleString(format),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setLocaleDate(new Date(date).toLocaleString());
|
||||
}, [date]);
|
||||
setLocaleDate(DateTime.fromJSDate(new Date(date)).toLocaleString(format));
|
||||
}, [date, format]);
|
||||
|
||||
return (
|
||||
<span className={className} {...props}>
|
||||
|
||||
@@ -44,7 +44,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
|
||||
} = useForm<TProfileFormSchema>({
|
||||
values: {
|
||||
name: user.name ?? '',
|
||||
signature: '',
|
||||
signature: user.signature || '',
|
||||
},
|
||||
resolver: zodResolver(ZProfileFormSchema),
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { signIn } from 'next-auth/react';
|
||||
@@ -7,12 +11,22 @@ import { useForm } from 'react-hook-form';
|
||||
import { FcGoogle } from 'react-icons/fc';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
const ERROR_MESSAGES = {
|
||||
[ErrorCode.CREDENTIALS_NOT_FOUND]: 'The email or password provided is incorrect',
|
||||
[ErrorCode.INCORRECT_EMAIL_PASSWORD]: 'The email or password provided is incorrect',
|
||||
[ErrorCode.USER_MISSING_PASSWORD]:
|
||||
'This account appears to be using a social login method, please sign in using that method',
|
||||
};
|
||||
|
||||
const LOGIN_REDIRECT_PATH = '/documents';
|
||||
|
||||
export const ZSignInFormSchema = z.object({
|
||||
email: z.string().email().min(1),
|
||||
password: z.string().min(6).max(72),
|
||||
@@ -25,6 +39,8 @@ export type SignInFormProps = {
|
||||
};
|
||||
|
||||
export const SignInForm = ({ className }: SignInFormProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const {
|
||||
@@ -39,17 +55,36 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
||||
resolver: zodResolver(ZSignInFormSchema),
|
||||
});
|
||||
|
||||
const errorCode = searchParams?.get('error');
|
||||
|
||||
useEffect(() => {
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
|
||||
if (isErrorCode(errorCode)) {
|
||||
timeout = setTimeout(() => {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
description: ERROR_MESSAGES[errorCode] ?? 'An unknown error occurred',
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
};
|
||||
}, [errorCode, toast]);
|
||||
|
||||
const onFormSubmit = async ({ email, password }: TSignInFormSchema) => {
|
||||
try {
|
||||
await signIn('credentials', {
|
||||
email,
|
||||
password,
|
||||
callbackUrl: '/documents',
|
||||
callbackUrl: LOGIN_REDIRECT_PATH,
|
||||
}).catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
|
||||
// throw new Error('Not implemented');
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'An unknown error occurred',
|
||||
@@ -61,8 +96,7 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
||||
|
||||
const onSignInWithGoogleClick = async () => {
|
||||
try {
|
||||
await signIn('google', { callbackUrl: '/dashboard' });
|
||||
// throw new Error('Not implemented');
|
||||
await signIn('google', { callbackUrl: LOGIN_REDIRECT_PATH });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'An unknown error occurred',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev --filter=@documenso/{web,marketing}",
|
||||
"dev": "turbo run dev --filter=@documenso/web --filter=@documenso/marketing",
|
||||
"start": "cd apps && cd web && next start",
|
||||
"lint": "turbo run lint",
|
||||
"format": "prettier --write \"**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,mdx}\"",
|
||||
|
||||
37
packages/lib/client-only/providers/locale.tsx
Normal file
37
packages/lib/client-only/providers/locale.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export type LocaleContextValue = {
|
||||
locale: string;
|
||||
};
|
||||
|
||||
export const LocaleContext = createContext<LocaleContextValue | null>(null);
|
||||
|
||||
export const useLocale = () => {
|
||||
const context = useContext(LocaleContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useLocale must be used within a LocaleProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export function LocaleProvider({
|
||||
children,
|
||||
locale,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
locale: string;
|
||||
}) {
|
||||
return (
|
||||
<LocaleContext.Provider
|
||||
value={{
|
||||
locale: locale,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</LocaleContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export const initials = (text: string) =>
|
||||
text
|
||||
?.split(' ')
|
||||
.map((name: string) => name.slice(0, 1).toUpperCase())
|
||||
.slice(0, 2)
|
||||
.join('') ?? 'UK';
|
||||
@@ -7,7 +7,7 @@ import GoogleProvider, { GoogleProfile } from 'next-auth/providers/google';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getUserByEmail } from '../server-only/user/get-user-by-email';
|
||||
import { ErrorCodes } from './error-codes';
|
||||
import { ErrorCode } from './error-codes';
|
||||
|
||||
export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
adapter: PrismaAdapter(prisma),
|
||||
@@ -24,23 +24,23 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
},
|
||||
authorize: async (credentials, _req) => {
|
||||
if (!credentials) {
|
||||
throw new Error(ErrorCodes.CredentialsNotFound);
|
||||
throw new Error(ErrorCode.CREDENTIALS_NOT_FOUND);
|
||||
}
|
||||
|
||||
const { email, password } = credentials;
|
||||
|
||||
const user = await getUserByEmail({ email }).catch(() => {
|
||||
throw new Error(ErrorCodes.IncorrectEmailPassword);
|
||||
throw new Error(ErrorCode.INCORRECT_EMAIL_PASSWORD);
|
||||
});
|
||||
|
||||
if (!user.password) {
|
||||
throw new Error(ErrorCodes.UserMissingPassword);
|
||||
throw new Error(ErrorCode.USER_MISSING_PASSWORD);
|
||||
}
|
||||
|
||||
const isPasswordsSame = await compare(password, user.password);
|
||||
|
||||
if (!isPasswordsSame) {
|
||||
throw new Error(ErrorCodes.IncorrectEmailPassword);
|
||||
throw new Error(ErrorCode.INCORRECT_EMAIL_PASSWORD);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
export const ErrorCodes = {
|
||||
IncorrectEmailPassword: 'incorrect-email-password',
|
||||
UserMissingPassword: 'missing-password',
|
||||
CredentialsNotFound: 'credentials-not-found',
|
||||
export const isErrorCode = (code: unknown): code is ErrorCode => {
|
||||
return typeof code === 'string' && code in ErrorCode;
|
||||
};
|
||||
|
||||
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
|
||||
|
||||
export const ErrorCode = {
|
||||
INCORRECT_EMAIL_PASSWORD: 'INCORRECT_EMAIL_PASSWORD',
|
||||
USER_MISSING_PASSWORD: 'USER_MISSING_PASSWORD',
|
||||
CREDENTIALS_NOT_FOUND: 'CREDENTIALS_NOT_FOUND',
|
||||
} as const;
|
||||
|
||||
5
packages/lib/next-auth/guards/is-admin.ts
Normal file
5
packages/lib/next-auth/guards/is-admin.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Role, User } from '@documenso/prisma/client';
|
||||
|
||||
const isAdmin = (user: User) => user.roles.includes(Role.ADMIN);
|
||||
|
||||
export { isAdmin };
|
||||
26
packages/lib/server-only/admin/get-documents-stats.ts
Normal file
26
packages/lib/server-only/admin/get-documents-stats.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
|
||||
export const getDocumentStats = async () => {
|
||||
const counts = await prisma.document.groupBy({
|
||||
by: ['status'],
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
});
|
||||
|
||||
const stats: Record<Exclude<ExtendedDocumentStatus, 'INBOX'>, number> = {
|
||||
[ExtendedDocumentStatus.DRAFT]: 0,
|
||||
[ExtendedDocumentStatus.PENDING]: 0,
|
||||
[ExtendedDocumentStatus.COMPLETED]: 0,
|
||||
[ExtendedDocumentStatus.ALL]: 0,
|
||||
};
|
||||
|
||||
counts.forEach((stat) => {
|
||||
stats[stat.status] = stat._count._all;
|
||||
|
||||
stats.ALL += stat._count._all;
|
||||
});
|
||||
|
||||
return stats;
|
||||
};
|
||||
29
packages/lib/server-only/admin/get-recipients-stats.ts
Normal file
29
packages/lib/server-only/admin/get-recipients-stats.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { ReadStatus, SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
export const getRecipientsStats = async () => {
|
||||
const results = await prisma.recipient.groupBy({
|
||||
by: ['readStatus', 'signingStatus', 'sendStatus'],
|
||||
_count: true,
|
||||
});
|
||||
|
||||
const stats = {
|
||||
TOTAL_RECIPIENTS: 0,
|
||||
[ReadStatus.OPENED]: 0,
|
||||
[ReadStatus.NOT_OPENED]: 0,
|
||||
[SigningStatus.SIGNED]: 0,
|
||||
[SigningStatus.NOT_SIGNED]: 0,
|
||||
[SendStatus.SENT]: 0,
|
||||
[SendStatus.NOT_SENT]: 0,
|
||||
};
|
||||
|
||||
results.forEach((result) => {
|
||||
const { readStatus, signingStatus, sendStatus, _count } = result;
|
||||
stats[readStatus] += _count;
|
||||
stats[signingStatus] += _count;
|
||||
stats[sendStatus] += _count;
|
||||
stats.TOTAL_RECIPIENTS += _count;
|
||||
});
|
||||
|
||||
return stats;
|
||||
};
|
||||
18
packages/lib/server-only/admin/get-users-stats.ts
Normal file
18
packages/lib/server-only/admin/get-users-stats.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { SubscriptionStatus } from '@documenso/prisma/client';
|
||||
|
||||
export const getUsersCount = async () => {
|
||||
return await prisma.user.count();
|
||||
};
|
||||
|
||||
export const getUsersWithSubscriptionsCount = async () => {
|
||||
return await prisma.user.count({
|
||||
where: {
|
||||
Subscription: {
|
||||
some: {
|
||||
status: SubscriptionStatus.ACTIVE,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
12
packages/lib/utils/recipient-formatter.ts
Normal file
12
packages/lib/utils/recipient-formatter.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Recipient } from '@documenso/prisma/client';
|
||||
|
||||
export const recipientInitials = (text: string) =>
|
||||
text
|
||||
.split(' ')
|
||||
.map((name: string) => name.slice(0, 1).toUpperCase())
|
||||
.slice(0, 2)
|
||||
.join('');
|
||||
|
||||
export const recipientAbbreviation = (recipient: Recipient) => {
|
||||
return recipientInitials(recipient.name) || recipient.email.slice(0, 1).toUpperCase();
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Role" AS ENUM ('ADMIN', 'USER');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "roles" "Role"[] DEFAULT ARRAY['USER']::"Role"[];
|
||||
@@ -13,6 +13,11 @@ enum IdentityProvider {
|
||||
GOOGLE
|
||||
}
|
||||
|
||||
enum Role {
|
||||
ADMIN
|
||||
USER
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
name String?
|
||||
@@ -21,6 +26,7 @@ model User {
|
||||
password String?
|
||||
source String?
|
||||
signature String?
|
||||
roles Role[] @default([USER])
|
||||
identityProvider IdentityProvider @default(DOCUMENSO)
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
|
||||
@@ -1,19 +1,46 @@
|
||||
import { Table } from '@tanstack/react-table';
|
||||
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { Button } from './button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select';
|
||||
|
||||
interface DataTablePaginationProps<TData> {
|
||||
table: Table<TData>;
|
||||
|
||||
/**
|
||||
* The type of information to show on the left hand side of the pagination.
|
||||
*
|
||||
* Defaults to 'VisibleCount'.
|
||||
*/
|
||||
additionalInformation?: 'SelectedCount' | 'VisibleCount' | 'None';
|
||||
}
|
||||
|
||||
export function DataTablePagination<TData>({ table }: DataTablePaginationProps<TData>) {
|
||||
export function DataTablePagination<TData>({
|
||||
table,
|
||||
additionalInformation = 'VisibleCount',
|
||||
}: DataTablePaginationProps<TData>) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-4 px-2">
|
||||
<div className="text-muted-foreground flex-1 text-sm">
|
||||
{match(additionalInformation)
|
||||
.with('SelectedCount', () => (
|
||||
<span>
|
||||
{table.getFilteredSelectedRowModel().rows.length} of{' '}
|
||||
{table.getFilteredRowModel().rows.length} row(s) selected.
|
||||
</span>
|
||||
))
|
||||
.with('VisibleCount', () => {
|
||||
const visibleRows = table.getFilteredRowModel().rows.length;
|
||||
|
||||
return (
|
||||
<span>
|
||||
Showing {visibleRows} result{visibleRows > 1 && 's'}.
|
||||
</span>
|
||||
);
|
||||
})
|
||||
.with('None', () => null)
|
||||
.exhaustive()}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-x-2">
|
||||
|
||||
@@ -102,6 +102,7 @@ export const AddFieldsFormPartial = ({
|
||||
|
||||
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
|
||||
const [selectedSigner, setSelectedSigner] = useState<Recipient | null>(null);
|
||||
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
|
||||
|
||||
const hasSelectedSignerBeenSent = selectedSigner?.sendStatus === SendStatus.SENT;
|
||||
|
||||
@@ -314,7 +315,7 @@ export const AddFieldsFormPartial = ({
|
||||
))}
|
||||
|
||||
{!hideRecipients && (
|
||||
<Popover>
|
||||
<Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -324,7 +325,7 @@ export const AddFieldsFormPartial = ({
|
||||
>
|
||||
{selectedSigner?.email && (
|
||||
<span className="flex-1 truncate text-left">
|
||||
{selectedSigner?.email} ({selectedSigner?.email})
|
||||
{selectedSigner?.name} ({selectedSigner?.email})
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -348,7 +349,10 @@ export const AddFieldsFormPartial = ({
|
||||
className={cn({
|
||||
'text-muted-foreground': recipient.sendStatus === SendStatus.SENT,
|
||||
})}
|
||||
onSelect={() => setSelectedSigner(recipient)}
|
||||
onSelect={() => {
|
||||
setSelectedSigner(recipient);
|
||||
setShowRecipientsSelector(false);
|
||||
}}
|
||||
>
|
||||
{recipient.sendStatus !== SendStatus.SENT ? (
|
||||
<Check
|
||||
|
||||
14
turbo.json
14
turbo.json
@@ -2,13 +2,8 @@
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"pipeline": {
|
||||
"build": {
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"outputs": [
|
||||
".next/**",
|
||||
"!.next/cache/**"
|
||||
]
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": [".next/**", "!.next/cache/**"]
|
||||
},
|
||||
"lint": {},
|
||||
"dev": {
|
||||
@@ -16,10 +11,9 @@
|
||||
"persistent": true
|
||||
}
|
||||
},
|
||||
"globalDependencies": [
|
||||
"**/.env.*local"
|
||||
],
|
||||
"globalDependencies": ["**/.env.*local"],
|
||||
"globalEnv": [
|
||||
"APP_VERSION",
|
||||
"NEXTAUTH_URL",
|
||||
"NEXTAUTH_SECRET",
|
||||
"NEXT_PUBLIC_APP_URL",
|
||||
|
||||
Reference in New Issue
Block a user