mirror of
https://github.com/documenso/documenso.git
synced 2025-11-16 09:41:35 +10:00
Merge branch 'feat/refresh' into fix/467-bugsafari-only-unable-to-copy-document-sharing-link
This commit is contained in:
4
packages/app-tests/.gitignore
vendored
Normal file
4
packages/app-tests/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/playwright/.cache/
|
||||
55
packages/app-tests/e2e/test-auth-flow.spec.ts
Normal file
55
packages/app-tests/e2e/test-auth-flow.spec.ts
Normal 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}`);
|
||||
}
|
||||
});
|
||||
21
packages/app-tests/package.json
Normal file
21
packages/app-tests/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
77
packages/app-tests/playwright.config.ts
Normal file
77
packages/app-tests/playwright.config.ts
Normal 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,
|
||||
// },
|
||||
});
|
||||
31
packages/ee/server-only/stripe/get-checkout-session.ts
Normal file
31
packages/ee/server-only/stripe/get-checkout-session.ts
Normal 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;
|
||||
};
|
||||
19
packages/ee/server-only/stripe/get-customer.ts
Normal file
19
packages/ee/server-only/stripe/get-customer.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
49
packages/ee/server-only/stripe/get-prices-by-interval.ts
Normal file
49
packages/ee/server-only/stripe/get-prices-by-interval.ts
Normal 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;
|
||||
};
|
||||
55
packages/lib/server-only/admin/get-all-documents.ts
Normal file
55
packages/lib/server-only/admin/get-all-documents.ts
Normal 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),
|
||||
};
|
||||
};
|
||||
13
packages/lib/server-only/admin/get-all-subscriptions.ts
Normal file
13
packages/lib/server-only/admin/get-all-subscriptions.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
};
|
||||
28
packages/lib/server-only/admin/update-user.ts
Normal file
28
packages/lib/server-only/admin/update-user.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -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 ?? '', {
|
||||
|
||||
7
packages/lib/server-only/stripe/stripe.d.ts
vendored
Normal file
7
packages/lib/server-only/stripe/stripe.d.ts
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
declare module 'stripe' {
|
||||
namespace Stripe {
|
||||
interface Product {
|
||||
features?: Array<{ name: string }>;
|
||||
}
|
||||
}
|
||||
}
|
||||
25
packages/lib/server-only/user/delete-user.ts
Normal file
25
packages/lib/server-only/user/delete-user.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
};
|
||||
57
packages/lib/server-only/user/get-all-users.ts
Normal file
57
packages/lib/server-only/user/get-all-users.ts
Normal 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),
|
||||
};
|
||||
};
|
||||
3
packages/lib/universal/stripe/to-human-price.ts
Normal file
3
packages/lib/universal/stripe/to-human-price.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export const toHumanPrice = (price: number) => {
|
||||
return Number(price / 100).toFixed(2);
|
||||
};
|
||||
@ -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");
|
||||
@ -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;
|
||||
@ -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])
|
||||
}
|
||||
|
||||
23
packages/trpc/server/admin-router/router.ts
Normal file
23
packages/trpc/server/admin-router/router.ts
Normal 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.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
13
packages/trpc/server/admin-router/schema.ts
Normal file
13
packages/trpc/server/admin-router/schema.ts
Normal 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
|
||||
>;
|
||||
@ -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 }) => {
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
1
packages/tsconfig/process-env.d.ts
vendored
1
packages/tsconfig/process-env.d.ts
vendored
@ -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;
|
||||
|
||||
82
packages/ui/primitives/combobox.tsx
Normal file
82
packages/ui/primitives/combobox.tsx
Normal 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 };
|
||||
Reference in New Issue
Block a user