mirror of
https://github.com/documenso/documenso.git
synced 2025-11-17 02:01:33 +10:00
fix: wip
This commit is contained in:
@ -22,6 +22,7 @@ import {
|
||||
getUsersWithSubscriptionsCount,
|
||||
} from '@documenso/lib/server-only/admin/get-users-stats';
|
||||
import { getSignerConversionMonthly } from '@documenso/lib/server-only/user/get-signer-conversion';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
|
||||
import { CardMetric } from '~/components/(dashboard)/metric-card/metric-card';
|
||||
import { AdminStatsSignerConversionChart } from '~/components/general/admin-stats-signer-conversion-chart';
|
||||
@ -87,11 +88,7 @@ export default function AdminStatsPage({ loaderData }: Route.ComponentProps) {
|
||||
value={usersWithSubscriptionsCount}
|
||||
/>
|
||||
|
||||
<CardMetric
|
||||
icon={FileCog}
|
||||
title={_(msg`App Version`)}
|
||||
value={`v${process.env.APP_VERSION}`}
|
||||
/>
|
||||
<CardMetric icon={FileCog} title={_(msg`App Version`)} value={`v${env('APP_VERSION')}`} />
|
||||
</div>
|
||||
|
||||
<div className="mt-16 gap-8">
|
||||
|
||||
283
apps/remix/app/routes/_authenticated+/documents+/$id._index.tsx
Normal file
283
apps/remix/app/routes/_authenticated+/documents+/$id._index.tsx
Normal file
@ -0,0 +1,283 @@
|
||||
import { Plural, Trans } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
import { ChevronLeft, Clock9, Users2 } from 'lucide-react';
|
||||
import { Link, redirect } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { getRequiredSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
||||
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';
|
||||
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
|
||||
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
|
||||
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
||||
import { DocumentHistorySheet } from '~/components/document/document-history-sheet';
|
||||
import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields';
|
||||
import { DocumentRecipientLinkCopyDialog } from '~/components/document/document-recipient-link-copy-dialog';
|
||||
import {
|
||||
DocumentStatus as DocumentStatusComponent,
|
||||
FRIENDLY_STATUS_MAP,
|
||||
} from '~/components/formatter/document-status';
|
||||
import { DocumentPageViewButton } from '~/components/pages/document/document-page-view-button';
|
||||
import { DocumentPageViewDropdown } from '~/components/pages/document/document-page-view-dropdown';
|
||||
import { DocumentPageViewInformation } from '~/components/pages/document/document-page-view-information';
|
||||
import { DocumentPageViewRecentActivity } from '~/components/pages/document/document-page-view-recent-activity';
|
||||
import { DocumentPageViewRecipients } from '~/components/pages/document/document-page-view-recipients';
|
||||
import { useAuth } from '~/providers/auth';
|
||||
|
||||
import type { Route } from './+types/$id._index';
|
||||
|
||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
const { id } = params;
|
||||
|
||||
const { user } = await getRequiredSession(request);
|
||||
|
||||
// Todo: Get from parent loader, this is just for testing.
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
documents: {
|
||||
some: {
|
||||
id: Number(id),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const documentId = Number(id);
|
||||
|
||||
const documentRootPath = formatDocumentsPath(team?.url);
|
||||
|
||||
if (!documentId || Number.isNaN(documentId)) {
|
||||
return redirect(documentRootPath);
|
||||
}
|
||||
|
||||
const document = await getDocumentById({
|
||||
documentId,
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
}).catch(() => null);
|
||||
|
||||
if (document?.teamId && !team?.url) {
|
||||
return redirect(documentRootPath);
|
||||
}
|
||||
|
||||
const documentVisibility = document?.visibility;
|
||||
const currentTeamMemberRole = team?.currentTeamMember?.role;
|
||||
const isRecipient = document?.recipients.find((recipient) => recipient.email === user.email);
|
||||
let canAccessDocument = true;
|
||||
|
||||
if (team && !isRecipient && document?.userId !== user.id) {
|
||||
canAccessDocument = match([documentVisibility, currentTeamMemberRole])
|
||||
.with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true)
|
||||
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true)
|
||||
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MEMBER], () => true)
|
||||
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.ADMIN], () => true)
|
||||
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.MANAGER], () => true)
|
||||
.with([DocumentVisibility.ADMIN, TeamMemberRole.ADMIN], () => true)
|
||||
.otherwise(() => false);
|
||||
}
|
||||
|
||||
if (!document || !document.documentData || (team && !canAccessDocument)) {
|
||||
return redirect(documentRootPath);
|
||||
}
|
||||
|
||||
if (team && !canAccessDocument) {
|
||||
return redirect(documentRootPath);
|
||||
}
|
||||
|
||||
const { documentMeta } = document;
|
||||
|
||||
if (documentMeta?.password) {
|
||||
const key = DOCUMENSO_ENCRYPTION_KEY;
|
||||
|
||||
if (!key) {
|
||||
throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY');
|
||||
}
|
||||
|
||||
const securePassword = Buffer.from(
|
||||
symmetricDecrypt({
|
||||
key,
|
||||
data: documentMeta.password,
|
||||
}),
|
||||
).toString('utf-8');
|
||||
|
||||
documentMeta.password = securePassword;
|
||||
}
|
||||
|
||||
// Todo: Get full document instead???
|
||||
const [recipients, fields] = await Promise.all([
|
||||
getRecipientsForDocument({
|
||||
documentId,
|
||||
teamId: team?.id,
|
||||
userId: user.id,
|
||||
}),
|
||||
getFieldsForDocument({
|
||||
documentId,
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
}),
|
||||
]);
|
||||
|
||||
const documentWithRecipients = {
|
||||
...document,
|
||||
recipients,
|
||||
};
|
||||
|
||||
return {
|
||||
document: documentWithRecipients,
|
||||
documentRootPath,
|
||||
fields,
|
||||
};
|
||||
}
|
||||
|
||||
export default function DocumentPage({ loaderData }: Route.ComponentProps) {
|
||||
const { _ } = useLingui();
|
||||
const { user } = useAuth();
|
||||
|
||||
const { document, documentRootPath, fields } = loaderData;
|
||||
|
||||
const { recipients, documentData, documentMeta } = document;
|
||||
|
||||
const isDocumentHistoryEnabled = false; // Todo: Was flag
|
||||
|
||||
return (
|
||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||
{document.status === DocumentStatus.PENDING && (
|
||||
<DocumentRecipientLinkCopyDialog recipients={recipients} />
|
||||
)}
|
||||
|
||||
<Link to={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
<Trans>Documents</Trans>
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-row justify-between truncate">
|
||||
<div>
|
||||
<h1
|
||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||
title={document.title}
|
||||
>
|
||||
{document.title}
|
||||
</h1>
|
||||
|
||||
<div className="mt-2.5 flex items-center gap-x-6">
|
||||
<DocumentStatusComponent
|
||||
inheritColor
|
||||
status={document.status}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
|
||||
{recipients.length > 0 && (
|
||||
<div className="text-muted-foreground flex items-center">
|
||||
<Users2 className="mr-2 h-5 w-5" />
|
||||
|
||||
<StackAvatarsWithTooltip
|
||||
recipients={recipients}
|
||||
documentStatus={document.status}
|
||||
position="bottom"
|
||||
>
|
||||
<span>
|
||||
<Trans>{recipients.length} Recipient(s)</Trans>
|
||||
</span>
|
||||
</StackAvatarsWithTooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{document.deletedAt && (
|
||||
<Badge variant="destructive">
|
||||
<Trans>Document deleted</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isDocumentHistoryEnabled && (
|
||||
<div className="self-end">
|
||||
<DocumentHistorySheet documentId={document.id} userId={user.id}>
|
||||
<Button variant="outline">
|
||||
<Clock9 className="mr-1.5 h-4 w-4" />
|
||||
<Trans>Document history</Trans>
|
||||
</Button>
|
||||
</DocumentHistorySheet>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid w-full grid-cols-12 gap-8">
|
||||
<Card
|
||||
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
|
||||
gradient
|
||||
>
|
||||
<CardContent className="p-2">
|
||||
<LazyPDFViewer document={document} key={documentData.id} documentData={documentData} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{document.status === DocumentStatus.PENDING && (
|
||||
<DocumentReadOnlyFields fields={fields} documentMeta={documentMeta || undefined} />
|
||||
)}
|
||||
|
||||
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||
<div className="space-y-6">
|
||||
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
|
||||
<div className="flex flex-row items-center justify-between px-4">
|
||||
<h3 className="text-foreground text-2xl font-semibold">
|
||||
{_(FRIENDLY_STATUS_MAP[document.status].labelExtended)}
|
||||
</h3>
|
||||
|
||||
<DocumentPageViewDropdown document={document} />
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground mt-2 px-4 text-sm">
|
||||
{match(document.status)
|
||||
.with(DocumentStatus.COMPLETED, () => (
|
||||
<Trans>This document has been signed by all recipients</Trans>
|
||||
))
|
||||
.with(DocumentStatus.DRAFT, () => (
|
||||
<Trans>This document is currently a draft and has not been sent</Trans>
|
||||
))
|
||||
.with(DocumentStatus.PENDING, () => {
|
||||
const pendingRecipients = recipients.filter(
|
||||
(recipient) => recipient.signingStatus === 'NOT_SIGNED',
|
||||
);
|
||||
|
||||
return (
|
||||
<Plural
|
||||
value={pendingRecipients.length}
|
||||
one="Waiting on 1 recipient"
|
||||
other="Waiting on # recipients"
|
||||
/>
|
||||
);
|
||||
})
|
||||
.exhaustive()}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 border-t px-4 pt-4">
|
||||
<DocumentPageViewButton document={document} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Document information section. */}
|
||||
<DocumentPageViewInformation document={document} userId={user.id} />
|
||||
|
||||
{/* Recipients section. */}
|
||||
<DocumentPageViewRecipients document={document} documentRootPath={documentRootPath} />
|
||||
|
||||
{/* Recent activity section. */}
|
||||
<DocumentPageViewRecentActivity documentId={document.id} userId={user.id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
163
apps/remix/app/routes/_authenticated+/documents+/$id.edit.tsx
Normal file
163
apps/remix/app/routes/_authenticated+/documents+/$id.edit.tsx
Normal file
@ -0,0 +1,163 @@
|
||||
import { Plural, Trans } from '@lingui/macro';
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
import { DocumentStatus as InternalDocumentStatus } from '@prisma/client';
|
||||
import { ChevronLeft, Users2 } from 'lucide-react';
|
||||
import { Link, redirect } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { getRequiredSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
||||
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
|
||||
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
|
||||
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||
import { DocumentEditForm } from '~/components/pages/document/document-edit-form';
|
||||
|
||||
import type { Route } from './+types/$id.edit';
|
||||
|
||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
const { id } = params;
|
||||
|
||||
const { user } = await getRequiredSession(request);
|
||||
|
||||
// Todo: Get from parent loader, this is just for testing.
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
documents: {
|
||||
some: {
|
||||
id: Number(id),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const documentId = Number(id);
|
||||
|
||||
const documentRootPath = formatDocumentsPath(team?.url);
|
||||
|
||||
if (!documentId || Number.isNaN(documentId)) {
|
||||
return redirect(documentRootPath);
|
||||
}
|
||||
|
||||
const document = await getDocumentWithDetailsById({
|
||||
documentId,
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
}).catch(() => null);
|
||||
|
||||
if (document?.teamId && !team?.url) {
|
||||
return redirect(documentRootPath);
|
||||
}
|
||||
|
||||
const documentVisibility = document?.visibility;
|
||||
const currentTeamMemberRole = team?.currentTeamMember?.role;
|
||||
const isRecipient = document?.recipients.find((recipient) => recipient.email === user.email);
|
||||
let canAccessDocument = true;
|
||||
|
||||
if (!isRecipient && document?.userId !== user.id) {
|
||||
canAccessDocument = match([documentVisibility, currentTeamMemberRole])
|
||||
.with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true)
|
||||
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true)
|
||||
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MEMBER], () => true)
|
||||
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.ADMIN], () => true)
|
||||
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.MANAGER], () => true)
|
||||
.with([DocumentVisibility.ADMIN, TeamMemberRole.ADMIN], () => true)
|
||||
.otherwise(() => false);
|
||||
}
|
||||
|
||||
if (!document) {
|
||||
return redirect(documentRootPath);
|
||||
}
|
||||
|
||||
if (team && !canAccessDocument) {
|
||||
return redirect(documentRootPath);
|
||||
}
|
||||
|
||||
if (document.status === InternalDocumentStatus.COMPLETED) {
|
||||
return redirect(`${documentRootPath}/${documentId}`);
|
||||
}
|
||||
|
||||
const { documentMeta, recipients } = document;
|
||||
|
||||
if (documentMeta?.password) {
|
||||
const key = DOCUMENSO_ENCRYPTION_KEY;
|
||||
|
||||
if (!key) {
|
||||
throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY');
|
||||
}
|
||||
|
||||
const securePassword = Buffer.from(
|
||||
symmetricDecrypt({
|
||||
key,
|
||||
data: documentMeta.password,
|
||||
}),
|
||||
).toString('utf-8');
|
||||
|
||||
documentMeta.password = securePassword;
|
||||
}
|
||||
|
||||
const isDocumentEnterprise = await isUserEnterprise({
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
});
|
||||
|
||||
return {
|
||||
document,
|
||||
documentRootPath,
|
||||
isDocumentEnterprise,
|
||||
};
|
||||
}
|
||||
|
||||
export default function DocumentEditPage({ loaderData }: Route.ComponentProps) {
|
||||
const { document, documentRootPath, isDocumentEnterprise } = loaderData;
|
||||
|
||||
const { recipients } = document;
|
||||
|
||||
return (
|
||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||
<Link to={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
<Trans>Documents</Trans>
|
||||
</Link>
|
||||
|
||||
<h1
|
||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||
title={document.title}
|
||||
>
|
||||
{document.title}
|
||||
</h1>
|
||||
|
||||
<div className="mt-2.5 flex items-center gap-x-6">
|
||||
<DocumentStatus inheritColor status={document.status} className="text-muted-foreground" />
|
||||
|
||||
{recipients.length > 0 && (
|
||||
<div className="text-muted-foreground flex items-center">
|
||||
<Users2 className="mr-2 h-5 w-5" />
|
||||
|
||||
<StackAvatarsWithTooltip
|
||||
recipients={recipients}
|
||||
documentStatus={document.status}
|
||||
position="bottom"
|
||||
>
|
||||
<span>
|
||||
<Plural one="1 Recipient" other="# Recipients" value={recipients.length} />
|
||||
</span>
|
||||
</StackAvatarsWithTooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DocumentEditForm
|
||||
className="mt-6"
|
||||
initialDocument={document}
|
||||
documentRootPath={documentRootPath}
|
||||
isDocumentEnterprise={isDocumentEnterprise}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
192
apps/remix/app/routes/_authenticated+/documents+/$id.logs.tsx
Normal file
192
apps/remix/app/routes/_authenticated+/documents+/$id.logs.tsx
Normal file
@ -0,0 +1,192 @@
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import type { Recipient } from '@prisma/client';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { Link, redirect } from 'react-router';
|
||||
|
||||
import { getRequiredSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { Card } from '@documenso/ui/primitives/card';
|
||||
|
||||
import {
|
||||
DocumentStatus as DocumentStatusComponent,
|
||||
FRIENDLY_STATUS_MAP,
|
||||
} from '~/components/formatter/document-status';
|
||||
import { DocumentAuditLogDownloadButton } from '~/components/pages/document/document-audit-log-download-button';
|
||||
import { DocumentCertificateDownloadButton } from '~/components/pages/document/document-certificate-download-button';
|
||||
import { DocumentLogsTable } from '~/components/tables/document-logs-table';
|
||||
|
||||
import type { Route } from './+types/$id.logs';
|
||||
|
||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
const { id } = params;
|
||||
|
||||
const { user } = await getRequiredSession(request);
|
||||
|
||||
// Todo: Get from parent loader, this is just for testing.
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
documents: {
|
||||
some: {
|
||||
id: Number(id),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const documentId = Number(id);
|
||||
|
||||
const documentRootPath = formatDocumentsPath(team?.url);
|
||||
|
||||
if (!documentId || Number.isNaN(documentId)) {
|
||||
return redirect(documentRootPath);
|
||||
}
|
||||
|
||||
// Todo: Get detailed?
|
||||
const [document, recipients] = await Promise.all([
|
||||
getDocumentById({
|
||||
documentId,
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
}).catch(() => null),
|
||||
getRecipientsForDocument({
|
||||
documentId,
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!document || !document.documentData) {
|
||||
return redirect(documentRootPath);
|
||||
}
|
||||
|
||||
return {
|
||||
document,
|
||||
documentRootPath,
|
||||
recipients,
|
||||
};
|
||||
}
|
||||
|
||||
export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps) {
|
||||
const { document, documentRootPath, recipients } = loaderData;
|
||||
|
||||
const { _, i18n } = useLingui();
|
||||
|
||||
const documentInformation: { description: MessageDescriptor; value: string }[] = [
|
||||
{
|
||||
description: msg`Document title`,
|
||||
value: document.title,
|
||||
},
|
||||
{
|
||||
description: msg`Document ID`,
|
||||
value: document.id.toString(),
|
||||
},
|
||||
{
|
||||
description: msg`Document status`,
|
||||
value: _(FRIENDLY_STATUS_MAP[document.status].label),
|
||||
},
|
||||
{
|
||||
description: msg`Created by`,
|
||||
value: document.user.name
|
||||
? `${document.user.name} (${document.user.email})`
|
||||
: document.user.email,
|
||||
},
|
||||
{
|
||||
description: msg`Date created`,
|
||||
value: DateTime.fromJSDate(document.createdAt)
|
||||
.setLocale(i18n.locales?.[0] || i18n.locale)
|
||||
.toLocaleString(DateTime.DATETIME_MED_WITH_SECONDS),
|
||||
},
|
||||
{
|
||||
description: msg`Last updated`,
|
||||
value: DateTime.fromJSDate(document.updatedAt)
|
||||
.setLocale(i18n.locales?.[0] || i18n.locale)
|
||||
.toLocaleString(DateTime.DATETIME_MED_WITH_SECONDS),
|
||||
},
|
||||
{
|
||||
description: msg`Time zone`,
|
||||
value: document.documentMeta?.timezone ?? 'N/A',
|
||||
},
|
||||
];
|
||||
|
||||
const formatRecipientText = (recipient: Recipient) => {
|
||||
let text = recipient.email;
|
||||
|
||||
if (recipient.name) {
|
||||
text = `${recipient.name} (${recipient.email})`;
|
||||
}
|
||||
|
||||
return `[${recipient.role}] ${text}`;
|
||||
};
|
||||
return (
|
||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||
<Link
|
||||
to={`${documentRootPath}/${document.id}`}
|
||||
className="flex items-center text-[#7AC455] hover:opacity-80"
|
||||
>
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
<Trans>Document</Trans>
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-col justify-between truncate sm:flex-row">
|
||||
<div>
|
||||
<h1
|
||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||
title={document.title}
|
||||
>
|
||||
{document.title}
|
||||
</h1>
|
||||
|
||||
<div className="mt-2.5 flex items-center gap-x-6">
|
||||
<DocumentStatusComponent
|
||||
inheritColor
|
||||
status={document.status}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end">
|
||||
<DocumentCertificateDownloadButton
|
||||
className="mr-2"
|
||||
documentId={document.id}
|
||||
documentStatus={document.status}
|
||||
/>
|
||||
|
||||
<DocumentAuditLogDownloadButton documentId={document.id} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="mt-6">
|
||||
<Card className="grid grid-cols-1 gap-4 p-4 sm:grid-cols-2" degrees={45} gradient>
|
||||
{documentInformation.map((info, i) => (
|
||||
<div className="text-foreground text-sm" key={i}>
|
||||
<h3 className="font-semibold">{_(info.description)}</h3>
|
||||
<p className="text-muted-foreground">{info.value}</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="text-foreground text-sm">
|
||||
<h3 className="font-semibold">Recipients</h3>
|
||||
<ul className="text-muted-foreground list-inside list-disc">
|
||||
{recipients.map((recipient) => (
|
||||
<li key={`recipient-${recipient.id}`}>
|
||||
<span className="-ml-2">{formatRecipientText(recipient)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="mt-6">
|
||||
<DocumentLogsTable documentId={document.id} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
165
apps/remix/app/routes/_authenticated+/documents+/_index.tsx
Normal file
165
apps/remix/app/routes/_authenticated+/documents+/_index.tsx
Normal file
@ -0,0 +1,165 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { parseToIntegerArray } from '@documenso/lib/utils/params';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
|
||||
import { DocumentSearch } from '~/components/(dashboard)/document-search/document-search';
|
||||
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
|
||||
import { isPeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
|
||||
import { DocumentUploadDropzone } from '~/components/document/document-upload';
|
||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||
import { UpcomingProfileClaimTeaser } from '~/components/general/upcoming-profile-claim-teaser';
|
||||
import { DocumentsTable } from '~/components/tables/documents-table';
|
||||
import { DocumentsTableEmptyState } from '~/components/tables/documents-table-empty-state';
|
||||
import { DocumentsTableSenderFilter } from '~/components/tables/documents-table-sender-filter';
|
||||
import { useAuth } from '~/providers/auth';
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
|
||||
export function meta() {
|
||||
return [{ title: 'Documents' }];
|
||||
}
|
||||
|
||||
// searchParams?: {
|
||||
// status?: ExtendedDocumentStatus;
|
||||
// period?: PeriodSelectorValue;
|
||||
// page?: string;
|
||||
// perPage?: string;
|
||||
// senderIds?: string;
|
||||
// search?: string;
|
||||
// };
|
||||
|
||||
export default function DocumentsPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const { user } = useAuth();
|
||||
const team = useOptionalCurrentTeam();
|
||||
|
||||
const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
|
||||
const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : '';
|
||||
const page = Number(searchParams.page) || 1;
|
||||
const perPage = Number(searchParams.perPage) || 20;
|
||||
const senderIds = parseToIntegerArray(searchParams.senderIds ?? '');
|
||||
const search = searchParams.search || '';
|
||||
const currentTeam = team
|
||||
? { id: team.id, url: team.url, teamEmail: team.teamEmail?.email }
|
||||
: undefined;
|
||||
const currentTeamMemberRole = team?.currentTeamMember?.role;
|
||||
|
||||
// const results = await findDocuments({
|
||||
// status,
|
||||
// orderBy: {
|
||||
// column: 'createdAt',
|
||||
// direction: 'desc',
|
||||
// },
|
||||
// page,
|
||||
// perPage,
|
||||
// period,
|
||||
// senderIds,
|
||||
// query: search,
|
||||
// });
|
||||
|
||||
const { data, isLoading, isLoadingError } = trpc.document.findDocuments.useQuery({
|
||||
page,
|
||||
perPage,
|
||||
});
|
||||
|
||||
const getTabHref = (value: typeof status) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
|
||||
params.set('status', value);
|
||||
|
||||
if (params.has('page')) {
|
||||
params.delete('page');
|
||||
}
|
||||
|
||||
return `${formatDocumentsPath(team?.url)}?${params.toString()}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<UpcomingProfileClaimTeaser />
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||
<DocumentUploadDropzone team={currentTeam} />
|
||||
|
||||
<div className="mt-12 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
|
||||
<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">
|
||||
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
|
||||
<AvatarFallback className="text-xs text-gray-400">
|
||||
{team.name.slice(0, 1)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
|
||||
<h1 className="text-4xl font-semibold">
|
||||
<Trans>Documents</Trans>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
|
||||
<Tabs value={status} className="overflow-x-auto">
|
||||
<TabsList>
|
||||
{[
|
||||
ExtendedDocumentStatus.INBOX,
|
||||
ExtendedDocumentStatus.PENDING,
|
||||
ExtendedDocumentStatus.COMPLETED,
|
||||
ExtendedDocumentStatus.DRAFT,
|
||||
ExtendedDocumentStatus.ALL,
|
||||
].map((value) => (
|
||||
<TabsTrigger
|
||||
key={value}
|
||||
className="hover:text-foreground min-w-[60px]"
|
||||
value={value}
|
||||
asChild
|
||||
>
|
||||
<Link to={getTabHref(value)} preventScrollReset>
|
||||
<DocumentStatus status={value} />
|
||||
|
||||
{value !== ExtendedDocumentStatus.ALL && (
|
||||
<span className="ml-1 inline-block opacity-50">todo</span>
|
||||
)}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{team && <DocumentsTableSenderFilter teamId={team.id} />}
|
||||
|
||||
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
||||
<PeriodSelector />
|
||||
</div>
|
||||
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
||||
<DocumentSearch initialValue={search} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<div>
|
||||
{data && data.count === 0 ? (
|
||||
<DocumentsTableEmptyState status={status} />
|
||||
) : (
|
||||
<DocumentsTable
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
isLoadingError={isLoadingError}
|
||||
showSenderColumn={team !== undefined}
|
||||
team={currentTeam}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { UpcomingProfileClaimTeaser } from '~/components/general/upcoming-profile-claim-teaser';
|
||||
import { DocumentsPageView } from '~/documents+/_documents-page-view';
|
||||
|
||||
export function meta() {
|
||||
return [{ title: 'Documents' }];
|
||||
}
|
||||
|
||||
export default function DocumentsPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
return (
|
||||
<>
|
||||
<UpcomingProfileClaimTeaser />
|
||||
<DocumentsPageView searchParams={searchParams} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { ChevronLeft, Loader } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
|
||||
<Link to="/documents" className="flex grow-0 items-center text-[#7AC455] hover:opacity-80">
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
<Trans>Documents</Trans>
|
||||
</Link>
|
||||
|
||||
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
|
||||
<Trans>Loading Document...</Trans>
|
||||
</h1>
|
||||
|
||||
<div className="flex h-10 items-center">
|
||||
<Skeleton className="my-6 h-4 w-24 rounded-2xl" />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid h-[80vh] max-h-[60rem] w-full grid-cols-12 gap-x-8">
|
||||
<div className="dark:bg-background border-border col-span-12 rounded-xl border-2 bg-white/50 p-2 before:rounded-xl lg:col-span-6 xl:col-span-7">
|
||||
<div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center">
|
||||
<Loader className="text-documenso h-12 w-12 animate-spin" />
|
||||
|
||||
<p className="text-muted-foreground mt-4">
|
||||
<Trans>Loading document...</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-background border-border col-span-12 rounded-xl border-2 before:rounded-xl lg:col-span-6 xl:col-span-5" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -2,14 +2,11 @@ import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||
import { AccountDeleteDialog } from '~/components/dialogs/account-delete-dialog';
|
||||
import { AvatarImageForm } from '~/components/forms/avatar-image';
|
||||
import { ProfileForm } from '~/components/forms/profile';
|
||||
|
||||
import type { Route } from './+types/profile';
|
||||
|
||||
// import { DeleteAccountDialog } from './settings/profile/delete-account-dialog';
|
||||
|
||||
export function meta(_args: Route.MetaArgs) {
|
||||
export function meta() {
|
||||
return [{ title: 'Profile' }];
|
||||
}
|
||||
|
||||
@ -28,7 +25,7 @@ export default function SettingsProfile() {
|
||||
|
||||
<hr className="my-4 max-w-xl" />
|
||||
|
||||
{/* <DeleteAccountDialog className="max-w-xl" /> */}
|
||||
<AccountDeleteDialog className="max-w-xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -6,8 +6,8 @@ import { trpc } from '@documenso/trpc/react';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
|
||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||
import { CreateTeamDialog } from '~/components/(teams)/dialogs/create-team-dialog';
|
||||
import { UserSettingsTeamsPageDataTable } from '~/components/(teams)/tables/user-settings-teams-page-data-table';
|
||||
import { TeamCreateDialog } from '~/components/dialogs/team-create-dialog';
|
||||
|
||||
import { TeamEmailUsage } from './team-email-usage';
|
||||
import { TeamInvitations } from './team-invitations';
|
||||
@ -23,7 +23,7 @@ export default function TeamsSettingsPage() {
|
||||
title={_(msg`Teams`)}
|
||||
subtitle={_(msg`Manage all teams you are currently associated with.`)}
|
||||
>
|
||||
<CreateTeamDialog />
|
||||
<TeamCreateDialog />
|
||||
</SettingsHeader>
|
||||
|
||||
<UserSettingsTeamsPageDataTable />
|
||||
|
||||
@ -0,0 +1,65 @@
|
||||
import { Outlet, replace } from 'react-router';
|
||||
|
||||
import { getRequiredSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
|
||||
import { TrpcProvider } from '@documenso/trpc/react';
|
||||
|
||||
import { TeamProvider } from '~/providers/team';
|
||||
|
||||
import type { Route } from './+types/_layout';
|
||||
|
||||
export const loader = async ({ request, params }: Route.LoaderArgs) => {
|
||||
// Todo: get user better from context or something
|
||||
// Todo: get user better from context or something
|
||||
const { user } = await getRequiredSession(request);
|
||||
|
||||
const [getTeamsPromise, getTeamPromise] = await Promise.allSettled([
|
||||
getTeams({ userId: user.id }),
|
||||
getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl }),
|
||||
]);
|
||||
|
||||
console.log('1');
|
||||
console.log({ userId: user.id, teamUrl: params.teamUrl });
|
||||
console.log(getTeamPromise.status);
|
||||
if (getTeamPromise.status === 'rejected') {
|
||||
console.log('2');
|
||||
return replace('/documents');
|
||||
}
|
||||
|
||||
const team = getTeamPromise.value;
|
||||
const teams = getTeamsPromise.status === 'fulfilled' ? getTeamsPromise.value : [];
|
||||
|
||||
const trpcHeaders = {
|
||||
'x-team-Id': team.id.toString(),
|
||||
};
|
||||
|
||||
return {
|
||||
team,
|
||||
teams,
|
||||
trpcHeaders,
|
||||
};
|
||||
};
|
||||
|
||||
export default function Layout({ loaderData }: Route.ComponentProps) {
|
||||
const { team, trpcHeaders } = loaderData;
|
||||
|
||||
return (
|
||||
<TeamProvider team={team}>
|
||||
<TrpcProvider headers={trpcHeaders}>
|
||||
{/* Todo: Do this. */}
|
||||
{/* {team.subscription && team.subscription.status !== SubscriptionStatus.ACTIVE && (
|
||||
<LayoutBillingBanner
|
||||
subscription={team.subscription}
|
||||
teamId={team.id}
|
||||
userRole={team.currentTeamMember.role}
|
||||
/>
|
||||
)} */}
|
||||
|
||||
<main className="mt-8 pb-8 md:mt-12 md:pb-12">
|
||||
<Outlet />
|
||||
</main>
|
||||
</TrpcProvider>
|
||||
</TeamProvider>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import DocumentPage, { loader } from '~/routes/_authenticated+/documents+/$id._index';
|
||||
|
||||
export { loader };
|
||||
|
||||
export default DocumentPage;
|
||||
@ -0,0 +1,5 @@
|
||||
import DocumentEditPage, { loader } from '~/routes/_authenticated+/documents+/$id.edit';
|
||||
|
||||
export { loader };
|
||||
|
||||
export default DocumentEditPage;
|
||||
@ -0,0 +1,5 @@
|
||||
import DocumentLogsPage, { loader } from '~/routes/_authenticated+/documents+/$id.logs';
|
||||
|
||||
export { loader };
|
||||
|
||||
export default DocumentLogsPage;
|
||||
@ -0,0 +1,5 @@
|
||||
import DocumentsPage, { meta } from '~/routes/_authenticated+/documents+/_index';
|
||||
|
||||
export { meta };
|
||||
|
||||
export default DocumentsPage;
|
||||
@ -0,0 +1,5 @@
|
||||
import DocumentsPage, { meta } from '~/routes/_authenticated+/documents+/_index';
|
||||
|
||||
export { meta };
|
||||
|
||||
export default DocumentsPage;
|
||||
@ -1,23 +1,24 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { Link, redirect } from 'react-router';
|
||||
|
||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import {
|
||||
IS_GOOGLE_SSO_ENABLED,
|
||||
IS_OIDC_SSO_ENABLED,
|
||||
OIDC_PROVIDER_LABEL,
|
||||
} from '@documenso/lib/constants/auth';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
|
||||
import { SignInForm } from '~/components/forms/signin';
|
||||
|
||||
import type { Route } from './+types/signin';
|
||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
|
||||
export function meta(_args: Route.MetaArgs) {
|
||||
return [{ title: 'Sign In' }];
|
||||
}
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const session = await getSession(request)
|
||||
const session = await getSession(request);
|
||||
|
||||
if (session.isAuthenticated) {
|
||||
return redirect('/documents');
|
||||
@ -25,9 +26,7 @@ export async function loader({ request }: Route.LoaderArgs) {
|
||||
}
|
||||
|
||||
export default function SignIn() {
|
||||
// Todo
|
||||
// const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
|
||||
const NEXT_PUBLIC_DISABLE_SIGNUP = 'false';
|
||||
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
|
||||
|
||||
return (
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
|
||||
@ -1,19 +1,16 @@
|
||||
import { redirect } from 'react-router';
|
||||
|
||||
import { IS_GOOGLE_SSO_ENABLED, IS_OIDC_SSO_ENABLED } from '@documenso/lib/constants/auth';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
|
||||
import { SignUpForm } from '~/components/forms/signup';
|
||||
|
||||
import type { Route } from './+types/_unauth.signup';
|
||||
|
||||
export function meta(_args: Route.MetaArgs) {
|
||||
export function meta() {
|
||||
return [{ title: 'Sign Up' }];
|
||||
}
|
||||
|
||||
export function loader() {
|
||||
// Todo
|
||||
// const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
|
||||
const NEXT_PUBLIC_DISABLE_SIGNUP: string = 'false';
|
||||
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
|
||||
|
||||
if (NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
|
||||
return redirect('/signin');
|
||||
|
||||
Reference in New Issue
Block a user