mirror of
https://github.com/documenso/documenso.git
synced 2025-11-14 00:32:43 +10:00
chore: merge main
This commit is contained in:
@ -25,7 +25,7 @@ export type DocumentPageViewProps = {
|
||||
team?: Team;
|
||||
};
|
||||
|
||||
export default async function DocumentPageView({ params, team }: DocumentPageViewProps) {
|
||||
export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) => {
|
||||
const { id } = params;
|
||||
|
||||
const documentId = Number(id);
|
||||
@ -128,4 +128,4 @@ export default async function DocumentPageView({ params, team }: DocumentPageVie
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@ -151,7 +151,7 @@ export const EditDocumentForm = ({
|
||||
};
|
||||
|
||||
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
|
||||
const { subject, message, timezone, dateFormat } = data.meta;
|
||||
const { subject, message, timezone, dateFormat, redirectUrl } = data.meta;
|
||||
|
||||
try {
|
||||
await sendDocument({
|
||||
@ -159,8 +159,9 @@ export const EditDocumentForm = ({
|
||||
meta: {
|
||||
subject,
|
||||
message,
|
||||
timezone,
|
||||
dateFormat,
|
||||
timezone,
|
||||
redirectUrl,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import DocumentPageView from './document-page-view';
|
||||
import { DocumentPageView } from './document-page-view';
|
||||
|
||||
export type DocumentPageProps = {
|
||||
params: {
|
||||
|
||||
@ -114,7 +114,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
||||
|
||||
{recipient?.role !== RecipientRole.CC && (
|
||||
{recipient && recipient?.role !== RecipientRole.CC && (
|
||||
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
|
||||
<Link href={`/sign/${recipient?.token}`}>
|
||||
{recipient?.role === RecipientRole.VIEWER && (
|
||||
|
||||
@ -33,10 +33,7 @@ export type DocumentsPageViewProps = {
|
||||
team?: Team & { teamEmail?: TeamEmail | null };
|
||||
};
|
||||
|
||||
export default async function DocumentsPageView({
|
||||
searchParams = {},
|
||||
team,
|
||||
}: DocumentsPageViewProps) {
|
||||
export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPageViewProps) => {
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
|
||||
@ -155,4 +152,4 @@ export default async function DocumentsPageView({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import type { DocumentsPageViewProps } from './documents-page-view';
|
||||
import DocumentsPageView from './documents-page-view';
|
||||
import { DocumentsPageView } from './documents-page-view';
|
||||
|
||||
export type DocumentsPageProps = {
|
||||
searchParams?: DocumentsPageViewProps['searchParams'];
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
|
||||
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
|
||||
export const createBillingPortal = async () => {
|
||||
@ -11,6 +12,6 @@ export const createBillingPortal = async () => {
|
||||
|
||||
return getPortalSession({
|
||||
customerId: stripeCustomer.id,
|
||||
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
|
||||
returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/settings/billing`,
|
||||
});
|
||||
};
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import { getCheckoutSession } from '@documenso/ee/server-only/stripe/get-checkout-session';
|
||||
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
|
||||
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscription/get-subscriptions-by-user-id';
|
||||
|
||||
@ -27,13 +28,13 @@ export const createCheckout = async ({ priceId }: CreateCheckoutOptions) => {
|
||||
if (foundSubscription) {
|
||||
return getPortalSession({
|
||||
customerId: stripeCustomer.id,
|
||||
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
|
||||
returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/settings/billing`,
|
||||
});
|
||||
}
|
||||
|
||||
return getCheckoutSession({
|
||||
customerId: stripeCustomer.id,
|
||||
priceId,
|
||||
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
|
||||
returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/settings/billing`,
|
||||
});
|
||||
};
|
||||
|
||||
@ -28,6 +28,7 @@ export type EditTemplateFormProps = {
|
||||
recipients: Recipient[];
|
||||
fields: Field[];
|
||||
documentData: DocumentData;
|
||||
templateRootPath: string;
|
||||
};
|
||||
|
||||
type EditTemplateStep = 'signers' | 'fields';
|
||||
@ -40,6 +41,7 @@ export const EditTemplateForm = ({
|
||||
fields,
|
||||
user: _user,
|
||||
documentData,
|
||||
templateRootPath,
|
||||
}: EditTemplateFormProps) => {
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
@ -98,7 +100,7 @@ export const EditTemplateForm = ({
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
router.push('/templates');
|
||||
router.push(templateRootPath);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
|
||||
@ -1,81 +1,10 @@
|
||||
import React from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
import type { TemplatePageViewProps } from './template-page-view';
|
||||
import { TemplatePageView } from './template-page-view';
|
||||
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
type TemplatePageProps = Pick<TemplatePageViewProps, 'params'>;
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getFieldsForTemplate } from '@documenso/lib/server-only/field/get-fields-for-template';
|
||||
import { getRecipientsForTemplate } from '@documenso/lib/server-only/recipient/get-recipients-for-template';
|
||||
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||
|
||||
import { TemplateType } from '~/components/formatter/template-type';
|
||||
|
||||
import { EditTemplateForm } from './edit-template';
|
||||
|
||||
export type TemplatePageProps = {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function TemplatePage({ params }: TemplatePageProps) {
|
||||
const { id } = params;
|
||||
|
||||
const templateId = Number(id);
|
||||
|
||||
if (!templateId || Number.isNaN(templateId)) {
|
||||
redirect('/documents');
|
||||
}
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
const template = await getTemplateById({
|
||||
id: templateId,
|
||||
userId: user.id,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!template || !template.templateDocumentData) {
|
||||
redirect('/documents');
|
||||
}
|
||||
|
||||
const { templateDocumentData } = template;
|
||||
|
||||
const [templateRecipients, templateFields] = await Promise.all([
|
||||
getRecipientsForTemplate({
|
||||
templateId,
|
||||
userId: user.id,
|
||||
}),
|
||||
getFieldsForTemplate({
|
||||
templateId,
|
||||
userId: user.id,
|
||||
}),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
|
||||
<Link href="/templates" className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
Templates
|
||||
</Link>
|
||||
|
||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
|
||||
{template.title}
|
||||
</h1>
|
||||
|
||||
<div className="mt-2.5 flex items-center gap-x-6">
|
||||
<TemplateType inheritColor type={template.type} className="text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<EditTemplateForm
|
||||
className="mt-8"
|
||||
template={template}
|
||||
user={user}
|
||||
recipients={templateRecipients}
|
||||
fields={templateFields}
|
||||
documentData={templateDocumentData}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
export default function TemplatePage({ params }: TemplatePageProps) {
|
||||
return <TemplatePageView params={params} />;
|
||||
}
|
||||
|
||||
@ -0,0 +1,86 @@
|
||||
import React from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getFieldsForTemplate } from '@documenso/lib/server-only/field/get-fields-for-template';
|
||||
import { getRecipientsForTemplate } from '@documenso/lib/server-only/recipient/get-recipients-for-template';
|
||||
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import type { Team } from '@documenso/prisma/client';
|
||||
|
||||
import { TemplateType } from '~/components/formatter/template-type';
|
||||
|
||||
import { EditTemplateForm } from './edit-template';
|
||||
|
||||
export type TemplatePageViewProps = {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
team?: Team;
|
||||
};
|
||||
|
||||
export const TemplatePageView = async ({ params, team }: TemplatePageViewProps) => {
|
||||
const { id } = params;
|
||||
|
||||
const templateId = Number(id);
|
||||
const templateRootPath = formatTemplatesPath(team?.url);
|
||||
|
||||
if (!templateId || Number.isNaN(templateId)) {
|
||||
redirect(templateRootPath);
|
||||
}
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
const template = await getTemplateById({
|
||||
id: templateId,
|
||||
userId: user.id,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!template || !template.templateDocumentData) {
|
||||
redirect(templateRootPath);
|
||||
}
|
||||
|
||||
const { templateDocumentData } = template;
|
||||
|
||||
const [templateRecipients, templateFields] = await Promise.all([
|
||||
getRecipientsForTemplate({
|
||||
templateId,
|
||||
userId: user.id,
|
||||
}),
|
||||
getFieldsForTemplate({
|
||||
templateId,
|
||||
userId: user.id,
|
||||
}),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
|
||||
<Link href="/templates" className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
Templates
|
||||
</Link>
|
||||
|
||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
|
||||
{template.title}
|
||||
</h1>
|
||||
|
||||
<div className="mt-2.5 flex items-center gap-x-6">
|
||||
<TemplateType inheritColor type={template.type} className="text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<EditTemplateForm
|
||||
className="mt-8"
|
||||
template={template}
|
||||
user={user}
|
||||
recipients={templateRecipients}
|
||||
fields={templateFields}
|
||||
documentData={templateDocumentData}
|
||||
templateRootPath={templateRootPath}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -21,9 +21,15 @@ import { DuplicateTemplateDialog } from './duplicate-template-dialog';
|
||||
|
||||
export type DataTableActionDropdownProps = {
|
||||
row: Template;
|
||||
templateRootPath: string;
|
||||
teamId?: number;
|
||||
};
|
||||
|
||||
export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
|
||||
export const DataTableActionDropdown = ({
|
||||
row,
|
||||
templateRootPath,
|
||||
teamId,
|
||||
}: DataTableActionDropdownProps) => {
|
||||
const { data: session } = useSession();
|
||||
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
@ -34,6 +40,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
}
|
||||
|
||||
const isOwner = row.userId === session.user.id;
|
||||
const isTeamTemplate = row.teamId === teamId;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
@ -44,20 +51,25 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuItem disabled={!isOwner} asChild>
|
||||
<Link href={`/templates/${row.id}`}>
|
||||
<DropdownMenuItem disabled={!isOwner && !isTeamTemplate} asChild>
|
||||
<Link href={`${templateRootPath}/${row.id}`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* <DropdownMenuItem disabled={!isOwner} onClick={async () => onDuplicateButtonClick(row.id)}> */}
|
||||
<DropdownMenuItem disabled={!isOwner} onClick={() => setDuplicateDialogOpen(true)}>
|
||||
<DropdownMenuItem
|
||||
disabled={!isOwner && !isTeamTemplate}
|
||||
onClick={() => setDuplicateDialogOpen(true)}
|
||||
>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem disabled={!isOwner} onClick={() => setDeleteDialogOpen(true)}>
|
||||
<DropdownMenuItem
|
||||
disabled={!isOwner && !isTeamTemplate}
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
@ -65,6 +77,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
|
||||
<DuplicateTemplateDialog
|
||||
id={row.id}
|
||||
teamId={teamId}
|
||||
open={isDuplicateDialogOpen}
|
||||
onOpenChange={setDuplicateDialogOpen}
|
||||
/>
|
||||
|
||||
@ -28,6 +28,9 @@ type TemplatesDataTableProps = {
|
||||
perPage: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
documentRootPath: string;
|
||||
templateRootPath: string;
|
||||
teamId?: number;
|
||||
};
|
||||
|
||||
export const TemplatesDataTable = ({
|
||||
@ -35,6 +38,9 @@ export const TemplatesDataTable = ({
|
||||
perPage,
|
||||
page,
|
||||
totalPages,
|
||||
documentRootPath,
|
||||
templateRootPath,
|
||||
teamId,
|
||||
}: TemplatesDataTableProps) => {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
@ -70,7 +76,7 @@ export const TemplatesDataTable = ({
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
router.push(`/documents/${id}`);
|
||||
router.push(`${documentRootPath}/${id}`);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
@ -131,7 +137,12 @@ export const TemplatesDataTable = ({
|
||||
{!isRowLoading && <Plus className="-ml-1 mr-2 h-4 w-4" />}
|
||||
Use Template
|
||||
</Button>
|
||||
<DataTableActionDropdown row={row.original} />
|
||||
|
||||
<DataTableActionDropdown
|
||||
row={row.original}
|
||||
teamId={teamId}
|
||||
templateRootPath={templateRootPath}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@ -35,20 +35,15 @@ export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateD
|
||||
|
||||
onOpenChange(false);
|
||||
},
|
||||
});
|
||||
|
||||
const onDeleteTemplate = async () => {
|
||||
try {
|
||||
await deleteTemplate({ id });
|
||||
} catch {
|
||||
onError: () => {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: 'This template could not be deleted at this time. Please try again.',
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
||||
@ -63,20 +58,18 @@ export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateD
|
||||
</DialogHeader>
|
||||
|
||||
<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"
|
||||
variant="secondary"
|
||||
disabled={isLoading}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button type="button" loading={isLoading} onClick={onDeleteTemplate} className="flex-1">
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
<Button type="button" loading={isLoading} onClick={async () => deleteTemplate({ id })}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@ -14,12 +14,14 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
type DuplicateTemplateDialogProps = {
|
||||
id: number;
|
||||
teamId?: number;
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
};
|
||||
|
||||
export const DuplicateTemplateDialog = ({
|
||||
id,
|
||||
teamId,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: DuplicateTemplateDialogProps) => {
|
||||
@ -40,22 +42,15 @@ export const DuplicateTemplateDialog = ({
|
||||
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while duplicating template.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const onDuplicate = async () => {
|
||||
try {
|
||||
await duplicateTemplate({
|
||||
templateId: id,
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while duplicating template.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
||||
<DialogContent>
|
||||
@ -66,20 +61,27 @@ export const DuplicateTemplateDialog = ({
|
||||
</DialogHeader>
|
||||
|
||||
<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={isLoading}
|
||||
variant="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button type="button" loading={isLoading} onClick={onDuplicate} className="flex-1">
|
||||
Duplicate
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
loading={isLoading}
|
||||
onClick={async () =>
|
||||
duplicateTemplate({
|
||||
templateId: id,
|
||||
teamId,
|
||||
})
|
||||
}
|
||||
>
|
||||
Duplicate
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@ -43,8 +43,14 @@ const ZCreateTemplateFormSchema = z.object({
|
||||
|
||||
type TCreateTemplateFormSchema = z.infer<typeof ZCreateTemplateFormSchema>;
|
||||
|
||||
export const NewTemplateDialog = () => {
|
||||
type NewTemplateDialogProps = {
|
||||
teamId?: number;
|
||||
templateRootPath: string;
|
||||
};
|
||||
|
||||
export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialogProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { data: session } = useSession();
|
||||
const { toast } = useToast();
|
||||
|
||||
@ -99,6 +105,7 @@ export const NewTemplateDialog = () => {
|
||||
});
|
||||
|
||||
const { id } = await createTemplate({
|
||||
teamId,
|
||||
title: values.name ? values.name : file.name,
|
||||
templateDocumentDataId,
|
||||
});
|
||||
@ -112,7 +119,7 @@ export const NewTemplateDialog = () => {
|
||||
|
||||
setShowNewTemplateDialog(false);
|
||||
|
||||
void router.push(`/templates/${id}`);
|
||||
router.push(`${templateRootPath}/${id}`);
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
|
||||
@ -2,57 +2,17 @@ import React from 'react';
|
||||
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getTemplates } from '@documenso/lib/server-only/template/get-templates';
|
||||
|
||||
import { TemplatesDataTable } from './data-table-templates';
|
||||
import { EmptyTemplateState } from './empty-state';
|
||||
import { NewTemplateDialog } from './new-template-dialog';
|
||||
import { TemplatesPageView } from './templates-page-view';
|
||||
import type { TemplatesPageViewProps } from './templates-page-view';
|
||||
|
||||
type TemplatesPageProps = {
|
||||
searchParams?: {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
};
|
||||
searchParams?: TemplatesPageViewProps['searchParams'];
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Templates',
|
||||
};
|
||||
|
||||
export default async function TemplatesPage({ searchParams = {} }: TemplatesPageProps) {
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
const page = Number(searchParams.page) || 1;
|
||||
const perPage = Number(searchParams.perPage) || 10;
|
||||
|
||||
const { templates, totalPages } = await getTemplates({
|
||||
userId: user.id,
|
||||
page: page,
|
||||
perPage: perPage,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h1 className="mb-5 mt-2 truncate text-2xl font-semibold md:text-3xl">Templates</h1>
|
||||
|
||||
<div>
|
||||
<NewTemplateDialog />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
{templates.length > 0 ? (
|
||||
<TemplatesDataTable
|
||||
templates={templates}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
totalPages={totalPages}
|
||||
/>
|
||||
) : (
|
||||
<EmptyTemplateState />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
export default function TemplatesPage({ searchParams = {} }: TemplatesPageProps) {
|
||||
return <TemplatesPageView searchParams={searchParams} />;
|
||||
}
|
||||
|
||||
@ -0,0 +1,73 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { findTemplates } from '@documenso/lib/server-only/template/find-templates';
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import type { Team } from '@documenso/prisma/client';
|
||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||
|
||||
import { TemplatesDataTable } from './data-table-templates';
|
||||
import { EmptyTemplateState } from './empty-state';
|
||||
import { NewTemplateDialog } from './new-template-dialog';
|
||||
|
||||
export type TemplatesPageViewProps = {
|
||||
searchParams?: {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
};
|
||||
team?: Team;
|
||||
};
|
||||
|
||||
export const TemplatesPageView = async ({ searchParams = {}, team }: TemplatesPageViewProps) => {
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
const page = Number(searchParams.page) || 1;
|
||||
const perPage = Number(searchParams.perPage) || 10;
|
||||
|
||||
const documentRootPath = formatDocumentsPath(team?.url);
|
||||
const templateRootPath = formatTemplatesPath(team?.url);
|
||||
|
||||
const { templates, totalPages } = await findTemplates({
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
page: page,
|
||||
perPage: perPage,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex flex-row items-center">
|
||||
{team && (
|
||||
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
|
||||
<AvatarFallback className="text-xs text-gray-400">
|
||||
{team.name.slice(0, 1)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
|
||||
<h1 className="truncate text-2xl font-semibold md:text-3xl">Templates</h1>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<NewTemplateDialog templateRootPath={templateRootPath} teamId={team?.id} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative mt-5">
|
||||
{templates.length > 0 ? (
|
||||
<TemplatesDataTable
|
||||
templates={templates}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
totalPages={totalPages}
|
||||
documentRootPath={documentRootPath}
|
||||
templateRootPath={templateRootPath}
|
||||
teamId={team?.id}
|
||||
/>
|
||||
) : (
|
||||
<EmptyTemplateState />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -3,6 +3,8 @@ import { NextResponse } from 'next/server';
|
||||
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
|
||||
import type { ShareHandlerAPIResponse } from '~/pages/api/share';
|
||||
|
||||
export const runtime = 'edge';
|
||||
@ -37,7 +39,7 @@ export async function GET(_request: Request, { params: { slug } }: SharePageOpen
|
||||
),
|
||||
]);
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
|
||||
const baseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
|
||||
const recipientOrSender: ShareHandlerAPIResponse = await fetch(
|
||||
new URL(`/api/share?slug=${slug}`, baseUrl),
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { Metadata } from 'next';
|
||||
import type { Metadata } from 'next';
|
||||
import { headers } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { APP_BASE_URL } from '@documenso/lib/constants/app';
|
||||
import { NEXT_PUBLIC_MARKETING_URL } from '@documenso/lib/constants/app';
|
||||
|
||||
type SharePageProps = {
|
||||
params: { slug: string };
|
||||
@ -16,12 +16,12 @@ export function generateMetadata({ params: { slug } }: SharePageProps) {
|
||||
title: 'Documenso - Join the open source signing revolution',
|
||||
description: 'I just signed with Documenso!',
|
||||
type: 'website',
|
||||
images: [`${APP_BASE_URL}/share/${slug}/opengraph`],
|
||||
images: [`/share/${slug}/opengraph`],
|
||||
},
|
||||
twitter: {
|
||||
site: '@documenso',
|
||||
card: 'summary_large_image',
|
||||
images: [`${APP_BASE_URL}/share/${slug}/opengraph`],
|
||||
images: [`/share/${slug}/opengraph`],
|
||||
description: 'I just signed with Documenso!',
|
||||
},
|
||||
} satisfies Metadata;
|
||||
@ -35,5 +35,5 @@ export default function SharePage() {
|
||||
return null;
|
||||
}
|
||||
|
||||
redirect(process.env.NEXT_PUBLIC_MARKETING_URL ?? 'http://localhost:3001');
|
||||
redirect(NEXT_PUBLIC_MARKETING_URL() ?? 'http://localhost:3001');
|
||||
}
|
||||
|
||||
@ -26,9 +26,10 @@ export type SigningFormProps = {
|
||||
document: Document;
|
||||
recipient: Recipient;
|
||||
fields: Field[];
|
||||
redirectUrl?: string | null;
|
||||
};
|
||||
|
||||
export const SigningForm = ({ document, recipient, fields }: SigningFormProps) => {
|
||||
export const SigningForm = ({ document, recipient, fields, redirectUrl }: SigningFormProps) => {
|
||||
const router = useRouter();
|
||||
const analytics = useAnalytics();
|
||||
const { data: session } = useSession();
|
||||
@ -74,7 +75,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
router.push(`/sign/${recipient.token}/complete`);
|
||||
redirectUrl ? router.push(redirectUrl) : router.push(`/sign/${recipient.token}/complete`);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@ -118,7 +118,7 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
|
||||
<span className="text-muted-foreground">({recipient.email})</span>
|
||||
</DialogTitle>
|
||||
|
||||
<div className="py-4">
|
||||
<div>
|
||||
<Label htmlFor="signature">Full Name</Label>
|
||||
|
||||
<Input
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { headers } from 'next/headers';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
|
||||
import { match } from 'ts-pattern';
|
||||
@ -8,12 +9,12 @@ import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { getDocumentMetaByDocumentId } from '@documenso/lib/server-only/document/get-document-meta-by-document-id';
|
||||
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
||||
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||
import { extractNextHeaderRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
@ -40,24 +41,26 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const requestHeaders = Object.fromEntries(headers().entries());
|
||||
|
||||
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
|
||||
|
||||
const [document, fields, recipient] = await Promise.all([
|
||||
getDocumentAndSenderByToken({
|
||||
token,
|
||||
}).catch(() => null),
|
||||
getFieldsForToken({ token }),
|
||||
getRecipientByToken({ token }).catch(() => null),
|
||||
viewedDocument({ token }).catch(() => null),
|
||||
viewedDocument({ token, requestMetadata }).catch(() => null),
|
||||
]);
|
||||
|
||||
const documentMeta = await getDocumentMetaByDocumentId({ id: document!.id }).catch(() => null);
|
||||
|
||||
if (!document || !document.documentData || !recipient) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const truncatedTitle = truncateTitle(document.title);
|
||||
|
||||
const { documentData } = document;
|
||||
const { documentData, documentMeta } = document;
|
||||
|
||||
const { user } = await getServerComponentSession();
|
||||
|
||||
@ -65,7 +68,9 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
||||
document.status === DocumentStatus.COMPLETED ||
|
||||
recipient.signingStatus === SigningStatus.SIGNED
|
||||
) {
|
||||
redirect(`/sign/${token}/complete`);
|
||||
documentMeta?.redirectUrl
|
||||
? redirect(documentMeta.redirectUrl)
|
||||
: redirect(`/sign/${token}/complete`);
|
||||
}
|
||||
|
||||
if (documentMeta?.password) {
|
||||
@ -133,7 +138,12 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
||||
</Card>
|
||||
|
||||
<div className="col-span-12 lg:col-span-5 xl:col-span-4">
|
||||
<SigningForm document={document} recipient={recipient} fields={fields} />
|
||||
<SigningForm
|
||||
document={document}
|
||||
recipient={recipient}
|
||||
fields={fields}
|
||||
redirectUrl={documentMeta?.redirectUrl}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
|
||||
import DocumentPageComponent from '~/app/(dashboard)/documents/[id]/document-page-view';
|
||||
import { DocumentPageView } from '~/app/(dashboard)/documents/[id]/document-page-view';
|
||||
|
||||
export type DocumentPageProps = {
|
||||
params: {
|
||||
@ -16,5 +16,5 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
const team = await getTeamByUrl({ userId: user.id, teamUrl });
|
||||
|
||||
return <DocumentPageComponent params={params} team={team} />;
|
||||
return <DocumentPageView params={params} team={team} />;
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-
|
||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
|
||||
import type { DocumentsPageViewProps } from '~/app/(dashboard)/documents/documents-page-view';
|
||||
import DocumentsPageView from '~/app/(dashboard)/documents/documents-page-view';
|
||||
import { DocumentsPageView } from '~/app/(dashboard)/documents/documents-page-view';
|
||||
|
||||
export type TeamsDocumentPageProps = {
|
||||
params: {
|
||||
|
||||
@ -18,7 +18,7 @@ export type TeamTransferStatusProps = {
|
||||
className?: string;
|
||||
currentUserTeamRole: TeamMemberRole;
|
||||
teamId: number;
|
||||
transferVerification: TeamTransferVerification | null;
|
||||
transferVerification: Pick<TeamTransferVerification, 'email' | 'expiresAt' | 'name'> | null;
|
||||
};
|
||||
|
||||
export const TeamTransferStatus = ({
|
||||
|
||||
22
apps/web/src/app/(teams)/t/[teamUrl]/templates/[id]/page.tsx
Normal file
22
apps/web/src/app/(teams)/t/[teamUrl]/templates/[id]/page.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
|
||||
import type { TemplatePageViewProps } from '~/app/(dashboard)/templates/[id]/template-page-view';
|
||||
import { TemplatePageView } from '~/app/(dashboard)/templates/[id]/template-page-view';
|
||||
|
||||
type TeamTemplatePageProps = {
|
||||
params: TemplatePageViewProps['params'] & {
|
||||
teamUrl: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function TeamTemplatePage({ params }: TeamTemplatePageProps) {
|
||||
const { teamUrl } = params;
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
const team = await getTeamByUrl({ userId: user.id, teamUrl });
|
||||
|
||||
return <TemplatePageView params={params} team={team} />;
|
||||
}
|
||||
26
apps/web/src/app/(teams)/t/[teamUrl]/templates/page.tsx
Normal file
26
apps/web/src/app/(teams)/t/[teamUrl]/templates/page.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
|
||||
import type { TemplatesPageViewProps } from '~/app/(dashboard)/templates/templates-page-view';
|
||||
import { TemplatesPageView } from '~/app/(dashboard)/templates/templates-page-view';
|
||||
|
||||
type TeamTemplatesPageProps = {
|
||||
searchParams?: TemplatesPageViewProps['searchParams'];
|
||||
params: {
|
||||
teamUrl: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function TeamTemplatesPage({
|
||||
searchParams = {},
|
||||
params,
|
||||
}: TeamTemplatesPageProps) {
|
||||
const { teamUrl } = params;
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
const team = await getTeamByUrl({ userId: user.id, teamUrl });
|
||||
|
||||
return <TemplatesPageView searchParams={searchParams} team={team} />;
|
||||
}
|
||||
@ -2,6 +2,8 @@ import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { env } from 'next-runtime-env';
|
||||
|
||||
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
|
||||
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
|
||||
|
||||
@ -18,6 +20,8 @@ type SignInPageProps = {
|
||||
};
|
||||
|
||||
export default function SignInPage({ searchParams }: SignInPageProps) {
|
||||
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
|
||||
|
||||
const rawEmail = typeof searchParams.email === 'string' ? searchParams.email : undefined;
|
||||
const email = rawEmail ? decryptSecondaryData(rawEmail) : null;
|
||||
|
||||
@ -39,7 +43,7 @@ export default function SignInPage({ searchParams }: SignInPageProps) {
|
||||
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
|
||||
/>
|
||||
|
||||
{process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (
|
||||
{NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (
|
||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||
Don't have an account?{' '}
|
||||
<Link href="/signup" className="text-primary duration-200 hover:opacity-70">
|
||||
|
||||
@ -2,6 +2,8 @@ import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { env } from 'next-runtime-env';
|
||||
|
||||
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
|
||||
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
|
||||
|
||||
@ -18,7 +20,9 @@ type SignUpPageProps = {
|
||||
};
|
||||
|
||||
export default function SignUpPage({ searchParams }: SignUpPageProps) {
|
||||
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
|
||||
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
|
||||
|
||||
if (NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
|
||||
redirect('/signin');
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,27 @@
|
||||
import { Mails } from 'lucide-react';
|
||||
|
||||
import { SendConfirmationEmailForm } from '~/components/forms/send-confirmation-email';
|
||||
|
||||
export default function UnverifiedAccount() {
|
||||
return (
|
||||
<div className="flex w-full items-start">
|
||||
<div className="mr-4 mt-1 hidden md:block">
|
||||
<Mails className="text-primary h-10 w-10" strokeWidth={2} />
|
||||
</div>
|
||||
<div className="">
|
||||
<h2 className="text-2xl font-bold md:text-4xl">Confirm email</h2>
|
||||
|
||||
<p className="text-muted-foreground mt-4">
|
||||
To gain access to your account, please confirm your email address by clicking on the
|
||||
confirmation link from your inbox.
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-4">
|
||||
If you don't find the confirmation link in your inbox, you can request a new one below.
|
||||
</p>
|
||||
|
||||
<SendConfirmationEmailForm />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -2,8 +2,11 @@ import { Suspense } from 'react';
|
||||
|
||||
import { Caveat, Inter } from 'next/font/google';
|
||||
|
||||
import { PublicEnvScript } from 'next-runtime-env';
|
||||
|
||||
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { LocaleProvider } from '@documenso/lib/client-only/providers/locale';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { getServerComponentAllFlags } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
||||
import { getLocale } from '@documenso/lib/server-only/headers/get-locale';
|
||||
import { TrpcProvider } from '@documenso/trpc/react';
|
||||
@ -19,32 +22,35 @@ import './globals.css';
|
||||
const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' });
|
||||
const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' });
|
||||
|
||||
export const metadata = {
|
||||
title: {
|
||||
template: '%s - Documenso',
|
||||
default: 'Documenso',
|
||||
},
|
||||
description:
|
||||
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
|
||||
keywords:
|
||||
'Documenso, open source, DocuSign alternative, document signing, open signing infrastructure, open-source community, fast signing, beautiful signing, smart templates',
|
||||
authors: { name: 'Documenso, Inc.' },
|
||||
robots: 'index, follow',
|
||||
openGraph: {
|
||||
title: 'Documenso - The Open Source DocuSign Alternative',
|
||||
export function generateMetadata() {
|
||||
return {
|
||||
title: {
|
||||
template: '%s - Documenso',
|
||||
default: 'Documenso',
|
||||
},
|
||||
description:
|
||||
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
|
||||
type: 'website',
|
||||
images: [`${process.env.NEXT_PUBLIC_WEBAPP_URL}/opengraph-image.jpg`],
|
||||
},
|
||||
twitter: {
|
||||
site: '@documenso',
|
||||
card: 'summary_large_image',
|
||||
images: [`${process.env.NEXT_PUBLIC_WEBAPP_URL}/opengraph-image.jpg`],
|
||||
description:
|
||||
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
|
||||
},
|
||||
};
|
||||
keywords:
|
||||
'Documenso, open source, DocuSign alternative, document signing, open signing infrastructure, open-source community, fast signing, beautiful signing, smart templates',
|
||||
authors: { name: 'Documenso, Inc.' },
|
||||
robots: 'index, follow',
|
||||
metadataBase: new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000'),
|
||||
openGraph: {
|
||||
title: 'Documenso - The Open Source DocuSign Alternative',
|
||||
description:
|
||||
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
|
||||
type: 'website',
|
||||
images: ['/opengraph-image.jpg'],
|
||||
},
|
||||
twitter: {
|
||||
site: '@documenso',
|
||||
card: 'summary_large_image',
|
||||
images: ['/opengraph-image.jpg'],
|
||||
description:
|
||||
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const flags = await getServerComponentAllFlags();
|
||||
@ -62,6 +68,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<PublicEnvScript />
|
||||
</head>
|
||||
|
||||
<Suspense>
|
||||
|
||||
@ -4,6 +4,7 @@ import React from 'react';
|
||||
|
||||
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
@ -25,7 +26,7 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
void copy(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${recipient.token}`).then(() => {
|
||||
void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`).then(() => {
|
||||
toast({
|
||||
title: 'Copied to clipboard',
|
||||
description: 'The signing link has been copied to your clipboard.',
|
||||
|
||||
@ -52,24 +52,22 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-baseline gap-x-6">
|
||||
{navigationLinks
|
||||
.filter(({ href }) => href !== '/templates' || rootHref === '') // Remove templates for team pages.
|
||||
.map(({ href, label }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={`${rootHref}${href}`}
|
||||
className={cn(
|
||||
'text-muted-foreground dark:text-muted focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2',
|
||||
{
|
||||
'text-foreground dark:text-muted-foreground': pathname?.startsWith(
|
||||
`${rootHref}${href}`,
|
||||
),
|
||||
},
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
{navigationLinks.map(({ href, label }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={`${rootHref}${href}`}
|
||||
className={cn(
|
||||
'text-muted-foreground dark:text-muted focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2',
|
||||
{
|
||||
'text-foreground dark:text-muted-foreground': pathname?.startsWith(
|
||||
`${rootHref}${href}`,
|
||||
),
|
||||
},
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<CommandMenu open={open} onOpenChange={setOpen} />
|
||||
|
||||
@ -7,6 +7,7 @@ import { useParams } from 'next/navigation';
|
||||
|
||||
import { MenuIcon, SearchIcon } from 'lucide-react';
|
||||
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
|
||||
import { getRootHref } from '@documenso/lib/utils/params';
|
||||
import type { User } from '@documenso/prisma/client';
|
||||
@ -18,6 +19,7 @@ import { CommandMenu } from '../common/command-menu';
|
||||
import { DesktopNav } from './desktop-nav';
|
||||
import { MenuSwitcher } from './menu-switcher';
|
||||
import { MobileNavigation } from './mobile-navigation';
|
||||
import { ProfileDropdown } from './profile-dropdown';
|
||||
|
||||
export type HeaderProps = HTMLAttributes<HTMLDivElement> & {
|
||||
user: User;
|
||||
@ -27,6 +29,10 @@ export type HeaderProps = HTMLAttributes<HTMLDivElement> & {
|
||||
export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
|
||||
const params = useParams();
|
||||
|
||||
const { getFlag } = useFeatureFlags();
|
||||
|
||||
const isTeamsEnabled = getFlag('app_teams');
|
||||
|
||||
const [isCommandMenuOpen, setIsCommandMenuOpen] = useState(false);
|
||||
const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false);
|
||||
const [scrollY, setScrollY] = useState(0);
|
||||
@ -41,6 +47,34 @@ export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
}, []);
|
||||
|
||||
if (!isTeamsEnabled) {
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
'supports-backdrop-blur:bg-background/60 bg-background/95 sticky top-0 z-[60] flex h-16 w-full items-center border-b border-b-transparent backdrop-blur duration-200',
|
||||
scrollY > 5 && 'border-b-border',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="mx-auto flex w-full max-w-screen-xl items-center justify-between gap-x-4 px-4 md:justify-normal md:px-8">
|
||||
<Link
|
||||
href="/"
|
||||
className="focus-visible:ring-ring ring-offset-background rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Logo className="h-6 w-auto" />
|
||||
</Link>
|
||||
|
||||
<DesktopNav />
|
||||
|
||||
<div className="flex gap-x-4 md:ml-8">
|
||||
<ProfileDropdown user={user} />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
@ -66,11 +100,11 @@ export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
|
||||
|
||||
<div className="flex flex-row items-center space-x-4 md:hidden">
|
||||
<button onClick={() => setIsCommandMenuOpen(true)}>
|
||||
<SearchIcon className="h-6 w-6" />
|
||||
<SearchIcon className="text-muted-foreground h-6 w-6" />
|
||||
</button>
|
||||
|
||||
<button onClick={() => setIsHamburgerMenuOpen(true)}>
|
||||
<MenuIcon className="h-6 w-6" />
|
||||
<MenuIcon className="text-muted-foreground h-6 w-6" />
|
||||
</button>
|
||||
|
||||
<CommandMenu open={isCommandMenuOpen} onOpenChange={setIsCommandMenuOpen} />
|
||||
|
||||
@ -6,7 +6,7 @@ import { usePathname } from 'next/navigation';
|
||||
import { CheckCircle2, ChevronsUpDown, Plus, Settings2 } from 'lucide-react';
|
||||
import { signOut } from 'next-auth/react';
|
||||
|
||||
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
||||
import { TEAM_MEMBER_ROLE_MAP, TEAM_URL_REGEX } from '@documenso/lib/constants/teams';
|
||||
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||
import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
@ -71,6 +71,22 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
|
||||
return TEAM_MEMBER_ROLE_MAP[team.currentTeamMember.role];
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats the redirect URL so we can switch between documents and templates page
|
||||
* seemlessly between teams and personal accounts.
|
||||
*/
|
||||
const formatRedirectUrlOnSwitch = (teamUrl?: string) => {
|
||||
const baseUrl = teamUrl ? `/t/${teamUrl}/` : '/';
|
||||
|
||||
const currentPathname = (pathname ?? '/').replace(TEAM_URL_REGEX, '');
|
||||
|
||||
if (currentPathname === '/templates') {
|
||||
return `${baseUrl}templates`;
|
||||
}
|
||||
|
||||
return baseUrl;
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@ -100,7 +116,7 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
|
||||
<DropdownMenuLabel>Personal</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/">
|
||||
<Link href={formatRedirectUrlOnSwitch()}>
|
||||
<AvatarWithText
|
||||
avatarFallback={formatAvatarFallback()}
|
||||
primaryText={user.name}
|
||||
@ -152,7 +168,7 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
|
||||
|
||||
{teams.map((team) => (
|
||||
<DropdownMenuItem asChild key={team.id}>
|
||||
<Link href={`/t/${team.url}`}>
|
||||
<Link href={formatRedirectUrlOnSwitch(team.url)}>
|
||||
<AvatarWithText
|
||||
avatarFallback={formatAvatarFallback(team.name)}
|
||||
primaryText={team.name}
|
||||
|
||||
@ -42,12 +42,12 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
|
||||
href: '/settings/profile',
|
||||
text: 'Settings',
|
||||
},
|
||||
].filter(({ text, href }) => text !== 'Templates' || href === '/templates'); // Filter out templates for teams.
|
||||
];
|
||||
|
||||
return (
|
||||
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
|
||||
<SheetContent className="flex w-full max-w-[400px] flex-col">
|
||||
<Link href="/" className="z-10" onClick={handleMenuItemClick}>
|
||||
<Link href="/" onClick={handleMenuItemClick}>
|
||||
<Image
|
||||
src={LogoImage}
|
||||
alt="Documenso Logo"
|
||||
|
||||
169
apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx
Normal file
169
apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx
Normal file
@ -0,0 +1,169 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import {
|
||||
CreditCard,
|
||||
FileSpreadsheet,
|
||||
Lock,
|
||||
LogOut,
|
||||
User as LucideUser,
|
||||
Monitor,
|
||||
Moon,
|
||||
Palette,
|
||||
Sun,
|
||||
UserCog,
|
||||
} from 'lucide-react';
|
||||
import { signOut } from 'next-auth/react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { LuGithub } from 'react-icons/lu';
|
||||
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import type { User } from '@documenso/prisma/client';
|
||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
|
||||
export type ProfileDropdownProps = {
|
||||
user: User;
|
||||
};
|
||||
|
||||
export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
||||
const { getFlag } = useFeatureFlags();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const isUserAdmin = isAdmin(user);
|
||||
|
||||
const isBillingEnabled = getFlag('app_billing');
|
||||
|
||||
const avatarFallback = user.name
|
||||
? extractInitials(user.name)
|
||||
: user.email.slice(0, 1).toUpperCase();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
title="Profile Dropdown"
|
||||
className="relative h-10 w-10 rounded-full"
|
||||
>
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarFallback>{avatarFallback}</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className="z-[60] w-56" align="end" forceMount>
|
||||
<DropdownMenuLabel>Account</DropdownMenuLabel>
|
||||
|
||||
{isUserAdmin && (
|
||||
<>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/admin" className="cursor-pointer">
|
||||
<UserCog className="mr-2 h-4 w-4" />
|
||||
Admin
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/settings/profile" className="cursor-pointer">
|
||||
<LucideUser className="mr-2 h-4 w-4" />
|
||||
Profile
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/settings/security" className="cursor-pointer">
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
Security
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{isBillingEnabled && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/settings/billing" className="cursor-pointer">
|
||||
<CreditCard className="mr-2 h-4 w-4" />
|
||||
Billing
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/templates" className="cursor-pointer">
|
||||
<FileSpreadsheet className="mr-2 h-4 w-4" />
|
||||
Templates
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Palette className="mr-2 h-4 w-4" />
|
||||
Themes
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent className="z-[60]">
|
||||
<DropdownMenuRadioGroup value={theme} onValueChange={setTheme}>
|
||||
<DropdownMenuRadioItem value="light">
|
||||
<Sun className="mr-2 h-4 w-4" /> Light
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="dark">
|
||||
<Moon className="mr-2 h-4 w-4" />
|
||||
Dark
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="system">
|
||||
<Monitor className="mr-2 h-4 w-4" />
|
||||
System
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href="https://github.com/documenso/documenso"
|
||||
className="cursor-pointer"
|
||||
target="_blank"
|
||||
>
|
||||
<LuGithub className="mr-2 h-4 w-4" />
|
||||
Star on Github
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
void signOut({
|
||||
callbackUrl: '/',
|
||||
})
|
||||
}
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Sign Out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@ -19,6 +19,7 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||
const { getFlag } = useFeatureFlags();
|
||||
|
||||
const isBillingEnabled = getFlag('app_billing');
|
||||
const isTeamsEnabled = getFlag('app_teams');
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-y-2', className)} {...props}>
|
||||
@ -35,18 +36,20 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="/settings/teams">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/teams') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Users className="mr-2 h-5 w-5" />
|
||||
Teams
|
||||
</Button>
|
||||
</Link>
|
||||
{isTeamsEnabled && (
|
||||
<Link href="/settings/teams">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/teams') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Users className="mr-2 h-5 w-5" />
|
||||
Teams
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Link href="/settings/webhooks">
|
||||
<Button
|
||||
|
||||
@ -19,6 +19,7 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
||||
const { getFlag } = useFeatureFlags();
|
||||
|
||||
const isBillingEnabled = getFlag('app_billing');
|
||||
const isTeamsEnabled = getFlag('app_teams');
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -38,18 +39,20 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="/settings/teams">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/teams') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Users className="mr-2 h-5 w-5" />
|
||||
Teams
|
||||
</Button>
|
||||
</Link>
|
||||
{isTeamsEnabled && (
|
||||
<Link href="/settings/teams">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/teams') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Users className="mr-2 h-5 w-5" />
|
||||
Teams
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Link href="/settings/webhooks">
|
||||
<Button
|
||||
|
||||
@ -158,6 +158,7 @@ export const CreateTeamCheckoutDialog = ({
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={selectedPrice.interval === 'yearly'}
|
||||
loading={isCreatingCheckout}
|
||||
onClick={async () =>
|
||||
createCheckout({
|
||||
@ -166,7 +167,7 @@ export const CreateTeamCheckoutDialog = ({
|
||||
})
|
||||
}
|
||||
>
|
||||
Checkout
|
||||
{selectedPrice.interval === 'monthly' ? 'Checkout' : 'Coming soon'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
|
||||
@ -238,7 +238,7 @@ export const TransferTeamDialog = ({
|
||||
<Alert variant="neutral">
|
||||
<AlertDescription>
|
||||
<ul className="list-outside list-disc space-y-2 pl-4">
|
||||
{IS_BILLING_ENABLED && (
|
||||
{IS_BILLING_ENABLED() && (
|
||||
// Temporary removed.
|
||||
// <li>
|
||||
// {form.getValues('clearPaymentMethods')
|
||||
|
||||
@ -48,7 +48,7 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{IS_BILLING_ENABLED && (
|
||||
{IS_BILLING_ENABLED() && (
|
||||
<Link href={billingPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@ -56,7 +56,7 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{IS_BILLING_ENABLED && (
|
||||
{IS_BILLING_ENABLED() && (
|
||||
<Link href={billingPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@ -30,13 +30,11 @@ export const AuthenticatorApp = ({ isTwoFactorEnabled }: AuthenticatorAppProps)
|
||||
</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)}
|
||||
/>
|
||||
|
||||
95
apps/web/src/components/forms/send-confirmation-email.tsx
Normal file
95
apps/web/src/components/forms/send-confirmation-email.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
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 ZSendConfirmationEmailFormSchema = z.object({
|
||||
email: z.string().email().min(1),
|
||||
});
|
||||
|
||||
export type TSendConfirmationEmailFormSchema = z.infer<typeof ZSendConfirmationEmailFormSchema>;
|
||||
|
||||
export type SendConfirmationEmailFormProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const SendConfirmationEmailForm = ({ className }: SendConfirmationEmailFormProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm<TSendConfirmationEmailFormSchema>({
|
||||
values: {
|
||||
email: '',
|
||||
},
|
||||
resolver: zodResolver(ZSendConfirmationEmailFormSchema),
|
||||
});
|
||||
|
||||
const isSubmitting = form.formState.isSubmitting;
|
||||
|
||||
const { mutateAsync: sendConfirmationEmail } = trpc.profile.sendConfirmationEmail.useMutation();
|
||||
|
||||
const onFormSubmit = async ({ email }: TSendConfirmationEmailFormSchema) => {
|
||||
try {
|
||||
await sendConfirmationEmail({ email });
|
||||
|
||||
toast({
|
||||
title: 'Confirmation email sent',
|
||||
description:
|
||||
'A confirmation email has been sent, and it should arrive in your inbox shortly.',
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
form.reset();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'An error occurred while sending your confirmation email',
|
||||
description: 'Please try again and make sure you enter the correct email address.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
className={cn('mt-6 flex w-full flex-col gap-y-4', className)}
|
||||
onSubmit={form.handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email address</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="email" {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormMessage />
|
||||
|
||||
<Button size="lg" type="submit" disabled={isSubmitting} loading={isSubmitting}>
|
||||
Send confirmation email
|
||||
</Button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { signIn } from 'next-auth/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
@ -38,6 +40,8 @@ const ERROR_MESSAGES: Partial<Record<keyof typeof ErrorCode, string>> = {
|
||||
'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',
|
||||
[ErrorCode.UNVERIFIED_EMAIL]:
|
||||
'This account has not been verified. Please verify your account before signing in.',
|
||||
};
|
||||
|
||||
const TwoFactorEnabledErrorCode = ErrorCode.TWO_FACTOR_MISSING_CREDENTIALS;
|
||||
@ -63,6 +67,7 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
||||
const { toast } = useToast();
|
||||
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
|
||||
useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
|
||||
'totp' | 'backup'
|
||||
@ -130,6 +135,17 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
||||
|
||||
const errorMessage = ERROR_MESSAGES[result.error];
|
||||
|
||||
if (result.error === ErrorCode.UNVERIFIED_EMAIL) {
|
||||
router.push(`/unverified-account`);
|
||||
|
||||
toast({
|
||||
title: 'Unable to sign in',
|
||||
description: errorMessage ?? 'An unknown error occurred',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Unable to sign in',
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { signIn } from 'next-auth/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
@ -55,6 +57,7 @@ export type SignUpFormProps = {
|
||||
export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled }: SignUpFormProps) => {
|
||||
const { toast } = useToast();
|
||||
const analytics = useAnalytics();
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm<TSignUpFormSchema>({
|
||||
values: {
|
||||
@ -74,10 +77,13 @@ export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
||||
try {
|
||||
await signup({ name, email, password, signature });
|
||||
|
||||
await signIn('credentials', {
|
||||
email,
|
||||
password,
|
||||
callbackUrl: SIGN_UP_REDIRECT_PATH,
|
||||
router.push(`/unverified-account`);
|
||||
|
||||
toast({
|
||||
title: 'Registration Successful',
|
||||
description:
|
||||
'You have successfully registered. Please verify your account by clicking on the link you received in the email.',
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
analytics.capture('App: User Sign Up', {
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
|
||||
/**
|
||||
* getAssetBuffer is used to retrieve array buffers for various assets
|
||||
* that are hosted in the `public` folder.
|
||||
@ -8,7 +10,7 @@
|
||||
* @param path The path to the asset, relative to the `public` folder.
|
||||
*/
|
||||
export const getAssetBuffer = async (path: string) => {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
|
||||
const baseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
|
||||
return fetch(new URL(path, baseUrl)).then(async (res) => res.arrayBuffer());
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user