Merge branch 'main' into mk/oidc-auth

This commit is contained in:
Lucas Smith
2024-05-30 15:41:36 +10:00
committed by GitHub
175 changed files with 7718 additions and 58270 deletions

View File

@ -8,6 +8,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import type { Recipient } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast';
@ -15,18 +16,21 @@ import { StackAvatar } from './stack-avatar';
export type AvatarWithRecipientProps = {
recipient: Recipient;
documentStatus: DocumentStatus;
};
export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
export function AvatarWithRecipient({ recipient, documentStatus }: AvatarWithRecipientProps) {
const [, copy] = useCopyToClipboard();
const { toast } = useToast();
const signingToken = documentStatus === DocumentStatus.PENDING ? recipient.token : null;
const onRecipientClick = () => {
if (!recipient.token) {
if (!signingToken) {
return;
}
void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`).then(() => {
void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${signingToken}`).then(() => {
toast({
title: 'Copied to clipboard',
description: 'The signing link has been copied to your clipboard.',
@ -37,10 +41,10 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
return (
<div
className={cn('my-1 flex items-center gap-2', {
'cursor-pointer hover:underline': recipient.token,
'cursor-pointer hover:underline': signingToken,
})}
role={recipient.token ? 'button' : undefined}
title={recipient.token && 'Click to copy signing link for sending to recipient'}
role={signingToken ? 'button' : undefined}
title={signingToken ? 'Click to copy signing link for sending to recipient' : undefined}
onClick={onRecipientClick}
>
<StackAvatar
@ -49,16 +53,15 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<div>
<div
className="text-muted-foreground text-sm"
title="Click to copy signing link for sending to recipient"
>
<p>{recipient.email} </p>
<p className="text-muted-foreground/70 text-xs">
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
</p>
</div>
<div
className="text-muted-foreground text-sm"
title={signingToken ? 'Click to copy signing link for sending to recipient' : undefined}
>
<p>{recipient.email}</p>
<p className="text-muted-foreground/70 text-xs">
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
</p>
</div>
</div>
);

View File

@ -1,33 +1,28 @@
'use client';
import { useRef, useState } from 'react';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import type { Recipient } from '@documenso/prisma/client';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import type { DocumentStatus, Recipient } from '@documenso/prisma/client';
import { PopoverHover } from '@documenso/ui/primitives/popover';
import { AvatarWithRecipient } from './avatar-with-recipient';
import { StackAvatar } from './stack-avatar';
import { StackAvatars } from './stack-avatars';
export type StackAvatarsWithTooltipProps = {
documentStatus: DocumentStatus;
recipients: Recipient[];
position?: 'top' | 'bottom';
children?: React.ReactNode;
};
export const StackAvatarsWithTooltip = ({
documentStatus,
recipients,
position,
children,
}: StackAvatarsWithTooltipProps) => {
const [open, setOpen] = useState(false);
const isControlled = useRef(false);
const isMouseOverTimeout = useRef<NodeJS.Timeout | null>(null);
const waitingRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) === 'waiting',
);
@ -44,105 +39,74 @@ export const StackAvatarsWithTooltip = ({
(recipient) => getRecipientType(recipient) === 'unsigned',
);
const onMouseEnter = () => {
if (isMouseOverTimeout.current) {
clearTimeout(isMouseOverTimeout.current);
}
if (isControlled.current) {
return;
}
isMouseOverTimeout.current = setTimeout(() => {
setOpen((o) => (!o ? true : o));
}, 200);
};
const onMouseLeave = () => {
if (isMouseOverTimeout.current) {
clearTimeout(isMouseOverTimeout.current);
}
if (isControlled.current) {
return;
}
setTimeout(() => {
setOpen((o) => (o ? false : o));
}, 200);
};
const onOpenChange = (newOpen: boolean) => {
isControlled.current = newOpen;
setOpen(newOpen);
};
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger
className="flex cursor-pointer"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{children || <StackAvatars recipients={recipients} />}
</PopoverTrigger>
<PopoverContent
side={position}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
className="flex flex-col gap-y-5 py-2"
>
{completedRecipients.length > 0 && (
<div>
<h1 className="text-base font-medium">Completed</h1>
{completedRecipients.map((recipient: Recipient) => (
<div key={recipient.id} className="my-1 flex items-center gap-2">
<StackAvatar
first={true}
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<div className="">
<p className="text-muted-foreground text-sm">{recipient.email}</p>
<p className="text-muted-foreground/70 text-xs">
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
</p>
</div>
<PopoverHover
trigger={children || <StackAvatars recipients={recipients} />}
contentProps={{
className: 'flex flex-col gap-y-5 py-2',
side: position,
}}
>
{completedRecipients.length > 0 && (
<div>
<h1 className="text-base font-medium">Completed</h1>
{completedRecipients.map((recipient: Recipient) => (
<div key={recipient.id} className="my-1 flex items-center gap-2">
<StackAvatar
first={true}
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<div className="">
<p className="text-muted-foreground text-sm">{recipient.email}</p>
<p className="text-muted-foreground/70 text-xs">
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
</p>
</div>
))}
</div>
)}
</div>
))}
</div>
)}
{waitingRecipients.length > 0 && (
<div>
<h1 className="text-base font-medium">Waiting</h1>
{waitingRecipients.map((recipient: Recipient) => (
<AvatarWithRecipient key={recipient.id} recipient={recipient} />
))}
</div>
)}
{waitingRecipients.length > 0 && (
<div>
<h1 className="text-base font-medium">Waiting</h1>
{waitingRecipients.map((recipient: Recipient) => (
<AvatarWithRecipient
key={recipient.id}
recipient={recipient}
documentStatus={documentStatus}
/>
))}
</div>
)}
{openedRecipients.length > 0 && (
<div>
<h1 className="text-base font-medium">Opened</h1>
{openedRecipients.map((recipient: Recipient) => (
<AvatarWithRecipient key={recipient.id} recipient={recipient} />
))}
</div>
)}
{openedRecipients.length > 0 && (
<div>
<h1 className="text-base font-medium">Opened</h1>
{openedRecipients.map((recipient: Recipient) => (
<AvatarWithRecipient
key={recipient.id}
recipient={recipient}
documentStatus={documentStatus}
/>
))}
</div>
)}
{uncompletedRecipients.length > 0 && (
<div>
<h1 className="text-base font-medium">Uncompleted</h1>
{uncompletedRecipients.map((recipient: Recipient) => (
<AvatarWithRecipient key={recipient.id} recipient={recipient} />
))}
</div>
)}
</PopoverContent>
</Popover>
{uncompletedRecipients.length > 0 && (
<div>
<h1 className="text-base font-medium">Uncompleted</h1>
{uncompletedRecipients.map((recipient: Recipient) => (
<AvatarWithRecipient
key={recipient.id}
recipient={recipient}
documentStatus={documentStatus}
/>
))}
</div>
)}
</PopoverHover>
);
};

View File

@ -5,7 +5,6 @@ import { useCallback, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Loader, Monitor, Moon, Sun } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { useTheme } from 'next-themes';
import { useHotkeys } from 'react-hotkeys-hook';
@ -18,7 +17,6 @@ import {
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
SKIP_QUERY_BATCH_META,
} from '@documenso/lib/constants/trpc';
import type { Document, Recipient } from '@documenso/prisma/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
import {
CommandDialog,
@ -71,7 +69,6 @@ export type CommandMenuProps = {
export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
const { setTheme } = useTheme();
const { data: session } = useSession();
const router = useRouter();
@ -93,17 +90,6 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
},
);
const isOwner = useCallback(
(document: Document) => document.userId === session?.user.id,
[session?.user.id],
);
const getSigningLink = useCallback(
(recipients: Recipient[]) =>
`/sign/${recipients.find((r) => r.email === session?.user.email)?.token}`,
[session?.user.email],
);
const searchResults = useMemo(() => {
if (!searchDocumentsData) {
return [];
@ -111,10 +97,10 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
return searchDocumentsData.map((document) => ({
label: document.title,
path: isOwner(document) ? `/documents/${document.id}` : getSigningLink(document.Recipient),
value: [document.id, document.title, ...document.Recipient.map((r) => r.email)].join(' '),
path: document.path,
value: document.value,
}));
}, [searchDocumentsData, isOwner, getSigningLink]);
}, [searchDocumentsData]);
const currentPage = pages[pages.length - 1];

View File

@ -1,5 +1,3 @@
'use client';
import type { HTMLAttributes } from 'react';
import { useEffect, useState } from 'react';
@ -12,8 +10,6 @@ import { getRootHref } from '@documenso/lib/utils/params';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { CommandMenu } from '../common/command-menu';
const navigationLinks = [
{
href: '/documents',
@ -25,13 +21,14 @@ const navigationLinks = [
},
];
export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
export type DesktopNavProps = HTMLAttributes<HTMLDivElement> & {
setIsCommandMenuOpen: (value: boolean) => void;
};
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
export const DesktopNav = ({ className, setIsCommandMenuOpen, ...props }: DesktopNavProps) => {
const pathname = usePathname();
const params = useParams();
const [open, setOpen] = useState(false);
const [modifierKey, setModifierKey] = useState(() => 'Ctrl');
const rootHref = getRootHref(params, { returnEmptyRootString: true });
@ -70,12 +67,10 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
))}
</div>
<CommandMenu open={open} onOpenChange={setOpen} />
<Button
variant="outline"
className="text-muted-foreground flex w-96 items-center justify-between rounded-lg"
onClick={() => setOpen((open) => !open)}
onClick={() => setIsCommandMenuOpen(true)}
>
<div className="flex items-center">
<Search className="mr-2 h-5 w-5" />

View File

@ -58,7 +58,7 @@ export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
<Logo className="h-6 w-auto" />
</Link>
<DesktopNav />
<DesktopNav setIsCommandMenuOpen={setIsCommandMenuOpen} />
<div className="flex gap-x-4 md:ml-8">
<MenuSwitcher user={user} teams={teams} />

View File

@ -3,6 +3,7 @@
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { motion } from 'framer-motion';
import { CheckCircle2, ChevronsUpDown, Plus, Settings2 } from 'lucide-react';
import { signOut } from 'next-auth/react';
@ -25,6 +26,8 @@ import {
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
const MotionLink = motion(Link);
export type MenuSwitcherProps = {
user: User;
teams: GetTeamsResponse;
@ -93,7 +96,7 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
<Button
data-testid="menu-switcher"
variant="none"
className="relative flex h-12 flex-row items-center px-2 py-2 ring-0 focus:outline-none focus-visible:border-0 focus-visible:ring-0 focus-visible:ring-transparent"
className="relative flex h-12 flex-row items-center px-0 py-2 ring-0 focus:outline-none focus-visible:border-0 focus-visible:ring-0 focus-visible:ring-transparent md:px-2"
>
<AvatarWithText
avatarFallback={formatAvatarFallback(selectedTeam?.name)}
@ -102,12 +105,13 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
rightSideComponent={
<ChevronsUpDown className="text-muted-foreground ml-auto h-4 w-4" />
}
textSectionClassName="hidden lg:flex"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className={cn('z-[60] ml-2 w-full md:ml-0', teams ? 'min-w-[20rem]' : 'min-w-[12rem]')}
className={cn('z-[60] ml-6 w-full md:ml-0', teams ? 'min-w-[20rem]' : 'min-w-[12rem]')}
align="end"
forceMount
>
@ -169,18 +173,43 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
<div className="custom-scrollbar max-h-[40vh] overflow-auto">
{teams.map((team) => (
<DropdownMenuItem asChild key={team.id}>
<Link href={formatRedirectUrlOnSwitch(team.url)}>
<MotionLink
initial="initial"
animate="initial"
whileHover="animate"
href={formatRedirectUrlOnSwitch(team.url)}
>
<AvatarWithText
avatarFallback={formatAvatarFallback(team.name)}
primaryText={team.name}
secondaryText={formatSecondaryAvatarText(team)}
secondaryText={
<div className="relative">
<motion.span
className="overflow-hidden"
variants={{
initial: { opacity: 1, translateY: 0 },
animate: { opacity: 0, translateY: '100%' },
}}
>
{formatSecondaryAvatarText(team)}
</motion.span>
<motion.span
className="absolute inset-0"
variants={{
initial: { opacity: 0, translateY: '100%' },
animate: { opacity: 1, translateY: 0 },
}}
>{`/t/${team.url}`}</motion.span>
</div>
}
rightSideComponent={
isPathTeamUrl(team.url) && (
<CheckCircle2 className="ml-auto fill-black text-white dark:fill-white dark:text-black" />
)
}
/>
</Link>
</MotionLink>
</DropdownMenuItem>
))}
</div>

View File

@ -46,7 +46,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
return (
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
<SheetContent className="flex w-full max-w-[400px] flex-col">
<SheetContent className="flex w-full max-w-[350px] flex-col">
<Link href="/" onClick={handleMenuItemClick}>
<Image
src={LogoImage}
@ -87,7 +87,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
</div>
<p className="text-muted-foreground text-sm">
© {new Date().getFullYear()} Documenso, Inc. All rights reserved.
© {new Date().getFullYear()} Documenso, Inc. <br /> All rights reserved.
</p>
</div>
</SheetContent>

View File

@ -0,0 +1,112 @@
'use client';
import { useState } from 'react';
import { P, match } from 'ts-pattern';
import {
DEFAULT_DOCUMENT_DATE_FORMAT,
convertToLocalSystemFormat,
} from '@documenso/lib/constants/date-formats';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import type { CompletedField } from '@documenso/lib/types/fields';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import type { DocumentMeta } from '@documenso/prisma/client';
import { FieldType } from '@documenso/prisma/client';
import { FieldRootContainer } from '@documenso/ui/components/field/field';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { PopoverHover } from '@documenso/ui/primitives/popover';
export type DocumentReadOnlyFieldsProps = {
fields: CompletedField[];
documentMeta?: DocumentMeta;
};
export const DocumentReadOnlyFields = ({ documentMeta, fields }: DocumentReadOnlyFieldsProps) => {
const [hiddenFieldIds, setHiddenFieldIds] = useState<Record<string, boolean>>({});
const handleHideField = (fieldId: string) => {
setHiddenFieldIds((prev) => ({ ...prev, [fieldId]: true }));
};
return (
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{fields.map(
(field) =>
!hiddenFieldIds[field.secondaryId] && (
<FieldRootContainer
field={field}
key={field.id}
cardClassName="border-gray-100/50 !shadow-none backdrop-blur-[1px] bg-background/90"
>
<div className="absolute -right-3 -top-3">
<PopoverHover
trigger={
<Avatar className="dark:border-border h-8 w-8 border-2 border-solid border-gray-200/50 transition-colors hover:border-gray-200">
<AvatarFallback className="bg-neutral-50 text-xs text-gray-400">
{extractInitials(field.Recipient.name || field.Recipient.email)}
</AvatarFallback>
</Avatar>
}
contentProps={{
className: 'flex w-fit flex-col py-2.5 text-sm',
}}
>
<p>
<span className="font-semibold">
{field.Recipient.name
? `${field.Recipient.name} (${field.Recipient.email})`
: field.Recipient.email}{' '}
</span>
inserted a {FRIENDLY_FIELD_TYPE[field.type].toLowerCase()}
</p>
<Button
variant="outline"
className="mt-2.5 h-6 text-xs focus:outline-none focus-visible:ring-0"
onClick={() => handleHideField(field.secondaryId)}
>
Hide field
</Button>
</PopoverHover>
</div>
<div className="text-muted-foreground break-all text-sm">
{match(field)
.with({ type: FieldType.SIGNATURE }, (field) =>
field.Signature?.signatureImageAsBase64 ? (
<img
src={field.Signature.signatureImageAsBase64}
alt="Signature"
className="h-full w-full object-contain dark:invert"
/>
) : (
<p className="font-signature text-muted-foreground text-lg duration-200 sm:text-xl md:text-2xl lg:text-3xl">
{field.Signature?.typedSignature}
</p>
),
)
.with(
{ type: P.union(FieldType.NAME, FieldType.TEXT, FieldType.EMAIL) },
() => field.customText,
)
.with({ type: FieldType.DATE }, () =>
convertToLocalSystemFormat(
field.customText,
documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE,
),
)
.with({ type: FieldType.FREE_SIGNATURE }, () => null)
.exhaustive()}
</div>
</FieldRootContainer>
),
)}
</ElementVisible>
);
};

View File

@ -28,7 +28,7 @@ import {
FormItem,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZDisable2FAForm = z.object({
@ -107,7 +107,15 @@ export const DisableAuthenticatorAppDialog = () => {
render={({ field }) => (
<FormItem>
<FormControl>
<Input {...field} placeholder="Token" />
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
{Array(6)
.fill(null)
.map((_, i) => (
<PinInputGroup key={i}>
<PinInputSlot index={i} />
</PinInputGroup>
))}
</PinInput>
</FormControl>
<FormMessage />
</FormItem>

View File

@ -30,7 +30,7 @@ import {
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { RecoveryCodeList } from './recovery-code-list';
@ -212,7 +212,15 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
<FormItem>
<FormLabel className="text-muted-foreground">Token</FormLabel>
<FormControl>
<Input {...field} type="text" value={field.value ?? ''} />
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
{Array(6)
.fill(null)
.map((_, i) => (
<PinInputGroup key={i}>
<PinInputSlot index={i} />
</PinInputGroup>
))}
</PinInput>
</FormControl>
<FormMessage />
</FormItem>

View File

@ -30,7 +30,7 @@ import {
FormItem,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
import { RecoveryCodeList } from './recovery-code-list';
@ -115,7 +115,15 @@ export const ViewRecoveryCodesDialog = () => {
render={({ field }) => (
<FormItem>
<FormControl>
<Input {...field} placeholder="Token" />
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
{Array(6)
.fill(null)
.map((_, i) => (
<PinInputGroup key={i}>
<PinInputSlot index={i} />
</PinInputGroup>
))}
</PinInput>
</FormControl>
<FormMessage />
</FormItem>

View File

@ -39,6 +39,7 @@ import {
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
import { useToast } from '@documenso/ui/primitives/use-toast';
const ERROR_MESSAGES: Partial<Record<keyof typeof ErrorCode, string>> = {
@ -406,9 +407,17 @@ export const SignInForm = ({
name="totpCode"
render={({ field }) => (
<FormItem>
<FormLabel>Authentication Token</FormLabel>
<FormLabel>Token</FormLabel>
<FormControl>
<Input type="text" {...field} />
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
{Array(6)
.fill(null)
.map((_, i) => (
<PinInputGroup key={i}>
<PinInputSlot index={i} />
</PinInputGroup>
))}
</PinInput>
</FormControl>
<FormMessage />
</FormItem>