Compare commits

...

36 Commits

Author SHA1 Message Date
Timur Ercan
9a58178ea5 Merge branch 'feat/refresh' into fix/building-documenso-description 2023-09-13 14:42:41 +02:00
Timur Ercan
3c36eedfba chore: phrasing 2023-09-13 14:42:27 +02:00
Timur Ercan
46dfaa70a3 Update apps/marketing/content/blog/building-documenso-pt1.mdx
Co-authored-by: Adithya Krishna  <aadithya794@gmail.com>
2023-09-13 14:39:01 +02:00
Lucas Smith
61da354a48 Merge pull request #361 from documenso/feat/admin-ui-metrics
feat: admin ui for metrics
2023-09-13 21:55:09 +10:00
Lucas Smith
fbb332fb35 Merge branch 'feat/refresh' into feat/admin-ui-metrics 2023-09-13 21:54:33 +10:00
Lucas Smith
7e1cce9155 Merge pull request #365 from documenso/feat/avatar-fallback
feat: add avatar email fallback
2023-09-13 21:51:42 +10:00
Mythie
599e857a1e fix: add removed layout guard 2023-09-12 17:53:38 +10:00
Lucas Smith
581f08c59b fix: update layout and wording 2023-09-12 07:25:44 +00:00
David Nguyen
24a2e9e6d4 feat: update document table layout (#371)
* feat: update document table layout

- Removed dashboard page
- Removed redundant ID column
- Moved date to first column
- Added estimated locales for SSR dates
2023-09-12 14:29:27 +10:00
David Nguyen
e8796a7d86 refactor: organise recipient utils 2023-09-12 12:33:04 +10:00
Mythie
db3f75c42f fix: data table links for recipients 2023-09-12 10:38:23 +10:00
Timur Ercan
e8b5b3b24a fix: update building documenso article description 2023-09-11 15:22:09 +02:00
Catalin Pit
00574325b9 chore: implemented feedback 2023-09-11 13:43:17 +03:00
Catalin Pit
99706e0ed6 chore: fix version in nextjs config 2023-09-11 11:34:10 +03:00
Catalin Pit
326743d8a1 chore: added app version 2023-09-11 10:59:50 +03:00
David Nguyen
3f67b0f27e Merge pull request #292 from documenso/feat/blog-post-next
fix: typo in blog post
2023-09-11 17:09:31 +10:00
flō
24036b0f24 fix typo 2023-09-11 17:03:14 +10:00
David Nguyen
fbf32404a6 feat: add avatar email fallback 2023-09-11 16:58:41 +10:00
Lucas Smith
975d52a07e Merge pull request #362 from documenso/fix/hide-user-selection
fix: hide popover when user selects a recipient
2023-09-11 12:27:50 +10:00
Ephraim Atta-Duncan
f8a193c0f8 refactor: replace whole implementation with a state 2023-09-09 10:56:45 +00:00
Ephraim Atta-Duncan
9186cb4d7b fix: hide popover when user selects a recipients 2023-09-09 10:42:03 +00:00
Lucas Smith
898f5a629c Merge branch 'feat/refresh' into feat/admin-ui-metrics 2023-09-09 15:49:56 +10:00
Mythie
933076fa3f fix: update devcontainer 2023-09-09 15:49:40 +10:00
Lucas Smith
27edcebef6 Merge branch 'feat/refresh' into feat/admin-ui-metrics 2023-09-09 15:44:34 +10:00
Mythie
abc91f7eac fix: update devcontainer 2023-09-09 15:44:10 +10:00
Lucas Smith
5862af3034 Merge branch 'feat/refresh' into feat/admin-ui-metrics 2023-09-09 15:16:03 +10:00
Mythie
35acf05997 feat: add devcontainer 2023-09-09 04:38:37 +00:00
Catalin Pit
5969f148c8 chore: changed the cards titles 2023-09-08 14:51:55 +03:00
Catalin Pit
660f5894a6 chore: feedback improvements 2023-09-08 12:56:44 +03:00
Catalin Pit
77058220a8 chore: rename files 2023-09-08 12:42:14 +03:00
Catalin Pit
6cdba45396 chore: implemented feedback 2023-09-08 12:39:13 +03:00
Catalin Pit
67571158e8 feat: add the admin page 2023-09-08 11:28:50 +03:00
Catalin Pit
171a5ba4ee feat: creating the admin ui for metrics 2023-09-08 09:16:31 +03:00
Lucas Smith
ff957a2f82 Merge pull request #353 from documenso/feat/disable-sign
feat: disable signing and editing for completed documents
2023-09-06 20:53:23 +10:00
Ephraim Atta-Duncan
6640f0496a feat: disable signing and editing for completed documents 2023-09-06 10:40:45 +00:00
Lucas Smith
de3ebe16ee Merge pull request #349 from documenso/feat/marketing-mobile-nav
feat: update marketing mobile nav
2023-09-05 18:20:53 +10:00
38 changed files with 631 additions and 312 deletions

View 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
View 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
View File

@@ -0,0 +1,3 @@
#!/usr/bin/env bash
npm run dev

View File

@@ -1,6 +1,6 @@
--- ---
title: 'Building Documenso — Part 1: Certificates' 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 worlds most open signing platform.
authorName: 'Timur Ercan' authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg' authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder' authorRole: 'Co-Founder'
@@ -79,7 +79,7 @@ There werent 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> 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> 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 Best from Hamburg

View File

@@ -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. 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. Today, I'm pleased to share with you a preview of the next Documenso.

View File

@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-var-requires */ /* eslint-disable @typescript-eslint/no-var-requires */
const path = require('path'); const path = require('path');
const { version } = require('./package.json');
const { parsed: env } = require('dotenv').config({ const { parsed: env } = require('dotenv').config({
path: path.join(__dirname, '../../.env.local'), path: path.join(__dirname, '../../.env.local'),
@@ -18,7 +19,10 @@ const config = {
'@documenso/ui', '@documenso/ui',
'@documenso/email', '@documenso/email',
], ],
env, env: {
...env,
APP_VERSION: version,
},
modularizeImports: { modularizeImports: {
'lucide-react': { 'lucide-react': {
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}', transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',

View 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>
);
}

View 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>
);
};

View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Admin() {
redirect('/admin/stats');
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -136,7 +136,7 @@ export const EditDocumentForm = ({
duration: 5000, duration: 5000,
}); });
router.push('/dashboard'); router.push('/documents');
} catch (err) { } catch (err) {
console.error(err); console.error(err);

View File

@@ -82,14 +82,14 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
<DropdownMenuContent className="w-52" align="start" forceMount> <DropdownMenuContent className="w-52" align="start" forceMount>
<DropdownMenuLabel>Action</DropdownMenuLabel> <DropdownMenuLabel>Action</DropdownMenuLabel>
<DropdownMenuItem disabled={!recipient} asChild> <DropdownMenuItem disabled={!recipient || isComplete} asChild>
<Link href={`/sign/${recipient?.token}`}> <Link href={`/sign/${recipient?.token}`}>
<Pencil className="mr-2 h-4 w-4" /> <Pencil className="mr-2 h-4 w-4" />
Sign Sign
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem disabled={!isOwner} asChild> <DropdownMenuItem disabled={!isOwner || isComplete} asChild>
<Link href={`/documents/${row.id}`}> <Link href={`/documents/${row.id}`}>
<Edit className="mr-2 h-4 w-4" /> <Edit className="mr-2 h-4 w-4" />
Edit Edit

View 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>
));
};

View File

@@ -2,9 +2,8 @@
import { useTransition } from 'react'; import { useTransition } from 'react';
import Link from 'next/link';
import { Loader } from 'lucide-react'; import { Loader } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { FindResultSet } from '@documenso/lib/types/find-result-set'; 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 { DataTableActionButton } from './data-table-action-button';
import { DataTableActionDropdown } from './data-table-action-dropdown'; import { DataTableActionDropdown } from './data-table-action-dropdown';
import { DataTableTitle } from './data-table-title';
export type DocumentsDataTableProps = { export type DocumentsDataTableProps = {
results: FindResultSet< results: FindResultSet<
@@ -29,6 +29,7 @@ export type DocumentsDataTableProps = {
}; };
export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => { export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
const { data: session } = useSession();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams(); const updateSearchParams = useUpdateSearchParams();
@@ -42,25 +43,22 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
}); });
}; };
if (!session) {
return null;
}
return ( return (
<div className="relative"> <div className="relative">
<DataTable <DataTable
columns={[ columns={[
{ {
header: 'ID', header: 'Created',
accessorKey: 'id', accessorKey: 'created',
cell: ({ row }) => <LocaleDate date={row.getValue('created')} />,
}, },
{ {
header: 'Title', header: 'Title',
cell: ({ row }) => ( cell: ({ row }) => <DataTableTitle row={row.original} />,
<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>
),
}, },
{ {
header: 'Recipient', header: 'Recipient',
@@ -74,11 +72,6 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
accessorKey: 'status', accessorKey: 'status',
cell: ({ row }) => <DocumentStatus status={row.getValue('status')} />, cell: ({ row }) => <DocumentStatus status={row.getValue('status')} />,
}, },
{
header: 'Created',
accessorKey: 'created',
cell: ({ row }) => <LocaleDate date={row.getValue('created')} />,
},
{ {
header: 'Actions', header: 'Actions',
cell: ({ row }) => ( cell: ({ row }) => (
@@ -95,7 +88,7 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
totalPages={results.totalPages} totalPages={results.totalPages}
onPaginationChange={onPaginationChange} onPaginationChange={onPaginationChange}
> >
{(table) => <DataTablePagination table={table} />} {(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable> </DataTable>
{isPending && ( {isPending && (

View File

@@ -11,8 +11,8 @@ import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-
import { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/types'; import { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
import { DocumentStatus } from '~/components/formatter/document-status'; import { DocumentStatus } from '~/components/formatter/document-status';
import { UploadDocument } from '../dashboard/upload-document';
import { DocumentsDataTable } from './data-table'; import { DocumentsDataTable } from './data-table';
import { UploadDocument } from './upload-document';
export type DocumentsPageProps = { export type DocumentsPageProps = {
searchParams?: { searchParams?: {
@@ -81,6 +81,7 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
{value !== ExtendedDocumentStatus.ALL && ( {value !== ExtendedDocumentStatus.ALL && (
<span className="ml-1 hidden opacity-50 md:inline-block"> <span className="ml-1 hidden opacity-50 md:inline-block">
{Math.min(stats[value], 99)} {Math.min(stats[value], 99)}
{stats[value] > 99 && '+'}
</span> </span>
)} )}
</Link> </Link>

View File

@@ -2,6 +2,8 @@ import { Suspense } from 'react';
import { Caveat, Inter } from 'next/font/google'; 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 { TrpcProvider } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Toaster } from '@documenso/ui/primitives/toaster'; import { Toaster } from '@documenso/ui/primitives/toaster';
@@ -45,6 +47,8 @@ export const metadata = {
export default async function RootLayout({ children }: { children: React.ReactNode }) { export default async function RootLayout({ children }: { children: React.ReactNode }) {
const flags = await getServerComponentAllFlags(); const flags = await getServerComponentAllFlags();
const locale = getLocale();
return ( return (
<html <html
lang="en" lang="en"
@@ -63,6 +67,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
</Suspense> </Suspense>
<body> <body>
<LocaleProvider locale={locale}>
<FeatureFlagProvider initialFlags={flags}> <FeatureFlagProvider initialFlags={flags}>
<PlausibleProvider> <PlausibleProvider>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem> <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
@@ -73,6 +78,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
</PlausibleProvider> </PlausibleProvider>
<Toaster /> <Toaster />
</FeatureFlagProvider> </FeatureFlagProvider>
</LocaleProvider>
</body> </body>
</html> </html>
); );

View File

@@ -15,7 +15,7 @@ export type StackAvatarProps = {
type: 'unsigned' | 'waiting' | 'opened' | 'completed'; type: 'unsigned' | 'waiting' | 'opened' | 'completed';
}; };
export const StackAvatar = ({ first, zIndex, fallbackText, type }: StackAvatarProps) => { export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAvatarProps) => {
let classes = ''; let classes = '';
let zIndexClass = ''; let zIndexClass = '';
const firstClass = first ? '' : '-ml-3'; const firstClass = first ? '' : '-ml-3';
@@ -48,7 +48,7 @@ export const StackAvatar = ({ first, zIndex, fallbackText, type }: StackAvatarPr
${firstClass} ${firstClass}
dark:border-border h-10 w-10 border-2 border-solid border-white`} 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> </Avatar>
); );
}; };

View File

@@ -1,5 +1,5 @@
import { initials } from '@documenso/lib/client-only/recipient-initials';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { Recipient } from '@documenso/prisma/client'; import { Recipient } from '@documenso/prisma/client';
import { import {
Tooltip, Tooltip,
@@ -56,7 +56,7 @@ export const StackAvatarsWithTooltip = ({
first={true} first={true}
key={recipient.id} key={recipient.id}
type={getRecipientType(recipient)} type={getRecipientType(recipient)}
fallbackText={initials(recipient.name)} fallbackText={recipientAbbreviation(recipient)}
/> />
<span className="text-sm text-gray-500">{recipient.email}</span> <span className="text-sm text-gray-500">{recipient.email}</span>
</div> </div>
@@ -73,7 +73,7 @@ export const StackAvatarsWithTooltip = ({
first={true} first={true}
key={recipient.id} key={recipient.id}
type={getRecipientType(recipient)} type={getRecipientType(recipient)}
fallbackText={initials(recipient.name)} fallbackText={recipientAbbreviation(recipient)}
/> />
<span className="text-sm text-gray-500">{recipient.email}</span> <span className="text-sm text-gray-500">{recipient.email}</span>
</div> </div>
@@ -90,7 +90,7 @@ export const StackAvatarsWithTooltip = ({
first={true} first={true}
key={recipient.id} key={recipient.id}
type={getRecipientType(recipient)} type={getRecipientType(recipient)}
fallbackText={initials(recipient.name)} fallbackText={recipientAbbreviation(recipient)}
/> />
<span className="text-sm text-gray-500">{recipient.email}</span> <span className="text-sm text-gray-500">{recipient.email}</span>
</div> </div>
@@ -107,7 +107,7 @@ export const StackAvatarsWithTooltip = ({
first={true} first={true}
key={recipient.id} key={recipient.id}
type={getRecipientType(recipient)} type={getRecipientType(recipient)}
fallbackText={initials(recipient.name)} fallbackText={recipientAbbreviation(recipient)}
/> />
<span className="text-sm text-gray-500">{recipient.email}</span> <span className="text-sm text-gray-500">{recipient.email}</span>
</div> </div>

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { initials } from '@documenso/lib/client-only/recipient-initials';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { Recipient } from '@documenso/prisma/client'; import { Recipient } from '@documenso/prisma/client';
import { StackAvatar } from './stack-avatar'; import { StackAvatar } from './stack-avatar';
@@ -26,7 +26,7 @@ export function StackAvatars({ recipients }: { recipients: Recipient[] }) {
first={first} first={first}
zIndex={String(zIndex - index * 10)} zIndex={String(zIndex - index * 10)}
type={lastItemText && index === 4 ? 'unsigned' : getRecipientType(recipient)} type={lastItemText && index === 4 ? 'unsigned' : getRecipientType(recipient)}
fallbackText={lastItemText ? lastItemText : initials(recipient.name)} fallbackText={lastItemText ? lastItemText : recipientAbbreviation(recipient)}
/> />
); );
}); });

View File

@@ -11,10 +11,13 @@ import {
Monitor, Monitor,
Moon, Moon,
Sun, Sun,
UserCog,
} from 'lucide-react'; } from 'lucide-react';
import { signOut } from 'next-auth/react'; import { signOut } from 'next-auth/react';
import { useTheme } from 'next-themes'; 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 { User } from '@documenso/prisma/client';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar'; import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@@ -35,24 +38,21 @@ export type ProfileDropdownProps = {
export const ProfileDropdown = ({ user }: ProfileDropdownProps) => { export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const { getFlag } = useFeatureFlags(); const { getFlag } = useFeatureFlags();
const isUserAdmin = isAdmin(user);
const isBillingEnabled = getFlag('app_billing'); const isBillingEnabled = getFlag('app_billing');
const initials = const avatarFallback = user.name
user.name ? recipientInitials(user.name)
?.split(' ') : user.email.slice(0, 1).toUpperCase();
.map((name: string) => name.slice(0, 1).toUpperCase())
.slice(0, 2)
.join('') ?? 'UK';
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-10 w-10 rounded-full"> <Button variant="ghost" className="relative h-10 w-10 rounded-full">
<Avatar className="h-10 w-10"> <Avatar className="h-10 w-10">
<AvatarFallback>{initials}</AvatarFallback> <AvatarFallback>{avatarFallback}</AvatarFallback>
</Avatar> </Avatar>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -60,6 +60,19 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
<DropdownMenuContent className="w-56" align="end" forceMount> <DropdownMenuContent className="w-56" align="end" forceMount>
<DropdownMenuLabel>Account</DropdownMenuLabel> <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> <DropdownMenuItem asChild>
<Link href="/settings/profile" className="cursor-pointer"> <Link href="/settings/profile" className="cursor-pointer">
<LucideUser className="mr-2 h-4 w-4" /> <LucideUser className="mr-2 h-4 w-4" />

View File

@@ -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="px-4 pb-6 pt-4 sm:px-4 sm:pb-8 sm:pt-4">
<div className="flex items-start"> <div className="flex items-center">
{Icon && <Icon className="mr-2 h-4 w-4 text-slate-500" />} {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> </div>
<p className="text-foreground mt-6 text-4xl font-semibold leading-8 md:mt-8"> <p className="text-foreground mt-6 text-4xl font-semibold leading-8 md:mt-8">

View File

@@ -2,16 +2,31 @@
import { HTMLAttributes, useEffect, useState } from 'react'; 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> & { export type LocaleDateProps = HTMLAttributes<HTMLSpanElement> & {
date: string | number | Date; 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(() => { useEffect(() => {
setLocaleDate(new Date(date).toLocaleString()); setLocaleDate(DateTime.fromJSDate(new Date(date)).toLocaleString(format));
}, [date]); }, [date, format]);
return ( return (
<span className={className} {...props}> <span className={className} {...props}>

View File

@@ -18,13 +18,15 @@ import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label'; import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
const ErrorMessages = { const ERROR_MESSAGES = {
[ErrorCode.CREDENTIALS_NOT_FOUND]: 'The email or password provided is incorrect', [ErrorCode.CREDENTIALS_NOT_FOUND]: 'The email or password provided is incorrect',
[ErrorCode.INCORRECT_EMAIL_PASSWORD]: 'The email or password provided is incorrect', [ErrorCode.INCORRECT_EMAIL_PASSWORD]: 'The email or password provided is incorrect',
[ErrorCode.USER_MISSING_PASSWORD]: [ErrorCode.USER_MISSING_PASSWORD]:
'This account appears to be using a social login method, please sign in using that method', '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({ export const ZSignInFormSchema = z.object({
email: z.string().email().min(1), email: z.string().email().min(1),
password: z.string().min(6).max(72), password: z.string().min(6).max(72),
@@ -37,9 +39,10 @@ export type SignInFormProps = {
}; };
export const SignInForm = ({ className }: SignInFormProps) => { export const SignInForm = ({ className }: SignInFormProps) => {
const { toast } = useToast();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { toast } = useToast();
const { const {
register, register,
handleSubmit, handleSubmit,
@@ -61,7 +64,7 @@ export const SignInForm = ({ className }: SignInFormProps) => {
timeout = setTimeout(() => { timeout = setTimeout(() => {
toast({ toast({
variant: 'destructive', variant: 'destructive',
description: ErrorMessages[errorCode] ?? 'An unknown error occurred', description: ERROR_MESSAGES[errorCode] ?? 'An unknown error occurred',
}); });
}, 0); }, 0);
} }
@@ -78,12 +81,10 @@ export const SignInForm = ({ className }: SignInFormProps) => {
await signIn('credentials', { await signIn('credentials', {
email, email,
password, password,
callbackUrl: '/documents', callbackUrl: LOGIN_REDIRECT_PATH,
}).catch((err) => { }).catch((err) => {
console.error(err); console.error(err);
}); });
// throw new Error('Not implemented');
} catch (err) { } catch (err) {
toast({ toast({
title: 'An unknown error occurred', title: 'An unknown error occurred',
@@ -95,8 +96,7 @@ export const SignInForm = ({ className }: SignInFormProps) => {
const onSignInWithGoogleClick = async () => { const onSignInWithGoogleClick = async () => {
try { try {
await signIn('google', { callbackUrl: '/dashboard' }); await signIn('google', { callbackUrl: LOGIN_REDIRECT_PATH });
// throw new Error('Not implemented');
} catch (err) { } catch (err) {
toast({ toast({
title: 'An unknown error occurred', title: 'An unknown error occurred',

View File

@@ -2,7 +2,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"build": "turbo run build", "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", "start": "cd apps && cd web && next start",
"lint": "turbo run lint", "lint": "turbo run lint",
"format": "prettier --write \"**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,mdx}\"", "format": "prettier --write \"**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,mdx}\"",

View 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>
);
}

View File

@@ -1,6 +0,0 @@
export const initials = (text: string) =>
text
?.split(' ')
.map((name: string) => name.slice(0, 1).toUpperCase())
.slice(0, 2)
.join('') ?? 'UK';

View File

@@ -0,0 +1,5 @@
import { Role, User } from '@documenso/prisma/client';
const isAdmin = (user: User) => user.roles.includes(Role.ADMIN);
export { isAdmin };

View 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;
};

View 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;
};

View 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,
},
},
},
});
};

View 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();
};

View File

@@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('ADMIN', 'USER');
-- AlterTable
ALTER TABLE "User" ADD COLUMN "roles" "Role"[] DEFAULT ARRAY['USER']::"Role"[];

View File

@@ -13,6 +13,11 @@ enum IdentityProvider {
GOOGLE GOOGLE
} }
enum Role {
ADMIN
USER
}
model User { model User {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String? name String?
@@ -21,6 +26,7 @@ model User {
password String? password String?
source String? source String?
signature String? signature String?
roles Role[] @default([USER])
identityProvider IdentityProvider @default(DOCUMENSO) identityProvider IdentityProvider @default(DOCUMENSO)
accounts Account[] accounts Account[]
sessions Session[] sessions Session[]

View File

@@ -1,19 +1,46 @@
import { Table } from '@tanstack/react-table'; import { Table } from '@tanstack/react-table';
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'; import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
import { match } from 'ts-pattern';
import { Button } from './button'; import { Button } from './button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select';
interface DataTablePaginationProps<TData> { interface DataTablePaginationProps<TData> {
table: Table<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 ( return (
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-4 px-2"> <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"> <div className="text-muted-foreground flex-1 text-sm">
{match(additionalInformation)
.with('SelectedCount', () => (
<span>
{table.getFilteredSelectedRowModel().rows.length} of{' '} {table.getFilteredSelectedRowModel().rows.length} of{' '}
{table.getFilteredRowModel().rows.length} row(s) selected. {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>
<div className="flex items-center gap-x-2"> <div className="flex items-center gap-x-2">

View File

@@ -102,6 +102,7 @@ export const AddFieldsFormPartial = ({
const [selectedField, setSelectedField] = useState<FieldType | null>(null); const [selectedField, setSelectedField] = useState<FieldType | null>(null);
const [selectedSigner, setSelectedSigner] = useState<Recipient | null>(null); const [selectedSigner, setSelectedSigner] = useState<Recipient | null>(null);
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
const hasSelectedSignerBeenSent = selectedSigner?.sendStatus === SendStatus.SENT; const hasSelectedSignerBeenSent = selectedSigner?.sendStatus === SendStatus.SENT;
@@ -314,7 +315,7 @@ export const AddFieldsFormPartial = ({
))} ))}
{!hideRecipients && ( {!hideRecipients && (
<Popover> <Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
type="button" type="button"
@@ -324,7 +325,7 @@ export const AddFieldsFormPartial = ({
> >
{selectedSigner?.email && ( {selectedSigner?.email && (
<span className="flex-1 truncate text-left"> <span className="flex-1 truncate text-left">
{selectedSigner?.email} ({selectedSigner?.email}) {selectedSigner?.name} ({selectedSigner?.email})
</span> </span>
)} )}
@@ -348,7 +349,10 @@ export const AddFieldsFormPartial = ({
className={cn({ className={cn({
'text-muted-foreground': recipient.sendStatus === SendStatus.SENT, 'text-muted-foreground': recipient.sendStatus === SendStatus.SENT,
})} })}
onSelect={() => setSelectedSigner(recipient)} onSelect={() => {
setSelectedSigner(recipient);
setShowRecipientsSelector(false);
}}
> >
{recipient.sendStatus !== SendStatus.SENT ? ( {recipient.sendStatus !== SendStatus.SENT ? (
<Check <Check

View File

@@ -2,13 +2,8 @@
"$schema": "https://turbo.build/schema.json", "$schema": "https://turbo.build/schema.json",
"pipeline": { "pipeline": {
"build": { "build": {
"dependsOn": [ "dependsOn": ["^build"],
"^build" "outputs": [".next/**", "!.next/cache/**"]
],
"outputs": [
".next/**",
"!.next/cache/**"
]
}, },
"lint": {}, "lint": {},
"dev": { "dev": {
@@ -16,10 +11,9 @@
"persistent": true "persistent": true
} }
}, },
"globalDependencies": [ "globalDependencies": ["**/.env.*local"],
"**/.env.*local"
],
"globalEnv": [ "globalEnv": [
"APP_VERSION",
"NEXTAUTH_URL", "NEXTAUTH_URL",
"NEXTAUTH_SECRET", "NEXTAUTH_SECRET",
"NEXT_PUBLIC_APP_URL", "NEXT_PUBLIC_APP_URL",