Merge branch 'feat/refresh' into feat/universal-upload

This commit is contained in:
Lucas Smith
2023-09-14 12:53:58 +10:00
committed by GitHub
22 changed files with 420 additions and 142 deletions

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

@@ -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'),
@@ -19,6 +20,9 @@ const config = {
'@documenso/ui', '@documenso/ui',
'@documenso/email', '@documenso/email',
], ],
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

@@ -21,16 +21,18 @@ export default async function BillingSettingsPage() {
redirect('/settings/profile'); redirect('/settings/profile');
} }
let subscription = await getSubscriptionByUserId({ userId: user.id }); const subscription = await getSubscriptionByUserId({ userId: user.id }).then(async (sub) => {
if (sub) {
return sub;
}
// If we don't have a customer record, create one as well as an empty subscription. // If we don't have a customer record, create one as well as an empty subscription.
if (!subscription?.customerId) { return createCustomer({ user });
subscription = await createCustomer({ user }); });
}
let billingPortalUrl = ''; let billingPortalUrl = '';
if (subscription?.customerId) { if (subscription.customerId) {
billingPortalUrl = await getPortalSession({ billingPortalUrl = await getPortalSession({
customerId: subscription.customerId, customerId: subscription.customerId,
returnUrl: `${process.env.NEXT_PUBLIC_SITE_URL}/settings/billing`, returnUrl: `${process.env.NEXT_PUBLIC_SITE_URL}/settings/billing`,

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

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { HTMLAttributes } from 'react'; import { HTMLAttributes, useEffect, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
@@ -17,10 +17,23 @@ export type HeaderProps = HTMLAttributes<HTMLDivElement> & {
}; };
export const Header = ({ className, user, ...props }: HeaderProps) => { export const Header = ({ className, user, ...props }: HeaderProps) => {
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
const onScroll = () => {
setScrollY(window.scrollY);
};
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, []);
return ( return (
<header <header
className={cn( className={cn(
'supports-backdrop-blur:bg-background/60 bg-background/95 sticky top-0 z-50 flex h-16 w-full items-center border-b backdrop-blur', 'supports-backdrop-blur:bg-background/60 bg-background/95 sticky top-0 z-50 flex h-16 w-full items-center border-b border-b-transparent backdrop-blur duration-200',
scrollY > 5 && 'border-b-border',
className, className,
)} )}
{...props} {...props}

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

@@ -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

@@ -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",