mirror of
https://github.com/documenso/documenso.git
synced 2025-11-22 20:51:33 +10:00
chore: fixed conflicts
Signed-off-by: Adithya Krishna <adi@documenso.com>
This commit is contained in:
@ -12,6 +12,7 @@ ENV_FILES.forEach((file) => {
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const config = {
|
||||
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
|
||||
experimental: {
|
||||
serverActionsBodySizeLimit: '50mb',
|
||||
},
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"lint:fix": "next lint --fix",
|
||||
"clean": "rimraf .next && rimraf node_modules",
|
||||
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
||||
},
|
||||
@ -36,11 +37,13 @@
|
||||
"react-dom": "18.2.0",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-hook-form": "^7.43.9",
|
||||
"react-hotkeys-hook": "^4.4.1",
|
||||
"react-icons": "^4.11.0",
|
||||
"react-rnd": "^10.4.1",
|
||||
"sharp": "0.32.5",
|
||||
"ts-pattern": "^5.0.5",
|
||||
"typescript": "5.2.2",
|
||||
"uqr": "^0.1.2",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -2,7 +2,7 @@ import React from 'react';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||
|
||||
import { AdminNav } from './nav';
|
||||
|
||||
@ -4,7 +4,7 @@ import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZUpdateProfileMutationByAdminSchema } from '@documenso/trpc/server/admin-router/schema';
|
||||
|
||||
@ -3,7 +3,7 @@ import { redirect } from 'next/navigation';
|
||||
|
||||
import { ChevronLeft, Users2 } from 'lucide-react';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
|
||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||
|
||||
@ -0,0 +1,185 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { History } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
|
||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { type Document, type Recipient, SigningStatus } from '@documenso/prisma/client';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { DropdownMenuItem } from '@documenso/ui/primitives/dropdown-menu';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { StackAvatar } from '~/components/(dashboard)/avatar/stack-avatar';
|
||||
|
||||
const FORM_ID = 'resend-email';
|
||||
|
||||
export type ResendDocumentActionItemProps = {
|
||||
document: Document;
|
||||
recipients: Recipient[];
|
||||
};
|
||||
|
||||
export const ZResendDocumentFormSchema = z.object({
|
||||
recipients: z.array(z.number()).min(1, {
|
||||
message: 'You must select at least one item.',
|
||||
}),
|
||||
});
|
||||
|
||||
export type TResendDocumentFormSchema = z.infer<typeof ZResendDocumentFormSchema>;
|
||||
|
||||
export const ResendDocumentActionItem = ({
|
||||
document,
|
||||
recipients,
|
||||
}: ResendDocumentActionItemProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const isDisabled =
|
||||
document.status !== 'PENDING' ||
|
||||
!recipients.some((r) => r.signingStatus === SigningStatus.NOT_SIGNED);
|
||||
|
||||
const { mutateAsync: resendDocument } = trpcReact.document.resendDocument.useMutation();
|
||||
|
||||
const form = useForm<TResendDocumentFormSchema>({
|
||||
resolver: zodResolver(ZResendDocumentFormSchema),
|
||||
defaultValues: {
|
||||
recipients: [],
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
} = form;
|
||||
|
||||
const onFormSubmit = async ({ recipients }: TResendDocumentFormSchema) => {
|
||||
try {
|
||||
await resendDocument({ documentId: document.id, recipients });
|
||||
|
||||
toast({
|
||||
title: 'Document re-sent',
|
||||
description: '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.',
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<DropdownMenuItem disabled={isDisabled} onSelect={(e) => e.preventDefault()}>
|
||||
<History className="mr-2 h-4 w-4" />
|
||||
Resend
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="sm:max-w-sm" hideClose>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<h1 className="text-center text-xl">Who do you want to remind?</h1>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form id={FORM_ID} onSubmit={handleSubmit(onFormSubmit)} className="px-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="recipients"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
{recipients.map((recipient) => (
|
||||
<FormItem
|
||||
key={recipient.id}
|
||||
className="flex flex-row items-center justify-between gap-x-3"
|
||||
>
|
||||
<FormLabel
|
||||
className={cn('my-2 flex items-center gap-2 font-normal', {
|
||||
'opacity-50': !value.includes(recipient.id),
|
||||
})}
|
||||
>
|
||||
<StackAvatar
|
||||
key={recipient.id}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
{recipient.email}
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
className="h-5 w-5 rounded-full data-[state=checked]:border-black data-[state=checked]:bg-black "
|
||||
checkClassName="text-white"
|
||||
value={recipient.id}
|
||||
checked={value.includes(recipient.id)}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
checked
|
||||
? onChange([...value, recipient.id])
|
||||
: onChange(value.filter((v) => v !== recipient.id))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
type="button"
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
||||
variant="secondary"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button className="flex-1" loading={isSubmitting} type="submit" form={FORM_ID}>
|
||||
Send reminder
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -6,14 +6,9 @@ import { Edit, Pencil, Share } from 'lucide-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useCopyShareLink } from '@documenso/lib/client-only/hooks/use-copy-share-link';
|
||||
import {
|
||||
TOAST_DOCUMENT_SHARE_ERROR,
|
||||
TOAST_DOCUMENT_SHARE_SUCCESS,
|
||||
} from '@documenso/lib/constants/toast';
|
||||
import { Document, DocumentStatus, Recipient, SigningStatus, User } from '@documenso/prisma/client';
|
||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type DataTableActionButtonProps = {
|
||||
row: Document & {
|
||||
@ -25,13 +20,6 @@ export type DataTableActionButtonProps = {
|
||||
export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
||||
const { data: session } = useSession();
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const { createAndCopyShareLink, isCopyingShareLink } = useCopyShareLink({
|
||||
onSuccess: () => toast(TOAST_DOCUMENT_SHARE_SUCCESS),
|
||||
onError: () => toast(TOAST_DOCUMENT_SHARE_ERROR),
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
@ -70,18 +58,15 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
||||
</Button>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<Button
|
||||
className="w-24"
|
||||
loading={isCopyingShareLink}
|
||||
onClick={async () =>
|
||||
createAndCopyShareLink({
|
||||
token: recipient?.token,
|
||||
documentId: row.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
{!isCopyingShareLink && <Share className="-ml-1 mr-2 h-4 w-4" />}
|
||||
Share
|
||||
</Button>
|
||||
<DocumentShareButton
|
||||
documentId={row.id}
|
||||
token={recipient?.token}
|
||||
trigger={({ loading }) => (
|
||||
<Button className="w-24" loading={loading}>
|
||||
{!loading && <Share className="-ml-1 mr-2 h-4 w-4" />}
|
||||
Share
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
@ -8,7 +8,6 @@ import {
|
||||
Copy,
|
||||
Download,
|
||||
Edit,
|
||||
History,
|
||||
Loader,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
@ -18,15 +17,12 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
import { useCopyShareLink } from '@documenso/lib/client-only/hooks/use-copy-share-link';
|
||||
import {
|
||||
TOAST_DOCUMENT_SHARE_ERROR,
|
||||
TOAST_DOCUMENT_SHARE_SUCCESS,
|
||||
} from '@documenso/lib/constants/toast';
|
||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||
import { Document, DocumentStatus, Recipient, User } from '@documenso/prisma/client';
|
||||
import { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@ -34,9 +30,10 @@ import {
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { ResendDocumentActionItem } from './_action-items/resend-document';
|
||||
import { DeleteDraftDocumentDialog } from './delete-draft-document-dialog';
|
||||
import { DuplicateDocumentDialog } from './duplicate-document-dialog';
|
||||
|
||||
export type DataTableActionDropdownProps = {
|
||||
row: Document & {
|
||||
@ -48,14 +45,8 @@ export type DataTableActionDropdownProps = {
|
||||
export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
|
||||
const { data: session } = useSession();
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const { createAndCopyShareLink, isCopyingShareLink } = useCopyShareLink({
|
||||
onSuccess: () => toast(TOAST_DOCUMENT_SHARE_SUCCESS),
|
||||
onError: () => toast(TOAST_DOCUMENT_SHARE_ERROR),
|
||||
});
|
||||
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
@ -106,6 +97,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
window.URL.revokeObjectURL(link.href);
|
||||
};
|
||||
|
||||
const nonSignedRecipients = row.Recipient.filter((item) => item.signingStatus !== 'SIGNED');
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
@ -134,7 +126,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
Download
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem disabled>
|
||||
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
@ -151,27 +143,20 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
|
||||
<DropdownMenuLabel>Share</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuItem disabled>
|
||||
<History className="mr-2 h-4 w-4" />
|
||||
Resend
|
||||
</DropdownMenuItem>
|
||||
<ResendDocumentActionItem document={row} recipients={nonSignedRecipients} />
|
||||
|
||||
<DropdownMenuItem
|
||||
disabled={isDraft}
|
||||
onClick={async () =>
|
||||
createAndCopyShareLink({
|
||||
token: recipient?.token,
|
||||
documentId: row.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
{isCopyingShareLink ? (
|
||||
<Loader className="mr-2 h-4 w-4" />
|
||||
) : (
|
||||
<Share className="mr-2 h-4 w-4" />
|
||||
<DocumentShareButton
|
||||
documentId={row.id}
|
||||
token={recipient?.token}
|
||||
trigger={({ loading, disabled }) => (
|
||||
<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
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
Share
|
||||
</DropdownMenuItem>
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
|
||||
{isDocumentDeletable && (
|
||||
@ -181,6 +166,13 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
/>
|
||||
)}
|
||||
{isDuplicateDialogOpen && (
|
||||
<DuplicateDocumentDialog
|
||||
id={row.id}
|
||||
open={isDuplicateDialogOpen}
|
||||
onOpenChange={setDuplicateDialogOpen}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
@ -0,0 +1,105 @@
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
type DuplicateDocumentDialogProps = {
|
||||
id: number;
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
};
|
||||
|
||||
export const DuplicateDocumentDialog = ({
|
||||
id,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: DuplicateDocumentDialogProps) => {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery({
|
||||
id,
|
||||
});
|
||||
|
||||
const documentData = document?.documentData;
|
||||
|
||||
const { mutateAsync: duplicateDocument, isLoading: isDuplicateLoading } =
|
||||
trpcReact.document.duplicateDocument.useMutation({
|
||||
onSuccess: (newId) => {
|
||||
router.push(`/documents/${newId}`);
|
||||
toast({
|
||||
title: 'Document Duplicated',
|
||||
description: 'Your document has been successfully duplicated.',
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
},
|
||||
});
|
||||
|
||||
const onDuplicate = async () => {
|
||||
try {
|
||||
await duplicateDocument({ id });
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: 'This document could not be duplicated at this time. Please try again.',
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Duplicate</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...
|
||||
</h1>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll ">
|
||||
<LazyPDFViewer key={document?.id} documentData={documentData} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isDuplicateLoading || isLoading}
|
||||
loading={isDuplicateLoading}
|
||||
onClick={onDuplicate}
|
||||
className="flex-1"
|
||||
>
|
||||
Duplicate
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -1,6 +1,6 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
||||
import { getStats } from '@documenso/lib/server-only/document/get-stats';
|
||||
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
|
||||
@ -8,7 +8,8 @@ import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-documen
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
|
||||
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
|
||||
import { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
|
||||
import type { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
|
||||
import { isPeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
|
||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||
|
||||
import { DocumentsDataTable } from './data-table';
|
||||
@ -32,7 +33,7 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
|
||||
});
|
||||
|
||||
const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
|
||||
// const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : '';
|
||||
const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : '';
|
||||
const page = Number(searchParams.page) || 1;
|
||||
const perPage = Number(searchParams.perPage) || 20;
|
||||
|
||||
@ -45,6 +46,7 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
|
||||
},
|
||||
page,
|
||||
perPage,
|
||||
period,
|
||||
});
|
||||
|
||||
const getTabHref = (value: typeof status) => {
|
||||
|
||||
@ -6,6 +6,7 @@ import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Loader } from 'lucide-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
||||
@ -22,6 +23,7 @@ export type UploadDocumentProps = {
|
||||
|
||||
export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||
const router = useRouter();
|
||||
const { data: session } = useSession();
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
@ -79,7 +81,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||
<div className={cn('relative', className)}>
|
||||
<DocumentDropzone
|
||||
className="min-h-[40vh]"
|
||||
disabled={remaining.documents === 0}
|
||||
disabled={remaining.documents === 0 || !session?.user.emailVerified}
|
||||
onDrop={onFileDrop}
|
||||
/>
|
||||
|
||||
|
||||
@ -6,9 +6,11 @@ import { getServerSession } from 'next-auth';
|
||||
|
||||
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server';
|
||||
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
|
||||
import { CommandMenu } from '~/components/(dashboard)/common/command-menu';
|
||||
import { Header } from '~/components/(dashboard)/layout/header';
|
||||
import { VerifyEmailBanner } from '~/components/(dashboard)/layout/verify-email-banner';
|
||||
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
|
||||
import { NextAuthProvider } from '~/providers/next-auth';
|
||||
|
||||
@ -30,6 +32,8 @@ export default async function AuthenticatedDashboardLayout({
|
||||
return (
|
||||
<NextAuthProvider session={session}>
|
||||
<LimitsProvider>
|
||||
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
|
||||
<CommandMenu />
|
||||
<Header user={user} />
|
||||
|
||||
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
||||
|
||||
@ -5,8 +5,9 @@ import {
|
||||
getStripeCustomerById,
|
||||
} from '@documenso/ee/server-only/stripe/get-customer';
|
||||
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
|
||||
|
||||
export const createBillingPortal = async () => {
|
||||
|
||||
@ -7,8 +7,8 @@ import {
|
||||
getStripeCustomerById,
|
||||
} from '@documenso/ee/server-only/stripe/get-customer';
|
||||
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
|
||||
|
||||
export type CreateCheckoutOptions = {
|
||||
|
||||
@ -4,9 +4,9 @@ import { match } from 'ts-pattern';
|
||||
|
||||
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
||||
import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
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 { Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
|
||||
|
||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||
@ -41,7 +41,7 @@ export default async function BillingSettingsPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Billing</h3>
|
||||
<h3 className="text-2xl font-semibold">Billing</h3>
|
||||
|
||||
<div className="text-muted-foreground mt-2 text-sm">
|
||||
{isMissingOrInactiveOrFreePlan && (
|
||||
|
||||
@ -1,19 +1,5 @@
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { PasswordForm } from '~/components/forms/password';
|
||||
|
||||
export default async function PasswordSettingsPage() {
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Password</h3>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">Here you can update your password.</p>
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
<PasswordForm user={user} className="max-w-xl" />
|
||||
</div>
|
||||
);
|
||||
export default function PasswordSettingsPage() {
|
||||
redirect('/settings/security');
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
|
||||
import { ProfileForm } from '~/components/forms/profile';
|
||||
|
||||
@ -7,7 +7,7 @@ export default async function ProfileSettingsPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Profile</h3>
|
||||
<h3 className="text-2xl font-semibold">Profile</h3>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">Here you can edit your personal details.</p>
|
||||
|
||||
|
||||
46
apps/web/src/app/(dashboard)/settings/security/page.tsx
Normal file
46
apps/web/src/app/(dashboard)/settings/security/page.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
|
||||
import { AuthenticatorApp } from '~/components/forms/2fa/authenticator-app';
|
||||
import { RecoveryCodes } from '~/components/forms/2fa/recovery-codes';
|
||||
import { PasswordForm } from '~/components/forms/password';
|
||||
|
||||
export default async function SecuritySettingsPage() {
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-2xl font-semibold">Security</h3>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
Here you can manage your password and security settings.
|
||||
</p>
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
<PasswordForm user={user} className="max-w-xl" />
|
||||
|
||||
<hr className="mb-4 mt-8" />
|
||||
|
||||
<h4 className="text-lg font-medium">Two Factor Authentication</h4>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
Add and manage your two factor security settings to add an extra layer of security to your
|
||||
account!
|
||||
</p>
|
||||
|
||||
<div className="mt-4 max-w-xl">
|
||||
<h5 className="font-medium">Two-factor methods</h5>
|
||||
|
||||
<AuthenticatorApp isTwoFactorEnabled={user.twoFactorEnabled} />
|
||||
</div>
|
||||
|
||||
{user.twoFactorEnabled && (
|
||||
<div className="mt-4 max-w-xl">
|
||||
<h5 className="font-medium">Recovery methods</h5>
|
||||
|
||||
<RecoveryCodes isTwoFactorEnabled={user.twoFactorEnabled} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -56,7 +56,7 @@ export async function GET(_request: Request, { params: { slug } }: SharePageOpen
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div tw="relative flex h-full w-full">
|
||||
<div tw="relative flex h-full w-full bg-white">
|
||||
{/* @ts-expect-error Lack of typing from ImageResponse */}
|
||||
<img src={shareFrameImage} alt="og-share-frame" tw="absolute inset-0 w-full h-full" />
|
||||
|
||||
@ -149,6 +149,10 @@ export async function GET(_request: Request, { params: { slug } }: SharePageOpen
|
||||
weight: 600,
|
||||
},
|
||||
],
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -28,6 +28,7 @@ import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||
import { DATE_FORMATS } from '~/helpers/constants';
|
||||
|
||||
import { useRequiredSigningContext } from './provider';
|
||||
import { SignDialog } from './sign-dialog';
|
||||
|
||||
export type SigningFormProps = {
|
||||
document: Document;
|
||||
@ -57,6 +58,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
||||
|
||||
const onFormSubmit = async () => {
|
||||
setValidateUninsertedFields(true);
|
||||
|
||||
const isFieldsValid = validateFieldsInserted(fields);
|
||||
|
||||
if (!isFieldsValid) {
|
||||
@ -168,9 +170,12 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button className="w-full" type="submit" size="lg" loading={isSubmitting}>
|
||||
Complete
|
||||
</Button>
|
||||
<SignDialog
|
||||
isSubmitting={isSubmitting}
|
||||
onSignatureComplete={handleSubmit(onFormSubmit)}
|
||||
document={document}
|
||||
fields={fields}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
|
||||
import { Header as AuthenticatedHeader } from '~/components/(dashboard)/layout/header';
|
||||
import { NextAuthProvider } from '~/providers/next-auth';
|
||||
|
||||
@ -3,7 +3,7 @@ import { notFound, redirect } from 'next/navigation';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||
|
||||
77
apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx
Normal file
77
apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Document, Field } from '@documenso/prisma/client';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
|
||||
export type SignDialogProps = {
|
||||
isSubmitting: boolean;
|
||||
document: Document;
|
||||
fields: Field[];
|
||||
onSignatureComplete: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
export const SignDialog = ({
|
||||
isSubmitting,
|
||||
document,
|
||||
fields,
|
||||
onSignatureComplete,
|
||||
}: SignDialogProps) => {
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
|
||||
const isComplete = fields.every((field) => field.inserted);
|
||||
|
||||
return (
|
||||
<Dialog open={showDialog} onOpenChange={setShowDialog}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
className="w-full"
|
||||
type="button"
|
||||
size="lg"
|
||||
disabled={!isComplete}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
Complete
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<div className="text-center">
|
||||
<div className="text-xl font-semibold text-neutral-800">Sign Document</div>
|
||||
<div className="text-muted-foreground mx-auto w-4/5 py-2 text-center">
|
||||
You are about to finish signing "{document.title}". Are you sure?
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowDialog(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="flex-1"
|
||||
disabled={!isComplete}
|
||||
loading={isSubmitting}
|
||||
onClick={onSignatureComplete}
|
||||
>
|
||||
Sign
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -5,7 +5,7 @@ import { ForgotPasswordForm } from '~/components/forms/forgot-password';
|
||||
export default function ForgotPasswordPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Forgotten your password?</h1>
|
||||
<h1 className="text-4xl font-semibold">Forgot your password?</h1>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
No worries, it happens! Enter your email and we'll email you a special link to reset your
|
||||
|
||||
@ -25,7 +25,7 @@ export default function SignInPage() {
|
||||
href="/forgot-password"
|
||||
className="text-muted-foreground text-sm duration-200 hover:opacity-70"
|
||||
>
|
||||
Forgotten your password?
|
||||
Forgot your password?
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,97 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { AlertTriangle, CheckCircle2, XCircle, XOctagon } from 'lucide-react';
|
||||
|
||||
import { verifyEmail } from '@documenso/lib/server-only/user/verify-email';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
export type PageProps = {
|
||||
params: {
|
||||
token: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function VerifyEmailPage({ params: { token } }: PageProps) {
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="mb-4 text-red-300">
|
||||
<XOctagon />
|
||||
</div>
|
||||
|
||||
<h2 className="text-4xl font-semibold">No token provided</h2>
|
||||
<p className="text-muted-foreground mt-2 text-base">
|
||||
It seems that there is no token provided. Please check your email and try again.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const verified = await verifyEmail({ token });
|
||||
|
||||
if (verified === null) {
|
||||
return (
|
||||
<div className="flex w-full items-start">
|
||||
<div className="mr-4 mt-1 hidden md:block">
|
||||
<AlertTriangle className="h-10 w-10 text-yellow-500" strokeWidth={2} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold md:text-4xl">Something went wrong</h2>
|
||||
|
||||
<p className="text-muted-foreground mt-4">
|
||||
We were unable to verify your email. If your email is not verified already, please try
|
||||
again.
|
||||
</p>
|
||||
|
||||
<Button className="mt-4" asChild>
|
||||
<Link href="/">Go back home</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!verified) {
|
||||
return (
|
||||
<div className="flex w-full items-start">
|
||||
<div className="mr-4 mt-1 hidden md:block">
|
||||
<XCircle className="text-destructive h-10 w-10" strokeWidth={2} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold md:text-4xl">Your token has expired!</h2>
|
||||
|
||||
<p className="text-muted-foreground mt-4">
|
||||
It seems that the provided token has expired. We've just sent you another token, please
|
||||
check your email and try again.
|
||||
</p>
|
||||
|
||||
<Button className="mt-4" asChild>
|
||||
<Link href="/">Go back home</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-start">
|
||||
<div className="mr-4 mt-1 hidden md:block">
|
||||
<CheckCircle2 className="h-10 w-10 text-green-500" strokeWidth={2} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold md:text-4xl">Email Confirmed!</h2>
|
||||
|
||||
<p className="text-muted-foreground mt-4">
|
||||
Your email has been successfully confirmed! You can now use all features of Documenso.
|
||||
</p>
|
||||
|
||||
<Button className="mt-4" asChild>
|
||||
<Link href="/">Go back home</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
apps/web/src/app/(unauthenticated)/verify-email/page.tsx
Normal file
28
apps/web/src/app/(unauthenticated)/verify-email/page.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { XCircle } from 'lucide-react';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
export default function EmailVerificationWithoutTokenPage() {
|
||||
return (
|
||||
<div className="flex w-full items-start">
|
||||
<div className="mr-4 mt-1 hidden md:block">
|
||||
<XCircle className="text-destructive h-10 w-10" strokeWidth={2} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold md:text-4xl">Uh oh! Looks like you're missing a token</h2>
|
||||
|
||||
<p className="text-muted-foreground mt-4">
|
||||
It seems that there is no token provided, if you are trying to verify your email please
|
||||
follow the link in your email.
|
||||
</p>
|
||||
|
||||
<Button className="mt-4" asChild>
|
||||
<Link href="/">Go back home</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import NotFoundPartial from '~/components/partials/not-found';
|
||||
|
||||
133
apps/web/src/components/(dashboard)/common/command-menu.tsx
Normal file
133
apps/web/src/components/(dashboard)/common/command-menu.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Monitor, Moon, Sun } from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
|
||||
import {
|
||||
DOCUMENTS_PAGE_SHORTCUT,
|
||||
SETTINGS_PAGE_SHORTCUT,
|
||||
} from '@documenso/lib/constants/keyboard-shortcuts';
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandShortcut,
|
||||
} from '@documenso/ui/primitives/command';
|
||||
|
||||
const DOCUMENTS_PAGES = [
|
||||
{
|
||||
label: 'All documents',
|
||||
path: '/documents?status=ALL',
|
||||
shortcut: DOCUMENTS_PAGE_SHORTCUT.replace('+', ''),
|
||||
},
|
||||
{ label: 'Draft documents', path: '/documents?status=DRAFT' },
|
||||
{ label: 'Completed documents', path: '/documents?status=COMPLETED' },
|
||||
{ label: 'Pending documents', path: '/documents?status=PENDING' },
|
||||
{ label: 'Inbox documents', path: '/documents?status=INBOX' },
|
||||
];
|
||||
|
||||
const SETTINGS_PAGES = [
|
||||
{ label: 'Settings', path: '/settings', shortcut: SETTINGS_PAGE_SHORTCUT.replace('+', '') },
|
||||
{ label: 'Profile', path: '/settings/profile' },
|
||||
{ label: 'Password', path: '/settings/password' },
|
||||
];
|
||||
|
||||
export function CommandMenu() {
|
||||
const { setTheme } = useTheme();
|
||||
const { push } = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [pages, setPages] = useState<string[]>([]);
|
||||
const currentPage = pages[pages.length - 1];
|
||||
|
||||
const toggleOpen = () => {
|
||||
setOpen((open) => !open);
|
||||
};
|
||||
|
||||
const goToSettings = useCallback(() => push(SETTINGS_PAGES[0].path), [push]);
|
||||
const goToDocuments = useCallback(() => push(DOCUMENTS_PAGES[0].path), [push]);
|
||||
|
||||
useHotkeys('ctrl+k', toggleOpen);
|
||||
useHotkeys(SETTINGS_PAGE_SHORTCUT, goToSettings);
|
||||
useHotkeys(DOCUMENTS_PAGE_SHORTCUT, goToDocuments);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
// Escape goes to previous page
|
||||
// Backspace goes to previous page when search is empty
|
||||
if (e.key === 'Escape' || (e.key === 'Backspace' && !search)) {
|
||||
e.preventDefault();
|
||||
if (currentPage === undefined) {
|
||||
setOpen(false);
|
||||
}
|
||||
setPages((pages) => pages.slice(0, -1));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CommandDialog commandProps={{ onKeyDown: handleKeyDown }} open={open} onOpenChange={setOpen}>
|
||||
<CommandInput
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
placeholder="Type a command or search..."
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
{!currentPage && (
|
||||
<>
|
||||
<CommandGroup heading="Documents">
|
||||
<Commands push={push} pages={DOCUMENTS_PAGES} />
|
||||
</CommandGroup>
|
||||
<CommandGroup heading="Settings">
|
||||
<Commands push={push} pages={SETTINGS_PAGES} />
|
||||
</CommandGroup>
|
||||
<CommandGroup heading="Preferences">
|
||||
<CommandItem onSelect={() => setPages([...pages, 'theme'])}>Change theme</CommandItem>
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
{currentPage === 'theme' && <ThemeCommands setTheme={setTheme} />}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
);
|
||||
}
|
||||
|
||||
const Commands = ({
|
||||
push,
|
||||
pages,
|
||||
}: {
|
||||
push: (_path: string) => void;
|
||||
pages: { label: string; path: string; shortcut?: string }[];
|
||||
}) => {
|
||||
return pages.map((page) => (
|
||||
<CommandItem key={page.path} onSelect={() => push(page.path)}>
|
||||
{page.label}
|
||||
{page.shortcut && <CommandShortcut>{page.shortcut}</CommandShortcut>}
|
||||
</CommandItem>
|
||||
));
|
||||
};
|
||||
|
||||
const ThemeCommands = ({ setTheme }: { setTheme: (_theme: string) => void }) => {
|
||||
const THEMES = useMemo(
|
||||
() => [
|
||||
{ label: 'Light Mode', theme: 'light', icon: Sun },
|
||||
{ label: 'Dark Mode', theme: 'dark', icon: Moon },
|
||||
{ label: 'System Theme', theme: 'system', icon: Monitor },
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return THEMES.map((theme) => (
|
||||
<CommandItem key={theme.theme} onSelect={() => setTheme(theme.theme)}>
|
||||
<theme.icon className="mr-2" />
|
||||
{theme.label}
|
||||
</CommandItem>
|
||||
));
|
||||
};
|
||||
@ -4,7 +4,7 @@ import Link from 'next/link';
|
||||
|
||||
import {
|
||||
CreditCard,
|
||||
Key,
|
||||
Lock,
|
||||
LogOut,
|
||||
User as LucideUser,
|
||||
Monitor,
|
||||
@ -87,9 +87,9 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/settings/password" className="cursor-pointer">
|
||||
<Key className="mr-2 h-4 w-4" />
|
||||
Password
|
||||
<Link href="/settings/security" className="cursor-pointer">
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
Security
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
|
||||
@ -0,0 +1,123 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
import { ONE_SECOND } from '@documenso/lib/constants/time';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type VerifyEmailBannerProps = {
|
||||
email: string;
|
||||
};
|
||||
|
||||
const RESEND_CONFIRMATION_EMAIL_TIMEOUT = 20 * ONE_SECOND;
|
||||
|
||||
export const VerifyEmailBanner = ({ email }: VerifyEmailBannerProps) => {
|
||||
const { toast } = useToast();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const [isButtonDisabled, setIsButtonDisabled] = useState(false);
|
||||
|
||||
const { mutateAsync: sendConfirmationEmail, isLoading } =
|
||||
trpc.profile.sendConfirmationEmail.useMutation();
|
||||
|
||||
const onResendConfirmationEmail = async () => {
|
||||
try {
|
||||
setIsButtonDisabled(true);
|
||||
|
||||
await sendConfirmationEmail({ email: email });
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Verification email sent successfully.',
|
||||
});
|
||||
|
||||
setIsOpen(false);
|
||||
setTimeout(() => setIsButtonDisabled(false), RESEND_CONFIRMATION_EMAIL_TIMEOUT);
|
||||
} catch (err) {
|
||||
setIsButtonDisabled(false);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Something went wrong while sending the confirmation email.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Check localStorage to see if we've recently automatically displayed the dialog
|
||||
// if it was within the past 24 hours, don't show it again
|
||||
// otherwise, show it again and update the localStorage timestamp
|
||||
const emailVerificationDialogLastShown = localStorage.getItem(
|
||||
'emailVerificationDialogLastShown',
|
||||
);
|
||||
|
||||
if (emailVerificationDialogLastShown) {
|
||||
const lastShownTimestamp = parseInt(emailVerificationDialogLastShown);
|
||||
|
||||
if (Date.now() - lastShownTimestamp < 24 * 60 * 60 * 1000) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setIsOpen(true);
|
||||
|
||||
localStorage.setItem('emailVerificationDialogLastShown', Date.now().toString());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-yellow-200 dark:bg-yellow-400">
|
||||
<div className="mx-auto flex max-w-screen-xl items-center justify-center gap-x-4 px-4 py-2 text-sm font-medium text-yellow-900">
|
||||
<div className="flex items-center">
|
||||
<AlertTriangle className="mr-2.5 h-5 w-5" />
|
||||
Verify your email address to unlock all features.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-auto px-2.5 py-1.5 text-yellow-900 hover:bg-yellow-100 hover:text-yellow-900 dark:hover:bg-yellow-500"
|
||||
disabled={isButtonDisabled}
|
||||
onClick={() => setIsOpen(true)}
|
||||
size="sm"
|
||||
>
|
||||
{isButtonDisabled ? 'Verification Email Sent' : 'Verify Now'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent>
|
||||
<DialogTitle>Verify your email address</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
We've sent a confirmation email to <strong>{email}</strong>. Please check your inbox and
|
||||
click the link in the email to verify your account.
|
||||
</DialogDescription>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
disabled={isButtonDisabled}
|
||||
loading={isLoading}
|
||||
onClick={onResendConfirmationEmail}
|
||||
>
|
||||
{isLoading ? 'Sending...' : 'Resend Confirmation Email'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -5,7 +5,7 @@ import { HTMLAttributes } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { CreditCard, Key, User } from 'lucide-react';
|
||||
import { CreditCard, Lock, User } from 'lucide-react';
|
||||
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@ -35,16 +35,16 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="/settings/password">
|
||||
<Link href="/settings/security">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/password') && 'bg-secondary',
|
||||
pathname?.startsWith('/settings/security') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Key className="mr-2 h-5 w-5" />
|
||||
Password
|
||||
<Lock className="mr-2 h-5 w-5" />
|
||||
Security
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ import { HTMLAttributes } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { CreditCard, Key, User } from 'lucide-react';
|
||||
import { CreditCard, Lock, User } from 'lucide-react';
|
||||
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@ -38,16 +38,16 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="/settings/password">
|
||||
<Link href="/settings/security">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/password') && 'bg-secondary',
|
||||
pathname?.startsWith('/settings/security') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Key className="mr-2 h-5 w-5" />
|
||||
Password
|
||||
<Lock className="mr-2 h-5 w-5" />
|
||||
Security
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
|
||||
58
apps/web/src/components/forms/2fa/authenticator-app.tsx
Normal file
58
apps/web/src/components/forms/2fa/authenticator-app.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { DisableAuthenticatorAppDialog } from './disable-authenticator-app-dialog';
|
||||
import { EnableAuthenticatorAppDialog } from './enable-authenticator-app-dialog';
|
||||
|
||||
type AuthenticatorAppProps = {
|
||||
isTwoFactorEnabled: boolean;
|
||||
};
|
||||
|
||||
export const AuthenticatorApp = ({ isTwoFactorEnabled }: AuthenticatorAppProps) => {
|
||||
const [modalState, setModalState] = useState<'enable' | 'disable' | null>(null);
|
||||
|
||||
const isEnableDialogOpen = modalState === 'enable';
|
||||
const isDisableDialogOpen = modalState === 'disable';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-4 flex flex-col justify-between gap-4 rounded-lg border p-4 md:flex-row md:items-center md:gap-8">
|
||||
<div className="flex-1">
|
||||
<p>Authenticator app</p>
|
||||
|
||||
<p className="text-muted-foreground mt-2 max-w-[50ch] text-sm">
|
||||
Create one-time passwords that serve as a secondary authentication method for confirming
|
||||
your identity when requested during the sign-in process.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{isTwoFactorEnabled ? (
|
||||
<Button variant="destructive" onClick={() => setModalState('disable')} size="sm">
|
||||
Disable 2FA
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={() => setModalState('enable')} size="sm">
|
||||
Enable 2FA
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EnableAuthenticatorAppDialog
|
||||
key={isEnableDialogOpen ? 'open' : 'closed'}
|
||||
open={isEnableDialogOpen}
|
||||
onOpenChange={(open) => !open && setModalState(null)}
|
||||
/>
|
||||
|
||||
<DisableAuthenticatorAppDialog
|
||||
key={isDisableDialogOpen ? 'open' : 'closed'}
|
||||
open={isDisableDialogOpen}
|
||||
onOpenChange={(open) => !open && setModalState(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,161 @@
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export const ZDisableTwoFactorAuthenticationForm = z.object({
|
||||
password: z.string().min(6).max(72),
|
||||
backupCode: z.string(),
|
||||
});
|
||||
|
||||
export type TDisableTwoFactorAuthenticationForm = z.infer<
|
||||
typeof ZDisableTwoFactorAuthenticationForm
|
||||
>;
|
||||
|
||||
export type DisableAuthenticatorAppDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
};
|
||||
|
||||
export const DisableAuthenticatorAppDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: DisableAuthenticatorAppDialogProps) => {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: disableTwoFactorAuthentication } =
|
||||
trpc.twoFactorAuthentication.disable.useMutation();
|
||||
|
||||
const disableTwoFactorAuthenticationForm = useForm<TDisableTwoFactorAuthenticationForm>({
|
||||
defaultValues: {
|
||||
password: '',
|
||||
backupCode: '',
|
||||
},
|
||||
resolver: zodResolver(ZDisableTwoFactorAuthenticationForm),
|
||||
});
|
||||
|
||||
const { isSubmitting: isDisableTwoFactorAuthenticationSubmitting } =
|
||||
disableTwoFactorAuthenticationForm.formState;
|
||||
|
||||
const onDisableTwoFactorAuthenticationFormSubmit = async ({
|
||||
password,
|
||||
backupCode,
|
||||
}: TDisableTwoFactorAuthenticationForm) => {
|
||||
try {
|
||||
await disableTwoFactorAuthentication({ password, backupCode });
|
||||
|
||||
toast({
|
||||
title: 'Two-factor authentication disabled',
|
||||
description:
|
||||
'Two-factor authentication has been disabled for your account. You will no longer be required to enter a code from your authenticator app when signing in.',
|
||||
});
|
||||
|
||||
flushSync(() => {
|
||||
onOpenChange(false);
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
} catch (_err) {
|
||||
toast({
|
||||
title: 'Unable to disable two-factor authentication',
|
||||
description:
|
||||
'We were unable to disable two-factor authentication for your account. Please ensure that you have entered your password and backup code correctly and try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Disable Authenticator App</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
To disable the Authenticator App for your account, please enter your password and a
|
||||
backup code. If you do not have a backup code available, please contact support.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...disableTwoFactorAuthenticationForm}>
|
||||
<form
|
||||
onSubmit={disableTwoFactorAuthenticationForm.handleSubmit(
|
||||
onDisableTwoFactorAuthenticationFormSubmit,
|
||||
)}
|
||||
className="flex flex-col gap-y-4"
|
||||
>
|
||||
<FormField
|
||||
name="password"
|
||||
control={disableTwoFactorAuthenticationForm.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-muted-foreground">Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={field.value ?? ''}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="backupCode"
|
||||
control={disableTwoFactorAuthenticationForm.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-muted-foreground">Backup Code</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="text" value={field.value ?? ''} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
loading={isDisableTwoFactorAuthenticationSubmitting}
|
||||
>
|
||||
Disable 2FA
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,283 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
import { renderSVG } from 'uqr';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { RecoveryCodeList } from './recovery-code-list';
|
||||
|
||||
export const ZSetupTwoFactorAuthenticationForm = z.object({
|
||||
password: z.string().min(6).max(72),
|
||||
});
|
||||
|
||||
export type TSetupTwoFactorAuthenticationForm = z.infer<typeof ZSetupTwoFactorAuthenticationForm>;
|
||||
|
||||
export const ZEnableTwoFactorAuthenticationForm = z.object({
|
||||
token: z.string(),
|
||||
});
|
||||
|
||||
export type TEnableTwoFactorAuthenticationForm = z.infer<typeof ZEnableTwoFactorAuthenticationForm>;
|
||||
|
||||
export type EnableAuthenticatorAppDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
};
|
||||
|
||||
export const EnableAuthenticatorAppDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: EnableAuthenticatorAppDialogProps) => {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: setupTwoFactorAuthentication, data: setupTwoFactorAuthenticationData } =
|
||||
trpc.twoFactorAuthentication.setup.useMutation();
|
||||
|
||||
const { mutateAsync: enableTwoFactorAuthentication, data: enableTwoFactorAuthenticationData } =
|
||||
trpc.twoFactorAuthentication.enable.useMutation();
|
||||
|
||||
const setupTwoFactorAuthenticationForm = useForm<TSetupTwoFactorAuthenticationForm>({
|
||||
defaultValues: {
|
||||
password: '',
|
||||
},
|
||||
resolver: zodResolver(ZSetupTwoFactorAuthenticationForm),
|
||||
});
|
||||
|
||||
const { isSubmitting: isSetupTwoFactorAuthenticationSubmitting } =
|
||||
setupTwoFactorAuthenticationForm.formState;
|
||||
|
||||
const enableTwoFactorAuthenticationForm = useForm<TEnableTwoFactorAuthenticationForm>({
|
||||
defaultValues: {
|
||||
token: '',
|
||||
},
|
||||
resolver: zodResolver(ZEnableTwoFactorAuthenticationForm),
|
||||
});
|
||||
|
||||
const { isSubmitting: isEnableTwoFactorAuthenticationSubmitting } =
|
||||
enableTwoFactorAuthenticationForm.formState;
|
||||
|
||||
const step = useMemo(() => {
|
||||
if (!setupTwoFactorAuthenticationData || isSetupTwoFactorAuthenticationSubmitting) {
|
||||
return 'setup';
|
||||
}
|
||||
|
||||
if (!enableTwoFactorAuthenticationData || isEnableTwoFactorAuthenticationSubmitting) {
|
||||
return 'enable';
|
||||
}
|
||||
|
||||
return 'view';
|
||||
}, [
|
||||
setupTwoFactorAuthenticationData,
|
||||
isSetupTwoFactorAuthenticationSubmitting,
|
||||
enableTwoFactorAuthenticationData,
|
||||
isEnableTwoFactorAuthenticationSubmitting,
|
||||
]);
|
||||
|
||||
const onSetupTwoFactorAuthenticationFormSubmit = async ({
|
||||
password,
|
||||
}: TSetupTwoFactorAuthenticationForm) => {
|
||||
try {
|
||||
await setupTwoFactorAuthentication({ password });
|
||||
} catch (_err) {
|
||||
toast({
|
||||
title: 'Unable to setup two-factor authentication',
|
||||
description:
|
||||
'We were unable to setup two-factor authentication for your account. Please ensure that you have entered your password correctly and try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onEnableTwoFactorAuthenticationFormSubmit = async ({
|
||||
token,
|
||||
}: TEnableTwoFactorAuthenticationForm) => {
|
||||
try {
|
||||
await enableTwoFactorAuthentication({ code: token });
|
||||
|
||||
toast({
|
||||
title: 'Two-factor authentication enabled',
|
||||
description:
|
||||
'Two-factor authentication has been enabled for your account. You will now be required to enter a code from your authenticator app when signing in.',
|
||||
});
|
||||
} catch (_err) {
|
||||
toast({
|
||||
title: 'Unable to setup two-factor authentication',
|
||||
description:
|
||||
'We were unable to setup two-factor authentication for your account. Please ensure that you have entered your password correctly and try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onCompleteClick = () => {
|
||||
flushSync(() => {
|
||||
onOpenChange(false);
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Enable Authenticator App</DialogTitle>
|
||||
|
||||
{step === 'setup' && (
|
||||
<DialogDescription>
|
||||
To enable two-factor authentication, please enter your password below.
|
||||
</DialogDescription>
|
||||
)}
|
||||
|
||||
{step === 'view' && (
|
||||
<DialogDescription>
|
||||
Your recovery codes are listed below. Please store them in a safe place.
|
||||
</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
{match(step)
|
||||
.with('setup', () => {
|
||||
return (
|
||||
<Form {...setupTwoFactorAuthenticationForm}>
|
||||
<form
|
||||
onSubmit={setupTwoFactorAuthenticationForm.handleSubmit(
|
||||
onSetupTwoFactorAuthenticationFormSubmit,
|
||||
)}
|
||||
className="flex flex-col gap-y-4"
|
||||
>
|
||||
<FormField
|
||||
name="password"
|
||||
control={setupTwoFactorAuthenticationForm.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-muted-foreground">Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={field.value ?? ''}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={isSetupTwoFactorAuthenticationSubmitting}>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
})
|
||||
.with('enable', () => (
|
||||
<Form {...enableTwoFactorAuthenticationForm}>
|
||||
<form
|
||||
onSubmit={enableTwoFactorAuthenticationForm.handleSubmit(
|
||||
onEnableTwoFactorAuthenticationFormSubmit,
|
||||
)}
|
||||
className="flex flex-col gap-y-4"
|
||||
>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
To enable two-factor authentication, scan the following QR code using your
|
||||
authenticator app.
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="flex h-36 justify-center"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: renderSVG(setupTwoFactorAuthenticationData?.uri ?? ''),
|
||||
}}
|
||||
/>
|
||||
|
||||
<p className="text-muted-foreground text-sm">
|
||||
If your authenticator app does not support QR codes, you can use the following
|
||||
code instead:
|
||||
</p>
|
||||
|
||||
<p className="bg-muted/60 text-muted-foreground rounded-lg p-2 text-center font-mono tracking-widest">
|
||||
{setupTwoFactorAuthenticationData?.secret}
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Once you have scanned the QR code or entered the code manually, enter the code
|
||||
provided by your authenticator app below.
|
||||
</p>
|
||||
|
||||
<FormField
|
||||
name="token"
|
||||
control={enableTwoFactorAuthenticationForm.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-muted-foreground">Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="text" value={field.value ?? ''} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={isEnableTwoFactorAuthenticationSubmitting}>
|
||||
Enable 2FA
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
))
|
||||
.with('view', () => (
|
||||
<div>
|
||||
{enableTwoFactorAuthenticationData?.recoveryCodes && (
|
||||
<RecoveryCodeList recoveryCodes={enableTwoFactorAuthenticationData.recoveryCodes} />
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex w-full flex-row-reverse items-center justify-between">
|
||||
<Button type="button" onClick={() => onCompleteClick()}>
|
||||
Complete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
.exhaustive()}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
57
apps/web/src/components/forms/2fa/recovery-code-list.tsx
Normal file
57
apps/web/src/components/forms/2fa/recovery-code-list.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import { Copy } from 'lucide-react';
|
||||
|
||||
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type RecoveryCodeListProps = {
|
||||
recoveryCodes: string[];
|
||||
};
|
||||
|
||||
export const RecoveryCodeList = ({ recoveryCodes }: RecoveryCodeListProps) => {
|
||||
const { toast } = useToast();
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const onCopyRecoveryCodeClick = async (code: string) => {
|
||||
try {
|
||||
const result = await copyToClipboard(code);
|
||||
|
||||
if (!result) {
|
||||
throw new Error('Unable to copy recovery code');
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Recovery code copied',
|
||||
description: 'Your recovery code has been copied to your clipboard.',
|
||||
});
|
||||
} catch (_err) {
|
||||
toast({
|
||||
title: 'Unable to copy recovery code',
|
||||
description:
|
||||
'We were unable to copy your recovery code to your clipboard. Please try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{recoveryCodes.map((code) => (
|
||||
<div
|
||||
key={code}
|
||||
className="bg-muted text-muted-foreground relative rounded-lg p-4 font-mono md:text-center"
|
||||
>
|
||||
<span>{code}</span>
|
||||
|
||||
<div className="absolute inset-y-0 right-4 flex items-center justify-center">
|
||||
<button
|
||||
className="opacity-60 hover:opacity-80"
|
||||
onClick={() => void onCopyRecoveryCodeClick(code)}
|
||||
>
|
||||
<Copy className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
43
apps/web/src/components/forms/2fa/recovery-codes.tsx
Normal file
43
apps/web/src/components/forms/2fa/recovery-codes.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { ViewRecoveryCodesDialog } from './view-recovery-codes-dialog';
|
||||
|
||||
type RecoveryCodesProps = {
|
||||
// backupCodes: string[] | null;
|
||||
isTwoFactorEnabled: boolean;
|
||||
};
|
||||
|
||||
export const RecoveryCodes = ({ isTwoFactorEnabled }: RecoveryCodesProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-4 flex flex-col justify-between gap-4 rounded-lg border p-4 md:flex-row md:items-center md:gap-8">
|
||||
<div className="flex-1">
|
||||
<p>Recovery Codes</p>
|
||||
|
||||
<p className="text-muted-foreground mt-2 max-w-[50ch] text-sm">
|
||||
Recovery codes are used to access your account in the event that you lose access to your
|
||||
authenticator app.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button onClick={() => setIsOpen(true)} disabled={!isTwoFactorEnabled} size="sm">
|
||||
View Codes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ViewRecoveryCodesDialog
|
||||
key={isOpen ? 'open' : 'closed'}
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
151
apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx
Normal file
151
apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx
Normal file
@ -0,0 +1,151 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { RecoveryCodeList } from './recovery-code-list';
|
||||
|
||||
export const ZViewRecoveryCodesForm = z.object({
|
||||
password: z.string().min(6).max(72),
|
||||
});
|
||||
|
||||
export type TViewRecoveryCodesForm = z.infer<typeof ZViewRecoveryCodesForm>;
|
||||
|
||||
export type ViewRecoveryCodesDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
};
|
||||
|
||||
export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCodesDialogProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: viewRecoveryCodes, data: viewRecoveryCodesData } =
|
||||
trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation();
|
||||
|
||||
const viewRecoveryCodesForm = useForm<TViewRecoveryCodesForm>({
|
||||
defaultValues: {
|
||||
password: '',
|
||||
},
|
||||
resolver: zodResolver(ZViewRecoveryCodesForm),
|
||||
});
|
||||
|
||||
const { isSubmitting: isViewRecoveryCodesSubmitting } = viewRecoveryCodesForm.formState;
|
||||
|
||||
const step = useMemo(() => {
|
||||
if (!viewRecoveryCodesData || isViewRecoveryCodesSubmitting) {
|
||||
return 'authenticate';
|
||||
}
|
||||
|
||||
return 'view';
|
||||
}, [viewRecoveryCodesData, isViewRecoveryCodesSubmitting]);
|
||||
|
||||
const onViewRecoveryCodesFormSubmit = async ({ password }: TViewRecoveryCodesForm) => {
|
||||
try {
|
||||
await viewRecoveryCodes({ password });
|
||||
} catch (_err) {
|
||||
toast({
|
||||
title: 'Unable to view recovery codes',
|
||||
description:
|
||||
'We were unable to view your recovery codes. Please ensure that you have entered your password correctly and try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>View Recovery Codes</DialogTitle>
|
||||
|
||||
{step === 'authenticate' && (
|
||||
<DialogDescription>
|
||||
To view your recovery codes, please enter your password below.
|
||||
</DialogDescription>
|
||||
)}
|
||||
|
||||
{step === 'view' && (
|
||||
<DialogDescription>
|
||||
Your recovery codes are listed below. Please store them in a safe place.
|
||||
</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
{match(step)
|
||||
.with('authenticate', () => {
|
||||
return (
|
||||
<Form {...viewRecoveryCodesForm}>
|
||||
<form
|
||||
onSubmit={viewRecoveryCodesForm.handleSubmit(onViewRecoveryCodesFormSubmit)}
|
||||
className="flex flex-col gap-y-4"
|
||||
>
|
||||
<FormField
|
||||
name="password"
|
||||
control={viewRecoveryCodesForm.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-muted-foreground">Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={field.value ?? ''}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={isViewRecoveryCodesSubmitting}>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
})
|
||||
.with('view', () => (
|
||||
<div>
|
||||
{viewRecoveryCodesData?.recoveryCodes && (
|
||||
<RecoveryCodeList recoveryCodes={viewRecoveryCodesData.recoveryCodes} />
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-row-reverse items-center justify-between">
|
||||
<Button onClick={() => onOpenChange(false)}>Complete</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
.exhaustive()}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -1,8 +1,8 @@
|
||||
'use server';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
|
||||
import { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
||||
import type { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
||||
|
||||
export type AddFieldsActionInput = TAddFieldsFormSchema & {
|
||||
documentId: number;
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
'use server';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
|
||||
import { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
|
||||
import type { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
|
||||
|
||||
export type AddSignersActionInput = TAddSignersFormSchema & {
|
||||
documentId: number;
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
'use server';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
|
||||
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
|
||||
import { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
||||
import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
||||
|
||||
export type CompleteDocumentActionInput = TAddSubjectFormSchema & {
|
||||
documentId: number;
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { signIn } from 'next-auth/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { FcGoogle } from 'react-icons/fc';
|
||||
@ -12,23 +11,30 @@ import { z } from 'zod';
|
||||
import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@documenso/ui/primitives/dialog';
|
||||
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Input, PasswordInput } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
const ERROR_MESSAGES = {
|
||||
const ERROR_MESSAGES: Partial<Record<keyof typeof ErrorCode, string>> = {
|
||||
[ErrorCode.CREDENTIALS_NOT_FOUND]: 'The email or password provided is incorrect',
|
||||
[ErrorCode.INCORRECT_EMAIL_PASSWORD]: 'The email or password provided is incorrect',
|
||||
[ErrorCode.USER_MISSING_PASSWORD]:
|
||||
'This account appears to be using a social login method, please sign in using that method',
|
||||
[ErrorCode.INCORRECT_TWO_FACTOR_CODE]: 'The two-factor authentication code provided is incorrect',
|
||||
[ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE]: 'The backup code provided is incorrect',
|
||||
};
|
||||
|
||||
const TwoFactorEnabledErrorCode = ErrorCode.TWO_FACTOR_MISSING_CREDENTIALS;
|
||||
|
||||
const LOGIN_REDIRECT_PATH = '/documents';
|
||||
|
||||
export const ZSignInFormSchema = z.object({
|
||||
email: z.string().email().min(1),
|
||||
password: z.string().min(6).max(72),
|
||||
totpCode: z.string().trim().optional(),
|
||||
backupCode: z.string().trim().optional(),
|
||||
});
|
||||
|
||||
export type TSignInFormSchema = z.infer<typeof ZSignInFormSchema>;
|
||||
@ -39,33 +45,84 @@ export type SignInFormProps = {
|
||||
|
||||
export const SignInForm = ({ className }: SignInFormProps) => {
|
||||
const { toast } = useToast();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
|
||||
useState(false);
|
||||
|
||||
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
|
||||
'totp' | 'backup'
|
||||
>('totp');
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<TSignInFormSchema>({
|
||||
values: {
|
||||
email: '',
|
||||
password: '',
|
||||
totpCode: '',
|
||||
backupCode: '',
|
||||
},
|
||||
resolver: zodResolver(ZSignInFormSchema),
|
||||
});
|
||||
|
||||
const onFormSubmit = async ({ email, password }: TSignInFormSchema) => {
|
||||
const onCloseTwoFactorAuthenticationDialog = () => {
|
||||
setValue('totpCode', '');
|
||||
setValue('backupCode', '');
|
||||
|
||||
setIsTwoFactorAuthenticationDialogOpen(false);
|
||||
};
|
||||
|
||||
const onToggleTwoFactorAuthenticationMethodClick = () => {
|
||||
const method = twoFactorAuthenticationMethod === 'totp' ? 'backup' : 'totp';
|
||||
|
||||
if (method === 'totp') {
|
||||
setValue('backupCode', '');
|
||||
}
|
||||
|
||||
if (method === 'backup') {
|
||||
setValue('totpCode', '');
|
||||
}
|
||||
|
||||
setTwoFactorAuthenticationMethod(method);
|
||||
};
|
||||
|
||||
const onFormSubmit = async ({ email, password, totpCode, backupCode }: TSignInFormSchema) => {
|
||||
try {
|
||||
const result = await signIn('credentials', {
|
||||
const credentials: Record<string, string> = {
|
||||
email,
|
||||
password,
|
||||
};
|
||||
|
||||
if (totpCode) {
|
||||
credentials.totpCode = totpCode;
|
||||
}
|
||||
|
||||
if (backupCode) {
|
||||
credentials.backupCode = backupCode;
|
||||
}
|
||||
|
||||
const result = await signIn('credentials', {
|
||||
...credentials,
|
||||
|
||||
callbackUrl: LOGIN_REDIRECT_PATH,
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
if (result?.error && isErrorCode(result.error)) {
|
||||
if (result.error === TwoFactorEnabledErrorCode) {
|
||||
setIsTwoFactorAuthenticationDialogOpen(true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMessage = ERROR_MESSAGES[result.error];
|
||||
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
description: ERROR_MESSAGES[result.error],
|
||||
title: 'Unable to sign in',
|
||||
description: errorMessage ?? 'An unknown error occurred',
|
||||
});
|
||||
|
||||
return;
|
||||
@ -118,31 +175,14 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
||||
<span>Password</span>
|
||||
</Label>
|
||||
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
minLength={6}
|
||||
maxLength={72}
|
||||
autoComplete="current-password"
|
||||
className="bg-background mt-2 pr-10"
|
||||
{...register('password')}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
type="button"
|
||||
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3"
|
||||
aria-label={showPassword ? 'Mask password' : 'Reveal password'}
|
||||
onClick={() => setShowPassword((show) => !show)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="text-muted-foreground h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="text-muted-foreground h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
minLength={6}
|
||||
maxLength={72}
|
||||
className="bg-background mt-2"
|
||||
autoComplete="current-password"
|
||||
{...register('password')}
|
||||
/>
|
||||
|
||||
<FormErrorMessage className="mt-1.5" error={errors.password} />
|
||||
</div>
|
||||
@ -173,6 +213,67 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
||||
<FcGoogle className="mr-2 h-5 w-5" />
|
||||
Google
|
||||
</Button>
|
||||
|
||||
<Dialog
|
||||
open={isTwoFactorAuthenticationDialogOpen}
|
||||
onOpenChange={onCloseTwoFactorAuthenticationDialog}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Two-Factor Authentication</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
{twoFactorAuthenticationMethod === 'totp' && (
|
||||
<div>
|
||||
<Label htmlFor="totpCode" className="text-muted-forground">
|
||||
Authentication Token
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
id="totpCode"
|
||||
type="text"
|
||||
className="bg-background mt-2"
|
||||
{...register('totpCode')}
|
||||
/>
|
||||
|
||||
<FormErrorMessage className="mt-1.5" error={errors.totpCode} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{twoFactorAuthenticationMethod === 'backup' && (
|
||||
<div>
|
||||
<Label htmlFor="backupCode" className="text-muted-forground">
|
||||
Backup Code
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
id="backupCode"
|
||||
type="text"
|
||||
className="bg-background mt-2"
|
||||
{...register('backupCode')}
|
||||
/>
|
||||
|
||||
<FormErrorMessage className="mt-1.5" error={errors.backupCode} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onToggleTwoFactorAuthenticationMethodClick}
|
||||
>
|
||||
{twoFactorAuthenticationMethod === 'totp' ? 'Use Backup Code' : 'Use Authenticator'}
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={isSubmitting}>
|
||||
Sign In
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user