Merge branch 'main' into feat/delete-archive

This commit is contained in:
Lucas Smith
2024-08-21 11:44:05 +10:00
committed by GitHub
124 changed files with 2129 additions and 772 deletions

View File

@ -1,6 +1,6 @@
{
"name": "@documenso/web",
"version": "1.6.0",
"version": "1.6.1-rc.1",
"private": true,
"license": "AGPL-3.0",
"scripts": {
@ -81,4 +81,4 @@
"next": "$next"
}
}
}
}

View File

@ -16,5 +16,7 @@ declare namespace NodeJS {
NEXT_PRIVATE_OIDC_WELL_KNOWN: string;
NEXT_PRIVATE_OIDC_CLIENT_ID: string;
NEXT_PRIVATE_OIDC_CLIENT_SECRET: string;
NEXT_PRIVATE_OIDC_ALLOW_SIGNUP?: string;
NEXT_PRIVATE_OIDC_SKIP_VERIFY?: string;
}
}

View File

@ -139,7 +139,7 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
documentStatus={document.status}
/>
<DownloadAuditLogButton documentId={document.id} />
<DownloadAuditLogButton teamId={team?.id} documentId={document.id} />
</div>
</div>

View File

@ -9,10 +9,15 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export type DownloadAuditLogButtonProps = {
className?: string;
teamId?: number;
documentId: number;
};
export const DownloadAuditLogButton = ({ className, documentId }: DownloadAuditLogButtonProps) => {
export const DownloadAuditLogButton = ({
className,
teamId,
documentId,
}: DownloadAuditLogButtonProps) => {
const { toast } = useToast();
const { mutateAsync: downloadAuditLogs, isLoading } =
@ -20,7 +25,7 @@ export const DownloadAuditLogButton = ({ className, documentId }: DownloadAuditL
const onDownloadAuditLogsClick = async () => {
try {
const { url } = await downloadAuditLogs({ documentId });
const { url } = await downloadAuditLogs({ teamId, documentId });
const iframe = Object.assign(document.createElement('iframe'), {
src: url,

View File

@ -4,6 +4,7 @@ import { useRouter } from 'next/navigation';
import { match } from 'ts-pattern';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { DocumentStatus } from '@documenso/prisma/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
@ -41,6 +42,7 @@ export const DeleteDocumentDialog = ({
const router = useRouter();
const { toast } = useToast();
const { refreshLimits } = useLimits();
const [inputValue, setInputValue] = useState('');
const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT);
@ -48,6 +50,7 @@ export const DeleteDocumentDialog = ({
const { mutateAsync: deleteDocument, isLoading } = trpcReact.document.deleteDocument.useMutation({
onSuccess: () => {
router.refresh();
void refreshLimits();
toast({
title: 'Document deleted',

View File

@ -36,7 +36,7 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
const { toast } = useToast();
const { quota, remaining } = useLimits();
const { quota, remaining, refreshLimits } = useLimits();
const [isLoading, setIsLoading] = useState(false);
@ -71,6 +71,8 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
teamId: team?.id,
});
void refreshLimits();
toast({
title: 'Document uploaded',
description: 'Your document has been uploaded successfully.',

View File

@ -2,7 +2,7 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useRouter, useSearchParams } from 'next/navigation';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { Field } from '@documenso/prisma/client';
@ -39,6 +39,7 @@ export const DirectTemplatePageView = ({
directTemplateToken,
}: TemplatesDirectPageViewProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const { toast } = useToast();
@ -82,8 +83,15 @@ export const DirectTemplatePageView = ({
const onSignDirectTemplateSubmit = async (fields: DirectTemplateLocalField[]) => {
try {
let directTemplateExternalId = searchParams?.get('externalId') || undefined;
if (directTemplateExternalId) {
directTemplateExternalId = decodeURIComponent(directTemplateExternalId);
}
const token = await createDocumentFromDirectTemplate({
directTemplateToken,
directTemplateExternalId,
directRecipientName: fullName,
directRecipientEmail: recipient.email,
templateUpdatedAt: template.updatedAt,

View File

@ -41,6 +41,7 @@ import { CheckboxField } from '~/app/(signing)/sign/[token]/checkbox-field';
import { DateField } from '~/app/(signing)/sign/[token]/date-field';
import { DropdownField } from '~/app/(signing)/sign/[token]/dropdown-field';
import { EmailField } from '~/app/(signing)/sign/[token]/email-field';
import { InitialsField } from '~/app/(signing)/sign/[token]/initials-field';
import { NameField } from '~/app/(signing)/sign/[token]/name-field';
import { NumberField } from '~/app/(signing)/sign/[token]/number-field';
import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider';
@ -182,6 +183,15 @@ export const SignDirectTemplateForm = ({
onUnsignField={onUnsignField}
/>
))
.with(FieldType.INITIALS, () => (
<InitialsField
key={field.id}
field={field}
recipient={directRecipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
))
.with(FieldType.NAME, () => (
<NameField
key={field.id}

View File

@ -13,6 +13,7 @@ import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-re
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 { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
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';
@ -25,6 +26,7 @@ import { truncateTitle } from '~/helpers/truncate-title';
import { SigningAuthPageView } from '../signing-auth-page';
import { ClaimAccount } from './claim-account';
import { DocumentPreviewButton } from './document-preview-button';
import { PollUntilDocumentCompleted } from './poll-until-document-completed';
export type CompletedSigningPageProps = {
params: {
@ -77,6 +79,9 @@ export default async function CompletedSigningPage({
}
const signatures = await getRecipientSignatures({ recipientId: recipient.id });
const isExistingUser = await getUserByEmail({ email: recipient.email })
.then((u) => !!u)
.catch(() => false);
const recipientName =
recipient.name ||
@ -85,7 +90,7 @@ export default async function CompletedSigningPage({
const sessionData = await getServerSession();
const isLoggedIn = !!sessionData?.user;
const canSignUp = !isLoggedIn && NEXT_PUBLIC_DISABLE_SIGNUP !== 'true';
const canSignUp = !isExistingUser && NEXT_PUBLIC_DISABLE_SIGNUP !== 'true';
return (
<div
@ -201,6 +206,8 @@ export default async function CompletedSigningPage({
</Link>
)}
</div>
<PollUntilDocumentCompleted document={document} />
</div>
);
}

View File

@ -0,0 +1,32 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import type { Document } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client';
export type PollUntilDocumentCompletedProps = {
document: Pick<Document, 'id' | 'status' | 'deletedAt'>;
};
export const PollUntilDocumentCompleted = ({ document }: PollUntilDocumentCompletedProps) => {
const router = useRouter();
useEffect(() => {
if (document.status === DocumentStatus.COMPLETED) {
return;
}
const interval = setInterval(() => {
if (window.document.hasFocus()) {
router.refresh();
}
}, 5000);
return () => clearInterval(interval);
}, [router, document.status]);
return <></>;
};

View File

@ -0,0 +1,140 @@
'use client';
import { useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredSigningContext } from './provider';
import { SigningFieldContainer } from './signing-field-container';
export type InitialsFieldProps = {
field: FieldWithSignature;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
export const InitialsField = ({
field,
recipient,
onSignField,
onUnsignField,
}: InitialsFieldProps) => {
const router = useRouter();
const { toast } = useToast();
const { fullName } = useRequiredSigningContext();
const initials = extractInitials(fullName);
const [isPending, startTransition] = useTransition();
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const {
mutateAsync: removeSignedFieldWithToken,
isLoading: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
const onSign = async (authOptions?: TRecipientActionAuth) => {
try {
const value = initials ?? '';
const payload: TSignFieldWithTokenMutationSchema = {
token: recipient.token,
fieldId: field.id,
value,
isBase64: false,
authOptions,
};
if (onSignField) {
await onSignField(payload);
return;
}
await signFieldWithToken(payload);
startTransition(() => router.refresh());
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.UNAUTHORIZED) {
throw error;
}
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while signing the document.',
variant: 'destructive',
});
}
};
const onRemove = async () => {
try {
const payload: TRemovedSignedFieldWithTokenMutationSchema = {
token: recipient.token,
fieldId: field.id,
};
if (onUnsignField) {
await onUnsignField(payload);
return;
}
await removeSignedFieldWithToken(payload);
startTransition(() => router.refresh());
} catch (err) {
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while removing the signature.',
variant: 'destructive',
});
}
};
return (
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Initials">
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}
{!field.inserted && (
<p className="group-hover:text-primary text-muted-foreground duration-200 group-hover:text-yellow-300">
Initials
</p>
)}
{field.inserted && (
<p className="text-muted-foreground dark:text-background/80 truncate duration-200">
{field.customText}
</p>
)}
</SigningFieldContainer>
);
};

View File

@ -216,7 +216,10 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
let fieldDisplayName = 'Number';
if (parsedFieldMeta?.label) {
fieldDisplayName = parsedFieldMeta.label.length > 10 ? parsedFieldMeta.label.substring(0, 10) + '...' : parsedFieldMeta.label;
fieldDisplayName =
parsedFieldMeta.label.length > 10
? parsedFieldMeta.label.substring(0, 10) + '...'
: parsedFieldMeta.label;
}
const userInputHasErrors = Object.values(errors).some((error) => error.length > 0);
@ -246,7 +249,7 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
)}
>
<span className="flex items-center justify-center gap-x-1 text-sm">
<Hash className='h-4 w-4' /> {fieldDisplayName}
<Hash className="h-4 w-4" /> {fieldDisplayName}
</span>
</p>
)}

View File

@ -172,15 +172,15 @@ export const RadioField = ({ field, recipient, onSignField, onUnsignField }: Rad
<RadioGroup>
{values?.map((item, index) => (
<div key={index} className="flex items-center gap-x-1.5">
<RadioGroupItem
className=""
value={item.value}
id={`option-${index}`}
checked={item.value === field.customText}
/>
<Label htmlFor={`option-${index}`}>
{item.value.includes('empty-value-') ? '' : item.value}
</Label>
<RadioGroupItem
className=""
value={item.value}
id={`option-${index}`}
checked={item.value === field.customText}
/>
<Label htmlFor={`option-${index}`}>
{item.value.includes('empty-value-') ? '' : item.value}
</Label>
</div>
))}
</RadioGroup>

View File

@ -190,7 +190,7 @@ export const SignatureField = ({
)}
{state === 'empty' && (
<p className="group-hover:text-primary font-signature text-muted-foreground duration-200 group-hover:text-yellow-300 text-xl">
<p className="group-hover:text-primary font-signature text-muted-foreground text-xl duration-200 group-hover:text-yellow-300">
Signature
</p>
)}

View File

@ -39,7 +39,16 @@ export type SignatureFieldProps = {
*/
onSign?: (documentAuthValue?: TRecipientActionAuth) => Promise<void> | void;
onRemove?: (fieldType?: string) => Promise<void> | void;
type?: 'Date' | 'Email' | 'Name' | 'Signature' | 'Radio' | 'Dropdown' | 'Number' | 'Checkbox';
type?:
| 'Date'
| 'Initials'
| 'Email'
| 'Name'
| 'Signature'
| 'Radio'
| 'Dropdown'
| 'Number'
| 'Checkbox';
tooltipText?: string | null;
};

View File

@ -26,6 +26,7 @@ import { DateField } from './date-field';
import { DropdownField } from './dropdown-field';
import { EmailField } from './email-field';
import { SigningForm } from './form';
import { InitialsField } from './initials-field';
import { NameField } from './name-field';
import { NumberField } from './number-field';
import { RadioField } from './radio-field';
@ -101,6 +102,9 @@ export const SigningPageView = ({
.with(FieldType.SIGNATURE, () => (
<SignatureField key={field.id} field={field} recipient={recipient} />
))
.with(FieldType.INITIALS, () => (
<InitialsField key={field.id} field={field} recipient={recipient} />
))
.with(FieldType.NAME, () => (
<NameField key={field.id} field={field} recipient={recipient} />
))

View File

@ -279,11 +279,13 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
/>
</div>
{parsedFieldMeta?.characterLimit !== undefined && parsedFieldMeta?.characterLimit > 0 && !userInputHasErrors && (
<div className="text-muted-foreground text-sm">
{charactersRemaining} characters remaining
</div>
)}
{parsedFieldMeta?.characterLimit !== undefined &&
parsedFieldMeta?.characterLimit > 0 &&
!userInputHasErrors && (
<div className="text-muted-foreground text-sm">
{charactersRemaining} characters remaining
</div>
)}
{userInputHasErrors && (
<div className="text-sm">

View File

@ -17,6 +17,10 @@ export default function UnauthenticatedLayout({ children }: UnauthenticatedLayou
src={backgroundPattern}
alt="background pattern"
className="dark:brightness-95 dark:contrast-[70%] dark:invert dark:sepia"
style={{
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
}}
/>
</div>

View File

@ -4,7 +4,11 @@ import { redirect } from 'next/navigation';
import { env } from 'next-runtime-env';
import { IS_GOOGLE_SSO_ENABLED, IS_OIDC_SSO_ENABLED } from '@documenso/lib/constants/auth';
import {
IS_GOOGLE_SSO_ENABLED,
IS_OIDC_SSO_ENABLED,
OIDC_PROVIDER_LABEL,
} from '@documenso/lib/constants/auth';
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
import { SignInForm } from '~/components/forms/signin';
@ -43,6 +47,7 @@ export default function SignInPage({ searchParams }: SignInPageProps) {
initialEmail={email || undefined}
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
isOIDCSSOEnabled={IS_OIDC_SSO_ENABLED}
oidcProviderLabel={OIDC_PROVIDER_LABEL}
/>
{NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (

View File

@ -196,6 +196,7 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
}
avatarFallback={formatAvatarFallback(team.name)}
primaryText={team.name}
textSectionClassName="w-[200px]"
secondaryText={
<div className="relative w-full">
<motion.span

View File

@ -98,6 +98,7 @@ export const DocumentReadOnlyFields = ({ documentMeta, fields }: DocumentReadOnl
{
type: P.union(
FieldType.NAME,
FieldType.INITIALS,
FieldType.EMAIL,
FieldType.NUMBER,
FieldType.RADIO,

View File

@ -71,6 +71,7 @@ export type SignInFormProps = {
initialEmail?: string;
isGoogleSSOEnabled?: boolean;
isOIDCSSOEnabled?: boolean;
oidcProviderLabel?: string;
};
export const SignInForm = ({
@ -78,6 +79,7 @@ export const SignInForm = ({
initialEmail,
isGoogleSSOEnabled,
isOIDCSSOEnabled,
oidcProviderLabel,
}: SignInFormProps) => {
const { toast } = useToast();
const { getFlag } = useFeatureFlags();
@ -369,7 +371,7 @@ export const SignInForm = ({
onClick={onSignInWithOIDCClick}
>
<FaIdCardClip className="mr-2 h-5 w-5" />
OIDC
{oidcProviderLabel || 'OIDC'}
</Button>
)}

View File

@ -29,6 +29,10 @@ export default function NotFoundPartial({ children }: NotFoundPartialProps) {
src={backgroundPattern}
alt="background pattern"
className="-mr-[50vw] -mt-[15vh] h-full scale-100 object-cover dark:contrast-[70%] dark:invert dark:sepia md:scale-100 lg:scale-[100%]"
style={{
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
}}
priority
/>
</motion.div>

View File

@ -6,6 +6,7 @@ import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-cu
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { slugify } from '@documenso/lib/utils/slugify';
import { prisma } from '@documenso/prisma';
import { UserSecurityAuditLogType } from '@documenso/prisma/client';
@ -60,13 +61,41 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
},
});
},
linkAccount: async ({ user }) => {
linkAccount: async ({ user, account, profile }) => {
const userId = typeof user.id === 'string' ? parseInt(user.id) : user.id;
if (isNaN(userId)) {
if (Number.isNaN(userId)) {
return;
}
// If the user is linking an OIDC account and the email verified date is set then update it in the db.
if (account.provider === 'oidc' && profile.emailVerified !== null) {
await prisma.user.update({
where: { id: userId },
data: {
emailVerified: profile.emailVerified,
},
});
}
// auto set public profile name
if (account.provider === 'oidc' && user.name && 'url' in user && !user.url) {
let counter = 1;
let url = slugify(user.name);
while (await prisma.user.findFirst({ where: { url } })) {
url = `${slugify(user.name)}-${counter}`;
counter++;
}
await prisma.user.update({
where: { id: userId },
data: {
url,
},
});
}
await prisma.userSecurityAuditLog.create({
data: {
userId,