mirror of
https://github.com/documenso/documenso.git
synced 2025-11-17 18:21:32 +10:00
Merge branch 'main' into feat/disable-access-unverified-users
This commit is contained in:
@ -7,9 +7,9 @@ import Link from 'next/link';
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { FindResultSet } from '@documenso/lib/types/find-result-set';
|
||||
import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { Document, User } from '@documenso/prisma/client';
|
||||
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import type { Document, User } from '@documenso/prisma/client';
|
||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||
@ -65,7 +65,7 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
||||
accessorKey: 'owner',
|
||||
cell: ({ row }) => {
|
||||
const avatarFallbackText = row.original.User.name
|
||||
? recipientInitials(row.original.User.name)
|
||||
? extractInitials(row.original.User.name)
|
||||
: row.original.User.email.slice(0, 1).toUpperCase();
|
||||
|
||||
return (
|
||||
|
||||
@ -0,0 +1,82 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||
|
||||
import { Role } from '@documenso/prisma/client';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
} from '@documenso/ui/primitives/command';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
|
||||
|
||||
type ComboboxProps = {
|
||||
listValues: string[];
|
||||
onChange: (_values: string[]) => void;
|
||||
};
|
||||
|
||||
const MultiSelectRoleCombobox = ({ listValues, onChange }: ComboboxProps) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [selectedValues, setSelectedValues] = React.useState<string[]>([]);
|
||||
const dbRoles = Object.values(Role);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedValues(listValues);
|
||||
}, [listValues]);
|
||||
|
||||
const allRoles = [...new Set([...dbRoles, ...selectedValues])];
|
||||
|
||||
const handleSelect = (currentValue: string) => {
|
||||
let newSelectedValues;
|
||||
if (selectedValues.includes(currentValue)) {
|
||||
newSelectedValues = selectedValues.filter((value) => value !== currentValue);
|
||||
} else {
|
||||
newSelectedValues = [...selectedValues, currentValue];
|
||||
}
|
||||
|
||||
setSelectedValues(newSelectedValues);
|
||||
onChange(newSelectedValues);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-[200px] justify-between"
|
||||
>
|
||||
{selectedValues.length > 0 ? selectedValues.join(', ') : 'Select values...'}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder={selectedValues.join(', ')} />
|
||||
<CommandEmpty>No value found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{allRoles.map((value: string, i: number) => (
|
||||
<CommandItem key={i} onSelect={() => handleSelect(value)}>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
selectedValues.includes(value) ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
{value}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export { MultiSelectRoleCombobox };
|
||||
@ -18,9 +18,10 @@ import {
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { MultiSelectCombobox } from '@documenso/ui/primitives/multiselect-combobox';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { MultiSelectRoleCombobox } from './multiselect-role-combobox';
|
||||
|
||||
const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true });
|
||||
|
||||
type TUserFormSchema = z.infer<typeof ZUserFormSchema>;
|
||||
@ -117,7 +118,7 @@ export default function UserPage({ params }: { params: { id: number } }) {
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<FormLabel className="text-muted-foreground">Roles</FormLabel>
|
||||
<FormControl>
|
||||
<MultiSelectCombobox
|
||||
<MultiSelectRoleCombobox
|
||||
listValues={roles}
|
||||
onChange={(values: string[]) => onChange(values)}
|
||||
/>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { getPricesByType } from '@documenso/ee/server-only/stripe/get-prices-by-type';
|
||||
import { getPricesByPlan } from '@documenso/ee/server-only/stripe/get-prices-by-plan';
|
||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||
|
||||
import { UsersDataTable } from './data-table-users';
|
||||
import { search } from './fetch-users.actions';
|
||||
@ -18,7 +19,7 @@ export default async function AdminManageUsers({ searchParams = {} }: AdminManag
|
||||
|
||||
const [{ users, totalPages }, individualPrices] = await Promise.all([
|
||||
search(searchString, page, perPage),
|
||||
getPricesByType('individual'),
|
||||
getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY),
|
||||
]);
|
||||
|
||||
const individualPriceIds = individualPrices.map((price) => price.id);
|
||||
|
||||
@ -0,0 +1,131 @@
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { ChevronLeft, Users2 } from 'lucide-react';
|
||||
|
||||
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
|
||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import type { Team } from '@documenso/prisma/client';
|
||||
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
|
||||
import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document';
|
||||
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||
|
||||
export type DocumentPageViewProps = {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
team?: Team;
|
||||
};
|
||||
|
||||
export default async function DocumentPageView({ params, team }: DocumentPageViewProps) {
|
||||
const { id } = params;
|
||||
|
||||
const documentId = Number(id);
|
||||
|
||||
const documentRootPath = formatDocumentsPath(team?.url);
|
||||
|
||||
if (!documentId || Number.isNaN(documentId)) {
|
||||
redirect(documentRootPath);
|
||||
}
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
const document = await getDocumentById({
|
||||
id: documentId,
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!document || !document.documentData) {
|
||||
redirect(documentRootPath);
|
||||
}
|
||||
|
||||
const { documentData, 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;
|
||||
}
|
||||
|
||||
const [recipients, fields] = await Promise.all([
|
||||
getRecipientsForDocument({
|
||||
documentId,
|
||||
userId: user.id,
|
||||
}),
|
||||
getFieldsForDocument({
|
||||
documentId,
|
||||
userId: user.id,
|
||||
}),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||
<Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
Documents
|
||||
</Link>
|
||||
|
||||
<h1 className="mt-4 truncate text-2xl font-semibold 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} position="bottom">
|
||||
<span>{recipients.length} Recipient(s)</span>
|
||||
</StackAvatarsWithTooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{document.status !== InternalDocumentStatus.COMPLETED && (
|
||||
<EditDocumentForm
|
||||
className="mt-8"
|
||||
document={document}
|
||||
user={user}
|
||||
documentMeta={documentMeta}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
documentData={documentData}
|
||||
documentRootPath={documentRootPath}
|
||||
/>
|
||||
)}
|
||||
|
||||
{document.status === InternalDocumentStatus.COMPLETED && (
|
||||
<div className="mx-auto mt-12 max-w-2xl">
|
||||
<LazyPDFViewer
|
||||
document={document}
|
||||
key={documentData.id}
|
||||
documentMeta={documentMeta}
|
||||
documentData={documentData}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -32,6 +32,7 @@ export type EditDocumentFormProps = {
|
||||
documentMeta: DocumentMeta | null;
|
||||
fields: Field[];
|
||||
documentData: DocumentData;
|
||||
documentRootPath: string;
|
||||
};
|
||||
|
||||
type EditDocumentStep = 'title' | 'signers' | 'fields' | 'subject';
|
||||
@ -45,6 +46,7 @@ export const EditDocumentForm = ({
|
||||
documentMeta,
|
||||
user: _user,
|
||||
documentData,
|
||||
documentRootPath,
|
||||
}: EditDocumentFormProps) => {
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
@ -168,7 +170,7 @@ export const EditDocumentForm = ({
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
router.push('/documents');
|
||||
router.push(documentRootPath);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
@ -218,9 +220,9 @@ export const EditDocumentForm = ({
|
||||
<AddTitleFormPartial
|
||||
key={recipients.length}
|
||||
documentFlow={documentFlow.title}
|
||||
document={document}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
document={document}
|
||||
onSubmit={onAddTitleFormSubmit}
|
||||
/>
|
||||
|
||||
|
||||
@ -1,20 +1,4 @@
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { ChevronLeft, Users2 } from 'lucide-react';
|
||||
|
||||
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
|
||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
|
||||
import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document';
|
||||
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||
import DocumentPageView from './document-page-view';
|
||||
|
||||
export type DocumentPageProps = {
|
||||
params: {
|
||||
@ -22,103 +6,6 @@ export type DocumentPageProps = {
|
||||
};
|
||||
};
|
||||
|
||||
export default async function DocumentPage({ params }: DocumentPageProps) {
|
||||
const { id } = params;
|
||||
|
||||
const documentId = Number(id);
|
||||
|
||||
if (!documentId || Number.isNaN(documentId)) {
|
||||
redirect('/documents');
|
||||
}
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
const document = await getDocumentById({
|
||||
id: documentId,
|
||||
userId: user.id,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!document || !document.documentData) {
|
||||
redirect('/documents');
|
||||
}
|
||||
|
||||
const { documentData, 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;
|
||||
}
|
||||
|
||||
const [recipients, fields] = await Promise.all([
|
||||
getRecipientsForDocument({
|
||||
documentId,
|
||||
userId: user.id,
|
||||
}),
|
||||
getFieldsForDocument({
|
||||
documentId,
|
||||
userId: user.id,
|
||||
}),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||
<Link href="/documents" className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
Documents
|
||||
</Link>
|
||||
|
||||
<h1 className="mt-4 truncate text-2xl font-semibold 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} position="bottom">
|
||||
<span>{recipients.length} Recipient(s)</span>
|
||||
</StackAvatarsWithTooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{document.status !== InternalDocumentStatus.COMPLETED && (
|
||||
<EditDocumentForm
|
||||
className="mt-8"
|
||||
document={document}
|
||||
user={user}
|
||||
documentMeta={documentMeta}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
documentData={documentData}
|
||||
/>
|
||||
)}
|
||||
|
||||
{document.status === InternalDocumentStatus.COMPLETED && (
|
||||
<div className="mx-auto mt-12 max-w-2xl">
|
||||
<LazyPDFViewer
|
||||
document={document}
|
||||
key={documentData.id}
|
||||
documentMeta={documentMeta}
|
||||
documentData={documentData}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
export default function DocumentPage({ params }: DocumentPageProps) {
|
||||
return <DocumentPageView params={params} />;
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import * as z from 'zod';
|
||||
|
||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import type { Team } from '@documenso/prisma/client';
|
||||
import { type Document, type Recipient, SigningStatus } from '@documenso/prisma/client';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@ -39,8 +40,11 @@ import { StackAvatar } from '~/components/(dashboard)/avatar/stack-avatar';
|
||||
const FORM_ID = 'resend-email';
|
||||
|
||||
export type ResendDocumentActionItemProps = {
|
||||
document: Document;
|
||||
document: Document & {
|
||||
team: Pick<Team, 'id' | 'url'> | null;
|
||||
};
|
||||
recipients: Recipient[];
|
||||
team?: Pick<Team, 'id' | 'url'>;
|
||||
};
|
||||
|
||||
export const ZResendDocumentFormSchema = z.object({
|
||||
@ -54,15 +58,17 @@ export type TResendDocumentFormSchema = z.infer<typeof ZResendDocumentFormSchema
|
||||
export const ResendDocumentActionItem = ({
|
||||
document,
|
||||
recipients,
|
||||
team,
|
||||
}: ResendDocumentActionItemProps) => {
|
||||
const { data: session } = useSession();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const isOwner = document.userId === session?.user?.id;
|
||||
const isCurrentTeamDocument = team && document.team?.url === team.url;
|
||||
|
||||
const isDisabled =
|
||||
!isOwner ||
|
||||
(!isOwner && !isCurrentTeamDocument) ||
|
||||
document.status !== 'PENDING' ||
|
||||
!recipients.some((r) => r.signingStatus === SigningStatus.NOT_SIGNED);
|
||||
|
||||
@ -82,7 +88,7 @@ export const ResendDocumentActionItem = ({
|
||||
|
||||
const onFormSubmit = async ({ recipients }: TResendDocumentFormSchema) => {
|
||||
try {
|
||||
await resendDocument({ documentId: document.id, recipients });
|
||||
await resendDocument({ documentId: document.id, recipients, teamId: team?.id });
|
||||
|
||||
toast({
|
||||
title: 'Document re-sent',
|
||||
|
||||
@ -2,13 +2,14 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Download, Edit, Pencil } from 'lucide-react';
|
||||
import { CheckCircle, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
||||
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
|
||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@ -18,10 +19,12 @@ export type DataTableActionButtonProps = {
|
||||
row: Document & {
|
||||
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||
Recipient: Recipient[];
|
||||
team: Pick<Team, 'id' | 'url'> | null;
|
||||
};
|
||||
team?: Pick<Team, 'id' | 'url'>;
|
||||
};
|
||||
|
||||
export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
||||
export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps) => {
|
||||
const { data: session } = useSession();
|
||||
const { toast } = useToast();
|
||||
|
||||
@ -37,6 +40,10 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
||||
const isPending = row.status === DocumentStatus.PENDING;
|
||||
const isComplete = row.status === DocumentStatus.COMPLETED;
|
||||
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||
const role = recipient?.role;
|
||||
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
||||
|
||||
const documentsPath = formatDocumentsPath(team?.url);
|
||||
|
||||
const onDownloadClick = async () => {
|
||||
try {
|
||||
@ -45,6 +52,7 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
||||
if (!recipient) {
|
||||
document = await trpcClient.document.getDocumentById.query({
|
||||
id: row.id,
|
||||
teamId: team?.id,
|
||||
});
|
||||
} else {
|
||||
document = await trpcClient.document.getDocumentByToken.query({
|
||||
@ -68,6 +76,11 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: Consider if want to keep this logic for hiding viewing for CC'ers
|
||||
if (recipient?.role === RecipientRole.CC && isComplete === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return match({
|
||||
isOwner,
|
||||
isRecipient,
|
||||
@ -75,27 +88,48 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
||||
isPending,
|
||||
isComplete,
|
||||
isSigned,
|
||||
isCurrentTeamDocument,
|
||||
})
|
||||
.with({ isOwner: true, isDraft: true }, () => (
|
||||
<Button className="w-32" asChild>
|
||||
<Link href={`/documents/${row.id}`}>
|
||||
<Edit className="-ml-1 mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
))
|
||||
.with(
|
||||
isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true },
|
||||
() => (
|
||||
<Button className="w-32" asChild>
|
||||
<Link href={`${documentsPath}/${row.id}`}>
|
||||
<Edit className="-ml-1 mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
),
|
||||
)
|
||||
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
|
||||
<Button className="w-32" asChild>
|
||||
<Link href={`/sign/${recipient?.token}`}>
|
||||
<Pencil className="-ml-1 mr-2 h-4 w-4" />
|
||||
Sign
|
||||
{match(role)
|
||||
.with(RecipientRole.SIGNER, () => (
|
||||
<>
|
||||
<Pencil className="-ml-1 mr-2 h-4 w-4" />
|
||||
Sign
|
||||
</>
|
||||
))
|
||||
.with(RecipientRole.APPROVER, () => (
|
||||
<>
|
||||
<CheckCircle className="-ml-1 mr-2 h-4 w-4" />
|
||||
Approve
|
||||
</>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<>
|
||||
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
|
||||
View
|
||||
</>
|
||||
))}
|
||||
</Link>
|
||||
</Button>
|
||||
))
|
||||
.with({ isPending: true, isSigned: true }, () => (
|
||||
<Button className="w-32" disabled={true}>
|
||||
<Pencil className="-ml-1 mr-2 inline h-4 w-4" />
|
||||
Sign
|
||||
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
|
||||
View
|
||||
</Button>
|
||||
))
|
||||
.with({ isComplete: true }, () => (
|
||||
|
||||
@ -5,9 +5,11 @@ import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import {
|
||||
CheckCircle,
|
||||
Copy,
|
||||
Download,
|
||||
Edit,
|
||||
EyeIcon,
|
||||
Loader,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
@ -18,8 +20,9 @@ import {
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { DocumentStatus, RecipientRole } from '@documenso/prisma/client';
|
||||
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
|
||||
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||
@ -40,10 +43,12 @@ export type DataTableActionDropdownProps = {
|
||||
row: Document & {
|
||||
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||
Recipient: Recipient[];
|
||||
team: Pick<Team, 'id' | 'url'> | null;
|
||||
};
|
||||
team?: Pick<Team, 'id' | 'url'>;
|
||||
};
|
||||
|
||||
export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
|
||||
export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownProps) => {
|
||||
const { data: session } = useSession();
|
||||
const { toast } = useToast();
|
||||
|
||||
@ -63,6 +68,9 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
const isComplete = row.status === DocumentStatus.COMPLETED;
|
||||
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||
const isDocumentDeletable = isOwner;
|
||||
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
||||
|
||||
const documentsPath = formatDocumentsPath(team?.url);
|
||||
|
||||
const onDownloadClick = async () => {
|
||||
try {
|
||||
@ -71,6 +79,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
if (!recipient) {
|
||||
document = await trpcClient.document.getDocumentById.query({
|
||||
id: row.id,
|
||||
teamId: team?.id,
|
||||
});
|
||||
} else {
|
||||
document = await trpcClient.document.getDocumentByToken.query({
|
||||
@ -105,15 +114,35 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
|
||||
<Link href={`/sign/${recipient?.token}`}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Sign
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{recipient?.role !== RecipientRole.CC && (
|
||||
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
|
||||
<Link href={`/sign/${recipient?.token}`}>
|
||||
{recipient?.role === RecipientRole.VIEWER && (
|
||||
<>
|
||||
<EyeIcon className="mr-2 h-4 w-4" />
|
||||
View
|
||||
</>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem disabled={!isOwner || isComplete} asChild>
|
||||
<Link href={`/documents/${row.id}`}>
|
||||
{recipient?.role === RecipientRole.SIGNER && (
|
||||
<>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Sign
|
||||
</>
|
||||
)}
|
||||
|
||||
{recipient?.role === RecipientRole.APPROVER && (
|
||||
<>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
Approve
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem disabled={(!isOwner && !isCurrentTeamDocument) || isComplete} asChild>
|
||||
<Link href={`${documentsPath}/${row.id}`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
@ -141,7 +170,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
|
||||
<DropdownMenuLabel>Share</DropdownMenuLabel>
|
||||
|
||||
<ResendDocumentActionItem document={row} recipients={nonSignedRecipients} />
|
||||
<ResendDocumentActionItem document={row} recipients={nonSignedRecipients} team={team} />
|
||||
|
||||
<DocumentShareButton
|
||||
documentId={row.id}
|
||||
@ -171,6 +200,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
id={row.id}
|
||||
open={isDuplicateDialogOpen}
|
||||
onOpenChange={setDuplicateDialogOpen}
|
||||
team={team}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenu>
|
||||
|
||||
@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||
import { parseToIntegerArray } from '@documenso/lib/utils/params';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
|
||||
|
||||
type DataTableSenderFilterProps = {
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const DataTableSenderFilter = ({ teamId }: DataTableSenderFilterProps) => {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
const senderIds = parseToIntegerArray(searchParams?.get('senderIds') ?? '');
|
||||
|
||||
const { data, isInitialLoading } = trpc.team.getTeamMembers.useQuery({
|
||||
teamId,
|
||||
});
|
||||
|
||||
const comboBoxOptions = (data ?? []).map((member) => ({
|
||||
label: member.user.name ?? member.user.email,
|
||||
value: member.user.id,
|
||||
}));
|
||||
|
||||
const onChange = (newSenderIds: number[]) => {
|
||||
if (!pathname) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
|
||||
params.set('senderIds', newSenderIds.join(','));
|
||||
|
||||
if (newSenderIds.length === 0) {
|
||||
params.delete('senderIds');
|
||||
}
|
||||
|
||||
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<MultiSelectCombobox
|
||||
emptySelectionPlaceholder={
|
||||
<p className="text-muted-foreground font-normal">
|
||||
<span className="text-muted-foreground/70">Sender:</span> All
|
||||
</p>
|
||||
}
|
||||
enableClearAllButton={true}
|
||||
inputPlaceholder="Search"
|
||||
loading={!isMounted || isInitialLoading}
|
||||
options={comboBoxOptions}
|
||||
selectedValues={senderIds}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -7,7 +7,7 @@ import { useSession } from 'next-auth/react';
|
||||
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
|
||||
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
||||
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||
@ -25,11 +25,18 @@ export type DocumentsDataTableProps = {
|
||||
Document & {
|
||||
Recipient: Recipient[];
|
||||
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||
team: Pick<Team, 'id' | 'url'> | null;
|
||||
}
|
||||
>;
|
||||
showSenderColumn?: boolean;
|
||||
team?: Pick<Team, 'id' | 'url'>;
|
||||
};
|
||||
|
||||
export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
||||
export const DocumentsDataTable = ({
|
||||
results,
|
||||
showSenderColumn,
|
||||
team,
|
||||
}: DocumentsDataTableProps) => {
|
||||
const { data: session } = useSession();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
@ -61,6 +68,11 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
||||
header: 'Title',
|
||||
cell: ({ row }) => <DataTableTitle row={row.original} />,
|
||||
},
|
||||
{
|
||||
id: 'sender',
|
||||
header: 'Sender',
|
||||
cell: ({ row }) => row.original.User.name ?? row.original.User.email,
|
||||
},
|
||||
{
|
||||
header: 'Recipient',
|
||||
accessorKey: 'recipient',
|
||||
@ -79,8 +91,8 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
||||
(!row.original.deletedAt ||
|
||||
row.original.status === ExtendedDocumentStatus.COMPLETED) && (
|
||||
<div className="flex items-center gap-x-4">
|
||||
<DataTableActionButton row={row.original} />
|
||||
<DataTableActionDropdown row={row.original} />
|
||||
<DataTableActionButton team={team} row={row.original} />
|
||||
<DataTableActionDropdown team={team} row={row.original} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@ -90,6 +102,9 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
||||
currentPage={results.currentPage}
|
||||
totalPages={results.totalPages}
|
||||
onPaginationChange={onPaginationChange}
|
||||
columnVisibility={{
|
||||
sender: Boolean(showSenderColumn),
|
||||
}}
|
||||
>
|
||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||
</DataTable>
|
||||
|
||||
158
apps/web/src/app/(dashboard)/documents/documents-page-view.tsx
Normal file
158
apps/web/src/app/(dashboard)/documents/documents-page-view.tsx
Normal file
@ -0,0 +1,158 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
||||
import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
|
||||
import type { GetStatsInput } from '@documenso/lib/server-only/document/get-stats';
|
||||
import { getStats } from '@documenso/lib/server-only/document/get-stats';
|
||||
import { parseToIntegerArray } from '@documenso/lib/utils/params';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import type { Team, TeamEmail } from '@documenso/prisma/client';
|
||||
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
|
||||
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
|
||||
import { isPeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
|
||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||
|
||||
import { DocumentsDataTable } from './data-table';
|
||||
import { DataTableSenderFilter } from './data-table-sender-filter';
|
||||
import { EmptyDocumentState } from './empty-state';
|
||||
import { UploadDocument } from './upload-document';
|
||||
|
||||
export type DocumentsPageViewProps = {
|
||||
searchParams?: {
|
||||
status?: ExtendedDocumentStatus;
|
||||
period?: PeriodSelectorValue;
|
||||
page?: string;
|
||||
perPage?: string;
|
||||
senderIds?: string;
|
||||
};
|
||||
team?: Team & { teamEmail?: TeamEmail | null };
|
||||
};
|
||||
|
||||
export default async function DocumentsPageView({
|
||||
searchParams = {},
|
||||
team,
|
||||
}: DocumentsPageViewProps) {
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
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 currentTeam = team ? { id: team.id, url: team.url } : undefined;
|
||||
|
||||
const getStatOptions: GetStatsInput = {
|
||||
user,
|
||||
period,
|
||||
};
|
||||
|
||||
if (team) {
|
||||
getStatOptions.team = {
|
||||
teamId: team.id,
|
||||
teamEmail: team.teamEmail?.email,
|
||||
senderIds,
|
||||
};
|
||||
}
|
||||
|
||||
const stats = await getStats(getStatOptions);
|
||||
|
||||
const results = await findDocuments({
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
status,
|
||||
orderBy: {
|
||||
column: 'createdAt',
|
||||
direction: 'desc',
|
||||
},
|
||||
page,
|
||||
perPage,
|
||||
period,
|
||||
senderIds,
|
||||
});
|
||||
|
||||
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 (
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||
<UploadDocument 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">
|
||||
<AvatarFallback className="text-xs text-gray-400">
|
||||
{team.name.slice(0, 1)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
|
||||
<h1 className="text-4xl font-semibold">Documents</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 href={getTabHref(value)} scroll={false}>
|
||||
<DocumentStatus status={value} />
|
||||
|
||||
{value !== ExtendedDocumentStatus.ALL && (
|
||||
<span className="ml-1 inline-block opacity-50">
|
||||
{Math.min(stats[value], 99)}
|
||||
{stats[value] > 99 && '+'}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{team && <DataTableSenderFilter teamId={team.id} />}
|
||||
|
||||
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
||||
<PeriodSelector />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
{results.count > 0 && (
|
||||
<DocumentsDataTable
|
||||
results={results}
|
||||
showSenderColumn={team !== undefined}
|
||||
team={currentTeam}
|
||||
/>
|
||||
)}
|
||||
{results.count === 0 && <EmptyDocumentState status={status} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,5 +1,7 @@
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import type { Team } from '@documenso/prisma/client';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
@ -16,18 +18,21 @@ type DuplicateDocumentDialogProps = {
|
||||
id: number;
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
team?: Pick<Team, 'id' | 'url'>;
|
||||
};
|
||||
|
||||
export const DuplicateDocumentDialog = ({
|
||||
id,
|
||||
open,
|
||||
onOpenChange,
|
||||
team,
|
||||
}: DuplicateDocumentDialogProps) => {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery({
|
||||
id,
|
||||
teamId: team?.id,
|
||||
});
|
||||
|
||||
const documentData = document?.documentData
|
||||
@ -37,10 +42,12 @@ export const DuplicateDocumentDialog = ({
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const documentsPath = formatDocumentsPath(team?.url);
|
||||
|
||||
const { mutateAsync: duplicateDocument, isLoading: isDuplicateLoading } =
|
||||
trpcReact.document.duplicateDocument.useMutation({
|
||||
onSuccess: (newId) => {
|
||||
router.push(`/documents/${newId}`);
|
||||
router.push(`${documentsPath}/${newId}`);
|
||||
|
||||
toast({
|
||||
title: 'Document Duplicated',
|
||||
@ -54,7 +61,7 @@ export const DuplicateDocumentDialog = ({
|
||||
|
||||
const onDuplicate = async () => {
|
||||
try {
|
||||
await duplicateDocument({ id });
|
||||
await duplicateDocument({ id, teamId: team?.id });
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
|
||||
@ -1,114 +1,16 @@
|
||||
import Link from 'next/link';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
||||
import { getStats } from '@documenso/lib/server-only/document/get-stats';
|
||||
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
|
||||
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
|
||||
import type { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
|
||||
import { isPeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
|
||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||
|
||||
import { DocumentsDataTable } from './data-table';
|
||||
import { EmptyDocumentState } from './empty-state';
|
||||
import { UploadDocument } from './upload-document';
|
||||
import type { DocumentsPageViewProps } from './documents-page-view';
|
||||
import DocumentsPageView from './documents-page-view';
|
||||
|
||||
export type DocumentsPageProps = {
|
||||
searchParams?: {
|
||||
status?: ExtendedDocumentStatus;
|
||||
period?: PeriodSelectorValue;
|
||||
page?: string;
|
||||
perPage?: string;
|
||||
};
|
||||
searchParams?: DocumentsPageViewProps['searchParams'];
|
||||
};
|
||||
|
||||
export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
export const metadata: Metadata = {
|
||||
title: 'Documents',
|
||||
};
|
||||
|
||||
const stats = await getStats({
|
||||
user,
|
||||
});
|
||||
|
||||
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 results = await findDocuments({
|
||||
userId: user.id,
|
||||
status,
|
||||
orderBy: {
|
||||
column: 'createdAt',
|
||||
direction: 'desc',
|
||||
},
|
||||
page,
|
||||
perPage,
|
||||
period,
|
||||
});
|
||||
|
||||
const getTabHref = (value: typeof status) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
|
||||
params.set('status', value);
|
||||
|
||||
if (params.has('page')) {
|
||||
params.delete('page');
|
||||
}
|
||||
|
||||
return `/documents?${params.toString()}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||
<UploadDocument />
|
||||
|
||||
<div className="mt-12 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
|
||||
<h1 className="text-4xl font-semibold">Documents</h1>
|
||||
|
||||
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
|
||||
<Tabs defaultValue={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 href={getTabHref(value)} scroll={false}>
|
||||
<DocumentStatus status={value} />
|
||||
|
||||
{value !== ExtendedDocumentStatus.ALL && (
|
||||
<span className="ml-1 inline-block opacity-50">
|
||||
{Math.min(stats[value], 99)}
|
||||
{stats[value] > 99 && '+'}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
||||
<PeriodSelector />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
{results.count > 0 && <DocumentsDataTable results={results} />}
|
||||
{results.count === 0 && <EmptyDocumentState status={status} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
export default function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
|
||||
return <DocumentsPageView searchParams={searchParams} />;
|
||||
}
|
||||
|
||||
@ -10,8 +10,10 @@ import { useSession } from 'next-auth/react';
|
||||
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { TRPCClientError } from '@documenso/trpc/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@ -20,9 +22,13 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type UploadDocumentProps = {
|
||||
className?: string;
|
||||
team?: {
|
||||
id: number;
|
||||
url: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||
export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
|
||||
const router = useRouter();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
@ -38,13 +44,15 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||
|
||||
const disabledMessage = useMemo(() => {
|
||||
if (remaining.documents === 0) {
|
||||
return 'You have reached your document limit.';
|
||||
return team
|
||||
? 'Document upload disabled due to unpaid invoices'
|
||||
: 'You have reached your document limit.';
|
||||
}
|
||||
|
||||
if (!session?.user.emailVerified) {
|
||||
return 'Verify your email to upload documents.';
|
||||
}
|
||||
}, [remaining.documents, session?.user.emailVerified]);
|
||||
}, [remaining.documents, session?.user.emailVerified, team]);
|
||||
|
||||
const onFileDrop = async (file: File) => {
|
||||
try {
|
||||
@ -60,6 +68,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||
const { id } = await createDocument({
|
||||
title: file.name,
|
||||
documentDataId,
|
||||
teamId: team?.id,
|
||||
});
|
||||
|
||||
toast({
|
||||
@ -74,7 +83,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
router.push(`/documents/${id}`);
|
||||
router.push(`${formatDocumentsPath(team?.url)}/${id}`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
@ -96,6 +105,15 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const onFileDropRejected = () => {
|
||||
toast({
|
||||
title: 'Your document failed to upload.',
|
||||
description: `File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`,
|
||||
duration: 5000,
|
||||
variant: 'destructive',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<DocumentDropzone
|
||||
@ -103,14 +121,17 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||
disabled={remaining.documents === 0 || !session?.user.emailVerified}
|
||||
disabledMessage={disabledMessage}
|
||||
onDrop={onFileDrop}
|
||||
onDropRejected={onFileDropRejected}
|
||||
/>
|
||||
|
||||
<div className="absolute -bottom-6 right-0">
|
||||
{remaining.documents > 0 && Number.isFinite(remaining.documents) && (
|
||||
<p className="text-muted-foreground/60 text-xs">
|
||||
{remaining.documents} of {quota.documents} documents remaining this month.
|
||||
</p>
|
||||
)}
|
||||
{team?.id === undefined &&
|
||||
remaining.documents > 0 &&
|
||||
Number.isFinite(remaining.documents) && (
|
||||
<p className="text-muted-foreground/60 text-xs">
|
||||
{remaining.documents} of {quota.documents} documents remaining this month.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
@ -119,7 +140,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{remaining.documents === 0 && (
|
||||
{team?.id === undefined && remaining.documents === 0 && (
|
||||
<div className="bg-background/60 absolute inset-0 flex items-center justify-center rounded-lg backdrop-blur-sm">
|
||||
<div className="text-center">
|
||||
<h2 className="text-muted-foreground/80 text-xl font-semibold">
|
||||
|
||||
@ -7,6 +7,7 @@ import { getServerSession } from 'next-auth';
|
||||
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server';
|
||||
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
|
||||
|
||||
import { Header } from '~/components/(dashboard)/layout/header';
|
||||
import { VerifyEmailBanner } from '~/components/(dashboard)/layout/verify-email-banner';
|
||||
@ -26,13 +27,17 @@ export default async function AuthenticatedDashboardLayout({
|
||||
redirect('/signin');
|
||||
}
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
const [{ user }, teams] = await Promise.all([
|
||||
getRequiredServerComponentSession(),
|
||||
getTeams({ userId: session.user.id }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<NextAuthProvider session={session}>
|
||||
<LimitsProvider>
|
||||
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
|
||||
<Header user={user} />
|
||||
|
||||
<Header user={user} teams={teams} />
|
||||
|
||||
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
||||
|
||||
|
||||
@ -7,7 +7,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { createBillingPortal } from './create-billing-portal.action';
|
||||
|
||||
export const BillingPortalButton = () => {
|
||||
export type BillingPortalButtonProps = {
|
||||
buttonProps?: React.ComponentProps<typeof Button>;
|
||||
};
|
||||
|
||||
export const BillingPortalButton = ({ buttonProps }: BillingPortalButtonProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isFetchingPortalUrl, setIsFetchingPortalUrl] = useState(false);
|
||||
@ -48,7 +52,11 @@ export const BillingPortalButton = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Button onClick={async () => handleFetchPortalUrl()} loading={isFetchingPortalUrl}>
|
||||
<Button
|
||||
{...buttonProps}
|
||||
onClick={async () => handleFetchPortalUrl()}
|
||||
loading={isFetchingPortalUrl}
|
||||
>
|
||||
Manage Subscription
|
||||
</Button>
|
||||
);
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
|
||||
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
||||
import { getPricesByType } from '@documenso/ee/server-only/stripe/get-prices-by-type';
|
||||
import { getPricesByPlan } from '@documenso/ee/server-only/stripe/get-prices-by-plan';
|
||||
import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
|
||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
||||
import { type Stripe } from '@documenso/lib/server-only/stripe';
|
||||
@ -17,6 +19,10 @@ import { LocaleDate } from '~/components/formatter/locale-date';
|
||||
import { BillingPlans } from './billing-plans';
|
||||
import { BillingPortalButton } from './billing-portal-button';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Billing',
|
||||
};
|
||||
|
||||
export default async function BillingSettingsPage() {
|
||||
let { user } = await getRequiredServerComponentSession();
|
||||
|
||||
@ -31,23 +37,23 @@ export default async function BillingSettingsPage() {
|
||||
user = await getStripeCustomerByUser(user).then((result) => result.user);
|
||||
}
|
||||
|
||||
const [subscriptions, prices, individualPrices] = await Promise.all([
|
||||
const [subscriptions, prices, communityPlanPrices] = await Promise.all([
|
||||
getSubscriptionsByUserId({ userId: user.id }),
|
||||
getPricesByInterval({ type: 'individual' }),
|
||||
getPricesByType('individual'),
|
||||
getPricesByInterval({ plan: STRIPE_PLAN_TYPE.COMMUNITY }),
|
||||
getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY),
|
||||
]);
|
||||
|
||||
const individualPriceIds = individualPrices.map(({ id }) => id);
|
||||
const communityPlanPriceIds = communityPlanPrices.map(({ id }) => id);
|
||||
|
||||
let subscriptionProduct: Stripe.Product | null = null;
|
||||
|
||||
const individualUserSubscriptions = subscriptions.filter(({ priceId }) =>
|
||||
individualPriceIds.includes(priceId),
|
||||
const communityPlanUserSubscriptions = subscriptions.filter(({ priceId }) =>
|
||||
communityPlanPriceIds.includes(priceId),
|
||||
);
|
||||
|
||||
const subscription =
|
||||
individualUserSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ??
|
||||
individualUserSubscriptions[0];
|
||||
communityPlanUserSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ??
|
||||
communityPlanUserSubscriptions[0];
|
||||
|
||||
if (subscription?.priceId) {
|
||||
subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch(
|
||||
|
||||
@ -1,17 +1,20 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
|
||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||
import { ProfileForm } from '~/components/forms/profile';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Profile',
|
||||
};
|
||||
|
||||
export default async function ProfileSettingsPage() {
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-2xl font-semibold">Profile</h3>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">Here you can edit your personal details.</p>
|
||||
|
||||
<hr className="my-4" />
|
||||
<SettingsHeader title="Profile" subtitle="Here you can edit your personal details." />
|
||||
|
||||
<ProfileForm user={user} className="max-w-xl" />
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,23 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { UserSecurityActivityDataTable } from './user-security-activity-data-table';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Security activity',
|
||||
};
|
||||
|
||||
export default function SettingsSecurityActivityPage() {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-2xl font-semibold">Security activity</h3>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
View all recent security activity related to your account.
|
||||
</p>
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
<UserSecurityActivityDataTable />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,156 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
import type { DateTimeFormatOptions } from 'luxon';
|
||||
import { DateTime } from 'luxon';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { USER_SECURITY_AUDIT_LOG_MAP } from '@documenso/lib/constants/auth';
|
||||
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||
import { TableCell } from '@documenso/ui/primitives/table';
|
||||
|
||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||
|
||||
const dateFormat: DateTimeFormatOptions = {
|
||||
...DateTime.DATETIME_SHORT,
|
||||
hourCycle: 'h12',
|
||||
};
|
||||
|
||||
export const UserSecurityActivityDataTable = () => {
|
||||
const parser = new UAParser();
|
||||
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
|
||||
const parsedSearchParams = ZBaseTableSearchParamsSchema.parse(
|
||||
Object.fromEntries(searchParams ?? []),
|
||||
);
|
||||
|
||||
const { data, isLoading, isInitialLoading, isLoadingError } =
|
||||
trpc.profile.findUserSecurityAuditLogs.useQuery(
|
||||
{
|
||||
page: parsedSearchParams.page,
|
||||
perPage: parsedSearchParams.perPage,
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
},
|
||||
);
|
||||
|
||||
const onPaginationChange = (page: number, perPage: number) => {
|
||||
updateSearchParams({
|
||||
page,
|
||||
perPage,
|
||||
});
|
||||
};
|
||||
|
||||
const results = data ?? {
|
||||
data: [],
|
||||
perPage: 10,
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={[
|
||||
{
|
||||
header: 'Date',
|
||||
accessorKey: 'createdAt',
|
||||
cell: ({ row }) => <LocaleDate format={dateFormat} date={row.original.createdAt} />,
|
||||
},
|
||||
{
|
||||
header: 'Device',
|
||||
cell: ({ row }) => {
|
||||
if (!row.original.userAgent) {
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
parser.setUA(row.original.userAgent);
|
||||
|
||||
const result = parser.getResult();
|
||||
|
||||
let output = result.os.name;
|
||||
|
||||
if (!output) {
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
if (result.os.version) {
|
||||
output += ` (${result.os.version})`;
|
||||
}
|
||||
|
||||
return output;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Browser',
|
||||
cell: ({ row }) => {
|
||||
if (!row.original.userAgent) {
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
parser.setUA(row.original.userAgent);
|
||||
|
||||
const result = parser.getResult();
|
||||
|
||||
return result.browser.name ?? 'N/A';
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'IP Address',
|
||||
accessorKey: 'ipAddress',
|
||||
cell: ({ row }) => row.original.ipAddress ?? 'N/A',
|
||||
},
|
||||
{
|
||||
header: 'Action',
|
||||
accessorKey: 'type',
|
||||
cell: ({ row }) => USER_SECURITY_AUDIT_LOG_MAP[row.original.type],
|
||||
},
|
||||
]}
|
||||
data={results.data}
|
||||
perPage={results.perPage}
|
||||
currentPage={results.currentPage}
|
||||
totalPages={results.totalPages}
|
||||
onPaginationChange={onPaginationChange}
|
||||
hasFilters={parsedSearchParams.page !== undefined || parsedSearchParams.perPage !== undefined}
|
||||
onClearFilters={() => router.push(pathname ?? '/')}
|
||||
error={{
|
||||
enable: isLoadingError,
|
||||
}}
|
||||
skeleton={{
|
||||
enable: isLoading && isInitialLoading,
|
||||
rows: 3,
|
||||
component: (
|
||||
<>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-20 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-12 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-12 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-12 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-12 rounded-full" />
|
||||
</TableCell>
|
||||
</>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{(table) => <DataTablePagination table={table} />}
|
||||
</DataTable>
|
||||
);
|
||||
};
|
||||
@ -1,62 +1,100 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { IDENTITY_PROVIDER_NAME } from '@documenso/lib/constants/auth';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||
import { AuthenticatorApp } from '~/components/forms/2fa/authenticator-app';
|
||||
import { RecoveryCodes } from '~/components/forms/2fa/recovery-codes';
|
||||
import { PasswordForm } from '~/components/forms/password';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Security',
|
||||
};
|
||||
|
||||
export default async function SecuritySettingsPage() {
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-2xl font-semibold">Security</h3>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
Here you can manage your password and security settings.
|
||||
</p>
|
||||
|
||||
<hr className="my-4" />
|
||||
<SettingsHeader
|
||||
title="Security"
|
||||
subtitle="Here you can manage your password and security settings."
|
||||
/>
|
||||
|
||||
{user.identityProvider === 'DOCUMENSO' ? (
|
||||
<div>
|
||||
<PasswordForm user={user} className="max-w-xl" />
|
||||
<PasswordForm user={user} />
|
||||
|
||||
<hr className="mb-4 mt-8" />
|
||||
<hr className="border-border/50 mt-6" />
|
||||
|
||||
<h4 className="text-lg font-medium">Two Factor Authentication</h4>
|
||||
<Alert
|
||||
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||
variant="neutral"
|
||||
>
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<AlertTitle>Two factor authentication</AlertTitle>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
Add and manage your two factor security settings to add an extra layer of security to
|
||||
your account!
|
||||
</p>
|
||||
|
||||
<div className="mt-4 max-w-xl">
|
||||
<h5 className="font-medium">Two-factor methods</h5>
|
||||
<AlertDescription className="mr-4">
|
||||
Create one-time passwords that serve as a secondary authentication method for
|
||||
confirming your identity when requested during the sign-in process.
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<AuthenticatorApp isTwoFactorEnabled={user.twoFactorEnabled} />
|
||||
</div>
|
||||
</Alert>
|
||||
|
||||
{user.twoFactorEnabled && (
|
||||
<div className="mt-4 max-w-xl">
|
||||
<h5 className="font-medium">Recovery methods</h5>
|
||||
<Alert
|
||||
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||
variant="neutral"
|
||||
>
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<AlertTitle>Recovery codes</AlertTitle>
|
||||
|
||||
<AlertDescription className="mr-4">
|
||||
Two factor authentication recovery codes are used to access your account in the
|
||||
event that you lose access to your authenticator app.
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<RecoveryCodes isTwoFactorEnabled={user.twoFactorEnabled} />
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<h4 className="text-lg font-medium">
|
||||
<Alert className="p-6" variant="neutral">
|
||||
<AlertTitle>
|
||||
Your account is managed by {IDENTITY_PROVIDER_NAME[user.identityProvider]}
|
||||
</h4>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
To update your password, enable two-factor authentication, and manage other security
|
||||
settings, please go to your {IDENTITY_PROVIDER_NAME[user.identityProvider]} account
|
||||
settings.
|
||||
</p>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Alert
|
||||
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||
variant="neutral"
|
||||
>
|
||||
<div className="mb-4 mr-4 sm:mb-0">
|
||||
<AlertTitle>Recent activity</AlertTitle>
|
||||
|
||||
<AlertDescription className="mr-2">
|
||||
View all recent security activity related to your account.
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<Button asChild>
|
||||
<Link href="/settings/security/activity">View activity</Link>
|
||||
</Button>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type AcceptTeamInvitationButtonProps = {
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const AcceptTeamInvitationButton = ({ teamId }: AcceptTeamInvitationButtonProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const {
|
||||
mutateAsync: acceptTeamInvitation,
|
||||
isLoading,
|
||||
isSuccess,
|
||||
} = trpc.team.acceptTeamInvitation.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Accepted team invitation',
|
||||
duration: 5000,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
description: 'Unable to join this team at this time.',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={async () => acceptTeamInvitation({ teamId })}
|
||||
loading={isLoading}
|
||||
disabled={isLoading || isSuccess}
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
39
apps/web/src/app/(dashboard)/settings/teams/page.tsx
Normal file
39
apps/web/src/app/(dashboard)/settings/teams/page.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
|
||||
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 { TeamEmailUsage } from './team-email-usage';
|
||||
import { TeamInvitations } from './team-invitations';
|
||||
|
||||
export default function TeamsSettingsPage() {
|
||||
const { data: teamEmail } = trpc.team.getTeamEmailByEmail.useQuery();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader title="Teams" subtitle="Manage all teams you are currently associated with.">
|
||||
<CreateTeamDialog />
|
||||
</SettingsHeader>
|
||||
|
||||
<UserSettingsTeamsPageDataTable />
|
||||
|
||||
<div className="mt-8 space-y-8">
|
||||
<AnimatePresence>
|
||||
{teamEmail && (
|
||||
<AnimateGenericFadeInOut>
|
||||
<TeamEmailUsage teamEmail={teamEmail} />
|
||||
</AnimateGenericFadeInOut>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<TeamInvitations />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
apps/web/src/app/(dashboard)/settings/teams/team-email-usage.tsx
Normal file
105
apps/web/src/app/(dashboard)/settings/teams/team-email-usage.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { TeamEmail } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type TeamEmailUsageProps = {
|
||||
teamEmail: TeamEmail & { team: { name: string; url: string } };
|
||||
};
|
||||
|
||||
export const TeamEmailUsage = ({ teamEmail }: TeamEmailUsageProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: deleteTeamEmail, isLoading: isDeletingTeamEmail } =
|
||||
trpc.team.deleteTeamEmail.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'You have successfully revoked access.',
|
||||
duration: 5000,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
description:
|
||||
'We encountered an unknown error while attempting to revoke access. Please try again or contact support.',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Alert variant="neutral" className="flex flex-row items-center justify-between p-6">
|
||||
<div>
|
||||
<AlertTitle className="mb-0">Team Email</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>
|
||||
Your email is currently being used by team{' '}
|
||||
<span className="font-semibold">{teamEmail.team.name}</span> ({teamEmail.team.url}
|
||||
).
|
||||
</p>
|
||||
|
||||
<p className="mt-1">They have permission on your behalf to:</p>
|
||||
|
||||
<ul className="mt-0.5 list-inside list-disc">
|
||||
<li>Display your name and email in documents</li>
|
||||
<li>View all documents sent to your account</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<Dialog open={open} onOpenChange={(value) => !isDeletingTeamEmail && setOpen(value)}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="destructive">Revoke access</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Are you sure?</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
You are about to revoke access for team{' '}
|
||||
<span className="font-semibold">{teamEmail.team.name}</span> ({teamEmail.team.url}) to
|
||||
use your email.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<fieldset disabled={isDeletingTeamEmail}>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
loading={isDeletingTeamEmail}
|
||||
onClick={async () => deleteTeamEmail({ teamId: teamEmail.teamId })}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import { BellIcon } from 'lucide-react';
|
||||
|
||||
import { formatTeamUrl } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
|
||||
import { AcceptTeamInvitationButton } from './accept-team-invitation-button';
|
||||
|
||||
export const TeamInvitations = () => {
|
||||
const { data, isInitialLoading } = trpc.team.getTeamInvitations.useQuery();
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{data && data.length > 0 && !isInitialLoading && (
|
||||
<AnimateGenericFadeInOut>
|
||||
<Alert variant="secondary">
|
||||
<div className="flex h-full flex-row items-center p-2">
|
||||
<BellIcon className="mr-4 h-5 w-5 text-blue-800" />
|
||||
|
||||
<AlertDescription className="mr-2">
|
||||
You have <strong>{data.length}</strong> pending team invitation
|
||||
{data.length > 1 ? 's' : ''}.
|
||||
</AlertDescription>
|
||||
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<button className="ml-auto text-sm font-medium text-blue-700 hover:text-blue-600">
|
||||
View invites
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Pending invitations</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
You have {data.length} pending team invitation{data.length > 1 ? 's' : ''}.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ul className="-mx-6 -mb-6 max-h-[80vh] divide-y overflow-auto px-6 pb-6 xl:max-h-[70vh]">
|
||||
{data.map((invitation) => (
|
||||
<li key={invitation.teamId}>
|
||||
<AvatarWithText
|
||||
className="w-full max-w-none py-4"
|
||||
avatarFallback={invitation.team.name.slice(0, 1)}
|
||||
primaryText={
|
||||
<span className="text-foreground/80 font-semibold">
|
||||
{invitation.team.name}
|
||||
</span>
|
||||
}
|
||||
secondaryText={formatTeamUrl(invitation.team.url)}
|
||||
rightSideComponent={
|
||||
<div className="ml-auto">
|
||||
<AcceptTeamInvitationButton teamId={invitation.team.id} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</Alert>
|
||||
</AnimateGenericFadeInOut>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
@ -2,13 +2,16 @@
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Loader, Plus } from 'lucide-react';
|
||||
import { AlertTriangle, Loader, Plus } from 'lucide-react';
|
||||
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import type { Template } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||
@ -36,6 +39,8 @@ export const TemplatesDataTable = ({
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
|
||||
const { remaining } = useLimits();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { toast } = useToast();
|
||||
@ -77,6 +82,19 @@ export const TemplatesDataTable = ({
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{remaining.documents === 0 && (
|
||||
<Alert variant="warning" className="mb-4">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>Document Limit Exceeded!</AlertTitle>
|
||||
<AlertDescription className="mt-2">
|
||||
You have reached your document limit.{' '}
|
||||
<Link className="underline underline-offset-4" href="/settings/billing">
|
||||
Upgrade your account to continue!
|
||||
</Link>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<DataTable
|
||||
columns={[
|
||||
{
|
||||
@ -102,7 +120,7 @@ export const TemplatesDataTable = ({
|
||||
return (
|
||||
<div className="flex items-center gap-x-4">
|
||||
<Button
|
||||
disabled={isRowLoading}
|
||||
disabled={isRowLoading || remaining.documents === 0}
|
||||
loading={isRowLoading}
|
||||
onClick={async () => {
|
||||
setLoadingStates((prev) => ({ ...prev, [row.original.id]: true }));
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
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';
|
||||
|
||||
@ -14,6 +16,10 @@ type TemplatesPageProps = {
|
||||
};
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Templates',
|
||||
};
|
||||
|
||||
export default async function TemplatesPage({ searchParams = {} }: TemplatesPageProps) {
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
const page = Number(searchParams.page) || 1;
|
||||
|
||||
@ -30,7 +30,7 @@ export const DocumentPreviewButton = ({
|
||||
{...props}
|
||||
>
|
||||
<FileSearch className="mr-2 h-5 w-5" strokeWidth={1.7} />
|
||||
View Document
|
||||
View Original Document
|
||||
</Button>
|
||||
|
||||
<DocumentDialog documentData={documentData} open={showDialog} onOpenChange={setShowDialog} />
|
||||
|
||||
@ -10,7 +10,7 @@ import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/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 { DocumentStatus, FieldType } from '@documenso/prisma/client';
|
||||
import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
|
||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
||||
@ -94,7 +94,10 @@ export default async function CompletedSigningPage({
|
||||
))}
|
||||
|
||||
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
||||
You have signed
|
||||
You have
|
||||
{recipient.role === RecipientRole.SIGNER && ' signed '}
|
||||
{recipient.role === RecipientRole.VIEWER && ' viewed '}
|
||||
{recipient.role === RecipientRole.APPROVER && ' approved '}
|
||||
<span className="mt-1.5 block">"{truncatedTitle}"</span>
|
||||
</h2>
|
||||
|
||||
@ -128,7 +131,7 @@ export default async function CompletedSigningPage({
|
||||
/>
|
||||
) : (
|
||||
<DocumentPreviewButton
|
||||
className="flex-1"
|
||||
className="text-[11px]"
|
||||
title="Signatures will appear once the document has been completed"
|
||||
documentData={documentData}
|
||||
/>
|
||||
|
||||
@ -9,7 +9,7 @@ import { useForm } from 'react-hook-form';
|
||||
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||
import type { Document, Field, Recipient } from '@documenso/prisma/client';
|
||||
import { type Document, type Field, type Recipient, RecipientRole } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@ -96,73 +96,114 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
||||
|
||||
<fieldset
|
||||
disabled={isSubmitting}
|
||||
className={cn('-mx-2 flex flex-1 flex-col overflow-hidden px-2')}
|
||||
className={cn(
|
||||
'custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2',
|
||||
)}
|
||||
>
|
||||
<h3 className="text-foreground text-2xl font-semibold">Sign Document</h3>
|
||||
<div className={cn('flex flex-1 flex-col')}>
|
||||
<h3 className="text-foreground text-2xl font-semibold">
|
||||
{recipient.role === RecipientRole.VIEWER && 'View Document'}
|
||||
{recipient.role === RecipientRole.SIGNER && 'Sign Document'}
|
||||
{recipient.role === RecipientRole.APPROVER && 'Approve Document'}
|
||||
</h3>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
Please review the document before signing.
|
||||
</p>
|
||||
{recipient.role === RecipientRole.VIEWER ? (
|
||||
<>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
Please mark as viewed to complete
|
||||
</p>
|
||||
|
||||
<hr className="border-border mb-8 mt-4" />
|
||||
<hr className="border-border mb-8 mt-4" />
|
||||
|
||||
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
|
||||
<div className="flex flex-1 flex-col gap-y-4">
|
||||
<div>
|
||||
<Label htmlFor="full-name">Full Name</Label>
|
||||
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
|
||||
<div className="flex flex-1 flex-col gap-y-4" />
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
disabled={typeof window !== 'undefined' && window.history.length <= 1}
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
id="full-name"
|
||||
className="bg-background mt-2"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value.trimStart())}
|
||||
/>
|
||||
<SignDialog
|
||||
isSubmitting={isSubmitting}
|
||||
onSignatureComplete={handleSubmit(onFormSubmit)}
|
||||
document={document}
|
||||
fields={fields}
|
||||
fieldsValidated={fieldsValidated}
|
||||
role={recipient.role}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
Please review the document before signing.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="Signature">Signature</Label>
|
||||
<hr className="border-border mb-8 mt-4" />
|
||||
|
||||
<Card className="mt-2" gradient degrees={-120}>
|
||||
<CardContent className="p-0">
|
||||
<SignaturePad
|
||||
className="h-44 w-full"
|
||||
defaultValue={signature ?? undefined}
|
||||
onChange={(value) => {
|
||||
setSignature(value);
|
||||
}}
|
||||
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
|
||||
<div className="flex flex-1 flex-col gap-y-4">
|
||||
<div>
|
||||
<Label htmlFor="full-name">Full Name</Label>
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
id="full-name"
|
||||
className="bg-background mt-2"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value.trimStart())}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="Signature">Signature</Label>
|
||||
|
||||
<Card className="mt-2" gradient degrees={-120}>
|
||||
<CardContent className="p-0">
|
||||
<SignaturePad
|
||||
className="h-44 w-full"
|
||||
disabled={isSubmitting}
|
||||
defaultValue={signature ?? undefined}
|
||||
onChange={(value) => {
|
||||
setSignature(value);
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
disabled={typeof window !== 'undefined' && window.history.length <= 1}
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<SignDialog
|
||||
isSubmitting={isSubmitting}
|
||||
onSignatureComplete={handleSubmit(onFormSubmit)}
|
||||
document={document}
|
||||
fields={fields}
|
||||
fieldsValidated={fieldsValidated}
|
||||
role={recipient.role}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
disabled={typeof window !== 'undefined' && window.history.length <= 1}
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<SignDialog
|
||||
isSubmitting={isSubmitting}
|
||||
onSignatureComplete={handleSubmit(onFormSubmit)}
|
||||
document={document}
|
||||
fields={fields}
|
||||
fieldsValidated={fieldsValidated}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
|
||||
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
|
||||
|
||||
import { Header as AuthenticatedHeader } from '~/components/(dashboard)/layout/header';
|
||||
import { NextAuthProvider } from '~/providers/next-auth';
|
||||
@ -12,10 +14,16 @@ export type SigningLayoutProps = {
|
||||
export default async function SigningLayout({ children }: SigningLayoutProps) {
|
||||
const { user, session } = await getServerComponentSession();
|
||||
|
||||
let teams: GetTeamsResponse = [];
|
||||
|
||||
if (user && session) {
|
||||
teams = await getTeams({ userId: user.id });
|
||||
}
|
||||
|
||||
return (
|
||||
<NextAuthProvider session={session}>
|
||||
<div className="min-h-screen">
|
||||
{user && <AuthenticatedHeader user={user} />}
|
||||
{user && <AuthenticatedHeader user={user} teams={teams} />}
|
||||
|
||||
<main className="mb-8 mt-8 px-4 md:mb-12 md:mt-12 md:px-8">{children}</main>
|
||||
</div>
|
||||
|
||||
@ -14,7 +14,7 @@ import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-f
|
||||
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 { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client';
|
||||
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';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
@ -110,7 +110,10 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
||||
|
||||
<div className="mt-2.5 flex items-center gap-x-6">
|
||||
<p className="text-muted-foreground">
|
||||
{document.User.name} ({document.User.email}) has invited you to sign this document.
|
||||
{document.User.name} ({document.User.email}) has invited you to{' '}
|
||||
{recipient.role === RecipientRole.VIEWER && 'view'}
|
||||
{recipient.role === RecipientRole.SIGNER && 'sign'}
|
||||
{recipient.role === RecipientRole.APPROVER && 'approve'} this document.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { Document, Field } from '@documenso/prisma/client';
|
||||
import { RecipientRole } from '@documenso/prisma/client';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
@ -17,6 +18,7 @@ export type SignDialogProps = {
|
||||
fields: Field[];
|
||||
fieldsValidated: () => void | Promise<void>;
|
||||
onSignatureComplete: () => void | Promise<void>;
|
||||
role: RecipientRole;
|
||||
};
|
||||
|
||||
export const SignDialog = ({
|
||||
@ -25,6 +27,7 @@ export const SignDialog = ({
|
||||
fields,
|
||||
fieldsValidated,
|
||||
onSignatureComplete,
|
||||
role,
|
||||
}: SignDialogProps) => {
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const truncatedTitle = truncateTitle(document.title);
|
||||
@ -45,9 +48,18 @@ export const SignDialog = ({
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<div className="text-center">
|
||||
<div className="text-xl font-semibold text-neutral-800">Sign Document</div>
|
||||
<div className="text-foreground text-xl font-semibold">
|
||||
{role === RecipientRole.VIEWER && 'Mark Document as Viewed'}
|
||||
{role === RecipientRole.SIGNER && 'Sign Document'}
|
||||
{role === RecipientRole.APPROVER && 'Approve Document'}
|
||||
</div>
|
||||
<div className="text-muted-foreground mx-auto w-4/5 py-2 text-center">
|
||||
You are about to finish signing "{truncatedTitle}". Are you sure?
|
||||
{role === RecipientRole.VIEWER &&
|
||||
`You are about to finish viewing "${truncatedTitle}". Are you sure?`}
|
||||
{role === RecipientRole.SIGNER &&
|
||||
`You are about to finish signing "${truncatedTitle}". Are you sure?`}
|
||||
{role === RecipientRole.APPROVER &&
|
||||
`You are about to finish approving "${truncatedTitle}". Are you sure?`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -71,7 +83,9 @@ export const SignDialog = ({
|
||||
loading={isSubmitting}
|
||||
onClick={onSignatureComplete}
|
||||
>
|
||||
Sign
|
||||
{role === RecipientRole.VIEWER && 'Mark as Viewed'}
|
||||
{role === RecipientRole.SIGNER && 'Sign'}
|
||||
{role === RecipientRole.APPROVER && 'Approve'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
|
||||
20
apps/web/src/app/(teams)/t/[teamUrl]/documents/[id]/page.tsx
Normal file
20
apps/web/src/app/(teams)/t/[teamUrl]/documents/[id]/page.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
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';
|
||||
|
||||
export type DocumentPageProps = {
|
||||
params: {
|
||||
id: string;
|
||||
teamUrl: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function DocumentPage({ params }: DocumentPageProps) {
|
||||
const { teamUrl } = params;
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
const team = await getTeamByUrl({ userId: user.id, teamUrl });
|
||||
|
||||
return <DocumentPageComponent params={params} team={team} />;
|
||||
}
|
||||
25
apps/web/src/app/(teams)/t/[teamUrl]/documents/page.tsx
Normal file
25
apps/web/src/app/(teams)/t/[teamUrl]/documents/page.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
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';
|
||||
|
||||
export type TeamsDocumentPageProps = {
|
||||
params: {
|
||||
teamUrl: string;
|
||||
};
|
||||
searchParams?: DocumentsPageViewProps['searchParams'];
|
||||
};
|
||||
|
||||
export default async function TeamsDocumentPage({
|
||||
params,
|
||||
searchParams = {},
|
||||
}: TeamsDocumentPageProps) {
|
||||
const { teamUrl } = params;
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
const team = await getTeamByUrl({ userId: user.id, teamUrl });
|
||||
|
||||
return <DocumentsPageView searchParams={searchParams} team={team} />;
|
||||
}
|
||||
54
apps/web/src/app/(teams)/t/[teamUrl]/error.tsx
Normal file
54
apps/web/src/app/(teams)/t/[teamUrl]/error.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
|
||||
import { AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
type ErrorProps = {
|
||||
error: Error & { digest?: string };
|
||||
};
|
||||
|
||||
export default function ErrorPage({ error }: ErrorProps) {
|
||||
const router = useRouter();
|
||||
|
||||
let errorMessage = 'Unknown error';
|
||||
let errorDetails = '';
|
||||
|
||||
if (error.message === AppErrorCode.UNAUTHORIZED) {
|
||||
errorMessage = 'Unauthorized';
|
||||
errorDetails = 'You are not authorized to view this page.';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[80vh] w-full items-center justify-center py-32">
|
||||
<div>
|
||||
<p className="text-muted-foreground font-semibold">{errorMessage}</p>
|
||||
|
||||
<h1 className="mt-3 text-2xl font-bold md:text-3xl">Oops! Something went wrong.</h1>
|
||||
|
||||
<p className="text-muted-foreground mt-4 text-sm">{errorDetails}</p>
|
||||
|
||||
<div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-32"
|
||||
onClick={() => {
|
||||
void router.back();
|
||||
}}
|
||||
>
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
Go Back
|
||||
</Button>
|
||||
|
||||
<Button asChild>
|
||||
<Link href="/settings/teams">View teams</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
apps/web/src/app/(teams)/t/[teamUrl]/layout-billing-banner.tsx
Normal file
130
apps/web/src/app/(teams)/t/[teamUrl]/layout-billing-banner.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||
import type { TeamMemberRole } from '@documenso/prisma/client';
|
||||
import { type Subscription, SubscriptionStatus } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type LayoutBillingBannerProps = {
|
||||
subscription: Subscription;
|
||||
teamId: number;
|
||||
userRole: TeamMemberRole;
|
||||
};
|
||||
|
||||
export const LayoutBillingBanner = ({
|
||||
subscription,
|
||||
teamId,
|
||||
userRole,
|
||||
}: LayoutBillingBannerProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { mutateAsync: createBillingPortal, isLoading } =
|
||||
trpc.team.createBillingPortal.useMutation();
|
||||
|
||||
const handleCreatePortal = async () => {
|
||||
try {
|
||||
const sessionUrl = await createBillingPortal({ teamId });
|
||||
|
||||
window.open(sessionUrl, '_blank');
|
||||
|
||||
setIsOpen(false);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description:
|
||||
'We are unable to proceed to the billing portal at this time. Please try again, or contact support.',
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (subscription.status === SubscriptionStatus.ACTIVE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn({
|
||||
'bg-yellow-200 text-yellow-900 dark:bg-yellow-400':
|
||||
subscription.status === SubscriptionStatus.PAST_DUE,
|
||||
'bg-destructive text-destructive-foreground':
|
||||
subscription.status === SubscriptionStatus.INACTIVE,
|
||||
})}
|
||||
>
|
||||
<div className="mx-auto flex max-w-screen-xl items-center justify-center gap-x-4 px-4 py-2 text-sm font-medium">
|
||||
<div className="flex items-center">
|
||||
<AlertTriangle className="mr-2.5 h-5 w-5" />
|
||||
|
||||
{match(subscription.status)
|
||||
.with(SubscriptionStatus.PAST_DUE, () => 'Payment overdue')
|
||||
.with(SubscriptionStatus.INACTIVE, () => 'Teams restricted')
|
||||
.exhaustive()}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn({
|
||||
'text-yellow-900 hover:bg-yellow-100 hover:text-yellow-900 dark:hover:bg-yellow-500':
|
||||
subscription.status === SubscriptionStatus.PAST_DUE,
|
||||
'text-destructive-foreground hover:bg-destructive-foreground hover:text-white':
|
||||
subscription.status === SubscriptionStatus.INACTIVE,
|
||||
})}
|
||||
disabled={isLoading}
|
||||
onClick={() => setIsOpen(true)}
|
||||
size="sm"
|
||||
>
|
||||
Resolve
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={isOpen} onOpenChange={(value) => !isLoading && setIsOpen(value)}>
|
||||
<DialogContent>
|
||||
<DialogTitle>Payment overdue</DialogTitle>
|
||||
|
||||
{match(subscription.status)
|
||||
.with(SubscriptionStatus.PAST_DUE, () => (
|
||||
<DialogDescription>
|
||||
Your payment for teams is overdue. Please settle the payment to avoid any service
|
||||
disruptions.
|
||||
</DialogDescription>
|
||||
))
|
||||
.with(SubscriptionStatus.INACTIVE, () => (
|
||||
<DialogDescription>
|
||||
Due to an unpaid invoice, your team has been restricted. Please settle the payment
|
||||
to restore full access to your team.
|
||||
</DialogDescription>
|
||||
))
|
||||
.otherwise(() => null)}
|
||||
|
||||
{canExecuteTeamAction('MANAGE_BILLING', userRole) && (
|
||||
<DialogFooter>
|
||||
<Button loading={isLoading} onClick={handleCreatePortal}>
|
||||
Resolve payment
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
65
apps/web/src/app/(teams)/t/[teamUrl]/layout.tsx
Normal file
65
apps/web/src/app/(teams)/t/[teamUrl]/layout.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
|
||||
import { RedirectType, redirect } from 'next/navigation';
|
||||
|
||||
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server';
|
||||
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
|
||||
import { SubscriptionStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { Header } from '~/components/(dashboard)/layout/header';
|
||||
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
|
||||
import { NextAuthProvider } from '~/providers/next-auth';
|
||||
|
||||
import { LayoutBillingBanner } from './layout-billing-banner';
|
||||
|
||||
export type AuthenticatedTeamsLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
params: {
|
||||
teamUrl: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function AuthenticatedTeamsLayout({
|
||||
children,
|
||||
params,
|
||||
}: AuthenticatedTeamsLayoutProps) {
|
||||
const { session, user } = await getServerComponentSession();
|
||||
|
||||
if (!session || !user) {
|
||||
redirect('/signin');
|
||||
}
|
||||
|
||||
const [getTeamsPromise, getTeamPromise] = await Promise.allSettled([
|
||||
getTeams({ userId: user.id }),
|
||||
getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl }),
|
||||
]);
|
||||
|
||||
if (getTeamPromise.status === 'rejected') {
|
||||
redirect('/documents', RedirectType.replace);
|
||||
}
|
||||
|
||||
const team = getTeamPromise.value;
|
||||
const teams = getTeamsPromise.status === 'fulfilled' ? getTeamsPromise.value : [];
|
||||
|
||||
return (
|
||||
<NextAuthProvider session={session}>
|
||||
<LimitsProvider teamId={team.id}>
|
||||
{team.subscription && team.subscription.status !== SubscriptionStatus.ACTIVE && (
|
||||
<LayoutBillingBanner
|
||||
subscription={team.subscription}
|
||||
teamId={team.id}
|
||||
userRole={team.currentTeamMember.role}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Header user={user} teams={teams} />
|
||||
|
||||
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
||||
|
||||
<RefreshOnFocus />
|
||||
</LimitsProvider>
|
||||
</NextAuthProvider>
|
||||
);
|
||||
}
|
||||
32
apps/web/src/app/(teams)/t/[teamUrl]/not-found.tsx
Normal file
32
apps/web/src/app/(teams)/t/[teamUrl]/not-found.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[80vh] w-full items-center justify-center py-32">
|
||||
<div>
|
||||
<p className="text-muted-foreground font-semibold">404 Team not found</p>
|
||||
|
||||
<h1 className="mt-3 text-2xl font-bold md:text-3xl">Oops! Something went wrong.</h1>
|
||||
|
||||
<p className="text-muted-foreground mt-4 text-sm">
|
||||
The team you are looking for may have been removed, renamed or may have never existed.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center">
|
||||
<Button asChild className="w-32">
|
||||
<Link href="/settings/teams">
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
Go Back
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,84 @@
|
||||
import { DateTime } from 'luxon';
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
|
||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||
import { TeamBillingInvoicesDataTable } from '~/components/(teams)/tables/team-billing-invoices-data-table';
|
||||
import { TeamBillingPortalButton } from '~/components/(teams)/team-billing-portal-button';
|
||||
|
||||
export type TeamsSettingsBillingPageProps = {
|
||||
params: {
|
||||
teamUrl: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function TeamsSettingBillingPage({ params }: TeamsSettingsBillingPageProps) {
|
||||
const session = await getRequiredServerComponentSession();
|
||||
|
||||
const team = await getTeamByUrl({ userId: session.user.id, teamUrl: params.teamUrl });
|
||||
|
||||
const canManageBilling = canExecuteTeamAction('MANAGE_BILLING', team.currentTeamMember.role);
|
||||
|
||||
let teamSubscription: Stripe.Subscription | null = null;
|
||||
|
||||
if (team.subscription) {
|
||||
teamSubscription = await stripe.subscriptions.retrieve(team.subscription.planId);
|
||||
}
|
||||
|
||||
const formatTeamSubscriptionDetails = (subscription: Stripe.Subscription | null) => {
|
||||
if (!subscription) {
|
||||
return 'No payment required';
|
||||
}
|
||||
|
||||
const numberOfSeats = subscription.items.data[0].quantity ?? 0;
|
||||
|
||||
const formattedTeamMemberQuanity = numberOfSeats > 1 ? `${numberOfSeats} members` : '1 member';
|
||||
|
||||
const formattedDate = DateTime.fromSeconds(subscription.current_period_end).toFormat(
|
||||
'LLL dd, yyyy',
|
||||
);
|
||||
|
||||
return `${formattedTeamMemberQuanity} • Monthly • Renews: ${formattedDate}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader title="Billing" subtitle="Your subscription is currently active." />
|
||||
|
||||
<Card gradient className="shadow-sm">
|
||||
<CardContent className="flex flex-row items-center justify-between p-4">
|
||||
<div className="flex flex-col text-sm">
|
||||
<p className="text-foreground font-semibold">
|
||||
Current plan: {teamSubscription ? 'Team' : 'Community Team'}
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-0.5">
|
||||
{formatTeamSubscriptionDetails(teamSubscription)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{teamSubscription && (
|
||||
<div
|
||||
title={
|
||||
canManageBilling
|
||||
? 'Manage team subscription.'
|
||||
: 'You must be an admin of this team to manage billing.'
|
||||
}
|
||||
>
|
||||
<TeamBillingPortalButton teamId={team.id} />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<section className="mt-6">
|
||||
<TeamBillingInvoicesDataTable teamId={team.id} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
apps/web/src/app/(teams)/t/[teamUrl]/settings/layout.tsx
Normal file
54
apps/web/src/app/(teams)/t/[teamUrl]/settings/layout.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||
|
||||
import { DesktopNav } from '~/components/(teams)/settings/layout/desktop-nav';
|
||||
import { MobileNav } from '~/components/(teams)/settings/layout/mobile-nav';
|
||||
|
||||
export type TeamSettingsLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
params: {
|
||||
teamUrl: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function TeamsSettingsLayout({
|
||||
children,
|
||||
params: { teamUrl },
|
||||
}: TeamSettingsLayoutProps) {
|
||||
const session = await getRequiredServerComponentSession();
|
||||
|
||||
try {
|
||||
const team = await getTeamByUrl({ userId: session.user.id, teamUrl });
|
||||
|
||||
if (!canExecuteTeamAction('MANAGE_TEAM', team.currentTeamMember.role)) {
|
||||
throw new Error(AppErrorCode.UNAUTHORIZED);
|
||||
}
|
||||
} catch (e) {
|
||||
const error = AppError.parseError(e);
|
||||
|
||||
if (error.code === 'P2025') {
|
||||
notFound();
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||
<h1 className="text-4xl font-semibold">Team Settings</h1>
|
||||
|
||||
<div className="mt-4 grid grid-cols-12 gap-x-8 md:mt-8">
|
||||
<DesktopNav className="hidden md:col-span-3 md:flex" />
|
||||
<MobileNav className="col-span-12 mb-8 md:hidden" />
|
||||
|
||||
<div className="col-span-12 md:col-span-9">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
|
||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||
import { InviteTeamMembersDialog } from '~/components/(teams)/dialogs/invite-team-member-dialog';
|
||||
import { TeamsMemberPageDataTable } from '~/components/(teams)/tables/teams-member-page-data-table';
|
||||
|
||||
export type TeamsSettingsMembersPageProps = {
|
||||
params: {
|
||||
teamUrl: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function TeamsSettingsMembersPage({ params }: TeamsSettingsMembersPageProps) {
|
||||
const { teamUrl } = params;
|
||||
|
||||
const session = await getRequiredServerComponentSession();
|
||||
|
||||
const team = await getTeamByUrl({ userId: session.user.id, teamUrl });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader title="Members" subtitle="Manage the members or invite new members.">
|
||||
<InviteTeamMembersDialog
|
||||
teamId={team.id}
|
||||
currentUserTeamRole={team.currentTeamMember.role}
|
||||
/>
|
||||
</SettingsHeader>
|
||||
|
||||
<TeamsMemberPageDataTable
|
||||
currentUserTeamRole={team.currentTeamMember.role}
|
||||
teamId={team.id}
|
||||
teamName={team.name}
|
||||
teamOwnerUserId={team.ownerUserId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
186
apps/web/src/app/(teams)/t/[teamUrl]/settings/page.tsx
Normal file
186
apps/web/src/app/(teams)/t/[teamUrl]/settings/page.tsx
Normal file
@ -0,0 +1,186 @@
|
||||
import { CheckCircle2, Clock } from 'lucide-react';
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { isTokenExpired } from '@documenso/lib/utils/token-verification';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
|
||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||
import { AddTeamEmailDialog } from '~/components/(teams)/dialogs/add-team-email-dialog';
|
||||
import { DeleteTeamDialog } from '~/components/(teams)/dialogs/delete-team-dialog';
|
||||
import { TransferTeamDialog } from '~/components/(teams)/dialogs/transfer-team-dialog';
|
||||
import { UpdateTeamForm } from '~/components/(teams)/forms/update-team-form';
|
||||
|
||||
import { TeamEmailDropdown } from './team-email-dropdown';
|
||||
import { TeamTransferStatus } from './team-transfer-status';
|
||||
|
||||
export type TeamsSettingsPageProps = {
|
||||
params: {
|
||||
teamUrl: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function TeamsSettingsPage({ params }: TeamsSettingsPageProps) {
|
||||
const { teamUrl } = params;
|
||||
|
||||
const session = await getRequiredServerComponentSession();
|
||||
|
||||
const team = await getTeamByUrl({ userId: session.user.id, teamUrl });
|
||||
|
||||
const isTransferVerificationExpired =
|
||||
!team.transferVerification || isTokenExpired(team.transferVerification.expiresAt);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader title="Team Profile" subtitle="Here you can edit your team's details." />
|
||||
|
||||
<TeamTransferStatus
|
||||
className="mb-4"
|
||||
currentUserTeamRole={team.currentTeamMember.role}
|
||||
teamId={team.id}
|
||||
transferVerification={team.transferVerification}
|
||||
/>
|
||||
|
||||
<UpdateTeamForm teamId={team.id} teamName={team.name} teamUrl={team.url} />
|
||||
|
||||
<section className="mt-6 space-y-6">
|
||||
{(team.teamEmail || team.emailVerification) && (
|
||||
<Alert className="p-6" variant="neutral">
|
||||
<AlertTitle>Team email</AlertTitle>
|
||||
|
||||
<AlertDescription className="mr-2">
|
||||
You can view documents associated with this email and use this identity when sending
|
||||
documents.
|
||||
</AlertDescription>
|
||||
|
||||
<hr className="border-border/50 mt-2" />
|
||||
|
||||
<div className="flex flex-row items-center justify-between pt-4">
|
||||
<AvatarWithText
|
||||
avatarClass="h-12 w-12"
|
||||
avatarFallback={extractInitials(
|
||||
(team.teamEmail?.name || team.emailVerification?.name) ?? '',
|
||||
)}
|
||||
primaryText={
|
||||
<span className="text-foreground/80 text-sm font-semibold">
|
||||
{team.teamEmail?.name || team.emailVerification?.name}
|
||||
</span>
|
||||
}
|
||||
secondaryText={
|
||||
<span className="text-sm">
|
||||
{team.teamEmail?.email || team.emailVerification?.email}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-row items-center pr-2">
|
||||
<div className="text-muted-foreground mr-4 flex flex-row items-center text-sm xl:mr-8">
|
||||
{match({
|
||||
teamEmail: team.teamEmail,
|
||||
emailVerification: team.emailVerification,
|
||||
})
|
||||
.with({ teamEmail: P.not(null) }, () => (
|
||||
<>
|
||||
<CheckCircle2 className="mr-1.5 text-green-500 dark:text-green-300" />
|
||||
Active
|
||||
</>
|
||||
))
|
||||
.with(
|
||||
{
|
||||
emailVerification: P.when(
|
||||
(emailVerification) =>
|
||||
emailVerification && emailVerification?.expiresAt < new Date(),
|
||||
),
|
||||
},
|
||||
() => (
|
||||
<>
|
||||
<Clock className="mr-1.5 text-yellow-500 dark:text-yellow-200" />
|
||||
Expired
|
||||
</>
|
||||
),
|
||||
)
|
||||
.with({ emailVerification: P.not(null) }, () => (
|
||||
<>
|
||||
<Clock className="mr-1.5 text-blue-600 dark:text-blue-300" />
|
||||
Awaiting email confirmation
|
||||
</>
|
||||
))
|
||||
.otherwise(() => null)}
|
||||
</div>
|
||||
|
||||
<TeamEmailDropdown team={team} />
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!team.teamEmail && !team.emailVerification && (
|
||||
<Alert
|
||||
className="flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||
variant="neutral"
|
||||
>
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<AlertTitle>Team email</AlertTitle>
|
||||
|
||||
<AlertDescription className="mr-2">
|
||||
<ul className="text-muted-foreground mt-0.5 list-inside list-disc text-sm">
|
||||
{/* Feature not available yet. */}
|
||||
{/* <li>Display this name and email when sending documents</li> */}
|
||||
{/* <li>View documents associated with this email</li> */}
|
||||
|
||||
<span>View documents associated with this email</span>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<AddTeamEmailDialog teamId={team.id} />
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{team.ownerUserId === session.user.id && (
|
||||
<>
|
||||
{isTransferVerificationExpired && (
|
||||
<Alert
|
||||
className="flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||
variant="neutral"
|
||||
>
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<AlertTitle>Transfer team</AlertTitle>
|
||||
|
||||
<AlertDescription className="mr-2">
|
||||
Transfer the ownership of the team to another team member.
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<TransferTeamDialog
|
||||
ownerUserId={team.ownerUserId}
|
||||
teamId={team.id}
|
||||
teamName={team.name}
|
||||
/>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Alert
|
||||
className="flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||
variant="neutral"
|
||||
>
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<AlertTitle>Delete team</AlertTitle>
|
||||
|
||||
<AlertDescription className="mr-2">
|
||||
This team, and any associated data excluding billing invoices will be permanently
|
||||
deleted.
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<DeleteTeamDialog teamId={team.id} teamName={team.name} />
|
||||
</Alert>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,143 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Edit, Loader, Mail, MoreHorizontal, X } from 'lucide-react';
|
||||
|
||||
import type { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { UpdateTeamEmailDialog } from '~/components/(teams)/dialogs/update-team-email-dialog';
|
||||
|
||||
export type TeamsSettingsPageProps = {
|
||||
team: Awaited<ReturnType<typeof getTeamByUrl>>;
|
||||
};
|
||||
|
||||
export const TeamEmailDropdown = ({ team }: TeamsSettingsPageProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: resendEmailVerification, isLoading: isResendingEmailVerification } =
|
||||
trpc.team.resendTeamEmailVerification.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Email verification has been resent',
|
||||
duration: 5000,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
description: 'Unable to resend verification at this time. Please try again.',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: deleteTeamEmail, isLoading: isDeletingTeamEmail } =
|
||||
trpc.team.deleteTeamEmail.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Team email has been removed',
|
||||
duration: 5000,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
description: 'Unable to remove team email at this time. Please try again.',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: deleteTeamEmailVerification, isLoading: isDeletingTeamEmailVerification } =
|
||||
trpc.team.deleteTeamEmailVerification.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Email verification has been removed',
|
||||
duration: 5000,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
description: 'Unable to remove email verification at this time. Please try again.',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const onRemove = async () => {
|
||||
if (team.teamEmail) {
|
||||
await deleteTeamEmail({ teamId: team.id });
|
||||
}
|
||||
|
||||
if (team.emailVerification) {
|
||||
await deleteTeamEmailVerification({ teamId: team.id });
|
||||
}
|
||||
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||
{!team.teamEmail && team.emailVerification && (
|
||||
<DropdownMenuItem
|
||||
disabled={isResendingEmailVerification}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void resendEmailVerification({ teamId: team.id });
|
||||
}}
|
||||
>
|
||||
{isResendingEmailVerification ? (
|
||||
<Loader className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Mail className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Resend verification
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{team.teamEmail && (
|
||||
<UpdateTeamEmailDialog
|
||||
teamEmail={team.teamEmail}
|
||||
trigger={
|
||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem
|
||||
disabled={isDeletingTeamEmail || isDeletingTeamEmailVerification}
|
||||
onClick={async () => onRemove()}
|
||||
>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,115 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
|
||||
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||
import { isTokenExpired } from '@documenso/lib/utils/token-verification';
|
||||
import type { TeamMemberRole, TeamTransferVerification } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type TeamTransferStatusProps = {
|
||||
className?: string;
|
||||
currentUserTeamRole: TeamMemberRole;
|
||||
teamId: number;
|
||||
transferVerification: TeamTransferVerification | null;
|
||||
};
|
||||
|
||||
export const TeamTransferStatus = ({
|
||||
className,
|
||||
currentUserTeamRole,
|
||||
teamId,
|
||||
transferVerification,
|
||||
}: TeamTransferStatusProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const isExpired = transferVerification && isTokenExpired(transferVerification.expiresAt);
|
||||
|
||||
const { mutateAsync: deleteTeamTransferRequest, isLoading } =
|
||||
trpc.team.deleteTeamTransferRequest.useMutation({
|
||||
onSuccess: () => {
|
||||
if (!isExpired) {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'The team transfer invitation has been successfully deleted.',
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
router.refresh();
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: 'An unknown error occurred',
|
||||
variant: 'destructive',
|
||||
description:
|
||||
'We encountered an unknown error while attempting to remove this transfer. Please try again or contact support.',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{transferVerification && (
|
||||
<AnimateGenericFadeInOut>
|
||||
<Alert
|
||||
variant={isExpired ? 'destructive' : 'warning'}
|
||||
className={cn(
|
||||
'flex flex-col justify-between p-6 sm:flex-row sm:items-center',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<AlertTitle>
|
||||
{isExpired ? 'Team transfer request expired' : 'Team transfer in progress'}
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
{isExpired ? (
|
||||
<p className="text-sm">
|
||||
The team transfer request to <strong>{transferVerification.name}</strong> has
|
||||
expired.
|
||||
</p>
|
||||
) : (
|
||||
<section className="text-sm">
|
||||
<p>
|
||||
A request to transfer the ownership of this team has been sent to{' '}
|
||||
<strong>
|
||||
{transferVerification.name} ({transferVerification.email})
|
||||
</strong>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
If they accept this request, the team will be transferred to their account.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
{canExecuteTeamAction('DELETE_TEAM_TRANSFER_REQUEST', currentUserTeamRole) && (
|
||||
<Button
|
||||
onClick={async () => deleteTeamTransferRequest({ teamId })}
|
||||
loading={isLoading}
|
||||
variant={isExpired ? 'destructive' : 'ghost'}
|
||||
className={cn('ml-auto', {
|
||||
'hover:bg-transparent hover:text-blue-800': !isExpired,
|
||||
})}
|
||||
>
|
||||
{isExpired ? 'Close' : 'Cancel'}
|
||||
</Button>
|
||||
)}
|
||||
</Alert>
|
||||
</AnimateGenericFadeInOut>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
@ -1,7 +1,12 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Forgot password',
|
||||
};
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
return (
|
||||
<div>
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ForgotPasswordForm } from '~/components/forms/forgot-password';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Forgot Password',
|
||||
};
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
return (
|
||||
<div>
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Reset Password',
|
||||
};
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<div>
|
||||
|
||||
@ -1,10 +1,30 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
|
||||
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
|
||||
|
||||
import { SignInForm } from '~/components/forms/signin';
|
||||
|
||||
export default function SignInPage() {
|
||||
export const metadata: Metadata = {
|
||||
title: 'Sign In',
|
||||
};
|
||||
|
||||
type SignInPageProps = {
|
||||
searchParams: {
|
||||
email?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default function SignInPage({ searchParams }: SignInPageProps) {
|
||||
const rawEmail = typeof searchParams.email === 'string' ? searchParams.email : undefined;
|
||||
const email = rawEmail ? decryptSecondaryData(rawEmail) : null;
|
||||
|
||||
if (!email && rawEmail) {
|
||||
redirect('/signin');
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Sign in to your account</h1>
|
||||
@ -13,7 +33,11 @@ export default function SignInPage() {
|
||||
Welcome back, we are lucky to have you.
|
||||
</p>
|
||||
|
||||
<SignInForm className="mt-4" isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED} />
|
||||
<SignInForm
|
||||
className="mt-4"
|
||||
initialEmail={email || undefined}
|
||||
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
|
||||
/>
|
||||
|
||||
{process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (
|
||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||
|
||||
@ -1,15 +1,34 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
|
||||
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
|
||||
|
||||
import { SignUpForm } from '~/components/forms/signup';
|
||||
|
||||
export default function SignUpPage() {
|
||||
export const metadata: Metadata = {
|
||||
title: 'Sign Up',
|
||||
};
|
||||
|
||||
type SignUpPageProps = {
|
||||
searchParams: {
|
||||
email?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default function SignUpPage({ searchParams }: SignUpPageProps) {
|
||||
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
|
||||
redirect('/signin');
|
||||
}
|
||||
|
||||
const rawEmail = typeof searchParams.email === 'string' ? searchParams.email : undefined;
|
||||
const email = rawEmail ? decryptSecondaryData(rawEmail) : null;
|
||||
|
||||
if (!email && rawEmail) {
|
||||
redirect('/signup');
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Create a new account</h1>
|
||||
@ -19,7 +38,11 @@ export default function SignUpPage() {
|
||||
signing is within your grasp.
|
||||
</p>
|
||||
|
||||
<SignUpForm className="mt-4" isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED} />
|
||||
<SignUpForm
|
||||
className="mt-4"
|
||||
initialEmail={email || undefined}
|
||||
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
|
||||
/>
|
||||
|
||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||
Already have an account?{' '}
|
||||
|
||||
121
apps/web/src/app/(unauthenticated)/team/invite/[token]/page.tsx
Normal file
121
apps/web/src/app/(unauthenticated)/team/invite/[token]/page.tsx
Normal file
@ -0,0 +1,121 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt';
|
||||
import { acceptTeamInvitation } from '@documenso/lib/server-only/team/accept-team-invitation';
|
||||
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { TeamMemberInviteStatus } from '@documenso/prisma/client';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
type AcceptInvitationPageProps = {
|
||||
params: {
|
||||
token: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function AcceptInvitationPage({
|
||||
params: { token },
|
||||
}: AcceptInvitationPageProps) {
|
||||
const session = await getServerComponentSession();
|
||||
|
||||
const teamMemberInvite = await prisma.teamMemberInvite.findUnique({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
});
|
||||
|
||||
if (!teamMemberInvite) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Invalid token</h1>
|
||||
|
||||
<p className="text-muted-foreground mb-4 mt-2 text-sm">
|
||||
This token is invalid or has expired. Please contact your team for a new invitation.
|
||||
</p>
|
||||
|
||||
<Button asChild>
|
||||
<Link href="/">Return</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const team = await getTeamById({ teamId: teamMemberInvite.teamId });
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: {
|
||||
equals: teamMemberInvite.email,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Directly convert the team member invite to a team member if they already have an account.
|
||||
if (user) {
|
||||
await acceptTeamInvitation({ userId: user.id, teamId: team.id });
|
||||
}
|
||||
|
||||
// For users who do not exist yet, set the team invite status to accepted, which is checked during
|
||||
// user creation to determine if we should add the user to the team at that time.
|
||||
if (!user && teamMemberInvite.status !== TeamMemberInviteStatus.ACCEPTED) {
|
||||
await prisma.teamMemberInvite.update({
|
||||
where: {
|
||||
id: teamMemberInvite.id,
|
||||
},
|
||||
data: {
|
||||
status: TeamMemberInviteStatus.ACCEPTED,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const email = encryptSecondaryData({
|
||||
data: teamMemberInvite.email,
|
||||
expiresAt: DateTime.now().plus({ days: 1 }).toMillis(),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Team invitation</h1>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
You have been invited by <strong>{team.name}</strong> to join their team.
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mb-4 mt-1 text-sm">
|
||||
To accept this invitation you must create an account.
|
||||
</p>
|
||||
|
||||
<Button asChild>
|
||||
<Link href={`/signup?email=${encodeURIComponent(email)}`}>Create account</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isSessionUserTheInvitedUser = user.id === session.user?.id;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Invitation accepted!</h1>
|
||||
|
||||
<p className="text-muted-foreground mb-4 mt-2 text-sm">
|
||||
You have accepted an invitation from <strong>{team.name}</strong> to join their team.
|
||||
</p>
|
||||
|
||||
{isSessionUserTheInvitedUser ? (
|
||||
<Button asChild>
|
||||
<Link href="/">Continue</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button asChild>
|
||||
<Link href={`/signin?email=${encodeURIComponent(email)}`}>Continue to login</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,89 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { isTokenExpired } from '@documenso/lib/utils/token-verification';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
type VerifyTeamEmailPageProps = {
|
||||
params: {
|
||||
token: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function VerifyTeamEmailPage({ params: { token } }: VerifyTeamEmailPageProps) {
|
||||
const teamEmailVerification = await prisma.teamEmailVerification.findUnique({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
include: {
|
||||
team: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!teamEmailVerification || isTokenExpired(teamEmailVerification.expiresAt)) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Invalid link</h1>
|
||||
|
||||
<p className="text-muted-foreground mb-4 mt-2 text-sm">
|
||||
This link is invalid or has expired. Please contact your team to resend a verification.
|
||||
</p>
|
||||
|
||||
<Button asChild>
|
||||
<Link href="/">Return</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { team } = teamEmailVerification;
|
||||
|
||||
let isTeamEmailVerificationError = false;
|
||||
|
||||
try {
|
||||
await prisma.$transaction([
|
||||
prisma.teamEmailVerification.deleteMany({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
},
|
||||
}),
|
||||
prisma.teamEmail.create({
|
||||
data: {
|
||||
teamId: team.id,
|
||||
email: teamEmailVerification.email,
|
||||
name: teamEmailVerification.name,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
isTeamEmailVerificationError = true;
|
||||
}
|
||||
|
||||
if (isTeamEmailVerificationError) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Team email verification</h1>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
Something went wrong while attempting to verify your email address for{' '}
|
||||
<strong>{team.name}</strong>. Please try again later.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Team email verified!</h1>
|
||||
|
||||
<p className="text-muted-foreground mb-4 mt-2 text-sm">
|
||||
You have verified your email address for <strong>{team.name}</strong>.
|
||||
</p>
|
||||
|
||||
<Button asChild>
|
||||
<Link href="/">Continue</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,80 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { transferTeamOwnership } from '@documenso/lib/server-only/team/transfer-team-ownership';
|
||||
import { isTokenExpired } from '@documenso/lib/utils/token-verification';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
type VerifyTeamTransferPage = {
|
||||
params: {
|
||||
token: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function VerifyTeamTransferPage({
|
||||
params: { token },
|
||||
}: VerifyTeamTransferPage) {
|
||||
const teamTransferVerification = await prisma.teamTransferVerification.findUnique({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
include: {
|
||||
team: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!teamTransferVerification || isTokenExpired(teamTransferVerification.expiresAt)) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Invalid link</h1>
|
||||
|
||||
<p className="text-muted-foreground mb-4 mt-2 text-sm">
|
||||
This link is invalid or has expired. Please contact your team to resend a transfer
|
||||
request.
|
||||
</p>
|
||||
|
||||
<Button asChild>
|
||||
<Link href="/">Return</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { team } = teamTransferVerification;
|
||||
|
||||
let isTransferError = false;
|
||||
|
||||
try {
|
||||
await transferTeamOwnership({ token });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
isTransferError = true;
|
||||
}
|
||||
|
||||
if (isTransferError) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Team ownership transfer</h1>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
Something went wrong while attempting to transfer the ownership of team{' '}
|
||||
<strong>{team.name}</strong> to your. Please try again later or contact support.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Team ownership transferred!</h1>
|
||||
|
||||
<p className="text-muted-foreground mb-4 mt-2 text-sm">
|
||||
The ownership of team <strong>{team.name}</strong> has been successfully transferred to you.
|
||||
</p>
|
||||
|
||||
<Button asChild>
|
||||
<Link href={`/t/${team.url}/settings`}>Continue</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,9 +1,14 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { XCircle } from 'lucide-react';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Verify Email',
|
||||
};
|
||||
|
||||
export default function EmailVerificationWithoutTokenPage() {
|
||||
return (
|
||||
<div className="flex w-full items-start">
|
||||
|
||||
@ -20,7 +20,10 @@ const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' });
|
||||
const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' });
|
||||
|
||||
export const metadata = {
|
||||
title: 'Documenso - The Open Source DocuSign Alternative',
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user