chore: merged main

This commit is contained in:
Catalin Pit
2024-01-25 10:53:05 +02:00
21 changed files with 265 additions and 30 deletions

View File

@ -4,8 +4,10 @@ NEXTAUTH_SECRET="secret"
# [[CRYPTO]]
# Application Key for symmetric encryption and decryption
# This should be a random string of at least 32 characters
NEXT_PRIVATE_ENCRYPTION_KEY="CAFEBABE"
# REQUIRED: This should be a random string of at least 32 characters
NEXT_PRIVATE_ENCRYPTION_KEY=""
# REQUIRED: This should be a random string of at least 32 characters
NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY=""
# [[AUTH OPTIONAL]]
NEXT_PRIVATE_GOOGLE_CLIENT_ID=""

View File

@ -47,6 +47,14 @@ export const TEAM_MEMBERS = [
engagement: 'Full-Time',
joinDate: 'October 9th, 2023',
},
{
name: 'Adithya Krishna',
role: 'Software Engineer - II',
salary: '-',
location: 'India',
engagement: 'Full-Time',
joinDate: 'December 1st, 2023',
},
];
export const FUNDING_RAISED = [

View File

@ -1,3 +1,4 @@
import { IDENTITY_PROVIDER_NAME } from '@documenso/lib/constants/auth';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { AuthenticatorApp } from '~/components/forms/2fa/authenticator-app';
@ -17,28 +18,43 @@ export default async function SecuritySettingsPage() {
<hr className="my-4" />
<PasswordForm user={user} className="max-w-xl" />
{user.identityProvider === 'DOCUMENSO' ? (
<div>
<PasswordForm user={user} className="max-w-xl" />
<hr className="mb-4 mt-8" />
<hr className="mb-4 mt-8" />
<h4 className="text-lg font-medium">Two Factor Authentication</h4>
<h4 className="text-lg font-medium">Two Factor Authentication</h4>
<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>
<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>
<div className="mt-4 max-w-xl">
<h5 className="font-medium">Two-factor methods</h5>
<AuthenticatorApp isTwoFactorEnabled={user.twoFactorEnabled} />
</div>
<AuthenticatorApp isTwoFactorEnabled={user.twoFactorEnabled} />
</div>
{user.twoFactorEnabled && (
<div className="mt-4 max-w-xl">
<h5 className="font-medium">Recovery methods</h5>
{user.twoFactorEnabled && (
<div className="mt-4 max-w-xl">
<h5 className="font-medium">Recovery methods</h5>
<RecoveryCodes isTwoFactorEnabled={user.twoFactorEnabled} />
<RecoveryCodes isTwoFactorEnabled={user.twoFactorEnabled} />
</div>
)}
</div>
) : (
<div>
<h4 className="text-lg font-medium">
Your account is managed by {IDENTITY_PROVIDER_NAME[user.identityProvider]}
</h4>
<p className="text-muted-foreground mt-2 text-sm">
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>
)}
</div>

View File

@ -3,6 +3,8 @@ import { redirect } from 'next/navigation';
import { env } from 'next-runtime-env';
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
import { SignUpForm } from '~/components/forms/signup';
export default function SignUpPage() {
@ -21,7 +23,7 @@ export default function SignUpPage() {
signing is within your grasp.
</p>
<SignUpForm className="mt-4" />
<SignUpForm className="mt-4" isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED} />
<p className="text-muted-foreground mt-6 text-center text-sm">
Already have an account?{' '}

View File

@ -252,7 +252,11 @@ const ThemeCommands = ({ setTheme }: { setTheme: (_theme: string) => void }) =>
);
return THEMES.map((theme) => (
<CommandItem key={theme.theme} onSelect={() => setTheme(theme.theme)}>
<CommandItem
key={theme.theme}
onSelect={() => setTheme(theme.theme)}
className="mx-2 first:mt-2 last:mb-2"
>
<theme.icon className="mr-2" />
{theme.label}
</CommandItem>

View File

@ -4,7 +4,7 @@ import { useEffect, useState } from 'react';
import { AlertTriangle } from 'lucide-react';
import { ONE_SECOND } from '@documenso/lib/constants/time';
import { ONE_DAY, ONE_SECOND } from '@documenso/lib/constants/time';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
@ -65,7 +65,7 @@ export const VerifyEmailBanner = ({ email }: VerifyEmailBannerProps) => {
if (emailVerificationDialogLastShown) {
const lastShownTimestamp = parseInt(emailVerificationDialogLastShown);
if (Date.now() - lastShownTimestamp < 24 * 60 * 60 * 1000) {
if (Date.now() - lastShownTimestamp < ONE_DAY) {
return;
}
}

View File

@ -112,7 +112,6 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
</Label>
<Input id="email" type="email" className="bg-muted mt-2" value={user.email} disabled />
</div>
<FormField
control={form.control}
name="signature"
@ -122,7 +121,10 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
<FormControl>
<SignaturePad
className="h-44 w-full"
containerClassName="rounded-lg border bg-background"
containerClassName={cn(
'rounded-lg border bg-background',
isSubmitting ? 'pointer-events-none opacity-50' : null,
)}
defaultValue={user.signature ?? undefined}
onChange={(v) => onChange(v ?? '')}
/>

View File

@ -3,6 +3,7 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { signIn } from 'next-auth/react';
import { useForm } from 'react-hook-form';
import { FcGoogle } from 'react-icons/fc';
import { z } from 'zod';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
@ -23,6 +24,8 @@ import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast';
const SIGN_UP_REDIRECT_PATH = '/documents';
export const ZSignUpFormSchema = z.object({
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
email: z.string().email().min(1),
@ -37,9 +40,10 @@ export type TSignUpFormSchema = z.infer<typeof ZSignUpFormSchema>;
export type SignUpFormProps = {
className?: string;
isGoogleSSOEnabled?: boolean;
};
export const SignUpForm = ({ className }: SignUpFormProps) => {
export const SignUpForm = ({ className, isGoogleSSOEnabled }: SignUpFormProps) => {
const { toast } = useToast();
const analytics = useAnalytics();
@ -64,7 +68,7 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
await signIn('credentials', {
email,
password,
callbackUrl: '/',
callbackUrl: SIGN_UP_REDIRECT_PATH,
});
analytics.capture('App: User Sign Up', {
@ -89,6 +93,19 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
}
};
const onSignUpWithGoogleClick = async () => {
try {
await signIn('google', { callbackUrl: SIGN_UP_REDIRECT_PATH });
} catch (err) {
toast({
title: 'An unknown error occurred',
description:
'We encountered an unknown error while attempting to sign you Up. Please try again later.',
variant: 'destructive',
});
}
};
return (
<Form {...form}>
<form
@ -166,6 +183,28 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
>
{isSubmitting ? 'Signing up...' : 'Sign Up'}
</Button>
{isGoogleSSOEnabled && (
<>
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<div className="bg-border h-px flex-1" />
<span className="text-muted-foreground bg-transparent">Or</span>
<div className="bg-border h-px flex-1" />
</div>
<Button
type="button"
size="lg"
variant={'outline'}
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignUpWithGoogleClick}
>
<FcGoogle className="mr-2 h-5 w-5" />
Sign Up with Google
</Button>
</>
)}
</form>
</Form>
);

View File

@ -10,7 +10,7 @@ export const TemplateFooter = ({ isDocument = true }: TemplateFooterProps) => {
{isDocument && (
<Text className="my-4 text-base text-slate-400">
This document was sent using{' '}
<Link className="text-[#7AC455]" href="https://documenso.com">
<Link className="text-[#7AC455]" href="https://documen.so/mail-footer">
Documenso.
</Link>
</Text>

View File

@ -1,5 +1,12 @@
import { IdentityProvider } from '@documenso/prisma/client';
export const SALT_ROUNDS = 12;
export const IDENTITY_PROVIDER_NAME: { [key in IdentityProvider]: string } = {
[IdentityProvider.DOCUMENSO]: 'Documenso',
[IdentityProvider.GOOGLE]: 'Google',
};
export const IS_GOOGLE_SSO_ENABLED = Boolean(
process.env.NEXT_PRIVATE_GOOGLE_CLIENT_ID && process.env.NEXT_PRIVATE_GOOGLE_CLIENT_SECRET,
);

View File

@ -1 +1,23 @@
export const DOCUMENSO_ENCRYPTION_KEY = process.env.NEXT_PRIVATE_ENCRYPTION_KEY;
export const DOCUMENSO_ENCRYPTION_SECONDARY_KEY = process.env.NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY;
if (!DOCUMENSO_ENCRYPTION_KEY || !DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY or DOCUMENSO_ENCRYPTION_SECONDARY_KEY keys');
}
if (DOCUMENSO_ENCRYPTION_KEY === DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
throw new Error(
'DOCUMENSO_ENCRYPTION_KEY and DOCUMENSO_ENCRYPTION_SECONDARY_KEY cannot be equal',
);
}
if (DOCUMENSO_ENCRYPTION_KEY === 'CAFEBABE') {
console.warn('*********************************************************************');
console.warn('*');
console.warn('*');
console.warn('Please change the encryption key from the default value of "CAFEBABE"');
console.warn('*');
console.warn('*');
console.warn('*********************************************************************');
}

View File

@ -10,6 +10,7 @@ import GoogleProvider from 'next-auth/providers/google';
import { env } from 'next-runtime-env';
import { prisma } from '@documenso/prisma';
import { IdentityProvider } from '@documenso/prisma/client';
import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble';
import { validateTwoFactorAuthentication } from '../server-only/2fa/validate-2fa';
@ -96,7 +97,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
}),
],
callbacks: {
async jwt({ token, user }) {
async jwt({ token, user, trigger, account }) {
const merged = {
...token,
...user,
@ -141,6 +142,22 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
merged.emailVerified = user.emailVerified?.toISOString() ?? null;
}
if ((trigger === 'signIn' || trigger === 'signUp') && account?.provider === 'google') {
merged.emailVerified = user?.emailVerified
? new Date(user.emailVerified).toISOString()
: new Date().toISOString();
await prisma.user.update({
where: {
id: Number(merged.id),
},
data: {
emailVerified: merged.emailVerified,
identityProvider: IdentityProvider.GOOGLE,
},
});
}
return {
id: merged.id,
name: merged.name,

View File

@ -0,0 +1,33 @@
import { DOCUMENSO_ENCRYPTION_SECONDARY_KEY } from '@documenso/lib/constants/crypto';
import { ZEncryptedDataSchema } from '@documenso/lib/server-only/crypto/encrypt';
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
/**
* Decrypt the passed in data. This uses the secondary encrypt key for miscellaneous data.
*
* @param encryptedData The data encrypted with the `encryptSecondaryData` function.
* @returns The decrypted value, or `null` if the data is invalid or expired.
*/
export const decryptSecondaryData = (encryptedData: string): string | null => {
if (!DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
throw new Error('Missing encryption key');
}
const decryptedBufferValue = symmetricDecrypt({
key: DOCUMENSO_ENCRYPTION_SECONDARY_KEY,
data: encryptedData,
});
const decryptedValue = Buffer.from(decryptedBufferValue).toString('utf-8');
const result = ZEncryptedDataSchema.safeParse(JSON.parse(decryptedValue));
if (!result.success) {
return null;
}
if (result.data.expiresAt !== undefined && result.data.expiresAt < Date.now()) {
return null;
}
return result.data.data;
};

View File

@ -0,0 +1,42 @@
import { z } from 'zod';
import { DOCUMENSO_ENCRYPTION_SECONDARY_KEY } from '@documenso/lib/constants/crypto';
import { symmetricEncrypt } from '@documenso/lib/universal/crypto';
import type { TEncryptSecondaryDataMutationSchema } from '@documenso/trpc/server/crypto/schema';
export const ZEncryptedDataSchema = z.object({
data: z.string(),
expiresAt: z.number().optional(),
});
export type EncryptDataOptions = {
data: string;
/**
* When the data should no longer be allowed to be decrypted.
*
* Leave this empty to never expire the data.
*/
expiresAt?: number;
};
/**
* Encrypt the passed in data. This uses the secondary encrypt key for miscellaneous data.
*
* @returns The encrypted data.
*/
export const encryptSecondaryData = ({ data, expiresAt }: TEncryptSecondaryDataMutationSchema) => {
if (!DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
throw new Error('Missing encryption key');
}
const dataToEncrypt: z.infer<typeof ZEncryptedDataSchema> = {
data,
expiresAt,
};
return symmetricEncrypt({
key: DOCUMENSO_ENCRYPTION_SECONDARY_KEY,
data: JSON.stringify(dataToEncrypt),
});
};

View File

@ -42,6 +42,11 @@ export const getStats = async ({ user }: GetStatsInput) => {
_all: true,
},
where: {
User: {
email: {
not: user.email,
},
},
OR: [
{
status: ExtendedDocumentStatus.PENDING,

View File

@ -0,0 +1,17 @@
import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt';
import { procedure, router } from '../trpc';
import { ZEncryptSecondaryDataMutationSchema } from './schema';
export const cryptoRouter = router({
encryptSecondaryData: procedure
.input(ZEncryptSecondaryDataMutationSchema)
.mutation(({ input }) => {
try {
return encryptSecondaryData(input);
} catch {
// Never leak errors for crypto.
throw new Error('Failed to encrypt data');
}
}),
});

View File

@ -0,0 +1,15 @@
import { z } from 'zod';
export const ZEncryptSecondaryDataMutationSchema = z.object({
data: z.string(),
expiresAt: z.number().optional(),
});
export const ZDecryptDataMutationSchema = z.object({
data: z.string(),
});
export type TEncryptSecondaryDataMutationSchema = z.infer<
typeof ZEncryptSecondaryDataMutationSchema
>;
export type TDecryptDataMutationSchema = z.infer<typeof ZDecryptDataMutationSchema>;

View File

@ -1,5 +1,6 @@
import { adminRouter } from './admin-router/router';
import { authRouter } from './auth-router/router';
import { cryptoRouter } from './crypto/router';
import { documentRouter } from './document-router/router';
import { fieldRouter } from './field-router/router';
import { profileRouter } from './profile-router/router';
@ -12,6 +13,7 @@ import { twoFactorAuthenticationRouter } from './two-factor-authentication-route
export const appRouter = router({
auth: authRouter,
crypto: cryptoRouter,
profile: profileRouter,
document: documentRouter,
field: fieldRouter,

View File

@ -8,6 +8,7 @@ declare namespace NodeJS {
NEXT_PRIVATE_DATABASE_URL: string;
NEXT_PRIVATE_ENCRYPTION_KEY: string;
NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY: string;
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;

View File

@ -35,7 +35,7 @@ const CommandDialog = ({ children, commandProps, ...props }: CommandDialogProps)
<DialogContent className="overflow-hidden p-0 shadow-2xl">
<Command
{...commandProps}
className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-4 [&_[cmdk-item]_svg]:w-4"
className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-0 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-4 [&_[cmdk-item]_svg]:w-4"
>
{children}
</Command>
@ -92,7 +92,7 @@ const CommandGroup = React.forwardRef<
<CommandPrimitive.Group
ref={ref}
className={cn(
'text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium',
'text-foreground [&_[cmdk-group-heading]]:text-muted-foreground mx-2 overflow-hidden border-b pb-2 last:border-0 [&_[cmdk-group-heading]]:mt-2 [&_[cmdk-group-heading]]:px-0 [&_[cmdk-group-heading]]:py-2 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-normal [&_[cmdk-group-heading]]:opacity-50 ',
className,
)}
{...props}
@ -121,7 +121,7 @@ const CommandItem = React.forwardRef<
<CommandPrimitive.Item
ref={ref}
className={cn(
'aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'aria-selected:bg-accent aria-selected:text-accent-foreground relative -mx-2 -my-1 flex cursor-default select-none items-center rounded-lg px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}

View File

@ -34,6 +34,7 @@
"globalEnv": [
"APP_VERSION",
"NEXT_PRIVATE_ENCRYPTION_KEY",
"NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY",
"NEXTAUTH_URL",
"NEXTAUTH_SECRET",
"NEXT_PUBLIC_PROJECT",