Compare commits

...

17 Commits

Author SHA1 Message Date
d599ab0630 v1.7.0-rc.2 2024-08-29 11:01:21 +10:00
9e714d607e feat: disable 2fa with backup codes (#1314)
Allow disabling two-factor authentication (2FA) by using either their
authenticator app (TOTP) or a backup code.
2024-08-29 11:00:57 +10:00
81479b5b55 v1.7.0-rc.1 2024-08-28 18:00:43 +10:00
15efc6c36d fix: broken pages by translation tags (#1312) 2024-08-28 17:58:56 +10:00
9638dfbf37 v1.7.0-rc.0 2024-08-28 14:31:30 +10:00
dfa89ffe44 fix: make invite and confirmations long lived (#1309)
Previously we would delete all invites and confirmation tokens upon
completing the action that they represent.

This change instead adds a flag on each token indicating whether it has
been completed so we can action a
completed token differently in the UI to reduce confusion for users.

This had been brought up a number of times where confirmation emails,
team member invites and other items
may have been actioned and forgotten about causing an error toast/page
upon subsequent revisit.
2024-08-28 14:08:35 +10:00
7943ed5353 chore: add translations (#1306) 2024-08-28 09:44:19 +10:00
cb50274450 fix: typo 2024-08-27 23:22:27 +09:00
04b92eac1d chore: add translations (#1305) 2024-08-27 23:37:05 +10:00
38a4b0f299 fix: dialog close on refresh (#1135)
Previously dialogs would be closed upon refocusing the browser tab due to router refetches occuring which would cause data-table columns to re-render. This is now resolved by extracting the column definitions outside of the returning render and into a memo hook.
2024-08-27 23:13:52 +10:00
75c8772a02 feat: web i18n (#1286) 2024-08-27 20:34:39 +09:00
0829311214 feat: prefill fields via api (#1261)
## Description

Configure the advanced field via API.

## Checklist

<!--- Please check the boxes that apply to this pull request. -->
<!--- You can add or remove items as needed. -->

- [x] I have tested these changes locally and they work as expected.
- [ ] I have added/updated tests that prove the effectiveness of these
changes.
- [x] I have updated the documentation to reflect these changes, if
applicable.
- [x] I have followed the project's coding style guidelines.
- [ ] I have addressed the code review feedback from the previous
submission, if applicable.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Enhanced API functionality to support field metadata during field
creation.
- Introduced validation checks for field metadata to ensure necessary
information is provided for advanced field types.

- **Bug Fixes**
- Improved error handling during field creation to return properly
formatted error responses.

- **Documentation**
- Updated API schemas to include field metadata, enhancing data
validation and response structures.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2024-08-26 12:37:56 +03:00
9223527b6f docs: fix documentation regarding the webhook secret header (#1278) 2024-08-21 14:03:21 +10:00
66fdc1d659 chore: update readme for manual self-hosting (#1270) 2024-08-21 11:08:04 +10:00
27066e2022 chore: add translations (#1295) 2024-08-21 11:06:26 +10:00
9178dbd3c1 chore: update marketing site 2024-08-20 23:23:36 +10:00
2c9136498c feat: update team email templates. (#1229)
Updates the email templates to include team information when sent
from a team context.
2024-08-20 15:41:19 +10:00
321 changed files with 16423 additions and 3138 deletions

2
.gitignore vendored
View File

@ -1,5 +1,7 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
packages/prisma/generated/types.ts
# dependencies
node_modules
.pnp

View File

@ -261,6 +261,7 @@ npm run prisma:migrate-deploy
Finally, you can start it with:
```
cd apps/web
npm run start
```

View File

@ -37,7 +37,7 @@ To create a new webhook subscription, you need to provide the following informat
- Enter the webhook URL that will receive the event payload.
- Select the event(s) you want to subscribe to: `document.created`, `document.sent`, `document.opened`, `document.signed`, `document.completed`.
- Optionally, you can provide a secret key that will be used to sign the payload. This key will be included in the `X-Documenso-Signature` header of the request.
- Optionally, you can provide a secret key that will be used to sign the payload. This key will be included in the `X-Documenso-Secret` header of the request.
![A screenshot of the Create Webhook modal that shows the URL input field and the event checkboxes](/webhook-images/webhooks-page-create-webhook-modal.webp)

View File

@ -1,6 +1,6 @@
{
"name": "@documenso/marketing",
"version": "1.6.1-rc.1",
"version": "1.7.0-rc.2",
"private": true,
"license": "AGPL-3.0",
"scripts": {
@ -20,7 +20,6 @@
"@documenso/trpc": "*",
"@documenso/ui": "*",
"@hookform/resolvers": "^3.1.0",
"@lingui/macro": "^4.11.1",
"@lingui/react": "^4.11.1",
"@openstatus/react": "^0.0.3",
"cmdk": "^0.2.1",

View File

@ -44,6 +44,10 @@ export default async function OSSFriendsPage() {
src={backgroundPattern}
alt="background pattern"
className="-mr-[15vw] -mt-[15vh] h-full max-h-[150vh] scale-125 object-cover dark:contrast-[70%] dark:invert dark:sepia md:-mr-[50vw] md:scale-150 lg:scale-[175%]"
style={{
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
}}
/>
</div>
</div>

View File

@ -5,6 +5,8 @@ import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { msg } from '@lingui/macro';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { base64 } from '@documenso/lib/universal/base64';
@ -46,8 +48,8 @@ export const SinglePlayerClient = () => {
const documentFlow: Record<SinglePlayerModeStep, DocumentFlowStep> = {
fields: {
title: 'Add document',
description: 'Upload a document and add fields.',
title: msg`Add document`,
description: msg`Upload a document and add fields.`,
stepIndex: 1,
onBackStep: uploadedFile
? () => {
@ -58,8 +60,8 @@ export const SinglePlayerClient = () => {
onNextStep: () => setStep('sign'),
},
sign: {
title: 'Sign',
description: 'Enter your details.',
title: msg`Sign`,
description: msg`Enter your details.`,
stepIndex: 2,
onBackStep: () => setStep('fields'),
},

View File

@ -26,6 +26,10 @@ export default function NotFound() {
src={backgroundPattern}
alt="background pattern"
className="-mr-[50vw] -mt-[15vh] h-full scale-100 object-cover dark:contrast-[70%] dark:invert dark:sepia md:scale-100 lg:scale-[100%]"
style={{
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
}}
priority
/>
</motion.div>

View File

@ -2,11 +2,14 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import type { AutoplayType } from 'embla-carousel-autoplay';
import Autoplay from 'embla-carousel-autoplay';
import useEmblaCarousel from 'embla-carousel-react';
import { usePlausible } from 'next-plausible';
import { useTheme } from 'next-themes';
import { Card } from '@documenso/ui/primitives/card';
@ -61,6 +64,7 @@ const SLIDES = [
export const Carousel = () => {
const { _ } = useLingui();
const event = usePlausible();
const slides = SLIDES;
const [_isPlaying, setIsPlaying] = useState(false);
@ -238,7 +242,10 @@ export const Carousel = () => {
if (!mounted) return null;
return (
<>
<Card className="mx-auto mt-12 w-full max-w-4xl rounded-2xl p-1 before:rounded-2xl" gradient>
<Card
className="relative mx-auto mt-12 w-full max-w-4xl rounded-2xl p-1 before:rounded-2xl"
gradient
>
<div className="overflow-hidden rounded-xl" ref={emblaRef}>
<div className="flex touch-pan-y rounded-xl">
{slides.map((slide, index) => (
@ -269,6 +276,19 @@ export const Carousel = () => {
</span>
<Progress value={progress} className="h-1" />
</div>
<Link
href="https://documen.so/book-a-demo"
className="bg-foreground/70 dark:bg-foreground/80 absolute inset-0 hidden flex-col items-center justify-center gap-y-2 rounded-xl opacity-0 backdrop-blur-[2px] transition-opacity group-hover:opacity-100 md:flex"
onClick={() => event('view-demo')}
>
<span className="text-background max-w-[60ch] text-2xl font-semibold">Book a Demo</span>
<span className="text-background max-w-[60ch] text-center text-sm">
Want to learn more about Documenso and how it works? Book a demo today! Our founders
will walk you through the application and answer any questions you may have regarding
usage, integration, and more.
</span>
</Link>
</Card>
<div className="mx-auto mt-6 w-full max-w-4xl px-2 sm:mt-12">

View File

@ -24,6 +24,10 @@ export const FasterSmarterBeautifulBento = ({
src={backgroundPattern}
alt="background pattern"
className="h-full scale-125 object-cover dark:contrast-[70%] dark:invert dark:sepia md:scale-150 lg:scale-[175%]"
style={{
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
}}
/>
</div>
<h2 className="px-0 text-[22px] font-semibold md:px-12 md:text-4xl lg:px-24">

View File

@ -86,6 +86,10 @@ export const Hero = ({ className, ...props }: HeroProps) => {
src={backgroundPattern}
alt="background pattern"
className="-mr-[50vw] -mt-[15vh] h-full scale-125 object-cover dark:contrast-[70%] dark:invert dark:sepia md:scale-150 lg:scale-[175%]"
style={{
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
}}
/>
</motion.div>
</div>

View File

@ -21,6 +21,10 @@ export const OpenBuildTemplateBento = ({ className, ...props }: OpenBuildTemplat
src={backgroundPattern}
alt="background pattern"
className="h-full scale-125 object-cover dark:contrast-[70%] dark:invert dark:sepia md:scale-150 lg:scale-[175%]"
style={{
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
}}
/>
</div>
<h2 className="px-0 text-[22px] font-semibold md:px-12 md:text-4xl lg:px-24">

View File

@ -25,6 +25,10 @@ export const ShareConnectPaidWidgetBento = ({
src={backgroundPattern}
alt="background pattern"
className="h-full scale-125 object-cover dark:contrast-[70%] dark:invert dark:sepia md:scale-150 lg:scale-[175%]"
style={{
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
}}
/>
</div>
<h2 className="px-0 text-[22px] font-semibold md:px-12 md:text-4xl lg:px-24">
@ -39,7 +43,7 @@ export const ShareConnectPaidWidgetBento = ({
<CardContent className="grid grid-cols-1 gap-8 p-6">
<p className="text-foreground/80 leading-relaxed">
<strong className="block">
<Trans>Easy Sharing (Soon).</Trans>
<Trans>Easy Sharing.</Trans>
</strong>
<Trans>Receive your personal link to share with everyone you care about.</Trans>
</p>

View File

@ -8,7 +8,7 @@ const config: LinguiConfig = {
locales: APP_I18N_OPTIONS.supportedLangs as unknown as string[],
catalogs: [
{
path: '<rootDir>/../../packages/lib/translations/web/{locale}',
path: '<rootDir>/../../packages/lib/translations/{locale}/web',
include: ['<rootDir>/apps/web/src'],
},
{

View File

@ -1,6 +1,6 @@
{
"name": "@documenso/web",
"version": "1.6.1-rc.1",
"version": "1.7.0-rc.2",
"private": true,
"license": "AGPL-3.0",
"scripts": {
@ -23,7 +23,6 @@
"@documenso/trpc": "*",
"@documenso/ui": "*",
"@hookform/resolvers": "^3.1.0",
"@lingui/macro": "^4.11.1",
"@lingui/react": "^4.11.1",
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.3",
@ -60,9 +59,9 @@
"zod": "^3.22.4"
},
"devDependencies": {
"@documenso/tailwind-config": "*",
"@lingui/loader": "^4.11.1",
"@lingui/swc-plugin": "4.0.6",
"@documenso/tailwind-config": "*",
"@simplewebauthn/types": "^9.0.1",
"@types/formidable": "^2.0.6",
"@types/luxon": "^3.3.1",

View File

@ -2,6 +2,9 @@
import Link from 'next/link';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import type { Recipient } from '@documenso/prisma/client';
import { type Document, SigningStatus } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
@ -22,20 +25,21 @@ export type AdminActionsProps = {
};
export const AdminActions = ({ className, document, recipients }: AdminActionsProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { mutate: resealDocument, isLoading: isResealDocumentLoading } =
trpc.admin.resealDocument.useMutation({
onSuccess: () => {
toast({
title: 'Success',
description: 'Document resealed',
title: _(msg`Success`),
description: _(msg`Document resealed`),
});
},
onError: () => {
toast({
title: 'Error',
description: 'Failed to reseal document',
title: _(msg`Error`),
description: _(msg`Failed to reseal document`),
variant: 'destructive',
});
},
@ -54,19 +58,23 @@ export const AdminActions = ({ className, document, recipients }: AdminActionsPr
)}
onClick={() => resealDocument({ id: document.id })}
>
Reseal document
<Trans>Reseal document</Trans>
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-[40ch]">
Attempts sealing the document again, useful for after a code change has occurred to
resolve an erroneous document.
<Trans>
Attempts sealing the document again, useful for after a code change has occurred to
resolve an erroneous document.
</Trans>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button variant="outline" asChild>
<Link href={`/admin/users/${document.userId}`}>Go to owner</Link>
<Link href={`/admin/users/${document.userId}`}>
<Trans>Go to owner</Trans>
</Link>
</Button>
</div>
);

View File

@ -1,5 +1,7 @@
import { Trans } from '@lingui/macro';
import { DateTime } from 'luxon';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
import {
Accordion,
@ -23,6 +25,8 @@ type AdminDocumentDetailsPageProps = {
};
export default async function AdminDocumentDetailsPage({ params }: AdminDocumentDetailsPageProps) {
setupI18nSSR();
const document = await getEntireDocument({ id: Number(params.id) });
return (
@ -35,28 +39,34 @@ export default async function AdminDocumentDetailsPage({ params }: AdminDocument
{document.deletedAt && (
<Badge size="large" variant="destructive">
Deleted
<Trans>Deleted</Trans>
</Badge>
)}
</div>
<div className="text-muted-foreground mt-4 text-sm">
<div>
Created on: <LocaleDate date={document.createdAt} format={DateTime.DATETIME_MED} />
<Trans>Created on</Trans>:{' '}
<LocaleDate date={document.createdAt} format={DateTime.DATETIME_MED} />
</div>
<div>
Last updated at: <LocaleDate date={document.updatedAt} format={DateTime.DATETIME_MED} />
<Trans>Last updated at</Trans>:{' '}
<LocaleDate date={document.updatedAt} format={DateTime.DATETIME_MED} />
</div>
</div>
<hr className="my-4" />
<h2 className="text-lg font-semibold">Admin Actions</h2>
<h2 className="text-lg font-semibold">
<Trans>Admin Actions</Trans>
</h2>
<AdminActions className="mt-2" document={document} recipients={document.Recipient} />
<hr className="my-4" />
<h2 className="text-lg font-semibold">Recipients</h2>
<h2 className="text-lg font-semibold">
<Trans>Recipients</Trans>
</h2>
<div className="mt-4">
<Accordion type="multiple" className="space-y-4">

View File

@ -1,7 +1,11 @@
'use client';
import { useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
@ -13,6 +17,7 @@ import {
} from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import {
Form,
@ -43,7 +48,9 @@ export type RecipientItemProps = {
};
export const RecipientItem = ({ recipient }: RecipientItemProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const router = useRouter();
const form = useForm<TAdminUpdateRecipientFormSchema>({
@ -55,6 +62,50 @@ export const RecipientItem = ({ recipient }: RecipientItemProps) => {
const { mutateAsync: updateRecipient } = trpc.admin.updateRecipient.useMutation();
const columns = useMemo(() => {
return [
{
header: 'ID',
accessorKey: 'id',
cell: ({ row }) => <div>{row.original.id}</div>,
},
{
header: _(msg`Type`),
accessorKey: 'type',
cell: ({ row }) => <div>{row.original.type}</div>,
},
{
header: _(msg`Inserted`),
accessorKey: 'inserted',
cell: ({ row }) => <div>{row.original.inserted ? 'True' : 'False'}</div>,
},
{
header: _(msg`Value`),
accessorKey: 'customText',
cell: ({ row }) => <div>{row.original.customText}</div>,
},
{
header: _(msg`Signature`),
accessorKey: 'signature',
cell: ({ row }) => (
<div>
{row.original.Signature?.typedSignature && (
<span>{row.original.Signature.typedSignature}</span>
)}
{row.original.Signature?.signatureImageAsBase64 && (
<img
src={row.original.Signature.signatureImageAsBase64}
alt="Signature"
className="h-12 w-full dark:invert"
/>
)}
</div>
),
},
] satisfies DataTableColumnDef<(typeof recipient)['Field'][number]>[];
}, []);
const onUpdateRecipientFormSubmit = async ({ name, email }: TAdminUpdateRecipientFormSchema) => {
try {
await updateRecipient({
@ -64,14 +115,14 @@ export const RecipientItem = ({ recipient }: RecipientItemProps) => {
});
toast({
title: 'Recipient updated',
description: 'The recipient has been updated successfully',
title: _(msg`Recipient updated`),
description: _(msg`The recipient has been updated successfully`),
});
router.refresh();
} catch (error) {
toast({
title: 'Failed to update recipient',
title: _(msg`Failed to update recipient`),
description: error.message,
variant: 'destructive',
});
@ -93,7 +144,9 @@ export const RecipientItem = ({ recipient }: RecipientItemProps) => {
name="name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel required>Name</FormLabel>
<FormLabel required>
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
@ -109,7 +162,9 @@ export const RecipientItem = ({ recipient }: RecipientItemProps) => {
name="email"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel required>Email</FormLabel>
<FormLabel required>
<Trans>Email</Trans>
</FormLabel>
<FormControl>
<Input type="email" {...field} />
@ -122,7 +177,7 @@ export const RecipientItem = ({ recipient }: RecipientItemProps) => {
<div>
<Button type="submit" loading={form.formState.isSubmitting}>
Update Recipient
<Trans>Update Recipient</Trans>
</Button>
</div>
</fieldset>
@ -131,52 +186,11 @@ export const RecipientItem = ({ recipient }: RecipientItemProps) => {
<hr className="my-4" />
<h2 className="mb-4 text-lg font-semibold">Fields</h2>
<h2 className="mb-4 text-lg font-semibold">
<Trans>Fields</Trans>
</h2>
<DataTable
data={recipient.Field}
columns={[
{
header: 'ID',
accessorKey: 'id',
cell: ({ row }) => <div>{row.original.id}</div>,
},
{
header: 'Type',
accessorKey: 'type',
cell: ({ row }) => <div>{row.original.type}</div>,
},
{
header: 'Inserted',
accessorKey: 'inserted',
cell: ({ row }) => <div>{row.original.inserted ? 'True' : 'False'}</div>,
},
{
header: 'Value',
accessorKey: 'customText',
cell: ({ row }) => <div>{row.original.customText}</div>,
},
{
header: 'Signature',
accessorKey: 'signature',
cell: ({ row }) => (
<div>
{row.original.Signature?.typedSignature && (
<span>{row.original.Signature.typedSignature}</span>
)}
{row.original.Signature?.signatureImageAsBase64 && (
<img
src={row.original.Signature.signatureImageAsBase64}
alt="Signature"
className="h-12 w-full dark:invert"
/>
)}
</div>
),
},
]}
/>
<DataTable columns={columns} data={recipient.Field} />
</div>
);
};

View File

@ -4,6 +4,9 @@ import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import type { Document } from '@documenso/prisma/client';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
@ -26,7 +29,9 @@ export type SuperDeleteDocumentDialogProps = {
};
export const SuperDeleteDocumentDialog = ({ document }: SuperDeleteDocumentDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const router = useRouter();
const [reason, setReason] = useState('');
@ -43,7 +48,7 @@ export const SuperDeleteDocumentDialog = ({ document }: SuperDeleteDocumentDialo
await deleteDocument({ id: document.id, reason });
toast({
title: 'Document deleted',
title: _(msg`Document deleted`),
description: 'The Document has been deleted successfully.',
duration: 5000,
});
@ -52,13 +57,13 @@ export const SuperDeleteDocumentDialog = ({ document }: SuperDeleteDocumentDialo
} catch (err) {
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
toast({
title: 'An error occurred',
title: _(msg`An error occurred`),
description: err.message,
variant: 'destructive',
});
} else {
toast({
title: 'An unknown error occurred',
title: _(msg`An unknown error occurred`),
variant: 'destructive',
description:
err.message ??
@ -76,31 +81,41 @@ export const SuperDeleteDocumentDialog = ({ document }: SuperDeleteDocumentDialo
variant="neutral"
>
<div>
<AlertTitle>Delete Document</AlertTitle>
<AlertTitle>
<Trans>Delete Document</Trans>
</AlertTitle>
<AlertDescription className="mr-2">
Delete the document. This action is irreversible so proceed with caution.
<Trans>
Delete the document. This action is irreversible so proceed with caution.
</Trans>
</AlertDescription>
</div>
<div className="flex-shrink-0">
<Dialog>
<DialogTrigger asChild>
<Button variant="destructive">Delete Document</Button>
<Button variant="destructive">
<Trans>Delete Document</Trans>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader className="space-y-4">
<DialogTitle>Delete Document</DialogTitle>
<DialogTitle>
<Trans>Delete Document</Trans>
</DialogTitle>
<Alert variant="destructive">
<AlertDescription className="selection:bg-red-100">
This action is not reversible. Please be certain.
<Trans>This action is not reversible. Please be certain.</Trans>
</AlertDescription>
</Alert>
</DialogHeader>
<div>
<DialogDescription>To confirm, please enter the reason</DialogDescription>
<DialogDescription>
<Trans>To confirm, please enter the reason</Trans>
</DialogDescription>
<Input
className="mt-2"
@ -117,7 +132,7 @@ export const SuperDeleteDocumentDialog = ({ document }: SuperDeleteDocumentDialo
variant="destructive"
disabled={!reason}
>
{isDeletingDocument ? 'Deleting document...' : 'Delete Document'}
<Trans>Delete document</Trans>
</Button>
</DialogFooter>
</DialogContent>

View File

@ -1,10 +1,12 @@
'use client';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { Loader } from 'lucide-react';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
@ -12,6 +14,7 @@ import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-upda
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { trpc } from '@documenso/trpc/react';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { Input } from '@documenso/ui/primitives/input';
@ -23,6 +26,8 @@ import { LocaleDate } from '~/components/formatter/locale-date';
// export type AdminDocumentResultsProps = {};
export const AdminDocumentResults = () => {
const { _ } = useLingui();
const searchParams = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
@ -45,6 +50,83 @@ export const AdminDocumentResults = () => {
},
);
const results = findDocumentsData ?? {
data: [],
perPage: 20,
currentPage: 1,
totalPages: 1,
};
const columns = useMemo(() => {
return [
{
header: _(msg`Created`),
accessorKey: 'createdAt',
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
},
{
header: _(msg`Title`),
accessorKey: 'title',
cell: ({ row }) => {
return (
<Link
href={`/admin/documents/${row.original.id}`}
className="block max-w-[5rem] truncate font-medium hover:underline md:max-w-[10rem]"
>
{row.original.title}
</Link>
);
},
},
{
header: _(msg`Status`),
accessorKey: 'status',
cell: ({ row }) => <DocumentStatus status={row.original.status} />,
},
{
header: _(msg`Owner`),
accessorKey: 'owner',
cell: ({ row }) => {
const avatarFallbackText = row.original.User.name
? extractInitials(row.original.User.name)
: row.original.User.email.slice(0, 1).toUpperCase();
return (
<Tooltip delayDuration={200}>
<TooltipTrigger>
<Link href={`/admin/users/${row.original.User.id}`}>
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
<AvatarFallback className="text-xs text-gray-400">
{avatarFallbackText}
</AvatarFallback>
</Avatar>
</Link>
</TooltipTrigger>
<TooltipContent className="flex max-w-xs items-center gap-2">
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
<AvatarFallback className="text-xs text-gray-400">
{avatarFallbackText}
</AvatarFallback>
</Avatar>
<div className="text-muted-foreground flex flex-col text-sm">
<span>{row.original.User.name}</span>
<span>{row.original.User.email}</span>
</div>
</TooltipContent>
</Tooltip>
);
},
},
{
header: 'Last updated',
accessorKey: 'updatedAt',
cell: ({ row }) => <LocaleDate date={row.original.updatedAt} />,
},
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
}, []);
const onPaginationChange = (newPage: number, newPerPage: number) => {
updateSearchParams({
page: newPage,
@ -56,84 +138,18 @@ export const AdminDocumentResults = () => {
<div>
<Input
type="search"
placeholder="Search by document title"
placeholder={_(msg`Search by document title`)}
value={term}
onChange={(e) => setTerm(e.target.value)}
/>
<div className="relative mt-4">
<DataTable
columns={[
{
header: 'Created',
accessorKey: 'createdAt',
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
},
{
header: 'Title',
accessorKey: 'title',
cell: ({ row }) => {
return (
<Link
href={`/admin/documents/${row.original.id}`}
className="block max-w-[5rem] truncate font-medium hover:underline md:max-w-[10rem]"
>
{row.original.title}
</Link>
);
},
},
{
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => <DocumentStatus status={row.original.status} />,
},
{
header: 'Owner',
accessorKey: 'owner',
cell: ({ row }) => {
const avatarFallbackText = row.original.User.name
? extractInitials(row.original.User.name)
: row.original.User.email.slice(0, 1).toUpperCase();
return (
<Tooltip delayDuration={200}>
<TooltipTrigger>
<Link href={`/admin/users/${row.original.User.id}`}>
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
<AvatarFallback className="text-xs text-gray-400">
{avatarFallbackText}
</AvatarFallback>
</Avatar>
</Link>
</TooltipTrigger>
<TooltipContent className="flex max-w-xs items-center gap-2">
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
<AvatarFallback className="text-xs text-gray-400">
{avatarFallbackText}
</AvatarFallback>
</Avatar>
<div className="text-muted-foreground flex flex-col text-sm">
<span>{row.original.User.name}</span>
<span>{row.original.User.email}</span>
</div>
</TooltipContent>
</Tooltip>
);
},
},
{
header: 'Last updated',
accessorKey: 'updatedAt',
cell: ({ row }) => <LocaleDate date={row.original.updatedAt} />,
},
]}
data={findDocumentsData?.data ?? []}
perPage={findDocumentsData?.perPage ?? 20}
currentPage={findDocumentsData?.currentPage ?? 1}
totalPages={findDocumentsData?.totalPages ?? 1}
columns={columns}
data={results.data}
perPage={results.perPage ?? 20}
currentPage={results.currentPage ?? 1}
totalPages={results.totalPages ?? 1}
onPaginationChange={onPaginationChange}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}

View File

@ -1,9 +1,17 @@
import { Trans } from '@lingui/macro';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { AdminDocumentResults } from './document-results';
export default function AdminDocumentsPage() {
setupI18nSSR();
return (
<div>
<h2 className="text-4xl font-semibold">Manage documents</h2>
<h2 className="text-4xl font-semibold">
<Trans>Manage documents</Trans>
</h2>
<div className="mt-8">
<AdminDocumentResults />

View File

@ -2,6 +2,7 @@ import React from 'react';
import { redirect } from 'next/navigation';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
@ -12,6 +13,8 @@ export type AdminSectionLayoutProps = {
};
export default async function AdminSectionLayout({ children }: AdminSectionLayoutProps) {
setupI18nSSR();
const { user } = await getRequiredServerComponentSession();
if (!isAdmin(user)) {

View File

@ -5,6 +5,7 @@ import type { HTMLAttributes } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Trans } from '@lingui/macro';
import { BarChart3, FileStack, Settings, Users, Wallet2 } from 'lucide-react';
import { cn } from '@documenso/ui/lib/utils';
@ -33,7 +34,7 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => {
>
<Link href="/admin/stats">
<BarChart3 className="mr-2 h-5 w-5" />
Stats
<Trans>Stats</Trans>
</Link>
</Button>
@ -47,7 +48,7 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => {
>
<Link href="/admin/users">
<Users className="mr-2 h-5 w-5" />
Users
<Trans>Users</Trans>
</Link>
</Button>
@ -61,7 +62,7 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => {
>
<Link href="/admin/documents">
<FileStack className="mr-2 h-5 w-5" />
Documents
<Trans>Documents</Trans>
</Link>
</Button>
@ -75,7 +76,7 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => {
>
<Link href="/admin/subscriptions">
<Wallet2 className="mr-2 h-5 w-5" />
Subscriptions
<Trans>Subscriptions</Trans>
</Link>
</Button>
@ -89,7 +90,7 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => {
>
<Link href="/admin/site-settings">
<Settings className="mr-2 h-5 w-5" />
Site Settings
<Trans>Site Settings</Trans>
</Link>
</Button>
</div>

View File

@ -3,6 +3,8 @@
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { useForm } from 'react-hook-form';
import type { z } from 'zod';
@ -37,8 +39,10 @@ export type BannerFormProps = {
};
export function BannerForm({ banner }: BannerFormProps) {
const router = useRouter();
const { toast } = useToast();
const { _ } = useLingui();
const router = useRouter();
const form = useForm<TBannerFormSchema>({
resolver: zodResolver(ZBannerFormSchema),
@ -67,8 +71,8 @@ export function BannerForm({ banner }: BannerFormProps) {
});
toast({
title: 'Banner Updated',
description: 'Your banner has been updated successfully.',
title: _(msg`Banner Updated`),
description: _(msg`Your banner has been updated successfully.`),
duration: 5000,
});
@ -76,16 +80,17 @@ export function BannerForm({ banner }: BannerFormProps) {
} catch (err) {
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
toast({
title: 'An error occurred',
title: _(msg`An error occurred`),
description: err.message,
variant: 'destructive',
});
} else {
toast({
title: 'An unknown error occurred',
title: _(msg`An unknown error occurred`),
variant: 'destructive',
description:
'We encountered an unknown error while attempting to update the banner. Please try again later.',
description: _(
msg`We encountered an unknown error while attempting to update the banner. Please try again later.`,
),
});
}
}
@ -93,10 +98,14 @@ export function BannerForm({ banner }: BannerFormProps) {
return (
<div>
<h2 className="font-semibold">Site Banner</h2>
<h2 className="font-semibold">
<Trans>Site Banner</Trans>
</h2>
<p className="text-muted-foreground mt-2 text-sm">
The site banner is a message that is shown at the top of the site. It can be used to display
important information to your users.
<Trans>
The site banner is a message that is shown at the top of the site. It can be used to
display important information to your users.
</Trans>
</p>
<Form {...form}>
@ -110,7 +119,9 @@ export function BannerForm({ banner }: BannerFormProps) {
name="enabled"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Enabled</FormLabel>
<FormLabel>
<Trans>Enabled</Trans>
</FormLabel>
<FormControl>
<div>
@ -131,7 +142,9 @@ export function BannerForm({ banner }: BannerFormProps) {
name="data.bgColor"
render={({ field }) => (
<FormItem>
<FormLabel>Background Color</FormLabel>
<FormLabel>
<Trans>Background Color</Trans>
</FormLabel>
<FormControl>
<div>
@ -149,7 +162,9 @@ export function BannerForm({ banner }: BannerFormProps) {
name="data.textColor"
render={({ field }) => (
<FormItem>
<FormLabel>Text Color</FormLabel>
<FormLabel>
<Trans>Text Color</Trans>
</FormLabel>
<FormControl>
<div>
@ -170,14 +185,16 @@ export function BannerForm({ banner }: BannerFormProps) {
name="data.content"
render={({ field }) => (
<FormItem>
<FormLabel>Content</FormLabel>
<FormLabel>
<Trans>Content</Trans>
</FormLabel>
<FormControl>
<Textarea className="h-32 resize-none" {...field} />
</FormControl>
<FormDescription>
The content to show in the banner, HTML is allowed
<Trans>The content to show in the banner, HTML is allowed</Trans>
</FormDescription>
<FormMessage />
@ -191,7 +208,7 @@ export function BannerForm({ banner }: BannerFormProps) {
loading={isUpdateSiteSettingLoading}
className="mt-4 justify-end self-end"
>
Update Banner
<Trans>Update Banner</Trans>
</Button>
</form>
</Form>

View File

@ -1,3 +1,7 @@
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
import { SITE_SETTINGS_BANNER_ID } from '@documenso/lib/server-only/site-settings/schemas/banner';
@ -8,13 +12,20 @@ import { BannerForm } from './banner-form';
// import { BannerForm } from './banner-form';
export default async function AdminBannerPage() {
setupI18nSSR();
const { _ } = useLingui();
const banner = await getSiteSettings().then((settings) =>
settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
);
return (
<div>
<SettingsHeader title="Site Settings" subtitle="Manage your site settings here" />
<SettingsHeader
title={_(msg`Site Settings`)}
subtitle={_(msg`Manage your site settings here`)}
/>
<div className="mt-8">
<BannerForm banner={banner} />

View File

@ -1,3 +1,5 @@
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import {
File,
FileCheck,
@ -12,6 +14,7 @@ import {
Users,
} from 'lucide-react';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { getDocumentStats } from '@documenso/lib/server-only/admin/get-documents-stats';
import { getRecipientsStats } from '@documenso/lib/server-only/admin/get-recipients-stats';
import {
@ -27,6 +30,10 @@ import { SignerConversionChart } from './signer-conversion-chart';
import { UserWithDocumentChart } from './user-with-document';
export default async function AdminStatsPage() {
setupI18nSSR();
const { _ } = useLingui();
const [
usersCount,
usersWithSubscriptionsCount,
@ -49,64 +56,98 @@ export default async function AdminStatsPage() {
return (
<div>
<h2 className="text-4xl font-semibold">Instance Stats</h2>
<h2 className="text-4xl font-semibold">
<Trans>Instance Stats</Trans>
</h2>
<div className="mt-8 grid flex-1 grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<CardMetric icon={Users} title="Total Users" value={usersCount} />
<CardMetric icon={File} title="Total Documents" value={docStats.ALL} />
<CardMetric icon={Users} title={_(msg`Total Users`)} value={usersCount} />
<CardMetric icon={File} title={_(msg`Total Documents`)} value={docStats.ALL} />
<CardMetric
icon={UserPlus}
title="Active Subscriptions"
title={_(msg`Active Subscriptions`)}
value={usersWithSubscriptionsCount}
/>
<CardMetric icon={FileCog} title="App Version" value={`v${process.env.APP_VERSION}`} />
<CardMetric
icon={FileCog}
title={_(msg`App Version`)}
value={`v${process.env.APP_VERSION}`}
/>
</div>
<div className="mt-16 gap-8">
<div>
<h3 className="text-3xl font-semibold">Document metrics</h3>
<h3 className="text-3xl font-semibold">
<Trans>Document metrics</Trans>
</h3>
<div className="mb-8 mt-4 grid flex-1 grid-cols-1 gap-4 md:grid-cols-2">
<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} />
<CardMetric icon={FileEdit} title={_(msg`Drafted Documents`)} value={docStats.DRAFT} />
<CardMetric
icon={FileClock}
title={_(msg`Pending Documents`)}
value={docStats.PENDING}
/>
<CardMetric
icon={FileCheck}
title={_(msg`Completed Documents`)}
value={docStats.COMPLETED}
/>
</div>
</div>
<div>
<h3 className="text-3xl font-semibold">Recipients metrics</h3>
<h3 className="text-3xl font-semibold">
<Trans>Recipients metrics</Trans>
</h3>
<div className="mb-8 mt-4 grid flex-1 grid-cols-1 gap-4 md:grid-cols-2">
<CardMetric
icon={UserSquare2}
title="Total Recipients"
title={_(msg`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} />
<CardMetric
icon={Mail}
title={_(msg`Documents Received`)}
value={recipientStats.SENT}
/>
<CardMetric
icon={MailOpen}
title={_(msg`Documents Viewed`)}
value={recipientStats.OPENED}
/>
<CardMetric
icon={PenTool}
title={_(msg`Signatures Collected`)}
value={recipientStats.SIGNED}
/>
</div>
</div>
</div>
<div className="mt-16">
<h3 className="text-3xl font-semibold">Charts</h3>
<h3 className="text-3xl font-semibold">
<Trans>Charts</Trans>
</h3>
<div className="mt-5 grid grid-cols-2 gap-8">
<UserWithDocumentChart
data={MONTHLY_USERS_SIGNED}
title="MAU (created document)"
tooltip="Monthly Active Users: Users that created at least one Document"
title={_(msg`MAU (created document)`)}
tooltip={_(msg`Monthly Active Users: Users that created at least one Document`)}
/>
<UserWithDocumentChart
data={MONTHLY_USERS_SIGNED}
completed
title="MAU (had document completed)"
tooltip="Monthly Active Users: Users that had at least one of their documents completed"
title={_(msg`MAU (had document completed)`)}
tooltip={_(
msg`Monthly Active Users: Users that had at least one of their documents completed`,
)}
/>
<SignerConversionChart title="Signers that Signed Up" data={signerConversionMonthly} />
<SignerConversionChart
title="Total Signers that Signed Up"
title={_(msg`Total Signers that Signed Up`)}
data={signerConversionMonthly}
cummulative
/>

View File

@ -1,5 +1,8 @@
import Link from 'next/link';
import { Trans } from '@lingui/macro';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { findSubscriptions } from '@documenso/lib/server-only/admin/get-all-subscriptions';
import {
Table,
@ -11,20 +14,32 @@ import {
} from '@documenso/ui/primitives/table';
export default async function Subscriptions() {
setupI18nSSR();
const subscriptions = await findSubscriptions();
return (
<div>
<h2 className="text-4xl font-semibold">Manage subscriptions</h2>
<h2 className="text-4xl font-semibold">
<Trans>Manage subscriptions</Trans>
</h2>
<div className="mt-8">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created At</TableHead>
<TableHead>Ends On</TableHead>
<TableHead>User ID</TableHead>
<TableHead>
<Trans>Status</Trans>
</TableHead>
<TableHead>
<Trans>Created At</Trans>
</TableHead>
<TableHead>
<Trans>Ends On</Trans>
</TableHead>
<TableHead>
<Trans>User ID</Trans>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>

View File

@ -4,6 +4,9 @@ import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import type { User } from '@documenso/prisma/client';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
@ -27,8 +30,10 @@ export type DeleteUserDialogProps = {
};
export const DeleteUserDialog = ({ className, user }: DeleteUserDialogProps) => {
const router = useRouter();
const { toast } = useToast();
const { _ } = useLingui();
const router = useRouter();
const [email, setEmail] = useState('');
@ -43,8 +48,8 @@ export const DeleteUserDialog = ({ className, user }: DeleteUserDialogProps) =>
});
toast({
title: 'Account deleted',
description: 'The account has been deleted successfully.',
title: _(msg`Account deleted`),
description: _(msg`The account has been deleted successfully.`),
duration: 5000,
});
@ -52,17 +57,19 @@ export const DeleteUserDialog = ({ className, user }: DeleteUserDialogProps) =>
} catch (err) {
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
toast({
title: 'An error occurred',
title: _(msg`An error occurred`),
description: err.message,
variant: 'destructive',
});
} else {
toast({
title: 'An unknown error occurred',
title: _(msg`An unknown error occurred`),
variant: 'destructive',
description:
err.message ??
'We encountered an unknown error while attempting to delete your account. Please try again later.',
_(
msg`We encountered an unknown error while attempting to delete your account. Please try again later.`,
),
});
}
}
@ -77,31 +84,39 @@ export const DeleteUserDialog = ({ className, user }: DeleteUserDialogProps) =>
<div>
<AlertTitle>Delete Account</AlertTitle>
<AlertDescription className="mr-2">
Delete the users account and all its contents. This action is irreversible and will
cancel their subscription, so proceed with caution.
<Trans>
Delete the users account and all its contents. This action is irreversible and will
cancel their subscription, so proceed with caution.
</Trans>
</AlertDescription>
</div>
<div className="flex-shrink-0">
<Dialog>
<DialogTrigger asChild>
<Button variant="destructive">Delete Account</Button>
<Button variant="destructive">
<Trans>Delete Account</Trans>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader className="space-y-4">
<DialogTitle>Delete Account</DialogTitle>
<DialogTitle>
<Trans>Delete Account</Trans>
</DialogTitle>
<Alert variant="destructive">
<AlertDescription className="selection:bg-red-100">
This action is not reversible. Please be certain.
<Trans>This action is not reversible. Please be certain.</Trans>
</AlertDescription>
</Alert>
</DialogHeader>
<div>
<DialogDescription>
To confirm, please enter the accounts email address <br />({user.email}).
<Trans>
To confirm, please enter the accounts email address <br />({user.email}).
</Trans>
</DialogDescription>
<Input
@ -119,7 +134,7 @@ export const DeleteUserDialog = ({ className, user }: DeleteUserDialogProps) =>
variant="destructive"
disabled={email !== user.email}
>
{isDeletingUser ? 'Deleting account...' : 'Delete Account'}
<Trans>Delete account</Trans>
</Button>
</DialogFooter>
</DialogContent>

View File

@ -1,5 +1,6 @@
import * as React from 'react';
import { Trans } from '@lingui/macro';
import { Check, ChevronsUpDown } from 'lucide-react';
import { Role } from '@documenso/prisma/client';
@ -59,7 +60,9 @@ const MultiSelectRoleCombobox = ({ listValues, onChange }: ComboboxProps) => {
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder={selectedValues.join(', ')} />
<CommandEmpty>No value found.</CommandEmpty>
<CommandEmpty>
<Trans>No value found.</Trans>
</CommandEmpty>
<CommandGroup>
{allRoles.map((value: string, i: number) => (
<CommandItem key={i} onSelect={() => handleSelect(value)}>

View File

@ -3,6 +3,8 @@
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { useForm } from 'react-hook-form';
import type { z } from 'zod';
@ -28,7 +30,9 @@ const ZUserFormSchema = ZAdminUpdateProfileMutationSchema.omit({ id: true });
type TUserFormSchema = z.infer<typeof ZUserFormSchema>;
export default function UserPage({ params }: { params: { id: number } }) {
const { _ } = useLingui();
const { toast } = useToast();
const router = useRouter();
const { data: user } = trpc.profile.getUser.useQuery(
@ -65,14 +69,14 @@ export default function UserPage({ params }: { params: { id: number } }) {
router.refresh();
toast({
title: 'Profile updated',
description: 'Your profile has been updated.',
title: _(msg`Profile updated`),
description: _(msg`Your profile has been updated.`),
duration: 5000,
});
} catch (e) {
toast({
title: 'Error',
description: 'An error occurred while updating your profile.',
title: _(msg`Error`),
description: _(msg`An error occurred while updating your profile.`),
variant: 'destructive',
});
}
@ -80,7 +84,9 @@ export default function UserPage({ params }: { params: { id: number } }) {
return (
<div>
<h2 className="text-4xl font-semibold">Manage {user?.name}'s profile</h2>
<h2 className="text-4xl font-semibold">
<Trans>Manage {user?.name}'s profile</Trans>
</h2>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset className="mt-6 flex w-full flex-col gap-y-4">
@ -89,7 +95,9 @@ export default function UserPage({ params }: { params: { id: number } }) {
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground">Name</FormLabel>
<FormLabel className="text-muted-foreground">
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input type="text" {...field} value={field.value ?? ''} />
</FormControl>
@ -102,7 +110,9 @@ export default function UserPage({ params }: { params: { id: number } }) {
name="email"
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground">Email</FormLabel>
<FormLabel className="text-muted-foreground">
<Trans>Email</Trans>
</FormLabel>
<FormControl>
<Input type="text" {...field} />
</FormControl>
@ -117,7 +127,9 @@ export default function UserPage({ params }: { params: { id: number } }) {
render={({ field: { onChange } }) => (
<FormItem>
<fieldset className="flex flex-col gap-2">
<FormLabel className="text-muted-foreground">Roles</FormLabel>
<FormLabel className="text-muted-foreground">
<Trans>Roles</Trans>
</FormLabel>
<FormControl>
<MultiSelectRoleCombobox
listValues={roles}
@ -132,7 +144,7 @@ export default function UserPage({ params }: { params: { id: number } }) {
<div className="mt-4">
<Button type="submit" loading={form.formState.isSubmitting}>
Update user
<Trans>Update user</Trans>
</Button>
</div>
</fieldset>

View File

@ -1,15 +1,18 @@
'use client';
import { useEffect, useState, useTransition } from 'react';
import { useEffect, useMemo, useState, useTransition } from 'react';
import Link from 'next/link';
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { Edit, Loader } from 'lucide-react';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import type { Document, Role, Subscription } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { Input } from '@documenso/ui/primitives/input';
@ -45,11 +48,70 @@ export const UsersDataTable = ({
page,
individualPriceIds,
}: UsersDataTableProps) => {
const { _ } = useLingui();
const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams();
const [searchString, setSearchString] = useState('');
const debouncedSearchString = useDebouncedValue(searchString, 1000);
const columns = useMemo(() => {
return [
{
header: 'ID',
accessorKey: 'id',
cell: ({ row }) => <div>{row.original.id}</div>,
},
{
header: _(msg`Name`),
accessorKey: 'name',
cell: ({ row }) => <div>{row.original.name}</div>,
},
{
header: _(msg`Email`),
accessorKey: 'email',
cell: ({ row }) => <div>{row.original.email}</div>,
},
{
header: _(msg`Roles`),
accessorKey: 'roles',
cell: ({ row }) => row.original.roles.join(', '),
},
{
header: _(msg`Subscription`),
accessorKey: 'subscription',
cell: ({ row }) => {
const foundIndividualSubscription = (row.original.Subscription ?? []).find((sub) =>
individualPriceIds.includes(sub.priceId),
);
return foundIndividualSubscription?.status ?? 'NONE';
},
},
{
header: _(msg`Documents`),
accessorKey: 'documents',
cell: ({ row }) => {
return <div>{row.original.Document.length}</div>;
},
},
{
header: '',
accessorKey: 'edit',
cell: ({ row }) => {
return (
<Button className="w-24" asChild>
<Link href={`/admin/users/${row.original.id}`}>
<Edit className="-ml-1 mr-2 h-4 w-4" />
Edit
</Link>
</Button>
);
},
},
] satisfies DataTableColumnDef<(typeof users)[number]>[];
}, [individualPriceIds]);
useEffect(() => {
startTransition(() => {
updateSearchParams({
@ -79,65 +141,12 @@ export const UsersDataTable = ({
<Input
className="my-6 flex flex-row gap-4"
type="text"
placeholder="Search by name or email"
placeholder={_(msg`Search by name or email`)}
value={searchString}
onChange={handleChange}
/>
<DataTable
columns={[
{
header: 'ID',
accessorKey: 'id',
cell: ({ row }) => <div>{row.original.id}</div>,
},
{
header: 'Name',
accessorKey: 'name',
cell: ({ row }) => <div>{row.original.name}</div>,
},
{
header: 'Email',
accessorKey: 'email',
cell: ({ row }) => <div>{row.original.email}</div>,
},
{
header: 'Roles',
accessorKey: 'roles',
cell: ({ row }) => row.original.roles.join(', '),
},
{
header: 'Subscription',
accessorKey: 'subscription',
cell: ({ row }) => {
const foundIndividualSubscription = (row.original.Subscription ?? []).find((sub) =>
individualPriceIds.includes(sub.priceId),
);
return foundIndividualSubscription?.status ?? 'NONE';
},
},
{
header: 'Documents',
accessorKey: 'documents',
cell: ({ row }) => {
return <div>{row.original.Document.length}</div>;
},
},
{
header: '',
accessorKey: 'edit',
cell: ({ row }) => {
return (
<Button className="w-24" asChild>
<Link href={`/admin/users/${row.original.id}`}>
<Edit className="-ml-1 mr-2 h-4 w-4" />
Edit
</Link>
</Button>
);
},
},
]}
columns={columns}
data={users}
perPage={perPage}
currentPage={page}

View File

@ -1,4 +1,7 @@
import { Trans } from '@lingui/macro';
import { getPricesByPlan } from '@documenso/ee/server-only/stripe/get-prices-by-plan';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { UsersDataTable } from './data-table-users';
@ -13,6 +16,8 @@ type AdminManageUsersProps = {
};
export default async function AdminManageUsers({ searchParams = {} }: AdminManageUsersProps) {
setupI18nSSR();
const page = Number(searchParams.page) || 1;
const perPage = Number(searchParams.perPage) || 10;
const searchString = searchParams.search || '';
@ -26,7 +31,10 @@ export default async function AdminManageUsers({ searchParams = {} }: AdminManag
return (
<div>
<h2 className="text-4xl font-semibold">Manage users</h2>
<h2 className="text-4xl font-semibold">
<Trans>Manage users</Trans>
</h2>
<UsersDataTable
users={users}
individualPriceIds={individualPriceIds}

View File

@ -2,6 +2,8 @@
import Link from 'next/link';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { CheckCircle, Download, EyeIcon, Pencil } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { match } from 'ts-pattern';
@ -26,6 +28,7 @@ export type DocumentPageViewButtonProps = {
export const DocumentPageViewButton = ({ document, team }: DocumentPageViewButtonProps) => {
const { data: session } = useSession();
const { toast } = useToast();
const { _ } = useLingui();
if (!session) {
return null;
@ -57,8 +60,8 @@ export const DocumentPageViewButton = ({ document, team }: DocumentPageViewButto
await downloadPDF({ documentData, fileName: documentWithData.title });
} catch (err) {
toast({
title: 'Something went wrong',
description: 'An error occurred while downloading your document.',
title: _(msg`Something went wrong`),
description: _(msg`An error occurred while downloading your document.`),
variant: 'destructive',
});
}
@ -77,19 +80,19 @@ export const DocumentPageViewButton = ({ document, team }: DocumentPageViewButto
.with(RecipientRole.SIGNER, () => (
<>
<Pencil className="-ml-1 mr-2 h-4 w-4" />
Sign
<Trans>Sign</Trans>
</>
))
.with(RecipientRole.APPROVER, () => (
<>
<CheckCircle className="-ml-1 mr-2 h-4 w-4" />
Approve
<Trans>Approve</Trans>
</>
))
.otherwise(() => (
<>
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
View
<Trans>View</Trans>
</>
))}
</Link>
@ -97,13 +100,15 @@ export const DocumentPageViewButton = ({ document, team }: DocumentPageViewButto
))
.with({ isComplete: false }, () => (
<Button className="w-full" asChild>
<Link href={`${documentsPath}/${document.id}/edit`}>Edit</Link>
<Link href={`${documentsPath}/${document.id}/edit`}>
<Trans>Edit</Trans>
</Link>
</Button>
))
.with({ isComplete: true }, () => (
<Button className="w-full" onClick={onDownloadClick}>
<Download className="-ml-1 mr-2 inline h-4 w-4" />
Download
<Trans>Download</Trans>
</Button>
))
.otherwise(() => null);

View File

@ -4,6 +4,8 @@ import { useState } from 'react';
import Link from 'next/link';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import {
Copy,
Download,
@ -47,6 +49,7 @@ export type DocumentPageViewDropdownProps = {
export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDropdownProps) => {
const { data: session } = useSession();
const { toast } = useToast();
const { _ } = useLingui();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
@ -82,8 +85,8 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
await downloadPDF({ documentData, fileName: document.title });
} catch (err) {
toast({
title: 'Something went wrong',
description: 'An error occurred while downloading your document.',
title: _(msg`Something went wrong`),
description: _(msg`An error occurred while downloading your document.`),
variant: 'destructive',
});
}
@ -98,13 +101,15 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="end" forceMount>
<DropdownMenuLabel>Action</DropdownMenuLabel>
<DropdownMenuLabel>
<Trans>Action</Trans>
</DropdownMenuLabel>
{(isOwner || isCurrentTeamDocument) && !isComplete && (
<DropdownMenuItem asChild>
<Link href={`${documentsPath}/${document.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit
<Trans>Edit</Trans>
</Link>
</DropdownMenuItem>
)}
@ -112,20 +117,20 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
{isComplete && (
<DropdownMenuItem onClick={onDownloadClick}>
<Download className="mr-2 h-4 w-4" />
Download
<Trans>Download</Trans>
</DropdownMenuItem>
)}
<DropdownMenuItem asChild>
<Link href={`${documentsPath}/${document.id}/logs`}>
<ScrollTextIcon className="mr-2 h-4 w-4" />
Audit Log
<Trans>Audit Log</Trans>
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
<Copy className="mr-2 h-4 w-4" />
Duplicate
<Trans>Duplicate</Trans>
</DropdownMenuItem>
<DropdownMenuItem
@ -133,10 +138,12 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
disabled={Boolean(!canManageDocument && team?.teamEmail) || isDeleted}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
<Trans>Delete</Trans>
</DropdownMenuItem>
<DropdownMenuLabel>Share</DropdownMenuLabel>
<DropdownMenuLabel>
<Trans>Share</Trans>
</DropdownMenuLabel>
<ResendDocumentActionItem
document={document}
@ -151,7 +158,7 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
<DropdownMenuItem disabled={disabled || isDraft} onSelect={(e) => e.preventDefault()}>
<div className="flex items-center">
{loading ? <Loader className="mr-2 h-4 w-4" /> : <Share className="mr-2 h-4 w-4" />}
Share Signing Card
<Trans>Share Signing Card</Trans>
</div>
</DropdownMenuItem>
)}

View File

@ -2,6 +2,8 @@
import { useMemo } from 'react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { DateTime } from 'luxon';
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
@ -23,6 +25,7 @@ export const DocumentPageViewInformation = ({
const isMounted = useIsMounted();
const { locale } = useLocale();
const { _ } = useLingui();
const documentInformation = useMemo(() => {
let createdValue = DateTime.fromJSDate(document.createdAt).toFormat('MMMM d, yyyy');
@ -38,31 +41,34 @@ export const DocumentPageViewInformation = ({
return [
{
description: 'Uploaded by',
value: userId === document.userId ? 'You' : document.User.name ?? document.User.email,
description: msg`Uploaded by`,
value: userId === document.userId ? _(msg`You`) : document.User.name ?? document.User.email,
},
{
description: 'Created',
description: msg`Created`,
value: createdValue,
},
{
description: 'Last modified',
description: msg`Last modified`,
value: lastModifiedValue,
},
];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMounted, document, locale, userId]);
return (
<section className="dark:bg-background text-foreground border-border bg-widget flex flex-col rounded-xl border">
<h1 className="px-4 py-3 font-medium">Information</h1>
<h1 className="px-4 py-3 font-medium">
<Trans>Information</Trans>
</h1>
<ul className="divide-y border-t">
{documentInformation.map((item) => (
{documentInformation.map((item, i) => (
<li
key={item.description}
key={i}
className="flex items-center justify-between px-4 py-2.5 text-sm last:border-b"
>
<span className="text-muted-foreground">{item.description}</span>
<span className="text-muted-foreground">{_(item.description)}</span>
<span>{item.value}</span>
</li>
))}

View File

@ -2,6 +2,8 @@
import { useMemo } from 'react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { CheckCheckIcon, CheckIcon, Loader, MailOpen } from 'lucide-react';
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
@ -21,6 +23,8 @@ export const DocumentPageViewRecentActivity = ({
documentId,
userId,
}: DocumentPageViewRecentActivityProps) => {
const { _ } = useLingui();
const {
data,
isLoading,
@ -49,7 +53,9 @@ export const DocumentPageViewRecentActivity = ({
return (
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
<div className="flex flex-row items-center justify-between border-b px-4 py-3">
<h1 className="text-foreground font-medium">Recent activity</h1>
<h1 className="text-foreground font-medium">
<Trans>Recent activity</Trans>
</h1>
{/* Can add dropdown menu here for additional options. */}
</div>
@ -62,12 +68,14 @@ export const DocumentPageViewRecentActivity = ({
{isLoadingError && (
<div className="flex h-full flex-col items-center justify-center py-16">
<p className="text-foreground/80 text-sm">Unable to load document history</p>
<p className="text-foreground/80 text-sm">
<Trans>Unable to load document history</Trans>
</p>
<button
onClick={async () => refetch()}
className="text-foreground/70 hover:text-muted-foreground mt-2 text-sm"
>
Click here to retry
<Trans>Click here to retry</Trans>
</button>
</div>
)}
@ -89,14 +97,16 @@ export const DocumentPageViewRecentActivity = ({
onClick={async () => fetchNextPage()}
className="text-foreground/70 hover:text-muted-foreground text-xs"
>
{isFetchingNextPage ? 'Loading...' : 'Load older activity'}
{isFetchingNextPage ? _(msg`Loading...`) : _(msg`Load older activity`)}
</button>
</li>
)}
{documentAuditLogs.length === 0 && (
<div className="flex items-center justify-center py-4">
<p className="text-muted-foreground/70 text-sm">No recent activity</p>
<p className="text-muted-foreground/70 text-sm">
<Trans>No recent activity</Trans>
</p>
</div>
)}
@ -133,6 +143,7 @@ export const DocumentPageViewRecentActivity = ({
))}
</div>
{/* Todo: Translations. */}
<p
className="text-muted-foreground dark:text-muted-foreground/70 flex-auto truncate py-0.5 text-xs leading-5"
title={`${formatDocumentAuditLogAction(auditLog, userId).prefix} ${

View File

@ -1,5 +1,7 @@
import Link from 'next/link';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { CheckIcon, Clock, MailIcon, MailOpenIcon, PenIcon, PlusIcon } from 'lucide-react';
import { match } from 'ts-pattern';
@ -21,17 +23,21 @@ export const DocumentPageViewRecipients = ({
document,
documentRootPath,
}: DocumentPageViewRecipientsProps) => {
const { _ } = useLingui();
const recipients = document.Recipient;
return (
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
<div className="flex flex-row items-center justify-between px-4 py-3">
<h1 className="text-foreground font-medium">Recipients</h1>
<h1 className="text-foreground font-medium">
<Trans>Recipients</Trans>
</h1>
{document.status !== DocumentStatus.COMPLETED && (
<Link
href={`${documentRootPath}/${document.id}/edit?step=signers`}
title="Modify recipients"
title={_(msg`Modify recipients`)}
className="flex flex-row items-center justify-between"
>
{recipients.length === 0 ? (
@ -45,7 +51,9 @@ export const DocumentPageViewRecipients = ({
<ul className="text-muted-foreground divide-y border-t">
{recipients.length === 0 && (
<li className="flex flex-col items-center justify-center py-6 text-sm">No recipients</li>
<li className="flex flex-col items-center justify-center py-6 text-sm">
<Trans>No recipients</Trans>
</li>
)}
{recipients.map((recipient) => (
@ -55,7 +63,7 @@ export const DocumentPageViewRecipients = ({
primaryText={<p className="text-muted-foreground text-sm">{recipient.email}</p>}
secondaryText={
<p className="text-muted-foreground/70 text-xs">
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
}
/>
@ -67,19 +75,19 @@ export const DocumentPageViewRecipients = ({
.with(RecipientRole.APPROVER, () => (
<>
<CheckIcon className="mr-1 h-3 w-3" />
Approved
<Trans>Approved</Trans>
</>
))
.with(RecipientRole.CC, () =>
document.status === DocumentStatus.COMPLETED ? (
<>
<MailIcon className="mr-1 h-3 w-3" />
Sent
<Trans>Sent</Trans>
</>
) : (
<>
<CheckIcon className="mr-1 h-3 w-3" />
Ready
<Trans>Ready</Trans>
</>
),
)
@ -87,13 +95,13 @@ export const DocumentPageViewRecipients = ({
.with(RecipientRole.SIGNER, () => (
<>
<SignatureIcon className="mr-1 h-3 w-3" />
Signed
<Trans>Signed</Trans>
</>
))
.with(RecipientRole.VIEWER, () => (
<>
<MailOpenIcon className="mr-1 h-3 w-3" />
Viewed
<Trans>Viewed</Trans>
</>
))
.exhaustive()}
@ -104,7 +112,7 @@ export const DocumentPageViewRecipients = ({
recipient.signingStatus === SigningStatus.NOT_SIGNED && (
<Badge variant="secondary">
<Clock className="mr-1 h-3 w-3" />
Pending
<Trans>Pending</Trans>
</Badge>
)}
</li>

View File

@ -1,6 +1,8 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { Plural, Trans } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { ChevronLeft, Clock9, Users2 } from 'lucide-react';
import { match } from 'ts-pattern';
@ -42,6 +44,7 @@ export type DocumentPageViewProps = {
export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) => {
const { id } = params;
const { _ } = useLingui();
const documentId = Number(id);
@ -107,7 +110,7 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
<Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
Documents
<Trans>Documents</Trans>
</Link>
<div className="flex flex-row justify-between truncate">
@ -132,12 +135,18 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
documentStatus={document.status}
position="bottom"
>
<span>{recipients.length} Recipient(s)</span>
<span>
<Trans>{recipients.length} Recipient(s)</Trans>
</span>
</StackAvatarsWithTooltip>
</div>
)}
{document.deletedAt && <Badge variant="destructive">Document deleted</Badge>}
{document.deletedAt && (
<Badge variant="destructive">
<Trans>Document deleted</Trans>
</Badge>
)}
</div>
</div>
@ -146,7 +155,7 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
<DocumentHistorySheet documentId={document.id} userId={user.id}>
<Button variant="outline">
<Clock9 className="mr-1.5 h-4 w-4" />
Document history
<Trans>Document history</Trans>
</Button>
</DocumentHistorySheet>
</div>
@ -172,7 +181,7 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
<div className="flex flex-row items-center justify-between px-4">
<h3 className="text-foreground text-2xl font-semibold">
Document {FRIENDLY_STATUS_MAP[document.status].label.toLowerCase()}
{_(FRIENDLY_STATUS_MAP[document.status].labelExtended)}
</h3>
<DocumentPageViewDropdown document={documentWithRecipients} team={team} />
@ -180,22 +189,24 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
<p className="text-muted-foreground mt-2 px-4 text-sm ">
{match(document.status)
.with(
DocumentStatus.COMPLETED,
() => 'This document has been signed by all recipients',
)
.with(
DocumentStatus.DRAFT,
() => 'This document is currently a draft and has not been sent',
)
.with(DocumentStatus.COMPLETED, () => (
<Trans>This document has been signed by all recipients</Trans>
))
.with(DocumentStatus.DRAFT, () => (
<Trans>This document is currently a draft and has not been sent</Trans>
))
.with(DocumentStatus.PENDING, () => {
const pendingRecipients = recipients.filter(
(recipient) => recipient.signingStatus === 'NOT_SIGNED',
);
return `Waiting on ${pendingRecipients.length} recipient${
pendingRecipients.length > 1 ? 's' : ''
}`;
return (
<Plural
value={pendingRecipients.length}
one="Waiting on 1 recipient"
other="Waiting on # recipients"
/>
);
})
.exhaustive()}
</p>

View File

@ -4,6 +4,9 @@ import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import {
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
SKIP_QUERY_BATCH_META,
@ -45,6 +48,7 @@ export const EditDocumentForm = ({
isDocumentEnterprise,
}: EditDocumentFormProps) => {
const { toast } = useToast();
const { _ } = useLingui();
const router = useRouter();
const searchParams = useSearchParams();
@ -125,23 +129,23 @@ export const EditDocumentForm = ({
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
settings: {
title: 'General',
description: 'Configure general settings for the document.',
title: msg`General`,
description: msg`Configure general settings for the document.`,
stepIndex: 1,
},
signers: {
title: 'Add Signers',
description: 'Add the people who will sign the document.',
title: msg`Add Signers`,
description: msg`Add the people who will sign the document.`,
stepIndex: 2,
},
fields: {
title: 'Add Fields',
description: 'Add all relevant fields for each recipient.',
title: msg`Add Fields`,
description: msg`Add all relevant fields for each recipient.`,
stepIndex: 3,
},
subject: {
title: 'Add Subject',
description: 'Add the subject and message you wish to send to signers.',
title: msg`Add Subject`,
description: msg`Add the subject and message you wish to send to signers.`,
stepIndex: 4,
},
};
@ -191,8 +195,8 @@ export const EditDocumentForm = ({
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while updating the document settings.',
title: _(msg`Error`),
description: _(msg`An error occurred while updating the document settings.`),
variant: 'destructive',
});
}
@ -218,8 +222,8 @@ export const EditDocumentForm = ({
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while adding signers.',
title: _(msg`Error`),
description: _(msg`An error occurred while adding signers.`),
variant: 'destructive',
});
}
@ -248,8 +252,8 @@ export const EditDocumentForm = ({
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while adding the fields.',
title: _(msg`Error`),
description: _(msg`An error occurred while adding the fields.`),
variant: 'destructive',
});
}
@ -269,8 +273,8 @@ export const EditDocumentForm = ({
});
toast({
title: 'Document sent',
description: 'Your document has been sent successfully.',
title: _(msg`Document sent`),
description: _(msg`Your document has been sent successfully.`),
duration: 5000,
});
@ -279,8 +283,8 @@ export const EditDocumentForm = ({
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while sending the document.',
title: _(msg`Error`),
description: _(msg`An error occurred while sending the document.`),
variant: 'destructive',
});
}

View File

@ -1,6 +1,7 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { Plural, Trans } from '@lingui/macro';
import { ChevronLeft, Users2 } from 'lucide-react';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
@ -78,7 +79,7 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
<Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
Documents
<Trans>Documents</Trans>
</Link>
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
@ -97,7 +98,9 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
documentStatus={document.status}
position="bottom"
>
<span>{recipients.length} Recipient(s)</span>
<span>
<Plural one="1 Recipient" other="# Recipients" value={recipients.length} />
</span>
</StackAvatarsWithTooltip>
</div>
)}

View File

@ -1,3 +1,5 @@
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { DocumentEditPageView } from './document-edit-page-view';
export type DocumentPageProps = {
@ -7,5 +9,7 @@ export type DocumentPageProps = {
};
export default function DocumentEditPage({ params }: DocumentPageProps) {
setupI18nSSR();
return <DocumentEditPageView params={params} />;
}

View File

@ -1,19 +1,23 @@
import Link from 'next/link';
import { Trans } from '@lingui/macro';
import { ChevronLeft, Loader } from 'lucide-react';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
export default function Loading() {
setupI18nSSR();
return (
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
<Link href="/documents" className="flex grow-0 items-center text-[#7AC455] hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
Documents
<Trans>Documents</Trans>
</Link>
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
Loading Document...
<Trans>Loading Document...</Trans>
</h1>
<div className="flex h-10 items-center">
@ -25,7 +29,9 @@ export default function Loading() {
<div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center">
<Loader className="text-documenso h-12 w-12 animate-spin" />
<p className="text-muted-foreground mt-4">Loading document...</p>
<p className="text-muted-foreground mt-4">
<Trans>Loading document...</Trans>
</p>
</div>
</div>

View File

@ -1,7 +1,11 @@
'use client';
import { useMemo } from 'react';
import { useSearchParams } from 'next/navigation';
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { DateTime } from 'luxon';
import type { DateTimeFormatOptions } from 'luxon';
import { UAParser } from 'ua-parser-js';
@ -10,6 +14,7 @@ import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-upda
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
import { trpc } from '@documenso/trpc/react';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
@ -27,7 +32,7 @@ const dateFormat: DateTimeFormatOptions = {
};
export const DocumentLogsDataTable = ({ documentId }: DocumentLogsDataTableProps) => {
const parser = new UAParser();
const { _ } = useLingui();
const searchParams = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
@ -66,64 +71,70 @@ export const DocumentLogsDataTable = ({ documentId }: DocumentLogsDataTableProps
totalPages: 1,
};
const columns = useMemo(() => {
const parser = new UAParser();
return [
{
header: _(msg`Time`),
accessorKey: 'createdAt',
cell: ({ row }) => <LocaleDate format={dateFormat} date={row.original.createdAt} />,
},
{
header: _(msg`User`),
accessorKey: 'name',
cell: ({ row }) =>
row.original.name || row.original.email ? (
<div>
{row.original.name && (
<p className="truncate" title={row.original.name}>
{row.original.name}
</p>
)}
{row.original.email && (
<p className="truncate" title={row.original.email}>
{row.original.email}
</p>
)}
</div>
) : (
<p>N/A</p>
),
},
{
header: _(msg`Action`),
accessorKey: 'type',
cell: ({ row }) => (
<span>
{uppercaseFistLetter(formatDocumentAuditLogAction(row.original).description)}
</span>
),
},
{
header: 'IP Address',
accessorKey: 'ipAddress',
},
{
header: 'Browser',
cell: ({ row }) => {
if (!row.original.userAgent) {
return 'N/A';
}
parser.setUA(row.original.userAgent);
const result = parser.getResult();
return result.browser.name ?? 'N/A';
},
},
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
}, []);
return (
<DataTable
columns={[
{
header: 'Time',
accessorKey: 'createdAt',
cell: ({ row }) => <LocaleDate format={dateFormat} date={row.original.createdAt} />,
},
{
header: 'User',
accessorKey: 'name',
cell: ({ row }) =>
row.original.name || row.original.email ? (
<div>
{row.original.name && (
<p className="truncate" title={row.original.name}>
{row.original.name}
</p>
)}
{row.original.email && (
<p className="truncate" title={row.original.email}>
{row.original.email}
</p>
)}
</div>
) : (
<p>N/A</p>
),
},
{
header: 'Action',
accessorKey: 'type',
cell: ({ row }) => (
<span>
{uppercaseFistLetter(formatDocumentAuditLogAction(row.original).description)}
</span>
),
},
{
header: 'IP Address',
accessorKey: 'ipAddress',
},
{
header: 'Browser',
cell: ({ row }) => {
if (!row.original.userAgent) {
return 'N/A';
}
parser.setUA(row.original.userAgent);
const result = parser.getResult();
return result.browser.name ?? 'N/A';
},
},
]}
columns={columns}
data={results.data}
perPage={results.perPage}
currentPage={results.currentPage}

View File

@ -1,6 +1,9 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import type { MessageDescriptor } from '@lingui/core';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { ChevronLeft } from 'lucide-react';
import { DateTime } from 'luxon';
@ -29,6 +32,8 @@ export type DocumentLogsPageViewProps = {
};
export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageViewProps) => {
const { _ } = useLingui();
const locale = getLocale();
const { id } = params;
@ -60,39 +65,39 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
redirect(documentRootPath);
}
const documentInformation: { description: string; value: string }[] = [
const documentInformation: { description: MessageDescriptor; value: string }[] = [
{
description: 'Document title',
description: msg`Document title`,
value: document.title,
},
{
description: 'Document ID',
description: msg`Document ID`,
value: document.id.toString(),
},
{
description: 'Document status',
value: FRIENDLY_STATUS_MAP[document.status].label,
description: msg`Document status`,
value: _(FRIENDLY_STATUS_MAP[document.status].label),
},
{
description: 'Created by',
description: msg`Created by`,
value: document.User.name
? `${document.User.name} (${document.User.email})`
: document.User.email,
},
{
description: 'Date created',
description: msg`Date created`,
value: DateTime.fromJSDate(document.createdAt)
.setLocale(locale)
.toLocaleString(DateTime.DATETIME_MED_WITH_SECONDS),
},
{
description: 'Last updated',
description: msg`Last updated`,
value: DateTime.fromJSDate(document.updatedAt)
.setLocale(locale)
.toLocaleString(DateTime.DATETIME_MED_WITH_SECONDS),
},
{
description: 'Time zone',
description: msg`Time zone`,
value: document.documentMeta?.timezone ?? 'N/A',
},
];
@ -114,7 +119,7 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
className="flex items-center text-[#7AC455] hover:opacity-80"
>
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
Document
<Trans>Document</Trans>
</Link>
<div className="flex flex-col justify-between truncate sm:flex-row">
@ -147,7 +152,7 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
<Card className="grid grid-cols-1 gap-4 p-4 sm:grid-cols-2" degrees={45} gradient>
{documentInformation.map((info, i) => (
<div className="text-foreground text-sm" key={i}>
<h3 className="font-semibold">{info.description}</h3>
<h3 className="font-semibold">{_(info.description)}</h3>
<p className="text-muted-foreground">{info.value}</p>
</div>
))}

View File

@ -1,5 +1,7 @@
'use client';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { DownloadIcon } from 'lucide-react';
import { trpc } from '@documenso/trpc/react';
@ -19,6 +21,7 @@ export const DownloadAuditLogButton = ({
documentId,
}: DownloadAuditLogButtonProps) => {
const { toast } = useToast();
const { _ } = useLingui();
const { mutateAsync: downloadAuditLogs, isLoading } =
trpc.document.downloadAuditLogs.useMutation();
@ -59,8 +62,10 @@ export const DownloadAuditLogButton = ({
console.error(error);
toast({
title: 'Something went wrong',
description: 'Sorry, we were unable to download the audit logs. Please try again later.',
title: _(msg`Something went wrong`),
description: _(
msg`Sorry, we were unable to download the audit logs. Please try again later.`,
),
variant: 'destructive',
});
}
@ -73,7 +78,7 @@ export const DownloadAuditLogButton = ({
onClick={() => void onDownloadAuditLogsClick()}
>
{!isLoading && <DownloadIcon className="mr-1.5 h-4 w-4" />}
Download Audit Logs
<Trans>Download Audit Logs</Trans>
</Button>
);
};

View File

@ -1,5 +1,7 @@
'use client';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { DownloadIcon } from 'lucide-react';
import { DocumentStatus } from '@documenso/prisma/client';
@ -20,6 +22,7 @@ export const DownloadCertificateButton = ({
documentStatus,
}: DownloadCertificateButtonProps) => {
const { toast } = useToast();
const { _ } = useLingui();
const { mutateAsync: downloadCertificate, isLoading } =
trpc.document.downloadCertificate.useMutation();
@ -60,8 +63,10 @@ export const DownloadCertificateButton = ({
console.error(error);
toast({
title: 'Something went wrong',
description: 'Sorry, we were unable to download the certificate. Please try again later.',
title: _(msg`Something went wrong`),
description: _(
msg`Sorry, we were unable to download the certificate. Please try again later.`,
),
variant: 'destructive',
});
}
@ -76,7 +81,7 @@ export const DownloadCertificateButton = ({
onClick={() => void onDownloadCertificatesClick()}
>
{!isLoading && <DownloadIcon className="mr-1.5 h-4 w-4" />}
Download Certificate
<Trans>Download Certificate</Trans>
</Button>
);
};

View File

@ -1,3 +1,5 @@
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { DocumentLogsPageView } from './document-logs-page-view';
export type DocumentsLogsPageProps = {
@ -7,5 +9,7 @@ export type DocumentsLogsPageProps = {
};
export default function DocumentsLogsPage({ params }: DocumentsLogsPageProps) {
setupI18nSSR();
return <DocumentLogsPageView params={params} />;
}

View File

@ -1,3 +1,5 @@
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { DocumentPageView } from './document-page-view';
export type DocumentPageProps = {
@ -7,5 +9,7 @@ export type DocumentPageProps = {
};
export default function DocumentPage({ params }: DocumentPageProps) {
setupI18nSSR();
return <DocumentPageView params={params} />;
}

View File

@ -1,17 +1,22 @@
import Link from 'next/link';
import { Trans } from '@lingui/macro';
import { ChevronLeft } from 'lucide-react';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
export default function DocumentSentPage() {
setupI18nSSR();
return (
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
<Link href="/documents" className="flex grow-0 items-center text-[#7AC455] hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
Documents
<Trans>Documents</Trans>
</Link>
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
Loading Document...
<Trans>Loading Document...</Trans>
</h1>
</div>
);

View File

@ -3,6 +3,8 @@
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { History } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { useForm } from 'react-hook-form';
@ -62,6 +64,7 @@ export const ResendDocumentActionItem = ({
}: ResendDocumentActionItemProps) => {
const { data: session } = useSession();
const { toast } = useToast();
const { _ } = useLingui();
const [isOpen, setIsOpen] = useState(false);
const isOwner = document.userId === session?.user?.id;
@ -91,16 +94,16 @@ export const ResendDocumentActionItem = ({
await resendDocument({ documentId: document.id, recipients, teamId: team?.id });
toast({
title: 'Document re-sent',
description: 'Your document has been re-sent successfully.',
title: _(msg`Document re-sent`),
description: _(msg`Your document has been re-sent successfully.`),
duration: 5000,
});
setIsOpen(false);
} catch (err) {
toast({
title: 'Something went wrong',
description: 'This document could not be re-sent at this time. Please try again.',
title: _(msg`Something went wrong`),
description: _(msg`This document could not be re-sent at this time. Please try again.`),
variant: 'destructive',
duration: 7500,
});
@ -112,14 +115,16 @@ export const ResendDocumentActionItem = ({
<DialogTrigger asChild>
<DropdownMenuItem disabled={isDisabled} onSelect={(e) => e.preventDefault()}>
<History className="mr-2 h-4 w-4" />
Resend
<Trans>Resend</Trans>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-sm" hideClose>
<DialogHeader>
<DialogTitle asChild>
<h1 className="text-center text-xl">Who do you want to remind?</h1>
<h1 className="text-center text-xl">
<Trans>Who do you want to remind?</Trans>
</h1>
</DialogTitle>
</DialogHeader>
@ -178,12 +183,12 @@ export const ResendDocumentActionItem = ({
variant="secondary"
disabled={isSubmitting}
>
Cancel
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button className="flex-1" loading={isSubmitting} type="submit" form={FORM_ID}>
Send reminder
<Trans>Send reminder</Trans>
</Button>
</div>
</DialogFooter>

View File

@ -2,6 +2,8 @@
import Link from 'next/link';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { CheckCircle, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { match } from 'ts-pattern';
@ -27,6 +29,7 @@ export type DataTableActionButtonProps = {
export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps) => {
const { data: session } = useSession();
const { toast } = useToast();
const { _ } = useLingui();
if (!session) {
return null;
@ -69,8 +72,8 @@ export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps)
await downloadPDF({ documentData, fileName: row.title });
} catch (err) {
toast({
title: 'Something went wrong',
description: 'An error occurred while downloading your document.',
title: _(msg`Something went wrong`),
description: _(msg`An error occurred while downloading your document.`),
variant: 'destructive',
});
}
@ -96,7 +99,7 @@ export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps)
<Button className="w-32" asChild>
<Link href={`${documentsPath}/${row.id}/edit`}>
<Edit className="-ml-1 mr-2 h-4 w-4" />
Edit
<Trans>Edit</Trans>
</Link>
</Button>
),
@ -108,19 +111,19 @@ export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps)
.with(RecipientRole.SIGNER, () => (
<>
<Pencil className="-ml-1 mr-2 h-4 w-4" />
Sign
<Trans>Sign</Trans>
</>
))
.with(RecipientRole.APPROVER, () => (
<>
<CheckCircle className="-ml-1 mr-2 h-4 w-4" />
Approve
<Trans>Approve</Trans>
</>
))
.otherwise(() => (
<>
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
View
<Trans>View</Trans>
</>
))}
</Link>
@ -129,13 +132,13 @@ export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps)
.with({ isPending: true, isSigned: true }, () => (
<Button className="w-32" disabled={true}>
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
View
<Trans>View</Trans>
</Button>
))
.with({ isComplete: true }, () => (
<Button className="w-32" onClick={onDownloadClick}>
<Download className="-ml-1 mr-2 inline h-4 w-4" />
Download
<Trans>Download</Trans>
</Button>
))
.otherwise(() => <div></div>);

View File

@ -4,6 +4,8 @@ import { useState } from 'react';
import Link from 'next/link';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import {
CheckCircle,
Copy,
@ -52,6 +54,7 @@ export type DataTableActionDropdownProps = {
export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownProps) => {
const { data: session } = useSession();
const { toast } = useToast();
const { _ } = useLingui();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
@ -98,8 +101,8 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
await downloadPDF({ documentData, fileName: row.title });
} catch (err) {
toast({
title: 'Something went wrong',
description: 'An error occurred while downloading your document.',
title: _(msg`Something went wrong`),
description: _(msg`An error occurred while downloading your document.`),
variant: 'destructive',
});
}
@ -114,7 +117,9 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="start" forceMount>
<DropdownMenuLabel>Action</DropdownMenuLabel>
<DropdownMenuLabel>
<Trans>Action</Trans>
</DropdownMenuLabel>
{!isDraft && recipient && recipient?.role !== RecipientRole.CC && (
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
@ -122,21 +127,21 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
{recipient?.role === RecipientRole.VIEWER && (
<>
<EyeIcon className="mr-2 h-4 w-4" />
View
<Trans>View</Trans>
</>
)}
{recipient?.role === RecipientRole.SIGNER && (
<>
<Pencil className="mr-2 h-4 w-4" />
Sign
<Trans>Sign</Trans>
</>
)}
{recipient?.role === RecipientRole.APPROVER && (
<>
<CheckCircle className="mr-2 h-4 w-4" />
Approve
<Trans>Approve</Trans>
</>
)}
</Link>
@ -146,25 +151,25 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
<DropdownMenuItem disabled={!canManageDocument || isComplete} asChild>
<Link href={`${documentsPath}/${row.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit
<Trans>Edit</Trans>
</Link>
</DropdownMenuItem>
<DropdownMenuItem disabled={!isComplete} onClick={onDownloadClick}>
<Download className="mr-2 h-4 w-4" />
Download
<Trans>Download</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
<Copy className="mr-2 h-4 w-4" />
Duplicate
<Trans>Duplicate</Trans>
</DropdownMenuItem>
{/* We don't want to allow teams moving documents across at the moment. */}
{!team && (
<DropdownMenuItem onClick={() => setMoveDialogOpen(true)}>
<MoveRight className="mr-2 h-4 w-4" />
Move to Team
<Trans>Move to Team</Trans>
</DropdownMenuItem>
)}
@ -179,10 +184,12 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
disabled={Boolean(!canManageDocument && team?.teamEmail)}
>
<Trash2 className="mr-2 h-4 w-4" />
{canManageDocument ? 'Delete' : 'Hide'}
{canManageDocument ? _(msg`Delete`) : _(msg`Hide`)}
</DropdownMenuItem>
<DropdownMenuLabel>Share</DropdownMenuLabel>
<DropdownMenuLabel>
<Trans>Share</Trans>
</DropdownMenuLabel>
<ResendDocumentActionItem document={row} recipients={nonSignedRecipients} team={team} />
@ -193,7 +200,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
<DropdownMenuItem disabled={disabled || isDraft} onSelect={(e) => e.preventDefault()}>
<div className="flex items-center">
{loading ? <Loader className="mr-2 h-4 w-4" /> : <Share className="mr-2 h-4 w-4" />}
Share Signing Card
<Trans>Share Signing Card</Trans>
</div>
</DropdownMenuItem>
)}

View File

@ -2,6 +2,9 @@
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
import { parseToIntegerArray } from '@documenso/lib/utils/params';
import { trpc } from '@documenso/trpc/react';
@ -12,6 +15,8 @@ type DataTableSenderFilterProps = {
};
export const DataTableSenderFilter = ({ teamId }: DataTableSenderFilterProps) => {
const { _ } = useLingui();
const pathname = usePathname();
const searchParams = useSearchParams();
const router = useRouter();
@ -49,11 +54,13 @@ export const DataTableSenderFilter = ({ teamId }: DataTableSenderFilterProps) =>
<MultiSelectCombobox
emptySelectionPlaceholder={
<p className="text-muted-foreground font-normal">
<span className="text-muted-foreground/70">Sender:</span> All
<Trans>
<span className="text-muted-foreground/70">Sender:</span> All
</Trans>
</p>
}
enableClearAllButton={true}
inputPlaceholder="Search"
inputPlaceholder={msg`Search`}
loading={!isMounted || isInitialLoading}
options={comboBoxOptions}
selectedValues={senderIds}

View File

@ -1,7 +1,9 @@
'use client';
import { useTransition } from 'react';
import { useMemo, useTransition } from 'react';
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { Loader } from 'lucide-react';
import { DateTime } from 'luxon';
import { useSession } from 'next-auth/react';
@ -10,6 +12,7 @@ import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-upda
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
@ -39,10 +42,63 @@ export const DocumentsDataTable = ({
team,
}: DocumentsDataTableProps) => {
const { data: session } = useSession();
const { _ } = useLingui();
const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams();
const columns = useMemo(() => {
return [
{
header: _(msg`Created`),
accessorKey: 'createdAt',
cell: ({ row }) => (
<LocaleDate
date={row.original.createdAt}
format={{ ...DateTime.DATETIME_SHORT, hourCycle: 'h12' }}
/>
),
},
{
header: _(msg`Title`),
cell: ({ row }) => <DataTableTitle row={row.original} teamUrl={team?.url} />,
},
{
id: 'sender',
header: _(msg`Sender`),
cell: ({ row }) => row.original.User.name ?? row.original.User.email,
},
{
header: _(msg`Recipient`),
accessorKey: 'recipient',
cell: ({ row }) => (
<StackAvatarsWithTooltip
recipients={row.original.Recipient}
documentStatus={row.original.status}
/>
),
},
{
header: _(msg`Status`),
accessorKey: 'status',
cell: ({ row }) => <DocumentStatus status={row.getValue('status')} />,
size: 140,
},
{
header: _(msg`Actions`),
cell: ({ row }) =>
(!row.original.deletedAt ||
row.original.status === ExtendedDocumentStatus.COMPLETED) && (
<div className="flex items-center gap-x-4">
<DataTableActionButton team={team} row={row.original} />
<DataTableActionDropdown team={team} row={row.original} />
</div>
),
},
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
}, [team]);
const onPaginationChange = (page: number, perPage: number) => {
startTransition(() => {
updateSearchParams({
@ -59,54 +115,7 @@ export const DocumentsDataTable = ({
return (
<div className="relative">
<DataTable
columns={[
{
header: 'Created',
accessorKey: 'createdAt',
cell: ({ row }) => (
<LocaleDate
date={row.original.createdAt}
format={{ ...DateTime.DATETIME_SHORT, hourCycle: 'h12' }}
/>
),
},
{
header: 'Title',
cell: ({ row }) => <DataTableTitle row={row.original} teamUrl={team?.url} />,
},
{
id: 'sender',
header: 'Sender',
cell: ({ row }) => row.original.User.name ?? row.original.User.email,
},
{
header: 'Recipient',
accessorKey: 'recipient',
cell: ({ row }) => (
<StackAvatarsWithTooltip
recipients={row.original.Recipient}
documentStatus={row.original.status}
/>
),
},
{
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => <DocumentStatus status={row.getValue('status')} />,
size: 140,
},
{
header: 'Actions',
cell: ({ row }) =>
(!row.original.deletedAt ||
row.original.status === ExtendedDocumentStatus.COMPLETED) && (
<div className="flex items-center gap-x-4">
<DataTableActionButton team={team} row={row.original} />
<DataTableActionDropdown team={team} row={row.original} />
</div>
),
},
]}
columns={columns}
data={results.data}
perPage={results.perPage}
currentPage={results.currentPage}

View File

@ -2,6 +2,8 @@ import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { match } from 'ts-pattern';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
@ -43,6 +45,7 @@ export const DeleteDocumentDialog = ({
const { toast } = useToast();
const { refreshLimits } = useLimits();
const { _ } = useLingui();
const [inputValue, setInputValue] = useState('');
const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT);
@ -53,8 +56,8 @@ export const DeleteDocumentDialog = ({
void refreshLimits();
toast({
title: 'Document deleted',
description: `"${documentTitle}" has been successfully deleted`,
title: _(msg`Document deleted`),
description: _(msg`"${documentTitle}" has been successfully deleted`),
duration: 5000,
});
@ -74,8 +77,8 @@ export const DeleteDocumentDialog = ({
await deleteDocument({ id, teamId });
} catch {
toast({
title: 'Something went wrong',
description: 'This document could not be deleted at this time. Please try again.',
title: _(msg`Something went wrong`),
description: _(msg`This document could not be deleted at this time. Please try again.`),
variant: 'destructive',
duration: 7500,
});
@ -91,11 +94,20 @@ export const DeleteDocumentDialog = ({
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Are you sure?</DialogTitle>
<DialogTitle>
<Trans>Are you sure?</Trans>
</DialogTitle>
<DialogDescription>
You are about to {canManageDocument ? 'delete' : 'hide'}{' '}
<strong>"{documentTitle}"</strong>
{canManageDocument ? (
<Trans>
You are about to delete <strong>"{documentTitle}"</strong>
</Trans>
) : (
<Trans>
You are about to hide <strong>"{documentTitle}"</strong>
</Trans>
)}
</DialogDescription>
</DialogHeader>
@ -104,33 +116,53 @@ export const DeleteDocumentDialog = ({
{match(status)
.with(DocumentStatus.DRAFT, () => (
<AlertDescription>
Please note that this action is <strong>irreversible</strong>. Once confirmed,
this document will be permanently deleted.
<Trans>
Please note that this action is <strong>irreversible</strong>. Once confirmed,
this document will be permanently deleted.
</Trans>
</AlertDescription>
))
.with(DocumentStatus.PENDING, () => (
<AlertDescription>
<p>
Please note that this action is <strong>irreversible</strong>.
<Trans>
Please note that this action is <strong>irreversible</strong>.
</Trans>
</p>
<p className="mt-1">Once confirmed, the following will occur:</p>
<p className="mt-1">
<Trans>Once confirmed, the following will occur:</Trans>
</p>
<ul className="mt-0.5 list-inside list-disc">
<li>Document will be permanently deleted</li>
<li>Document signing process will be cancelled</li>
<li>All inserted signatures will be voided</li>
<li>All recipients will be notified</li>
<li>
<Trans>Document will be permanently deleted</Trans>
</li>
<li>
<Trans>Document signing process will be cancelled</Trans>
</li>
<li>
<Trans>All inserted signatures will be voided</Trans>
</li>
<li>
<Trans>All recipients will be notified</Trans>
</li>
</ul>
</AlertDescription>
))
.with(DocumentStatus.COMPLETED, () => (
<AlertDescription>
<p>By deleting this document, the following will occur:</p>
<p>
<Trans>By deleting this document, the following will occur:</Trans>
</p>
<ul className="mt-0.5 list-inside list-disc">
<li>The document will be hidden from your account</li>
<li>Recipients will still retain their copy of the document</li>
<li>
<Trans>The document will be hidden from your account</Trans>
</li>
<li>
<Trans>Recipients will still retain their copy of the document</Trans>
</li>
</ul>
</AlertDescription>
))
@ -139,7 +171,7 @@ export const DeleteDocumentDialog = ({
) : (
<Alert variant="warning" className="-mt-1">
<AlertDescription>
Please contact support if you would like to revert this action.
<Trans>Please contact support if you would like to revert this action.</Trans>
</AlertDescription>
</Alert>
)}
@ -149,13 +181,13 @@ export const DeleteDocumentDialog = ({
type="text"
value={inputValue}
onChange={onInputChange}
placeholder="Type 'delete' to confirm"
placeholder={_(msg`Type 'delete' to confirm`)}
/>
)}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
<Trans>Cancel</Trans>
</Button>
<Button
@ -165,7 +197,7 @@ export const DeleteDocumentDialog = ({
disabled={!isDeleteEnabled && canManageDocument}
variant="destructive"
>
{canManageDocument ? 'Delete' : 'Hide'}
{canManageDocument ? _(msg`Delete`) : _(msg`Hide`)}
</Button>
</DialogFooter>
</DialogContent>

View File

@ -1,5 +1,7 @@
import Link from 'next/link';
import { Trans } from '@lingui/macro';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
@ -104,7 +106,9 @@ export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPa
</Avatar>
)}
<h1 className="text-4xl font-semibold">Documents</h1>
<h1 className="text-4xl font-semibold">
<Trans>Documents</Trans>
</h1>
</div>
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">

View File

@ -1,5 +1,8 @@
import { useRouter } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { Team } from '@documenso/prisma/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
@ -28,7 +31,9 @@ export const DuplicateDocumentDialog = ({
team,
}: DuplicateDocumentDialogProps) => {
const router = useRouter();
const { toast } = useToast();
const { _ } = useLingui();
const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery({
id,
@ -50,8 +55,8 @@ export const DuplicateDocumentDialog = ({
router.push(`${documentsPath}/${newId}/edit`);
toast({
title: 'Document Duplicated',
description: 'Your document has been successfully duplicated.',
title: _(msg`Document Duplicated`),
description: _(msg`Your document has been successfully duplicated.`),
duration: 5000,
});
@ -64,8 +69,8 @@ export const DuplicateDocumentDialog = ({
await duplicateDocument({ id, teamId: team?.id });
} catch {
toast({
title: 'Something went wrong',
description: 'This document could not be duplicated at this time. Please try again.',
title: _(msg`Something went wrong`),
description: _(msg`This document could not be duplicated at this time. Please try again.`),
variant: 'destructive',
duration: 7500,
});
@ -76,12 +81,14 @@ export const DuplicateDocumentDialog = ({
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Duplicate</DialogTitle>
<DialogTitle>
<Trans>Duplicate</Trans>
</DialogTitle>
</DialogHeader>
{!documentData || isLoading ? (
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
Loading Document...
<Trans>Loading Document...</Trans>
</h1>
</div>
) : (
@ -98,7 +105,7 @@ export const DuplicateDocumentDialog = ({
onClick={() => onOpenChange(false)}
className="flex-1"
>
Cancel
<Trans>Cancel</Trans>
</Button>
<Button
@ -108,7 +115,7 @@ export const DuplicateDocumentDialog = ({
onClick={onDuplicate}
className="flex-1"
>
Duplicate
<Trans>Duplicate</Trans>
</Button>
</div>
</DialogFooter>

View File

@ -1,3 +1,5 @@
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { Bird, CheckCircle2 } from 'lucide-react';
import { match } from 'ts-pattern';
@ -6,33 +8,31 @@ import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-documen
export type EmptyDocumentProps = { status: ExtendedDocumentStatus };
export const EmptyDocumentState = ({ status }: EmptyDocumentProps) => {
const { _ } = useLingui();
const {
title,
message,
icon: Icon,
} = match(status)
.with(ExtendedDocumentStatus.COMPLETED, () => ({
title: 'Nothing to do',
message:
'There are no completed documents yet. Documents that you have created or received will appear here once completed.',
title: msg`Nothing to do`,
message: msg`There are no completed documents yet. Documents that you have created or received will appear here once completed.`,
icon: CheckCircle2,
}))
.with(ExtendedDocumentStatus.DRAFT, () => ({
title: 'No active drafts',
message:
'There are no active drafts at the current moment. You can upload a document to start drafting.',
title: msg`No active drafts`,
message: msg`There are no active drafts at the current moment. You can upload a document to start drafting.`,
icon: CheckCircle2,
}))
.with(ExtendedDocumentStatus.ALL, () => ({
title: "We're all empty",
message:
'You have not yet created or received any documents. To create a document please upload one.',
title: msg`We're all empty`,
message: msg`You have not yet created or received any documents. To create a document please upload one.`,
icon: Bird,
}))
.otherwise(() => ({
title: 'Nothing to do',
message:
'All documents have been processed. Any new documents that are sent or received will show here.',
title: msg`Nothing to do`,
message: msg`All documents have been processed. Any new documents that are sent or received will show here.`,
icon: CheckCircle2,
}));
@ -44,9 +44,9 @@ export const EmptyDocumentState = ({ status }: EmptyDocumentProps) => {
<Icon className="h-12 w-12" strokeWidth={1.5} />
<div className="text-center">
<h3 className="text-lg font-semibold">{title}</h3>
<h3 className="text-lg font-semibold">{_(title)}</h3>
<p className="mt-2 max-w-[60ch]">{message}</p>
<p className="mt-2 max-w-[60ch]">{_(message)}</p>
</div>
</div>
);

View File

@ -2,6 +2,9 @@ import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { trpc } from '@documenso/trpc/react';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
@ -30,25 +33,29 @@ type MoveDocumentDialogProps = {
};
export const MoveDocumentDialog = ({ documentId, open, onOpenChange }: MoveDocumentDialogProps) => {
const router = useRouter();
const { _ } = useLingui();
const { toast } = useToast();
const router = useRouter();
const [selectedTeamId, setSelectedTeamId] = useState<number | null>(null);
const { data: teams, isLoading: isLoadingTeams } = trpc.team.getTeams.useQuery();
const { mutateAsync: moveDocument, isLoading } = trpc.document.moveDocumentToTeam.useMutation({
onSuccess: () => {
router.refresh();
toast({
title: 'Document moved',
description: 'The document has been successfully moved to the selected team.',
title: _(msg`Document moved`),
description: _(msg`The document has been successfully moved to the selected team.`),
duration: 5000,
});
onOpenChange(false);
},
onError: (error) => {
toast({
title: 'Error',
description: error.message || 'An error occurred while moving the document.',
title: _(msg`Error`),
description: error.message || _(msg`An error occurred while moving the document.`),
variant: 'destructive',
duration: 7500,
});
@ -56,7 +63,10 @@ export const MoveDocumentDialog = ({ documentId, open, onOpenChange }: MoveDocum
});
const onMove = async () => {
if (!selectedTeamId) return;
if (!selectedTeamId) {
return;
}
await moveDocument({ documentId, teamId: selectedTeamId });
};
@ -64,20 +74,22 @@ export const MoveDocumentDialog = ({ documentId, open, onOpenChange }: MoveDocum
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Move Document to Team</DialogTitle>
<DialogTitle>
<Trans>Move Document to Team</Trans>
</DialogTitle>
<DialogDescription>
Select a team to move this document to. This action cannot be undone.
<Trans>Select a team to move this document to. This action cannot be undone.</Trans>
</DialogDescription>
</DialogHeader>
<Select onValueChange={(value) => setSelectedTeamId(Number(value))}>
<SelectTrigger>
<SelectValue placeholder="Select a team" />
<SelectValue placeholder={_(msg`Select a team`)} />
</SelectTrigger>
<SelectContent>
{isLoadingTeams ? (
<SelectItem value="loading" disabled>
Loading teams...
<Trans>Loading teams...</Trans>
</SelectItem>
) : (
teams?.map((team) => (

View File

@ -1,5 +1,6 @@
import type { Metadata } from 'next';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import type { DocumentsPageViewProps } from './documents-page-view';
@ -15,7 +16,10 @@ export const metadata: Metadata = {
};
export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
setupI18nSSR();
const { user } = await getRequiredServerComponentSession();
return (
<>
<UpcomingProfileClaimTeaser user={user} />

View File

@ -2,6 +2,9 @@
import { useCallback, useEffect, useState } from 'react';
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import type { User } from '@documenso/prisma/client';
import { useToast } from '@documenso/ui/primitives/use-toast';
@ -12,6 +15,7 @@ export type UpcomingProfileClaimTeaserProps = {
};
export const UpcomingProfileClaimTeaser = ({ user }: UpcomingProfileClaimTeaserProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const [open, setOpen] = useState(false);
@ -21,14 +25,17 @@ export const UpcomingProfileClaimTeaser = ({ user }: UpcomingProfileClaimTeaserP
(open: boolean) => {
if (!open && !claimed) {
toast({
title: 'Claim your profile later',
description: 'You can claim your profile later on by going to your profile settings!',
title: _(msg`Claim your profile later`),
description: _(
msg`You can claim your profile later on by going to your profile settings!`,
),
});
}
setOpen(open);
localStorage.setItem('app.hasShownProfileClaimDialog', 'true');
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[claimed, toast],
);

View File

@ -4,6 +4,8 @@ import { useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { Loader } from 'lucide-react';
import { useSession } from 'next-auth/react';
@ -34,6 +36,7 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
const { data: session } = useSession();
const { _ } = useLingui();
const { toast } = useToast();
const { quota, remaining, refreshLimits } = useLimits();
@ -45,13 +48,14 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
const disabledMessage = useMemo(() => {
if (remaining.documents === 0) {
return team
? 'Document upload disabled due to unpaid invoices'
: 'You have reached your document limit.';
? msg`Document upload disabled due to unpaid invoices`
: msg`You have reached your document limit.`;
}
if (!session?.user.emailVerified) {
return 'Verify your email to upload documents.';
return msg`Verify your email to upload documents.`;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [remaining.documents, session?.user.emailVerified, team]);
const onFileDrop = async (file: File) => {
@ -74,8 +78,8 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
void refreshLimits();
toast({
title: 'Document uploaded',
description: 'Your document has been uploaded successfully.',
title: _(msg`Document uploaded`),
description: _(msg`Your document has been uploaded successfully.`),
duration: 5000,
});
@ -93,20 +97,20 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
if (error.code === 'INVALID_DOCUMENT_FILE') {
toast({
title: 'Invalid file',
description: 'You cannot upload encrypted PDFs',
title: _(msg`Invalid file`),
description: _(msg`You cannot upload encrypted PDFs`),
variant: 'destructive',
});
} else if (err instanceof TRPCClientError) {
toast({
title: 'Error',
title: _(msg`Error`),
description: err.message,
variant: 'destructive',
});
} else {
toast({
title: 'Error',
description: 'An error occurred while uploading your document.',
title: _(msg`Error`),
description: _(msg`An error occurred while uploading your document.`),
variant: 'destructive',
});
}
@ -117,8 +121,8 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
const onFileDropRejected = () => {
toast({
title: 'Your document failed to upload.',
description: `File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`,
title: _(msg`Your document failed to upload.`),
description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`),
duration: 5000,
variant: 'destructive',
});
@ -139,7 +143,9 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
remaining.documents > 0 &&
Number.isFinite(remaining.documents) && (
<p className="text-muted-foreground/60 text-xs">
{remaining.documents} of {quota.documents} documents remaining this month.
<Trans>
{remaining.documents} of {quota.documents} documents remaining this month.
</Trans>
</p>
)}
</div>

View File

@ -5,6 +5,7 @@ import { redirect } from 'next/navigation';
import { getServerSession } from 'next-auth';
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
@ -22,6 +23,8 @@ export type AuthenticatedDashboardLayoutProps = {
export default async function AuthenticatedDashboardLayout({
children,
}: AuthenticatedDashboardLayoutProps) {
setupI18nSSR();
const session = await getServerSession(NEXT_AUTH_OPTIONS);
if (!session) {

View File

@ -2,6 +2,9 @@
import { useState } from 'react';
import type { MessageDescriptor } from '@lingui/core';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { AnimatePresence, motion } from 'framer-motion';
import type { PriceIntervals } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
@ -21,11 +24,11 @@ const INTERVALS: Interval[] = ['day', 'week', 'month', 'year'];
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const isInterval = (value: unknown): value is Interval => INTERVALS.includes(value as Interval);
const FRIENDLY_INTERVALS: Record<Interval, string> = {
day: 'Daily',
week: 'Weekly',
month: 'Monthly',
year: 'Yearly',
const FRIENDLY_INTERVALS: Record<Interval, MessageDescriptor> = {
day: msg`Daily`,
week: msg`Weekly`,
month: msg`Monthly`,
year: msg`Yearly`,
};
const MotionCard = motion(Card);
@ -35,6 +38,7 @@ export type BillingPlansProps = {
};
export const BillingPlans = ({ prices }: BillingPlansProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const isMounted = useIsMounted();
@ -55,8 +59,8 @@ export const BillingPlans = ({ prices }: BillingPlansProps) => {
window.open(url);
} catch (_err) {
toast({
title: 'Something went wrong',
description: 'An error occurred while trying to create a checkout session.',
title: _(msg`Something went wrong`),
description: _(msg`An error occurred while trying to create a checkout session.`),
variant: 'destructive',
});
} finally {
@ -72,7 +76,7 @@ export const BillingPlans = ({ prices }: BillingPlansProps) => {
(interval) =>
prices[interval].length > 0 && (
<TabsTrigger key={interval} className="min-w-[150px]" value={interval}>
{FRIENDLY_INTERVALS[interval]}
{_(FRIENDLY_INTERVALS[interval])}
</TabsTrigger>
),
)}
@ -121,7 +125,7 @@ export const BillingPlans = ({ prices }: BillingPlansProps) => {
loading={isFetchingCheckoutSession}
onClick={() => void onSubscribeClick(price.id)}
>
Subscribe
<Trans>Subscribe</Trans>
</Button>
</CardContent>
</MotionCard>

View File

@ -2,6 +2,9 @@
import { useState } from 'react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
@ -12,6 +15,7 @@ export type BillingPortalButtonProps = {
};
export const BillingPortalButton = ({ buttonProps }: BillingPortalButtonProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const [isFetchingPortalUrl, setIsFetchingPortalUrl] = useState(false);
@ -32,16 +36,18 @@ export const BillingPortalButton = ({ buttonProps }: BillingPortalButtonProps) =
window.open(sessionUrl, '_blank');
} catch (e) {
let description =
'We are unable to proceed to the billing portal at this time. Please try again, or contact support.';
let description = _(
msg`We are unable to proceed to the billing portal at this time. Please try again, or contact support.`,
);
if (e.message === 'CUSTOMER_NOT_FOUND') {
description =
'You do not currently have a customer record, this should not happen. Please contact support for assistance.';
description = _(
msg`You do not currently have a customer record, this should not happen. Please contact support for assistance.`,
);
}
toast({
title: 'Something went wrong',
title: _(msg`Something went wrong`),
description,
variant: 'destructive',
duration: 10000,
@ -57,7 +63,7 @@ export const BillingPortalButton = ({ buttonProps }: BillingPortalButtonProps) =
onClick={async () => handleFetchPortalUrl()}
loading={isFetchingPortalUrl}
>
Manage Subscription
<Trans>Manage Subscription</Trans>
</Button>
);
};

View File

@ -1,12 +1,14 @@
import type { Metadata } from 'next';
import { redirect } from 'next/navigation';
import { Trans } from '@lingui/macro';
import { match } from 'ts-pattern';
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
import { getPrimaryAccountPlanPrices } from '@documenso/ee/server-only/stripe/get-primary-account-plan-prices';
import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
@ -24,6 +26,8 @@ export const metadata: Metadata = {
};
export default async function BillingSettingsPage() {
setupI18nSSR();
let { user } = await getRequiredServerComponentSession();
const isBillingEnabled = await getServerComponentFlag('app_billing');
@ -66,15 +70,20 @@ export default async function BillingSettingsPage() {
return (
<div>
<h3 className="text-2xl font-semibold">Billing</h3>
<h3 className="text-2xl font-semibold">
<Trans>Billing</Trans>
</h3>
<div className="text-muted-foreground mt-2 text-sm">
{isMissingOrInactiveOrFreePlan && (
<p>
You are currently on the <span className="font-semibold">Free Plan</span>.
<Trans>
You are currently on the <span className="font-semibold">Free Plan</span>.
</Trans>
</p>
)}
{/* Todo: Translation */}
{!isMissingOrInactiveOrFreePlan &&
match(subscription.status)
.with('ACTIVE', () => (
@ -108,7 +117,11 @@ export default async function BillingSettingsPage() {
</p>
))
.with('PAST_DUE', () => (
<p>Your current plan is past due. Please update your payment information.</p>
<p>
<Trans>
Your current plan is past due. Please update your payment information.
</Trans>
</p>
))
.otherwise(() => null)}
</div>

View File

@ -1,5 +1,9 @@
import React from 'react';
import { Trans } from '@lingui/macro';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { DesktopNav } from '~/components/(dashboard)/settings/layout/desktop-nav';
import { MobileNav } from '~/components/(dashboard)/settings/layout/mobile-nav';
@ -8,9 +12,13 @@ export type DashboardSettingsLayoutProps = {
};
export default function DashboardSettingsLayout({ children }: DashboardSettingsLayoutProps) {
setupI18nSSR();
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<h1 className="text-4xl font-semibold">Settings</h1>
<h1 className="text-4xl font-semibold">
<Trans>Settings</Trans>
</h1>
<div className="mt-4 grid grid-cols-12 gap-x-8 md:mt-8">
<DesktopNav className="hidden md:col-span-3 md:flex" />

View File

@ -2,6 +2,8 @@
import { useState } from 'react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { signOut } from 'next-auth/react';
import type { User } from '@documenso/prisma/client';
@ -28,6 +30,7 @@ export type DeleteAccountDialogProps = {
};
export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const hasTwoFactorAuthentication = user.twoFactorEnabled;
@ -42,8 +45,8 @@ export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProp
await deleteAccount();
toast({
title: 'Account deleted',
description: 'Your account has been deleted successfully.',
title: _(msg`Account deleted`),
description: _(msg`Your account has been deleted successfully.`),
duration: 5000,
});
@ -51,17 +54,19 @@ export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProp
} catch (err) {
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
toast({
title: 'An error occurred',
title: _(msg`An error occurred`),
description: err.message,
variant: 'destructive',
});
} else {
toast({
title: 'An unknown error occurred',
title: _(msg`An unknown error occurred`),
variant: 'destructive',
description:
err.message ??
'We encountered an unknown error while attempting to delete your account. Please try again later.',
_(
msg`We encountered an unknown error while attempting to delete your account. Please try again later.`,
),
});
}
}
@ -74,50 +79,63 @@ export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProp
variant="neutral"
>
<div>
<AlertTitle>Delete Account</AlertTitle>
<AlertTitle>
<Trans>Delete Account</Trans>
</AlertTitle>
<AlertDescription className="mr-2">
Delete your account and all its contents, including completed documents. This action is
irreversible and will cancel your subscription, so proceed with caution.
<Trans>
Delete your account and all its contents, including completed documents. This action
is irreversible and will cancel your subscription, so proceed with caution.
</Trans>
</AlertDescription>
</div>
<div className="flex-shrink-0">
<Dialog onOpenChange={() => setEnteredEmail('')}>
<DialogTrigger asChild>
<Button variant="destructive">Delete Account</Button>
<Button variant="destructive">
<Trans>Delete Account</Trans>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader className="space-y-4">
<DialogTitle>Delete Account</DialogTitle>
<DialogTitle>
<Trans>Delete Account</Trans>
</DialogTitle>
<Alert variant="destructive">
<AlertDescription className="selection:bg-red-100">
This action is not reversible. Please be certain.
<Trans>This action is not reversible. Please be certain.</Trans>
</AlertDescription>
</Alert>
{hasTwoFactorAuthentication && (
<Alert variant="destructive">
<AlertDescription className="selection:bg-red-100">
Disable Two Factor Authentication before deleting your account.
<Trans>Disable Two Factor Authentication before deleting your account.</Trans>
</AlertDescription>
</Alert>
)}
<DialogDescription>
Documenso will delete <span className="font-semibold">all of your documents</span>
, along with all of your completed documents, signatures, and all other resources
belonging to your Account.
<Trans>
Documenso will delete{' '}
<span className="font-semibold">all of your documents</span>, along with all of
your completed documents, signatures, and all other resources belonging to your
Account.
</Trans>
</DialogDescription>
</DialogHeader>
{!hasTwoFactorAuthentication && (
<div className="mt-4">
<Label>
Please type{' '}
<span className="text-muted-foreground font-semibold">{user.email}</span> to
confirm.
<Trans>
Please type{' '}
<span className="text-muted-foreground font-semibold">{user.email}</span> to
confirm.
</Trans>
</Label>
<Input
@ -136,7 +154,7 @@ export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProp
variant="destructive"
disabled={hasTwoFactorAuthentication || enteredEmail !== user.email}
>
{isDeletingAccount ? 'Deleting account...' : 'Confirm Deletion'}
{isDeletingAccount ? _(msg`Deleting account...`) : _(msg`Confirm Deletion`)}
</Button>
</DialogFooter>
</DialogContent>

View File

@ -1,5 +1,9 @@
import type { Metadata } from 'next';
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
@ -13,11 +17,17 @@ export const metadata: Metadata = {
};
export default async function ProfileSettingsPage() {
setupI18nSSR();
const { _ } = useLingui();
const { user } = await getRequiredServerComponentSession();
return (
<div>
<SettingsHeader title="Profile" subtitle="Here you can edit your personal details." />
<SettingsHeader
title={_(msg`Profile`)}
subtitle={_(msg`Here you can edit your personal details.`)}
/>
<AvatarImageForm className="mb-8 max-w-xl" user={user} />
<ProfileForm className="mb-8 max-w-xl" user={user} />

View File

@ -1,9 +1,12 @@
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getUserPublicProfile } from '@documenso/lib/server-only/user/get-user-public-profile';
import { PublicProfilePageView } from './public-profile-page-view';
export default async function Page() {
setupI18nSSR();
const { user } = await getRequiredServerComponentSession();
const { profile } = await getUserPublicProfile({

View File

@ -2,6 +2,9 @@
import { useEffect, useMemo, useState } from 'react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import type { FindTemplateRow } from '@documenso/lib/server-only/template/find-templates';
import type {
Team,
@ -36,22 +39,21 @@ type DirectTemplate = FindTemplateRow & {
};
const userProfileText = {
settingsTitle: 'Public Profile',
settingsSubtitle: 'You can choose to enable or disable your profile for public view.',
templatesTitle: 'My templates',
templatesSubtitle:
'Show templates in your public profile for your audience to sign and get started quickly',
settingsTitle: msg`Public Profile`,
settingsSubtitle: msg`You can choose to enable or disable your profile for public view.`,
templatesTitle: msg`My templates`,
templatesSubtitle: msg`Show templates in your public profile for your audience to sign and get started quickly`,
};
const teamProfileText = {
settingsTitle: 'Team Public Profile',
settingsSubtitle: 'You can choose to enable or disable your team profile for public view.',
templatesTitle: 'Team templates',
templatesSubtitle:
'Show templates in your team public profile for your audience to sign and get started quickly',
settingsTitle: msg`Team Public Profile`,
settingsSubtitle: msg`You can choose to enable or disable your team profile for public view.`,
templatesTitle: msg`Team templates`,
templatesSubtitle: msg`Show templates in your team public profile for your audience to sign and get started quickly`,
};
export const PublicProfilePageView = ({ user, team, profile }: PublicProfilePageViewOptions) => {
const { _ } = useLingui();
const { toast } = useToast();
const [isPublicProfileVisible, setIsPublicProfileVisible] = useState(profile.enabled);
@ -104,7 +106,7 @@ export const PublicProfilePageView = ({ user, team, profile }: PublicProfilePage
if (isVisible && !user.url) {
toast({
title: 'You must set a profile URL before enabling your public profile.',
title: _(msg`You must set a profile URL before enabling your public profile.`),
variant: 'destructive',
});
@ -119,8 +121,8 @@ export const PublicProfilePageView = ({ user, team, profile }: PublicProfilePage
});
} catch {
toast({
title: 'Something went wrong',
description: 'We were unable to set your public profile to public. Please try again.',
title: _(msg`Something went wrong`),
description: _(msg`We were unable to set your public profile to public. Please try again.`),
variant: 'destructive',
});
@ -134,7 +136,10 @@ export const PublicProfilePageView = ({ user, team, profile }: PublicProfilePage
return (
<div className="max-w-2xl">
<SettingsHeader title={profileText.settingsTitle} subtitle={profileText.settingsSubtitle}>
<SettingsHeader
title={_(profileText.settingsTitle)}
subtitle={_(profileText.settingsSubtitle)}
>
<Tooltip open={isTooltipOpen} onOpenChange={setIsTooltipOpen}>
<TooltipTrigger asChild>
<div
@ -146,13 +151,17 @@ export const PublicProfilePageView = ({ user, team, profile }: PublicProfilePage
},
)}
>
<span>Hide</span>
<span>
<Trans>Hide</Trans>
</span>
<Switch
disabled={isUpdating}
checked={isPublicProfileVisible}
onCheckedChange={togglePublicProfileVisibility}
/>
<span>Show</span>
<span>
<Trans>Show</Trans>
</span>
</div>
</TooltipTrigger>
@ -160,18 +169,26 @@ export const PublicProfilePageView = ({ user, team, profile }: PublicProfilePage
{isPublicProfileVisible ? (
<>
<p>
Profile is currently <strong>visible</strong>.
<Trans>
Profile is currently <strong>visible</strong>.
</Trans>
</p>
<p>Toggle the switch to hide your profile from the public.</p>
<p>
<Trans>Toggle the switch to hide your profile from the public.</Trans>
</p>
</>
) : (
<>
<p>
Profile is currently <strong>hidden</strong>.
<Trans>
Profile is currently <strong>hidden</strong>.
</Trans>
</p>
<p>Toggle the switch to show your profile to the public.</p>
<p>
<Trans>Toggle the switch to show your profile to the public.</Trans>
</p>
</>
)}
</TooltipContent>
@ -187,14 +204,18 @@ export const PublicProfilePageView = ({ user, team, profile }: PublicProfilePage
<div className="mt-4">
<SettingsHeader
title={profileText.templatesTitle}
subtitle={profileText.templatesSubtitle}
title={_(profileText.templatesTitle)}
subtitle={_(profileText.templatesSubtitle)}
hideDivider={true}
className="mt-8 [&>*>h3]:text-base"
>
<ManagePublicTemplateDialog
directTemplates={enabledPrivateDirectTemplates}
trigger={<Button variant="outline">Link template</Button>}
trigger={
<Button variant="outline">
<Trans>Link template</Trans>
</Button>
}
/>
</SettingsHeader>

View File

@ -2,6 +2,8 @@
import { useMemo, useState } from 'react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { EditIcon, FileIcon, LinkIcon, MoreHorizontalIcon, Trash2Icon } from 'lucide-react';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
@ -30,6 +32,7 @@ type DirectTemplate = FindTemplateRow & {
export const PublicTemplatesDataTable = () => {
const team = useOptionalCurrentTeam();
const { _ } = useLingui();
const { toast } = useToast();
const [, copy] = useCopyToClipboard();
@ -71,8 +74,8 @@ export const PublicTemplatesDataTable = () => {
const onCopyClick = async (token: string) =>
copy(formatDirectTemplatePath(token)).then(() => {
toast({
title: 'Copied to clipboard',
description: 'The direct link has been copied to your clipboard',
title: _(msg`Copied to clipboard`),
description: _(msg`The direct link has been copied to your clipboard`),
});
});
@ -105,26 +108,26 @@ export const PublicTemplatesDataTable = () => {
{isLoadingError && (
<div className="text-muted-foreground flex h-32 flex-col items-center justify-center text-sm">
Unable to load your public profile templates at this time
<Trans>Unable to load your public profile templates at this time</Trans>
<button
onClick={(e) => {
e.preventDefault();
void refetch();
}}
>
Click here to retry
<Trans>Click here to retry</Trans>
</button>
</div>
)}
{!isInitialLoading && (
<div className="text-muted-foreground flex h-32 flex-col items-center justify-center text-sm">
No public profile templates found
<Trans>No public profile templates found</Trans>
<ManagePublicTemplateDialog
directTemplates={privateDirectTemplates}
trigger={
<button className="hover:text-muted-foreground/80 mt-1 text-xs">
Click here to get started
<Trans>Click here to get started</Trans>
</button>
}
/>
@ -157,11 +160,13 @@ export const PublicTemplatesDataTable = () => {
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="center" side="left">
<DropdownMenuLabel>Action</DropdownMenuLabel>
<DropdownMenuLabel>
<Trans>Action</Trans>
</DropdownMenuLabel>
<DropdownMenuItem onClick={() => void onCopyClick(template.directLink.token)}>
<LinkIcon className="mr-2 h-4 w-4" />
Copy sharable link
<Trans>Copy sharable link</Trans>
</DropdownMenuItem>
<DropdownMenuItem
@ -173,7 +178,7 @@ export const PublicTemplatesDataTable = () => {
}}
>
<EditIcon className="mr-2 h-4 w-4" />
Update
<Trans>Update</Trans>
</DropdownMenuItem>
<DropdownMenuItem
@ -185,7 +190,7 @@ export const PublicTemplatesDataTable = () => {
}
>
<Trash2Icon className="mr-2 h-4 w-4" />
Remove
<Trans>Remove</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@ -1,5 +1,10 @@
import type { Metadata } from 'next';
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
import ActivityPageBackButton from '../../../../../components/(dashboard)/settings/layout/activity-back';
@ -10,11 +15,15 @@ export const metadata: Metadata = {
};
export default function SettingsSecurityActivityPage() {
setupI18nSSR();
const { _ } = useLingui();
return (
<div>
<SettingsHeader
title="Security activity"
subtitle="View all recent security activity related to your account."
title={_(msg`Security activity`)}
subtitle={_(msg`View all security activity related to your account.`)}
hideDivider={true}
>
<ActivityPageBackButton />

View File

@ -1,7 +1,11 @@
'use client';
import { useMemo } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import type { DateTimeFormatOptions } from 'luxon';
import { DateTime } from 'luxon';
import { UAParser } from 'ua-parser-js';
@ -10,6 +14,7 @@ import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-upda
import { USER_SECURITY_AUDIT_LOG_MAP } from '@documenso/lib/constants/auth';
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
import { trpc } from '@documenso/trpc/react';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
@ -23,7 +28,7 @@ const dateFormat: DateTimeFormatOptions = {
};
export const UserSecurityActivityDataTable = () => {
const parser = new UAParser();
const { _ } = useLingui();
const pathname = usePathname();
const router = useRouter();
@ -59,63 +64,69 @@ export const UserSecurityActivityDataTable = () => {
totalPages: 1,
};
const columns = useMemo(() => {
const parser = new UAParser();
return [
{
header: _(msg`Date`),
accessorKey: 'createdAt',
cell: ({ row }) => <LocaleDate format={dateFormat} date={row.original.createdAt} />,
},
{
header: _(msg`Device`),
cell: ({ row }) => {
if (!row.original.userAgent) {
return 'N/A';
}
parser.setUA(row.original.userAgent);
const result = parser.getResult();
let output = result.os.name;
if (!output) {
return 'N/A';
}
if (result.os.version) {
output += ` (${result.os.version})`;
}
return output;
},
},
{
header: _(msg`Browser`),
cell: ({ row }) => {
if (!row.original.userAgent) {
return 'N/A';
}
parser.setUA(row.original.userAgent);
const result = parser.getResult();
return result.browser.name ?? 'N/A';
},
},
{
header: 'IP Address',
accessorKey: 'ipAddress',
cell: ({ row }) => row.original.ipAddress ?? 'N/A',
},
{
header: _(msg`Action`),
accessorKey: 'type',
cell: ({ row }) => USER_SECURITY_AUDIT_LOG_MAP[row.original.type],
},
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
}, []);
return (
<DataTable
columns={[
{
header: 'Date',
accessorKey: 'createdAt',
cell: ({ row }) => <LocaleDate format={dateFormat} date={row.original.createdAt} />,
},
{
header: 'Device',
cell: ({ row }) => {
if (!row.original.userAgent) {
return 'N/A';
}
parser.setUA(row.original.userAgent);
const result = parser.getResult();
let output = result.os.name;
if (!output) {
return 'N/A';
}
if (result.os.version) {
output += ` (${result.os.version})`;
}
return output;
},
},
{
header: 'Browser',
cell: ({ row }) => {
if (!row.original.userAgent) {
return 'N/A';
}
parser.setUA(row.original.userAgent);
const result = parser.getResult();
return result.browser.name ?? 'N/A';
},
},
{
header: 'IP Address',
accessorKey: 'ipAddress',
cell: ({ row }) => row.original.ipAddress ?? 'N/A',
},
{
header: 'Action',
accessorKey: 'type',
cell: ({ row }) => USER_SECURITY_AUDIT_LOG_MAP[row.original.type],
},
]}
columns={columns}
data={results.data}
perPage={results.perPage}
currentPage={results.currentPage}

View File

@ -1,6 +1,10 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
@ -17,6 +21,9 @@ export const metadata: Metadata = {
};
export default async function SecuritySettingsPage() {
setupI18nSSR();
const { _ } = useLingui();
const { user } = await getRequiredServerComponentSession();
const isPasskeyEnabled = await getServerComponentFlag('app_passkey');
@ -24,8 +31,8 @@ export default async function SecuritySettingsPage() {
return (
<div>
<SettingsHeader
title="Security"
subtitle="Here you can manage your password and security settings."
title={_(msg`Security`)}
subtitle={_(msg`Here you can manage your password and security settings.`)}
/>
{user.identityProvider === 'DOCUMENSO' && (
@ -41,13 +48,22 @@ export default async function SecuritySettingsPage() {
variant="neutral"
>
<div className="mb-4 sm:mb-0">
<AlertTitle>Two factor authentication</AlertTitle>
<AlertTitle>
<Trans>Two factor authentication</Trans>
</AlertTitle>
<AlertDescription className="mr-4">
Add an authenticator to serve as a secondary authentication method{' '}
{user.identityProvider === 'DOCUMENSO'
? 'when signing in, or when signing documents.'
: 'for signing documents.'}
{user.identityProvider === 'DOCUMENSO' ? (
<Trans>
Add an authenticator to serve as a secondary authentication method when signing in,
or when signing documents.
</Trans>
) : (
<Trans>
Add an authenticator to serve as a secondary authentication method for signing
documents.
</Trans>
)}
</AlertDescription>
</div>
@ -64,11 +80,15 @@ export default async function SecuritySettingsPage() {
variant="neutral"
>
<div className="mb-4 sm:mb-0">
<AlertTitle>Recovery codes</AlertTitle>
<AlertTitle>
<Trans>Recovery codes</Trans>
</AlertTitle>
<AlertDescription className="mr-4">
Two factor authentication recovery codes are used to access your account in the event
that you lose access to your authenticator app.
<Trans>
Two factor authentication recovery codes are used to access your account in the
event that you lose access to your authenticator app.
</Trans>
</AlertDescription>
</div>
@ -82,15 +102,21 @@ export default async function SecuritySettingsPage() {
variant="neutral"
>
<div className="mb-4 sm:mb-0">
<AlertTitle>Passkeys</AlertTitle>
<AlertTitle>
<Trans>Passkeys</Trans>
</AlertTitle>
<AlertDescription className="mr-4">
Allows authenticating using biometrics, password managers, hardware keys, etc.
<Trans>
Allows authenticating using biometrics, password managers, hardware keys, etc.
</Trans>
</AlertDescription>
</div>
<Button asChild variant="outline" className="bg-background">
<Link href="/settings/security/passkeys">Manage passkeys</Link>
<Link href="/settings/security/passkeys">
<Trans>Manage passkeys</Trans>
</Link>
</Button>
</Alert>
)}
@ -100,15 +126,19 @@ export default async function SecuritySettingsPage() {
variant="neutral"
>
<div className="mb-4 mr-4 sm:mb-0">
<AlertTitle>Recent activity</AlertTitle>
<AlertTitle>
<Trans>Recent activity</Trans>
</AlertTitle>
<AlertDescription className="mr-2">
View all recent security activity related to your account.
<Trans>View all recent security activity related to your account.</Trans>
</AlertDescription>
</div>
<Button asChild variant="outline" className="bg-background">
<Link href="/settings/security/activity">View activity</Link>
<Link href="/settings/security/activity">
<Trans>View activity</Trans>
</Link>
</Button>
</Alert>
</div>

View File

@ -3,6 +3,8 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { startRegistration } from '@simplewebauthn/browser';
import { KeyRoundIcon } from 'lucide-react';
@ -53,6 +55,7 @@ export const CreatePasskeyDialog = ({ trigger, onSuccess, ...props }: CreatePass
const [open, setOpen] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const { _ } = useLingui();
const { toast } = useToast();
const form = useForm<TCreatePasskeyFormSchema>({
@ -81,7 +84,7 @@ export const CreatePasskeyDialog = ({ trigger, onSuccess, ...props }: CreatePass
});
toast({
description: 'Successfully created passkey',
description: _(msg`Successfully created passkey`),
duration: 5000,
});
@ -140,17 +143,22 @@ export const CreatePasskeyDialog = ({ trigger, onSuccess, ...props }: CreatePass
{trigger ?? (
<Button variant="secondary" loading={isLoading}>
<KeyRoundIcon className="-ml-1 mr-1 h-5 w-5" />
Add passkey
<Trans>Add passkey</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>Add passkey</DialogTitle>
<DialogTitle>
<Trans>Add passkey</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
Passkeys allow you to sign in and authenticate using biometrics, password managers, etc.
<Trans>
Passkeys allow you to sign in and authenticate using biometrics, password managers,
etc.
</Trans>
</DialogDescription>
</DialogHeader>
@ -165,7 +173,9 @@ export const CreatePasskeyDialog = ({ trigger, onSuccess, ...props }: CreatePass
name="passkeyName"
render={({ field }) => (
<FormItem>
<FormLabel required>Passkey name</FormLabel>
<FormLabel required>
<Trans>Passkey name</Trans>
</FormLabel>
<FormControl>
<Input className="bg-background" placeholder="eg. Mac" {...field} />
</FormControl>
@ -176,13 +186,17 @@ export const CreatePasskeyDialog = ({ trigger, onSuccess, ...props }: CreatePass
<Alert variant="neutral">
<AlertDescription>
When you click continue, you will be prompted to add the first available
authenticator on your system.
<Trans>
When you click continue, you will be prompted to add the first available
authenticator on your system.
</Trans>
</AlertDescription>
<AlertDescription className="mt-2">
If you do not want to use the authenticator prompted, you can close it, which will
then display the next available authenticator.
<Trans>
If you do not want to use the authenticator prompted, you can close it, which
will then display the next available authenticator.
</Trans>
</AlertDescription>
</Alert>
@ -190,30 +204,40 @@ export const CreatePasskeyDialog = ({ trigger, onSuccess, ...props }: CreatePass
<Alert variant="destructive">
{match(formError)
.with('ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED', () => (
<AlertDescription>This passkey has already been registered.</AlertDescription>
<AlertDescription>
<Trans>This passkey has already been registered.</Trans>
</AlertDescription>
))
.with('TOO_MANY_PASSKEYS', () => (
<AlertDescription>
You cannot have more than {MAXIMUM_PASSKEYS} passkeys.
<Trans>You cannot have more than {MAXIMUM_PASSKEYS} passkeys.</Trans>
</AlertDescription>
))
.with('InvalidStateError', () => (
<>
<AlertTitle className="text-sm">
Passkey creation cancelled due to one of the following reasons:
<Trans>
Passkey creation cancelled due to one of the following reasons:
</Trans>
</AlertTitle>
<AlertDescription>
<ul className="mt-1 list-inside list-disc">
<li>Cancelled by user</li>
<li>Passkey already exists for the provided authenticator</li>
<li>Exceeded timeout</li>
<li>
<Trans>Cancelled by user</Trans>
</li>
<li>
<Trans>Passkey already exists for the provided authenticator</Trans>
</li>
<li>
<Trans>Exceeded timeout</Trans>
</li>
</ul>
</AlertDescription>
</>
))
.otherwise(() => (
<AlertDescription>
Something went wrong. Please try again or contact support.
<Trans>Something went wrong. Please try again or contact support.</Trans>
</AlertDescription>
))}
</Alert>
@ -221,11 +245,11 @@ export const CreatePasskeyDialog = ({ trigger, onSuccess, ...props }: CreatePass
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
Cancel
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
Continue
<Trans>Continue</Trans>
</Button>
</DialogFooter>
</fieldset>

View File

@ -1,6 +1,10 @@
import type { Metadata } from 'next';
import { redirect } from 'next/navigation';
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
@ -13,6 +17,9 @@ export const metadata: Metadata = {
};
export default async function SettingsManagePasskeysPage() {
setupI18nSSR();
const { _ } = useLingui();
const isPasskeyEnabled = await getServerComponentFlag('app_passkey');
if (!isPasskeyEnabled) {
@ -21,7 +28,11 @@ export default async function SettingsManagePasskeysPage() {
return (
<div>
<SettingsHeader title="Passkeys" subtitle="Manage your passkeys." hideDivider={true}>
<SettingsHeader
title={_(msg`Passkeys`)}
subtitle={_(msg`Manage your passkeys.`)}
hideDivider={true}
>
<CreatePasskeyDialog />
</SettingsHeader>

View File

@ -1,6 +1,8 @@
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
@ -45,6 +47,7 @@ export const UserPasskeysDataTableActions = ({
passkeyId,
passkeyName,
}: UserPasskeysDataTableActionsProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
@ -61,15 +64,16 @@ export const UserPasskeysDataTableActions = ({
trpc.auth.updatePasskey.useMutation({
onSuccess: () => {
toast({
title: 'Success',
description: 'Passkey has been updated',
title: _(msg`Success`),
description: _(msg`Passkey has been updated`),
});
},
onError: () => {
toast({
title: 'Something went wrong',
description:
'We are unable to update this passkey at the moment. Please try again later.',
title: _(msg`Something went wrong`),
description: _(
msg`We are unable to update this passkey at the moment. Please try again later.`,
),
duration: 10000,
variant: 'destructive',
});
@ -80,15 +84,16 @@ export const UserPasskeysDataTableActions = ({
trpc.auth.deletePasskey.useMutation({
onSuccess: () => {
toast({
title: 'Success',
description: 'Passkey has been removed',
title: _(msg`Success`),
description: _(msg`Passkey has been removed`),
});
},
onError: () => {
toast({
title: 'Something went wrong',
description:
'We are unable to remove this passkey at the moment. Please try again later.',
title: _(msg`Something went wrong`),
description: _(
msg`We are unable to remove this passkey at the moment. Please try again later.`,
),
duration: 10000,
variant: 'destructive',
});
@ -102,15 +107,21 @@ export const UserPasskeysDataTableActions = ({
onOpenChange={(value) => !isUpdatingPasskey && setIsUpdateDialogOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
<Button variant="outline">Edit</Button>
<Button variant="outline">
<Trans>Edit</Trans>
</Button>
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>Update passkey</DialogTitle>
<DialogTitle>
<Trans>Update passkey</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
You are currently updating the <strong>{passkeyName}</strong> passkey.
<Trans>
You are currently updating the <strong>{passkeyName}</strong> passkey.
</Trans>
</DialogDescription>
</DialogHeader>
@ -129,7 +140,9 @@ export const UserPasskeysDataTableActions = ({
name="name"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel required>Name</FormLabel>
<FormLabel required>
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
@ -141,12 +154,12 @@ export const UserPasskeysDataTableActions = ({
<DialogFooter className="mt-4">
<DialogClose asChild>
<Button type="button" variant="secondary">
Cancel
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button type="submit" loading={isUpdatingPasskey}>
Update
<Trans>Update</Trans>
</Button>
</DialogFooter>
</fieldset>
@ -160,15 +173,21 @@ export const UserPasskeysDataTableActions = ({
onOpenChange={(value) => !isDeletingPasskey && setIsDeleteDialogOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
<Button variant="destructive">Delete</Button>
<Button variant="destructive">
<Trans>Delete</Trans>
</Button>
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>Delete passkey</DialogTitle>
<DialogTitle>
<Trans>Delete passkey</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
Are you sure you want to remove the <strong>{passkeyName}</strong> passkey.
<Trans>
Are you sure you want to remove the <strong>{passkeyName}</strong> passkey.
</Trans>
</DialogDescription>
</DialogHeader>
@ -176,7 +195,7 @@ export const UserPasskeysDataTableActions = ({
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary">
Cancel
<Trans>Cancel</Trans>
</Button>
</DialogClose>
@ -189,7 +208,7 @@ export const UserPasskeysDataTableActions = ({
variant="destructive"
loading={isDeletingPasskey}
>
Delete
<Trans>Delete</Trans>
</Button>
</DialogFooter>
</fieldset>

View File

@ -1,12 +1,17 @@
'use client';
import { useMemo } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { DateTime } from 'luxon';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
import { trpc } from '@documenso/trpc/react';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
@ -15,6 +20,8 @@ import { TableCell } from '@documenso/ui/primitives/table';
import { UserPasskeysDataTableActions } from './user-passkeys-data-table-actions';
export const UserPasskeysDataTable = () => {
const { _ } = useLingui();
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
@ -48,38 +55,42 @@ export const UserPasskeysDataTable = () => {
totalPages: 1,
};
const columns = useMemo(() => {
return [
{
header: _(msg`Name`),
accessorKey: 'name',
},
{
header: _(msg`Created`),
accessorKey: 'createdAt',
cell: ({ row }) => DateTime.fromJSDate(row.original.createdAt).toRelative(),
},
{
header: _(msg`Last used`),
accessorKey: 'updatedAt',
cell: ({ row }) =>
row.original.lastUsedAt
? DateTime.fromJSDate(row.original.lastUsedAt).toRelative()
: msg`Never`,
},
{
id: 'actions',
cell: ({ row }) => (
<UserPasskeysDataTableActions
className="justify-end"
passkeyId={row.original.id}
passkeyName={row.original.name}
/>
),
},
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
}, []);
return (
<DataTable
columns={[
{
header: 'Name',
accessorKey: 'name',
},
{
header: 'Created',
accessorKey: 'createdAt',
cell: ({ row }) => DateTime.fromJSDate(row.original.createdAt).toRelative(),
},
{
header: 'Last used',
accessorKey: 'updatedAt',
cell: ({ row }) =>
row.original.lastUsedAt
? DateTime.fromJSDate(row.original.lastUsedAt).toRelative()
: 'Never',
},
{
id: 'actions',
cell: ({ row }) => (
<UserPasskeysDataTableActions
className="justify-end"
passkeyId={row.original.id}
passkeyName={row.original.name}
/>
),
},
]}
columns={columns}
data={results.data}
perPage={results.perPage}
currentPage={results.currentPage}

View File

@ -1,5 +1,8 @@
'use client';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
@ -9,6 +12,7 @@ export type AcceptTeamInvitationButtonProps = {
};
export const AcceptTeamInvitationButton = ({ teamId }: AcceptTeamInvitationButtonProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const {
@ -18,17 +22,17 @@ export const AcceptTeamInvitationButton = ({ teamId }: AcceptTeamInvitationButto
} = trpc.team.acceptTeamInvitation.useMutation({
onSuccess: () => {
toast({
title: 'Success',
description: 'Accepted team invitation',
title: _(msg`Success`),
description: _(msg`Accepted team invitation`),
duration: 5000,
});
},
onError: () => {
toast({
title: 'Something went wrong',
title: _(msg`Something went wrong`),
description: _(msg`Unable to join this team at this time.`),
variant: 'destructive',
duration: 10000,
description: 'Unable to join this team at this time.',
});
},
});
@ -39,7 +43,7 @@ export const AcceptTeamInvitationButton = ({ teamId }: AcceptTeamInvitationButto
loading={isLoading}
disabled={isLoading || isSuccess}
>
Accept
<Trans>Accept</Trans>
</Button>
);
};

View File

@ -1,5 +1,8 @@
'use client';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
@ -9,6 +12,7 @@ export type DeclineTeamInvitationButtonProps = {
};
export const DeclineTeamInvitationButton = ({ teamId }: DeclineTeamInvitationButtonProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const {
@ -18,17 +22,17 @@ export const DeclineTeamInvitationButton = ({ teamId }: DeclineTeamInvitationBut
} = trpc.team.declineTeamInvitation.useMutation({
onSuccess: () => {
toast({
title: 'Success',
description: 'Declined team invitation',
title: _(msg`Success`),
description: _(msg`Declined team invitation`),
duration: 5000,
});
},
onError: () => {
toast({
title: 'Something went wrong',
title: _(msg`Something went wrong`),
description: _(msg`Unable to decline this team invitation at this time.`),
variant: 'destructive',
duration: 10000,
description: 'Unable to decline this team invitation at this time.',
});
},
});
@ -40,7 +44,7 @@ export const DeclineTeamInvitationButton = ({ teamId }: DeclineTeamInvitationBut
disabled={isLoading || isSuccess}
variant="ghost"
>
Decline
<Trans>Decline</Trans>
</Button>
);
};

View File

@ -1,5 +1,7 @@
'use client';
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { AnimatePresence } from 'framer-motion';
import { trpc } from '@documenso/trpc/react';
@ -13,11 +15,16 @@ import { TeamEmailUsage } from './team-email-usage';
import { TeamInvitations } from './team-invitations';
export default function TeamsSettingsPage() {
const { _ } = useLingui();
const { data: teamEmail } = trpc.team.getTeamEmailByEmail.useQuery();
return (
<div>
<SettingsHeader title="Teams" subtitle="Manage all teams you are currently associated with.">
<SettingsHeader
title={_(msg`Teams`)}
subtitle={_(msg`Manage all teams you are currently associated with.`)}
>
<CreateTeamDialog />
</SettingsHeader>

View File

@ -2,6 +2,9 @@
import { useState } from 'react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import type { TeamEmail } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
@ -24,24 +27,26 @@ export type TeamEmailUsageProps = {
export const TeamEmailUsage = ({ teamEmail }: TeamEmailUsageProps) => {
const [open, setOpen] = useState(false);
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: deleteTeamEmail, isLoading: isDeletingTeamEmail } =
trpc.team.deleteTeamEmail.useMutation({
onSuccess: () => {
toast({
title: 'Success',
description: 'You have successfully revoked access.',
title: _(msg`Success`),
description: _(msg`You have successfully revoked access.`),
duration: 5000,
});
},
onError: () => {
toast({
title: 'Something went wrong',
title: _(msg`Something went wrong`),
description: _(
msg`We encountered an unknown error while attempting to revoke access. Please try again or contact support.`,
),
variant: 'destructive',
duration: 10000,
description:
'We encountered an unknown error while attempting to revoke access. Please try again or contact support.',
});
},
});
@ -49,43 +54,59 @@ export const TeamEmailUsage = ({ teamEmail }: TeamEmailUsageProps) => {
return (
<Alert variant="neutral" className="flex flex-row items-center justify-between p-6">
<div>
<AlertTitle className="mb-0">Team Email</AlertTitle>
<AlertTitle className="mb-0">
<Trans>Team Email</Trans>
</AlertTitle>
<AlertDescription>
<p>
Your email is currently being used by team{' '}
<span className="font-semibold">{teamEmail.team.name}</span> ({teamEmail.team.url}
).
<Trans>
Your email is currently being used by team{' '}
<span className="font-semibold">{teamEmail.team.name}</span> ({teamEmail.team.url}
).
</Trans>
</p>
<p className="mt-1">They have permission on your behalf to:</p>
<p className="mt-1">
<Trans>They have permission on your behalf to:</Trans>
</p>
<ul className="mt-0.5 list-inside list-disc">
<li>Display your name and email in documents</li>
<li>View all documents sent to your account</li>
<li>
<Trans>Display your name and email in documents</Trans>
</li>
<li>
<Trans>View all documents sent to your account</Trans>
</li>
</ul>
</AlertDescription>
</div>
<Dialog open={open} onOpenChange={(value) => !isDeletingTeamEmail && setOpen(value)}>
<DialogTrigger asChild>
<Button variant="destructive">Revoke access</Button>
<Button variant="destructive">
<Trans>Revoke access</Trans>
</Button>
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>Are you sure?</DialogTitle>
<DialogTitle>
<Trans>Are you sure?</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
You are about to revoke access for team{' '}
<span className="font-semibold">{teamEmail.team.name}</span> ({teamEmail.team.url}) to
use your email.
<Trans>
You are about to revoke access for team{' '}
<span className="font-semibold">{teamEmail.team.name}</span> ({teamEmail.team.url})
to use your email.
</Trans>
</DialogDescription>
</DialogHeader>
<fieldset disabled={isDeletingTeamEmail}>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
Cancel
<Trans>Cancel</Trans>
</Button>
<Button
@ -94,7 +115,7 @@ export const TeamEmailUsage = ({ teamEmail }: TeamEmailUsageProps) => {
loading={isDeletingTeamEmail}
onClick={async () => deleteTeamEmail({ teamId: teamEmail.teamId })}
>
Revoke
<Trans>Revoke</Trans>
</Button>
</DialogFooter>
</fieldset>

View File

@ -1,5 +1,6 @@
'use client';
import { Plural, Trans } from '@lingui/macro';
import { AnimatePresence } from 'framer-motion';
import { BellIcon } from 'lucide-react';
@ -33,23 +34,48 @@ export const TeamInvitations = () => {
<BellIcon className="mr-4 h-5 w-5 text-blue-800" />
<AlertDescription className="mr-2">
You have <strong>{data.length}</strong> pending team invitation
{data.length > 1 ? 's' : ''}.
<Plural
value={data.length}
one={
<span>
You have <strong>1</strong> pending team invitation
</span>
}
other={
<span>
You have <strong>#</strong> pending team invitations
</span>
}
/>
</AlertDescription>
<Dialog>
<DialogTrigger asChild>
<button className="ml-auto text-sm font-medium text-blue-700 hover:text-blue-600">
View invites
<Trans>View invites</Trans>
</button>
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>Pending invitations</DialogTitle>
<DialogTitle>
<Trans>Pending invitations</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
You have {data.length} pending team invitation{data.length > 1 ? 's' : ''}.
<Plural
value={data.length}
one={
<span>
You have <strong>1</strong> pending team invitation
</span>
}
other={
<span>
You have <strong>#</strong> pending team invitations
</span>
}
/>
</DialogDescription>
</DialogHeader>

View File

@ -1,5 +1,7 @@
import { Trans } from '@lingui/macro';
import { DateTime } from 'luxon';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getUserTokens } from '@documenso/lib/server-only/public-api/get-all-user-tokens';
import { Button } from '@documenso/ui/primitives/button';
@ -9,25 +11,31 @@ import { LocaleDate } from '~/components/formatter/locale-date';
import { ApiTokenForm } from '~/components/forms/token';
export default async function ApiTokensPage() {
setupI18nSSR();
const { user } = await getRequiredServerComponentSession();
const tokens = await getUserTokens({ userId: user.id });
return (
<div>
<h3 className="text-2xl font-semibold">API Tokens</h3>
<h3 className="text-2xl font-semibold">
<Trans>API Tokens</Trans>
</h3>
<p className="text-muted-foreground mt-2 text-sm">
On this page, you can create new API tokens and manage the existing ones. <br />
Also see our{' '}
<a
className="text-primary underline"
href={'https://docs.documenso.com/developers/public-api'}
target="_blank"
>
Documentation
</a>
.
<Trans>
On this page, you can create new API tokens and manage the existing ones. <br />
Also see our{' '}
<a
className="text-primary underline"
href={'https://docs.documenso.com/developers/public-api'}
target="_blank"
>
Documentation
</a>
.
</Trans>
</p>
<hr className="my-4" />
@ -36,12 +44,14 @@ export default async function ApiTokensPage() {
<hr className="mb-4 mt-8" />
<h4 className="text-xl font-medium">Your existing tokens</h4>
<h4 className="text-xl font-medium">
<Trans>Your existing tokens</Trans>
</h4>
{tokens.length === 0 && (
<div className="mb-4">
<p className="text-muted-foreground mt-2 text-sm italic">
Your tokens will be shown here once you create them.
<Trans>Your tokens will be shown here once you create them.</Trans>
</p>
</div>
)}
@ -55,22 +65,26 @@ export default async function ApiTokensPage() {
<h5 className="text-base">{token.name}</h5>
<p className="text-muted-foreground mt-2 text-xs">
Created on <LocaleDate date={token.createdAt} format={DateTime.DATETIME_FULL} />
<Trans>Created on</Trans>{' '}
<LocaleDate date={token.createdAt} format={DateTime.DATETIME_FULL} />
</p>
{token.expires ? (
<p className="text-muted-foreground mt-1 text-xs">
Expires on <LocaleDate date={token.expires} format={DateTime.DATETIME_FULL} />
<Trans>Expires on</Trans>{' '}
<LocaleDate date={token.expires} format={DateTime.DATETIME_FULL} />
</p>
) : (
<p className="text-muted-foreground mt-1 text-xs">
Token doesn't have an expiration date
<Trans>Token doesn't have an expiration date</Trans>
</p>
)}
</div>
<div>
<DeleteTokenDialog token={token}>
<Button variant="destructive">Delete</Button>
<Button variant="destructive">
<Trans>Delete</Trans>
</Button>
</DeleteTokenDialog>
</div>
</div>

View File

@ -3,6 +3,8 @@
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { Loader } from 'lucide-react';
import { useForm } from 'react-hook-form';
import type { z } from 'zod';
@ -38,6 +40,7 @@ export type WebhookPageOptions = {
};
export default function WebhookPage({ params }: WebhookPageOptions) {
const { _ } = useLingui();
const { toast } = useToast();
const router = useRouter();
@ -68,16 +71,18 @@ export default function WebhookPage({ params }: WebhookPageOptions) {
});
toast({
title: 'Webhook updated',
description: 'The webhook has been updated successfully.',
title: _(msg`Webhook updated`),
description: _(msg`The webhook has been updated successfully.`),
duration: 5000,
});
router.refresh();
} catch (err) {
toast({
title: 'Failed to update webhook',
description: 'We encountered an error while updating the webhook. Please try again later.',
title: _(msg`Failed to update webhook`),
description: _(
msg`We encountered an error while updating the webhook. Please try again later.`,
),
variant: 'destructive',
});
}
@ -86,8 +91,8 @@ export default function WebhookPage({ params }: WebhookPageOptions) {
return (
<div>
<SettingsHeader
title="Edit webhook"
subtitle="On this page, you can edit the webhook and its settings."
title={_(msg`Edit webhook`)}
subtitle={_(msg`On this page, you can edit the webhook and its settings.`)}
/>
{isLoading && (
@ -108,13 +113,15 @@ export default function WebhookPage({ params }: WebhookPageOptions) {
name="webhookUrl"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel required>Webhook URL</FormLabel>
<FormLabel required>
<Trans>Webhook URL</Trans>
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
<FormDescription>
The URL for Documenso to send webhook events to.
<Trans>The URL for Documenso to send webhook events to.</Trans>
</FormDescription>
<FormMessage />
@ -127,7 +134,9 @@ export default function WebhookPage({ params }: WebhookPageOptions) {
name="enabled"
render={({ field }) => (
<FormItem>
<FormLabel>Enabled</FormLabel>
<FormLabel>
<Trans>Enabled</Trans>
</FormLabel>
<div>
<FormControl>
@ -150,7 +159,9 @@ export default function WebhookPage({ params }: WebhookPageOptions) {
name="eventTriggers"
render={({ field: { onChange, value } }) => (
<FormItem className="flex flex-col gap-2">
<FormLabel required>Triggers</FormLabel>
<FormLabel required>
<Trans>Triggers</Trans>
</FormLabel>
<FormControl>
<TriggerMultiSelectCombobox
listValues={value}
@ -161,7 +172,7 @@ export default function WebhookPage({ params }: WebhookPageOptions) {
</FormControl>
<FormDescription>
The events that will trigger a webhook to be sent to your URL.
<Trans> The events that will trigger a webhook to be sent to your URL.</Trans>
</FormDescription>
<FormMessage />
@ -174,14 +185,18 @@ export default function WebhookPage({ params }: WebhookPageOptions) {
name="secret"
render={({ field }) => (
<FormItem>
<FormLabel>Secret</FormLabel>
<FormLabel>
<Trans>Secret</Trans>
</FormLabel>
<FormControl>
<PasswordInput className="bg-background" {...field} value={field.value ?? ''} />
</FormControl>
<FormDescription>
A secret that will be sent to your URL so you can verify that the request has
been sent by Documenso.
<Trans>
A secret that will be sent to your URL so you can verify that the request has
been sent by Documenso.
</Trans>
</FormDescription>
<FormMessage />
</FormItem>
@ -190,7 +205,7 @@ export default function WebhookPage({ params }: WebhookPageOptions) {
<div className="mt-4">
<Button type="submit" loading={form.formState.isSubmitting}>
Update webhook
<Trans>Update webhook</Trans>
</Button>
</div>
</fieldset>

View File

@ -2,6 +2,8 @@
import Link from 'next/link';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { Loader } from 'lucide-react';
import { DateTime } from 'luxon';
@ -17,13 +19,15 @@ import { DeleteWebhookDialog } from '~/components/(dashboard)/settings/webhooks/
import { LocaleDate } from '~/components/formatter/locale-date';
export default function WebhookPage() {
const { _ } = useLingui();
const { data: webhooks, isLoading } = trpc.webhook.getWebhooks.useQuery();
return (
<div>
<SettingsHeader
title="Webhooks"
subtitle="On this page, you can create new Webhooks and manage the existing ones."
title={_(msg`Webhooks`)}
subtitle={_(msg`On this page, you can create new Webhooks and manage the existing ones.`)}
>
<CreateWebhookDialog />
</SettingsHeader>
@ -38,7 +42,9 @@ export default function WebhookPage() {
// TODO: Perhaps add some illustrations here to make the page more engaging
<div className="mb-4">
<p className="text-muted-foreground mt-2 text-sm italic">
You have no webhooks yet. Your webhooks will be shown here once you create them.
<Trans>
You have no webhooks yet. Your webhooks will be shown here once you create them.
</Trans>
</p>
</div>
)}
@ -66,29 +72,37 @@ export default function WebhookPage() {
</h5>
<Badge variant={webhook.enabled ? 'neutral' : 'warning'} size="small">
{webhook.enabled ? 'Enabled' : 'Disabled'}
{webhook.enabled ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>}
</Badge>
</div>
<p className="text-muted-foreground mt-2 text-xs">
Listening to{' '}
{webhook.eventTriggers
.map((trigger) => toFriendlyWebhookEventName(trigger))
.join(', ')}
<Trans>
Listening to{' '}
{webhook.eventTriggers
.map((trigger) => toFriendlyWebhookEventName(trigger))
.join(', ')}
</Trans>
</p>
<p className="text-muted-foreground mt-2 text-xs">
Created on{' '}
<LocaleDate date={webhook.createdAt} format={DateTime.DATETIME_FULL} />
<Trans>
Created on{' '}
<LocaleDate date={webhook.createdAt} format={DateTime.DATETIME_FULL} />
</Trans>
</p>
</div>
<div className="mt-4 flex flex-shrink-0 gap-4 sm:mt-0">
<Button asChild variant="outline">
<Link href={`/settings/webhooks/${webhook.id}`}>Edit</Link>
<Link href={`/settings/webhooks/${webhook.id}`}>
<Trans>Edit</Trans>
</Link>
</Button>
<DeleteWebhookDialog webhook={webhook}>
<Button variant="destructive">Delete</Button>
<Button variant="destructive">
<Trans>Delete</Trans>
</Button>
</DeleteWebhookDialog>
</div>
</div>

View File

@ -4,6 +4,9 @@ import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import {
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
SKIP_QUERY_BATCH_META,
@ -42,7 +45,9 @@ export const EditTemplateForm = ({
isEnterprise,
templateRootPath,
}: EditTemplateFormProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const router = useRouter();
const team = useOptionalCurrentTeam();
@ -68,18 +73,18 @@ export const EditTemplateForm = ({
const documentFlow: Record<EditTemplateStep, DocumentFlowStep> = {
settings: {
title: 'General',
description: 'Configure general settings for the template.',
title: msg`General`,
description: msg`Configure general settings for the template.`,
stepIndex: 1,
},
signers: {
title: 'Add Placeholders',
description: 'Add all relevant placeholders for each recipient.',
title: msg`Add Placeholders`,
description: msg`Add all relevant placeholders for each recipient.`,
stepIndex: 2,
},
fields: {
title: 'Add Fields',
description: 'Add all relevant fields for each recipient.',
title: msg`Add Fields`,
description: msg`Add all relevant fields for each recipient.`,
stepIndex: 3,
},
};
@ -144,8 +149,8 @@ export const EditTemplateForm = ({
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while updating the document settings.',
title: _(msg`Error`),
description: _(msg`An error occurred while updating the document settings.`),
variant: 'destructive',
});
}
@ -167,8 +172,8 @@ export const EditTemplateForm = ({
setStep('fields');
} catch (err) {
toast({
title: 'Error',
description: 'An error occurred while adding signers.',
title: _(msg`Error`),
description: _(msg`An error occurred while adding signers.`),
variant: 'destructive',
});
}
@ -190,8 +195,8 @@ export const EditTemplateForm = ({
}
toast({
title: 'Template saved',
description: 'Your templates has been saved successfully.',
title: _(msg`Template saved`),
description: _(msg`Your templates has been saved successfully.`),
duration: 5000,
});
@ -201,8 +206,8 @@ export const EditTemplateForm = ({
router.push(templateRootPath);
} catch (err) {
toast({
title: 'Error',
description: 'An error occurred while adding signers.',
title: _(msg`Error`),
description: _(msg`An error occurred while adding signers.`),
variant: 'destructive',
});
}

View File

@ -1,10 +1,14 @@
import React from 'react';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import type { TemplatePageViewProps } from './template-page-view';
import { TemplatePageView } from './template-page-view';
type TemplatePageProps = Pick<TemplatePageViewProps, 'params'>;
export default function TemplatePage({ params }: TemplatePageProps) {
setupI18nSSR();
return <TemplatePageView params={params} />;
}

View File

@ -2,6 +2,7 @@
import React, { useState } from 'react';
import { Trans } from '@lingui/macro';
import { LinkIcon } from 'lucide-react';
import type { Recipient, Template, TemplateDirectLink } from '@documenso/prisma/client';
@ -27,7 +28,12 @@ export const TemplateDirectLinkDialogWrapper = ({ template }: TemplatePageViewPr
}}
>
<LinkIcon className="mr-1.5 h-3.5 w-3.5" />
{template.directLink ? 'Manage' : 'Create'} Direct Link
{template.directLink ? (
<Trans>Manage Direct Link</Trans>
) : (
<Trans>Create Direct Link</Trans>
)}
</Button>
<TemplateDirectLinkDialog

View File

@ -3,6 +3,7 @@ import React from 'react';
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { Trans } from '@lingui/macro';
import { ChevronLeft } from 'lucide-react';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
@ -56,7 +57,7 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
<div>
<Link href="/templates" className="flex items-center text-[#7AC455] hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
Templates
<Trans>Templates</Trans>
</Link>
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>

View File

@ -4,6 +4,7 @@ import { useState } from 'react';
import Link from 'next/link';
import { Trans } from '@lingui/macro';
import { Copy, Edit, MoreHorizontal, MoveRight, Share2Icon, Trash2 } from 'lucide-react';
import { useSession } from 'next-auth/react';
@ -58,7 +59,7 @@ export const DataTableActionDropdown = ({
<DropdownMenuItem disabled={!isOwner && !isTeamTemplate} asChild>
<Link href={`${templateRootPath}/${row.id}`}>
<Edit className="mr-2 h-4 w-4" />
Edit
<Trans>Edit</Trans>
</Link>
</DropdownMenuItem>
@ -67,18 +68,18 @@ export const DataTableActionDropdown = ({
onClick={() => setDuplicateDialogOpen(true)}
>
<Copy className="mr-2 h-4 w-4" />
Duplicate
<Trans>Duplicate</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTemplateDirectLinkDialogOpen(true)}>
<Share2Icon className="mr-2 h-4 w-4" />
Direct link
<Trans>Direct link</Trans>
</DropdownMenuItem>
{!teamId && (
<DropdownMenuItem onClick={() => setMoveDialogOpen(true)}>
<MoveRight className="mr-2 h-4 w-4" />
Move to Team
<Trans>Move to Team</Trans>
</DropdownMenuItem>
)}
@ -87,7 +88,7 @@ export const DataTableActionDropdown = ({
onClick={() => setDeleteDialogOpen(true)}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
<Trans>Delete</Trans>
</DropdownMenuItem>
</DropdownMenuContent>

View File

@ -1,15 +1,18 @@
'use client';
import { useTransition } from 'react';
import { useMemo, useTransition } from 'react';
import Link from 'next/link';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { AlertTriangle, Globe2Icon, InfoIcon, Link2Icon, Loader, LockIcon } from 'lucide-react';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import type { FindTemplateRow } from '@documenso/lib/server-only/template/find-templates';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
@ -45,8 +48,119 @@ export const TemplatesDataTable = ({
const updateSearchParams = useUpdateSearchParams();
const { _ } = useLingui();
const { remaining } = useLimits();
const columns = useMemo(() => {
return [
{
header: _(msg`Created`),
accessorKey: 'createdAt',
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
},
{
header: _(msg`Title`),
cell: ({ row }) => <DataTableTitle row={row.original} />,
},
{
header: () => (
<div className="flex flex-row items-center">
<Trans>Type</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 !p-0">
<ul className="text-muted-foreground space-y-0.5 divide-y [&>li]:p-4">
<li>
<h2 className="mb-2 flex flex-row items-center font-semibold">
<Globe2Icon className="mr-2 h-5 w-5 text-green-500 dark:text-green-300" />
<Trans>Public</Trans>
</h2>
<p>
<Trans>
Public templates are connected to your public profile. Any modifications
to public templates will also appear in your public profile.
</Trans>
</p>
</li>
<li>
<div className="mb-2 flex w-fit flex-row items-center rounded border border-neutral-300 bg-neutral-200 px-1.5 py-0.5 text-xs dark:border-neutral-500 dark:bg-neutral-600">
<Link2Icon className="mr-1 h-3 w-3" />
<Trans>direct link</Trans>
</div>
<p>
<Trans>
Direct link templates contain one dynamic recipient placeholder. Anyone
with access to this link can sign the document, and it will then appear
on your documents page.
</Trans>
</p>
</li>
<li>
<h2 className="mb-2 flex flex-row items-center font-semibold">
<LockIcon className="mr-2 h-5 w-5 text-blue-600 dark:text-blue-300" />
{teamId ? <Trans>Team Only</Trans> : <Trans>Private</Trans>}
</h2>
<p>
{teamId ? (
<Trans>
Team only templates are not linked anywhere and are visible only to
your team.
</Trans>
) : (
<Trans>Private templates can only be modified and viewed by you.</Trans>
)}
</p>
</li>
</ul>
</TooltipContent>
</Tooltip>
</div>
),
accessorKey: 'type',
cell: ({ row }) => (
<div className="flex flex-row items-center">
<TemplateType type="PRIVATE" />
{row.original.directLink?.token && (
<TemplateDirectLinkBadge
className="ml-2"
token={row.original.directLink.token}
enabled={row.original.directLink.enabled}
/>
)}
</div>
),
},
{
header: _(msg`Actions`),
accessorKey: 'actions',
cell: ({ row }) => {
return (
<div className="flex items-center gap-x-4">
<UseTemplateDialog
templateId={row.original.id}
recipients={row.original.Recipient}
documentRootPath={documentRootPath}
/>
<DataTableActionDropdown
row={row.original}
teamId={teamId}
templateRootPath={templateRootPath}
/>
</div>
);
},
},
] satisfies DataTableColumnDef<(typeof templates)[number]>[];
}, [documentRootPath, teamId, templateRootPath]);
const onPaginationChange = (page: number, perPage: number) => {
startTransition(() => {
updateSearchParams({
@ -61,115 +175,22 @@ export const TemplatesDataTable = ({
{remaining.documents === 0 && (
<Alert variant="warning" className="mb-4">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Document Limit Exceeded!</AlertTitle>
<AlertTitle>
<Trans>Document Limit Exceeded!</Trans>
</AlertTitle>
<AlertDescription className="mt-2">
You have reached your document limit.{' '}
<Link className="underline underline-offset-4" href="/settings/billing">
Upgrade your account to continue!
</Link>
<Trans>
You have reached your document limit.{' '}
<Link className="underline underline-offset-4" href="/settings/billing">
Upgrade your account to continue!
</Link>
</Trans>
</AlertDescription>
</Alert>
)}
<DataTable
columns={[
{
header: 'Created',
accessorKey: 'createdAt',
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
},
{
header: 'Title',
cell: ({ row }) => <DataTableTitle row={row.original} />,
},
{
header: () => (
<div className="flex flex-row items-center">
Type
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 !p-0">
<ul className="text-muted-foreground space-y-0.5 divide-y [&>li]:p-4">
<li>
<h2 className="mb-2 flex flex-row items-center font-semibold">
<Globe2Icon className="mr-2 h-5 w-5 text-green-500 dark:text-green-300" />
Public
</h2>
<p>
Public templates are connected to your public profile. Any modifications
to public templates will also appear in your public profile.
</p>
</li>
<li>
<div className="mb-2 flex w-fit flex-row items-center rounded border border-neutral-300 bg-neutral-200 px-1.5 py-0.5 text-xs dark:border-neutral-500 dark:bg-neutral-600">
<Link2Icon className="mr-1 h-3 w-3" />
direct link
</div>
<p>
Direct link templates contain one dynamic recipient placeholder. Anyone
with access to this link can sign the document, and it will then appear on
your documents page.
</p>
</li>
<li>
<h2 className="mb-2 flex flex-row items-center font-semibold">
<LockIcon className="mr-2 h-5 w-5 text-blue-600 dark:text-blue-300" />
{teamId ? 'Team Only' : 'Private'}
</h2>
<p>
{teamId
? 'Team only templates are not linked anywhere and are visible only to your team.'
: 'Private templates can only be modified and viewed by you.'}
</p>
</li>
</ul>
</TooltipContent>
</Tooltip>
</div>
),
accessorKey: 'type',
cell: ({ row }) => (
<div className="flex flex-row items-center">
<TemplateType type="PRIVATE" />
{row.original.directLink?.token && (
<TemplateDirectLinkBadge
className="ml-2"
token={row.original.directLink.token}
enabled={row.original.directLink.enabled}
/>
)}
</div>
),
},
{
header: 'Actions',
accessorKey: 'actions',
cell: ({ row }) => {
return (
<div className="flex items-center gap-x-4">
<UseTemplateDialog
templateId={row.original.id}
recipients={row.original.Recipient}
documentRootPath={documentRootPath}
/>
<DataTableActionDropdown
row={row.original}
teamId={teamId}
templateRootPath={templateRootPath}
/>
</div>
);
},
},
]}
columns={columns}
data={templates}
perPage={perPage}
currentPage={page}

View File

@ -1,5 +1,8 @@
import { useRouter } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
@ -27,6 +30,7 @@ export const DeleteTemplateDialog = ({
}: DeleteTemplateDialogProps) => {
const router = useRouter();
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: deleteTemplate, isLoading } = trpcReact.template.deleteTemplate.useMutation({
@ -34,8 +38,8 @@ export const DeleteTemplateDialog = ({
router.refresh();
toast({
title: 'Template deleted',
description: 'Your template has been successfully deleted.',
title: _(msg`Template deleted`),
description: _(msg`Your template has been successfully deleted.`),
duration: 5000,
});
@ -43,8 +47,8 @@ export const DeleteTemplateDialog = ({
},
onError: () => {
toast({
title: 'Something went wrong',
description: 'This template could not be deleted at this time. Please try again.',
title: _(msg`Something went wrong`),
description: _(msg`This template could not be deleted at this time. Please try again.`),
variant: 'destructive',
duration: 7500,
});
@ -55,11 +59,15 @@ export const DeleteTemplateDialog = ({
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Do you want to delete this template?</DialogTitle>
<DialogTitle>
<Trans>Do you want to delete this template?</Trans>
</DialogTitle>
<DialogDescription>
Please note that this action is irreversible. Once confirmed, your template will be
permanently deleted.
<Trans>
Please note that this action is irreversible. Once confirmed, your template will be
permanently deleted.
</Trans>
</DialogDescription>
</DialogHeader>
@ -70,7 +78,7 @@ export const DeleteTemplateDialog = ({
disabled={isLoading}
onClick={() => onOpenChange(false)}
>
Cancel
<Trans>Cancel</Trans>
</Button>
<Button
@ -79,7 +87,7 @@ export const DeleteTemplateDialog = ({
loading={isLoading}
onClick={async () => deleteTemplate({ id, teamId })}
>
Delete
<Trans>Delete</Trans>
</Button>
</DialogFooter>
</DialogContent>

View File

@ -1,5 +1,8 @@
import { useRouter } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
@ -27,6 +30,7 @@ export const DuplicateTemplateDialog = ({
}: DuplicateTemplateDialogProps) => {
const router = useRouter();
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: duplicateTemplate, isLoading } =
@ -35,8 +39,8 @@ export const DuplicateTemplateDialog = ({
router.refresh();
toast({
title: 'Template duplicated',
description: 'Your template has been duplicated successfully.',
title: _(msg`Template duplicated`),
description: _(msg`Your template has been duplicated successfully.`),
duration: 5000,
});
@ -44,8 +48,8 @@ export const DuplicateTemplateDialog = ({
},
onError: () => {
toast({
title: 'Error',
description: 'An error occurred while duplicating template.',
title: _(msg`Error`),
description: _(msg`An error occurred while duplicating template.`),
variant: 'destructive',
});
},
@ -55,9 +59,13 @@ export const DuplicateTemplateDialog = ({
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Do you want to duplicate this template?</DialogTitle>
<DialogTitle>
<Trans>Do you want to duplicate this template?</Trans>
</DialogTitle>
<DialogDescription className="pt-2">Your template will be duplicated.</DialogDescription>
<DialogDescription className="pt-2">
<Trans>Your template will be duplicated.</Trans>
</DialogDescription>
</DialogHeader>
<DialogFooter>
@ -67,7 +75,7 @@ export const DuplicateTemplateDialog = ({
variant="secondary"
onClick={() => onOpenChange(false)}
>
Cancel
<Trans>Cancel</Trans>
</Button>
<Button
@ -80,7 +88,7 @@ export const DuplicateTemplateDialog = ({
})
}
>
Duplicate
<Trans>Duplicate</Trans>
</Button>
</DialogFooter>
</DialogContent>

View File

@ -1,3 +1,4 @@
import { Trans } from '@lingui/macro';
import { Bird } from 'lucide-react';
export const EmptyTemplateState = () => {
@ -6,10 +7,14 @@ export const EmptyTemplateState = () => {
<Bird className="h-12 w-12" strokeWidth={1.5} />
<div className="text-center">
<h3 className="text-lg font-semibold">We're all empty</h3>
<h3 className="text-lg font-semibold">
<Trans>We're all empty</Trans>
</h3>
<p className="mt-2 max-w-[50ch]">
You have not yet created any templates. To create a template please upload one.
<Trans>
You have not yet created any templates. To create a template please upload one.
</Trans>
</p>
</div>
</div>

View File

@ -2,6 +2,9 @@ import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { trpc } from '@documenso/trpc/react';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
@ -31,7 +34,10 @@ type MoveTemplateDialogProps = {
export const MoveTemplateDialog = ({ templateId, open, onOpenChange }: MoveTemplateDialogProps) => {
const router = useRouter();
const { toast } = useToast();
const { _ } = useLingui();
const [selectedTeamId, setSelectedTeamId] = useState<number | null>(null);
const { data: teams, isLoading: isLoadingTeams } = trpc.team.getTeams.useQuery();
@ -39,16 +45,16 @@ export const MoveTemplateDialog = ({ templateId, open, onOpenChange }: MoveTempl
onSuccess: () => {
router.refresh();
toast({
title: 'Template moved',
description: 'The template has been successfully moved to the selected team.',
title: _(msg`Template moved`),
description: _(msg`The template has been successfully moved to the selected team.`),
duration: 5000,
});
onOpenChange(false);
},
onError: (error) => {
toast({
title: 'Error',
description: error.message || 'An error occurred while moving the template.',
title: _(msg`Error`),
description: error.message || _(msg`An error occurred while moving the template.`),
variant: 'destructive',
duration: 7500,
});
@ -67,20 +73,22 @@ export const MoveTemplateDialog = ({ templateId, open, onOpenChange }: MoveTempl
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Move Template to Team</DialogTitle>
<DialogTitle>
<Trans>Move Template to Team</Trans>
</DialogTitle>
<DialogDescription>
Select a team to move this template to. This action cannot be undone.
<Trans>Select a team to move this template to. This action cannot be undone.</Trans>
</DialogDescription>
</DialogHeader>
<Select onValueChange={(value) => setSelectedTeamId(Number(value))}>
<SelectTrigger>
<SelectValue placeholder="Select a team" />
<SelectValue placeholder={_(msg`Select a team`)} />
</SelectTrigger>
<SelectContent>
{isLoadingTeams ? (
<SelectItem value="loading" disabled>
Loading teams...
<Trans>Loading teams...</Trans>
</SelectItem>
) : (
teams?.map((team) => (
@ -108,10 +116,10 @@ export const MoveTemplateDialog = ({ templateId, open, onOpenChange }: MoveTempl
<DialogFooter>
<Button variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
<Trans>Cancel</Trans>
</Button>
<Button onClick={onMove} loading={isLoading} disabled={!selectedTeamId || isLoading}>
{isLoading ? 'Moving...' : 'Move'}
{isLoading ? <Trans>Moving...</Trans> : <Trans>Move</Trans>}
</Button>
</DialogFooter>
</DialogContent>

View File

@ -4,6 +4,8 @@ import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { FilePlus, Loader } from 'lucide-react';
import { useSession } from 'next-auth/react';
@ -34,6 +36,7 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
const { data: session } = useSession();
const { toast } = useToast();
const { _ } = useLingui();
const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation();
@ -61,9 +64,10 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
});
toast({
title: 'Template document uploaded',
description:
'Your document has been uploaded successfully. You will be redirected to the template page.',
title: _(msg`Template document uploaded`),
description: _(
msg`Your document has been uploaded successfully. You will be redirected to the template page.`,
),
duration: 5000,
});
@ -72,8 +76,8 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
router.push(`${templateRootPath}/${id}`);
} catch {
toast({
title: 'Something went wrong',
description: 'Please try again later.',
title: _(msg`Something went wrong`),
description: _(msg`Please try again later.`),
variant: 'destructive',
});
@ -89,15 +93,20 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
<DialogTrigger asChild>
<Button className="cursor-pointer" disabled={!session?.user.emailVerified}>
<FilePlus className="-ml-1 mr-2 h-4 w-4" />
New Template
<Trans>New Template</Trans>
</Button>
</DialogTrigger>
<DialogContent className="w-full max-w-xl">
<DialogHeader>
<DialogTitle>New Template</DialogTitle>
<DialogTitle>
<Trans>New Template</Trans>
</DialogTitle>
<DialogDescription>
Templates allow you to quickly generate documents with pre-filled recipients and fields.
<Trans>
Templates allow you to quickly generate documents with pre-filled recipients and
fields.
</Trans>
</DialogDescription>
</DialogHeader>
@ -114,7 +123,7 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary" disabled={isUploadingFile}>
Close
<Trans>Close</Trans>
</Button>
</DialogClose>
</DialogFooter>

View File

@ -2,6 +2,8 @@ import React from 'react';
import type { Metadata } from 'next';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { TemplatesPageView } from './templates-page-view';
import type { TemplatesPageViewProps } from './templates-page-view';
@ -14,5 +16,7 @@ export const metadata: Metadata = {
};
export default function TemplatesPage({ searchParams = {} }: TemplatesPageProps) {
setupI18nSSR();
return <TemplatesPageView searchParams={searchParams} />;
}

View File

@ -1,5 +1,7 @@
'use client';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { Link2Icon } from 'lucide-react';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
@ -19,13 +21,15 @@ export const TemplateDirectLinkBadge = ({
className,
}: TemplateDirectLinkBadgeProps) => {
const [, copy] = useCopyToClipboard();
const { _ } = useLingui();
const { toast } = useToast();
const onCopyClick = async (token: string) =>
copy(formatDirectTemplatePath(token)).then(() => {
toast({
title: 'Copied to clipboard',
description: 'The direct link has been copied to your clipboard',
title: _(msg`Copied to clipboard`),
description: _(msg`The direct link has been copied to your clipboard`),
});
});
@ -39,7 +43,7 @@ export const TemplateDirectLinkBadge = ({
onClick={async () => onCopyClick(token)}
>
<Link2Icon className="mr-1 h-3 w-3" />
direct link {!enabled && 'disabled'}
{enabled ? <Trans>direct link</Trans> : <Trans>direct link disabled</Trans>}
</button>
);
};

View File

@ -3,16 +3,16 @@ import { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { CircleDotIcon, CircleIcon, ClipboardCopyIcon, InfoIcon, LoaderIcon } from 'lucide-react';
import { P, match } from 'ts-pattern';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import {
DIRECT_TEMPLATE_DOCUMENTATION,
DIRECT_TEMPLATE_RECIPIENT_EMAIL,
} from '@documenso/lib/constants/template';
import { DIRECT_TEMPLATE_DOCUMENTATION } from '@documenso/lib/constants/template';
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
import {
type Recipient,
@ -66,6 +66,7 @@ export const TemplateDirectLinkDialog = ({
}: TemplateDirectLinkDialogProps) => {
const { toast } = useToast();
const { quota, remaining } = useLimits();
const { _ } = useLingui();
const team = useOptionalCurrentTeam();
@ -100,8 +101,8 @@ export const TemplateDirectLinkDialog = ({
setSelectedRecipientId(null);
toast({
title: 'Something went wrong',
description: 'Unable to create direct template access. Please try again later.',
title: _(msg`Something went wrong`),
description: _(msg`Unable to create direct template access. Please try again later.`),
variant: 'destructive',
});
},
@ -110,17 +111,21 @@ export const TemplateDirectLinkDialog = ({
const { mutateAsync: toggleTemplateDirectLink, isLoading: isTogglingTemplateAccess } =
trpcReact.template.toggleTemplateDirectLink.useMutation({
onSuccess: (data) => {
const enabledDescription = msg`Direct link signing has been enabled`;
const disabledDescription = msg`Direct link signing has been disabled`;
toast({
title: 'Success',
description: `Direct link signing has been ${data.enabled ? 'enabled' : 'disabled'}`,
title: _(msg`Success`),
description: _(data.enabled ? enabledDescription : disabledDescription),
});
},
onError: (_ctx, data) => {
const enabledDescription = msg`An error occurred while enabling direct link signing.`;
const disabledDescription = msg`An error occurred while disabling direct link signing.`;
toast({
title: 'Something went wrong',
description: `An error occurred while ${
data.enabled ? 'enabling' : 'disabling'
} direct link signing.`,
title: _(msg`Something went wrong`),
description: _(data.enabled ? enabledDescription : disabledDescription),
variant: 'destructive',
});
},
@ -133,8 +138,8 @@ export const TemplateDirectLinkDialog = ({
setToken(null);
toast({
title: 'Success',
description: 'Direct template link deleted',
title: _(msg`Success`),
description: _(msg`Direct template link deleted`),
duration: 5000,
});
@ -143,9 +148,10 @@ export const TemplateDirectLinkDialog = ({
},
onError: () => {
toast({
title: 'Something went wrong',
description:
'We encountered an error while removing the direct template link. Please try again later.',
title: _(msg`Something went wrong`),
description: _(
msg`We encountered an error while removing the direct template link. Please try again later.`,
),
variant: 'destructive',
});
},
@ -154,8 +160,8 @@ export const TemplateDirectLinkDialog = ({
const onCopyClick = async (token: string) =>
copy(formatDirectTemplatePath(token)).then(() => {
toast({
title: 'Copied to clipboard',
description: 'The direct link has been copied to your clipboard',
title: _(msg`Copied to clipboard`),
description: _(msg`The direct link has been copied to your clipboard`),
});
});
@ -192,9 +198,13 @@ export const TemplateDirectLinkDialog = ({
.with({ token: P.nullish, currentStep: 'ONBOARD' }, () => (
<DialogContent>
<DialogHeader>
<DialogTitle>Create Direct Signing Link</DialogTitle>
<DialogTitle>
<Trans>Create Direct Signing Link</Trans>
</DialogTitle>
<DialogDescription>Here's how it works:</DialogDescription>
<DialogDescription>
<Trans>Here's how it works:</Trans>
</DialogDescription>
</DialogHeader>
<ul className="mt-4 space-y-4 pl-12">
@ -206,8 +216,8 @@ export const TemplateDirectLinkDialog = ({
</div>
</div>
<h3 className="font-semibold">{step.title}</h3>
<p className="text-muted-foreground mt-1 text-sm">{step.description}</p>
<h3 className="font-semibold">{_(step.title)}</h3>
<p className="text-muted-foreground mt-1 text-sm">{_(step.description)}</p>
</li>
))}
</ul>
@ -215,18 +225,22 @@ export const TemplateDirectLinkDialog = ({
{remaining.directTemplates === 0 && (
<Alert variant="warning">
<AlertTitle>
Direct template link usage exceeded ({quota.directTemplates}/
{quota.directTemplates})
<Trans>
Direct template link usage exceeded ({quota.directTemplates}/
{quota.directTemplates})
</Trans>
</AlertTitle>
<AlertDescription>
You have reached the maximum limit of {quota.directTemplates} direct
templates.{' '}
<Link
className="mt-1 block underline underline-offset-4"
href="/settings/billing"
>
Upgrade your account to continue!
</Link>
<Trans>
You have reached the maximum limit of {quota.directTemplates} direct
templates.{' '}
<Link
className="mt-1 block underline underline-offset-4"
href="/settings/billing"
>
Upgrade your account to continue!
</Link>
</Trans>
</AlertDescription>
</Alert>
)}
@ -234,7 +248,7 @@ export const TemplateDirectLinkDialog = ({
{remaining.directTemplates !== 0 && (
<DialogFooter className="mx-auto mt-4">
<Button type="button" onClick={() => setCurrentStep('SELECT_RECIPIENT')}>
Enable direct link signing
<Trans> Enable direct link signing</Trans>
</Button>
</DialogFooter>
)}
@ -249,10 +263,12 @@ export const TemplateDirectLinkDialog = ({
)}
<DialogHeader>
<DialogTitle>Choose Direct Link Recipient</DialogTitle>
<DialogTitle>
<Trans>Choose Direct Link Recipient</Trans>
</DialogTitle>
<DialogDescription>
Choose an existing recipient from below to continue
<Trans>Choose an existing recipient from below to continue</Trans>
</DialogDescription>
</DialogHeader>
@ -260,8 +276,12 @@ export const TemplateDirectLinkDialog = ({
<Table>
<TableHeader>
<TableRow>
<TableHead>Recipient</TableHead>
<TableHead>Role</TableHead>
<TableHead>
<Trans>Recipient</Trans>
</TableHead>
<TableHead>
<Trans>Role</Trans>
</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
@ -269,7 +289,9 @@ export const TemplateDirectLinkDialog = ({
{validDirectTemplateRecipients.length === 0 && (
<TableRow>
<TableCell colSpan={3} className="h-16 text-center">
<p className="text-muted-foreground">No valid recipients found</p>
<p className="text-muted-foreground">
<Trans>No valid recipients found</Trans>
</p>
</TableCell>
</TableRow>
)}
@ -288,7 +310,7 @@ export const TemplateDirectLinkDialog = ({
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{RECIPIENT_ROLES_DESCRIPTION[row.role].roleName}
{_(RECIPIENT_ROLES_DESCRIPTION[row.role].roleName)}
</TableCell>
<TableCell>
@ -311,7 +333,9 @@ export const TemplateDirectLinkDialog = ({
<DialogFooter className="mx-auto">
<div className="flex flex-col items-center justify-center">
{validDirectTemplateRecipients.length !== 0 && (
<p className="text-muted-foreground text-sm">Or</p>
<p className="text-muted-foreground text-sm">
<Trans>Or</Trans>
</p>
)}
<Button
@ -325,7 +349,7 @@ export const TemplateDirectLinkDialog = ({
})
}
>
Create one automatically
<Trans>Create one automatically</Trans>
</Button>
</div>
</DialogFooter>
@ -335,23 +359,28 @@ export const TemplateDirectLinkDialog = ({
.with({ token: P.string, currentStep: 'MANAGE' }, ({ token }) => (
<DialogContent className="relative">
<DialogHeader>
<DialogTitle>Direct Link Signing</DialogTitle>
<DialogTitle>
<Trans>Direct Link Signing</Trans>
</DialogTitle>
<DialogDescription>
Manage the direct link signing for this template
<Trans>Manage the direct link signing for this template</Trans>
</DialogDescription>
</DialogHeader>
<div>
<div className="flex flex-row items-center justify-between">
<Label className="flex flex-row">
Enable Direct Link Signing
<Trans>Enable Direct Link Signing</Trans>
<Tooltip>
<TooltipTrigger tabIndex={-1} className="ml-2">
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
Disabling direct link signing will prevent anyone from accessing the link.
<Trans>
Disabling direct link signing will prevent anyone from accessing the
link.
</Trans>
</TooltipContent>
</Tooltip>
</Label>
@ -364,7 +393,9 @@ export const TemplateDirectLinkDialog = ({
</div>
<div className="mt-2">
<Label htmlFor="copy-direct-link">Copy Shareable Link</Label>
<Label htmlFor="copy-direct-link">
<Trans>Copy Shareable Link</Trans>
</Label>
<div className="relative mt-1">
<Input
@ -397,7 +428,7 @@ export const TemplateDirectLinkDialog = ({
loading={isDeletingTemplateDirectLink}
onClick={() => setCurrentStep('CONFIRM_DELETE')}
>
Remove
<Trans>Remove</Trans>
</Button>
<Button
@ -410,7 +441,7 @@ export const TemplateDirectLinkDialog = ({
})
}
>
Save
<Trans>Save</Trans>
</Button>
</DialogFooter>
</DialogContent>
@ -418,11 +449,15 @@ export const TemplateDirectLinkDialog = ({
.with({ token: P.string, currentStep: 'CONFIRM_DELETE' }, () => (
<DialogContent className="relative">
<DialogHeader>
<DialogTitle>Are you sure?</DialogTitle>
<DialogTitle>
<Trans>Are you sure?</Trans>
</DialogTitle>
<DialogDescription>
Please note that proceeding will remove direct linking recipient and turn it
into a placeholder.
<Trans>
Please note that proceeding will remove direct linking recipient and turn it
into a placeholder.
</Trans>
</DialogDescription>
</DialogHeader>
@ -432,7 +467,7 @@ export const TemplateDirectLinkDialog = ({
variant="secondary"
onClick={() => setCurrentStep('MANAGE')}
>
Cancel
<Trans>Cancel</Trans>
</Button>
<Button
@ -441,7 +476,7 @@ export const TemplateDirectLinkDialog = ({
loading={isDeletingTemplateDirectLink}
onClick={() => void deleteTemplateDirectLink({ templateId: template.id })}
>
Confirm
<Trans>Confirm</Trans>
</Button>
</DialogFooter>
</DialogContent>

Some files were not shown because too many files have changed in this diff Show More