Merge branch 'feat/refresh' into fix/467-bugsafari-only-unable-to-copy-document-sharing-link

This commit is contained in:
David Nguyen
2023-10-16 12:45:05 +11:00
49 changed files with 2032 additions and 73 deletions

4
packages/app-tests/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
/test-results/
/playwright-report/
/playwright/.cache/

View File

@ -0,0 +1,55 @@
import { type Page, expect, test } from '@playwright/test';
import { deleteUser } from '@documenso/lib/server-only/user/delete-user';
test.use({ storageState: { cookies: [], origins: [] } });
/*
Using them sequentially so the 2nd test
uses the details from the 1st (registration) test
*/
test.describe.configure({ mode: 'serial' });
const username = process.env.E2E_TEST_AUTHENTICATE_USERNAME;
const email = process.env.E2E_TEST_AUTHENTICATE_USER_EMAIL;
const password = process.env.E2E_TEST_AUTHENTICATE_USER_PASSWORD;
test('user can sign up with email and password', async ({ page }: { page: Page }) => {
await page.goto('/signup');
await page.getByLabel('Name').fill(username);
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password', { exact: true }).fill(password);
const canvas = page.locator('canvas');
const box = await canvas.boundingBox();
if (box) {
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4);
await page.mouse.up();
}
await page.getByRole('button', { name: 'Sign Up' }).click();
await page.waitForURL('/documents');
await expect(page).toHaveURL('/documents');
});
test('user can login with user and password', async ({ page }: { page: Page }) => {
await page.goto('/signin');
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password', { exact: true }).fill(password);
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('/documents');
await expect(page).toHaveURL('/documents');
});
test.afterAll('Teardown', async () => {
try {
await deleteUser({ email });
} catch (e) {
throw new Error(`Error deleting user: ${e}`);
}
});

View File

@ -0,0 +1,21 @@
{
"name": "@documenso/app-tests",
"version": "1.0.0",
"license": "to-update",
"description": "",
"main": "index.js",
"scripts": {
"test:dev": "playwright test",
"test:e2e": "start-server-and-test \"(cd ../../apps/web && npm run start)\" http://localhost:3000 \"playwright test\""
},
"keywords": [],
"author": "",
"devDependencies": {
"@playwright/test": "^1.18.1",
"@types/node": "^20.8.2",
"@documenso/web": "*"
},
"dependencies": {
"start-server-and-test": "^2.0.1"
}
}

View File

@ -0,0 +1,77 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './e2e',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: 'http://localhost:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
// {
// name: 'firefox',
// use: { ...devices['Desktop Firefox'] },
// },
// {
// name: 'webkit',
// use: { ...devices['Desktop Safari'] },
// },
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Run your local dev server before starting the tests */
// webServer: {
// command: 'npm run start',
// url: 'http://127.0.0.1:3000',
// reuseExistingServer: !process.env.CI,
// },
});

View File

@ -0,0 +1,31 @@
'use server';
import { stripe } from '@documenso/lib/server-only/stripe';
export type GetCheckoutSessionOptions = {
customerId: string;
priceId: string;
returnUrl: string;
};
export const getCheckoutSession = async ({
customerId,
priceId,
returnUrl,
}: GetCheckoutSessionOptions) => {
'use server';
const session = await stripe.checkout.sessions.create({
customer: customerId,
line_items: [
{
price: priceId,
quantity: 1,
},
],
success_url: `${returnUrl}?success=true`,
cancel_url: `${returnUrl}?canceled=true`,
});
return session.url;
};

View File

@ -0,0 +1,19 @@
import { stripe } from '@documenso/lib/server-only/stripe';
export const getStripeCustomerByEmail = async (email: string) => {
const foundStripeCustomers = await stripe.customers.list({
email,
});
return foundStripeCustomers.data[0] ?? null;
};
export const getStripeCustomerById = async (stripeCustomerId: string) => {
try {
const stripeCustomer = await stripe.customers.retrieve(stripeCustomerId);
return !stripeCustomer.deleted ? stripeCustomer : null;
} catch {
return null;
}
};

View File

@ -0,0 +1,49 @@
import Stripe from 'stripe';
import { stripe } from '@documenso/lib/server-only/stripe';
// Utility type to handle usage of the `expand` option.
type PriceWithProduct = Stripe.Price & { product: Stripe.Product };
export type PriceIntervals = Record<Stripe.Price.Recurring.Interval, PriceWithProduct[]>;
export const getPricesByInterval = async () => {
let { data: prices } = await stripe.prices.search({
query: `active:'true' type:'recurring'`,
expand: ['data.product'],
limit: 100,
});
prices = prices.filter((price) => {
// We use `expand` to get the product, but it's not typed as part of the Price type.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const product = price.product as Stripe.Product;
// Filter out prices for products that are not active.
return product.active;
});
const intervals: PriceIntervals = {
day: [],
week: [],
month: [],
year: [],
};
// Add each price to the correct interval.
for (const price of prices) {
if (price.recurring?.interval) {
// We use `expand` to get the product, but it's not typed as part of the Price type.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
intervals[price.recurring.interval].push(price as PriceWithProduct);
}
}
// Order all prices by unit_amount.
intervals.day.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
intervals.week.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
intervals.month.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
intervals.year.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
return intervals;
};

View File

@ -0,0 +1,55 @@
import { prisma } from '@documenso/prisma';
import { Prisma } from '@documenso/prisma/client';
export interface FindDocumentsOptions {
term?: string;
page?: number;
perPage?: number;
}
export const findDocuments = async ({ term, page = 1, perPage = 10 }: FindDocumentsOptions) => {
const termFilters: Prisma.DocumentWhereInput | undefined = !term
? undefined
: {
title: {
contains: term,
mode: 'insensitive',
},
};
const [data, count] = await Promise.all([
prisma.document.findMany({
where: {
...termFilters,
},
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
createdAt: 'desc',
},
include: {
User: {
select: {
id: true,
name: true,
email: true,
},
},
Recipient: true,
},
}),
prisma.document.count({
where: {
...termFilters,
},
}),
]);
return {
data,
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
};
};

View File

@ -0,0 +1,13 @@
import { prisma } from '@documenso/prisma';
export const findSubscriptions = async () => {
return prisma.subscription.findMany({
select: {
id: true,
status: true,
createdAt: true,
periodEnd: true,
userId: true,
},
});
};

View File

@ -0,0 +1,28 @@
import { prisma } from '@documenso/prisma';
import { Role } from '@documenso/prisma/client';
export type UpdateUserOptions = {
id: number;
name: string | null | undefined;
email: string | undefined;
roles: Role[] | undefined;
};
export const updateUser = async ({ id, name, email, roles }: UpdateUserOptions) => {
await prisma.user.findFirstOrThrow({
where: {
id,
},
});
return await prisma.user.update({
where: {
id,
},
data: {
name,
email,
roles,
},
});
};

View File

@ -1,3 +1,4 @@
/// <reference types="./stripe.d.ts" />
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.NEXT_PRIVATE_STRIPE_API_KEY ?? '', {

View File

@ -0,0 +1,7 @@
declare module 'stripe' {
namespace Stripe {
interface Product {
features?: Array<{ name: string }>;
}
}
}

View File

@ -0,0 +1,25 @@
import { prisma } from '@documenso/prisma';
export type DeleteUserOptions = {
email: string;
};
export const deleteUser = async ({ email }: DeleteUserOptions) => {
const user = await prisma.user.findFirst({
where: {
email: {
contains: email,
},
},
});
if (!user) {
throw new Error(`User with email ${email} not found`);
}
return await prisma.user.delete({
where: {
id: user.id,
},
});
};

View File

@ -0,0 +1,57 @@
import { prisma } from '@documenso/prisma';
import { Prisma } from '@documenso/prisma/client';
type GetAllUsersProps = {
username: string;
email: string;
page: number;
perPage: number;
};
export const findUsers = async ({
username = '',
email = '',
page = 1,
perPage = 10,
}: GetAllUsersProps) => {
const whereClause = Prisma.validator<Prisma.UserWhereInput>()({
OR: [
{
name: {
contains: username,
mode: 'insensitive',
},
},
{
email: {
contains: email,
mode: 'insensitive',
},
},
],
});
const [users, count] = await Promise.all([
await prisma.user.findMany({
include: {
Subscription: true,
Document: {
select: {
id: true,
},
},
},
where: whereClause,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
}),
await prisma.user.count({
where: whereClause,
}),
]);
return {
users,
totalPages: Math.ceil(count / perPage),
};
};

View File

@ -0,0 +1,3 @@
export const toHumanPrice = (price: number) => {
return Number(price / 100).toFixed(2);
};

View File

@ -0,0 +1,17 @@
/*
Warnings:
- A unique constraint covering the columns `[userId]` on the table `Subscription` will be added. If there are existing duplicate values, this will fail.
- Made the column `customerId` on table `Subscription` required. This step will fail if there are existing NULL values in that column.
*/
DELETE FROM "Subscription"
WHERE "customerId" IS NULL;
-- AlterTable
ALTER TABLE "Subscription" ADD COLUMN "cancelAtPeriodEnd" BOOLEAN NOT NULL DEFAULT false,
ALTER COLUMN "customerId" SET NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "Subscription_userId_key" ON "Subscription"("userId");

View File

@ -0,0 +1,5 @@
-- DropForeignKey
ALTER TABLE "DocumentShareLink" DROP CONSTRAINT "DocumentShareLink_documentId_fkey";
-- AddForeignKey
ALTER TABLE "DocumentShareLink" ADD CONSTRAINT "DocumentShareLink_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -51,15 +51,16 @@ enum SubscriptionStatus {
}
model Subscription {
id Int @id @default(autoincrement())
status SubscriptionStatus @default(INACTIVE)
planId String?
priceId String?
customerId String?
periodEnd DateTime?
userId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id Int @id @default(autoincrement())
status SubscriptionStatus @default(INACTIVE)
planId String?
priceId String?
customerId String
periodEnd DateTime?
userId Int @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
cancelAtPeriodEnd Boolean @default(false)
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
@ -219,7 +220,7 @@ model DocumentShareLink {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
document Document @relation(fields: [documentId], references: [id])
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
@@unique([documentId, email])
}

View File

@ -0,0 +1,23 @@
import { TRPCError } from '@trpc/server';
import { updateUser } from '@documenso/lib/server-only/admin/update-user';
import { adminProcedure, router } from '../trpc';
import { ZUpdateProfileMutationByAdminSchema } from './schema';
export const adminRouter = router({
updateUser: adminProcedure
.input(ZUpdateProfileMutationByAdminSchema)
.mutation(async ({ input }) => {
const { id, name, email, roles } = input;
try {
return await updateUser({ id, name, email, roles });
} catch (err) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to retrieve the specified account. Please try again.',
});
}
}),
});

View File

@ -0,0 +1,13 @@
import { Role } from '@prisma/client';
import z from 'zod';
export const ZUpdateProfileMutationByAdminSchema = z.object({
id: z.number().min(1),
name: z.string().nullish(),
email: z.string().email().optional(),
roles: z.array(z.nativeEnum(Role)).optional(),
});
export type TUpdateProfileMutationByAdminSchema = z.infer<
typeof ZUpdateProfileMutationByAdminSchema
>;

View File

@ -1,19 +1,34 @@
import { TRPCError } from '@trpc/server';
import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password';
import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id';
import { resetPassword } from '@documenso/lib/server-only/user/reset-password';
import { updatePassword } from '@documenso/lib/server-only/user/update-password';
import { updateProfile } from '@documenso/lib/server-only/user/update-profile';
import { authenticatedProcedure, procedure, router } from '../trpc';
import { adminProcedure, authenticatedProcedure, procedure, router } from '../trpc';
import {
ZForgotPasswordFormSchema,
ZResetPasswordFormSchema,
ZRetrieveUserByIdQuerySchema,
ZUpdatePasswordMutationSchema,
ZUpdateProfileMutationSchema,
} from './schema';
export const profileRouter = router({
getUser: adminProcedure.input(ZRetrieveUserByIdQuerySchema).query(async ({ input }) => {
try {
const { id } = input;
return await getUserById({ id });
} catch (err) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to retrieve the specified account. Please try again.',
});
}
}),
updateProfile: authenticatedProcedure
.input(ZUpdateProfileMutationSchema)
.mutation(async ({ input, ctx }) => {

View File

@ -1,5 +1,9 @@
import { z } from 'zod';
export const ZRetrieveUserByIdQuerySchema = z.object({
id: z.number().min(1),
});
export const ZUpdateProfileMutationSchema = z.object({
name: z.string().min(1),
signature: z.string(),
@ -19,6 +23,7 @@ export const ZResetPasswordFormSchema = z.object({
token: z.string().min(1),
});
export type TRetrieveUserByIdQuerySchema = z.infer<typeof ZRetrieveUserByIdQuerySchema>;
export type TUpdateProfileMutationSchema = z.infer<typeof ZUpdateProfileMutationSchema>;
export type TUpdatePasswordMutationSchema = z.infer<typeof ZUpdatePasswordMutationSchema>;
export type TForgotPasswordFormSchema = z.infer<typeof ZForgotPasswordFormSchema>;

View File

@ -1,3 +1,4 @@
import { adminRouter } from './admin-router/router';
import { authRouter } from './auth-router/router';
import { documentRouter } from './document-router/router';
import { fieldRouter } from './field-router/router';
@ -13,6 +14,7 @@ export const appRouter = router({
profile: profileRouter,
document: documentRouter,
field: fieldRouter,
admin: adminRouter,
shareLink: shareLinkRouter,
});

View File

@ -1,6 +1,8 @@
import { TRPCError, initTRPC } from '@trpc/server';
import SuperJSON from 'superjson';
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
import { TrpcContext } from './context';
const t = initTRPC.context<TrpcContext>().create({
@ -28,9 +30,37 @@ export const authenticatedMiddleware = t.middleware(async ({ ctx, next }) => {
});
});
export const adminMiddleware = t.middleware(async ({ ctx, next }) => {
if (!ctx.session || !ctx.user) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'You must be logged in to perform this action.',
});
}
const isUserAdmin = isAdmin(ctx.user);
if (!isUserAdmin) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Not authorized to perform this action.',
});
}
return await next({
ctx: {
...ctx,
user: ctx.user,
session: ctx.session,
},
});
});
/**
* Routers and Procedures
*/
export const router = t.router;
export const procedure = t.procedure;
export const authenticatedProcedure = t.procedure.use(authenticatedMiddleware);
export const adminProcedure = t.procedure.use(adminMiddleware);

View File

@ -10,6 +10,7 @@ declare namespace NodeJS {
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string;
NEXT_PUBLIC_STRIPE_FREE_PLAN_ID?: string;
NEXT_PRIVATE_STRIPE_API_KEY: string;
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;

View File

@ -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 Combobox = ({ 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 { Combobox };