mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 12:32:34 +10:00
Merge branch 'feat/refresh' into feat/universal-upload
This commit is contained in:
@ -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
|
||||
|
||||
|
||||
@ -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'),
|
||||
@ -19,6 +20,9 @@ const config = {
|
||||
'@documenso/ui',
|
||||
'@documenso/email',
|
||||
],
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -21,16 +21,18 @@ export default async function BillingSettingsPage() {
|
||||
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 (!subscription?.customerId) {
|
||||
subscription = await createCustomer({ user });
|
||||
}
|
||||
// If we don't have a customer record, create one as well as an empty subscription.
|
||||
return createCustomer({ user });
|
||||
});
|
||||
|
||||
let billingPortalUrl = '';
|
||||
|
||||
if (subscription?.customerId) {
|
||||
if (subscription.customerId) {
|
||||
billingPortalUrl = await getPortalSession({
|
||||
customerId: subscription.customerId,
|
||||
returnUrl: `${process.env.NEXT_PUBLIC_SITE_URL}/settings/billing`,
|
||||
|
||||
@ -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)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { HTMLAttributes } from 'react';
|
||||
import { HTMLAttributes, useEffect, useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
@ -17,10 +17,23 @@ export type HeaderProps = HTMLAttributes<HTMLDivElement> & {
|
||||
};
|
||||
|
||||
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 (
|
||||
<header
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -1,6 +0,0 @@
|
||||
export const initials = (text: string) =>
|
||||
text
|
||||
?.split(' ')
|
||||
.map((name: string) => name.slice(0, 1).toUpperCase())
|
||||
.slice(0, 2)
|
||||
.join('') ?? 'UK';
|
||||
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[]
|
||||
|
||||
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