mirror of
https://github.com/documenso/documenso.git
synced 2025-11-11 04:52:41 +10:00
Compare commits
117 Commits
docs/minor
...
feat/plan-
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ba4ff1c17 | |||
| 652af26754 | |||
| 093488a67c | |||
| 0d026f3476 | |||
| 3e89ec1afc | |||
| df0d18fc81 | |||
| dd25c355ff | |||
| 6f851833b2 | |||
| 442b089d7f | |||
| 1c58b21383 | |||
| a6e13faf7b | |||
| ede9eb052d | |||
| fab006078c | |||
| 4d5275f915 | |||
| 901e83af58 | |||
| e1bee1591f | |||
| a354c23231 | |||
| f728dd13c5 | |||
| 7927b87259 | |||
| 55301a9d53 | |||
| c0dd57a4d2 | |||
| cc80773402 | |||
| c803d2c4ba | |||
| eb5f5f7a90 | |||
| 2ea5ff2c94 | |||
| bc9a6fa50a | |||
| e02ab7d256 | |||
| 01e6367b72 | |||
| 565602f8e1 | |||
| 9e0d281883 | |||
| 67629dd735 | |||
| 2a89278c7b | |||
| 8f4ba6eb8a | |||
| 8dfcfb99e0 | |||
| 1299aa51ee | |||
| e0271cace3 | |||
| a11440a7f3 | |||
| cc8c4b8297 | |||
| a287aab4f4 | |||
| 4c518df60d | |||
| d4ae733e9e | |||
| b5ed703553 | |||
| f49880125a | |||
| 8380c357d9 | |||
| 4e010c5624 | |||
| f53cdbace9 | |||
| b4d04e2ce9 | |||
| 2470aeee1f | |||
| fd07b47325 | |||
| 9257a05831 | |||
| 1faa6f2944 | |||
| 5584bbe9ca | |||
| cc65537ea3 | |||
| 5f14f87406 | |||
| 04a80b7c03 | |||
| 2b44e54d99 | |||
| c71a89d1b7 | |||
| e2abfd2312 | |||
| 49d55227e8 | |||
| 0dadec3b8d | |||
| e2d8591d66 | |||
| aecc703317 | |||
| 2422c3e7be | |||
| 4e1994a0c8 | |||
| a3dce67117 | |||
| 64dcd451e9 | |||
| a85523ecfc | |||
| 85b32bb15b | |||
| 742ad86b10 | |||
| 39ff11a59d | |||
| 4f5976479a | |||
| eac7aa84b0 | |||
| bd941202c8 | |||
| b854f0eedc | |||
| 1814bd4167 | |||
| b6f9d70fec | |||
| 7c54913bf5 | |||
| e8d5044ac5 | |||
| ddf097ede3 | |||
| 1bad85e1d6 | |||
| 68458b50d2 | |||
| e00f28cf87 | |||
| 4cc34ec50a | |||
| 693249916d | |||
| 381a248543 | |||
| f637381198 | |||
| 071335cc66 | |||
| 4d4b011146 | |||
| d10713b477 | |||
| 2efaabd2c3 | |||
| 7bc1e9dcc8 | |||
| 8848df701c | |||
| 2e800d0eed | |||
| 70ecc9a4a8 | |||
| 97dfacd133 | |||
| 87a5bab734 | |||
| b5fc6e1aaf | |||
| 87f70fa290 | |||
| fa61bb660e | |||
| 83dd079e03 | |||
| f7933d8a4d | |||
| 4bd0cfd283 | |||
| 86f39f3824 | |||
| 94216f5219 | |||
| a2e6187dae | |||
| c2cda0f06e | |||
| f1bc772985 | |||
| 15e3926ce4 | |||
| ab26af19b8 | |||
| dc512600dc | |||
| 07bf780c3e | |||
| 775de16d0a | |||
| 4d485940ea | |||
| cbe118b74f | |||
| de9116e9b2 | |||
| 027a588604 | |||
| 773566f193 |
@ -9,10 +9,5 @@ npm install
|
||||
# Copy the env file
|
||||
cp .env.example .env
|
||||
|
||||
# Source the env file, export the variables
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
|
||||
# Run the migrations
|
||||
npm run -w @documenso/prisma prisma:migrate-dev
|
||||
npm run prisma:migrate-dev
|
||||
|
||||
@ -15,6 +15,11 @@ NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documen
|
||||
# Defines the URL to use for the database when running migrations and other commands that won't work with a connection pool.
|
||||
NEXT_PRIVATE_DIRECT_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
|
||||
|
||||
# [[E2E Tests]]
|
||||
E2E_TEST_AUTHENTICATE_USERNAME="Test User"
|
||||
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
|
||||
E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_password"
|
||||
|
||||
# [[STORAGE]]
|
||||
# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3
|
||||
NEXT_PUBLIC_UPLOAD_TRANSPORT="database"
|
||||
@ -68,6 +73,7 @@ NEXT_PRIVATE_STRIPE_API_KEY=
|
||||
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=
|
||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID=
|
||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID=
|
||||
NEXT_PUBLIC_STRIPE_FREE_PLAN_ID=
|
||||
|
||||
# [[FEATURES]]
|
||||
# OPTIONAL: Leave blank to disable PostHog and feature flags.
|
||||
|
||||
50
.github/ISSUE_TEMPLATE/bug-report.md
vendored
Normal file
50
.github/ISSUE_TEMPLATE/bug-report.md
vendored
Normal file
@ -0,0 +1,50 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Create a bug report to help us improve
|
||||
---
|
||||
|
||||
<!--- Please provide a general summary of the issue in the Title above -->
|
||||
|
||||
## Issue Description
|
||||
|
||||
<!--- Please provide a clear and concise description of the problem. -->
|
||||
|
||||
## Steps to Reproduce
|
||||
|
||||
<!--- Please provide step-by-step instructions to reproduce the issue. -->
|
||||
<!--- Include code snippets, error messages, and any other relevant information. -->
|
||||
|
||||
1. Step 1
|
||||
2. Step 2
|
||||
3. ...
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
<!--- Describe what you expected to happen. -->
|
||||
|
||||
## Current Behavior
|
||||
|
||||
<!--- Describe what is currently happening. -->
|
||||
|
||||
## Screenshots (optional)
|
||||
|
||||
<!--- If applicable, add screenshots to help explain the issue. -->
|
||||
|
||||
## Environment
|
||||
|
||||
<!--- Please provide information about your environment, such as operating system, browser, version, etc. -->
|
||||
|
||||
- OS: [e.g., Windows 10]
|
||||
- Browser: [e.g., Chrome, Firefox]
|
||||
- Version: [e.g., 2.0.1]
|
||||
|
||||
## Checklist
|
||||
|
||||
<!--- Please check the boxes that apply to this issue report. -->
|
||||
<!--- You can add or remove items as needed. -->
|
||||
|
||||
- [ ] I have searched the existing issues to make sure this is not a duplicate.
|
||||
- [ ] I have provided steps to reproduce the issue.
|
||||
- [ ] I have included relevant environment information.
|
||||
- [ ] I have included any relevant screenshots.
|
||||
- [ ] I understand that this is a voluntary contribution and that there is no guarantee of resolution.
|
||||
41
.github/ISSUE_TEMPLATE/feature-request.md
vendored
Normal file
41
.github/ISSUE_TEMPLATE/feature-request.md
vendored
Normal file
@ -0,0 +1,41 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Suggest a new idea or enhancement for this project
|
||||
---
|
||||
|
||||
<!--- Please provide a clear and concise title for your feature request -->
|
||||
|
||||
## Feature Description
|
||||
|
||||
<!--- Describe the feature you are requesting in detail. -->
|
||||
<!--- Explain what problem it solves or what value it adds to the project. -->
|
||||
|
||||
## Use Case
|
||||
|
||||
<!--- Provide a scenario or use case where this feature would be beneficial. -->
|
||||
<!--- Explain how users would interact with this feature and why it's important. -->
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
<!--- If you have an idea of how this feature could be implemented, describe it here. -->
|
||||
<!--- Include any technical details, UI/UX considerations, or design suggestions. -->
|
||||
|
||||
## Alternatives (optional)
|
||||
|
||||
<!--- Are there any alternative ways to achieve the same goal? -->
|
||||
<!--- Describe other approaches that could be considered if this feature is not implemented. -->
|
||||
|
||||
## Additional Context
|
||||
|
||||
<!--- Add any additional context or information that might be relevant to the feature request. -->
|
||||
|
||||
## Checklist
|
||||
|
||||
<!--- Please check the boxes that apply to this feature request. -->
|
||||
<!--- You can add or remove items as needed. -->
|
||||
|
||||
- [ ] I have searched the existing feature requests to make sure this is not a duplicate.
|
||||
- [ ] I have provided a detailed description of the requested feature.
|
||||
- [ ] I have explained the use case or scenario for this feature.
|
||||
- [ ] I have included any relevant technical details or design suggestions.
|
||||
- [ ] I understand that this is a suggestion and that there is no guarantee of implementation.
|
||||
41
.github/ISSUE_TEMPLATE/improvement.md
vendored
Normal file
41
.github/ISSUE_TEMPLATE/improvement.md
vendored
Normal file
@ -0,0 +1,41 @@
|
||||
---
|
||||
name: General Improvement
|
||||
about: Suggest a minor enhancement or improvement for this project
|
||||
---
|
||||
|
||||
<!--- Please provide a clear and concise title for your improvement suggestion -->
|
||||
|
||||
## Improvement Description
|
||||
|
||||
<!--- Describe the improvement you are suggesting in detail. -->
|
||||
<!--- Explain what specific aspect of the project it addresses or enhances. -->
|
||||
|
||||
## Rationale
|
||||
|
||||
<!--- Explain why this improvement would be beneficial. -->
|
||||
<!--- Share any context, pain points, or reasons for suggesting this change. -->
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
<!--- If you have a suggestion for how this improvement could be implemented, describe it here. -->
|
||||
<!--- Include any technical details, design suggestions, or other relevant information. -->
|
||||
|
||||
## Alternatives (optional)
|
||||
|
||||
<!--- Are there any alternative approaches to achieve the same improvement? -->
|
||||
<!--- Describe other ways to address the issue or enhance the project. -->
|
||||
|
||||
## Additional Context
|
||||
|
||||
<!--- Add any additional context or information that might be relevant to the improvement suggestion. -->
|
||||
|
||||
## Checklist
|
||||
|
||||
<!--- Please check the boxes that apply to this improvement suggestion. -->
|
||||
<!--- You can add or remove items as needed. -->
|
||||
|
||||
- [ ] I have searched the existing issues and improvement suggestions to avoid duplication.
|
||||
- [ ] I have provided a clear description of the improvement being suggested.
|
||||
- [ ] I have explained the rationale behind this improvement.
|
||||
- [ ] I have included any relevant technical details or design suggestions.
|
||||
- [ ] I understand that this is a suggestion and that there is no guarantee of implementation.
|
||||
49
.github/PULL_REQUEST_TEMPLATE/generic.md
vendored
Normal file
49
.github/PULL_REQUEST_TEMPLATE/generic.md
vendored
Normal file
@ -0,0 +1,49 @@
|
||||
---
|
||||
name: Pull Request
|
||||
about: Submit changes to the project for review and inclusion
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!--- Describe the changes introduced by this pull request. -->
|
||||
<!--- Explain what problem it solves or what feature/fix it adds. -->
|
||||
|
||||
## Related Issue
|
||||
|
||||
<!--- If this pull request is related to a specific issue, reference it here using #issue_number. -->
|
||||
<!--- For example, "Fixes #123" or "Addresses #456". -->
|
||||
|
||||
## Changes Made
|
||||
|
||||
<!--- Provide a summary of the changes made in this pull request. -->
|
||||
<!--- Include any relevant technical details or architecture changes. -->
|
||||
|
||||
- Change 1
|
||||
- Change 2
|
||||
- ...
|
||||
|
||||
## Testing Performed
|
||||
|
||||
<!--- Describe the testing that you have performed to validate these changes. -->
|
||||
<!--- Include information about test cases, testing environments, and results. -->
|
||||
|
||||
- Tested feature X in scenario Y.
|
||||
- Ran unit tests for component Z.
|
||||
- Tested on browsers A, B, and C.
|
||||
- ...
|
||||
|
||||
## Checklist
|
||||
|
||||
<!--- Please check the boxes that apply to this pull request. -->
|
||||
<!--- You can add or remove items as needed. -->
|
||||
|
||||
- [ ] I have tested these changes locally and they work as expected.
|
||||
- [ ] I have added/updated tests that prove the effectiveness of these changes.
|
||||
- [ ] I have updated the documentation to reflect these changes, if applicable.
|
||||
- [ ] I have followed the project's coding style guidelines.
|
||||
- [ ] I have addressed the code review feedback from the previous submission, if applicable.
|
||||
|
||||
## Additional Notes
|
||||
|
||||
<!--- Provide any additional context or notes for the reviewers. -->
|
||||
<!--- This might include details about design decisions, potential concerns, or anything else relevant. -->
|
||||
40
.github/PULL_REQUEST_TEMPLATE/test-addition.md
vendored
Normal file
40
.github/PULL_REQUEST_TEMPLATE/test-addition.md
vendored
Normal file
@ -0,0 +1,40 @@
|
||||
---
|
||||
name: Test Addition
|
||||
about: Submit a new test, either unit or end-to-end (E2E), for review and inclusion
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!--- Provide a clear and concise description of the new test you are adding. -->
|
||||
<!--- Explain the purpose of the test and what it aims to validate. -->
|
||||
|
||||
## Related Issue
|
||||
|
||||
<!--- If this test addition is related to a specific issue, reference it here using #issue_number. -->
|
||||
<!--- For example, "Fixes #123" or "Addresses #456". -->
|
||||
|
||||
## Test Details
|
||||
|
||||
<!--- Describe the details of the test you're adding. -->
|
||||
<!--- Include information about inputs, expected outputs, and any specific scenarios. -->
|
||||
|
||||
- Test Name: Name of the test
|
||||
- Type: [Unit / E2E]
|
||||
- Description: Brief description of what the test checks
|
||||
- Inputs: What inputs the test uses (if applicable)
|
||||
- Expected Output: What output or behavior the test expects
|
||||
|
||||
## Checklist
|
||||
|
||||
<!--- Please check the boxes that apply to this pull request. -->
|
||||
<!--- You can add or remove items as needed. -->
|
||||
|
||||
- [ ] I have written the new test and ensured it works as intended.
|
||||
- [ ] I have added necessary documentation to explain the purpose of the test.
|
||||
- [ ] I have followed the project's testing guidelines and coding style.
|
||||
- [ ] I have addressed any review feedback from previous submissions, if applicable.
|
||||
|
||||
## Additional Notes
|
||||
|
||||
<!--- Provide any additional context or notes for the reviewers. -->
|
||||
<!--- This might include explanations about the testing approach or any potential concerns. -->
|
||||
6
.github/dependabot.yml
vendored
6
.github/dependabot.yml
vendored
@ -9,7 +9,7 @@ updates:
|
||||
labels:
|
||||
- "ci dependencies"
|
||||
- "ci"
|
||||
open-pull-requests-limit: 2
|
||||
open-pull-requests-limit: 0
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/apps/marketing"
|
||||
@ -19,7 +19,7 @@ updates:
|
||||
labels:
|
||||
- "npm dependencies"
|
||||
- "frontend"
|
||||
open-pull-requests-limit: 2
|
||||
open-pull-requests-limit: 0
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/apps/web"
|
||||
@ -29,4 +29,4 @@ updates:
|
||||
labels:
|
||||
- "npm dependencies"
|
||||
- "frontend"
|
||||
open-pull-requests-limit: 2
|
||||
open-pull-requests-limit: 0
|
||||
|
||||
51
.github/workflows/e2e-tests.yml
vendored
Normal file
51
.github/workflows/e2e-tests.yml
vendored
Normal file
@ -0,0 +1,51 @@
|
||||
name: Playwright Tests
|
||||
on:
|
||||
push:
|
||||
branches: [feat/refresh]
|
||||
pull_request:
|
||||
branches: [feat/refresh]
|
||||
jobs:
|
||||
e2e_tests:
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 5432:5432
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Copy env
|
||||
run: cp .env.example .env
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install --with-deps
|
||||
- name: Generate Prisma Client
|
||||
run: npm run prisma:generate -w @documenso/prisma
|
||||
- name: Create the database
|
||||
run: npm run prisma:migrate-dev
|
||||
- name: Run Playwright tests
|
||||
run: npm run ci
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
env:
|
||||
NEXT_PRIVATE_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/documenso
|
||||
NEXT_PRIVATE_DIRECT_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/documenso
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||
55
.gitpod.yml
Normal file
55
.gitpod.yml
Normal file
@ -0,0 +1,55 @@
|
||||
tasks:
|
||||
- init: |
|
||||
npm i &&
|
||||
npm run dx:up &&
|
||||
cp .env.example .env &&
|
||||
set -a; source .env &&
|
||||
export NEXTAUTH_URL="$(gp url 3000)" &&
|
||||
export NEXT_PUBLIC_WEBAPP_URL="$(gp url 3000)" &&
|
||||
export NEXT_PUBLIC_MARKETING_URL="$(gp url 3001)"
|
||||
command: npm run d
|
||||
|
||||
ports:
|
||||
- port: 3000
|
||||
visibility: public
|
||||
onOpen: open-preview
|
||||
- port: 3001
|
||||
visibility: public
|
||||
onOpen: open-preview
|
||||
- port: 9000
|
||||
visibility: public
|
||||
onOpen: ignore
|
||||
- port: 1100
|
||||
visibility: private
|
||||
onOpen: ignore
|
||||
- port: 2500
|
||||
visibility: private
|
||||
onOpen: ignore
|
||||
- port: 54320
|
||||
visibility: private
|
||||
onOpen: ignore
|
||||
|
||||
|
||||
github:
|
||||
prebuilds:
|
||||
master: true
|
||||
pullRequests: true
|
||||
pullRequestsFromForks: true
|
||||
addCheck: true
|
||||
addComment: true
|
||||
addBadge: true
|
||||
|
||||
vscode:
|
||||
extensions:
|
||||
- aaron-bond.better-comments
|
||||
- bradlc.vscode-tailwindcss
|
||||
- dbaeumer.vscode-eslint
|
||||
- esbenp.prettier-vscode
|
||||
- mikestead.dotenv
|
||||
- unifiedjs.vscode-mdx
|
||||
- GitHub.copilot-chat
|
||||
- GitHub.copilot-labs
|
||||
- GitHub.copilot
|
||||
- GitHub.vscode-pull-request-github
|
||||
- Prisma.prisma
|
||||
- VisualStudioExptTeam.vscodeintellicode
|
||||
126
CODE_OF_CONDUCT.md
Normal file
126
CODE_OF_CONDUCT.md
Normal file
@ -0,0 +1,126 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, caste, color, religion, or sexual
|
||||
identity and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
- Demonstrating empathy and kindness toward other people
|
||||
- Being respectful of differing opinions, viewpoints, and experiences
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
- Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
- Focusing on what is best not just for us as individuals, but for the overall
|
||||
community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
- The use of sexualized language or imagery, and sexual attention or advances of
|
||||
any kind
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or email address,
|
||||
without their explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
support@documenso.com.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series of
|
||||
actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or permanent
|
||||
ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within the
|
||||
community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.1, available at
|
||||
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||
|
||||
Community Impact Guidelines were inspired by
|
||||
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
|
||||
[https://www.contributor-covenant.org/translations][translations].
|
||||
@ -5,20 +5,36 @@ If you plan to contribute to Documenso, please take a moment to feel awesome ✨
|
||||
## Before getting started
|
||||
|
||||
- Before jumping into a PR be sure to search [existing PRs](https://github.com/documenso/documenso/pulls) or [issues](https://github.com/documenso/documenso/issues) for an open or closed item that relates to your submission.
|
||||
- Select and issue from [here](https://github.com/documenso/documenso/issues) or create a new one
|
||||
- Consider the results from the discussion in the issue
|
||||
- Select an issue from [here](https://github.com/documenso/documenso/issues) or create a new one
|
||||
- Consider the results from the discussion on the issue
|
||||
- Accept the [Contributor License Agreement](https://documen.so/cla) to ensure we can accept your contributions.
|
||||
|
||||
## Taking issues
|
||||
|
||||
Before taking an issue, ensure that:
|
||||
|
||||
- The issue has been assigned the public label
|
||||
- The issue is clearly defined and understood
|
||||
- No one has been assigned to the issue
|
||||
- No one has expressed intention to work on it
|
||||
|
||||
You can then:
|
||||
|
||||
1. Comment on the issue with your intention to work on it
|
||||
2. Begin work on the issue
|
||||
|
||||
Always feel free to ask questions or seek clarification on the issue.
|
||||
|
||||
## Developing
|
||||
|
||||
The development branch is <code>main</code>. All pull request should be made against this branch. If you need help getting started, [join us on Discord](https://documen.so/discord).
|
||||
The development branch is <code>main</code>. All pull requests should be made against this branch. If you need help getting started, [join us on Discord](https://documen.so/discord).
|
||||
|
||||
1. [Fork](https://help.github.com/articles/fork-a-repo/) this repository to your
|
||||
own GitHub account and then
|
||||
[clone](https://help.github.com/articles/cloning-a-repository/) it to your local device.
|
||||
2. Create a new branch:
|
||||
|
||||
- Create a new branch (include the issue id and somthing readable):
|
||||
- Create a new branch (include the issue id and something readable):
|
||||
|
||||
```sh
|
||||
git checkout -b doc-999-my-feature-or-fix
|
||||
@ -29,7 +45,7 @@ The development branch is <code>main</code>. All pull request should be made aga
|
||||
## Building
|
||||
|
||||
> **Note**
|
||||
> Please be sure that you can make a full production build before pushing code or creating PRs.
|
||||
> Please ensure you can make a full production build before pushing code or creating PRs.
|
||||
|
||||
You can build the project with:
|
||||
|
||||
|
||||
38
README.md
38
README.md
@ -27,6 +27,7 @@
|
||||
<a href="https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/documenso/documenso">
|
||||
<img alt="open in devcontainer" src="https://img.shields.io/static/v1?label=Dev%20Containers&message=Enabled&color=blue&logo=visualstudiocode" />
|
||||
</a>
|
||||
<a href="code_of_conduct.md"><img src="https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg" alt="Contributor Covenant"></a>
|
||||
</p>
|
||||
|
||||
> 🦺 Documenso 1.0 is deployed to our <a href="https://documen.so/staging" target="_blank">Staging Environment</a>.
|
||||
@ -130,14 +131,12 @@ git clone https://github.com/documenso/documenso
|
||||
|
||||
2. Set up your `.env` file using the recommendations in the `.env.example` file. Alternatively just run `cp .env.example .env` to get started with our handpicked defaults.
|
||||
|
||||
|
||||
3. Run `npm run dx` in the root directory
|
||||
|
||||
- This will spin up a postgres database and inbucket mailserver in a docker container.
|
||||
|
||||
4. Run `npm run dev` in the root directory
|
||||
|
||||
|
||||
5. Want it even faster? Just use
|
||||
|
||||
```sh
|
||||
@ -170,16 +169,17 @@ git clone https://github.com/documenso/documenso
|
||||
3. Create your `.env` from the `.env.example`. You can use `cp .env.example .env` to get started with our handpicked defaults.
|
||||
|
||||
4. Set the following environement variables.
|
||||
- NEXTAUTH_URL
|
||||
- NEXTAUTH_SECRET
|
||||
- NEXT_PUBLIC_WEBAPP_URL
|
||||
- NEXT_PUBLIC_MARKETING_URL
|
||||
- NEXT_PRIVATE_DATABASE_URL
|
||||
- NEXT_PRIVATE_DIRECT_DATABASE_URL
|
||||
- NEXT_PRIVATE_SMTP_FROM_NAME
|
||||
- NEXT_PRIVATE_SMTP_FROM_ADDRESS
|
||||
|
||||
5. Create the database schema by running `npm run prisma:migrate-dev -w @documenso/prisma`
|
||||
- NEXTAUTH_URL
|
||||
- NEXTAUTH_SECRET
|
||||
- NEXT_PUBLIC_WEBAPP_URL
|
||||
- NEXT_PUBLIC_MARKETING_URL
|
||||
- NEXT_PRIVATE_DATABASE_URL
|
||||
- NEXT_PRIVATE_DIRECT_DATABASE_URL
|
||||
- NEXT_PRIVATE_SMTP_FROM_NAME
|
||||
- NEXT_PRIVATE_SMTP_FROM_ADDRESS
|
||||
|
||||
5. Create the database schema by running `npm run prisma:migrate-dev`
|
||||
|
||||
6. Run `npm run dev` root directory to start
|
||||
|
||||
@ -254,6 +254,22 @@ containers:
|
||||
- '::'
|
||||
```
|
||||
|
||||
### I can't see environment variables in my package scripts
|
||||
|
||||
Wrap your package script with the `with:env` script like such:
|
||||
|
||||
```
|
||||
npm run with:env -- npm run myscript
|
||||
```
|
||||
|
||||
The same can be done when using `npx` for one of bin scripts:
|
||||
|
||||
```
|
||||
npm run with:env -- npx myscript
|
||||
```
|
||||
|
||||
This will load environment variables from your `.env` and `.env.local` files.
|
||||
|
||||
## Repo Activity
|
||||
|
||||

|
||||
|
||||
@ -31,7 +31,7 @@
|
||||
"react-confetti": "^6.1.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hook-form": "^7.43.9",
|
||||
"react-icons": "^4.8.0",
|
||||
"react-icons": "^4.11.0",
|
||||
"recharts": "^2.7.2",
|
||||
"sharp": "0.32.5",
|
||||
"typescript": "5.1.6",
|
||||
|
||||
1
apps/marketing/process-env.d.ts
vendored
1
apps/marketing/process-env.d.ts
vendored
@ -7,6 +7,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;
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
import { Footer } from '~/components/(marketing)/footer';
|
||||
@ -13,6 +15,7 @@ export type MarketingLayoutProps = {
|
||||
|
||||
export default function MarketingLayout({ children }: MarketingLayoutProps) {
|
||||
const [scrollY, setScrollY] = useState(0);
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => {
|
||||
@ -25,7 +28,11 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative max-w-[100vw] overflow-y-auto overflow-x-hidden pt-20 md:pt-28">
|
||||
<div
|
||||
className={cn('relative max-w-[100vw] pt-20 md:pt-28', {
|
||||
'overflow-y-auto overflow-x-hidden': pathname !== '/singleplayer',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={cn('fixed left-0 top-0 z-50 w-full bg-transparent', {
|
||||
'bg-background/50 backdrop-blur-md': scrollY > 5,
|
||||
|
||||
@ -23,22 +23,6 @@ export const TEAM_MEMBERS = [
|
||||
engagement: 'Part-Time',
|
||||
joinDate: 'June 6th, 2023',
|
||||
},
|
||||
{
|
||||
name: 'Florent Merian',
|
||||
role: 'Marketer - III',
|
||||
salary: 'Project-Based',
|
||||
location: 'France',
|
||||
engagement: 'Full-Time',
|
||||
joinDate: 'July 10th, 2023',
|
||||
},
|
||||
{
|
||||
name: 'Thilo Konzok',
|
||||
role: 'Designer',
|
||||
salary: 'Project-Based',
|
||||
location: 'Germany',
|
||||
engagement: 'Full-Time',
|
||||
joinDate: 'April 26th, 2023',
|
||||
},
|
||||
{
|
||||
name: 'David Nguyen',
|
||||
role: 'Software Engineer - III',
|
||||
@ -47,6 +31,22 @@ export const TEAM_MEMBERS = [
|
||||
engagement: 'Full-Time',
|
||||
joinDate: 'July 26th, 2023',
|
||||
},
|
||||
{
|
||||
name: 'Catalin-Marinel Pit',
|
||||
role: 'Software Engineer - II',
|
||||
salary: 80_000,
|
||||
location: 'Romania',
|
||||
engagement: 'Full-Time',
|
||||
joinDate: 'September 4th, 2023',
|
||||
},
|
||||
{
|
||||
name: 'Gowdhama Rajan B',
|
||||
role: 'Designer - III',
|
||||
salary: 100_000,
|
||||
location: 'India',
|
||||
engagement: 'Full-Time',
|
||||
joinDate: 'October 9th, 2023',
|
||||
},
|
||||
];
|
||||
|
||||
export const FUNDING_RAISED = [
|
||||
|
||||
@ -130,7 +130,7 @@ export default function SinglePlayerModePage() {
|
||||
signer: data.email,
|
||||
});
|
||||
|
||||
router.push(`/single-player-mode/${documentToken}/success`);
|
||||
router.push(`/singleplayer/${documentToken}/success`);
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
@ -4,6 +4,7 @@ import { Caveat, Inter } from 'next/font/google';
|
||||
|
||||
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { getAllAnonymousFlags } from '@documenso/lib/universal/get-feature-flag';
|
||||
import { TrpcProvider } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Toaster } from '@documenso/ui/primitives/toaster';
|
||||
|
||||
@ -63,7 +64,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
<body>
|
||||
<FeatureFlagProvider initialFlags={flags}>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<PlausibleProvider>{children}</PlausibleProvider>
|
||||
<PlausibleProvider>
|
||||
<TrpcProvider>{children}</TrpcProvider>
|
||||
</PlausibleProvider>
|
||||
</ThemeProvider>
|
||||
</FeatureFlagProvider>
|
||||
|
||||
|
||||
@ -2,8 +2,8 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Github } from 'lucide-react';
|
||||
import { usePlausible } from 'next-plausible';
|
||||
import { LuGithub } from 'react-icons/lu';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
@ -52,7 +52,7 @@ export const Callout = ({ starCount }: CalloutProps) => {
|
||||
onClick={() => event('view-github')}
|
||||
>
|
||||
<Button variant="outline" className="rounded-full bg-transparent backdrop-blur-sm">
|
||||
<Github className="mr-2 h-5 w-5" />
|
||||
<LuGithub className="mr-2 h-5 w-5" />
|
||||
Star on Github
|
||||
{starCount && starCount > 0 && (
|
||||
<span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs">
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
@ -55,8 +55,8 @@ export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialog
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
reset,
|
||||
} = useForm<TClaimPlanDialogFormSchema>({
|
||||
mode: 'onBlur',
|
||||
defaultValues: {
|
||||
name: params?.get('name') ?? '',
|
||||
email: params?.get('email') ?? '',
|
||||
@ -91,6 +91,12 @@ export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialog
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSubmitting && !open) {
|
||||
reset();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isSubmitting && setOpen(value)}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
|
||||
@ -5,22 +5,25 @@ import { HTMLAttributes } from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Github, MessagesSquare, Moon, Sun, Twitter } from 'lucide-react';
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { FaXTwitter } from 'react-icons/fa6';
|
||||
import { LiaDiscord } from 'react-icons/lia';
|
||||
import { LuGithub } from 'react-icons/lu';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
export type FooterProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
const SOCIAL_LINKS = [
|
||||
{ href: 'https://twitter.com/documenso', icon: <Twitter className="h-6 w-6" /> },
|
||||
{ href: 'https://github.com/documenso/documenso', icon: <Github className="h-6 w-6" /> },
|
||||
{ href: 'https://documen.so/discord', icon: <MessagesSquare className="h-6 w-6" /> },
|
||||
{ href: 'https://twitter.com/documenso', icon: <FaXTwitter className="h-6 w-6" /> },
|
||||
{ href: 'https://github.com/documenso/documenso', icon: <LuGithub className="h-6 w-6" /> },
|
||||
{ href: 'https://documen.so/discord', icon: <LiaDiscord className="h-7 w-7" /> },
|
||||
];
|
||||
|
||||
const FOOTER_LINKS = [
|
||||
{ href: '/pricing', text: 'Pricing' },
|
||||
{ href: '/single-player-mode', text: 'Single Player Mode' },
|
||||
{ href: '/singleplayer', text: 'Singleplayer' },
|
||||
{ href: '/blog', text: 'Blog' },
|
||||
{ href: '/open', text: 'Open' },
|
||||
{ href: 'https://shop.documenso.com', text: 'Shop', target: '_blank' },
|
||||
|
||||
@ -35,7 +35,7 @@ export const Header = ({ className, ...props }: HeaderProps) => {
|
||||
|
||||
{isSinglePlayerModeMarketingEnabled && (
|
||||
<Link
|
||||
href="/single-player-mode"
|
||||
href="/singleplayer"
|
||||
className="bg-primary dark:text-background rounded-full px-2 py-1 text-xs font-semibold sm:px-3"
|
||||
>
|
||||
Try now!
|
||||
|
||||
@ -4,8 +4,8 @@ import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Variants, motion } from 'framer-motion';
|
||||
import { Github } from 'lucide-react';
|
||||
import { usePlausible } from 'next-plausible';
|
||||
import { LuGithub } from 'react-icons/lu';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
@ -122,7 +122,7 @@ export const Hero = ({ className, ...props }: HeroProps) => {
|
||||
|
||||
<Link href="https://github.com/documenso/documenso" onClick={() => event('view-github')}>
|
||||
<Button variant="outline" className="rounded-full bg-transparent backdrop-blur-sm">
|
||||
<Github className="mr-2 h-5 w-5" />
|
||||
<LuGithub className="mr-2 h-5 w-5" />
|
||||
Star on Github
|
||||
</Button>
|
||||
</Link>
|
||||
@ -134,9 +134,9 @@ export const Hero = ({ className, ...props }: HeroProps) => {
|
||||
variants={HeroTitleVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
className="border-primary bg-background hover:bg-muted mx-auto mt-8 w-60 rounded-xl border transition duration-300"
|
||||
className="border-primary bg-background hover:bg-muted mx-auto mt-8 w-60 rounded-xl border transition-colors duration-300"
|
||||
>
|
||||
<Link href="/single-player-mode" className="block px-4 py-2 text-center">
|
||||
<Link href="/singleplayer" className="block px-4 py-2 text-center">
|
||||
<h2 className="text-muted-foreground text-xs font-semibold">
|
||||
Introducing Single Player Mode
|
||||
</h2>
|
||||
|
||||
@ -4,7 +4,9 @@ import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { motion, useReducedMotion } from 'framer-motion';
|
||||
import { Github, MessagesSquare, Twitter } from 'lucide-react';
|
||||
import { FaXTwitter } from 'react-icons/fa6';
|
||||
import { LiaDiscord } from 'react-icons/lia';
|
||||
import { LuGithub } from 'react-icons/lu';
|
||||
|
||||
import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet';
|
||||
|
||||
@ -15,8 +17,8 @@ export type MobileNavigationProps = {
|
||||
|
||||
export const MENU_NAVIGATION_LINKS = [
|
||||
{
|
||||
href: '/single-player-mode',
|
||||
text: 'Single Player Mode',
|
||||
href: '/singleplayer',
|
||||
text: 'Singleplayer',
|
||||
},
|
||||
{
|
||||
href: '/blog',
|
||||
@ -111,7 +113,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
|
||||
target="_blank"
|
||||
className="text-foreground hover:text-foreground/80"
|
||||
>
|
||||
<Twitter className="h-6 w-6" />
|
||||
<FaXTwitter className="h-6 w-6" />
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
@ -119,7 +121,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
|
||||
target="_blank"
|
||||
className="text-foreground hover:text-foreground/80"
|
||||
>
|
||||
<Github className="h-6 w-6" />
|
||||
<LuGithub className="h-6 w-6" />
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
@ -127,7 +129,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
|
||||
target="_blank"
|
||||
className="text-foreground hover:text-foreground/80"
|
||||
>
|
||||
<MessagesSquare className="h-6 w-6" />
|
||||
<LiaDiscord className="h-7 w-7" />
|
||||
</Link>
|
||||
</div>
|
||||
</SheetContent>
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useCopyToClipboard } from '~/hooks/use-copy-to-clipboard';
|
||||
|
||||
export type PasswordRevealProps = {
|
||||
password: string;
|
||||
};
|
||||
|
||||
@ -4,14 +4,13 @@ import { useEffect, useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Share } from 'lucide-react';
|
||||
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { base64 } from '@documenso/lib/universal/base64';
|
||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||
import { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
|
||||
import DocumentDialog from '@documenso/ui/components/document/document-dialog';
|
||||
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
|
||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@ -87,11 +86,11 @@ export const SinglePlayerModeSuccess = ({ className, document }: SinglePlayerMod
|
||||
<div className="relative mt-8 w-full">
|
||||
<div className={cn('flex flex-col items-center', className)}>
|
||||
<div className="grid w-full max-w-sm grid-cols-2 gap-4">
|
||||
{/* TODO: Hook this up */}
|
||||
<Button variant="outline" className="flex-1 bg-transparent backdrop-blur-sm" disabled>
|
||||
<Share className="mr-2 h-5 w-5" />
|
||||
Share
|
||||
</Button>
|
||||
<DocumentShareButton
|
||||
documentId={document.id}
|
||||
token={document.Recipient.token}
|
||||
className="flex-1 bg-transparent backdrop-blur-sm"
|
||||
/>
|
||||
|
||||
<DocumentDownloadButton
|
||||
className="flex-1 bg-transparent backdrop-blur-sm"
|
||||
@ -103,7 +102,7 @@ export const SinglePlayerModeSuccess = ({ className, document }: SinglePlayerMod
|
||||
<Button
|
||||
onClick={async () => onShowDocumentClick()}
|
||||
loading={isFetchingDocumentFile}
|
||||
className="col-span-2"
|
||||
className="z-10 col-span-2"
|
||||
>
|
||||
Show document
|
||||
</Button>
|
||||
|
||||
@ -377,7 +377,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
||||
</Card>
|
||||
|
||||
<Dialog open={showSigningDialog} onOpenChange={setShowSigningDialog}>
|
||||
<DialogContent>
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add your signature</DialogTitle>
|
||||
</DialogHeader>
|
||||
@ -391,6 +391,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
||||
|
||||
<SignaturePad
|
||||
className="aspect-video w-full rounded-md border"
|
||||
defaultValue={signatureDataUrl || ''}
|
||||
onChange={setDraftSignatureDataUrl}
|
||||
/>
|
||||
|
||||
|
||||
@ -41,7 +41,7 @@ export default async function handler(
|
||||
},
|
||||
});
|
||||
|
||||
if (user && user.Subscription.length > 0) {
|
||||
if (user && user.Subscription) {
|
||||
return res.status(200).json({
|
||||
redirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/login`,
|
||||
});
|
||||
|
||||
8
apps/marketing/src/pages/api/trpc/[trpc].ts
Normal file
8
apps/marketing/src/pages/api/trpc/[trpc].ts
Normal file
@ -0,0 +1,8 @@
|
||||
import * as trpcNext from '@documenso/trpc/server/adapters/next';
|
||||
import { createTrpcContext } from '@documenso/trpc/server/context';
|
||||
import { appRouter } from '@documenso/trpc/server/router';
|
||||
|
||||
export default trpcNext.createNextApiHandler({
|
||||
router: appRouter,
|
||||
createContext: async ({ req, res }) => createTrpcContext({ req, res }),
|
||||
});
|
||||
@ -36,7 +36,7 @@
|
||||
"react-dom": "18.2.0",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-hook-form": "^7.43.9",
|
||||
"react-icons": "^4.8.0",
|
||||
"react-icons": "^4.11.0",
|
||||
"react-rnd": "^10.4.1",
|
||||
"sharp": "0.32.5",
|
||||
"ts-pattern": "^5.0.5",
|
||||
|
||||
1
apps/web/process-env.d.ts
vendored
1
apps/web/process-env.d.ts
vendored
@ -7,6 +7,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;
|
||||
|
||||
99
apps/web/src/app/(dashboard)/admin/documents/data-table.tsx
Normal file
99
apps/web/src/app/(dashboard)/admin/documents/data-table.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
'use client';
|
||||
|
||||
import { useTransition } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { FindResultSet } from '@documenso/lib/types/find-result-set';
|
||||
import { Document, User } from '@documenso/prisma/client';
|
||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||
|
||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||
|
||||
export type DocumentsDataTableProps = {
|
||||
results: FindResultSet<
|
||||
Document & {
|
||||
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
|
||||
const onPaginationChange = (page: number, perPage: number) => {
|
||||
startTransition(() => {
|
||||
updateSearchParams({
|
||||
page,
|
||||
perPage,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<DataTable
|
||||
columns={[
|
||||
{
|
||||
header: 'Created',
|
||||
accessorKey: 'createdAt',
|
||||
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
|
||||
},
|
||||
{
|
||||
header: 'Title',
|
||||
accessorKey: 'title',
|
||||
cell: ({ row }) => {
|
||||
return <div>{row.original.title}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Owner',
|
||||
accessorKey: 'owner',
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Link href={`/admin/users/${row.original.User.id}`}>
|
||||
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
|
||||
<AvatarFallback className="text-gray-400">
|
||||
<span className="text-xs">{row.original.User.name}</span>
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Last updated',
|
||||
accessorKey: 'updatedAt',
|
||||
cell: ({ row }) => <LocaleDate date={row.original.updatedAt} />,
|
||||
},
|
||||
{
|
||||
header: 'Status',
|
||||
accessorKey: 'status',
|
||||
cell: ({ row }) => <DocumentStatus status={row.original.status} />,
|
||||
},
|
||||
]}
|
||||
data={results.data}
|
||||
perPage={results.perPage}
|
||||
currentPage={results.currentPage}
|
||||
totalPages={results.totalPages}
|
||||
onPaginationChange={onPaginationChange}
|
||||
>
|
||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||
</DataTable>
|
||||
|
||||
{isPending && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
|
||||
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
29
apps/web/src/app/(dashboard)/admin/documents/page.tsx
Normal file
29
apps/web/src/app/(dashboard)/admin/documents/page.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { findDocuments } from '@documenso/lib/server-only/admin/get-all-documents';
|
||||
|
||||
import { DocumentsDataTable } from './data-table';
|
||||
|
||||
export type DocumentsPageProps = {
|
||||
searchParams?: {
|
||||
page?: string;
|
||||
perPage?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function Documents({ searchParams = {} }: DocumentsPageProps) {
|
||||
const page = Number(searchParams.page) || 1;
|
||||
const perPage = Number(searchParams.perPage) || 20;
|
||||
|
||||
const results = await findDocuments({
|
||||
page,
|
||||
perPage,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-4xl font-semibold">Manage documents</h2>
|
||||
<div className="mt-8">
|
||||
<DocumentsDataTable results={results} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -5,7 +5,7 @@ import { HTMLAttributes } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { BarChart3, User2 } from 'lucide-react';
|
||||
import { BarChart3, FileStack, User2, Wallet2 } from 'lucide-react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@ -37,10 +37,40 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => {
|
||||
'justify-start md:w-full',
|
||||
pathname?.startsWith('/admin/users') && 'bg-secondary',
|
||||
)}
|
||||
disabled
|
||||
asChild
|
||||
>
|
||||
<User2 className="mr-2 h-5 w-5" />
|
||||
Users (Coming Soon)
|
||||
<Link href="/admin/users">
|
||||
<User2 className="mr-2 h-5 w-5" />
|
||||
Users
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'justify-start md:w-full',
|
||||
pathname?.startsWith('/admin/documents') && 'bg-secondary',
|
||||
)}
|
||||
asChild
|
||||
>
|
||||
<Link href="/admin/documents">
|
||||
<FileStack className="mr-2 h-5 w-5" />
|
||||
Documents
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'justify-start md:w-full',
|
||||
pathname?.startsWith('/admin/subscriptions') && 'bg-secondary',
|
||||
)}
|
||||
asChild
|
||||
>
|
||||
<Link href="/admin/subscriptions">
|
||||
<Wallet2 className="mr-2 h-5 w-5" />
|
||||
Subscriptions
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
65
apps/web/src/app/(dashboard)/admin/subscriptions/page.tsx
Normal file
65
apps/web/src/app/(dashboard)/admin/subscriptions/page.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { findSubscriptions } from '@documenso/lib/server-only/admin/get-all-subscriptions';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@documenso/ui/primitives/table';
|
||||
|
||||
export default async function Subscriptions() {
|
||||
const subscriptions = await findSubscriptions();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-4xl font-semibold">Manage subscriptions</h2>
|
||||
<div className="mt-8">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created At</TableHead>
|
||||
<TableHead>Ends On</TableHead>
|
||||
<TableHead>User ID</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{subscriptions.map((subscription, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>{subscription.id}</TableCell>
|
||||
<TableCell>{subscription.status}</TableCell>
|
||||
<TableCell>
|
||||
{subscription.createdAt
|
||||
? new Date(subscription.createdAt).toLocaleDateString(undefined, {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
: 'N/A'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{subscription.periodEnd
|
||||
? new Date(subscription.periodEnd).toLocaleDateString(undefined, {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
: 'N/A'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Link href={`/admin/users/${subscription.userId}`}>{subscription.userId}</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
141
apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx
Normal file
141
apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx
Normal file
@ -0,0 +1,141 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZUpdateProfileMutationByAdminSchema } from '@documenso/trpc/server/admin-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Combobox } from '@documenso/ui/primitives/combobox';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true });
|
||||
|
||||
type TUserFormSchema = z.infer<typeof ZUserFormSchema>;
|
||||
|
||||
export default function UserPage({ params }: { params: { id: number } }) {
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const { data: user } = trpc.profile.getUser.useQuery(
|
||||
{
|
||||
id: Number(params.id),
|
||||
},
|
||||
{
|
||||
enabled: !!params.id,
|
||||
},
|
||||
);
|
||||
|
||||
const roles = user?.roles ?? [];
|
||||
|
||||
const { mutateAsync: updateUserMutation } = trpc.admin.updateUser.useMutation();
|
||||
|
||||
const form = useForm<TUserFormSchema>({
|
||||
resolver: zodResolver(ZUserFormSchema),
|
||||
values: {
|
||||
name: user?.name ?? '',
|
||||
email: user?.email ?? '',
|
||||
roles: user?.roles ?? [],
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async ({ name, email, roles }: TUserFormSchema) => {
|
||||
try {
|
||||
await updateUserMutation({
|
||||
id: Number(user?.id),
|
||||
name,
|
||||
email,
|
||||
roles,
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
|
||||
toast({
|
||||
title: 'Profile updated',
|
||||
description: 'Your profile has been updated.',
|
||||
duration: 5000,
|
||||
});
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while updating your profile.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-4xl font-semibold">Manage {user?.name}'s profile</h2>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<fieldset className="mt-6 flex w-full flex-col gap-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-muted-foreground">Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" {...field} value={field.value ?? ''} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-muted-foreground">Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="roles"
|
||||
render={({ field: { onChange } }) => (
|
||||
<FormItem>
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<FormLabel className="text-muted-foreground">Roles</FormLabel>
|
||||
<FormControl>
|
||||
<Combobox
|
||||
listValues={roles}
|
||||
onChange={(values: string[]) => onChange(values)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</fieldset>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
Update user
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
143
apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx
Normal file
143
apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useTransition } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Edit, Loader } from 'lucide-react';
|
||||
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { Document, Role, Subscription } from '@documenso/prisma/client';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
name: string | null;
|
||||
email: string;
|
||||
roles: Role[];
|
||||
Subscription?: SubscriptionLite | null;
|
||||
Document: DocumentLite[];
|
||||
}
|
||||
|
||||
type SubscriptionLite = Pick<
|
||||
Subscription,
|
||||
'id' | 'status' | 'planId' | 'priceId' | 'createdAt' | 'periodEnd'
|
||||
>;
|
||||
|
||||
type DocumentLite = Pick<Document, 'id'>;
|
||||
|
||||
type UsersDataTableProps = {
|
||||
users: User[];
|
||||
totalPages: number;
|
||||
perPage: number;
|
||||
page: number;
|
||||
};
|
||||
|
||||
export const UsersDataTable = ({ users, totalPages, perPage, page }: UsersDataTableProps) => {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
const [searchString, setSearchString] = useState('');
|
||||
const debouncedSearchString = useDebouncedValue(searchString, 1000);
|
||||
|
||||
useEffect(() => {
|
||||
startTransition(() => {
|
||||
updateSearchParams({
|
||||
search: debouncedSearchString,
|
||||
page: 1,
|
||||
perPage,
|
||||
});
|
||||
});
|
||||
}, [debouncedSearchString]);
|
||||
|
||||
const onPaginationChange = (page: number, perPage: number) => {
|
||||
startTransition(() => {
|
||||
updateSearchParams({
|
||||
page,
|
||||
perPage,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchString(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="my-6 flex flex-row gap-4"
|
||||
type="text"
|
||||
placeholder="Search by name or email"
|
||||
value={searchString}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<DataTable
|
||||
columns={[
|
||||
{
|
||||
header: 'ID',
|
||||
accessorKey: 'id',
|
||||
cell: ({ row }) => <div>{row.original.id}</div>,
|
||||
},
|
||||
{
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
cell: ({ row }) => <div>{row.original.name}</div>,
|
||||
},
|
||||
{
|
||||
header: 'Email',
|
||||
accessorKey: 'email',
|
||||
cell: ({ row }) => <div>{row.original.email}</div>,
|
||||
},
|
||||
{
|
||||
header: 'Roles',
|
||||
accessorKey: 'roles',
|
||||
cell: ({ row }) => row.original.roles.join(', '),
|
||||
},
|
||||
{
|
||||
header: 'Subscription',
|
||||
accessorKey: 'subscription',
|
||||
cell: ({ row }) => row.original.Subscription?.status ?? 'NONE',
|
||||
},
|
||||
{
|
||||
header: 'Documents',
|
||||
accessorKey: 'documents',
|
||||
cell: ({ row }) => {
|
||||
return <div>{row.original.Document.length}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: '',
|
||||
accessorKey: 'edit',
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Button className="w-24" asChild>
|
||||
<Link href={`/admin/users/${row.original.id}`}>
|
||||
<Edit className="-ml-1 mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
data={users}
|
||||
perPage={perPage}
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPaginationChange={onPaginationChange}
|
||||
>
|
||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||
</DataTable>
|
||||
|
||||
{isPending && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
|
||||
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,9 @@
|
||||
'use server';
|
||||
|
||||
import { findUsers } from '@documenso/lib/server-only/user/get-all-users';
|
||||
|
||||
export async function search(search: string, page: number, perPage: number) {
|
||||
const results = await findUsers({ username: search, email: search, page, perPage });
|
||||
|
||||
return results;
|
||||
}
|
||||
25
apps/web/src/app/(dashboard)/admin/users/page.tsx
Normal file
25
apps/web/src/app/(dashboard)/admin/users/page.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { UsersDataTable } from './data-table-users';
|
||||
import { search } from './fetch-users.actions';
|
||||
|
||||
type AdminManageUsersProps = {
|
||||
searchParams?: {
|
||||
search?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function AdminManageUsers({ searchParams = {} }: AdminManageUsersProps) {
|
||||
const page = Number(searchParams.page) || 1;
|
||||
const perPage = Number(searchParams.perPage) || 10;
|
||||
const searchString = searchParams.search || '';
|
||||
|
||||
const { users, totalPages } = await search(searchString, page, perPage);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-4xl font-semibold">Manage users</h2>
|
||||
<UsersDataTable users={users} totalPages={totalPages} page={page} perPage={perPage} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -6,13 +6,12 @@ import { Edit, Pencil, Share } from 'lucide-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||
import { Document, DocumentStatus, Recipient, SigningStatus, User } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useCopyToClipboard } from '~/hooks/use-copy-to-clipboard';
|
||||
|
||||
export type DataTableActionButtonProps = {
|
||||
row: Document & {
|
||||
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||
@ -47,7 +46,7 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
||||
documentId: row.id,
|
||||
});
|
||||
|
||||
await copyToClipboard(`${window.location.origin}/share/${slug}`).catch(() => null);
|
||||
await copyToClipboard(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/share/${slug}`).catch(() => null);
|
||||
|
||||
toast({
|
||||
title: 'Copied to clipboard',
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import {
|
||||
@ -16,6 +18,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||
import { Document, DocumentStatus, Recipient, User } from '@documenso/prisma/client';
|
||||
import { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||
@ -30,7 +33,7 @@ import {
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useCopyToClipboard } from '~/hooks/use-copy-to-clipboard';
|
||||
import { DeleteDraftDocumentDialog } from './delete-draft-document-dialog';
|
||||
|
||||
export type DataTableActionDropdownProps = {
|
||||
row: Document & {
|
||||
@ -44,6 +47,8 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
const { toast } = useToast();
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
@ -55,10 +60,11 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
|
||||
const isOwner = row.User.id === session.user.id;
|
||||
// const isRecipient = !!recipient;
|
||||
// const isDraft = row.status === DocumentStatus.DRAFT;
|
||||
const isDraft = row.status === DocumentStatus.DRAFT;
|
||||
// const isPending = row.status === DocumentStatus.PENDING;
|
||||
const isComplete = row.status === DocumentStatus.COMPLETED;
|
||||
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||
const isDocumentDeletable = isOwner && row.status === DocumentStatus.DRAFT;
|
||||
|
||||
const onShareClick = async () => {
|
||||
const { slug } = await createOrGetShareLink({
|
||||
@ -66,7 +72,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
documentId: row.id,
|
||||
});
|
||||
|
||||
await copyToClipboard(`${window.location.origin}/share/${slug}`).catch(() => null);
|
||||
await copyToClipboard(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/share/${slug}`).catch(() => null);
|
||||
|
||||
toast({
|
||||
title: 'Copied to clipboard',
|
||||
@ -147,7 +153,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
Void
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem disabled>
|
||||
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)} disabled={!isDocumentDeletable}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
@ -159,7 +165,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
Resend
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={onShareClick}>
|
||||
<DropdownMenuItem disabled={isDraft} onClick={onShareClick}>
|
||||
{isCreatingShareLink ? (
|
||||
<Loader className="mr-2 h-4 w-4" />
|
||||
) : (
|
||||
@ -168,6 +174,14 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
Share
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
{isDocumentDeletable && (
|
||||
<DeleteDraftDocumentDialog
|
||||
id={row.id}
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
@ -0,0 +1,89 @@
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
type DeleteDraftDocumentDialogProps = {
|
||||
id: number;
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
};
|
||||
|
||||
export const DeleteDraftDocumentDialog = ({
|
||||
id,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: DeleteDraftDocumentDialogProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: deleteDocument, isLoading } =
|
||||
trpcReact.document.deleteDraftDocument.useMutation({
|
||||
onSuccess: () => {
|
||||
router.refresh();
|
||||
|
||||
toast({
|
||||
title: 'Document deleted',
|
||||
description: 'Your document has been successfully deleted.',
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
},
|
||||
});
|
||||
|
||||
const onDraftDelete = async () => {
|
||||
try {
|
||||
await deleteDocument({ id });
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: 'This document could not be deleted at this time. Please try again.',
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Do you want to delete this document?</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
Please note that this action is irreversible. Once confirmed, your document will be
|
||||
permanently deleted.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button type="button" loading={isLoading} onClick={onDraftDelete} className="flex-1">
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -66,7 +66,7 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
|
||||
<div className="mt-12 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
|
||||
<h1 className="text-4xl font-semibold">Documents</h1>
|
||||
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-6 overflow-hidden">
|
||||
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
|
||||
<Tabs defaultValue={status} className="overflow-x-auto">
|
||||
<TabsList>
|
||||
{[
|
||||
|
||||
@ -2,12 +2,15 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { TRPCClientError } from '@documenso/trpc/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
||||
@ -22,6 +25,8 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const { quota, remaining } = useLimits();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { mutateAsync: createDocument } = trpc.document.createDocument.useMutation();
|
||||
@ -52,11 +57,19 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while uploading your document.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (error instanceof TRPCClientError) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while uploading your document.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@ -64,13 +77,46 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<DocumentDropzone className="min-h-[40vh]" onDrop={onFileDrop} />
|
||||
<DocumentDropzone
|
||||
className="min-h-[40vh]"
|
||||
disabled={remaining.documents === 0}
|
||||
onDrop={onFileDrop}
|
||||
/>
|
||||
|
||||
<div className="absolute -bottom-6 right-0">
|
||||
{remaining.documents > 0 && Number.isFinite(remaining.documents) && (
|
||||
<p className="text-muted-foreground/60 text-xs">
|
||||
{remaining.documents} of {quota.documents} documents remaining this month.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="bg-background/50 absolute inset-0 flex items-center justify-center">
|
||||
<div className="bg-background/50 absolute inset-0 flex items-center justify-center rounded-lg">
|
||||
<Loader className="text-muted-foreground h-12 w-12 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{remaining.documents === 0 && (
|
||||
<div className="bg-background/60 absolute inset-0 flex items-center justify-center rounded-lg backdrop-blur-sm">
|
||||
<div className="text-center">
|
||||
<h2 className="text-muted-foreground/80 text-xl font-semibold">
|
||||
You have reached your document limit.
|
||||
</h2>
|
||||
|
||||
<p className="text-muted-foreground/60 mt-2 text-sm">
|
||||
You can upload up to {quota.documents} documents per month on your current plan.
|
||||
</p>
|
||||
|
||||
<Link
|
||||
className="text-primary hover:text-primary/80 mt-6 block font-medium"
|
||||
href="/settings/billing"
|
||||
>
|
||||
Upgrade your account to upload more documents.
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -4,6 +4,7 @@ import { redirect } from 'next/navigation';
|
||||
|
||||
import { getServerSession } from 'next-auth';
|
||||
|
||||
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server';
|
||||
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
|
||||
@ -28,11 +29,13 @@ export default async function AuthenticatedDashboardLayout({
|
||||
|
||||
return (
|
||||
<NextAuthProvider session={session}>
|
||||
<Header user={user} />
|
||||
<LimitsProvider>
|
||||
<Header user={user} />
|
||||
|
||||
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
||||
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
||||
|
||||
<RefreshOnFocus />
|
||||
<RefreshOnFocus />
|
||||
</LimitsProvider>
|
||||
</NextAuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
133
apps/web/src/app/(dashboard)/settings/billing/billing-plans.tsx
Normal file
133
apps/web/src/app/(dashboard)/settings/billing/billing-plans.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
|
||||
import { PriceIntervals } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||
import { toHumanPrice } from '@documenso/lib/universal/stripe/to-human-price';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent, CardTitle } from '@documenso/ui/primitives/card';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { createCheckout } from './create-checkout.action';
|
||||
|
||||
type Interval = keyof PriceIntervals;
|
||||
|
||||
const INTERVALS: Interval[] = ['day', 'week', 'month', 'year'];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const isInterval = (value: unknown): value is Interval => INTERVALS.includes(value as Interval);
|
||||
|
||||
const FRIENDLY_INTERVALS: Record<Interval, string> = {
|
||||
day: 'Daily',
|
||||
week: 'Weekly',
|
||||
month: 'Monthly',
|
||||
year: 'Yearly',
|
||||
};
|
||||
|
||||
const MotionCard = motion(Card);
|
||||
|
||||
export type BillingPlansProps = {
|
||||
prices: PriceIntervals;
|
||||
};
|
||||
|
||||
export const BillingPlans = ({ prices }: BillingPlansProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
const [interval, setInterval] = useState<Interval>('month');
|
||||
const [isFetchingCheckoutSession, setIsFetchingCheckoutSession] = useState(false);
|
||||
|
||||
const onSubscribeClick = async (priceId: string) => {
|
||||
try {
|
||||
setIsFetchingCheckoutSession(true);
|
||||
|
||||
const url = await createCheckout({ priceId });
|
||||
|
||||
if (!url) {
|
||||
throw new Error('Unable to create session');
|
||||
}
|
||||
|
||||
window.open(url);
|
||||
} catch (_err) {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: 'An error occurred while trying to create a checkout session.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsFetchingCheckoutSession(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Tabs value={interval} onValueChange={(value) => isInterval(value) && setInterval(value)}>
|
||||
<TabsList>
|
||||
{INTERVALS.map(
|
||||
(interval) =>
|
||||
prices[interval].length > 0 && (
|
||||
<TabsTrigger key={interval} className="min-w-[150px]" value={interval}>
|
||||
{FRIENDLY_INTERVALS[interval]}
|
||||
</TabsTrigger>
|
||||
),
|
||||
)}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="mt-8 grid gap-8 lg:grid-cols-2 2xl:grid-cols-3">
|
||||
<AnimatePresence mode="wait">
|
||||
{prices[interval].map((price) => (
|
||||
<MotionCard
|
||||
key={price.id}
|
||||
initial={{ opacity: isMounted ? 0 : 1, y: isMounted ? 20 : 0 }}
|
||||
animate={{ opacity: 1, y: 0, transition: { duration: 0.3 } }}
|
||||
exit={{ opacity: 0, transition: { duration: 0.3 } }}
|
||||
>
|
||||
<CardContent className="flex h-full flex-col p-6">
|
||||
<CardTitle>{price.product.name}</CardTitle>
|
||||
|
||||
<div className="text-muted-foreground mt-2 text-lg font-medium">
|
||||
${toHumanPrice(price.unit_amount ?? 0)} {price.currency.toUpperCase()}{' '}
|
||||
<span className="text-xs">per {interval}</span>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground mt-1.5 text-sm">
|
||||
{price.product.description}
|
||||
</div>
|
||||
|
||||
{price.product.features && price.product.features.length > 0 && (
|
||||
<div className="text-muted-foreground mt-4">
|
||||
<div className="text-sm font-medium">Includes:</div>
|
||||
|
||||
<ul className="mt-1 divide-y text-sm">
|
||||
{price.product.features.map((feature, index) => (
|
||||
<li key={index} className="py-2">
|
||||
{feature.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<Button
|
||||
className="mt-4"
|
||||
loading={isFetchingCheckoutSession}
|
||||
onClick={() => void onSubscribeClick(price.id)}
|
||||
>
|
||||
Subscribe
|
||||
</Button>
|
||||
</CardContent>
|
||||
</MotionCard>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { createBillingPortal } from './create-billing-portal.action';
|
||||
|
||||
export const BillingPortalButton = () => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isFetchingPortalUrl, setIsFetchingPortalUrl] = useState(false);
|
||||
|
||||
const handleFetchPortalUrl = async () => {
|
||||
if (isFetchingPortalUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsFetchingPortalUrl(true);
|
||||
|
||||
try {
|
||||
const sessionUrl = await createBillingPortal();
|
||||
|
||||
if (!sessionUrl) {
|
||||
throw new Error('NO_SESSION');
|
||||
}
|
||||
|
||||
window.open(sessionUrl, '_blank');
|
||||
} catch (e) {
|
||||
let description =
|
||||
'We are unable to proceed to the billing portal at this time. Please try again, or contact support.';
|
||||
|
||||
if (e.message === 'CUSTOMER_NOT_FOUND') {
|
||||
description =
|
||||
'You do not currently have a customer record, this should not happen. Please contact support for assistance.';
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description,
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
setIsFetchingPortalUrl(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button onClick={async () => handleFetchPortalUrl()} loading={isFetchingPortalUrl}>
|
||||
Manage Subscription
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,48 @@
|
||||
'use server';
|
||||
|
||||
import {
|
||||
getStripeCustomerByEmail,
|
||||
getStripeCustomerById,
|
||||
} from '@documenso/ee/server-only/stripe/get-customer';
|
||||
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
|
||||
|
||||
export const createBillingPortal = async () => {
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
const existingSubscription = await getSubscriptionByUserId({ userId: user.id });
|
||||
|
||||
let stripeCustomer: Stripe.Customer | null = null;
|
||||
|
||||
// Find the Stripe customer for the current user subscription.
|
||||
if (existingSubscription) {
|
||||
stripeCustomer = await getStripeCustomerById(existingSubscription.customerId);
|
||||
|
||||
if (!stripeCustomer) {
|
||||
throw new Error('Missing Stripe customer for subscription');
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to check if a Stripe customer already exists for the current user email.
|
||||
if (!stripeCustomer) {
|
||||
stripeCustomer = await getStripeCustomerByEmail(user.email);
|
||||
}
|
||||
|
||||
// Create a Stripe customer if it does not exist for the current user.
|
||||
if (!stripeCustomer) {
|
||||
stripeCustomer = await stripe.customers.create({
|
||||
name: user.name ?? undefined,
|
||||
email: user.email,
|
||||
metadata: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return getPortalSession({
|
||||
customerId: stripeCustomer.id,
|
||||
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,59 @@
|
||||
'use server';
|
||||
|
||||
import { getCheckoutSession } from '@documenso/ee/server-only/stripe/get-checkout-session';
|
||||
import {
|
||||
getStripeCustomerByEmail,
|
||||
getStripeCustomerById,
|
||||
} from '@documenso/ee/server-only/stripe/get-customer';
|
||||
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
|
||||
|
||||
export type CreateCheckoutOptions = {
|
||||
priceId: string;
|
||||
};
|
||||
|
||||
export const createCheckout = async ({ priceId }: CreateCheckoutOptions) => {
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
const existingSubscription = await getSubscriptionByUserId({ userId: user.id });
|
||||
|
||||
let stripeCustomer: Stripe.Customer | null = null;
|
||||
|
||||
// Find the Stripe customer for the current user subscription.
|
||||
if (existingSubscription) {
|
||||
stripeCustomer = await getStripeCustomerById(existingSubscription.customerId);
|
||||
|
||||
if (!stripeCustomer) {
|
||||
throw new Error('Missing Stripe customer for subscription');
|
||||
}
|
||||
|
||||
return getPortalSession({
|
||||
customerId: stripeCustomer.id,
|
||||
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback to check if a Stripe customer already exists for the current user email.
|
||||
if (!stripeCustomer) {
|
||||
stripeCustomer = await getStripeCustomerByEmail(user.email);
|
||||
}
|
||||
|
||||
// Create a Stripe customer if it does not exist for the current user.
|
||||
if (!stripeCustomer) {
|
||||
stripeCustomer = await stripe.customers.create({
|
||||
name: user.name ?? undefined,
|
||||
email: user.email,
|
||||
metadata: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return getCheckoutSession({
|
||||
customerId: stripeCustomer.id,
|
||||
priceId,
|
||||
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
|
||||
});
|
||||
};
|
||||
@ -1,16 +1,19 @@
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer';
|
||||
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
||||
import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
||||
import { Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
|
||||
import { SubscriptionStatus } from '@documenso/prisma/client';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||
|
||||
import { BillingPlans } from './billing-plans';
|
||||
import { BillingPortalButton } from './billing-portal-button';
|
||||
|
||||
export default async function BillingSettingsPage() {
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
@ -21,57 +24,73 @@ export default async function BillingSettingsPage() {
|
||||
redirect('/settings/profile');
|
||||
}
|
||||
|
||||
const subscription = await getSubscriptionByUserId({ userId: user.id }).then(async (sub) => {
|
||||
if (sub) {
|
||||
return sub;
|
||||
}
|
||||
const [subscription, prices] = await Promise.all([
|
||||
getSubscriptionByUserId({ userId: user.id }),
|
||||
getPricesByInterval(),
|
||||
]);
|
||||
|
||||
// If we don't have a customer record, create one as well as an empty subscription.
|
||||
return createCustomer({ user });
|
||||
});
|
||||
let subscriptionProduct: Stripe.Product | null = null;
|
||||
|
||||
let billingPortalUrl = '';
|
||||
|
||||
if (subscription.customerId) {
|
||||
billingPortalUrl = await getPortalSession({
|
||||
customerId: subscription.customerId,
|
||||
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
|
||||
});
|
||||
if (subscription?.priceId) {
|
||||
subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch(
|
||||
() => null,
|
||||
);
|
||||
}
|
||||
|
||||
const isMissingOrInactiveOrFreePlan = !subscription || subscription.status === 'INACTIVE';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Billing</h3>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
Your subscription is{' '}
|
||||
{subscription.status !== SubscriptionStatus.INACTIVE ? 'active' : 'inactive'}.
|
||||
{subscription?.periodEnd && (
|
||||
<>
|
||||
{' '}
|
||||
Your next payment is due on{' '}
|
||||
<span className="font-semibold">
|
||||
<LocaleDate date={subscription.periodEnd} />
|
||||
</span>
|
||||
.
|
||||
</>
|
||||
<div className="text-muted-foreground mt-2 text-sm">
|
||||
{isMissingOrInactiveOrFreePlan && (
|
||||
<p>
|
||||
You are currently on the <span className="font-semibold">Free Plan</span>.
|
||||
</p>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{!isMissingOrInactiveOrFreePlan &&
|
||||
match(subscription.status)
|
||||
.with('ACTIVE', () => (
|
||||
<p>
|
||||
{subscriptionProduct ? (
|
||||
<span>
|
||||
You are currently subscribed to{' '}
|
||||
<span className="font-semibold">{subscriptionProduct.name}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>You currently have an active plan</span>
|
||||
)}
|
||||
|
||||
{subscription.periodEnd && (
|
||||
<span>
|
||||
{' '}
|
||||
which is set to{' '}
|
||||
{subscription.cancelAtPeriodEnd ? (
|
||||
<span>
|
||||
end on{' '}
|
||||
<LocaleDate className="font-semibold" date={subscription.periodEnd} />.
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
automatically renew on{' '}
|
||||
<LocaleDate className="font-semibold" date={subscription.periodEnd} />.
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
))
|
||||
.with('PAST_DUE', () => (
|
||||
<p>Your current plan is past due. Please update your payment information.</p>
|
||||
))
|
||||
.otherwise(() => null)}
|
||||
</div>
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
{billingPortalUrl && (
|
||||
<Button asChild>
|
||||
<Link href={billingPortalUrl}>Manage Subscription</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!billingPortalUrl && (
|
||||
<p className="text-muted-foreground max-w-[60ch] text-base">
|
||||
You do not currently have a customer record, this should not happen. Please contact
|
||||
support for assistance.
|
||||
</p>
|
||||
)}
|
||||
{isMissingOrInactiveOrFreePlan ? <BillingPlans prices={prices} /> : <BillingPortalButton />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -9,12 +9,11 @@ import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-f
|
||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||
import { DocumentStatus, FieldType } from '@documenso/prisma/client';
|
||||
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
|
||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
||||
|
||||
import signingCelebration from '~/assets/signing-celebration.png';
|
||||
|
||||
import { ShareButton } from './share-button';
|
||||
|
||||
export type CompletedSigningPageProps = {
|
||||
params: {
|
||||
token?: string;
|
||||
@ -89,7 +88,7 @@ export default async function CompletedSigningPage({
|
||||
))}
|
||||
|
||||
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
|
||||
<ShareButton documentId={document.id} token={recipient.token} />
|
||||
<DocumentShareButton documentId={document.id} token={recipient.token} />
|
||||
|
||||
<DocumentDownloadButton
|
||||
className="flex-1"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState, useTransition } from 'react';
|
||||
import { useEffect, useMemo, useState, useTransition } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
@ -48,6 +48,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
||||
|
||||
const [showSignatureModal, setShowSignatureModal] = useState(false);
|
||||
const [localSignature, setLocalSignature] = useState<string | null>(null);
|
||||
const [isLocalSignatureSet, setIsLocalSignatureSet] = useState(false);
|
||||
|
||||
const state = useMemo<SignatureFieldState>(() => {
|
||||
if (!field.inserted) {
|
||||
@ -61,9 +62,16 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
||||
return 'signed-text';
|
||||
}, [field.inserted, signature?.signatureImageAsBase64]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSignatureModal && !isLocalSignatureSet) {
|
||||
setLocalSignature(null);
|
||||
}
|
||||
}, [showSignatureModal, isLocalSignatureSet]);
|
||||
|
||||
const onSign = async (source: 'local' | 'provider' = 'provider') => {
|
||||
try {
|
||||
if (!providedSignature && !localSignature) {
|
||||
setIsLocalSignatureSet(false);
|
||||
setShowSignatureModal(true);
|
||||
return;
|
||||
}
|
||||
@ -178,6 +186,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
||||
disabled={!localSignature}
|
||||
onClick={() => {
|
||||
setShowSignatureModal(false);
|
||||
setIsLocalSignatureSet(true);
|
||||
void onSign('local');
|
||||
}}
|
||||
>
|
||||
|
||||
@ -4,7 +4,6 @@ import Link from 'next/link';
|
||||
|
||||
import {
|
||||
CreditCard,
|
||||
Github,
|
||||
Key,
|
||||
LogOut,
|
||||
User as LucideUser,
|
||||
@ -16,6 +15,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { signOut } from 'next-auth/react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { LuGithub } from 'react-icons/lu';
|
||||
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||
@ -130,7 +130,7 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="https://github.com/documenso/documenso" className="cursor-pointer">
|
||||
<Github className="mr-2 h-4 w-4" />
|
||||
<LuGithub className="mr-2 h-4 w-4" />
|
||||
Star on Github
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@ -73,7 +73,7 @@ export const ForgotPasswordForm = ({ className }: ForgotPasswordFormProps) => {
|
||||
</div>
|
||||
|
||||
<Button size="lg" loading={isSubmitting}>
|
||||
Reset Password
|
||||
{isSubmitting ? 'Sending Reset Email...' : 'Reset Password'}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
|
||||
@ -20,6 +20,7 @@ import { FormErrorMessage } from '../form/form-error-message';
|
||||
|
||||
export const ZPasswordFormSchema = z
|
||||
.object({
|
||||
currentPassword: z.string().min(6).max(72),
|
||||
password: z.string().min(6).max(72),
|
||||
repeatedPassword: z.string().min(6).max(72),
|
||||
})
|
||||
@ -40,6 +41,7 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [showCurrentPassword, setShowCurrentPassword] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
@ -48,6 +50,7 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<TPasswordFormSchema>({
|
||||
values: {
|
||||
currentPassword: '',
|
||||
password: '',
|
||||
repeatedPassword: '',
|
||||
},
|
||||
@ -56,9 +59,10 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
|
||||
|
||||
const { mutateAsync: updatePassword } = trpc.profile.updatePassword.useMutation();
|
||||
|
||||
const onFormSubmit = async ({ password }: TPasswordFormSchema) => {
|
||||
const onFormSubmit = async ({ currentPassword, password }: TPasswordFormSchema) => {
|
||||
try {
|
||||
await updatePassword({
|
||||
currentPassword,
|
||||
password,
|
||||
});
|
||||
|
||||
@ -92,6 +96,39 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
|
||||
className={cn('flex w-full flex-col gap-y-4', className)}
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<div>
|
||||
<Label htmlFor="current-password" className="text-muted-foreground">
|
||||
Current Password
|
||||
</Label>
|
||||
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="current-password"
|
||||
type={showCurrentPassword ? 'text' : 'password'}
|
||||
minLength={6}
|
||||
maxLength={72}
|
||||
autoComplete="current-password"
|
||||
className="bg-background mt-2 pr-10"
|
||||
{...register('currentPassword')}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
type="button"
|
||||
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3"
|
||||
aria-label={showCurrentPassword ? 'Mask password' : 'Reveal password'}
|
||||
onClick={() => setShowCurrentPassword((show) => !show)}
|
||||
>
|
||||
{showCurrentPassword ? (
|
||||
<EyeOff className="text-muted-foreground h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="text-muted-foreground h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<FormErrorMessage className="mt-1.5" error={errors.currentPassword} />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="password" className="text-muted-foreground">
|
||||
Password
|
||||
|
||||
@ -117,7 +117,8 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
|
||||
name="signature"
|
||||
render={({ field: { onChange } }) => (
|
||||
<SignaturePad
|
||||
className="h-44 w-full rounded-lg border bg-white backdrop-blur-sm dark:border-[#e2d7c5] dark:bg-[#fcf8ee]"
|
||||
className="h-44 w-full"
|
||||
containerClassName="rounded-lg border bg-background"
|
||||
defaultValue={user.signature ?? undefined}
|
||||
onChange={(v) => onChange(v ?? '')}
|
||||
/>
|
||||
|
||||
@ -166,7 +166,7 @@ export const ResetPasswordForm = ({ className, token }: ResetPasswordFormProps)
|
||||
</div>
|
||||
|
||||
<Button size="lg" loading={isSubmitting}>
|
||||
Reset Password
|
||||
{isSubmitting ? 'Resetting Password...' : 'Reset Password'}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Eye, EyeOff, Loader } from 'lucide-react';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { signIn } from 'next-auth/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { FcGoogle } from 'react-icons/fc';
|
||||
@ -147,9 +147,13 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
||||
<FormErrorMessage className="mt-1.5" error={errors.password} />
|
||||
</div>
|
||||
|
||||
<Button size="lg" disabled={isSubmitting} className="dark:bg-documenso dark:hover:opacity-90">
|
||||
{isSubmitting && <Loader className="mr-2 h-5 w-5 animate-spin" />}
|
||||
Sign In
|
||||
<Button
|
||||
size="lg"
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting}
|
||||
className="dark:bg-documenso dark:hover:opacity-90"
|
||||
>
|
||||
{isSubmitting ? 'Signing in...' : 'Sign In'}
|
||||
</Button>
|
||||
|
||||
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Eye, EyeOff, Loader } from 'lucide-react';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { signIn } from 'next-auth/react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
@ -147,7 +147,8 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
|
||||
name="signature"
|
||||
render={({ field: { onChange } }) => (
|
||||
<SignaturePad
|
||||
className="mt-2 h-36 w-full rounded-lg border bg-white dark:border-[#e2d7c5] dark:bg-[#fcf8ee]"
|
||||
className="h-36 w-full"
|
||||
containerClassName="mt-2 rounded-lg border bg-background"
|
||||
onChange={(v) => onChange(v ?? '')}
|
||||
/>
|
||||
)}
|
||||
@ -157,9 +158,13 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
|
||||
<FormErrorMessage className="mt-1.5" error={errors.signature} />
|
||||
</div>
|
||||
|
||||
<Button size="lg" disabled={isSubmitting} className="dark:bg-documenso dark:hover:opacity-90">
|
||||
{isSubmitting && <Loader className="mr-2 h-5 w-5 animate-spin" />}
|
||||
Sign Up
|
||||
<Button
|
||||
size="lg"
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting}
|
||||
className="dark:bg-documenso dark:hover:opacity-90"
|
||||
>
|
||||
{isSubmitting ? 'Signing up...' : 'Sign Up'}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
|
||||
@ -1,28 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export type CopiedValue = string | null;
|
||||
export type CopyFn = (_text: string) => Promise<boolean>;
|
||||
|
||||
export function useCopyToClipboard(): [CopiedValue, CopyFn] {
|
||||
const [copiedText, setCopiedText] = useState<CopiedValue>(null);
|
||||
|
||||
const copy: CopyFn = async (text) => {
|
||||
if (!navigator?.clipboard) {
|
||||
console.warn('Clipboard not supported');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try to save to clipboard then save it in the state if worked
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopiedText(text);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Copy failed', error);
|
||||
setCopiedText(null);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return [copiedText, copy];
|
||||
}
|
||||
@ -41,7 +41,7 @@ export default async function handler(
|
||||
},
|
||||
});
|
||||
|
||||
if (user && user.Subscription.length > 0) {
|
||||
if (user && user.Subscription) {
|
||||
return res.status(200).json({
|
||||
redirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/login`,
|
||||
});
|
||||
|
||||
3
apps/web/src/pages/api/limits/index.ts
Normal file
3
apps/web/src/pages/api/limits/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { limitsHandler } from '@documenso/ee/server-only/limits/handler';
|
||||
|
||||
export default limitsHandler;
|
||||
@ -1,197 +1,7 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import { randomBytes } from 'crypto';
|
||||
import { readFileSync } from 'fs';
|
||||
import { buffer } from 'micro';
|
||||
|
||||
import { insertImageInPDF } from '@documenso/lib/server-only/pdf/insert-image-in-pdf';
|
||||
import { insertTextInPDF } from '@documenso/lib/server-only/pdf/insert-text-in-pdf';
|
||||
import { redis } from '@documenso/lib/server-only/redis';
|
||||
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import {
|
||||
DocumentDataType,
|
||||
DocumentStatus,
|
||||
FieldType,
|
||||
ReadStatus,
|
||||
SendStatus,
|
||||
SigningStatus,
|
||||
} from '@documenso/prisma/client';
|
||||
|
||||
const log = (...args: unknown[]) => console.log('[stripe]', ...args);
|
||||
import { stripeWebhookHandler } from '@documenso/ee/server-only/stripe/webhook/handler';
|
||||
|
||||
export const config = {
|
||||
api: { bodyParser: false },
|
||||
};
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
// if (!process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS) {
|
||||
// return res.status(500).json({
|
||||
// success: false,
|
||||
// message: 'Subscriptions are not enabled',
|
||||
// });
|
||||
// }
|
||||
|
||||
const sig =
|
||||
typeof req.headers['stripe-signature'] === 'string' ? req.headers['stripe-signature'] : '';
|
||||
|
||||
if (!sig) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'No signature found in request',
|
||||
});
|
||||
}
|
||||
|
||||
log('constructing body...');
|
||||
const body = await buffer(req);
|
||||
log('constructed body');
|
||||
|
||||
const event = stripe.webhooks.constructEvent(
|
||||
body,
|
||||
sig,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion, turbo/no-undeclared-env-vars
|
||||
process.env.NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET!,
|
||||
);
|
||||
log('event-type:', event.type);
|
||||
|
||||
if (event.type === 'checkout.session.completed') {
|
||||
// This is required since we don't want to create a guard for every event type
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
|
||||
if (session.metadata?.source === 'landing') {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: Number(session.client_reference_id),
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'User not found',
|
||||
});
|
||||
}
|
||||
|
||||
const signatureText = session.metadata?.signatureText || user.name;
|
||||
let signatureDataUrl = '';
|
||||
|
||||
if (session.metadata?.signatureDataUrl) {
|
||||
const result = await redis.get<string>(`signature:${session.metadata.signatureDataUrl}`);
|
||||
|
||||
if (result) {
|
||||
signatureDataUrl = result;
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const bytes64 = readFileSync('./public/documenso-supporter-pledge.pdf').toString('base64');
|
||||
|
||||
const { id: documentDataId } = await prisma.documentData.create({
|
||||
data: {
|
||||
type: DocumentDataType.BYTES_64,
|
||||
data: bytes64,
|
||||
initialData: bytes64,
|
||||
},
|
||||
});
|
||||
|
||||
const document = await prisma.document.create({
|
||||
data: {
|
||||
title: 'Documenso Supporter Pledge.pdf',
|
||||
status: DocumentStatus.COMPLETED,
|
||||
userId: user.id,
|
||||
documentDataId,
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { documentData } = document;
|
||||
|
||||
if (!documentData) {
|
||||
throw new Error(`Document ${document.id} has no document data`);
|
||||
}
|
||||
|
||||
const recipient = await prisma.recipient.create({
|
||||
data: {
|
||||
name: user.name ?? '',
|
||||
email: user.email,
|
||||
token: randomBytes(16).toString('hex'),
|
||||
signedAt: now,
|
||||
readStatus: ReadStatus.OPENED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
documentId: document.id,
|
||||
},
|
||||
});
|
||||
|
||||
const field = await prisma.field.create({
|
||||
data: {
|
||||
documentId: document.id,
|
||||
recipientId: recipient.id,
|
||||
type: FieldType.SIGNATURE,
|
||||
page: 0,
|
||||
positionX: 77,
|
||||
positionY: 638,
|
||||
inserted: false,
|
||||
customText: '',
|
||||
},
|
||||
});
|
||||
|
||||
if (signatureDataUrl) {
|
||||
documentData.data = await insertImageInPDF(
|
||||
documentData.data,
|
||||
signatureDataUrl,
|
||||
field.positionX.toNumber(),
|
||||
field.positionY.toNumber(),
|
||||
field.page,
|
||||
);
|
||||
} else {
|
||||
documentData.data = await insertTextInPDF(
|
||||
documentData.data,
|
||||
signatureText ?? '',
|
||||
field.positionX.toNumber(),
|
||||
field.positionY.toNumber(),
|
||||
field.page,
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
prisma.signature.create({
|
||||
data: {
|
||||
fieldId: field.id,
|
||||
recipientId: recipient.id,
|
||||
signatureImageAsBase64: signatureDataUrl || undefined,
|
||||
typedSignature: signatureDataUrl ? '' : signatureText,
|
||||
},
|
||||
}),
|
||||
prisma.document.update({
|
||||
where: {
|
||||
id: document.id,
|
||||
},
|
||||
data: {
|
||||
documentData: {
|
||||
update: {
|
||||
data: documentData.data,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
});
|
||||
}
|
||||
|
||||
log('Unhandled webhook event', event.type);
|
||||
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Unhandled webhook event',
|
||||
});
|
||||
}
|
||||
export default stripeWebhookHandler;
|
||||
|
||||
@ -6,7 +6,3 @@ export default trpcNext.createNextApiHandler({
|
||||
router: appRouter,
|
||||
createContext: async ({ req, res }) => createTrpcContext({ req, res }),
|
||||
});
|
||||
|
||||
// export default async function handler(_req: NextApiRequest, res: NextApiResponse) {
|
||||
// res.json({ hello: 'world' });
|
||||
// }
|
||||
|
||||
394
package-lock.json
generated
394
package-lock.json
generated
@ -15,8 +15,8 @@
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^17.7.1",
|
||||
"@commitlint/config-conventional": "^17.7.0",
|
||||
"dotenv": "^16.0.3",
|
||||
"dotenv-cli": "^7.2.1",
|
||||
"dotenv": "^16.3.1",
|
||||
"dotenv-cli": "^7.3.0",
|
||||
"eslint": "^8.40.0",
|
||||
"eslint-config-custom": "*",
|
||||
"husky": "^8.0.0",
|
||||
@ -54,7 +54,7 @@
|
||||
"react-confetti": "^6.1.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hook-form": "^7.43.9",
|
||||
"react-icons": "^4.8.0",
|
||||
"react-icons": "^4.11.0",
|
||||
"recharts": "^2.7.2",
|
||||
"sharp": "0.32.5",
|
||||
"typescript": "5.1.6",
|
||||
@ -95,7 +95,7 @@
|
||||
"react-dom": "18.2.0",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-hook-form": "^7.43.9",
|
||||
"react-icons": "^4.8.0",
|
||||
"react-icons": "^4.11.0",
|
||||
"react-rnd": "^10.4.1",
|
||||
"sharp": "0.32.5",
|
||||
"ts-pattern": "^5.0.5",
|
||||
@ -1848,6 +1848,10 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@documenso/app-tests": {
|
||||
"resolved": "packages/app-tests",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@documenso/ee": {
|
||||
"resolved": "packages/ee",
|
||||
"link": true
|
||||
@ -2461,6 +2465,19 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@hapi/hoek": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
|
||||
"integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ=="
|
||||
},
|
||||
"node_modules/@hapi/topo": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz",
|
||||
"integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==",
|
||||
"dependencies": {
|
||||
"@hapi/hoek": "^9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hookform/resolvers": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.3.0.tgz",
|
||||
@ -3797,6 +3814,21 @@
|
||||
"url": "https://opencollective.com/unts"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.38.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.38.1.tgz",
|
||||
"integrity": "sha512-NqRp8XMwj3AK+zKLbZShl0r/9wKgzqI/527bkptKXomtuo+dOjU9NdMASQ8DNC9z9zLOMbG53T4eihYr3XR+BQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"playwright": "1.38.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/client": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.3.1.tgz",
|
||||
@ -5446,6 +5478,24 @@
|
||||
"url": "https://ko-fi.com/killymxi"
|
||||
}
|
||||
},
|
||||
"node_modules/@sideway/address": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.4.tgz",
|
||||
"integrity": "sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw==",
|
||||
"dependencies": {
|
||||
"@hapi/hoek": "^9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sideway/formula": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz",
|
||||
"integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg=="
|
||||
},
|
||||
"node_modules/@sideway/pinpoint": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz",
|
||||
"integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ=="
|
||||
},
|
||||
"node_modules/@sindresorhus/slugify": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@sindresorhus/slugify/-/slugify-2.2.1.tgz",
|
||||
@ -7630,6 +7680,14 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/check-more-types": {
|
||||
"version": "2.24.0",
|
||||
"resolved": "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz",
|
||||
"integrity": "sha512-Pj779qHxV2tuapviy1bSZNEL1maXr13bPYpsvSDB68HlYcYuhlDrmGd63i0JHMCLKzc7rUSNIrpdJlhVlNwrxA==",
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "3.5.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
|
||||
@ -9103,7 +9161,6 @@
|
||||
"version": "16.3.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz",
|
||||
"integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@ -9112,13 +9169,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv-cli": {
|
||||
"version": "7.2.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv-cli/-/dotenv-cli-7.2.1.tgz",
|
||||
"integrity": "sha512-ODHbGTskqRtXAzZapDPvgNuDVQApu4oKX8lZW7Y0+9hKA6le1ZJlyRS687oU9FXjOVEDU/VFV6zI125HzhM1UQ==",
|
||||
"dev": true,
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/dotenv-cli/-/dotenv-cli-7.3.0.tgz",
|
||||
"integrity": "sha512-314CA4TyK34YEJ6ntBf80eUY+t1XaFLyem1k9P0sX1gn30qThZ5qZr/ZwE318gEnzyYP9yj9HJk6SqwE0upkfw==",
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.3",
|
||||
"dotenv": "^16.0.0",
|
||||
"dotenv": "^16.3.0",
|
||||
"dotenv-expand": "^10.0.0",
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
@ -9130,11 +9186,15 @@
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-10.0.0.tgz",
|
||||
"integrity": "sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/duplexer": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz",
|
||||
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="
|
||||
},
|
||||
"node_modules/duplexer2": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz",
|
||||
@ -10339,6 +10399,20 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/event-stream": {
|
||||
"version": "3.3.4",
|
||||
"resolved": "https://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz",
|
||||
"integrity": "sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==",
|
||||
"dependencies": {
|
||||
"duplexer": "~0.1.1",
|
||||
"from": "~0",
|
||||
"map-stream": "~0.1.0",
|
||||
"pause-stream": "0.0.11",
|
||||
"split": "0.3",
|
||||
"stream-combiner": "~0.0.4",
|
||||
"through": "~2.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||
@ -10738,6 +10812,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/from": {
|
||||
"version": "0.1.7",
|
||||
"resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz",
|
||||
"integrity": "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g=="
|
||||
},
|
||||
"node_modules/fs-constants": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||
@ -12176,6 +12255,18 @@
|
||||
"resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz",
|
||||
"integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA=="
|
||||
},
|
||||
"node_modules/joi": {
|
||||
"version": "17.10.2",
|
||||
"resolved": "https://registry.npmjs.org/joi/-/joi-17.10.2.tgz",
|
||||
"integrity": "sha512-hcVhjBxRNW/is3nNLdGLIjkgXetkeGc2wyhydhz8KumG23Aerk4HPjU5zaPAMRqXQFc0xNqXTC7+zQjxr0GlKA==",
|
||||
"dependencies": {
|
||||
"@hapi/hoek": "^9.0.0",
|
||||
"@hapi/topo": "^5.0.0",
|
||||
"@sideway/address": "^4.1.3",
|
||||
"@sideway/formula": "^3.0.1",
|
||||
"@sideway/pinpoint": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "4.14.4",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-4.14.4.tgz",
|
||||
@ -12410,6 +12501,14 @@
|
||||
"language-subtag-registry": "~0.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lazy-ass": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz",
|
||||
"integrity": "sha512-cc8oEVoctTvsFZ/Oje/kGnHbpWHYBe8IAJe4C0QNc3t8uM/0Y8+erSz/7Y1ALuXTEZTMvxXwO6YbX1ey3ujiZw==",
|
||||
"engines": {
|
||||
"node": "> 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/leac": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz",
|
||||
@ -12957,6 +13056,11 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/map-stream": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz",
|
||||
"integrity": "sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g=="
|
||||
},
|
||||
"node_modules/markdown-extensions": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/markdown-extensions/-/markdown-extensions-1.1.1.tgz",
|
||||
@ -15010,6 +15114,14 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pause-stream": {
|
||||
"version": "0.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz",
|
||||
"integrity": "sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==",
|
||||
"dependencies": {
|
||||
"through": "~2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/pdf-lib": {
|
||||
"version": "1.17.1",
|
||||
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
|
||||
@ -15108,6 +15220,36 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.38.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.38.1.tgz",
|
||||
"integrity": "sha512-oRMSJmZrOu1FP5iu3UrCx8JEFRIMxLDM0c/3o4bpzU5Tz97BypefWf7TuTNPWeCe279TPal5RtPPZ+9lW/Qkow==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"playwright-core": "1.38.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.38.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.38.1.tgz",
|
||||
"integrity": "sha512-tQqNFUKa3OfMf4b2jQ7aGLB8o9bS3bOY0yMEtldtC2+spf8QXG9zvXLTXUeRsoNuxEYMgLYR+NXfAa1rjKRcrg==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.4.27",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.27.tgz",
|
||||
@ -15658,6 +15800,20 @@
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz",
|
||||
"integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q=="
|
||||
},
|
||||
"node_modules/ps-tree": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ps-tree/-/ps-tree-1.2.0.tgz",
|
||||
"integrity": "sha512-0VnamPPYHl4uaU/nSFeZZpR21QAWRz+sRv4iW9+v/GS/J5U5iZB5BNN6J0RMoOvdx2gWM2+ZFMIm58q24e4UYA==",
|
||||
"dependencies": {
|
||||
"event-stream": "=3.3.4"
|
||||
},
|
||||
"bin": {
|
||||
"ps-tree": "bin/ps-tree.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
|
||||
@ -16359,9 +16515,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-icons": {
|
||||
"version": "4.10.1",
|
||||
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.10.1.tgz",
|
||||
"integrity": "sha512-/ngzDP/77tlCfqthiiGNZeYFACw85fUjZtLbedmJ5DTlNDIwETxhwBzdOJ21zj4iJdvc0J3y7yOsX3PpxAJzrw==",
|
||||
"version": "4.11.0",
|
||||
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.11.0.tgz",
|
||||
"integrity": "sha512-V+4khzYcE5EBk/BvcuYRq6V/osf11ODUM2J8hg2FDSswRrGvqiYUYPRy4OdrWaQOBj4NcpJfmHZLNaD+VH0TyA==",
|
||||
"peerDependencies": {
|
||||
"react": "*"
|
||||
}
|
||||
@ -17301,6 +17457,14 @@
|
||||
"resolved": "https://registry.npmjs.org/rusha/-/rusha-0.8.14.tgz",
|
||||
"integrity": "sha512-cLgakCUf6PedEu15t8kbsjnwIFFR2D4RfL+W3iWFJ4iac7z4B0ZI8fxy4R3J956kAI68HclCFGL8MPoUVC3qVA=="
|
||||
},
|
||||
"node_modules/rxjs": {
|
||||
"version": "7.8.1",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
|
||||
"integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sade": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
|
||||
@ -17676,6 +17840,17 @@
|
||||
"resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.13.tgz",
|
||||
"integrity": "sha512-XkD+zwiqXHikFZm4AX/7JSCXA98U5Db4AFd5XUg/+9UNtnH75+Z9KxtpYiJZx36mUDVOwH83pl7yvCer6ewM3w=="
|
||||
},
|
||||
"node_modules/split": {
|
||||
"version": "0.3.3",
|
||||
"resolved": "https://registry.npmjs.org/split/-/split-0.3.3.tgz",
|
||||
"integrity": "sha512-wD2AeVmxXRBoX44wAycgjVpMhvbwdI2aZjCkvfNcH1YqHQvJVa1duWc73OyVGJUc05fhFaTZeQ/PYsrmyH0JVA==",
|
||||
"dependencies": {
|
||||
"through": "2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-3.2.2.tgz",
|
||||
@ -17707,6 +17882,121 @@
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
|
||||
},
|
||||
"node_modules/start-server-and-test": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/start-server-and-test/-/start-server-and-test-2.0.1.tgz",
|
||||
"integrity": "sha512-8PFo4DLLLCDMuS51/BEEtE1m9CAXw1LNVtZSS1PzkYQh6Qf9JUwM4huYeSoUumaaoAyuwYBwCa9OsrcpMqcOdQ==",
|
||||
"dependencies": {
|
||||
"arg": "^5.0.2",
|
||||
"bluebird": "3.7.2",
|
||||
"check-more-types": "2.24.0",
|
||||
"debug": "4.3.4",
|
||||
"execa": "5.1.1",
|
||||
"lazy-ass": "1.6.0",
|
||||
"ps-tree": "1.2.0",
|
||||
"wait-on": "7.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"server-test": "src/bin/start.js",
|
||||
"start-server-and-test": "src/bin/start.js",
|
||||
"start-test": "src/bin/start.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/start-server-and-test/node_modules/arg": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
||||
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="
|
||||
},
|
||||
"node_modules/start-server-and-test/node_modules/bluebird": {
|
||||
"version": "3.7.2",
|
||||
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
|
||||
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="
|
||||
},
|
||||
"node_modules/start-server-and-test/node_modules/execa": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
|
||||
"integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.3",
|
||||
"get-stream": "^6.0.0",
|
||||
"human-signals": "^2.1.0",
|
||||
"is-stream": "^2.0.0",
|
||||
"merge-stream": "^2.0.0",
|
||||
"npm-run-path": "^4.0.1",
|
||||
"onetime": "^5.1.2",
|
||||
"signal-exit": "^3.0.3",
|
||||
"strip-final-newline": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sindresorhus/execa?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/start-server-and-test/node_modules/human-signals": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
|
||||
"integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
|
||||
"engines": {
|
||||
"node": ">=10.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/start-server-and-test/node_modules/is-stream": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
||||
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/start-server-and-test/node_modules/mimic-fn": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
|
||||
"integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/start-server-and-test/node_modules/npm-run-path": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
|
||||
"integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
|
||||
"dependencies": {
|
||||
"path-key": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/start-server-and-test/node_modules/onetime": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
|
||||
"integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
|
||||
"dependencies": {
|
||||
"mimic-fn": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/start-server-and-test/node_modules/strip-final-newline": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
|
||||
"integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz",
|
||||
@ -17715,6 +18005,14 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/stream-combiner": {
|
||||
"version": "0.0.4",
|
||||
"resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.0.4.tgz",
|
||||
"integrity": "sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==",
|
||||
"dependencies": {
|
||||
"duplexer": "~0.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/stream-shift": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz",
|
||||
@ -18260,8 +18558,7 @@
|
||||
"node_modules/through": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
|
||||
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
|
||||
"dev": true
|
||||
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="
|
||||
},
|
||||
"node_modules/through2": {
|
||||
"version": "4.0.2",
|
||||
@ -19125,6 +19422,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "5.25.3",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.25.3.tgz",
|
||||
"integrity": "sha512-Ga1jfYwRn7+cP9v8auvEXN1rX3sWqlayd4HP7OKk4mZWylEmu3KzXDUGrQUN6Ol7qo1gPvB2e5gX6udnyEPgdA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/unified": {
|
||||
"version": "10.1.2",
|
||||
"resolved": "https://registry.npmjs.org/unified/-/unified-10.1.2.tgz",
|
||||
@ -19522,6 +19825,24 @@
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/wait-on": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-7.0.1.tgz",
|
||||
"integrity": "sha512-9AnJE9qTjRQOlTZIldAaf/da2eW0eSRSgcqq85mXQja/DW3MriHxkpODDSUEg+Gri/rKEcXUZHe+cevvYItaog==",
|
||||
"dependencies": {
|
||||
"axios": "^0.27.2",
|
||||
"joi": "^17.7.0",
|
||||
"lodash": "^4.17.21",
|
||||
"minimist": "^1.2.7",
|
||||
"rxjs": "^7.8.0"
|
||||
},
|
||||
"bin": {
|
||||
"wait-on": "bin/wait-on"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/watchpack": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz",
|
||||
@ -19791,15 +20112,49 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"packages/app-tests": {
|
||||
"name": "@documenso/app-tests",
|
||||
"version": "1.0.0",
|
||||
"license": "to-update",
|
||||
"dependencies": {
|
||||
"start-server-and-test": "^2.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@documenso/web": "*",
|
||||
"@playwright/test": "^1.18.1",
|
||||
"@types/node": "^20.8.2"
|
||||
}
|
||||
},
|
||||
"packages/app-tests/node_modules/@types/node": {
|
||||
"version": "20.8.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.4.tgz",
|
||||
"integrity": "sha512-ZVPnqU58giiCjSxjVUESDtdPk4QR5WQhhINbc9UBrKLU68MX5BF6kbQzTrkwbolyr0X8ChBpXfavr5mZFKZQ5A==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~5.25.1"
|
||||
}
|
||||
},
|
||||
"packages/ee": {
|
||||
"name": "@documenso/ee",
|
||||
"version": "1.0.0",
|
||||
"license": "COMMERCIAL",
|
||||
"dependencies": {
|
||||
"@documenso/lib": "*",
|
||||
"@documenso/prisma": "*"
|
||||
"@documenso/prisma": "*",
|
||||
"luxon": "^3.4.0",
|
||||
"micro": "^10.0.1",
|
||||
"next": "13.4.19",
|
||||
"next-auth": "4.22.3",
|
||||
"react": "18.2.0",
|
||||
"ts-pattern": "^5.0.5",
|
||||
"zod": "^3.21.4"
|
||||
}
|
||||
},
|
||||
"packages/ee/node_modules/ts-pattern": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.0.5.tgz",
|
||||
"integrity": "sha512-tL0w8U/pgaacOmkb9fRlYzWEUDCfVjjv9dD4wHTgZ61MjhuMt46VNWTG747NqW6vRzoWIKABVhFSOJ82FvXrfA=="
|
||||
},
|
||||
"packages/email": {
|
||||
"name": "@documenso/email",
|
||||
"version": "1.0.0",
|
||||
@ -19859,7 +20214,8 @@
|
||||
"pdf-lib": "^1.17.1",
|
||||
"react": "18.2.0",
|
||||
"stripe": "^12.7.0",
|
||||
"ts-pattern": "^5.0.5"
|
||||
"ts-pattern": "^5.0.5",
|
||||
"zod": "^3.21.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
@ -19889,6 +20245,8 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@prisma/client": "5.3.1",
|
||||
"dotenv": "^16.3.1",
|
||||
"dotenv-cli": "^7.3.0",
|
||||
"prisma": "5.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
13
package.json
13
package.json
@ -2,6 +2,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"build:web": "turbo run build --filter=@documenso/web",
|
||||
"dev": "turbo run dev --filter=@documenso/web --filter=@documenso/marketing",
|
||||
"start": "cd apps && cd web && next start",
|
||||
"lint": "turbo run lint",
|
||||
@ -10,9 +11,13 @@
|
||||
"commitlint": "commitlint --edit",
|
||||
"clean": "turbo run clean && rimraf node_modules",
|
||||
"d": "npm run dx && npm run dev",
|
||||
"dx": "npm i && npm run dx:up && npm run prisma:migrate-dev -w @documesno/prisma",
|
||||
"dx": "npm i && npm run dx:up && npm run prisma:migrate-dev",
|
||||
"dx:up": "docker compose -f docker/compose-services.yml up -d",
|
||||
"dx:down": "docker compose -f docker/compose-services.yml down"
|
||||
"dx:down": "docker compose -f docker/compose-services.yml down",
|
||||
"ci": "turbo run build test:e2e",
|
||||
"prisma:migrate-dev": "npm run with:env -- npm run prisma:migrate-dev -w @documenso/prisma",
|
||||
"prisma:migrate-deploy": "npm run with:env -- npm run prisma:migrate-deploy -w @documenso/prisma",
|
||||
"with:env": "dotenv -e .env -e .env.local --"
|
||||
},
|
||||
"engines": {
|
||||
"npm": ">=8.6.0",
|
||||
@ -21,8 +26,8 @@
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^17.7.1",
|
||||
"@commitlint/config-conventional": "^17.7.0",
|
||||
"dotenv": "^16.0.3",
|
||||
"dotenv-cli": "^7.2.1",
|
||||
"dotenv": "^16.3.1",
|
||||
"dotenv-cli": "^7.3.0",
|
||||
"eslint": "^8.40.0",
|
||||
"eslint-config-custom": "*",
|
||||
"husky": "^8.0.0",
|
||||
|
||||
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,
|
||||
// },
|
||||
});
|
||||
@ -14,6 +14,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@documenso/lib": "*",
|
||||
"@documenso/prisma": "*"
|
||||
"@documenso/prisma": "*",
|
||||
"luxon": "^3.4.0",
|
||||
"micro": "^10.0.1",
|
||||
"next": "13.4.19",
|
||||
"next-auth": "4.22.3",
|
||||
"react": "18.2.0",
|
||||
"ts-pattern": "^5.0.5",
|
||||
"zod": "^3.21.4"
|
||||
}
|
||||
}
|
||||
|
||||
31
packages/ee/server-only/limits/client.ts
Normal file
31
packages/ee/server-only/limits/client.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { APP_BASE_URL } from '@documenso/lib/constants/app';
|
||||
|
||||
import { FREE_PLAN_LIMITS } from './constants';
|
||||
import { TLimitsResponseSchema, ZLimitsResponseSchema } from './schema';
|
||||
|
||||
export type GetLimitsOptions = {
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
|
||||
export const getLimits = async ({ headers }: GetLimitsOptions = {}) => {
|
||||
const requestHeaders = headers ?? {};
|
||||
|
||||
const url = new URL(`${APP_BASE_URL}/api/limits`);
|
||||
|
||||
return fetch(url, {
|
||||
headers: {
|
||||
...requestHeaders,
|
||||
},
|
||||
next: {
|
||||
revalidate: 60,
|
||||
},
|
||||
})
|
||||
.then(async (res) => res.json())
|
||||
.then((res) => ZLimitsResponseSchema.parse(res))
|
||||
.catch(() => {
|
||||
return {
|
||||
quota: FREE_PLAN_LIMITS,
|
||||
remaining: FREE_PLAN_LIMITS,
|
||||
} satisfies TLimitsResponseSchema;
|
||||
});
|
||||
};
|
||||
11
packages/ee/server-only/limits/constants.ts
Normal file
11
packages/ee/server-only/limits/constants.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { TLimitsSchema } from './schema';
|
||||
|
||||
export const FREE_PLAN_LIMITS: TLimitsSchema = {
|
||||
documents: 5,
|
||||
recipients: 10,
|
||||
};
|
||||
|
||||
export const SELFHOSTED_PLAN_LIMITS: TLimitsSchema = {
|
||||
documents: Infinity,
|
||||
recipients: Infinity,
|
||||
};
|
||||
6
packages/ee/server-only/limits/errors.ts
Normal file
6
packages/ee/server-only/limits/errors.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export const ERROR_CODES: Record<string, string> = {
|
||||
UNAUTHORIZED: 'You must be logged in to access this resource',
|
||||
USER_FETCH_FAILED: 'An error occurred while fetching your user account',
|
||||
SUBSCRIPTION_FETCH_FAILED: 'An error occurred while fetching your subscription',
|
||||
UNKNOWN: 'An unknown error occurred',
|
||||
};
|
||||
54
packages/ee/server-only/limits/handler.ts
Normal file
54
packages/ee/server-only/limits/handler.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import { getToken } from 'next-auth/jwt';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { withStaleWhileRevalidate } from '@documenso/lib/server-only/http/with-swr';
|
||||
import { getFlag } from '@documenso/lib/universal/get-feature-flag';
|
||||
|
||||
import { SELFHOSTED_PLAN_LIMITS } from './constants';
|
||||
import { ERROR_CODES } from './errors';
|
||||
import { TLimitsErrorResponseSchema, TLimitsResponseSchema } from './schema';
|
||||
import { getServerLimits } from './server';
|
||||
|
||||
export const limitsHandler = async (
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<TLimitsResponseSchema | TLimitsErrorResponseSchema>,
|
||||
) => {
|
||||
try {
|
||||
const token = await getToken({ req });
|
||||
|
||||
const isBillingEnabled = await getFlag('app_billing');
|
||||
|
||||
if (!isBillingEnabled) {
|
||||
return withStaleWhileRevalidate<typeof res>(res).status(200).json({
|
||||
quota: SELFHOSTED_PLAN_LIMITS,
|
||||
remaining: SELFHOSTED_PLAN_LIMITS,
|
||||
});
|
||||
}
|
||||
|
||||
if (!token?.email) {
|
||||
throw new Error(ERROR_CODES.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
const limits = await getServerLimits({ email: token.email });
|
||||
|
||||
return withStaleWhileRevalidate<typeof res>(res).status(200).json(limits);
|
||||
} catch (err) {
|
||||
console.error('error', err);
|
||||
|
||||
if (err instanceof Error) {
|
||||
const status = match(err.message)
|
||||
.with(ERROR_CODES.UNAUTHORIZED, () => 401)
|
||||
.otherwise(() => 500);
|
||||
|
||||
return res.status(status).json({
|
||||
error: ERROR_CODES[err.message] ?? ERROR_CODES.UNKNOWN,
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
error: ERROR_CODES.UNKNOWN,
|
||||
});
|
||||
}
|
||||
};
|
||||
53
packages/ee/server-only/limits/provider/client.tsx
Normal file
53
packages/ee/server-only/limits/provider/client.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
import { getLimits } from '../client';
|
||||
import { FREE_PLAN_LIMITS } from '../constants';
|
||||
import { TLimitsResponseSchema } from '../schema';
|
||||
|
||||
export type LimitsContextValue = TLimitsResponseSchema;
|
||||
|
||||
const LimitsContext = createContext<LimitsContextValue | null>(null);
|
||||
|
||||
export const useLimits = () => {
|
||||
const limits = useContext(LimitsContext);
|
||||
|
||||
if (!limits) {
|
||||
throw new Error('useLimits must be used within a LimitsProvider');
|
||||
}
|
||||
|
||||
return limits;
|
||||
};
|
||||
|
||||
export type LimitsProviderProps = {
|
||||
initialValue?: LimitsContextValue;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const LimitsProvider = ({ initialValue, children }: LimitsProviderProps) => {
|
||||
const defaultValue: TLimitsResponseSchema = {
|
||||
quota: FREE_PLAN_LIMITS,
|
||||
remaining: FREE_PLAN_LIMITS,
|
||||
};
|
||||
|
||||
const [limits, setLimits] = useState(() => initialValue ?? defaultValue);
|
||||
|
||||
useEffect(() => {
|
||||
void getLimits().then((limits) => setLimits(limits));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onFocus = () => {
|
||||
void getLimits().then((limits) => setLimits(limits));
|
||||
};
|
||||
|
||||
window.addEventListener('focus', onFocus);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('focus', onFocus);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <LimitsContext.Provider value={limits}>{children}</LimitsContext.Provider>;
|
||||
};
|
||||
18
packages/ee/server-only/limits/provider/server.tsx
Normal file
18
packages/ee/server-only/limits/provider/server.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
'use server';
|
||||
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
import { getLimits } from '../client';
|
||||
import { LimitsProvider as ClientLimitsProvider } from './client';
|
||||
|
||||
export type LimitsProviderProps = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const LimitsProvider = async ({ children }: LimitsProviderProps) => {
|
||||
const requestHeaders = Object.fromEntries(headers().entries());
|
||||
|
||||
const limits = await getLimits({ headers: requestHeaders });
|
||||
|
||||
return <ClientLimitsProvider initialValue={limits}>{children}</ClientLimitsProvider>;
|
||||
};
|
||||
28
packages/ee/server-only/limits/schema.ts
Normal file
28
packages/ee/server-only/limits/schema.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// Not proud of the below but it's a way to deal with Infinity when returning JSON.
|
||||
export const ZLimitsSchema = z.object({
|
||||
documents: z
|
||||
.preprocess((v) => (v === null ? Infinity : Number(v)), z.number())
|
||||
.optional()
|
||||
.default(0),
|
||||
recipients: z
|
||||
.preprocess((v) => (v === null ? Infinity : Number(v)), z.number())
|
||||
.optional()
|
||||
.default(0),
|
||||
});
|
||||
|
||||
export type TLimitsSchema = z.infer<typeof ZLimitsSchema>;
|
||||
|
||||
export const ZLimitsResponseSchema = z.object({
|
||||
quota: ZLimitsSchema,
|
||||
remaining: ZLimitsSchema,
|
||||
});
|
||||
|
||||
export type TLimitsResponseSchema = z.infer<typeof ZLimitsResponseSchema>;
|
||||
|
||||
export const ZLimitsErrorResponseSchema = z.object({
|
||||
error: z.string(),
|
||||
});
|
||||
|
||||
export type TLimitsErrorResponseSchema = z.infer<typeof ZLimitsErrorResponseSchema>;
|
||||
78
packages/ee/server-only/limits/server.ts
Normal file
78
packages/ee/server-only/limits/server.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { getFlag } from '@documenso/lib/universal/get-feature-flag';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS } from './constants';
|
||||
import { ERROR_CODES } from './errors';
|
||||
import { ZLimitsSchema } from './schema';
|
||||
|
||||
export type GetServerLimitsOptions = {
|
||||
email?: string;
|
||||
};
|
||||
|
||||
export const getServerLimits = async ({ email }: GetServerLimitsOptions) => {
|
||||
const isBillingEnabled = await getFlag('app_billing');
|
||||
|
||||
if (!isBillingEnabled) {
|
||||
return {
|
||||
quota: SELFHOSTED_PLAN_LIMITS,
|
||||
remaining: SELFHOSTED_PLAN_LIMITS,
|
||||
};
|
||||
}
|
||||
|
||||
if (!email) {
|
||||
throw new Error(ERROR_CODES.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
include: {
|
||||
Subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error(ERROR_CODES.USER_FETCH_FAILED);
|
||||
}
|
||||
|
||||
let quota = structuredClone(FREE_PLAN_LIMITS);
|
||||
let remaining = structuredClone(FREE_PLAN_LIMITS);
|
||||
|
||||
if (user.Subscription?.priceId) {
|
||||
const { product } = await stripe.prices
|
||||
.retrieve(user.Subscription.priceId, {
|
||||
expand: ['product'],
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
throw err;
|
||||
});
|
||||
|
||||
if (typeof product === 'string') {
|
||||
throw new Error(ERROR_CODES.SUBSCRIPTION_FETCH_FAILED);
|
||||
}
|
||||
|
||||
quota = ZLimitsSchema.parse('metadata' in product ? product.metadata : {});
|
||||
remaining = structuredClone(quota);
|
||||
}
|
||||
|
||||
const documents = await prisma.document.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
createdAt: {
|
||||
gte: DateTime.utc().startOf('month').toJSDate(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
remaining.documents = Math.max(remaining.documents - documents, 0);
|
||||
|
||||
return {
|
||||
quota,
|
||||
remaining,
|
||||
};
|
||||
};
|
||||
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;
|
||||
};
|
||||
17
packages/ee/server-only/stripe/get-product-by-price-id.ts
Normal file
17
packages/ee/server-only/stripe/get-product-by-price-id.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
|
||||
export type GetProductByPriceIdOptions = {
|
||||
priceId: string;
|
||||
};
|
||||
|
||||
export const getProductByPriceId = async ({ priceId }: GetProductByPriceIdOptions) => {
|
||||
const { product } = await stripe.prices.retrieve(priceId, {
|
||||
expand: ['product'],
|
||||
});
|
||||
|
||||
if (typeof product === 'string' || 'deleted' in product) {
|
||||
throw new Error('Product not found');
|
||||
}
|
||||
|
||||
return product;
|
||||
};
|
||||
224
packages/ee/server-only/stripe/webhook/handler.ts
Normal file
224
packages/ee/server-only/stripe/webhook/handler.ts
Normal file
@ -0,0 +1,224 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import { buffer } from 'micro';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { getFlag } from '@documenso/lib/universal/get-feature-flag';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { onSubscriptionDeleted } from './on-subscription-deleted';
|
||||
import { onSubscriptionUpdated } from './on-subscription-updated';
|
||||
|
||||
type StripeWebhookResponse = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export const stripeWebhookHandler = async (
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<StripeWebhookResponse>,
|
||||
) => {
|
||||
try {
|
||||
const isBillingEnabled = await getFlag('app_billing');
|
||||
|
||||
if (!isBillingEnabled) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'Billing is disabled',
|
||||
});
|
||||
}
|
||||
|
||||
const signature =
|
||||
typeof req.headers['stripe-signature'] === 'string' ? req.headers['stripe-signature'] : '';
|
||||
|
||||
if (!signature) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'No signature found in request',
|
||||
});
|
||||
}
|
||||
|
||||
const body = await buffer(req);
|
||||
|
||||
const event = stripe.webhooks.constructEvent(
|
||||
body,
|
||||
signature,
|
||||
process.env.NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET,
|
||||
);
|
||||
|
||||
await match(event.type)
|
||||
.with('checkout.session.completed', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
|
||||
const userId = Number(session.client_reference_id);
|
||||
const subscriptionId =
|
||||
typeof session.subscription === 'string'
|
||||
? session.subscription
|
||||
: session.subscription?.id;
|
||||
|
||||
if (!subscriptionId || Number.isNaN(userId)) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'Invalid session',
|
||||
});
|
||||
}
|
||||
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||
|
||||
await onSubscriptionUpdated({ userId, subscription });
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
});
|
||||
})
|
||||
.with('customer.subscription.updated', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
|
||||
const customerId =
|
||||
typeof subscription.customer === 'string'
|
||||
? subscription.customer
|
||||
: subscription.customer.id;
|
||||
|
||||
const result = await prisma.subscription.findFirst({
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result?.userId) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'User not found',
|
||||
});
|
||||
}
|
||||
|
||||
await onSubscriptionUpdated({ userId: result.userId, subscription });
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
});
|
||||
})
|
||||
.with('invoice.payment_succeeded', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const invoice = event.data.object as Stripe.Invoice;
|
||||
|
||||
if (invoice.billing_reason !== 'subscription_cycle') {
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
});
|
||||
}
|
||||
|
||||
const customerId =
|
||||
typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id;
|
||||
|
||||
const subscriptionId =
|
||||
typeof invoice.subscription === 'string'
|
||||
? invoice.subscription
|
||||
: invoice.subscription?.id;
|
||||
|
||||
if (!customerId || !subscriptionId) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'Invalid invoice',
|
||||
});
|
||||
}
|
||||
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||
|
||||
const result = await prisma.subscription.findFirst({
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result?.userId) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'User not found',
|
||||
});
|
||||
}
|
||||
|
||||
await onSubscriptionUpdated({ userId: result.userId, subscription });
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
});
|
||||
})
|
||||
.with('invoice.payment_failed', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const invoice = event.data.object as Stripe.Invoice;
|
||||
|
||||
const customerId =
|
||||
typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id;
|
||||
|
||||
const subscriptionId =
|
||||
typeof invoice.subscription === 'string'
|
||||
? invoice.subscription
|
||||
: invoice.subscription?.id;
|
||||
|
||||
if (!customerId || !subscriptionId) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'Invalid invoice',
|
||||
});
|
||||
}
|
||||
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||
|
||||
const result = await prisma.subscription.findFirst({
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result?.userId) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'User not found',
|
||||
});
|
||||
}
|
||||
|
||||
await onSubscriptionUpdated({ userId: result.userId, subscription });
|
||||
})
|
||||
.with('customer.subscription.deleted', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
|
||||
await onSubscriptionDeleted({ subscription });
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
});
|
||||
})
|
||||
.otherwise(() => {
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Unknown error',
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,21 @@
|
||||
import { Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { SubscriptionStatus } from '@documenso/prisma/client';
|
||||
|
||||
export type OnSubscriptionDeletedOptions = {
|
||||
subscription: Stripe.Subscription;
|
||||
};
|
||||
|
||||
export const onSubscriptionDeleted = async ({ subscription }: OnSubscriptionDeletedOptions) => {
|
||||
const customerId =
|
||||
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer?.id;
|
||||
|
||||
await prisma.subscription.update({
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
data: {
|
||||
status: SubscriptionStatus.INACTIVE,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,44 @@
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { SubscriptionStatus } from '@documenso/prisma/client';
|
||||
|
||||
export type OnSubscriptionUpdatedOptions = {
|
||||
userId: number;
|
||||
subscription: Stripe.Subscription;
|
||||
};
|
||||
|
||||
export const onSubscriptionUpdated = async ({
|
||||
userId,
|
||||
subscription,
|
||||
}: OnSubscriptionUpdatedOptions) => {
|
||||
const customerId =
|
||||
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer?.id;
|
||||
|
||||
const status = match(subscription.status)
|
||||
.with('active', () => SubscriptionStatus.ACTIVE)
|
||||
.with('past_due', () => SubscriptionStatus.PAST_DUE)
|
||||
.otherwise(() => SubscriptionStatus.INACTIVE);
|
||||
|
||||
await prisma.subscription.upsert({
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
create: {
|
||||
customerId,
|
||||
status: status,
|
||||
planId: subscription.id,
|
||||
priceId: subscription.items.data[0].price.id,
|
||||
periodEnd: new Date(subscription.current_period_end * 1000),
|
||||
userId,
|
||||
},
|
||||
update: {
|
||||
customerId,
|
||||
status: status,
|
||||
planId: subscription.id,
|
||||
priceId: subscription.items.data[0].price.id,
|
||||
periodEnd: new Date(subscription.current_period_end * 1000),
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -60,26 +60,17 @@ export const calculateTextScaleSize = (
|
||||
*/
|
||||
export function useElementScaleSize(
|
||||
container: { width: number; height: number },
|
||||
child: RefObject<HTMLElement | null>,
|
||||
text: string,
|
||||
fontSize: number,
|
||||
fontFamily: string,
|
||||
) {
|
||||
const [scalingFactor, setScalingFactor] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (!child.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scaleSize = calculateTextScaleSize(
|
||||
container,
|
||||
child.current.innerText,
|
||||
`${fontSize}px`,
|
||||
fontFamily,
|
||||
);
|
||||
const scaleSize = calculateTextScaleSize(container, text, `${fontSize}px`, fontFamily);
|
||||
|
||||
setScalingFactor(scaleSize);
|
||||
}, [child, container, fontFamily, fontSize]);
|
||||
}, [text, container, fontFamily, fontSize]);
|
||||
|
||||
return scalingFactor;
|
||||
}
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
import { Role, User } from '@documenso/prisma/client';
|
||||
|
||||
const isAdmin = (user: User) => user.roles.includes(Role.ADMIN);
|
||||
|
||||
export { isAdmin };
|
||||
export const isAdmin = (user: User) => user.roles.includes(Role.ADMIN);
|
||||
|
||||
@ -33,7 +33,8 @@
|
||||
"pdf-lib": "^1.17.1",
|
||||
"react": "18.2.0",
|
||||
"stripe": "^12.7.0",
|
||||
"ts-pattern": "^5.0.5"
|
||||
"ts-pattern": "^5.0.5",
|
||||
"zod": "^3.21.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -9,9 +9,7 @@ export const getUsersWithSubscriptionsCount = async () => {
|
||||
return await prisma.user.count({
|
||||
where: {
|
||||
Subscription: {
|
||||
some: {
|
||||
status: SubscriptionStatus.ACTIVE,
|
||||
},
|
||||
status: SubscriptionStatus.ACTIVE,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
};
|
||||
13
packages/lib/server-only/document/delete-draft-document.ts
Normal file
13
packages/lib/server-only/document/delete-draft-document.ts
Normal file
@ -0,0 +1,13 @@
|
||||
'use server';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
|
||||
export type DeleteDraftDocumentOptions = {
|
||||
id: number;
|
||||
userId: number;
|
||||
};
|
||||
|
||||
export const deleteDraftDocument = async ({ id, userId }: DeleteDraftDocumentOptions) => {
|
||||
return await prisma.document.delete({ where: { id, userId, status: DocumentStatus.DRAFT } });
|
||||
};
|
||||
9
packages/lib/server-only/http/to-next-request.ts
Normal file
9
packages/lib/server-only/http/to-next-request.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
export const toNextRequest = (req: Request) => {
|
||||
const headers = Object.fromEntries(req.headers.entries());
|
||||
|
||||
return new NextRequest(req, {
|
||||
headers: headers,
|
||||
});
|
||||
};
|
||||
28
packages/lib/server-only/http/with-swr.ts
Normal file
28
packages/lib/server-only/http/with-swr.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { NextApiResponse } from 'next';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
type NarrowedResponse<T> = T extends NextResponse
|
||||
? NextResponse
|
||||
: T extends NextApiResponse<infer U>
|
||||
? NextApiResponse<U>
|
||||
: never;
|
||||
|
||||
export const withStaleWhileRevalidate = <T>(
|
||||
res: NarrowedResponse<T>,
|
||||
cacheInSeconds = 60,
|
||||
staleCacheInSeconds = 300,
|
||||
) => {
|
||||
if ('headers' in res) {
|
||||
res.headers.set(
|
||||
'Cache-Control',
|
||||
`public, s-maxage=${cacheInSeconds}, stale-while-revalidate=${staleCacheInSeconds}`,
|
||||
);
|
||||
} else {
|
||||
res.setHeader(
|
||||
'Cache-Control',
|
||||
`public, s-maxage=${cacheInSeconds}, stale-while-revalidate=${staleCacheInSeconds}`,
|
||||
);
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
@ -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 ?? '', {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user