- {Icon &&
}
+
+ {Icon && }
-
{title}
+ {title}
diff --git a/apps/web/src/components/formatter/locale-date.tsx b/apps/web/src/components/formatter/locale-date.tsx
index 837c6aa38..ecefb1e3b 100644
--- a/apps/web/src/components/formatter/locale-date.tsx
+++ b/apps/web/src/components/formatter/locale-date.tsx
@@ -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 & {
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 (
diff --git a/apps/web/src/components/forms/signin.tsx b/apps/web/src/components/forms/signin.tsx
index 5e44146ea..d9d727afc 100644
--- a/apps/web/src/components/forms/signin.tsx
+++ b/apps/web/src/components/forms/signin.tsx
@@ -18,13 +18,15 @@ import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
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.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),
@@ -37,9 +39,10 @@ export type SignInFormProps = {
};
export const SignInForm = ({ className }: SignInFormProps) => {
- const { toast } = useToast();
const searchParams = useSearchParams();
+ const { toast } = useToast();
+
const {
register,
handleSubmit,
@@ -61,7 +64,7 @@ export const SignInForm = ({ className }: SignInFormProps) => {
timeout = setTimeout(() => {
toast({
variant: 'destructive',
- description: ErrorMessages[errorCode] ?? 'An unknown error occurred',
+ description: ERROR_MESSAGES[errorCode] ?? 'An unknown error occurred',
});
}, 0);
}
@@ -78,12 +81,10 @@ export const SignInForm = ({ className }: SignInFormProps) => {
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',
@@ -95,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',
diff --git a/packages/lib/client-only/providers/locale.tsx b/packages/lib/client-only/providers/locale.tsx
new file mode 100644
index 000000000..ff8b03e5a
--- /dev/null
+++ b/packages/lib/client-only/providers/locale.tsx
@@ -0,0 +1,37 @@
+'use client';
+
+import { createContext, useContext } from 'react';
+
+export type LocaleContextValue = {
+ locale: string;
+};
+
+export const LocaleContext = createContext(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 (
+
+ {children}
+
+ );
+}
diff --git a/packages/lib/client-only/recipient-initials.ts b/packages/lib/client-only/recipient-initials.ts
deleted file mode 100644
index 0712ccd7d..000000000
--- a/packages/lib/client-only/recipient-initials.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-export const initials = (text: string) =>
- text
- ?.split(' ')
- .map((name: string) => name.slice(0, 1).toUpperCase())
- .slice(0, 2)
- .join('') ?? 'UK';
diff --git a/packages/lib/next-auth/guards/is-admin.ts b/packages/lib/next-auth/guards/is-admin.ts
new file mode 100644
index 000000000..2801305dd
--- /dev/null
+++ b/packages/lib/next-auth/guards/is-admin.ts
@@ -0,0 +1,5 @@
+import { Role, User } from '@documenso/prisma/client';
+
+const isAdmin = (user: User) => user.roles.includes(Role.ADMIN);
+
+export { isAdmin };
diff --git a/packages/lib/server-only/admin/get-documents-stats.ts b/packages/lib/server-only/admin/get-documents-stats.ts
new file mode 100644
index 000000000..e0d53373f
--- /dev/null
+++ b/packages/lib/server-only/admin/get-documents-stats.ts
@@ -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, 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;
+};
diff --git a/packages/lib/server-only/admin/get-recipients-stats.ts b/packages/lib/server-only/admin/get-recipients-stats.ts
new file mode 100644
index 000000000..f24d0b5a2
--- /dev/null
+++ b/packages/lib/server-only/admin/get-recipients-stats.ts
@@ -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;
+};
diff --git a/packages/lib/server-only/admin/get-users-stats.ts b/packages/lib/server-only/admin/get-users-stats.ts
new file mode 100644
index 000000000..09892171a
--- /dev/null
+++ b/packages/lib/server-only/admin/get-users-stats.ts
@@ -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,
+ },
+ },
+ },
+ });
+};
diff --git a/packages/lib/utils/recipient-formatter.ts b/packages/lib/utils/recipient-formatter.ts
new file mode 100644
index 000000000..da404830b
--- /dev/null
+++ b/packages/lib/utils/recipient-formatter.ts
@@ -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();
+};
diff --git a/packages/prisma/migrations/20230907075057_user_roles/migration.sql b/packages/prisma/migrations/20230907075057_user_roles/migration.sql
new file mode 100644
index 000000000..f47e48361
--- /dev/null
+++ b/packages/prisma/migrations/20230907075057_user_roles/migration.sql
@@ -0,0 +1,5 @@
+-- CreateEnum
+CREATE TYPE "Role" AS ENUM ('ADMIN', 'USER');
+
+-- AlterTable
+ALTER TABLE "User" ADD COLUMN "roles" "Role"[] DEFAULT ARRAY['USER']::"Role"[];
diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma
index 2e016f5ec..22955310b 100644
--- a/packages/prisma/schema.prisma
+++ b/packages/prisma/schema.prisma
@@ -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[]
diff --git a/packages/ui/primitives/data-table-pagination.tsx b/packages/ui/primitives/data-table-pagination.tsx
index 0ff27ae11..8147c92fb 100644
--- a/packages/ui/primitives/data-table-pagination.tsx
+++ b/packages/ui/primitives/data-table-pagination.tsx
@@ -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 {
table: Table;
+
+ /**
+ * The type of information to show on the left hand side of the pagination.
+ *
+ * Defaults to 'VisibleCount'.
+ */
+ additionalInformation?: 'SelectedCount' | 'VisibleCount' | 'None';
}
-export function DataTablePagination({ table }: DataTablePaginationProps) {
+export function DataTablePagination({
+ table,
+ additionalInformation = 'VisibleCount',
+}: DataTablePaginationProps) {
return (
- {table.getFilteredSelectedRowModel().rows.length} of{' '}
- {table.getFilteredRowModel().rows.length} row(s) selected.
+ {match(additionalInformation)
+ .with('SelectedCount', () => (
+
+ {table.getFilteredSelectedRowModel().rows.length} of{' '}
+ {table.getFilteredRowModel().rows.length} row(s) selected.
+
+ ))
+ .with('VisibleCount', () => {
+ const visibleRows = table.getFilteredRowModel().rows.length;
+
+ return (
+
+ Showing {visibleRows} result{visibleRows > 1 && 's'}.
+
+ );
+ })
+ .with('None', () => null)
+ .exhaustive()}
diff --git a/turbo.json b/turbo.json
index f7d3d342c..6dc2735e1 100644
--- a/turbo.json
+++ b/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",