Compare commits

...
Author SHA1 Message Date
Marc Seitz bfc02040ca fix: limit source files 2026-03-05 21:42:34 +11:00
Marc Seitz 1e65b74b8d Merge branch 'codex/agent' of github.com:mfts/papermark into codex/agent 2026-03-05 17:58:56 +11:00
Marc Seitz a0f91e4b93 fix 2026-03-05 17:58:34 +11:00
Marc SeitzGitHubcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
6ead6e6410 Update ee/features/ai/components/chat-message.tsx
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-03-05 17:49:36 +11:00
Marc SeitzGitHubCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
0df4ab5a74 Potential fix for code scanning alert no. 92: Incomplete string escaping or encoding
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-03-05 17:47:52 +11:00
Marc Seitz afa7e0fa7d feat(ee): improve agents 2026-03-05 17:38:18 +11:00
Marc SeitzandGitHub 120a1b88fb Merge pull request #2083 from mfts/cursor/root-domain-redirects-96b8
Root domain redirects
2026-03-05 14:33:58 +11:00
Marc SeitzandGitHub 86b0ee68b9 Merge pull request #2095 from mfts/cursor/upload-limit-warnings-9750
Upload limit warnings
2026-03-02 18:49:50 +11:00
Marc Seitz a44bae5edd fix: fix propagate button 2026-03-02 18:31:27 +11:00
Marc Seitz 6950dec056 chore: prettier 2026-03-02 18:21:53 +11:00
Marc Seitz 6e9d6fe7d4 feat: force https on redirect url 2026-03-02 18:21:42 +11:00
Marc Seitz 5e115f1896 refactor: remove outdated getTeamWithDomain helper 2026-03-02 18:21:31 +11:00
Marc Seitz 4b557e909c Merge branch 'main' into cursor/root-domain-redirects-96b8 2026-03-02 17:56:26 +11:00
Marc Seitz 8c3d677af8 Merge branch 'main' into cursor/upload-limit-warnings-9750 2026-03-02 17:46:24 +11:00
Marc SeitzandGitHub 6687d842db Merge pull request #2096 from mfts/cursor/file-upload-authentication-bypass-dcd1
File upload authentication bypass
2026-03-02 17:43:12 +11:00
Marc SeitzandGitHub 4a969ad9ad Merge pull request #2093 from mfts/feat/dataroom-visitor-notifications
feat: add notification settings for visitors
2026-03-02 17:27:44 +11:00
Cursor AgentandMarc Seitz 0b1acdf136 Cap folder traversal at remainingDocuments to prevent unnecessary backend folder creation
getFilesFromEvent now tracks a collectedFileCount counter and a fileLimit
derived from remainingDocuments. traverseFolder checks the counter before
entering directories (skipping folder creation) and before resolving files.
Once the budget is exhausted the traversal short-circuits, so no extra API
calls are made for folders that would never receive uploads.

A fileLimitTruncatedRef signals to onDrop that files were capped during
traversal so the warning toast fires even though acceptedFiles.length is
already <= remainingDocuments. The else-if branch in onDrop still handles
the file-picker path (no traversal) as a safety net.

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-03-02 06:23:49 +00:00
Marc Seitz 13a98ca7e5 fix: don't send empty digest 2026-03-02 17:23:23 +11:00
Cursor AgentandMarc Seitz 015c44af80 Harden TUS upload auth, team access, and limits
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-03-02 06:11:09 +00:00
Marc SeitzandGitHub 1ebfd522a3 Merge pull request #2094 from mfts/cursor/q-a-content-wrapping-64f3
Q&a content wrapping
2026-03-02 16:59:14 +11:00
Cursor AgentandMarc Seitz 82b47a3e76 fix: increase Q&A message limit to 4000 chars and surface validation errors
The admin was unable to reply to conversations because the 1000 character
limit was too restrictive for dataroom Q&A. This commit:

- Increases the default message character limit from 1000 to 4000
- Surfaces the actual validation error message (e.g. 'Content cannot be
  longer than 4000 characters') through the API instead of returning a
  generic 'Internal server error'
- Parses and displays the API error in the frontend toast notification
- Adds a character counter to the admin reply form with visual feedback
  when approaching/exceeding the limit
- Adds maxLength to viewer-side input fields for client-side enforcement

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-03-02 05:08:58 +00:00
Cursor AgentandMarc Seitz 0f59913c14 fix: Q&A content text wrapping so questions and answers are not cut off
- conversation-message.tsx: Change w-max to w-fit on message bubbles so
  text wraps at max-w-[80%] instead of overflowing. Add min-w-0 and
  break-words to the content paragraph for proper word wrapping.

- faq-section.tsx: Add min-w-0 and break-words to FAQ answer text to
  prevent overflow in flex containers. Add break-words to question text
  and min-w-0 to accordion trigger for consistent wrapping behavior.

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-03-02 04:59:51 +00:00
Marc Seitz d5c7a6d109 fix 2026-03-02 15:05:35 +11:00
Marc Seitz c394f623eb fix: try and catch around sending notification 2026-03-02 13:58:19 +11:00
Marc Seitz adde340bbd fix: don't ignore other keys in notificationprefs 2026-03-02 13:13:16 +11:00
Marc Seitz 32275c6068 fix: wrap in try catch 2026-03-02 13:13:04 +11:00
Marc Seitz ef2f06f824 fix: throw if no linkurl present 2026-03-02 13:12:54 +11:00
Marc Seitz 8d5568b807 fix: classname typo 2026-03-02 13:12:39 +11:00
Marc Seitz eaabf16b2c fix: type error 2026-03-02 12:40:58 +11:00
Marc Seitz 3c7e4f93d0 feat: add notification settings for visitors 2026-03-02 12:31:44 +11:00
Marc SeitzandGitHub 3f95f1c118 Merge pull request #2092 from mfts/feat/deps
fix: remove warning
2026-03-02 12:20:05 +11:00
Marc Seitz 996a40ceb9 fix: remove warning 2026-03-01 13:33:15 +11:00
Marc SeitzandGitHub 6c1f5e261d Merge pull request #2091 from mfts/chore/robots
chore: update robots
2026-02-28 17:11:50 +11:00
Marc Seitz dc0de8a8dd chore: update robots 2026-02-28 16:58:45 +11:00
Marc Seitz 898313f5a5 fix: remove redirect if it fails 2026-02-28 15:29:32 +11:00
Marc Seitz a279392ba9 chore: make sure keyword matching is case insentive 2026-02-28 15:24:01 +11:00
Marc Seitz 6951b2d92a feat: add zod validation for domain update body 2026-02-28 15:23:35 +11:00
Marc Seitz 76444e2992 Merge branch 'main' into cursor/root-domain-redirects-96b8 2026-02-28 15:20:12 +11:00
Marc SeitzandGitHub 47c256a53c Merge pull request #2004 from mfts/cursor/PM-468-dataroom-upload-visibility-b14f
Dataroom upload visibility
2026-02-28 08:38:33 +11:00
Marc Seitz a0570a18ca fix: colors for uploads 2026-02-28 08:23:19 +11:00
Marc Seitz 58d31905ef feat: successive uploads 2026-02-28 08:23:12 +11:00
Marc Seitz b8c6bb093f Merge branch 'main' into cursor/PM-468-dataroom-upload-visibility-b14f 2026-02-27 19:05:29 +11:00
Marc SeitzandGitHub 24b22dc1df Merge pull request #2041 from mfts/deal-flow-infp
Deal flow info
2026-02-27 17:50:49 +11:00
Marc Seitz c9b249c969 fix: add track dismissed state 2026-02-27 17:41:34 +11:00
Marc Seitz f677204bc2 feat: add migration 2026-02-27 17:11:08 +11:00
Marc Seitz 71b243ad9b fix: skip dealsize for pm 2026-02-27 16:50:26 +11:00
Marc Seitz 7216556de2 fix: typo 2026-02-27 16:49:34 +11:00
Marc Seitz 223d81adbf fix: save survey and submission guard 2026-02-27 16:47:56 +11:00
Marc Seitz e19ae608d9 fix: disable skip button when submitting 2026-02-27 16:45:55 +11:00
Marc Seitz be9c7dedae fix: return errorhandler 2026-02-27 16:45:38 +11:00
Marc Seitz 9bdbe333ad fix: use zod validated data instead of body 2026-02-27 16:44:23 +11:00
Marc Seitz f1a686e39d Merge branch 'main' into deal-flow-infp 2026-02-27 16:39:43 +11:00
Marc Seitz d3bd1802cd fix: return early if no document present 2026-02-27 16:35:46 +11:00
Marc Seitz 3ae1dbb678 fix: clear uploads and uploadids 2026-02-27 16:33:43 +11:00
Marc Seitz 58476317a5 refactor: extract duplicate filter logic 2026-02-27 16:33:28 +11:00
Marc Seitz c670f29273 feat: reset rejected file 2026-02-27 16:30:17 +11:00
Marc SeitzandGitHub 996d5f0e21 Merge pull request #2090 from mfts/cursor/user-settings-billing-popup-f92e
User settings billing popup
2026-02-27 16:23:00 +11:00
Cursor AgentandMarc Seitz 0dd914a87d Add pre-upload validation warnings for document and bulk upload limits
- Handle 'too-many-files' rejection in onDropRejected with clear message
  showing the max files per upload limit and suggesting smaller batches
- Improve onDrop document limit check: show usage (X/Y) and upgrade action
  when at limit, and truncate files with warning when partially exceeding
- Add early return in getFilesFromEvent to skip folder creation when at limit
- Improve add-document-modal error messages with usage context and upgrade CTA
- Fix remainingDocuments calculation: use Infinity for unlimited plans

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-26 14:54:47 +00:00
Cursor AgentandMarc Seitz febb9322a7 fix: remove auto-opening upgrade modal from User Settings page
The account/general page had a useEffect that automatically opened
an UpgradePlanModal every time a non-annual-plan user navigated to
User Settings. This was disruptive as users would see a billing popup
about upgrading to data rooms on every visit.

Removed the UpgradePlanModal component, the useEffect trigger, the
plan detection logic, and all related imports from the page.

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-26 03:08:16 +00:00
Marc SeitzandGitHub 661014c893 Merge pull request #2088 from mfts/cursor/viewer-link-content-responsiveness-d996
Viewer link content responsiveness
2026-02-25 22:32:54 +11:00
Marc SeitzandCursor 53d328c2d9 fix: resolve three bugs in upload visibility feature
- Fix persisted uploads stuck in 'processing': updatePendingUpload now
  updates both in-flight and persisted state arrays
- Fix duplicate filename collision: key pending upload IDs by batch index
  instead of filename, thread index through onUploadComplete callback
- Fix modal premature close on multi-file upload: track expected/completed
  counts and only fire onUploadSuccess after all files resolve

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 18:30:15 +11:00
Marc SeitzandCursor 5f81333e6e feat: add dataroom upload visibility with optimistic UI and persisted uploads
- Add GET endpoint to fetch viewer's previously uploaded documents
- Return document data from POST upload for optimistic UI rendering
- Add PendingUploadsProvider context with server-side persistence
- Add PendingDocumentCard with Trigger.dev realtime processing status
- Add Documents/My Uploads tab switcher in dataroom viewer
- Redesign upload modal with folder indicator and success state
- Redesign upload component with progress bars and drag-drop zone

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 18:30:08 +11:00
Marc SeitzandGitHub 47922d48d7 Merge pull request #2089 from mfts/cursor/dataroom-document-audit-analytics-7cbc
feat: improve dataroom audit analytics
2026-02-25 18:15:25 +11:00
Marc Seitz 3794b3eeff fix: auth dataroom to team 2026-02-25 18:10:33 +11:00
Marc Seitz 8feb0e2d6e fix 2026-02-25 18:09:22 +11:00
Marc SeitzandCursor 4e0969ab71 fix: increase xs gauge size to fit 3-digit values like 100
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 17:14:42 +11:00
Marc SeitzandCursor 3c20959060 fix: pass documentId through chart data and add rate limiting to thumbnail API
The tooltip was using router.query.id (dataroom ID) instead of the
actual document ID, and versionNumber was NaN. Pass documentId through
BarChartComponent data so the tooltip resolves the correct thumbnail.
Add rate limiting (150 req/min per user) to the get-thumbnail endpoint.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 17:14:36 +11:00
Marc SeitzandCursor 4145145b74 refactor: restructure dataroom audit log layout with proper columns
Move document stats (duration, completion) into dedicated table columns
instead of squeezing them next to the document name. Page-by-page
analytics now opens as a separate expandable row. Replace "See document"
button with arrow link icon. Add table-fixed layout with explicit column
widths to prevent shifting on expand. Conditionally show column headers
only when a row is expanded. Slim down inner row padding.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 17:14:30 +11:00
Marc SeitzandGitHub 9d7b8bf8f2 Merge pull request #2087 from mfts/fix/cjk-slugify
fix: slugify cjk names
2026-02-25 15:43:30 +11:00
Marc Seitz 2425a8933b feat: add transliteration 2026-02-25 15:22:06 +11:00
Marc SeitzandCursor d1a3603837 fix: preserve original CJK filename in Content-Disposition header
Use RFC 5987 filename* parameter to include the UTF-8 encoded original
filename alongside the ASCII-safe slug, so downloads show the proper
CJK name in modern browsers. Also adds missing Content-Disposition to
put-file-server and stream-file-server uploads.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 14:43:02 +11:00
Marc SeitzandCursor 70beab2c5e fix: add CJK-safe slugify with nanoid fallback
slugify strips all CJK characters, producing empty strings for
filenames and folder names that contain only CJK text. Replace all
usages with safeSlugify which falls back to a 12-char lowercase
alphanumeric nanoid when slugify returns an empty result.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 14:42:53 +11:00
Cursor AgentandMarc Seitz e5dab063db Fix horizontal viewer refit after viewport resize
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-24 22:45:32 +00:00
Iuliia Shnai c7c1f59649 feat:updates 2026-02-23 19:31:26 +11:00
Iuliia Shnai 7fd0d7bdfe feat:updates 2026-02-23 19:01:12 +11:00
Marc Seitz cf1e783d8f Merge branch 'main' into cursor/PM-468-dataroom-upload-visibility-b14f 2026-02-23 13:19:45 +11:00
Marc SeitzandGitHub 7542ffe53a Merge pull request #2084 from mfts/cursor/dataroom-viewer-background-fill-2c9f
Dataroom viewer background fill
2026-02-22 22:23:03 +11:00
Cursor AgentandMarc Seitz ae45df36b8 fix: add min-h-screen to dataroom viewer wrapper to prevent white background gap
When applyAccentColorToDataroomView is enabled but there are not enough
folders/documents to fill the viewport, a white background was visible
underneath the content. Adding min-h-screen ensures the accent background
color extends to fill at least the full viewport height.

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-22 11:10:41 +00:00
Marc SeitzandGitHub 1571168300 Merge pull request #2077 from mfts/cursor/dataroom-viewer-background-color-ac68
Dataroom viewer background color
2026-02-22 21:53:54 +11:00
Marc SeitzandGitHub 3f351a0900 Merge pull request #2073 from mfts/cursor/visitor-groups-access-1b01
Visitor groups access
2026-02-22 20:36:42 +11:00
Cursor AgentandMarc Seitz 5df098f630 fix: clean up unused imports, add stagger delay for per-page chart loading
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-22 09:20:45 +00:00
Cursor AgentandMarc Seitz 6d7d88431b feat: add document stats (duration, completion, per-page chart) to dataroom visitor audit log
- Create new Tinybird pipe get_dataroom_view_document_stats for bulk document view stats
- Add API endpoint for dataroom view document stats with per-page chart support
- Create SWR hooks with deferred loading (enabled flag)
- Create reusable DocumentViewDuration, DocumentViewCompletion, DocumentPageChart components
- Create DataroomViewStats component combining history with stats
- Update dataroom-visitors-table.tsx and dataroom-viewers.tsx to track expansion state
- Stats only load when visitor row is expanded (deferred loading)
- Per-page charts only load when individual document line is expanded

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-22 09:14:36 +00:00
Marc Seitz ebb928916c fix: visitorgroup on the team 2026-02-22 19:30:04 +11:00
Cursor AgentandMarc Seitz ce2b40daad fix: harden root domain redirects - validation, plan gating, cleanup
- Change redirect status from 301 to 302 (non-permanent, user-configurable)
- Remove backfill UPDATE entries from migration SQL
- Remove unnecessary sync-redirects cron endpoint
- Add URL validation: SSRF protection, edge config keyword blocklist,
  protocol check, URL sanitization via shared validateRedirectUrl helper
- Add plan gating: require Business plan or higher for redirect URLs
  (enforced on both API endpoints and UI)
- Clean up Redis redirect URLs on team deletion
- Clear redirect URLs (Postgres + Redis) on subscription cancellation
  and on plan downgrade below Business
- Split redis.ts to avoid Prisma import in edge middleware context
- Gate DomainCard redirect UI based on plan (show upgrade prompt)

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-22 08:24:29 +00:00
Marc Seitz e591d6cea1 fix 2026-02-22 19:17:46 +11:00
Marc SeitzandGitHub ebd0e9f367 Merge pull request #2082 from mfts/cursor/document-name-sanitization-9fc2
Document name sanitization
2026-02-22 19:12:01 +11:00
Cursor AgentandMarc Seitz 931bb5a0ba Decode entities in document name sanitization
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-22 07:58:10 +00:00
Marc Seitz 03a6a09bb0 feat: hide visitor groups when not set 2026-02-22 18:51:26 +11:00
Marc SeitzandGitHub 548b0f51f3 Merge pull request #2081 from mfts/cursor/user-team-action-authorization-13a8
User team action authorization
2026-02-22 18:45:06 +11:00
Cursor AgentandMarc Seitz c5fd6bbfa6 Sanitize document names on upload and rename
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-21 13:07:32 +00:00
Cursor AgentandMarc Seitz f97f5e9164 Harden team action auth with composite user-team lookup
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-21 13:03:57 +00:00
Marc Seitz 3a75915740 Update viewer-surface-theme.tsx 2026-02-21 13:36:44 +11:00
Marc Seitz 4dd6981e48 Merge branch 'main' into cursor/dataroom-viewer-background-color-ac68 2026-02-21 13:20:26 +11:00
Marc Seitz 01aa7f4306 feat: improve visitor group layouts 2026-02-21 13:18:15 +11:00
Marc Seitz 75a5795882 Merge branch 'main' into cursor/visitor-groups-access-1b01 2026-02-21 13:05:06 +11:00
Cursor AgentandMarc Seitz 2709049f68 feat: add UI for root domain redirect and Redis sync endpoint
- Update DomainCard to accept and display redirectUrl with inline editor
- Add save/remove functionality for redirect URL with toast notifications
- Pass redirectUrl from domains settings page to DomainCard
- Add /api/cron/domains/sync-redirects endpoint to sync Postgres -> Redis
- Add backfill SQL for existing hardcoded domain redirects

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-20 22:50:09 +00:00
Cursor AgentandMarc Seitz e81d4118aa feat: add root domain redirect support (schema, redis, middleware, API)
- Add redirectUrl field to Domain model in Prisma schema
- Create migration for the new field
- Add Redis helper for domain redirect URL caching
- Update middleware to check Redis for redirect URL instead of hardcoded values
- Update domain API: GET includes redirectUrl, POST accepts redirectUrl, PUT updates redirectUrl
- Sync Redis on domain create, update, and delete

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-20 22:45:48 +00:00
Marc SeitzandGitHub c9638ebd9f Merge pull request #2080 from mfts/feat/sanitize-docx-on-fail
fix: improve libreoffice conversion
2026-02-21 00:17:48 +11:00
Marc Seitz 47b0564ad6 fix 2026-02-21 00:11:48 +11:00
Marc Seitz fcc3fd4d92 fix 2026-02-20 23:58:57 +11:00
Marc Seitz db70856fb0 fix: libreoffice fixes in conversion 2026-02-20 23:43:50 +11:00
Marc Seitz df1fbfda77 feat: add document sanitizer 2026-02-20 17:05:20 +11:00
Marc Seitz 4d29ecc1c4 chore: update dependencies 2026-02-20 00:11:05 +11:00
Marc Seitz a8981b4950 feat: improve group layout 2026-02-20 00:10:55 +11:00
Marc Seitz 9e0cc88a4a chore: add skills 2026-02-20 00:10:28 +11:00
Marc Seitz e0eaaa5b3d fix: typescript errors 2026-02-19 18:08:29 +11:00
Marc Seitz 07f56a1b51 Merge branch 'main' into cursor/visitor-groups-access-1b01 2026-02-19 17:53:00 +11:00
Marc SeitzandGitHub a3108dc5fa Merge pull request #2079 from mfts/fix/sso
fix: jackson db error
2026-02-19 17:48:24 +11:00
Marc Seitz c2e4adc30b feat: add dataroom view background 2026-02-19 17:39:56 +11:00
Marc Seitz 640aa0275f fix: jackson db error 2026-02-19 16:14:23 +11:00
5401d3d5d2 Notion text selection (#2062)
* feat: add option to allow text selection on Notion pages

Add enableTextSelection field to Link model that allows document owners
to toggle text selection/copying for visitors on Notion pages.

Changes:
- Add enableTextSelection Boolean field to Link Prisma schema
- Add database migration for the new field
- Create TextSelectionSection toggle component for link settings
- Wire the setting through link sheet, link options, API routes
- Pass textSelectionEnabled prop to NotionPage viewer component
- Add .notion-text-selection-enabled CSS class that overrides
  user-select: none when text selection is allowed
- Update link-active-controls to show when text selection is active
- Update webhooks and link data queries to include new field

By default, text selection remains disabled (preserving existing
behavior). When enabled, visitors can select and copy text content
on Notion document pages.

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>

* refactor: use team feature flag for text selection instead of per-link toggle

Replace the per-link enableTextSelection database field with a team-level
feature flag (textSelection) using the existing Vercel Edge Config system.

This means text selection on Notion pages is controlled at the team level
via the betaFeatures edge config, so there's no per-link toggle to manage.

Changes:
- Remove enableTextSelection from Link Prisma schema and migration
- Remove TextSelectionSection toggle component and link sheet wiring
- Add 'textSelection' to BetaFeatures type in featureFlags
- Fetch textSelection flag in all view page getStaticProps:
  - pages/view/[linkId]/index.tsx (document + dataroom paths)
  - pages/view/domains/[domain]/[slug]/index.tsx (document + dataroom)
  - pages/view/[linkId]/d/[documentId].tsx (dataroom document)
  - pages/view/domains/[domain]/[slug]/d/[documentId].tsx (domain dataroom doc)
- Pass textSelectionEnabled prop through component chain:
  DocumentView -> ViewData -> NotionPage
  DataroomDocumentView -> ViewData -> NotionPage
- Keep CSS override (.notion-text-selection-enabled) and NotionPage
  conditional class application from previous commit

To enable: add the team's ID to the 'textSelection' array in
Vercel Edge Config betaFeatures.

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-19 14:37:27 +11:00
Cursor AgentandMarc Seitz c6a95059bd feat: apply brand background color to dataroom viewer
- Apply accentColor (background color) to the dataroom viewer content area,
  not just the document view
- Add explicit white background to document and folder cards so they remain
  visible on any background color
- Adapt tree view sidebar, breadcrumbs, search banner, and empty state text
  colors for dark backgrounds using determineTextColor utility
- Update room preview demo to also render background color
- Update branding page label to indicate background color applies to
  dataroom view as well as front page

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-18 07:35:53 +00:00
Marc SeitzandGitHub 10d58b0832 Merge pull request #2076 from mfts/fix/download-custom 2026-02-18 16:23:51 +11:00
Marc Seitz d1957652f4 fix: download document from link 2026-02-18 16:05:23 +11:00
Marc SeitzandGitHub 24e1266310 Merge pull request #2075 from mfts/cursor/trigger-conversion-error-handling-7fec
Trigger conversion error handling
2026-02-18 11:45:13 +11:00
Marc Seitz 127af79601 feat: add trustedTeam to skip check 2026-02-18 11:28:48 +11:00
Cursor AgentandMarc Seitz 1c0e32e19e fix: throw AbortTaskRunError on conversion failures instead of returning success:false
Previously, the convert-pdf-to-image-route task returned { success: false }
on failures (document not found, blocked documents, page conversion errors),
which Trigger.dev treated as successful completions. This caused failed
conversion runs to show as 'Completed' in the Trigger dashboard.

Now all failure paths throw AbortTaskRunError which:
- Marks the run as FAILED in Trigger.dev
- Does NOT retry the task (these are non-transient errors)
- Shows the appropriate error message to the user

Affected failure cases:
- Document version not found
- Failed to get signed URL
- Failed to get number of pages
- Invalid page count
- Failed to fetch page count
- Document processing blocked
- Failed to convert individual pages

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-17 20:44:21 +00:00
Marc SeitzandGitHub 2dc82a8ffb Merge pull request #2074 from mfts/fix/update-link-issue
fix: only call one view
2026-02-17 22:44:56 +11:00
Marc Seitz 5a515d295e fix: only call one view 2026-02-17 22:34:29 +11:00
Marc SeitzandGitHub e828053799 Merge pull request #2071 from mfts/cursor/custom-domain-slug-handling-29d0
Custom domain slug handling
2026-02-16 18:47:22 +11:00
Marc SeitzandGitHub 8583d8cd21 Merge pull request #2072 from mfts/cursor/document-versioning-resource-67a0
Document versioning resource
2026-02-16 18:33:13 +11:00
Cursor AgentandMarc Seitz f050eeaa7c fix: use functional updater form for setData to avoid stale state
Convert all setData({...data, ...}) calls in DomainSection to
setData(prev => ({...prev, ...})) so updates read the latest state
and don't overwrite concurrent changes. Applies to handleDomainChange,
the default-domain useEffect, and the slug onChange handler.

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-16 07:32:19 +00:00
Cursor AgentandMarc Seitz 321ddacc8f fix: bind aria-invalid to actual validation state and fix indentation in links API
- Replace hardcoded aria-invalid="true" on the slug input with a
  computed boolean that checks for invalid characters and blocked
  pathnames, so assistive technology reflects the real state.
- Reformat the domain/slug handling block in pages/api/links/index.ts
  to use consistent 6-space indentation matching the enclosing try block.

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-16 06:58:36 +00:00
Cursor AgentandMarc Seitz 31b1f9ec30 refactor: use nanoid with unambiguous alphabet for slug generation
Replace hand-rolled Math.random slug generator with nanoid's
customAlphabet for cryptographically secure random slugs.
Uses an unambiguous charset that excludes easily confused
characters (0/O, 1/l/I).

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-16 06:53:02 +00:00
Cursor AgentandMarc Seitz b5ea475878 refactor: keep slugs case-sensitive and generate mixed-case alphanumeric slugs
- Revert all lowercase normalization across backend and frontend
- Update random slug generator to use full alphanumeric charset (A-Z, a-z, 0-9)
- Custom domain links remain case-sensitive as originally designed

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-16 05:16:26 +00:00
Cursor AgentandMarc Seitz a5ccda79ba refactor: simplify document.update handler by reusing createNewDocumentVersion
Instead of duplicating all the version creation and document processing
logic in the webhook handler, this refactors the approach to:

1. Add Bearer token auth support to the versions API endpoint
   (matching the pattern already used by the documents endpoint),
   so it can be called from webhook/server-side contexts.

2. Update createNewDocumentVersion() to accept an optional token
   param and use an absolute URL when provided (server-side context),
   while preserving the existing relative-URL behavior for client-side.

3. Simplify handleDocumentUpdate to: fetch file -> upload to storage ->
   call createNewDocumentVersion(), which delegates all version creation,
   primary flag management, and document processing triggers to the
   existing versions endpoint.

Net result: ~130 fewer lines, no duplicated processing logic.

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-16 05:12:10 +00:00
Cursor AgentandMarc Seitz fd1fc0a9e1 feat: auto-generate slug for custom domain links and add random generate button
- Auto-generate a 10-character lowercase alphanumeric slug when a custom domain
  is selected and no slug exists yet
- Add a shuffle button next to the slug input for generating random slugs
- Normalize all custom domain slugs to lowercase for case-insensitive URL handling
  across all create/update endpoints and lookup paths
- Update slug input validation to enforce lowercase characters only

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-16 04:52:49 +00:00
Marc SeitzandGitHub 6950852307 Merge pull request #2067 from mfts/cursor/dataroom-upload-notifications-e731
Dataroom upload notifications
2026-02-16 15:46:32 +11:00
Cursor AgentandMarc Seitz b0b2d15381 feat: add document.update resourceType for creating new document versions via webhook
Adds a new 'document.update' resource type to the incoming webhook services
endpoint. This allows API consumers to create a new version for an existing
document by providing a documentId, fileUrl, and contentType.

The handler:
- Validates the document exists and belongs to the team
- Fetches the file from the provided URL
- Uploads it to storage
- Creates a new DocumentVersion (incrementing version number)
- Sets the new version as primary (marks all others as non-primary)
- Updates the document record with the new file reference
- Triggers appropriate document processing based on type:
  - docs/slides: convertFilesToPdfTask
  - keynote slides: convertKeynoteToPdfTask
  - pdf: convertPdfToImageRoute
  - cad: convertCadToPdfTask
  - video (non-mp4): processVideo
  - excel (advanced mode): copyFileToBucketServer

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-16 04:45:37 +00:00
Marc SeitzandGitHub df8dd7c046 Merge pull request #2070 from mfts/cursor/domain-validation-data-leak-c652
Domain validation data leak
2026-02-16 15:38:07 +11:00
Marc SeitzandGitHub eca3f5f56c Apply suggestion from @mfts 2026-02-16 15:31:32 +11:00
Marc Seitz 50cceb7ac9 chore: extend notification time 2026-02-16 15:30:10 +11:00
Marc Seitz e04a2bbb41 fix: cancel notifications from same viewer 2026-02-16 15:29:59 +11:00
Cursor AgentandMarc Seitz 211fa25a9f fix: prevent data leak in domain validation by hiding 'already in use' status
When a domain is already registered on Papermark by another account,
the validation modal previously showed 'is already in use' which leaked
information about domain registrations.

Changes:
- Validate API now returns 'has site' instead of 'conflict' for existing
  domains, making it indistinguishable from domains with websites
- POST endpoint error message changed to generic 'Unable to add this
  domain' instead of 'Domain already exists'
- Removed unused 'conflict' status from frontend types and config

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-16 04:05:49 +00:00
Cursor AgentandMarc Seitz ba9bd0c45c fix: merge PR #2069 fixes for allow/deny list sync issues
Incorporates fixes from PR #2069 (cursor/link-allow-list-email-update):
- Replace useEffect-based sync with direct onChange handler updates for
  allow-list and deny-list, preventing stale data on quick saves
- useEffect now only handles disabling when emailProtected is turned off
- Add key props to AllowListSection and DenyListSection in link-options
  to force remount when switching between links (prevents stale state)
- Visitor group clearing is also handled in the new useEffect pattern

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-16 03:59:47 +00:00
Marc Seitz a8497211ba Merge branch 'main' into cursor/dataroom-upload-notifications-e731 2026-02-16 14:57:25 +11:00
Marc SeitzandGitHub ec3dc72115 Merge pull request #2064 from mfts/cursor/dataroom-bulk-download-b9dd
Dataroom bulk download
2026-02-16 14:54:01 +11:00
Marc SeitzGitHubcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
64294773c7 Update lib/trigger/dataroom-upload-notification.ts
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-16 14:46:03 +11:00
Marc SeitzandGitHub 5723fe6ddc Merge pull request #2068 from mfts/cursor/visitor-link-download-failure-bd9f
Visitor link download failure
2026-02-16 14:40:58 +11:00
Marc Seitz 30d9225e24 feat: store file key instead of urls 2026-02-16 14:36:08 +11:00
Cursor AgentandMarc Seitz 07aa0bd98f fix: use blob-based downloads for reliable cross-browser file downloads
The download flow for visitor links showed a success toast but never
started the actual file download. This affected Chrome, Brave, and
all iOS/iPad browsers.

Root causes:
- nav.tsx: created an <a> element without the download attribute for
  cross-origin URLs. The download attribute is ignored for cross-origin
  URLs, and link.click() in async callbacks loses user gesture context
  on iOS WebKit.
- download-only-viewer.tsx: used window.open() which is blocked by
  popup blockers in async callbacks (especially on mobile browsers).
- document-card.tsx: used an iframe approach with a bug where it fell
  back to response.url instead of the actual downloadUrl.

Fix: fetch the file as a blob and create a same-origin blob URL with
the download attribute. Same-origin blob URLs always respect the
download attribute and work reliably across all browsers including
iOS Safari, Chrome, and Brave.

Also returns fileName from the download API so the downloaded file
has the correct name.

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-16 03:01:18 +00:00
Cursor AgentandMarc Seitz 14feeb0f9a fix: scope upload notification runs by viewer to avoid cancelling other visitors' notifications
Tag trigger runs with viewer_${viewerId} and filter by it when cancelling
pending runs, so that concurrent uploads from different visitors each get
their own batched notification.

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-16 02:51:06 +00:00
Cursor AgentandMarc Seitz 6e79e34044 feat: add upload notifications for dataroom file requests
When a dataroom link has file requests enabled and notifications turned on,
the team (admin, managers, link owner) receives an email notification when
a visitor uploads documents to the dataroom.

Uses the same delayed trigger strategy as existing new document notifications:
- Cancels any pending notification for the same dataroom+link
- Triggers a new notification with a 5-minute delay
- The trigger task collects all uploads in that window and sends a single email

Components:
- Trigger.dev task: send-dataroom-upload-notification (5-min batched)
- Email template: DataroomUploadNotification
- API endpoint: /api/jobs/send-dataroom-upload-notification
- Modified upload route to trigger notification when link.enableNotification is on

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-16 02:40:10 +00:00
Marc SeitzandGitHub b2b251ab96 Merge pull request #2065 from mfts/cursor/team-links-endpoint-b2e0
Team links endpoint
2026-02-16 13:28:31 +11:00
Cursor AgentandMarc Seitz 519004a8b9 fix: prevent concurrent download loops and fix progress UI on modal close
Two issues fixed:

1. Concurrent handleDownloadAll guard:
   - Added early return guard at the top of handleDownloadAll in all three
     files to bail out immediately if downloadProgress is already non-null,
     preventing a second async loop from starting and overwriting the shared
     state.
   - Changed all Download All button disabled props from checking a specific
     downloadId/jobId match (e.g. downloadProgress?.downloadId === id) to
     !!downloadProgress, so ALL Download All buttons are disabled whenever
     any download loop is in progress.

2. Modal close during active downloads:
   - Removed setDownloadProgress(null) from handleClose in both modal
     components (download-progress-modal.tsx and
     viewer-download-progress-modal.tsx). Previously, closing the modal
     reset downloadProgress to null, but the still-running async loop
     immediately overwrote it on the next iteration, causing progress UI
     to reappear or linger after reopening. Now the loop's own final
     setDownloadProgress(null) is the only cleanup, which fires naturally
     when all downloads complete.

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-16 02:27:02 +00:00
Cursor AgentandMarc Seitz d7831c64ff fix: remove url field from links.get response
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-16 02:20:26 +00:00
Cursor AgentandMarc Seitz cab3c8a0be refactor: change team links endpoint from GET to POST with 'links.get' resource type
Switches the team links endpoint to use a 'links.get' POST resource type
instead of a separate GET handler. This is consistent with the existing
pattern where all webhook operations use POST with a resourceType
discriminator (document.create, link.create, link.update, dataroom.create).

Usage: POST with body { "resourceType": "links.get" }

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-16 02:11:08 +00:00
Marc SeitzandGitHub 29193ef703 Merge pull request #2063 from mfts/cursor/visitor-docx-file-support-06ce
Visitor docx file support
2026-02-16 12:53:40 +11:00
Cursor AgentandMarc Seitz a61612cc64 feat: enable DOCX file support for visitor uploads in dataroom
Add .doc and .docx MIME types to VIEWER_ACCEPTED_FILE_TYPES so that
external visitors can upload Word documents via file requests in the
dataroom. Updated all related UI text to reflect the new supported
file types.

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-16 01:15:20 +00:00
Cursor AgentandMarc Seitz 6c14660208 fix: sequential bulk download with progress to prevent browser blocking
Browsers (Chrome, Firefox, etc.) limit the number of simultaneous
programmatic downloads to ~10. When a dataroom had 20+ zip parts,
clicking 'Download All' would only download ~10 files because:

1. downloads-panel.tsx used forEach+setTimeout with only 300ms delay
2. viewer-download-progress-modal.tsx used forEach+setTimeout with 1500ms
3. Both patterns schedule all downloads upfront, hitting browser limits

Changes across all three download components:
- Use async/await for truly sequential downloads (one at a time)
- Increase delay between downloads to 2 seconds
- Add visual progress feedback ('Downloading X of Y...')
- Disable the Download All button while downloads are in progress
- Reset download progress state on modal/panel close

Also improved download-progress-modal.tsx (team view):
- Always show 'Download All' button regardless of part count (was
  previously hidden for >3 parts, forcing individual clicks)
- Added same progress feedback UI

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-16 01:09:51 +00:00
Marc SeitzandGitHub e9a87848d6 Merge pull request #2061 from mfts/cursor/dataroom-download-content-4809
Dataroom download content
2026-02-15 11:27:35 +11:00
Marc Seitz a43166c46a refactor: update build hierarchy before filtering 2026-02-15 11:09:43 +11:00
Marc Seitz c979fd9b89 fix: O(n^2) issue 2026-02-14 23:49:04 +11:00
Marc Seitz a16e57b8b6 fix: large O(n x m) document folder matching 2026-02-14 23:46:28 +11:00
Marc Seitz 3a390b0900 fix: subfolder descendent matching 2026-02-14 23:45:23 +11:00
Marc Seitz a6d6b56543 fix: avoid stale document folder paths 2026-02-14 23:36:23 +11:00
Cursor AgentandMarc Seitz d0d28cf680 feat: add GET endpoint to incoming webhooks to return all team links
Adds a new GET handler to the incoming webhooks service endpoint that
returns all links from a team. Each link includes:
- linkId
- name
- linkType (DOCUMENT_LINK, DATAROOM_LINK, WORKFLOW_LINK)
- documentId (if applicable)
- dataroomId (if applicable)
- url, slug, domainSlug
- expiresAt, isArchived
- createdAt, updatedAt
- linkUrl (the full URL for the link)

The endpoint reuses the same authentication (webhook ID + Bearer token)
and rate limiting as the existing POST handler.

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-13 04:57:38 +00:00
Cursor AgentandMarc Seitz fee8af5245 fix: use parentId hierarchy for download folder structure instead of stale path field
The download code was building folder structures using the materialized
'path' field on DataroomFolder records, while the UI uses 'parentId' to
build the tree hierarchy. When folder paths become stale after renames
or moves (e.g., descendant paths not updated), the download would
include ghost folders with slugified names that no longer exist.

This fix:
- Adds buildFolderPathsFromHierarchy() utility that computes folder
  paths from the parentId chain (matching UI behavior)
- Updates team bulk download, visitor bulk download, and visitor folder
  download to use computed paths instead of stored path field
- Ensures download ZIP structure always matches what users see in the UI

Fixes the issue where deleted/renamed folders like 'company-background'
appeared in downloads alongside the correct '02. Company Background',
and where restructured folder hierarchies (e.g., '04. R&D/Clinical')
appeared in downloads but not in the dataroom view.

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-12 23:14:10 +00:00
Marc SeitzandGitHub 526258a284 Merge pull request #2060 from mfts/feat/dataroom
feat(analytics): add Link Updated
2026-02-13 09:47:03 +11:00
Marc Seitz 8a9a299f91 chore: clean logs 2026-02-13 01:46:34 +11:00
Marc Seitz f723c4be1b feat(analytics): add Link Updated 2026-02-13 00:45:40 +11:00
Marc SeitzandGitHub 9a7cd85f05 Merge pull request #2059 from mfts/feat/dataroom
feat: update dataroom
2026-02-13 00:20:47 +11:00
Marc Seitz 7f66601a40 feat: remove drv 2026-02-12 23:57:45 +11:00
Marc Seitz 4e62b04701 fix: remove encrypted password 2026-02-12 23:57:32 +11:00
Cursor AgentandMarc Seitz 2c6f390ddc fix: add visitorGroups include to dataroom group links API
Ensure dataroom group links endpoint also returns visitor group
associations for proper editing in the link sheet.

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-12 07:42:19 +00:00
Marc SeitzandGitHub 02468b7522 Merge pull request #2058 from mfts/feat/link-owner
feat: add ownerId to link
2026-02-12 18:40:22 +11:00
Cursor AgentandMarc Seitz 4237049af5 feat: add visitor groups UI and link integration
- Add VisitorGroupsSection component with create/edit/delete UI
- Add VisitorGroupModal for creating and editing groups
- Add 'Visitor Groups' tab to /visitors page
- Update AllowListSection with multi-group selector (popover with checkboxes)
- Add visitorGroupIds to DEFAULT_LINK_TYPE and link sheet data flow
- Update link create/update APIs to handle visitor group associations
- Update document and dataroom link fetch APIs to include visitorGroups
- Update LinkWithViews type to include visitorGroups
- Update views and views-dataroom routes to merge group emails with allow list
- Visitor group emails are additive: groups + individual emails combined

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-12 07:31:23 +00:00
Marc Seitz 79247f6d05 feat: add owner to link 2026-02-12 18:19:59 +11:00
Cursor AgentandMarc Seitz 9386dce3ee feat: add visitor groups API routes and SWR hook
- GET/POST /api/teams/:teamId/visitor-groups - list & create
- GET/PUT/DELETE /api/teams/:teamId/visitor-groups/:groupId - CRUD
- Deletion protection: prevents deletion if group is used by active links
- SWR hook for consuming visitor groups data in UI

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-12 07:14:48 +00:00
Cursor AgentandMarc Seitz bd109580ff feat: add VisitorGroup and LinkVisitorGroup models to Prisma schema
Add team-level visitor groups that allow users to define named groups
of emails/domains once, then apply them to document and data room links.

New models:
- VisitorGroup: team-scoped named group with emails/domains list
- LinkVisitorGroup: many-to-many join table between Link and VisitorGroup

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-12 07:13:18 +00:00
Marc Seitz 8cad966eca chore: remove find skills 2026-02-12 18:12:37 +11:00
Marc SeitzandGitHub ea317af943 Merge pull request #2057 from mfts/fix/revalidation-saml
fix: revalidation
2026-02-12 17:35:38 +11:00
Marc Seitz 725d7f5803 fix: revalidation 2026-02-12 17:26:11 +11:00
Marc Seitz 5d041f20f8 Merge branch 'main' into cursor/PM-468-dataroom-upload-visibility-b14f 2026-02-12 16:49:10 +11:00
Marc SeitzandGitHub fb5304eb39 Merge pull request #1999 from mfts/cursor/PM-466-folder-customization-options-4a78
Folder customization options
2026-02-12 16:47:29 +11:00
Marc SeitzandGitHub 13b77e60cd Merge pull request #2056 from mfts/feat/api-link-update
feat: add link.update to api
2026-02-12 15:54:27 +11:00
Marc Seitz e8e96c2e56 fix 2026-02-12 15:49:00 +11:00
Marc Seitz 59a4fccdc5 fix 2026-02-12 15:45:06 +11:00
Marc Seitz 22973a4ffc refactor: change date 2026-02-12 15:40:13 +11:00
Marc Seitz 9131639a45 fix: type error 2026-02-12 15:39:50 +11:00
Marc Seitz 95534c4ad6 chore: rename rule 2026-02-12 15:39:44 +11:00
Marc SeitzandCursor 58b676e2fd symlink .cursor/skills to .agents/skills for shared skill definitions
Replace direct copies of vercel-react-best-practices and web-design-guidelines
in .cursor/skills with symlinks pointing to .agents/skills, keeping a single
source of truth for skill files.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 15:07:30 +11:00
Marc Seitz b8ac2fd021 Merge branch 'main' into cursor/PM-466-folder-customization-options-4a78 2026-02-12 14:51:30 +11:00
Marc Seitz 57fb7f1583 refactor: change how false is handled 2026-02-12 14:48:19 +11:00
Marc Seitz 083e6f566a misc: change error status code 2026-02-12 14:48:08 +11:00
Marc Seitz cc2d30eb2b feat: improve link.update handler to avoid overwriting any fields 2026-02-12 14:47:54 +11:00
Marc Seitz 17e17fea8f chore: ignore docs 2026-02-12 14:41:38 +11:00
Marc Seitz 32a26a7849 fix: validate both domain and slug 2026-02-12 14:41:32 +11:00
Marc Seitz 52bcabcd22 feat: add link.update to api 2026-02-12 14:25:30 +11:00
Marc SeitzandGitHub e76a24b1f2 Merge pull request #2055 from mfts/cursor/saml-scim-integration-6ebf
fix: require force dynamic on jackson routes
2026-02-12 13:09:18 +11:00
Marc Seitz 983390d65e fix: vercel build error force dynamic 2026-02-12 12:49:55 +11:00
Marc Seitz f0f12d59d4 Merge branch 'main' into cursor/saml-scim-integration-6ebf 2026-02-12 12:46:35 +11:00
Marc SeitzandGitHub b381981cec Merge pull request #2053 from mfts/cursor/saml-scim-integration-6ebf
SAML SCIM integration
2026-02-12 12:38:54 +11:00
Marc Seitz 26b5a7708c fix: join tenant after signup 2026-02-11 21:06:07 +11:00
Marc Seitz 0c6b76d332 fix 2026-02-11 20:34:03 +11:00
Marc Seitz bf39419746 fix: new saml user goes through createUser path 2026-02-11 20:30:10 +11:00
Marc Seitz a31dea9c16 fix: redundant team query 2026-02-11 20:24:36 +11:00
Marc Seitz 1353ac5678 fix: handle lowercase string 2026-02-11 20:17:11 +11:00
Marc Seitz a177f18eb4 fix: parse request 2026-02-11 20:16:13 +11:00
Marc Seitz ef757e4a4a chore: update dependencies 2026-02-11 19:28:31 +11:00
Marc Seitz 9c421e0f99 fix: avoid stale membership 2026-02-11 18:53:52 +11:00
Marc Seitz 3f4f95a925 chore: normalize email 2026-02-11 18:52:41 +11:00
Marc Seitz 5aab0a2436 fix: validate connection belongs to team 2026-02-11 18:52:26 +11:00
Marc Seitz 86fce11bfe fix: type error 2026-02-11 18:48:45 +11:00
Marc Seitz 26fd570e53 chore: upgrade prisma back to 6.5.0 2026-02-11 18:34:29 +11:00
Marc Seitz 196fa1a173 fix: create and then delete scim directories 2026-02-11 18:31:38 +11:00
Marc Seitz 42146c0ac7 fix: show error for sso 2026-02-11 18:30:45 +11:00
Marc Seitz 9bd9dae2d3 fix: remove artificial char limitations from jackson tables 2026-02-11 18:29:17 +11:00
Marc Seitz bbffa428bc fix: show proper error if sso is required 2026-02-11 18:27:12 +11:00
Marc Seitz ae851e0a20 chore: console errors for non existen team 2026-02-11 18:26:26 +11:00
Marc Seitz 71177d6076 fix: add route to outputfiletracing 2026-02-11 18:22:48 +11:00
Marc Seitz 2ddaba948a fix: admin session loading 2026-02-11 18:20:10 +11:00
Marc Seitz 645ac31f4b fix: copyToClipboard 2026-02-11 18:19:56 +11:00
Marc Seitz 84b58e84de fix date 2026-02-11 18:17:39 +11:00
Marc Seitz 15d99ed837 fix: disallow public emails 2026-02-11 18:15:33 +11:00
Marc Seitz 315b339c52 fix: hide email in logs 2026-02-11 18:06:53 +11:00
Marc Seitz d1119beae7 fix: lint error 2026-02-11 17:55:31 +11:00
Marc SeitzandGitHub 1476e763dc Merge pull request #2044 from mfts/new-papermark-example-document
feat:new papermark example document
2026-02-11 17:54:49 +11:00
Marc Seitz 6c37b60967 chore: reduce size 2026-02-11 17:49:12 +11:00
Marc Seitz b0cfc00bd0 feat: add enterprise saml sso / scim 2026-02-11 17:41:56 +11:00
Marc SeitzandGitHub 0d013f6356 Merge pull request #2046 from mfts/cursor/all-links-section-collapsibility-7362
All links section collapsibility
2026-02-11 16:32:11 +11:00
Marc Seitz 30ea59b62f fixed 2026-02-11 15:53:53 +11:00
Marc SeitzandGitHub 970da40a28 Merge pull request #2054 from mfts/fix/premium
fix: hide datarooms+ badge on premium
2026-02-11 15:53:15 +11:00
Marc Seitz c3edd6efad fix: hide datarooms+ badge on premium 2026-02-11 15:46:11 +11:00
Marc Seitz 612f84a863 refactor: remove redundant code 2026-02-11 15:37:54 +11:00
Marc SeitzandGitHub 6e9ea3e906 Merge pull request #2052 from mfts/codex/domains
Improve domain modal validation and feedback experience
2026-02-11 14:38:49 +11:00
Marc Seitz d4e024c312 fix 2026-02-11 14:33:29 +11:00
Marc Seitz cc6e1c6542 fix: add abort old request 2026-02-11 14:08:08 +11:00
Marc Seitz eb15d18676 fix: wrap in try/catch 2026-02-11 13:52:39 +11:00
Marc Seitz 1bbc0b5bb5 feat: update domain validation 2026-02-11 13:45:06 +11:00
Cursor AgentandMarc Seitz 9ec1fc6c20 refactor: rewrite jackson lib to use same DB with jackson-specific tables
- Rewrite lib/jackson.ts to match Dub's pattern: named export,
  globalThis singleton, clientSecretVerifier, same DB connection
- Add jackson.prisma schema with jackson_index, jackson_store,
  jackson_ttl tables (shared database, separate tables)
- Update migration to create Jackson tables alongside Team fields
- Rename connectionController → apiController across all consumers
- Switch all imports from default to named: { jackson }
- Remove unnecessary env vars (JACKSON_EXTERNAL_URL, SAML_PATH,
  JACKSON_ENCRYPTION_KEY) — Jackson uses NEXTAUTH_URL and
  NEXTAUTH_SECRET directly

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-11 02:44:45 +00:00
Marc Seitz 516a2af892 chore: add gh cli skill 2026-02-11 13:36:19 +11:00
Marc Seitz 876eaa725a Improve domain add modal UX 2026-02-11 13:31:12 +11:00
Marc SeitzandGitHub b3a0f1e26a Merge pull request #2051 from mfts/cursor/dataroom-document-renaming-563d
Dataroom document renaming
2026-02-11 13:17:50 +11:00
Marc SeitzandGitHub d445bc00c4 Merge pull request #2050 from mfts/cursor/deleted-link-slug-handling-e5c1
Deleted link slug handling
2026-02-11 13:05:15 +11:00
Cursor AgentandMarc Seitz 7feb3e4e14 feat: add rename document option to dataroom document card
- Add EditDataroomDocumentModal component for renaming documents
- Add 'Rename' menu item to the three-dot dropdown menu on dataroom document cards
- Uses existing /api/teams/[teamId]/documents/[id]/update-name endpoint
- Follows the same UX pattern as folder renaming (modal with input)
- Properly revalidates SWR cache for dataroom documents and folder tree

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-11 01:12:17 +00:00
Cursor AgentandMarc Seitz d8fdda0edf feat: rename slug on link soft-delete to allow slug reuse
When a link is soft-deleted, rename the slug from <slug> to
<slug>-DELETED-<random 6 alphanumeric chars> so the original slug
can be reused for new links.

Updated all three link deletion endpoints:
- pages/api/links/[id]/index.ts
- pages/api/teams/[teamId]/links/[id]/index.ts
- app/(ee)/api/workflows/[workflowId]/route.ts

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-10 23:29:29 +00:00
Marc Seitz 8ad4839304 fix: time 2026-02-11 10:09:37 +11:00
Marc SeitzandGitHub 6f6a731da9 Merge pull request #2049 from mfts/fix/notio
fix: notion value.value return
2026-02-10 23:30:35 +11:00
Marc Seitz dcc47bba03 fix: notion value.value return
reference: https://github.com/NotionX/react-notion-x/issues/681
2026-02-10 23:18:51 +11:00
Cursor AgentandMarc Seitz 8f62d0bdde fix: fix TypeScript type issues in SCIM endpoint and regenerate Prisma client
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-10 11:10:06 +00:00
Cursor AgentandMarc Seitz c936105455 feat: add SAML/SCIM admin UI, security settings page, and SSO login
- Add Prisma migration for SAML/SCIM fields on Team model
- Create SAML config modal component for admin settings
- Create Directory Sync config modal component for admin settings
- Create security settings page at /settings/security
- Add Security tab to settings navigation and sidebar
- Create SSO login component and add to login page
- Create SAML callback page at /auth/saml

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-10 11:07:55 +00:00
Cursor AgentandMarc Seitz 94fed02d72 feat: add SAML SSO & SCIM directory sync backend (Jackson integration)
- Install @boxyhq/saml-jackson dependency
- Add Jackson environment variables to .env.example
- Create lib/jackson.ts singleton initialization
- Add saml-idp CredentialsProvider to NextAuth config
- Create SAML API routes (authorize, callback, token, userinfo, check)
- Create SAML connection management API (teams/[teamId]/saml)
- Create directory sync management API (teams/[teamId]/directory-sync)
- Create SCIM 2.0 catch-all endpoint (scim/v2.0/[...path])

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-10 11:03:01 +00:00
Marc Seitz d006c4147f feat: add count badges 2026-02-10 16:59:23 +11:00
Marc Seitz ef455e5482 fix: unreachable code 2026-02-10 16:59:14 +11:00
Marc SeitzandGitHub 01b06b58e4 Merge pull request #2048 from mfts/fix/viewer-download
fix: jobs returns proxy urls
2026-02-10 01:23:58 +11:00
Marc Seitz ea14ae1103 fix: jobs returns proxy urls 2026-02-10 01:18:20 +11:00
Marc SeitzandGitHub 47d8c7bddc Merge pull request #2047 from mfts/fix/viewer-download
fix: add relative download url and otp for downloads page
2026-02-10 00:55:19 +11:00
Marc Seitz 564652d9b4 feat: add otp to downloads page 2026-02-10 00:49:22 +11:00
Marc Seitz 2553b32cf4 fix: relative url for downloads 2026-02-10 00:47:13 +11:00
Marc Seitz d35a3338f6 fix: increase max files 2026-02-09 23:40:19 +11:00
Marc SeitzandGitHub a8c604b659 Merge pull request #2045 from mfts/feat/bulk-viewer
feat: improve bulk download
2026-02-09 23:34:28 +11:00
Cursor AgentandMarc Seitz 965dbdf00b feat: add collapsible 'All Links' section in document view
- Add collapsible toggle to the 'All Links' section header (document pages only)
- Default state: expanded (open)
- Persist collapse state in localStorage (key: papermark-all-links-collapsed)
- Add smooth expand/collapse animation with CSS keyframes
- DATAROOM pages retain non-collapsible layout

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-09 12:29:04 +00:00
Marc Seitz c7a95dfbe8 fix: expiration time 2026-02-09 23:24:07 +11:00
Marc Seitz 027608b9d6 fix 2026-02-09 23:07:48 +11:00
Marc Seitz e39db2ee5e fix: validate 2026-02-09 23:06:08 +11:00
Marc Seitz 144218b4d7 fix 2026-02-09 23:06:00 +11:00
Marc Seitz 3c73ba7f7f fix 2026-02-09 23:01:12 +11:00
Marc Seitz cad49a7a3f refactor: extract download panel to component 2026-02-09 22:56:36 +11:00
Marc Seitz 98b8af5332 fix: ratelimiting to endpoint 2026-02-09 22:52:23 +11:00
Marc Seitz 4dedbcfc55 fix: remove error message from client 2026-02-09 22:50:07 +11:00
Marc Seitz d65eba3036 fix 2026-02-09 22:49:05 +11:00
Marc Seitz c2f81db900 fix: lowercase 2026-02-09 22:46:20 +11:00
Marc Seitz 976c8482ce fix: text 2026-02-09 22:46:13 +11:00
Marc Seitz 054f411458 fix: viewId guard 2026-02-09 22:43:57 +11:00
Marc Seitz 98244608f1 fix: useRef 2026-02-09 22:42:58 +11:00
Marc Seitz 400f8e288d feat: improve bulk download 2026-02-09 22:23:34 +11:00
Iuliia Shnai 017f66e59c feat:new papermark example document 2026-02-09 18:02:50 +11:00
Marc SeitzandGitHub f2e88b57f0 Merge pull request #2036 from mfts/fix/notion-video
feat: add notion video styles
2026-02-06 19:51:19 +11:00
Iuliia Shnai 8a874e10e0 feat:updates 2026-02-06 16:21:41 +11:00
Marc SeitzandGitHub a15df65ef1 Merge pull request #2042 from mfts/fix/ai-gen
fix: ai dataroom generation
2026-02-06 14:36:16 +11:00
Marc Seitz 7825e1cd1f fix: ai dataroom generation 2026-02-06 14:32:05 +11:00
Iuliia Shnai 7c50bf6242 featL:updates 2026-02-06 14:31:28 +11:00
Iuliia Shnai fb51399469 feat: add two sruvey questions 2026-02-06 14:29:12 +11:00
Marc SeitzandGitHub 4172a97716 Merge pull request #2040 from mfts/cursor/ai-data-room-folder-structure-5859
Ai data room folder structure
2026-02-06 13:54:51 +11:00
Cursor AgentandMarc Seitz 65d9d51040 fix: further simplify AI data room folder structure
- Limited to 2 levels only: top-level folders + 1 subfolder level
- Reduced top-level folders from 10 to max 8
- Max 5 subfolders per top-level folder
- No deeper nesting allowed (subfolders cannot have subfolders)

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-06 02:48:08 +00:00
Marc SeitzandGitHub 2f3a352ae0 Merge pull request #2039 from mfts/cursor/slack-notifications-domain-ignore-4a77
Slack notifications domain ignore
2026-02-06 13:45:59 +11:00
Cursor AgentandMarc Seitz 6f4ca098bf fix: Align ignored domain check with send-notification.ts and avoid PII in logs
- Remove .toLowerCase() to make domain comparison case-sensitive, matching send-notification.ts behavior
- Log only the extracted domain instead of full email address to avoid persisting PII
- Use fallback 'unknown-domain' if email cannot be parsed

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-06 02:41:37 +00:00
Cursor AgentandMarc Seitz 079af5b864 fix: reduce AI data room folder structure complexity
- Reduced max folder depth from 5 levels to 3 levels
- Limited top-level folders to max 10
- Limited subfolders: max 5 at level 1, max 4 at level 2
- Reduced maxOutputTokens from 1000 to 600
- Lowered temperature from 0.5 to 0.3 for more consistent output
- Added validation constraints to both generate-ai-structure and generate-ai endpoints

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-06 02:34:20 +00:00
Marc SeitzandGitHub df1862d6bc Merge pull request #2038 from mfts/feat/slack
feat: add private channel support
2026-02-06 13:17:53 +11:00
Marc Seitz 1578ea9d67 fix: mobile screen full screen 2026-02-06 13:15:49 +11:00
Cursor AgentandMarc Seitz 7a4d344b99 feat: Add ignored domains check to Slack notifications
- Skip Slack notifications for viewers whose email domain is in the team's ignored domains list
- Fetch team's ignoredDomains in parallel with integration lookup for efficiency
- Add isViewerDomainIgnored helper method with domain normalization (handles @ prefix)
- Log when notifications are skipped due to ignored domains

This matches the existing behavior in send-notification.ts for email notifications.

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-06 02:00:36 +00:00
Marc Seitz 1121f948d8 feat: add private channel support 2026-02-06 12:39:39 +11:00
Marc Seitz c89037293b feat: add notion video styles 2026-02-05 19:44:33 +11:00
Marc SeitzandGitHub 62d75de88c Merge pull request #2035 from mfts/chore/improve-trigger-logging
chore: add better logging
2026-02-05 18:21:33 +11:00
Marc Seitz 4dbab51f04 chore: add better logging 2026-02-05 16:14:37 +11:00
Marc SeitzandGitHub 295b2bccef Merge pull request #2034 from mfts/introduction-page 2026-02-04 19:42:04 +11:00
Marc Seitz 2a04858b37 Create migration.sql 2026-02-04 18:21:10 +11:00
Marc Seitz e7dfa206d1 feat: add success toast 2026-02-04 17:34:43 +11:00
Marc Seitz f9e3d38140 fix: skip autosavfe after initial save 2026-02-04 17:34:34 +11:00
Marc Seitz c2a57a12f9 fix: check nocookie too 2026-02-04 17:34:10 +11:00
Marc Seitz c8ae154689 fix: render inline items with italic and bold 2026-02-04 17:33:53 +11:00
Marc Seitz 0a5ea654c3 fix: trim youtube url 2026-02-04 17:32:57 +11:00
Marc Seitz b75a5d902a fix: avoid nesting <main> 2026-02-04 17:13:50 +11:00
Marc Seitz 210869a9fd feat: set intro page as viewed on modal close 2026-02-04 17:13:35 +11:00
Marc Seitz b2cf31411c fix: validate youtube video url 2026-02-04 17:11:48 +11:00
Marc Seitz 0de09e34d9 chore: update dependencies 2026-02-04 17:10:10 +11:00
Marc Seitz 03bf3390de chore: typo 2026-02-04 17:10:00 +11:00
Marc Seitz b410910a0d Merge branch 'main' into introduction-page 2026-02-04 17:04:59 +11:00
Iuliia Shnai 1e4011c93c feat:updates 2026-02-04 16:39:32 +11:00
Iuliia Shnai 3ae9e80058 feat:updates 2026-02-04 15:41:23 +11:00
Marc SeitzandGitHub eb2aeae8c3 Merge pull request #2033 from mfts/fix/sidebar-jump
fix: sidebar flickering
2026-02-04 13:36:10 +11:00
Marc SeitzandGitHub 6624b79f8f Merge pull request #2025 from mfts/domain-connect-login
feat: main domain connected on login page
2026-02-04 13:05:35 +11:00
Marc Seitz ac6a121d94 fix 2026-02-04 13:00:30 +11:00
Marc Seitz e43c11a44a fix: sidebar flickering 2026-02-04 10:15:26 +11:00
Marc SeitzandGitHub b6559af1be Merge pull request #2032 from mfts/cursor/dataroom-document-access-authentication-1e24
Dataroom document access authentication
2026-02-04 09:22:49 +11:00
Cursor Agentandmarcftone 41f56f66e7 fix: preserve dataroomId for dataroom links to fix document access authentication
- Fix dataroomId being set to undefined for DATAROOM_LINK types in processLinkData
- Update views-dataroom API to use link.dataroomId from database instead of request body
- This fixes the 'dataroomId Required' validation error when accessing documents in a dataroom
- Ensures dataroom session creation works correctly for direct document access

Co-authored-by: marcftone <marcftone@gmail.com>
2026-02-03 21:02:01 +00:00
Marc SeitzandGitHub 3405aeb9b8 Merge pull request #2031 from mfts/fix/bulk-download
feat: add show individual downloads
2026-02-03 18:45:44 +11:00
Marc Seitz 5d5172815a feat: add show individual downloads 2026-02-03 18:34:38 +11:00
Marc SeitzandGitHub 95d0af04f9 Merge pull request #2030 from mfts/fix/bulk-download
feat: improve dataroom download
2026-02-03 17:50:10 +11:00
Marc Seitz 9b2497dd57 refactor: improve dataroom download 2026-02-03 17:31:22 +11:00
Marc SeitzandGitHub 0f6841f9f5 Merge pull request #2029 from mfts/fix/revalidation-issue
refactor: direct fetch in static pages
2026-02-03 15:01:08 +11:00
Marc Seitz 875799a299 fix: add try catch block 2026-02-03 14:36:07 +11:00
Marc Seitz 5ae7cacda6 fix: change to dot com 2026-02-03 14:29:58 +11:00
Marc Seitz 29b1d8e0e5 fix: proper order of checks 2026-02-03 14:19:24 +11:00
Marc Seitz 52a58a8888 fix: guard against missing teamId 2026-02-03 14:19:14 +11:00
Marc Seitz 978e2d5212 refactor: direct fetch in static pages 2026-02-03 14:09:33 +11:00
Marc SeitzandGitHub 5d4a4eefb4 Merge pull request #2028 from mfts/cursor/command-enter-link-saving-68fc
Command enter link saving
2026-02-03 10:32:16 +11:00
Marc SeitzandGitHub e2b2cc5ed4 Merge pull request #2027 from mfts/cursor/teamid-link-exclusion-25c4
Teamid link exclusion
2026-02-03 10:23:56 +11:00
Marc SeitzandGitHub 45fc84f23e Merge pull request #2026 from mfts/fix/revalidation-issue
fix: errors in revalidation and logging
2026-02-03 10:11:47 +11:00
Marc Seitz cdcc80f178 fix: errors in revalidation and logging 2026-02-03 09:38:36 +11:00
Cursor Agentandmarcftone 91a9dc1b43 Add team ID cmk2hnmqh0000k304zcoezt6n to Papermark link exclusion list
This hides the Papermark branding link on access screens for shared documents
and data rooms for the specified team.

Co-authored-by: marcftone <marcftone@gmail.com>
2026-02-02 21:12:50 +00:00
Marc Seitz d7504748b3 docs: add find-skill skill 2026-02-02 21:49:12 +11:00
Iuliia Shnai 862d8f820f feat: main domain connected on login page 2026-02-02 14:45:54 +11:00
Marc Seitz 053394901c Merge branch 'main' into cursor/PM-468-dataroom-upload-visibility-b14f 2026-01-28 12:24:04 +11:00
Cursor Agentandmarcftone 5e7ff9fa29 feat: show uploaded documents immediately with processing state
- Add PendingUploadsContext for managing optimistic uploads
- Create PendingDocumentCard component to show uploading/processing documents
- Update upload API to return document data for optimistic display
- Update ViewerUploadComponent to track pending uploads
- Update DataroomViewer to display pending uploads at top of list
- Update DocumentUploadModal with success feedback and auto-close
- Add 'pending' prefix to id-helper for generating pending upload IDs

This ensures external visitors see their uploaded documents immediately
after upload, with a clear 'Processing...' indicator while the document
is being processed on the backend.

Fixes PM-468

Co-authored-by: marcftone <marcftone@gmail.com>
2026-01-21 10:33:38 +00:00
Marc Seitz eaae5e92bf fix: type errpr 2026-01-21 22:19:22 +13:00
Marc Seitz 59b5cae07c fix: update child paths when changing a folder name 2026-01-21 18:34:19 +13:00
Marc Seitz 644f0b2c92 fix: colors for black 2026-01-21 18:32:47 +13:00
Marc Seitz cd0d693242 fix: css variable for ring color 2026-01-21 18:31:55 +13:00
Marc Seitz 14f6e010a2 fix: folder length api validation 2026-01-21 18:31:01 +13:00
Marc Seitz dba2b6f470 fix: ringcolor 2026-01-20 23:05:53 +13:00
Marc Seitz 4f2340d923 feat: update folder modal with logo and color 2026-01-20 23:03:39 +13:00
Marc Seitz 1a7a3cbbd8 chore: add skills 2026-01-20 22:47:07 +13:00
Cursor Agentandmarcftone c21a3830bd feat: Add folder customization with icon and color selection
- Add icon and color columns to Folder and DataroomFolder models
- Create Prisma migration for new columns
- Add folder icon list (27 icons) and color palette (8 colors) constants
- Update API endpoints to validate and persist icon/color fields
- Create FolderIconPicker and FolderColorPicker UI components
- Update EditFolderModal with icon/color selection and live preview
- Update FolderCard components to display custom icons/colors
- Support both regular folders and dataroom folders

Closes PM-466

Co-authored-by: marcftone <marcftone@gmail.com>
2026-01-20 07:39:33 +00:00
331 changed files with 34658 additions and 4687 deletions
+177
View File
@@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
+42
View File
@@ -0,0 +1,42 @@
---
name: frontend-design
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics.
license: Complete terms in LICENSE.txt
---
This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
## Design Thinking
Before coding, understand the context and commit to a BOLD aesthetic direction:
- **Purpose**: What problem does this interface solve? Who uses it?
- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
- **Constraints**: Technical requirements (framework, performance, accessibility).
- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
- Production-grade and functional
- Visually striking and memorable
- Cohesive with a clear aesthetic point-of-view
- Meticulously refined in every detail
## Frontend Aesthetics Guidelines
Focus on:
- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
---
name: postgres
description: PostgreSQL best practices, query optimization, connection troubleshooting, and performance improvement. Load when working with Postgres databases.
license: MIT
metadata:
author: planetscale
version: "1.0.0"
---
# PlanetScale Postgres
## Generic Postgres
| Topic | Reference | Use for |
| ---------------------- | ---------------------------------------------------------------- | --------------------------------------------------------- |
| Schema Design | [references/schema-design.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/schema-design.md) | Tables, primary keys, data types, foreign keys |
| Indexing | [references/indexing.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/indexing.md) | Index types, composite indexes, performance |
| Index Optimization | [references/index-optimization.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/index-optimization.md) | Unused/duplicate index queries, index audit |
| Partitioning | [references/partitioning.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/partitioning.md) | Large tables, time-series, data retention |
| Query Patterns | [references/query-patterns.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/query-patterns.md) | SQL anti-patterns, JOINs, pagination, batch queries |
| Optimization Checklist | [references/optimization-checklist.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/optimization-checklist.md) | Pre-optimization audit, cleanup, readiness checks |
| MVCC and VACUUM | [references/mvcc-vacuum.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/mvcc-vacuum.md) | Dead tuples, long transactions, xid wraparound prevention |
## Operations and Architecture
| Topic | Reference | Use for |
| ---------------------- | ---------------------------------------------------------------------------- | --------------------------------------------------------------- |
| Process Architecture | [references/process-architecture.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/process-architecture.md) | Multi-process model, connection pooling, auxiliary processes |
| Memory Architecture | [references/memory-management-ops.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/memory-management-ops.md) | Shared/private memory layout, OS page cache, OOM prevention |
| MVCC Transactions | [references/mvcc-transactions.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/mvcc-transactions.md) | Isolation levels, XID wraparound, serialization errors |
| WAL and Checkpoints | [references/wal-operations.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/wal-operations.md) | WAL internals, checkpoint tuning, durability, crash recovery |
| Replication | [references/replication.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/replication.md) | Streaming replication, slots, sync commit, failover |
| Storage Layout | [references/storage-layout.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/storage-layout.md) | PGDATA structure, TOAST, fillfactor, tablespaces, disk mgmt |
| Monitoring | [references/monitoring.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/monitoring.md) | pg_stat views, logging, pg_stat_statements, host metrics |
| Backup and Recovery | [references/backup-recovery.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/backup-recovery.md) | pg_dump, pg_basebackup, PITR, WAL archiving, backup tools |
## PlanetScale-Specific
| Topic | Reference | Use for |
| ------------------ | ---------------------------------------------------------------------------- | ----------------------------------------------------- |
| Connection Pooling | [references/ps-connection-pooling.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/ps-connection-pooling.md) | PgBouncer, pool sizing, pooled vs direct |
| Extensions | [references/ps-extensions.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/ps-extensions.md) | Supported extensions, compatibility |
| Connections | [references/ps-connections.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/ps-connections.md) | Connection troubleshooting, drivers, SSL |
| Insights | [references/ps-insights.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/ps-insights.md) | Slow queries, MCP server, pscale CLI |
| CLI Commands | [references/ps-cli-commands.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/ps-cli-commands.md) | pscale CLI reference, branches, deploy requests, auth |
| CLI API Insights | [references/ps-cli-api-insights.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/ps-cli-api-insights.md) | Query insights via `pscale api`, schema analysis |
@@ -0,0 +1,41 @@
---
title: Backup and Recovery
description: Logical/physical backups, PITR, WAL archiving, backup tools, and recovery strategies
tags: postgres, backup, recovery, pitr, pg_dump, pg_basebackup, wal-archiving, operations
---
# Backup and Recovery
**FUNDAMENTAL RULE: Backups are useless until you've successfully tested recovery.**
## Logical Backups (pg_dump)
Exports as SQL or custom format; portable across PG versions and architectures. Formats: `-Fp` (plain SQL), `-Fc` (custom compressed, selective restore), `-Fd` (directory, parallel with `-j`), `-Ft` (tar, avoid). Use `-Fd -j 4` for large DBs. Restore: `pg_restore -d dbname file.dump`; add `-j` for parallel restore. Selective table restore: `pg_restore -t tablename`. Slow for large DBs; RPO = backup frequency (typically 24h).
## Physical Backups (pg_basebackup)
Copies raw PGDATA; same major version and platform required; cross-architecture works if same endianness (e.g., x86_64 ↔ ARM64). Faster for large clusters; includes all databases. Flags: `-Ft -z -P` for compressed tar with progress. Manual alternative: `pg_backup_start()` → copy PGDATA → `pg_backup_stop()` (complex; must write returned `backup_label`).
## PITR (Point-in-Time Recovery)
Requires base backup + continuous WAL archiving. Restores to any timestamp, transaction, or named restore point. Without PITR: restore only to backup time (potentially lose hours). With PITR: RPO = minutes. `archive_command` must return 0 ONLY when file is safely stored—premature 0 = data loss risk. `wal_level` must be `replica` or `logical` (not `minimal`).
## WAL Archiving
`archive_mode=on`, `archive_command='test ! -f /archive/%f && cp %p /archive/%f'`. **Test archive command as postgres user** (not root) since permission issues are common. Monitor `pg_stat_archiver` for `failed_count`, `last_archived_time`. Archive failures prevent WAL recycling → disk fills.
## Tool Comparison
| Tool | Use case |
|------|----------|
| pg_dump | Small DBs, migrations, selective restore |
| pg_basebackup | Basic PITR, built-in |
| pgBackRest | Production—parallel, incremental, S3/GCS/Azure, retention |
| Barman | Enterprise PITR, retention policies |
| WAL-G | Cloud-native, S3/GCS/Azure |
## RPO/RTO
Logical only: RPO = backup interval (hours); RTO = hours. PITR: RPO = minutes; RTO = hours. Synchronous replication: RPO = 0; RTO = seconds to minutes (failover).
## Operational Rules
- Verify integrity with `pg_verifybackup` (PG 13+)
- Test recovery / PITR regularly
- Take backups from standby to avoid impacting primary
- Retention: 7 daily, 4 weekly, 12 monthly
- Monitor archive growth and backup age
- **Never assume backups work without testing**
@@ -0,0 +1,69 @@
---
title: Index Optimization Queries
description: Index audit queries
tags: postgres, indexes, unused-indexes, duplicate-indexes, optimization
---
# Index Optimization
## Identify Unused Indexes
Query to find unused indexes:
```sql
-- indexes with 0 scans (check pg_stat_reset / pg_postmaster_start_time first)
SELECT
s.schemaname,
s.relname AS table_name,
s.indexrelname AS index_name,
pg_size_pretty(pg_relation_size(s.indexrelid)) AS index_size
FROM pg_catalog.pg_stat_user_indexes s
JOIN pg_catalog.pg_index i ON s.indexrelid = i.indexrelid
WHERE s.idx_scan = 0
AND 0 <> ALL (i.indkey) -- exclude expression indexes
AND NOT i.indisunique -- exclude UNIQUE indexes
AND NOT EXISTS ( -- exclude constraint-backing indexes
SELECT 1 FROM pg_catalog.pg_constraint c
WHERE c.conindid = s.indexrelid
)
ORDER BY pg_relation_size(s.indexrelid) DESC;
```
## Indexes Per Table Guidelines
- **< 5**: Normal
- **5-10**: Monitor (Verify necessity)
- **> 10**: Audit required (High write overhead)
```sql
SELECT relname AS table, count(*) as index_count
FROM pg_stat_user_indexes
GROUP BY relname
ORDER BY count(*) DESC;
```
## Identify Unused Indexes
Indexes with identical definitions (after normalizing names) on the same table are duplicates:
```sql
SELECT
schemaname || '.' || tablename AS table,
array_agg(indexname) AS duplicate_indexes,
pg_size_pretty(sum(pg_relation_size((schemaname || '.' || indexname)::regclass))) AS total_size
FROM pg_indexes
WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
GROUP BY schemaname, tablename,
regexp_replace(indexdef, 'INDEX \S+ ON ', 'INDEX ON ')
HAVING count(*) > 1;
```
**Always confirm with a human before dropping or removing any indexes identified by the queries above.** Even indexes with 0 scans may be needed for infrequent but critical queries, and stats may have been reset recently.
## Per-table Index Count Guidelines
| Index Count | Recommendation |
| ----------- | ------------------------------------------- |
| <5 | Normal |
| 5-10 | Review for unused/duplicates |
| >10 | Audit required - significant write overhead |
@@ -0,0 +1,61 @@
---
title: Indexing Best Practices
description: Index design guide
tags: postgres, indexes, composite, partial, covering, gin, brin
---
# Indexing Best Practices
## Core Rules
1. **Always index foreign key columns** — PostgreSQL does not auto-create these
2. **Index columns in WHERE, JOIN, and ORDER BY** clauses
3. **Don't over-index** — each index slows writes and uses storage
4. **Verify with EXPLAIN ANALYZE** — confirm indexes are actually used
## Composite Indexes
Put equality columns first, then range/sort columns:
```sql
-- WHERE status = 'active' AND created_at > '2026-01-01'
CREATE INDEX order_status_created_idx ON order (status, created_at);
```
A composite index on `(a, b)` supports queries on `a` + `b` and `a` alone, but not `b` alone.
## Partial Indexes
Reduce index size by filtering to common query patterns.
Only use if index size is problematic but the index is needed for performance.
```sql
CREATE INDEX order_active_idx ON order (customer_id)
WHERE status = 'active';
```
## Covering Indexes
Consider creating covering indexes for commonly executed query patterns that return only 1 or a small number of columns.
## Index Types
| Type | Use Case | Example |
| --- | --- | --- |
| B-tree (default) | Equality, range, sorting | `WHERE id = 1`, `ORDER BY date` |
| GIN | Arrays, JSONB, full-text | `WHERE tags @> ARRAY['x']` |
| GiST | Geometric, range types, full-text | PostGIS, `tsrange`, `tsvector` |
| BRIN | Large sequential/time-series | Append-only logs, events (requires physical row order correlation) |
```sql
CREATE INDEX metadata_idx ON order USING GIN (metadata); -- JSONB
CREATE INDEX event_created_idx ON event USING BRIN (created_at); -- time-series
```
## Guidelines
- Name indexes consistently: `{table}_{column}_idx`
- Review for unused indexes periodically
- **Always confirm with a human before removing or dropping any indexes** — even unused ones may serve a purpose not reflected in recent stats
- Use partial indexes for frequently filtered subsets
- Use covering indexes on hot read paths
@@ -0,0 +1,39 @@
---
title: Memory Architecture and OOM Prevention
description: PostgreSQL shared/private memory layout, OS page cache interaction, and OOM avoidance strategies
tags: postgres, memory, shared_buffers, work_mem, oom, architecture, operations
---
# Memory Architecture and OOM Prevention
## Memory Areas
- **Shared memory**: `shared_buffers` — main data cache, all processes, requires restart to change.
- **Private per backend**: `work_mem` (sorts/hashes/joins, per-operation); `maintenance_work_mem` (VACUUM, CREATE INDEX, ALTER TABLE ADD FOREIGN KEY); `temp_buffers` (8MB default).
- **Planner hint only**: `effective_cache_size` is NOT allocated — set to ~5075% of total RAM.
- **Hash multiplier**: `hash_mem_multiplier` (default 2.0) means hash ops use up to 2× `work_mem`.
## Memory Multiplication Danger
Maximum potential: `work_mem × operations_per_query × (parallel_workers + 1) × connections` (leader participates by default via `parallel_leader_participation = on`; hash operations use up to `hash_mem_multiplier × work_mem`, default 2.0). Example: 128MB work_mem, 3 ops (2 sorts + 1 hash join), 2 parallel workers, 100 connections → 2 sorts at 128MB = 256MB, 1 hash join at 128MB × 2.0 = 256MB, per process = 512MB, × 3 processes (2 workers + leader) = 1536MB/query, × 100 connections = **~150GB** worst case. This case is rare.
Not all queries hit limits at once, but high concurrency + large datasets approach it. This is a common cause of OOM in containerized/Kubernetes deployments. Plan capacity with a 1.52× safety margin.
## OS Page Cache (Double Buffering)
Data exists in both `shared_buffers` and OS page cache. A miss in shared_buffers can still hit OS cache (avoiding disk I/O). Extremely large shared_buffers can hurt performance: less OS cache, slower startup, heavier checkpoints. Optimal split depends on workload (OLTP vs OLAP).
## OOM Prevention
- Implement connection pooling to reduce total backend count.
- Reduce `work_mem` globally; use per-session overrides for heavy queries only.
- Lower `max_parallel_workers_per_gather` in high-concurrency systems.
- Set `statement_timeout` to kill runaway queries.
- Monitor: `dmesg -T | grep "killed process"` and `temp_blks_written` in pg_stat_statements.
## Operational Rules
- Tune per-session first, global last.
- Suspect OOM when memory spikes during high concurrency, dashboards, or large batch jobs.
- Increase memory only after confirming spill behavior (`temp_blks_written > 0`).
- `maintenance_work_mem` can be set much higher (12GB) — fewer processes use it. Cap autovacuum with `autovacuum_work_mem` to avoid `autovacuum_max_workers × maintenance_work_mem` memory spikes.
- `shared_buffers` change requires full restart; `work_mem` is per-session changeable.
@@ -0,0 +1,59 @@
---
title: Monitoring
description: Essential PostgreSQL monitoring views, pg_stat_statements, logging, host metrics, and statistics management
tags: postgres, monitoring, pg_stat_statements, logging, pgbadger, metrics, operations
---
# Monitoring
## Essential Views
- **pg_stat_activity**: First stop when something is wrong — running queries, states, wait events, locks.
- **pg_stat_statements**: Execution stats for all SQL. Requires `shared_preload_libraries = 'pg_stat_statements'` and `CREATE EXTENSION pg_stat_statements`.
- **pg_stat_database**: Cache hit ratio, temp files, deadlocks, connections per database.
- **pg_stat_user_tables**: `seq_scan` vs `idx_scan`, dead tuples, last vacuum/analyze times.
- **pg_stat_user_indexes**: Find unused indexes (`idx_scan = 0` with large size).
- **pg_stat_bgwriter**: `buffers_clean`, `maxwritten_clean`, `buffers_alloc`. Pre-PG 17 also had `buffers_checkpoint`, `buffers_backend` (high = backends bypassing bgwriter). PG 17+ moved checkpoint stats to `pg_stat_checkpointer`.
- **pg_stat_checkpointer** (PG 17+): Checkpoint frequency (`num_timed`, `num_requested`), write/sync time.
## Key Queries
```sql
-- Slow queries (with cache hit ratio)
SELECT query, calls, mean_exec_time,
100.0 * shared_blks_hit / nullif(shared_blks_hit + shared_blks_read, 0) AS cache_hit_pct
FROM pg_stat_statements ORDER BY mean_exec_time DESC LIMIT 10;
-- Connection counts / states
SELECT state, count(*) FROM pg_stat_activity GROUP BY state;
-- Dead tuples (vacuum candidates)
SELECT relname, n_dead_tup, last_autovacuum FROM pg_stat_user_tables ORDER BY n_dead_tup DESC;
-- last_autovacuum = <null> means autovacuum has not run on this table
```
Blocking: use `pg_blocking_pids(pid)` with `pg_stat_activity` to find blocked and blocking sessions.
## Logging — First Line of Defense
PostgreSQL is extremely vocal about problems. **Always check logs first**: `tail -f /var/log/postgresql/postgresql-*.log`.
Key settings: `log_min_duration_statement` (OLTP: 13s, analytics: 3060s, dev: 100500ms). Enable `log_checkpoints=on`, `log_connections=on`, `log_disconnections=on`, `log_lock_waits=on`, `log_temp_files=0`. Use CSV log format for pgBadger analysis; pgBadger generates HTML reports with query stats and performance graphs.
## pg_activity
Interactive top-like tool (pip install pg_activity). Run on DB host for OS metrics alongside PG metrics. Combines `pg_stat_activity` with CPU/memory/I/O context.
## Host Metrics — Critical
PostgreSQL cannot report these. **Monitor them yourself:**
- **CPU**: Steal time >10% in VMs bad; load average > core count; context switches >100k/sec.
- **Memory**: Any swap = performance degradation. Check `dmesg` for OOM kills.
- **Disk I/O**: `iostat -x``%util=100%` means saturated; `await` >10ms = high latency.
- **Disk space**: >90% critical (VACUUM fails, writes fail). Check inode usage too.
- **Network**: Packet loss >0% = problems; high retransmits = instability.
## Statistics Management
Stats accumulate since last reset or restart; check `stats_reset` timestamp. `pg_stat_statements_reset()` clears query stats; `pg_stat_reset()` clears database stats. Reset after major maintenance, config changes, or perf testing — not routinely. Prefer snapshotting stats to external monitoring (Prometheus, Datadog) over resetting. **Always confirm with a human before resetting statistics** — resetting destroys historical performance baselines and can make it harder to identify unused indexes or regressions.
@@ -0,0 +1,38 @@
---
title: MVCC Transactions and Concurrency
description: Transaction isolation levels, XID wraparound prevention, serialization errors, and long-transaction impact
tags: postgres, mvcc, transactions, isolation, xid-wraparound, concurrency, serialization
---
# MVCC Transactions and Concurrency
## Transaction Isolation Levels
- **READ UNCOMMITTED** — treated as READ COMMITTED in PostgreSQL; no dirty reads ever.
- **READ COMMITTED** (default): new snapshot per statement; can see different data within same tx.
- **REPEATABLE READ**: snapshot at first query; can cause serialization errors on write conflicts.
- **SERIALIZABLE**: strongest; transactions appear serial; requires retry logic in app code.
Readers never block writers; writers never block readers (only writer-writer conflicts on same row). No lock escalation — row locks never degrade to table locks.
## XID Wraparound
32-bit transaction IDs wrap at ~2 billion (2^31). `VACUUM FREEZE` replaces old XIDs with FrozenXID (value 2, always visible). Without freeze: after wraparound, old rows appear "in the future" and become **invisible**. Data physically exists but is invisible to all queries — looks like total data loss. PostgreSQL emergency shutdown at 2B XIDs to prevent this. XID wraparound should be avoided at all cost.
Warning messages start at ~1.4B XIDs; shutdown at 2B. Recovery requires single-user mode VACUUM — can take hours to days on large DBs. **Never disable autovacuum** — it's your protection against wraparound.
## XID Age Monitoring
```sql
SELECT datname, age(datfrozenxid),
ROUND(100.0 * age(datfrozenxid) / 2147483648, 2) AS pct
FROM pg_database ORDER BY age(datfrozenxid) DESC;
```
## Long Transaction Impact
A single long-running transaction blocks VACUUM from removing dead tuples across the **entire database**. Causes table bloat, increased disk, slower queries, cache pollution. `idle_in_transaction` connections are the #1 operational MVCC issue. Set `idle_in_transaction_session_timeout` (30s5min). Dead tuples waste I/O on seq scans and cause useless heap lookups from indexes.
## Serialization Errors
Apps **must** handle "could not serialize access" with retry logic. More common in REPEATABLE READ and SERIALIZABLE. Smaller, faster transactions reduce conflict frequency.
@@ -0,0 +1,41 @@
---
title: MVCC and VACUUM
description: MVCC internals, VACUUM/autovacuum tuning, and bloat prevention
tags: postgres, mvcc, vacuum, autovacuum, xid, bloat, dead-tuples
---
# MVCC and VACUUM
## MVCC
Every `UPDATE` creates a new tuple and marks the old one dead; `DELETE` marks tuples dead. Dead tuples accumulate until `VACUUM` reclaims space. Each transaction gets a 32-bit XID (2^32 ≈ 4B values, but modular comparison means the effective danger zone is 2^31 ≈ 2B). VACUUM must freeze old XIDs to prevent wraparound.
## VACUUM vs VACUUM FULL
`VACUUM` is non-blocking (ShareUpdateExclusive lock) and marks dead space reusable. `VACUUM FULL` rewrites the table and requires an AccessExclusive lock — use only as a last resort. For online bloat reduction prefer `pg_squeeze` or `pg_repack`.
## Autovacuum Tuning
Triggers when dead tuples > `Min(autovacuum_vacuum_max_threshold, autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor * reltuples)`. `autovacuum_vacuum_max_threshold` defaults to 100M (PG 18+), capping the threshold for very large tables. Also triggers on inserts exceeding `autovacuum_vacuum_insert_threshold + autovacuum_vacuum_insert_scale_factor * reltuples * pct_not_frozen` (ensures insert-only tables get frozen; PG 13+). For large/hot tables, set per-table overrides:
- `autovacuum_vacuum_scale_factor` — default 0.2; lower to 0.010.05 for large tables.
- `autovacuum_vacuum_cost_delay` — default 2 ms; set to 0 on fast storage.
- `autovacuum_vacuum_cost_limit` — default -1 (uses `vacuum_cost_limit`, effectively 200); raise to 10002000 on fast storage.
- `autovacuum_freeze_max_age` — default 200M; triggers anti-wraparound vacuum.
- `vacuum_failsafe_age` — default 1.6B; last-resort mode (PG 14+) that disables throttling and skips index vacuuming when wraparound is imminent.
## Key Monitoring Queries
Dead tuples: `SELECT relname, n_dead_tup, last_autovacuum FROM pg_stat_user_tables ORDER BY n_dead_tup DESC;`
XID age: `SELECT datname, age(datfrozenxid) AS xid_age FROM pg_database ORDER BY xid_age DESC;`
Long transactions: `SELECT pid, state, now() - xact_start AS tx_age FROM pg_stat_activity WHERE xact_start IS NOT NULL ORDER BY xact_start;`
## Best Practices
- Keep transactions short; set `idle_in_transaction_session_timeout` (30s5min).
- Alert when `age(datfrozenxid)` exceeds 4050% of wraparound (~800M1B).
- Tune autovacuum per-table for write-heavy tables; don't change global defaults first.
- Fix application transaction scope before adjusting vacuum parameters.
- Never disable autovacuum globally.
@@ -0,0 +1,19 @@
---
title: Database Optimization Checklist
description: Optimize checklist
tags: postgres, optimization, indexes, partitioning, maintenance
---
# Optimization Checklist
When optimizing performance, check the following:
- Look for unused indexes (0 scans; exclude unique/primary indexes and verify stats age first)
- Look for duplicate indexes
- Archive audit/log tables >10GB
- Review tables >500GB for partitioning (>100GB for time-series/logs)
- Verify all extensions are supported
- Check for circular foreign key dependencies
- Consider alternatives to UUID primary keys for large tables
- Configure connection pooling for OLTP workloads
- **Always confirm with a human before removing any indexes, dropping partitions, archiving tables, or performing other destructive actions**
@@ -0,0 +1,79 @@
---
title: Table Partitioning Guide
description: Partition guide
tags: postgres, partitioning, range, list, pg_partman, data-retention
---
# Table Partitioning
Plan partitioning upfront for tables expected to grow large. Retrofitting later requires a migration.
## When to Partition
Partitioning benefits maintenance (vacuum, index builds) and data retention more than pure query speed.
| Table Type | Size Threshold | Row Threshold |
| --- | --- | --- |
| General tables | >100 GB (or >RAM) | >20M rows |
| Time-series / logs | >50 GB | >10M rows |
Use the lower thresholds for append-heavy, time-ordered data with retention needs (logs, events, audit trails, metrics).
## Range Partitioning (Most Common)
```sql
-- EXAMPLE
CREATE TABLE event (
id BIGINT GENERATED ALWAYS AS IDENTITY,
event_type TEXT NOT NULL,
payload JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (id, created_at) -- Partition key MUST be part of PK
) PARTITION BY RANGE (created_at);
CREATE TABLE event_2026_01 PARTITION OF event
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE event_2026_02 PARTITION OF event
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
```
## List Partitioning
Useful for partitioning by region, tenant, or category:
```sql
-- EXAMPLE
CREATE TABLE order (
id BIGINT GENERATED ALWAYS AS IDENTITY,
region TEXT NOT NULL,
total NUMERIC(10,2),
PRIMARY KEY (id, region) -- Partition key MUST be part of PK
) PARTITION BY LIST (region);
CREATE TABLE order_us PARTITION OF order FOR VALUES IN ('us');
CREATE TABLE order_eu PARTITION OF order FOR VALUES IN ('eu');
CREATE TABLE order_default PARTITION OF order DEFAULT; -- catches unmatched values
```
## Partition Management
- Use `pg_partman` (extension) to automate partition creation and cleanup.
- Use `DETACH PARTITION` to remove a partition while retaining it as a standalone table (e.g., for archiving).
- Use `DETACH PARTITION ... CONCURRENTLY` (PG 14+) to avoid `ACCESS EXCLUSIVE` locks on the parent table.
- Drop old partitions for data retention instead of `DELETE` to avoid vacuum overhead and bloat.
- Create future partitions ahead of time to avoid insert failures.
- **Always confirm with a human before detaching or dropping partitions.** These are destructive actions — detaching removes data from the partitioned table, and dropping permanently deletes the data.
```sql
-- DESTRUCTIVE: confirm with a human before executing
ALTER TABLE event DETACH PARTITION event_2025_01 CONCURRENTLY;
DROP TABLE event_2025_01;
```
## Guidelines & Limitations
- **Primary Keys**: Partition key columns MUST be included in the `PRIMARY KEY` and any `UNIQUE` constraints.
- **Global Uniqueness**: Global unique constraints on non-partition columns are NOT supported.
- **Indexes**: Indexes defined on the parent are automatically created on all partitions (and future ones).
- **Pruning**: Ensure queries filter by the partition key to enable "partition pruning" (skipping unrelated partitions).
@@ -0,0 +1,46 @@
---
title: Process Architecture
description: PostgreSQL multi-process model, connection management, and auxiliary processes
tags: postgres, processes, connections, pooling, memory, operations
---
# Process Architecture
PostgreSQL uses a **multi-process** model, not multi-threaded: one OS process per client connection. The postmaster is the parent; it spawns backend processes per connection. Each backend has some private memory (`work_mem`, temp buffers). 1000 connections = 1000 processes (~510MB base + query memory each). There is also a large buffer shared amongst all.
## Auxiliary Processes
WAL Writer, Background Writer, Checkpointer, Autovacuum Launcher/Workers, Archiver, WAL Summarizer (PG 17+). These run alongside backends and are not spawned per connection.
## Memory Risk
`work_mem` is per-operation, not per-query. Estimate: `work_mem × operations_per_query × parallel_workers × connections` can grow very large at high concurrency. Scale connections and parallelism before raising `work_mem`.
## Connection Pooling (Critical)
Each connection = OS process (fork overhead, context switching, memory). PgBouncer can multiplex many app connections to fewer DB connections. Typical: 1000 app connections → pooler → 2050 backends. Implement pooling before raising `max_connections`; `max_connections` requires a full restart to change (default 100). Note: `superuser_reserved_connections` (default 3) reserves slots for emergency superuser access, so non-superusers are rejected before `max_connections` is fully reached.
## Monitoring
```sql
SELECT state, count(*) FROM pg_stat_activity WHERE backend_type = 'client backend' GROUP BY state;
```
```sql
-- Show used and free connection slots
SELECT count(*) AS used, max(max_conn) - count(*) AS free
FROM pg_stat_activity, (SELECT setting::int AS max_conn FROM pg_settings WHERE name = 'max_connections') s
WHERE backend_type = 'client backend';
```
Use `pg_activity` for interactive top-like monitoring. Alert at 80% connection usage, critical at 95%. Count by state to find idle-in-transaction leaks — these hold locks and **block VACUUM** from reclaiming dead tuples.
## Common Problems
| Problem | Fix |
| ------- | --- |
| `too many clients already` | Implement pooling; find idle connections; check for connection leaks |
| High memory / OOM | Reduce `work_mem`; add pooling; set `statement_timeout` |
| Stuck process | `SELECT pg_cancel_backend(pid);` then `SELECT pg_terminate_backend(pid);`**always confirm with a human before terminating backends**, as this may abort in-flight transactions and cause data issues for the application |
Prefer pooling + conservative `max_connections` over raising limits reactively.
@@ -0,0 +1,53 @@
---
title: CLI Query Insights API
description: CLI insights usage
tags: postgres, planetscale, cli, insights, query-patterns, api
---
# Query Insights via pscale CLI
Analyze slow queries and missing indexes using `pscale api`. Endpoints may change—see https://planetscale.com/docs/api/reference/getting-started-with-planetscale-api for current API docs.
## Using pscale api
The `pscale api` command makes authenticated API calls using your current login or service token (see [ps-cli-commands.md](ps-cli-commands.md#service-token-cicd) for auth setup). No need to manage auth headers manually.
```bash
pscale api "<endpoint>" [--method POST] [--field key=value] [--org <org>]
```
## Query Patterns Reports
```bash
# Create a new report
pscale api "organizations/{org}/databases/{db}/branches/{branch}/query-patterns-reports" \
--method POST --org my-org
# Check status (poll until state=complete)
pscale api "organizations/{org}/databases/{db}/branches/{branch}/query-patterns-reports/{id}/status"
# Download completed report
pscale api "organizations/{org}/databases/{db}/branches/{branch}/query-patterns-reports/{id}"
# List all reports
pscale api "organizations/{org}/databases/{db}/branches/{branch}/query-patterns-reports"
```
## Schema Analysis
```bash
# Get branch schema
pscale api "organizations/{org}/databases/{db}/branches/{branch}/schema"
# Lint schema for issues
pscale api "organizations/{org}/databases/{db}/branches/{branch}/schema/lint"
```
## What to Look For
| Metric | Indicates | Action |
| -------------------------------- | --------------------- | ------------------------------- |
| High `rows_read / rows_returned` | Missing or poor index | Add index on WHERE/JOIN columns |
| High `total_time_s` | Heavy query | Optimize or cache |
| High `count` with same pattern | N+1 queries | Batch or eager-load |
| `indexed: false` | Full table scan | Add index |
@@ -0,0 +1,72 @@
---
title: PlanetScale CLI Reference
description: CLI command guide
tags: planetscale, cli, branches, deploy-requests, authentication
---
# pscale CLI Commands
Full CLI reference: https://planetscale.com/docs/cli. Use `pscale <command> --help` for subcommands and flags.
## Authentication
```bash
pscale auth login # Opens browser
pscale auth logout
pscale org list
pscale org switch <name>
```
### Service Token (CI/CD)
```bash
# Create and configure
pscale service-token create
pscale service-token add-access <id> read_branch --database <db>
# Use in CI/CD
export PLANETSCALE_SERVICE_TOKEN_ID="<id>"
export PLANETSCALE_SERVICE_TOKEN="<token>"
```
## Core Commands
```bash
# Databases
pscale database list
pscale database create <name>
# Branches
pscale branch list <db>
pscale branch create <db> <branch> [--from <parent>]
pscale branch delete <db> <branch> # DESTRUCTIVE — always confirm with a human first
pscale branch schema <db> <branch>
# Deploy requests (schema changes) — Vitess only
pscale deploy-request create <db> <branch>
pscale deploy-request list <db>
pscale deploy-request deploy <db> <number>
# Connect
pscale shell <db> <branch> # Opens psql (Postgres) or mysql (Vitess)
pscale connect <db> <branch> # Proxy for GUI tools (secure tunnel) — Vitess only
# Credentials
pscale role create <db> <branch> <name> # Postgres
pscale password create <db> <branch> <name> # Vitess
# Other
pscale ping # Check latency to regions
pscale region list # Available regions
pscale backup list <db> <branch>
pscale backup create <db> <branch>
```
## Useful Flags
```bash
--format json # Output as JSON (also: csv, human)
--org <name> # Specify organization
--debug # Debug output
```
For API calls via CLI, see [ps-cli-api-insights.md](ps-cli-api-insights.md).
@@ -0,0 +1,72 @@
---
title: PgBouncer Connection Pooling
description: Pooling setup guide
tags: postgres, pgbouncer, connection-pooling, performance, transactions
---
# Connection Pooling with PgBouncer
PlanetScale provides PgBouncer for connection pooling. Connect on port `6432` instead of `5432`.
## When to Use PgBouncer (Port 6432)
All OLTP application workloads: web apps, APIs, high-concurrency read/write operations.
## When to Use Direct Connections (Port 5432)
- Schema changes (DDL)
- Analytics, reporting, batch processing
- Session-specific features (temp tables, session variables)
- ETL, data streaming, `pg_dump`
- Long-running admin transactions
## PgBouncer Types
PlanetScale offers three PgBouncer options. All use port `6432`.
| Type | Runs On | Routes To | Key Trait |
| ---- | ------- | --------- | --------- |
| **Local** | Same node as primary | Primary only | Included with every database; no replica routing |
| **Dedicated Primary** | Separate node | Primary | Connections persist through resizes, upgrades, and most failovers |
| **Dedicated Replica** | Separate node | Replicas | Read-only traffic; supports AZ affinity for lower latency |
- **Local PgBouncer** — use same credentials as direct, just change port to `6432`. Always routes to primary regardless of username.
- **Dedicated Primary** — runs off-server for improved HA. Use for production OLTP write traffic.
- **Dedicated Replica** — runs off-server for read-heavy workloads. Supports AZ affinity to prefer same-zone replicas. Multiple can be created for capacity or per-app isolation.
To connect to a dedicated PgBouncer, append `|pgbouncer-name` to the username (e.g., `postgres.xxx|write-pool` or `postgres.xxx|read-bouncer`).
## Transaction Pooling Limitations
PlanetScale PgBouncer uses **transaction pooling mode**. These features are unavailable:
- Prepared statements that persist across transactions
- Temporary tables
- `LISTEN`/`NOTIFY`
- Session-level advisory locks
- `SET` commands persisting beyond a transaction
## Recommended Patterns
- Size pools from observed concurrency, query memory behavior, and connection limits.
- Keep pooled app traffic on `6432` and reserve direct connections for DDL/admin/long-running jobs.
## Avoid Patterns
- Avoid setting pool size with only `CPU_cores * N` while ignoring query-memory amplification.
- Avoid running session-dependent workflows through transaction pooling.
## Connecting
```bash
# Local PgBouncer (same credentials, port 6432)
psql 'host=xxx.horizon.psdb.cloud port=6432 user=postgres.xxx password=pscale_pw_xxx dbname=mydb sslnegotiation=direct sslmode=verify-full sslrootcert=system'
# Dedicated primary PgBouncer (append |pgbouncer-name to user)
psql 'host=xxx.horizon.psdb.cloud port=6432 user=postgres.xxx|write-pool password=pscale_pw_xxx dbname=mydb sslnegotiation=direct sslmode=verify-full sslrootcert=system'
# Dedicated replica PgBouncer (append |pgbouncer-name to user)
psql 'host=xxx.horizon.psdb.cloud port=6432 user=postgres.xxx|read-bouncer password=pscale_pw_xxx dbname=mydb sslnegotiation=direct sslmode=verify-full sslrootcert=system'
```
Docs: https://planetscale.com/docs/postgres/connecting/pgbouncer
@@ -0,0 +1,37 @@
---
title: PlanetScale Postgres Connections
description: Connection guide for PlanetScale Postgres
tags: planetscale, postgres, connections, ssl, troubleshooting
---
# PlanetScale Postgres Connections
Postgres docs: https://planetscale.com/docs/postgres/connecting
| Protocol | Standard Port | Pooled Port | SSL |
| -------- | ------------- | ----------------------- | -------- |
| Postgres | 5432 | 6432 (PgBouncer) | Required |
Credentials (roles) are branch-specific and cannot be recovered after creation.
## Connection String
```
postgresql://<user>:<password>@<host>.horizon.psdb.cloud:5432/<database>?sslmode=verify-full&sslrootcert=system&sslnegotiation=direct
```
Use port **6432** for PgBouncer (applications/OLTP).
Use port **5432** for DDL, admin tasks, and migrations.
## Troubleshooting
| Error | Fix |
| -------------------------------- | --------------------------------------- |
| `password authentication failed` | Check role format: `<role>.<branch_id>` |
| `too many clients already` | Use PgBouncer (port 6432) |
| `SSL connection is required` | Add `sslmode=verify-full&sslrootcert=system` |
**Best practices:**
- Use the PlanetScale Postgres metrics page to monitor direct and PgBouncer connections
- Route OLTP traffic to port 6432 and reserve 5432 for admin/migrations.
- Avoid raising `max_connections` reactively instead of pooling.
@@ -0,0 +1,27 @@
---
title: PlanetScale PostgreSQL Extensions
description: Extension reference
tags: postgres, extensions
---
# PostgreSQL Extensions on PlanetScale
Only use PlanetScale-supported extensions. For the complete and up-to-date list of available extensions, see: https://planetscale.com/docs/postgres/extensions
Do not rely on hard-coded extension lists — always check the documentation above for current availability.
## Enabling Extensions
Some extensions must first be **enabled in the PlanetScale Dashboard** (Clusters > Extensions) before they can be created in SQL. This often requires a database restart.
Once enabled in the dashboard, create the extension in SQL:
```sql
CREATE EXTENSION IF NOT EXISTS <extension_name>;
```
## Recommended Patterns
- Always check the [PlanetScale extensions docs](https://planetscale.com/docs/postgres/extensions) before assuming an extension is available.
- Verify extension availability in PlanetScale configuration and docs before schema design depends on it.
- Enable `pg_stat_statements` early for baseline query telemetry.
@@ -0,0 +1,62 @@
---
title: PlanetScale Query Insights
description: Query insights guide
tags: postgres, planetscale, insights, monitoring, optimization
---
# PlanetScale Insights
## Fetch current documentation first
Prefer retrieval over pre-training knowledge. Docs: https://planetscale.com/docs
## MCP Server (Preferred)
When the PlanetScale MCP server is configured in your environment, prefer it over CLI. Key tools:
- `planetscale_get_branch_schema` — Get schema for a branch
- `planetscale_execute_read_query` — Run SELECT, SHOW, DESCRIBE, EXPLAIN
- `planetscale_get_insights` — Query performance insights
- `planetscale_list_schema_recommendations` — Index and schema suggestions
- `planetscale_search_documentation` — Search PlanetScale docs
MCP setup: https://planetscale.com/docs/connect/mcp
The MCP server is the ideal way to interact with insights from an AI agent.
If not installed, prompt the user to install it to make the agent more effective.
## Query Insights (CLI)
Generating reports via CLI is a multi-step process (create → wait → download).
See [ps-cli-api-insights.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/ps-cli-api-insights.md) for how to use.
What to look for:
- High `rows_read / rows_returned` ratio → missing index
- High `total_time_s` → optimization target
## Insights UI (Dashboard)
In the [PlanetScale dashboard](https://app.planetscale.com/), select your database and click **Insights**.
- **Filtering** — Pick a branch, choose primary or replica, and scroll through the last 7 days. Click-and-drag on graphs to zoom into a time window.
- **Graphs** — Four tabs: Query latency (p50/p95/p99/p99.9), Queries per second, Rows read/s, and Rows written/s.
- **Queries table** — All queries in the selected timeframe, normalized into patterns. Sortable and filterable by SQL, schema, table, latency, index usage, and more. Customizable columns (count, total time, latency percentiles, rows read/returned/affected, CPU/IO time, cache hit ratio, etc.). Enable sparklines for inline trend graphs. Orange icons flag full table scans.
- **Query deep dive** — Click any query to see per-pattern graphs, summary stats, index usage breakdown, and a table of notable executions (>1 s, >10k rows read, or errors). Use "Summarize query" for an LLM-generated plain-English description.
- **Anomalies tab** — Flags periods with elevated slow-running queries and surfaces the responsible patterns.
- **Errors tab** — Surfaces queries that produced errors.
- **pginsights settings** — `pginsights.raw_queries` enables full query text collection for notable queries; `pginsights.normalize_schema_names` groups identical patterns across schemas (useful for schema-per-tenant designs). Both configurable in the Extensions tab on the Clusters page.
More: [PlanetScale Insights docs](https://planetscale.com/docs/postgres/monitoring/query-insights)
## Optimization Checklist
- Remove unused indexes (0 scans)
- Remove duplicate indexes
- Archive audit/log tables >10 GB
- Review tables >100 GB for partitioning
**Always confirm with a human before removing indexes, dropping tables/partitions, or archiving data.** These are destructive actions that cannot be easily undone.
More: [optimization-checklist.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/optimization-checklist.md)
@@ -0,0 +1,80 @@
---
title: SQL Query Patterns
description: Common SQL anti-patterns and optimized alternatives
tags: postgres, sql, query-optimization, n-plus-one, pagination
---
# SQL Query Patterns
## Query Structure
**SELECT specific columns** — avoids fetching unnecessary data and enables covering indexes:
```sql
-- Bad:
SELECT * FROM user WHERE status = 'active';
-- Good:
SELECT id, name, email FROM user WHERE status = 'active';
```
**Subqueries → JOINs** — correlated subqueries re-execute per row:
```sql
-- Bad
SELECT id, (SELECT COUNT(*) FROM order WHERE order.user_id = user.id) FROM user;
-- Good
SELECT u.id, COUNT(o.id) FROM user u LEFT JOIN order o ON o.user_id = u.id GROUP BY u.id;
```
**Always LIMIT unbounded queries** — prevent runaway result sets:
```sql
SELECT id, message FROM log WHERE level = 'error' ORDER BY created_at DESC LIMIT 100;
```
**Avoid functions on indexed columns (SARGable)** — functions prevent index usage unless a functional index exists:
```sql
-- Bad: Full table scan
SELECT * FROM user WHERE date_trunc('day', created_at) = '2023-01-01';
-- Good: Index scan
SELECT * FROM user WHERE created_at >= '2023-01-01' AND created_at < '2023-01-02';
```
## N+1 Detection
**Queries inside loops → batch with ANY/IN:**
```python
# Bad
for uid in user_ids:
cursor.execute("SELECT name FROM user WHERE id = %s", (uid,))
# Good (Postgres specific)
cursor.execute("SELECT id, name FROM user WHERE id = ANY(%s)", (list(user_ids),))
# Good (Standard SQL)
# cursor.execute("SELECT id, name FROM user WHERE id IN %s", (tuple(user_ids),))
```
**ORM lazy loading → eager loading:**
```python
# Bad: N+1 — each iteration fires a query
for user in User.query.all():
print(user.posts)
# Good
users = User.query.options(joinedload(User.posts)).all()
```
## Query Rewrites
**UNION → UNION ALL** — skip deduplication when duplicates are impossible or acceptable.
**IN subquery → EXISTS** — EXISTS short-circuits on first match:
```sql
SELECT id, name FROM user u
WHERE EXISTS (SELECT 1 FROM order o WHERE o.user_id = u.id AND o.total > 100);
```
**OFFSET → cursor pagination** — OFFSET scans and discards rows, degrading at depth:
```sql
-- Bad: OFFSET 10000 scans 10020 rows
SELECT id, title FROM article ORDER BY created_at DESC LIMIT 20 OFFSET 10000;
-- Good: cursor-based (requires index on (created_at DESC, id DESC))
SELECT id, title FROM article
WHERE (created_at, id) < ('2025-06-15T12:00:00Z', 987654)
ORDER BY created_at DESC, id DESC LIMIT 20;
```
@@ -0,0 +1,49 @@
---
title: Replication
description: Streaming replication, replication slots, synchronous commit levels, failover, and standby management
tags: postgres, replication, streaming, slots, synchronous, failover, standby, operations
---
# Replication
## Streaming Replication for followers
Use physical (byte-for-byte) replication via WAL stream from primary to standbys. Standbys are read-only (hot standby); same major PG version and architecture required (same minor recommended). Without replication slots, the primary may recycle WAL before the standby receives it → standby needs full resync via `pg_basebackup`. Use replication slots to guarantee WAL retention for specific standbys.
## Replication Slots
Postgres supports Physical slots (streaming) and logical slots (logical replication). Slots prevent WAL deletion even if standby is offline — can exhaust `pg_wal/` disk. Use `max_slot_wal_keep_size` to cap retained WAL per slot. Use `idle_replication_slot_timeout` (PG 17+) to auto-invalidate idle slots. `wal_keep_size` is a simpler alternative to slots for WAL retention. Drop inactive slots immediately to prevent disk exhaustion.
Slot lag (MB behind): `SELECT slot_name, pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)/1024/1024 AS mb_behind FROM pg_replication_slots;`
Drop inactive slot: `SELECT pg_drop_replication_slot('slot_name');`
**Always confirm with a human before dropping replication slots.** Dropping an active or needed slot can cause downstream issues.
## Synchronous Commit Levels
| Level | Behavior | Use Case |
|-------|----------|----------|
| `off` | Returns immediately, no wait | Non-critical writes; risks losing ~600ms of commits on crash (no inconsistency) |
| `local` | Waits for local WAL fsync only | Local durability only; no standby wait |
| `remote_write` | Waits for standby OS buffer | Data loss on standby OS crash |
| `on` | Waits for standby WAL to disk when `synchronous_standby_names` is set; otherwise same as `local` | **Default. This level or higher recommended for HA** |
| `remote_apply` | Waits for standby to apply WAL | Strongest; read-your-writes |
Configure with `synchronous_standby_names`. Use `ANY N` for quorum or `FIRST N` for priority-based sync.
## Quorum and Failure
`FIRST 2 (s1, s2, s3)` is priority-based: waits for the 2 highest-priority connected standbys (s1+s2; s3 takes over only if one disconnects). `ANY 2 (s1, s2, s3)` is quorum-based: waits for any 2. With either, if only 1 is healthy, commits hang. Provision at least N+1 standbys: need 2 confirmations → provision 3. PostgreSQL never commits unless required standbys confirm — no inconsistency, but clients may timeout.
## Failover
`pg_ctl promote` or `SELECT pg_promote()` (SQL function, PG 12+) converts standby to primary. One-way: promoted standby cannot rejoin as standby without rebuild. `pg_rewind` can resync old primary to new primary (requires `wal_log_hints=on` or data checksums) — faster than full rebuild. After promotion: update connection strings, rebuild old primary as standby, reconfigure other standbys.
## Monitoring
On the primary, query `pg_stat_replication` for each connected standby's `state` (`streaming` = healthy, `catchup` = behind), `sync_state` (`sync`/`async`), and LSN positions (`sent_lsn`, `write_lsn`, `flush_lsn`, `replay_lsn`) to compute lag. On standbys, `pg_stat_wal_receiver` shows the receiver process status and `flushed_lsn`; compare `pg_last_wal_receive_lsn()` vs `pg_last_wal_replay_lsn()` for local replay lag.
Replication lag (MB): `SELECT application_name, pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)/1024/1024 AS lag_mb FROM pg_stat_replication;`
Enable `wal_compression` (`pglz`, `lz4`, or `zstd`) to compress full page images in WAL (not all WAL data) — reduces WAL size for bandwidth-limited replication.
@@ -0,0 +1,66 @@
---
title: PostgreSQL Schema Design
description: Schema design guide
tags: postgres, schema, primary-keys, data-types, foreign-keys, naming
---
# Schema Design
## Primary Keys
Prefer `BIGINT GENERATED ALWAYS AS IDENTITY`. Avoid random UUIDs (UUIDv4) as primary keys; use `uuidv7()` when you need UUIDs.
```sql
CREATE TABLE user (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email TEXT NOT NULL UNIQUE
);
```
Random UUID PKs (v4) can cause index fragmentation; UUIDs are also larger (16 vs 8 bytes for BIGINT) and can slow joins.
## Data Types
| Use | Avoid |
| --- | --- |
| `TEXT`, `VARCHAR` | Extension-specific types |
| `JSONB` | Custom ENUMs (use CHECK instead) |
| `TIMESTAMPTZ` | `TIMESTAMP` without time zone |
| `BIGINT`, `INTEGER` | Platform-specific types |
Prefer CHECK constraints over ENUM types — they're easier to modify:
```sql
CREATE TABLE order (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
status TEXT NOT NULL CHECK (status IN ('pending', 'shipped', 'delivered'))
);
```
## Foreign Keys
- Always index FK columns (PostgreSQL does not auto-create these)
- Avoid circular FK dependencies
- Suggestion: use `ON DELETE CASCADE` or `ON DELETE SET NULL` explicitly
```sql
CREATE TABLE order (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id BIGINT NOT NULL REFERENCES customer(id) ON DELETE CASCADE
);
CREATE INDEX order_customer_id_idx ON order (customer_id);
```
## Naming Conventions
- Tables: singular snake_case (`user_account`, `order_item`)
- Columns: singular snake_case (`created_at`, `user_id`)
- Indexes: `{table}_{column}_idx`
- Constraints: `{table}_{column}_{type}` (e.g., `order_status_check`)
## General Guidelines
- Add `NOT NULL` to as many columns as possible
- Add `created_at TIMESTAMPTZ DEFAULT NOW()` to all tables
- Use `BIGINT` for all IDs and foreign keys, even on small tables
- Keep tables normalized; denormalize only for proven hot read paths
@@ -0,0 +1,41 @@
---
title: Storage Layout and Tablespaces
description: PGDATA directory structure, TOAST, fillfactor, tablespaces, and disk management
tags: postgres, storage, pgdata, toast, fillfactor, tablespaces, disk, operations
---
# Storage Layout and Tablespaces
## PGDATA Structure
- **base/** — database files (one subdirectory per database, named by OID)
- **global/** — cluster-wide shared catalogs (pg_database, pg_authid, pg_tablespace)
- **pg_wal/** — WAL files
- **pg_xact/** — transaction commit status
"Cluster" in PostgreSQL = single instance with one PGDATA, not an HA cluster. Each table/index = one or more files, split into 1GB segments. Tables have companion **_fsm** (free space map) and **_vm** (visibility map); indexes have **_fsm** only (no _vm), except hash indexes.
## Visibility Map and Free Space Map
- **_vm** tracks all-visible pages — VACUUM skips these
- **_fsm** tracks free space per page — INSERT uses this to find pages with room
- Both are small files but critical for performance
## TOAST
TOAST triggers when a **row** exceeds ~2KB. Large values are compressed and/or moved out-of-line to `pg_toast.pg_toast_<oid>` tables. **Strategies:** PLAIN (no TOAST), EXTENDED (compress+out-of-line, default for text/bytea), EXTERNAL (out-of-line, no compression — use for pre-compressed data), MAIN (compress, avoid out-of-line). TOAST tables bloat like regular tables — they need VACUUM. `SELECT *` fetches all TOAST columns; always SELECT only needed columns. Move large rarely-accessed columns to separate tables.
## Fillfactor
Controls how full pages are packed (default 100%). Lower fillfactor (7080%) leaves room for HOT (Heap-Only Tuple) updates, which avoid index entries and reduce bloat on UPDATE-heavy tables. Keep 100% for insert-only or read-mostly tables. `ALTER TABLE t SET (fillfactor = 70);`
## Tablespaces
`pg_default` (base/), `pg_global` (global/) are built-in. Custom tablespaces: symbolic links in **pg_tblspc/** to other filesystem locations. Use for separating hot data (SSD) from archives (HDD). Moving tablespaces requires exclusive lock on affected tables.
## Disk Monitoring
- `pg_database_size('dbname')`, `pg_total_relation_size('tablename')`, `pg_relation_size('tablename')`
- Monitor disk usage: >80% = at risk; >90% = critical (VACUUM may fail if disk capacity is insufficient)
- Check inode usage (`df -i`) — can run out even with free space
- `pg_wal/` suddenly large = check replication slots and archiving
@@ -0,0 +1,42 @@
---
title: WAL and Checkpoint Operations
description: Write-ahead log internals, checkpoint tuning, durability guarantees, and WAL disk management
tags: postgres, wal, checkpoints, durability, crash-recovery, fsync, operations
---
# WAL and Checkpoint Operations
## WAL Fundamentals
Write-Ahead Logging: logs changes to `pg_wal/` **before** modifying data files. WAL segments are 16MB (fixed at initdb). On COMMIT, PostgreSQL fsyncs WAL to disk and returns SUCCESS — data files are updated lazily. WAL records are written for all changes (including uncommitted transactions and rollbacks). **Never disable `fsync` in production** — power loss without fsync risks unrecoverable data loss.
`wal_level`: `minimal` (crash recovery only), `replica` (default; replication + archiving), `logical` (logical replication).
## Dirty Pages and Checkpoints
A dirty page is modified in shared_buffers but not yet written to data files. A checkpoint flushes all dirty pages to disk and writes a checkpoint record to WAL; recovery only replays WAL since the last checkpoint.
- `checkpoint_timeout` (default 5 min) and `max_wal_size` (default 1GB) — checkpoint on whichever triggers first.
- `checkpoint_completion_target=0.9` spreads I/O over 90% of the interval; avoid spikes.
- "Checkpoints are occurring too frequently" in logs → increase `max_wal_size`.
- **Target: >90% of checkpoints should be time-based** (`num_timed` in `pg_stat_checkpointer`), not size-based (`num_requested`). If num_requested/(num_timed+num_requested) > 10%, tune `max_wal_size` up.
## WAL Disk Management
Replication slots prevent WAL deletion even when standbys are offline — they can fill disk. WAL archiving failures also block recycling. `max_wal_size` is a *soft* limit; WAL can grow beyond it under heavy load.
WAL size: `SELECT count(*) AS files, pg_size_pretty(sum(size)) AS total FROM pg_ls_waldir();`
Slot lag: `SELECT slot_name, pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) AS lag_bytes FROM pg_replication_slots;`
## Checkpoint Monitoring
PG17+ moved checkpoint stats from `pg_stat_bgwriter` to `pg_stat_checkpointer` and renamed columns.
`SELECT num_timed, num_requested, write_time, sync_time, buffers_written FROM pg_stat_checkpointer;`
Backend-direct writes (formerly `buffers_backend` in `pg_stat_bgwriter`) are now tracked in `pg_stat_io`: `SELECT writes FROM pg_stat_io WHERE backend_type = 'client backend' AND object = 'relation';`
## Crash Recovery
On crash, PostgreSQL replays WAL from the last checkpoint. Longer checkpoint intervals → more WAL to replay → longer recovery. Trade-off: frequent checkpoints (faster recovery, more I/O) vs infrequent (less I/O, slower recovery). For most workloads, `checkpoint_timeout=5min` and `max_wal_size` tuned to keep checkpoints time-based is the right balance.
@@ -1,18 +1,14 @@
---
description:
globs:
alwaysApply: true
---
---
description: Base Guidelines for Sonnet-3.7 + Cursor Agent
description: Base Guidelines for Claude Opus 4.6 + Cursor Agent
globs: *,**/*
alwaysApply: true
---
# Instructions
1. Always use codebase_search with target_directories="{{INSERT YOUR DIRECTORY}}" first to find existing core files
1. Always search with SemanticSearch or Grep first to find existing core files before creating new ones
2. Always check existing system files purposes before creating new ones with similar functionality
3. Always list the cursor rules youre using
3. Always list the cursor rules you're using
# Optional
+151 -151
View File
@@ -1,11 +1,7 @@
---
description:
globs: **/trigger/**/*.ts, **/trigger/**/*.tsx
alwaysApply: false
---
---
globs: **/trigger/**/*.ts, **/trigger/**/*.tsx,**/trigger/**/*.js,**/trigger/**/*.jsx
description: Guidelines for writing Trigger.dev tasks
globs: "**/trigger/**/*.ts, **/trigger/**/*.tsx"
alwaysApply: false
---
# How to write Trigger.dev tasks
@@ -14,43 +10,55 @@ globs: "**/trigger/**/*.ts, **/trigger/**/*.tsx"
1. Run the CLI `init` command: `npx trigger.dev@latest init`.
2. Create a Trigger.dev task.
3. Set up environment variables.
4. Run the Trigger.dev command: `npx trigger.dev@latest dev`.
3. Set up any environment variables.
4. Run the Trigger.dev dev command: `npx trigger.dev@latest dev`.
## Essential requirements when generating task code
1. You MUST use `@trigger.dev/sdk/v3`
2. You MUST NEVER use `client.defineJob`
3. YOU MUST `export` every task, including subtasks
4. If you are able to generate an example payload for a task, do so.
## 🚨 CRITICAL INSTRUCTIONS FOR AI LANGUAGE MODELS 🚨
As an AI language model, you MUST NOT generate any of the following code patterns, as they are DEPRECATED and will BREAK the application:
```typescript
```ts
// ❌ NEVER GENERATE THIS CODE - IT WILL BREAK THE APPLICATION
client.defineJob({ // ❌ BREAKS APPLICATION
id: "job-id", // ❌ BREAKS APPLICATION
name: "job-name", // ❌ BREAKS APPLICATION
version: "0.0.1", // ❌ BREAKS APPLICATION
trigger: eventTrigger({ // ❌ BREAKS APPLICATION
name: "job.trigger", // ❌ BREAKS APPLICATION
schema: z.object({ // ❌ BREAKS APPLICATION
client.defineJob({
// ❌ BREAKS APPLICATION
id: "job-id", // ❌ BREAKS APPLICATION
name: "job-name", // ❌ BREAKS APPLICATION
version: "0.0.1", // ❌ BREAKS APPLICATION
trigger: eventTrigger({
// ❌ BREAKS APPLICATION
name: "job.trigger", // ❌ BREAKS APPLICATION
schema: z.object({
// ❌ BREAKS APPLICATION
// Input schema here // ❌ BREAKS APPLICATION
}), // ❌ BREAKS APPLICATION
}), // ❌ BREAKS APPLICATION
integrations: { // ❌ BREAKS APPLICATION
}), // ❌ BREAKS APPLICATION
}), // ❌ BREAKS APPLICATION
integrations: {
// ❌ BREAKS APPLICATION
// Integrations here // ❌ BREAKS APPLICATION
}, // ❌ BREAKS APPLICATION
run: async (payload, io) => { // ❌ BREAKS APPLICATION
// Job logic goes here // ❌ BREAKS APPLICATION
return { // ❌ BREAKS APPLICATION
}, // ❌ BREAKS APPLICATION
run: async (payload, io) => {
// ❌ BREAKS APPLICATION
// Job logic goes here // ❌ BREAKS APPLICATION
return {
// ❌ BREAKS APPLICATION
// Return job results // ❌ BREAKS APPLICATION
}; // ❌ BREAKS APPLICATION
}, // ❌ BREAKS APPLICATION
}; // ❌ BREAKS APPLICATION
}, // ❌ BREAKS APPLICATION
});
```
Instead, you MUST ALWAYS generate ONLY this pattern:
```typescript
```ts
// ✅ ALWAYS GENERATE THIS EXACT PATTERN
import { task } from "@trigger.dev/sdk/v3";
//1. You need to export each task, even if it's a subtask
@@ -64,18 +72,11 @@ export const helloWorld = task({
});
```
## ABSOLUTE REQUIREMENTS FOR AI CODE GENERATION
1. You MUST use `@trigger.dev/sdk/v3`
2. You MUST NEVER use `client.defineJob`
3. YOU MUST `export` every task, including subtasks
4. If you are able to generate an example payload for a task, do so.
## Correct Task implementations
A task is a function that can run for a long time with resilience to failure:
```typescript
```ts
import { task } from "@trigger.dev/sdk/v3";
export const helloWorld = task({
@@ -87,6 +88,7 @@ export const helloWorld = task({
```
Key points:
- Tasks must be exported, even subtasks in the same file
- Each task needs a unique ID within your project
- The `run` function contains your task logic
@@ -97,7 +99,7 @@ Key points:
Control retry behavior when errors occur:
```typescript
```ts
export const taskWithRetries = task({
id: "task-with-retries",
retry: {
@@ -117,7 +119,7 @@ export const taskWithRetries = task({
Control concurrency:
```typescript
```ts
export const oneAtATime = task({
id: "one-at-a-time",
queue: {
@@ -133,7 +135,7 @@ export const oneAtATime = task({
Specify CPU/RAM requirements:
```typescript
```ts
export const heavyTask = task({
id: "heavy-task",
machine: {
@@ -147,21 +149,21 @@ export const heavyTask = task({
Machine configuration options:
| Machine name | vCPU | Memory | Disk space |
| ------------------- | ---- | ------ | ---------- |
| micro | 0.25 | 0.25 | 10GB |
| small-1x (default) | 0.5 | 0.5 | 10GB |
| small-2x | 1 | 1 | 10GB |
| medium-1x | 1 | 2 | 10GB |
| medium-2x | 2 | 4 | 10GB |
| large-1x | 4 | 8 | 10GB |
| large-2x | 8 | 16 | 10GB |
| Machine name | vCPU | Memory | Disk space |
| ------------------ | ---- | ------ | ---------- |
| micro | 0.25 | 0.25 | 10GB |
| small-1x (default) | 0.5 | 0.5 | 10GB |
| small-2x | 1 | 1 | 10GB |
| medium-1x | 1 | 2 | 10GB |
| medium-2x | 2 | 4 | 10GB |
| large-1x | 4 | 8 | 10GB |
| large-2x | 8 | 16 | 10GB |
#### Max Duration
Limit how long a task can run:
```typescript
```ts
export const longTask = task({
id: "long-task",
maxDuration: 300, // 5 minutes
@@ -179,7 +181,7 @@ Tasks support several lifecycle hooks:
Runs before each attempt, can return data for other functions:
```typescript
```ts
export const taskWithInit = task({
id: "task-with-init",
init: async (payload, { ctx }) => {
@@ -195,7 +197,7 @@ export const taskWithInit = task({
Runs after each attempt, regardless of success/failure:
```typescript
```ts
export const taskWithCleanup = task({
id: "task-with-cleanup",
cleanup: async (payload, { ctx }) => {
@@ -211,7 +213,7 @@ export const taskWithCleanup = task({
Runs once when a task starts (not on retries):
```typescript
```ts
export const taskWithOnStart = task({
id: "task-with-on-start",
onStart: async (payload, { ctx }) => {
@@ -227,7 +229,7 @@ export const taskWithOnStart = task({
Runs when a task succeeds:
```typescript
```ts
export const taskWithOnSuccess = task({
id: "task-with-on-success",
onSuccess: async (payload, output, { ctx }) => {
@@ -243,7 +245,7 @@ export const taskWithOnSuccess = task({
Runs when a task fails after all retries:
```typescript
```ts
export const taskWithOnFailure = task({
id: "task-with-on-failure",
onFailure: async (payload, error, { ctx }) => {
@@ -259,7 +261,7 @@ export const taskWithOnFailure = task({
Controls error handling and retry behavior:
```typescript
```ts
export const taskWithErrorHandling = task({
id: "task-with-error-handling",
handleError: async (error, { ctx }) => {
@@ -275,7 +277,7 @@ Global lifecycle hooks can also be defined in `trigger.config.ts` to apply to al
## Correct Schedules task (cron) implementations
```typescript
```ts
import { schedules } from "@trigger.dev/sdk/v3";
export const firstScheduledTask = schedules.task({
@@ -316,7 +318,7 @@ export const firstScheduledTask = schedules.task({
### Attach a Declarative schedule
```typescript
```ts
import { schedules } from "@trigger.dev/sdk/v3";
// Sepcify a cron pattern (UTC)
@@ -330,7 +332,7 @@ export const firstScheduledTask = schedules.task({
});
```
```typescript
```ts
import { schedules } from "@trigger.dev/sdk/v3";
// Specify a specific timezone like this:
@@ -350,6 +352,7 @@ export const secondScheduledTask = schedules.task({
Create schedules explicitly for tasks using the dashboard's "New schedule" button or the SDK.
#### Benefits
- Dynamic creation (e.g., one schedule per user)
- Manage without code deployment:
- Activate/disable
@@ -357,14 +360,16 @@ Create schedules explicitly for tasks using the dashboard's "New schedule" butto
- Delete
#### Implementation
1. Define a task using `schedules.task()`
2. Attach one or more schedules via:
- Dashboard
- SDK
1. Define a task using `schedules.task()`
2. Attach one or more schedules via:
- Dashboard
- SDK
#### Attach schedules with the SDK like this
```typescript
```ts
const createdSchedule = await schedules.create({
//The id of the scheduled task you want to attach to.
task: firstScheduledTask.id,
@@ -379,7 +384,7 @@ const createdSchedule = await schedules.create({
Schema tasks validate payloads against a schema before execution:
```typescript
```ts
import { schemaTask } from "@trigger.dev/sdk/v3";
import { z } from "zod";
@@ -404,7 +409,7 @@ When you trigger a task from your backend code, you need to set the `TRIGGER_SEC
Triggers a single run of a task with specified payload and options without importing the task. Use type-only imports for full type checking.
```typescript
```ts
import { tasks } from "@trigger.dev/sdk/v3";
import type { emailSequence } from "~/trigger/emails";
@@ -422,7 +427,7 @@ export async function POST(request: Request) {
Triggers multiple runs of a single task with different payloads without importing the task.
```typescript
```ts
import { tasks } from "@trigger.dev/sdk/v3";
import type { emailSequence } from "~/trigger/emails";
@@ -430,39 +435,17 @@ export async function POST(request: Request) {
const data = await request.json();
const batchHandle = await tasks.batchTrigger<typeof emailSequence>(
"email-sequence",
data.users.map((u) => ({ payload: { to: u.email, name: u.name } }))
data.users.map((u) => ({ payload: { to: u.email, name: u.name } })),
);
return Response.json(batchHandle);
}
```
### tasks.triggerAndPoll()
Triggers a task and polls until completion. Not recommended for web requests as it blocks until the run completes. Consider using Realtime docs for better alternatives.
```typescript
import { tasks } from "@trigger.dev/sdk/v3";
import type { emailSequence } from "~/trigger/emails";
export async function POST(request: Request) {
const data = await request.json();
const result = await tasks.triggerAndPoll<typeof emailSequence>(
"email-sequence",
{
to: data.email,
name: data.name,
},
{ pollIntervalMs: 5000 }
);
return Response.json(result);
}
```
### batch.trigger()
Triggers multiple runs of different tasks at once, useful when you need to execute multiple tasks simultaneously.
```typescript
```ts
import { batch } from "@trigger.dev/sdk/v3";
import type { myTask1, myTask2 } from "~/trigger/myTasks";
@@ -482,7 +465,7 @@ export async function POST(request: Request) {
Triggers a single run of a task with specified payload and options.
```typescript
```ts
import { myOtherTask, runs } from "~/trigger/my-other-task";
export const myTask = task({
@@ -502,13 +485,15 @@ If you need to call `trigger()` on a task in a loop, use `batchTrigger()` instea
Triggers multiple runs of a single task with different payloads.
```typescript
import { myOtherTask, batch } from "~/trigger/my-other-task";
```ts
import { batch, myOtherTask } from "~/trigger/my-other-task";
export const myTask = task({
id: "my-task",
run: async (payload: string) => {
const batchHandle = await myOtherTask.batchTrigger([{ payload: "some data" }]);
const batchHandle = await myOtherTask.batchTrigger([
{ payload: "some data" },
]);
//...do other stuff
const batch = await batch.retrieve(batchHandle.id);
@@ -520,7 +505,7 @@ export const myTask = task({
Triggers a task and waits for the result, useful when you need to call a different task and use its result.
```typescript
```ts
export const parentTask = task({
id: "parent-task",
run: async (payload: string) => {
@@ -538,7 +523,7 @@ The result object needs to be checked to see if the child task run was successfu
Batch triggers a task and waits for all results, useful for fan-out patterns.
```typescript
```ts
export const batchParentTask = task({
id: "parent-task",
run: async (payload: string) => {
@@ -560,11 +545,13 @@ You can handle run failures by inspecting individual run results and implementin
Batch triggers multiple different tasks and waits for all results.
```typescript
```ts
export const parentTask = task({
id: "parent-task",
run: async (payload: string) => {
const results = await batch.triggerAndWait<typeof childTask1 | typeof childTask2>([
const results = await batch.triggerAndWait<
typeof childTask1 | typeof childTask2
>([
{ id: "child-task-1", payload: { foo: "World" } },
{ id: "child-task-2", payload: { bar: 42 } },
]);
@@ -589,7 +576,7 @@ export const parentTask = task({
Batch triggers multiple tasks by passing task instances, useful for static task sets.
```typescript
```ts
export const parentTask = task({
id: "parent-task",
run: async (payload: string) => {
@@ -608,7 +595,7 @@ export const parentTask = task({
Batch triggers multiple tasks by passing task instances and waits for all results.
```typescript
```ts
export const parentTask = task({
id: "parent-task",
run: async (payload: string) => {
@@ -638,24 +625,24 @@ Metadata allows attaching up to 256KB of structured data to a run, which can be
Add metadata when triggering a task:
```typescript
```ts
const handle = await myTask.trigger(
{ message: "hello world" },
{ metadata: { user: { name: "Eric", id: "user_1234" } } }
{ metadata: { user: { name: "Eric", id: "user_1234" } } },
);
```
Access metadata inside a run:
```typescript
import { task, metadata } from "@trigger.dev/sdk/v3";
```ts
import { metadata, task } from "@trigger.dev/sdk/v3";
export const myTask = task({
id: "my-task",
run: async (payload: { message: string }) => {
// Get the whole metadata object
const currentMetadata = metadata.current();
// Get a specific key
const user = metadata.get("user");
console.log(user.name); // "Eric"
@@ -679,8 +666,9 @@ Metadata can be updated as the run progresses:
Updates can be chained with a fluent API:
```typescript
metadata.set("progress", 0.1)
```ts
metadata
.set("progress", 0.1)
.append("logs", "Step 1 complete")
.increment("progress", 0.4);
```
@@ -689,13 +677,13 @@ metadata.set("progress", 0.1)
Child tasks can update parent task metadata:
```typescript
```ts
export const childTask = task({
id: "child-task",
run: async (payload: { message: string }) => {
// Update parent task's metadata
metadata.parent.set("progress", 0.5);
// Update root task's metadata
metadata.root.set("status", "processing");
},
@@ -706,7 +694,7 @@ export const childTask = task({
Metadata accepts any JSON-serializable object. For type safety, consider wrapping with Zod:
```typescript
```ts
import { z } from "zod";
const Metadata = z.object({
@@ -739,7 +727,7 @@ Trigger.dev Realtime enables subscribing to runs for real-time updates on run st
Subscribe to a run after triggering a task:
```typescript
```ts
import { runs, tasks } from "@trigger.dev/sdk/v3";
async function myBackend() {
@@ -761,13 +749,14 @@ async function myBackend() {
You can infer types of run's payload and output by passing the task type:
```typescript
```ts
import { runs } from "@trigger.dev/sdk/v3";
import type { myTask } from "./trigger/my-task";
for await (const run of runs.subscribeToRun<typeof myTask>(handle.id)) {
console.log(run.payload.some); // Type-safe access to payload
if (run.output) {
console.log(run.output.result); // Type-safe access to output
}
@@ -778,8 +767,8 @@ for await (const run of runs.subscribeToRun<typeof myTask>(handle.id)) {
Stream data in realtime from inside your tasks using the metadata system:
```typescript
import { task, metadata } from "@trigger.dev/sdk/v3";
```ts
import { metadata, task } from "@trigger.dev/sdk/v3";
import OpenAI from "openai";
export type STREAMS = {
@@ -810,8 +799,10 @@ export const myTask = task({
Subscribe to streams using `withStreams`:
```typescript
for await (const part of runs.subscribeToRun<typeof myTask>(runId).withStreams<STREAMS>()) {
```ts
for await (const part of runs
.subscribeToRun<typeof myTask>(runId)
.withStreams<STREAMS>()) {
switch (part.type) {
case "run": {
console.log("Received run", part.run);
@@ -837,7 +828,7 @@ npm add @trigger.dev/react-hooks
All hooks require a Public Access Token. You can provide it directly to each hook:
```typescriptx
```ts
import { useRealtimeRun } from "@trigger.dev/react-hooks";
function MyComponent({ runId, publicAccessToken }) {
@@ -850,7 +841,7 @@ function MyComponent({ runId, publicAccessToken }) {
Or use the `TriggerAuthContext` provider:
```typescriptx
```ts
import { TriggerAuthContext } from "@trigger.dev/react-hooks";
function SetupTrigger({ publicAccessToken }) {
@@ -864,7 +855,7 @@ function SetupTrigger({ publicAccessToken }) {
For Next.js App Router, wrap the provider in a client component:
```typescriptx
```ts
// components/TriggerProvider.tsx
"use client";
@@ -884,7 +875,8 @@ export function TriggerProvider({ accessToken, children }) {
Several approaches for Next.js App Router:
1. **Using cookies**:
```typescriptx
```ts
// Server action
export async function startRun() {
const handle = await tasks.trigger<typeof exampleTask>("example", { foo: "bar" });
@@ -904,16 +896,20 @@ export default function RunPage({ params }) {
```
2. **Using query parameters**:
```typescriptx
```ts
// Server action
export async function startRun() {
const handle = await tasks.trigger<typeof exampleTask>("example", { foo: "bar" });
const handle = await tasks.trigger<typeof exampleTask>("example", {
foo: "bar",
});
redirect(`/runs/${handle.id}?publicAccessToken=${handle.publicAccessToken}`);
}
```
3. **Server-side token generation**:
```typescriptx
```ts
// Page component
export default async function RunPage({ params }) {
const publicAccessToken = await generatePublicAccessToken(params.id);
@@ -943,7 +939,7 @@ export async function generatePublicAccessToken(runId: string) {
Data fetching hooks that use SWR for caching:
```typescriptx
```ts
"use client";
import { useRun } from "@trigger.dev/react-hooks";
import type { myTask } from "@/trigger/myTask";
@@ -959,6 +955,7 @@ function MyComponent({ runId }) {
```
Common options:
- `revalidateOnFocus`: Revalidate when window regains focus
- `revalidateOnReconnect`: Revalidate when network reconnects
- `refreshInterval`: Polling interval in milliseconds
@@ -973,7 +970,7 @@ For most use cases, Realtime hooks are preferred over SWR hooks with polling due
For client-side usage, generate a public access token with appropriate scopes:
```typescript
```ts
import { auth } from "@trigger.dev/sdk/v3";
const publicToken = await auth.createPublicToken({
@@ -993,7 +990,7 @@ Idempotency ensures that an operation produces the same result when called multi
Provide an `idempotencyKey` when triggering a task to ensure it runs only once with that key:
```typescript
```ts
import { idempotencyKeys, task } from "@trigger.dev/sdk/v3";
export const myTask = task({
@@ -1018,20 +1015,22 @@ export const myTask = task({
By default, keys are scoped to the current run. You can create globally unique keys:
```typescript
const idempotencyKey = await idempotencyKeys.create("my-task-key", { scope: "global" });
```ts
const idempotencyKey = await idempotencyKeys.create("my-task-key", {
scope: "global",
});
```
When triggering from backend code:
```typescript
```ts
const idempotencyKey = await idempotencyKeys.create([myUser.id, "my-task"]);
await tasks.trigger("my-task", { some: "data" }, { idempotencyKey });
```
You can also pass a string directly:
```typescript
```ts
await myTask.trigger({ some: "data" }, { idempotencyKey: myUser.id });
```
@@ -1039,10 +1038,10 @@ await myTask.trigger({ some: "data" }, { idempotencyKey: myUser.id });
The `idempotencyKeyTTL` option defines a time window during which duplicate triggers return the original run:
```typescript
```ts
await childTask.trigger(
{ foo: "bar" },
{ idempotencyKey, idempotencyKeyTTL: "60s" }
{ foo: "bar" },
{ idempotencyKey, idempotencyKeyTTL: "60s" },
);
await wait.for({ seconds: 61 });
@@ -1052,6 +1051,7 @@ await childTask.trigger({ foo: "bar" }, { idempotencyKey });
```
Supported time units:
- `s` for seconds (e.g., `60s`)
- `m` for minutes (e.g., `5m`)
- `h` for hours (e.g., `2h`)
@@ -1061,7 +1061,7 @@ Supported time units:
While not directly supported, you can implement payload-based idempotency by hashing the payload:
```typescript
```ts
import { createHash } from "node:crypto";
const idempotencyKey = await idempotencyKeys.create(hash(payload));
@@ -1083,9 +1083,9 @@ function hash(payload: any): string {
## Correct Logs implementation
```typescript
```ts
// onFailure executes after all retries are exhausted; use for notifications, logging, or side effects on final failure:
import { task, logger } from "@trigger.dev/sdk/v3";
import { logger, task } from "@trigger.dev/sdk/v3";
export const loggingExample = task({
id: "logging-example",
@@ -1100,11 +1100,11 @@ export const loggingExample = task({
});
```
## Correct `trigger.config.ts` implementation
## Correct `trigger.config.ts` implementation
The `trigger.config.ts` file configures your Trigger.dev project, specifying task locations, retry settings, telemetry, and build options.
```typescript
```ts
import { defineConfig } from "@trigger.dev/sdk/v3";
export default defineConfig({
@@ -1129,7 +1129,7 @@ export default defineConfig({
Specify where your tasks are located:
```typescript
```ts
dirs: ["./trigger"],
```
@@ -1139,7 +1139,7 @@ Files with `.test` or `.spec` are automatically excluded, but you can customize
Add global hooks for all tasks:
```typescript
```ts
onStart: async (payload, { ctx }) => {
console.log("Task started", ctx.task.id);
},
@@ -1155,7 +1155,7 @@ onFailure: async (payload, error, { ctx }) => {
Add OpenTelemetry instrumentations for enhanced logging:
```typescript
```ts
telemetry: {
instrumentations: [
new PrismaInstrumentation(),
@@ -1169,7 +1169,7 @@ telemetry: {
Specify the runtime environment:
```typescript
```ts
runtime: "node", // or "bun" (experimental)
```
@@ -1177,7 +1177,7 @@ runtime: "node", // or "bun" (experimental)
Set default machine for all tasks:
```typescript
```ts
defaultMachine: "large-1x",
```
@@ -1185,7 +1185,7 @@ defaultMachine: "large-1x",
Configure logging verbosity:
```typescript
```ts
logLevel: "debug", // Controls logger API logs
```
@@ -1193,7 +1193,7 @@ logLevel: "debug", // Controls logger API logs
Set default maximum runtime for all tasks:
```typescript
```ts
maxDuration: 60, // 60 seconds
```
@@ -1201,7 +1201,7 @@ maxDuration: 60, // 60 seconds
Customize the build process:
```typescript
```ts
build: {
external: ["header-generator"], // Don't bundle these packages
jsx: {
@@ -1245,11 +1245,11 @@ You can also create custom build extensions with hooks like `onBuildStart`, `onB
#### Trigger with:
```typescript
```ts
await myTask.trigger({ name: "Alice", age: 30 });
```
## AI MODEL VERIFICATION STEPS
## AI model verification steps
Before generating any code, you MUST verify:
@@ -1257,7 +1257,7 @@ Before generating any code, you MUST verify:
2. Have you exported every task? If not, STOP and FIX.
3. Have you generated any DEPRECATED code patterns? If yes, STOP and FIX.
## CONSEQUENCES OF INCORRECT IMPLEMENTATION
## Consequences of incorrect implementations
If you generate code that fails the verification steps above, your implementation will:
@@ -1265,10 +1265,10 @@ If you generate code that fails the verification steps above, your implementatio
2. Fail to deploy to the Trigger.dev servers
3. Fail to run in a local Dev environment
## AI MODEL RESPONSE TEMPLATE
## AI model response template
When asked about Trigger.dev task implementation, you MUST:
1. FIRST use code patterns from this guide
2. NEVER suggest deprecated approaches
3. VERIFY your response against the patterns shown here
4. If an answer cannot be found using this guide, look up further information ONLY from the official LLM-friendly version of the [Trigger.dev docs site](mdc:https:/trigger.dev/docs/llms.txt).
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/frontend-design
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/gh-cli
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/postgres
+3 -2
View File
@@ -60,5 +60,6 @@ lib/emails/marketing
# trigger.dev
.trigger
# changelog
changelog
# changelog and docs
changelog
.docs
@@ -115,11 +115,13 @@ export default function EmailVerificationClient() {
<div className="flex w-full justify-center bg-gray-50 md:w-1/2 lg:w-1/2">
<div className="z-10 mx-5 mt-[calc(1vh)] h-fit w-full max-w-md overflow-hidden rounded-lg sm:mx-0 sm:mt-[calc(2vh)] md:mt-[calc(3vh)]">
<div className="items-left flex flex-col space-y-3 px-4 py-6 pt-8 sm:px-12">
<img
src="/_static/papermark-logo.svg"
alt="Papermark Logo"
className="-mt-8 mb-36 h-7 w-auto self-start sm:mb-32 md:mb-48"
/>
<Link href="https://www.papermark.com" target="_blank">
<img
src="/_static/papermark-logo.svg"
alt="Papermark Logo"
className="-mt-8 mb-36 h-7 w-auto self-start sm:mb-32 md:mb-48"
/>
</Link>
<span className="text-balance text-3xl font-semibold text-gray-900">
Code Expired
</span>
@@ -151,11 +153,13 @@ export default function EmailVerificationClient() {
></div>
<div className="z-10 mx-5 mt-[calc(1vh)] h-fit w-full max-w-md overflow-hidden rounded-lg sm:mx-0 sm:mt-[calc(2vh)] md:mt-[calc(3vh)]">
<div className="items-left flex flex-col space-y-3 px-4 py-6 pt-8 sm:px-12">
<img
src="/_static/papermark-logo.svg"
alt="Papermark Logo"
className="-mt-8 mb-36 h-7 w-auto self-start sm:mb-32 md:mb-48"
/>
<Link href="https://www.papermark.com" target="_blank">
<img
src="/_static/papermark-logo.svg"
alt="Papermark Logo"
className="-mt-8 mb-36 h-7 w-auto self-start sm:mb-32 md:mb-48"
/>
</Link>
<Link href="/">
<span className="text-balance text-3xl font-semibold text-gray-900">
Check your email
+77
View File
@@ -0,0 +1,77 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { signIn } from "next-auth/react";
/**
* SAML Callback Page
*
* This page handles IdP-initiated SSO flow:
* 1. User clicks the app tile in their IdP dashboard
* 2. Jackson processes the SAML response and redirects here with a `code`
* 3. We exchange the code via the `saml-idp` CredentialsProvider
*
* SP-initiated SSO (user clicks "Continue with SSO" on login page) is handled
* entirely by NextAuth's OAuth flow via the `saml` provider — it never hits this page.
*/
export default function SAMLCallbackClient() {
const searchParams = useSearchParams();
const router = useRouter();
const [status, setStatus] = useState<"loading" | "error">("loading");
const [errorMessage, setErrorMessage] = useState<string>("");
useEffect(() => {
const code = searchParams?.get("code");
if (code) {
signIn("saml-idp", {
code,
redirect: false,
}).then((result) => {
if (result?.ok) {
router.push("/dashboard");
} else {
setStatus("error");
setErrorMessage(
result?.error || "SSO authentication failed. Please try again.",
);
}
});
} else {
setStatus("error");
setErrorMessage(
"No authorization code received from your identity provider.",
);
}
}, [searchParams, router]);
if (status === "error") {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="mx-auto max-w-md text-center">
<h2 className="text-xl font-semibold text-gray-900">
SSO Login Failed
</h2>
<p className="mt-2 text-sm text-gray-600">{errorMessage}</p>
<button
onClick={() => router.push("/login")}
className="mt-4 rounded-md bg-gray-900 px-4 py-2 text-sm text-white hover:bg-gray-800"
>
Return to Login
</button>
</div>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center">
<div className="mx-auto max-w-md text-center">
<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-gray-900" />
<p className="text-sm text-gray-600">Completing SSO login...</p>
</div>
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { Metadata } from "next";
import SAMLCallbackClient from "./page-client";
export const metadata: Metadata = {
title: "SSO Login | Papermark",
description: "Completing SSO login",
};
export default function SAMLCallbackPage() {
return <SAMLCallbackClient />;
}
+31 -6
View File
@@ -1,10 +1,13 @@
"use client";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import { AlertCircle } from "lucide-react";
import { SSOLogin } from "@/ee/features/security/sso";
import { signInWithPasskey } from "@teamhanko/passkeys-next-auth-provider/client";
import { signIn } from "next-auth/react";
import { toast } from "sonner";
@@ -24,6 +27,9 @@ import { Label } from "@/components/ui/label";
export default function Login() {
const { next } = useParams as { next?: string };
const router = useRouter();
const searchParams = useSearchParams();
const authError = searchParams?.get("error");
const isSSORequired = authError === "require-saml-sso";
const [lastUsed, setLastUsed] = useLastUsed();
const authMethods = ["google", "email", "linkedin", "passkey"] as const;
@@ -55,11 +61,13 @@ export default function Login() {
></div>
<div className="z-10 mx-5 mt-[calc(1vh)] h-fit w-full max-w-md overflow-hidden rounded-lg sm:mx-0 sm:mt-[calc(2vh)] md:mt-[calc(3vh)]">
<div className="items-left flex flex-col space-y-3 px-4 py-6 pt-8 sm:px-12">
<img
src="/_static/papermark-logo.svg"
alt="Papermark Logo"
className="md:mb-48s -mt-8 mb-36 h-7 w-auto self-start sm:mb-32"
/>
<Link href="https://www.papermark.com" target="_blank">
<img
src="/_static/papermark-logo.svg"
alt="Papermark Logo"
className="md:mb-48s -mt-8 mb-36 h-7 w-auto self-start sm:mb-32"
/>
</Link>
<Link href="/">
<span className="text-balance text-3xl font-semibold text-gray-900">
Welcome to Papermark
@@ -69,6 +77,20 @@ export default function Login() {
Share documents. Not attachments.
</h3>
</div>
{isSSORequired && (
<div className="mx-4 mb-2 flex items-start gap-3 rounded-lg border border-orange-200 bg-orange-50 px-4 py-3 sm:mx-12">
<AlertCircle className="mt-0.5 h-5 w-5 flex-shrink-0 text-orange-600" />
<div>
<p className="text-sm font-medium text-orange-900">
Your organization requires SSO login
</p>
<p className="mt-1 text-sm text-orange-700">
Please use the <strong>SAML SSO</strong> option below to sign
in with your company&apos;s identity provider.
</p>
</div>
</div>
)}
<form
className="flex flex-col gap-4 px-4 pt-8 sm:px-12"
onSubmit={(e) => {
@@ -209,6 +231,9 @@ export default function Login() {
{lastUsed === "passkey" && <LastUsed />}
</Button>
</div>
<div className="relative">
<SSOLogin autoExpand={isSSORequired} />
</div>
</div>
<p className="mt-10 w-full max-w-md px-4 text-xs text-muted-foreground sm:px-12">
By clicking continue, you acknowledge that you have read and agree
+1 -1
View File
@@ -35,7 +35,7 @@ export default function Register() {
</div>
<div className="z-10 mx-5 mt-[calc(20vh)] h-fit w-full max-w-md overflow-hidden rounded-lg border border-border bg-gray-50 dark:bg-gray-900 sm:mx-0 sm:shadow-xl">
<div className="flex flex-col items-center justify-center space-y-3 px-4 py-6 pt-8 text-center sm:px-16">
<Link href="/">
<Link href="https://www.papermark.com" target="_blank">
<Image
src={PapermarkLogo}
width={119}
@@ -35,8 +35,11 @@ export async function POST(
);
}
const { content, filterDocumentId, filterDataroomDocumentIds } =
validation.data;
const {
content,
filterDocumentId,
filterDataroomDocumentIds,
} = validation.data;
const session = await getServerSession(authOptions);
const searchParams = req.nextUrl.searchParams;
@@ -133,16 +136,49 @@ export async function POST(
}
// Send message and get streaming response
const result = await sendMessage({
const { result, referencesForStream } = await sendMessage({
chatId,
content,
vectorStoreId: chat.vectorStoreId,
filteredDataroomDocumentIds,
filterDocumentId,
userSelectedDataroomDocumentIds: filterDataroomDocumentIds,
dataroomId: chat.dataroomId || undefined,
linkId: chat.linkId || undefined,
});
return result.toTextStreamResponse();
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
try {
for await (const chunk of result.textStream) {
controller.enqueue(encoder.encode(chunk));
}
const referencesSection = await Promise.race([
referencesForStream,
new Promise<string>((resolve) =>
setTimeout(() => resolve(""), 5000),
),
]);
if (referencesSection) {
controller.enqueue(encoder.encode(referencesSection));
}
controller.close();
} catch (error) {
console.error("Error streaming AI response:", error);
controller.error(error);
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
},
});
} catch (error) {
console.error("Error sending message:", error);
return new Response(JSON.stringify({ error: "Internal server error" }), {
@@ -27,10 +27,12 @@ export async function GET(
const userId = (session.user as CustomUser).id;
// Verify user is member of team
const userTeam = await prisma.userTeam.findFirst({
const userTeam = await prisma.userTeam.findUnique({
where: {
userId,
teamId,
userId_teamId: {
userId,
teamId,
},
},
});
+77
View File
@@ -0,0 +1,77 @@
import { jackson } from "@/lib/jackson";
import type { OAuthReq } from "@boxyhq/saml-jackson";
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
export async function GET(req: Request) {
try {
const { oauthController } = await jackson();
const url = new URL(req.url);
const requestParams = Object.fromEntries(
url.searchParams.entries(),
) as unknown as OAuthReq;
const { redirect_url, authorize_form } =
await oauthController.authorize(requestParams);
if (redirect_url) {
return NextResponse.redirect(redirect_url, { status: 302 });
} else if (authorize_form) {
return new Response(authorize_form, {
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
return NextResponse.json(
{ error: "No redirect URL returned" },
{ status: 400 },
);
} catch (error: any) {
console.error("[SAML] Authorize error:", error);
return NextResponse.json(
{ error: error.message || "Internal server error" },
{ status: 500 },
);
}
}
export async function POST(req: Request) {
try {
const { oauthController } = await jackson();
const contentType = req.headers.get("content-type") || "";
let body: Record<string, any>;
if (contentType.includes("application/x-www-form-urlencoded")) {
const formData = await req.formData();
body = Object.fromEntries(formData.entries());
} else {
body = await req.json();
}
const { redirect_url, authorize_form } =
await oauthController.authorize(body as unknown as OAuthReq);
if (redirect_url) {
return NextResponse.redirect(redirect_url, { status: 302 });
} else if (authorize_form) {
return new Response(authorize_form, {
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
return NextResponse.json(
{ error: "No redirect URL returned" },
{ status: 400 },
);
} catch (error: any) {
console.error("[SAML] Authorize error:", error);
return NextResponse.json(
{ error: error.message || "Internal server error" },
{ status: 500 },
);
}
}
+34
View File
@@ -0,0 +1,34 @@
import { jackson } from "@/lib/jackson";
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
export async function POST(req: Request) {
try {
const { oauthController } = await jackson();
const formData = await req.formData();
const RelayState = (formData.get("RelayState") as string) || "";
const SAMLResponse = (formData.get("SAMLResponse") as string) || "";
const { redirect_url } = await oauthController.samlResponse({
RelayState,
SAMLResponse,
});
if (!redirect_url) {
return NextResponse.json(
{ error: "No redirect URL returned" },
{ status: 400 },
);
}
return NextResponse.redirect(redirect_url, { status: 302 });
} catch (error: any) {
console.error("[SAML] Callback error:", error);
return NextResponse.json(
{ error: error.message || "Internal server error" },
{ status: 500 },
);
}
}
+33
View File
@@ -0,0 +1,33 @@
import { jackson } from "@/lib/jackson";
// These imports fix crypto module bundling issues with Jackson in Next.js.
// Without them, the serverless function bundle tree-shakes away jose's crypto
// primitives, causing ERR_CRYPTO_INVALID_KEYLEN at runtime.
// See: https://github.com/ory/polis/blob/main/pages/api/import-hack.ts
import * as jose from "jose";
import { NextResponse } from "next/server";
import * as openidClient from "openid-client";
// Reference the imports so they aren't removed by tree-shaking
const _dependencies = [jose, openidClient];
void _dependencies;
export const dynamic = "force-dynamic";
export async function POST(req: Request) {
try {
const { oauthController } = await jackson();
const formData = await req.formData();
const body = Object.fromEntries(formData.entries());
const token = await oauthController.token(body as any);
return NextResponse.json(token);
} catch (error: any) {
console.error("[SAML] Token error:", error);
return NextResponse.json(
{ error: error.message || "Internal server error" },
{ status: 500 },
);
}
}
+35
View File
@@ -0,0 +1,35 @@
import { jackson } from "@/lib/jackson";
// Force-include crypto dependencies (same workaround as token route)
import * as jose from "jose";
import { NextResponse } from "next/server";
import * as openidClient from "openid-client";
const _dependencies = [jose, openidClient];
void _dependencies;
// Prevent Next.js from statically generating this route at build time —
// it requires a live database connection via Jackson.
export const dynamic = "force-dynamic";
export async function GET(req: Request) {
try {
const { oauthController } = await jackson();
const authHeader = req.headers.get("Authorization");
if (!authHeader) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// RFC 6750: token type is case-insensitive
const token = authHeader.replace(/^bearer\s+/i, "");
const userInfo = await oauthController.userInfo(token);
return NextResponse.json(userInfo);
} catch (error: any) {
console.error("[SAML] UserInfo error:", error);
return NextResponse.json(
{ error: error.message || "Internal server error" },
{ status: 500 },
);
}
}
+66
View File
@@ -0,0 +1,66 @@
import { jackson, jacksonProduct } from "@/lib/jackson";
import prisma from "@/lib/prisma";
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
/**
* POST /api/auth/saml/verify
* Verifies that a team has SSO configured.
* Accepts either `slug` (preferred, user-friendly) or `teamId` (fallback).
* Returns only the teamId — no team names, provider info, or other metadata.
*/
export async function POST(req: Request) {
try {
const body = await req.json();
const { slug, teamId } = body;
if (!slug && !teamId) {
return NextResponse.json(
{ error: "Team slug or ID is required" },
{ status: 400 },
);
}
// Look up team by slug first, then by ID
const team = slug
? await prisma.team.findUnique({
where: { slug },
select: { id: true, ssoEnabled: true },
})
: await prisma.team.findUnique({
where: { id: teamId },
select: { id: true, ssoEnabled: true },
});
const ssoUnavailable = NextResponse.json(
{ error: "SSO is not available for this team." },
{ status: 404 },
);
if (!team || !team.ssoEnabled) {
return ssoUnavailable;
}
// Check Jackson for actual SAML connections
const { apiController } = await jackson();
const connections = await apiController.getConnections({
tenant: team.id,
product: jacksonProduct,
});
if (!connections || connections.length === 0) {
return ssoUnavailable;
}
// Only return the team ID — no names, providers, or other metadata
return NextResponse.json({ data: { teamId: team.id } });
} catch (error: any) {
console.error("[SAML] Verify error:", error);
return NextResponse.json(
{ error: "Something went wrong" },
{ status: 500 },
);
}
}
+194 -2
View File
@@ -4,7 +4,112 @@ import { processDocument } from "@/lib/api/documents/process-document";
import { verifyDataroomSession } from "@/lib/auth/dataroom-auth";
import { DocumentData } from "@/lib/documents/create-document";
import prisma from "@/lib/prisma";
import { sendDataroomUploadNotificationTask } from "@/lib/trigger/dataroom-upload-notification";
import { sanitizePlainText } from "@/lib/utils/sanitize-html";
import { supportsAdvancedExcelMode } from "@/lib/utils/get-content-type";
import { runs } from "@trigger.dev/sdk/v3";
import { waitUntil } from "@vercel/functions";
/**
* GET /api/links/[id]/upload?dataroomId=xxx
* Returns the viewer's previously uploaded documents for this dataroom.
*/
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } },
) {
try {
const linkId = params.id;
const dataroomId = request.nextUrl.searchParams.get("dataroomId");
if (!linkId || !dataroomId) {
return NextResponse.json(
{ message: "Missing required parameters" },
{ status: 400 },
);
}
// Verify the dataroom session
const dataroomSession = await verifyDataroomSession(
request,
linkId,
dataroomId,
);
if (!dataroomSession || !dataroomSession.viewerId) {
return NextResponse.json(
{ message: "Unauthorized" },
{ status: 401 },
);
}
const { viewerId } = dataroomSession;
// Fetch the viewer's uploads for this dataroom
const uploads = await prisma.documentUpload.findMany({
where: {
viewerId,
dataroomId,
linkId,
},
select: {
id: true,
documentId: true,
dataroomDocumentId: true,
originalFilename: true,
uploadedAt: true,
document: {
select: {
id: true,
name: true,
type: true,
versions: {
where: { isPrimary: true },
select: {
id: true,
hasPages: true,
},
take: 1,
},
},
},
dataroomDocument: {
select: {
folderId: true,
},
},
},
orderBy: { uploadedAt: "desc" },
});
const formattedUploads = uploads.map((upload) => {
const fileType = upload.document?.type ?? "";
const hasPages = upload.document?.versions?.[0]?.hasPages ?? false;
const needsProcessing = ["pdf", "docs", "slides"].includes(fileType);
const isComplete = !needsProcessing || hasPages;
return {
id: upload.id,
documentId: upload.documentId,
dataroomDocumentId: upload.dataroomDocumentId,
documentVersionId: upload.document?.versions?.[0]?.id ?? null,
name: upload.originalFilename ?? upload.document?.name ?? "Unknown",
fileType,
folderId: upload.dataroomDocument?.folderId ?? null,
uploadedAt: upload.uploadedAt,
status: isComplete ? "complete" : "processing",
};
});
return NextResponse.json({ uploads: formattedUploads });
} catch (error) {
console.error("Error fetching viewer uploads:", error);
return NextResponse.json(
{ message: "Error fetching uploads" },
{ status: 500 },
);
}
}
export async function POST(
request: NextRequest,
@@ -45,7 +150,9 @@ export async function POST(
where: { id: linkId, dataroomId },
select: {
id: true,
name: true,
enableUpload: true,
enableNotification: true,
uploadFolderId: true,
dataroomId: true,
teamId: true,
@@ -89,9 +196,31 @@ export async function POST(
);
}
if (typeof documentData.name !== "string") {
return NextResponse.json(
{ message: "Document name is required" },
{ status: 400 },
);
}
const sanitizedDocumentName = sanitizePlainText(documentData.name);
if (!sanitizedDocumentName) {
return NextResponse.json(
{ message: "Document name is required" },
{ status: 400 },
);
}
if (sanitizedDocumentName.length > 255) {
return NextResponse.json(
{ message: "Document name too long" },
{ status: 400 },
);
}
const updatedDocumentData = {
...documentData,
name: sanitizedDocumentName,
enableExcelAdvancedMode: documentData.supportedFileType === "sheet" &&
link.team?.enableExcelAdvancedMode &&
supportsAdvancedExcelMode(documentData.contentType),
@@ -139,7 +268,7 @@ export async function POST(
viewId: viewId,
linkId: linkId,
originalFilename: document.name,
fileSize: document.versions[0].fileSize,
fileSize: documentData.fileSize ?? 0,
numPages: document.numPages,
mimeType: document.contentType,
dataroomId: dataroomId,
@@ -148,7 +277,70 @@ export async function POST(
},
});
return NextResponse.json({ success: true });
// 4. Send upload notification to team if enabled
if (link.enableNotification) {
try {
// Cancel any existing pending notification runs for this viewer+dataroom+link
// Note: runs.list tag filter uses OR logic, so we must post-filter
// to ensure we only cancel runs matching ALL three tags
const requiredTags = [
`dataroom_${dataroomId}`,
`link_${linkId}`,
`viewer_${viewerId}`,
];
const allRuns = await runs.list({
taskIdentifier: ["send-dataroom-upload-notification"],
tag: requiredTags,
status: ["DELAYED", "QUEUED"],
period: "10m",
});
const matchingRuns = allRuns.data.filter((run) =>
requiredTags.every((tag) => run.tags?.includes(tag)),
);
await Promise.all(matchingRuns.map((run) => runs.cancel(run.id)));
// Trigger a new notification with 5-minute delay to batch uploads
waitUntil(
sendDataroomUploadNotificationTask.trigger(
{
dataroomId,
linkId,
viewerId,
teamId: link.teamId,
},
{
idempotencyKey: `upload-notification-${link.teamId}-${dataroomId}-${linkId}-${viewerId}-${newDataroomDocument.id}`,
tags: [
`team_${link.teamId}`,
`dataroom_${dataroomId}`,
`link_${linkId}`,
`viewer_${viewerId}`,
],
delay: new Date(Date.now() + 5 * 60 * 1000), // 5 minute delay
},
),
);
} catch (error) {
console.error("Error triggering upload notification:", error);
}
}
// Return document data for optimistic UI rendering
return NextResponse.json({
success: true,
document: {
id: document.id,
name: document.name,
dataroomDocumentId: newDataroomDocument.id,
documentVersionId: document.versions[0]?.id,
folderId: dataroomFolderId,
fileType: document.type,
hasPages: (document.numPages ?? 0) > 0,
createdAt: document.createdAt,
},
});
} catch (error) {
console.error("Error uploading document:", error);
return NextResponse.json(
@@ -0,0 +1,315 @@
import { jackson } from "@/lib/jackson";
import prisma from "@/lib/prisma";
import type { DirectorySyncEvent } from "@boxyhq/saml-jackson";
import { createHash } from "crypto";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
/** Return a truncated SHA-256 hex digest (first 12 chars) for log-safe pseudonymisation. */
function hashEmail(email: string): string {
return createHash("sha256").update(email).digest("hex").slice(0, 12);
}
const handler = async (
req: Request,
{ params }: { params: Promise<{ directory: string[] }> },
) => {
try {
const resolvedParams = await params;
const headersList = await headers();
const authHeader = headersList.get("Authorization");
const apiSecret = authHeader ? authHeader.split(" ")[1] : null;
const url = new URL(req.url);
const query = Object.fromEntries(url.searchParams.entries());
const [directoryId, path, resourceId] = resolvedParams.directory;
let body: any = {};
try {
body = await req.json();
} catch {
body = {};
}
const { directorySyncController } = await jackson();
const request = {
method: req.method as "GET" | "POST" | "PUT" | "PATCH" | "DELETE",
body,
directoryId,
resourceId,
resourceType: (path === "Users" ? "users" : "groups") as
| "users"
| "groups",
apiSecret,
query: {
count: query.count ? parseInt(query.count) : undefined,
startIndex: query.startIndex ? parseInt(query.startIndex) : undefined,
filter: query.filter as string,
},
};
const { status, data } = await directorySyncController.requests.handle(
request,
handleSCIMEvents,
);
return NextResponse.json(data, { status });
} catch (error: any) {
console.error("[SCIM] Request error:", error);
return NextResponse.json(
{
schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
detail: "Internal server error",
status: 500,
},
{ status: 500 },
);
}
};
export {
handler as DELETE,
handler as GET,
handler as PATCH,
handler as POST,
handler as PUT,
};
// ──────────────────────────────────────────────────────────
// SCIM Event Handler — sync changes to the main app DB
// ──────────────────────────────────────────────────────────
async function handleSCIMEvents(event: DirectorySyncEvent) {
const { event: eventType, data, tenant } = event;
// Verify the team exists and has SSO enabled
const team = await prisma.team.findUnique({
where: { id: tenant },
select: { id: true, plan: true, ssoEnabled: true },
});
if (!team || !team.ssoEnabled) {
console.warn(
`[SCIM] Ignoring event for tenant ${tenant} — SSO not enabled`,
);
return;
}
// Plan gate: only datarooms-premium or higher
const allowedPlans = ["datarooms-premium", "datarooms-premium+old"];
if (!allowedPlans.includes(team.plan)) {
console.warn(
`[SCIM] Ignoring event for tenant ${tenant} — plan ${team.plan} not eligible`,
);
return;
}
if (!("email" in data) || !data.email) {
return;
}
// Normalize once so look-ups/upserts always use a consistent lowercase key
const email = data.email.trim().toLowerCase();
try {
switch (eventType) {
case "user.created": {
console.log(
`[SCIM] User created: user_${hashEmail(email)} for tenant ${tenant}`,
);
const user = await prisma.user.upsert({
where: { email },
create: {
email,
name: [data.first_name, data.last_name].filter(Boolean).join(" "),
},
update: {},
});
await prisma.userTeam.upsert({
where: {
userId_teamId: {
userId: user.id,
teamId: tenant,
},
},
update: {},
create: {
userId: user.id,
teamId: tenant,
role: "MEMBER",
},
});
break;
}
case "user.updated": {
console.log(
`[SCIM] User updated: user_${hashEmail(email)} for tenant ${tenant}`,
);
// Handle Azure AD's active/inactive (can be boolean or string in any casing)
const rawActive = (data as any).active;
const normalizedActive =
rawActive === undefined
? undefined
: typeof rawActive === "string"
? rawActive.toLowerCase() === "true"
: Boolean(rawActive);
const isActive = normalizedActive === true;
const isInactive = normalizedActive === false;
if (isInactive) {
// Deactivated — remove from team (same as user.deleted)
const user = await prisma.user.findUnique({
where: { email },
});
if (user) {
await Promise.all([
prisma.link
.updateMany({
where: {
teamId: tenant,
ownerId: user.id,
},
data: {
ownerId: null,
},
})
.catch(() => {
console.warn(
`[SCIM] Could not reset link ownership for user_${hashEmail(email)}`,
);
}),
prisma.userTeam
.delete({
where: {
userId_teamId: {
userId: user.id,
teamId: tenant,
},
},
})
.catch(() => {
console.warn(
`[SCIM] Could not remove team membership for user_${hashEmail(email)}`,
);
}),
]);
}
} else if (isActive) {
// Reactivated — re-add to team
const user = await prisma.user.upsert({
where: { email },
create: {
email,
name: [data.first_name, data.last_name]
.filter(Boolean)
.join(" "),
},
update: {
name:
[data.first_name, data.last_name].filter(Boolean).join(" ") ||
undefined,
},
});
await prisma.userTeam.upsert({
where: {
userId_teamId: {
userId: user.id,
teamId: tenant,
},
},
update: {},
create: {
userId: user.id,
teamId: tenant,
role: "MEMBER",
},
});
} else {
// Just a name/attribute update
await prisma.user
.update({
where: { email },
data: {
name:
[data.first_name, data.last_name]
.filter(Boolean)
.join(" ") || undefined,
},
})
.catch(() => {
console.warn(
`[SCIM] Could not update user user_${hashEmail(email)} — user not found`,
);
});
}
break;
}
case "user.deleted": {
console.log(
`[SCIM] User deleted: user_${hashEmail(email)} for tenant ${tenant}`,
);
const deletedUser = await prisma.user.findUnique({
where: { email },
});
if (deletedUser) {
await Promise.all([
prisma.link
.updateMany({
where: {
teamId: tenant,
ownerId: deletedUser.id,
},
data: {
ownerId: null,
},
})
.catch(() => {
console.warn(
`[SCIM] Could not reset link ownership for user_${hashEmail(email)}`,
);
}),
prisma.userTeam
.delete({
where: {
userId_teamId: {
userId: deletedUser.id,
teamId: tenant,
},
},
})
.catch(() => {
console.warn(
`[SCIM] Could not remove team membership for user_${hashEmail(email)}`,
);
}),
]);
}
break;
}
case "group.created":
case "group.updated":
case "group.deleted":
case "group.user_added":
case "group.user_removed": {
console.log(`[SCIM] Group event ${eventType} for tenant ${tenant}`);
break;
}
}
} catch (error) {
console.error(`[SCIM] Error handling event ${eventType}:`, error);
}
}
@@ -0,0 +1,186 @@
import { jackson, jacksonProduct } from "@/lib/jackson";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
import { getServerSession } from "next-auth/next";
import { NextResponse } from "next/server";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
const SSO_ELIGIBLE_PLANS = ["datarooms-premium", "datarooms-premium+old"];
function isJacksonUnavailableError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return (
message.includes("error connecting to engine") ||
message.includes("Missing Jackson DB URL") ||
message.includes("ENOENT: no such file or directory, open 'system'")
);
}
async function getAuthenticatedAdmin(teamId: string) {
const session = await getServerSession(authOptions);
if (!session) return null;
const userId = (session.user as CustomUser).id;
const teamAccess = await prisma.userTeam.findUnique({
where: { userId_teamId: { userId, teamId } },
select: { role: true },
});
if (!teamAccess || teamAccess.role !== "ADMIN") return null;
const team = await prisma.team.findUnique({
where: { id: teamId },
select: { id: true, plan: true, ssoEnabled: true },
});
if (!team) return null;
return { userId, team };
}
// GET /api/teams/:teamId/directory-sync — list SCIM directories
export async function GET(
req: Request,
{ params }: { params: Promise<{ teamId: string }> },
) {
const { teamId } = await params;
const auth = await getAuthenticatedAdmin(teamId);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
if (!auth.team.ssoEnabled || !SSO_ELIGIBLE_PLANS.includes(auth.team.plan)) {
return NextResponse.json({ directories: [] });
}
const { directorySyncController } = await jackson();
const { data, error } =
await directorySyncController.directories.getByTenantAndProduct(
teamId,
jacksonProduct,
);
if (error) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
return NextResponse.json({ directories: data });
} catch (error: any) {
if (isJacksonUnavailableError(error)) {
console.warn("[SCIM] Jackson unavailable, returning empty directories", error);
return NextResponse.json({ directories: [] });
}
console.error("[SCIM] Get directories error:", error);
return NextResponse.json(
{ error: error.message || "Internal server error" },
{ status: 500 },
);
}
}
// POST /api/teams/:teamId/directory-sync — create a SCIM directory connection
export async function POST(
req: Request,
{ params }: { params: Promise<{ teamId: string }> },
) {
const { teamId } = await params;
const auth = await getAuthenticatedAdmin(teamId);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Plan gate
if (!SSO_ELIGIBLE_PLANS.includes(auth.team.plan)) {
return NextResponse.json(
{ error: "SCIM Directory Sync requires a Datarooms Premium plan" },
{ status: 403 },
);
}
// Feature flag gate
if (!auth.team.ssoEnabled) {
return NextResponse.json(
{ error: "SSO is not enabled for this team" },
{ status: 403 },
);
}
try {
const { directorySyncController } = await jackson();
const body = await req.json();
const { name, type, currentDirectoryId } = body;
// Create the new directory first; only delete the old one on success
const result = await directorySyncController.directories.create({
tenant: teamId,
product: jacksonProduct,
name: name || "Papermark SCIM Directory",
type: type || "azure-scim-v2",
});
if (result.error) {
return NextResponse.json(
{ error: result.error.message },
{ status: 400 },
);
}
// If replacing an existing directory, delete the old one after successful create
if (currentDirectoryId) {
await directorySyncController.directories.delete(currentDirectoryId);
}
return NextResponse.json(result, { status: 201 });
} catch (error: any) {
console.error("[SCIM] Create directory error:", error);
return NextResponse.json(
{ error: error.message || "Internal server error" },
{ status: 500 },
);
}
}
// DELETE /api/teams/:teamId/directory-sync — delete a SCIM directory
export async function DELETE(
req: Request,
{ params }: { params: Promise<{ teamId: string }> },
) {
const { teamId } = await params;
const auth = await getAuthenticatedAdmin(teamId);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const { directorySyncController } = await jackson();
const body = await req.json();
const { directoryId } = body;
if (!directoryId) {
return NextResponse.json(
{ error: "directoryId is required" },
{ status: 400 },
);
}
const { error } =
await directorySyncController.directories.delete(directoryId);
if (error) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
return NextResponse.json({ ok: true });
} catch (error: any) {
console.error("[SCIM] Delete directory error:", error);
return NextResponse.json(
{ error: error.message || "Internal server error" },
{ status: 500 },
);
}
}
+408
View File
@@ -0,0 +1,408 @@
import { jackson, jacksonProduct, samlAudience } from "@/lib/jackson";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
import { isGenericDomain } from "@/lib/utils/email-domain";
import { getServerSession } from "next-auth/next";
import { NextResponse } from "next/server";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
const SSO_ELIGIBLE_PLANS = ["datarooms-premium", "datarooms-premium+old"];
function isJacksonUnavailableError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return (
message.includes("error connecting to engine") ||
message.includes("Missing Jackson DB URL") ||
message.includes("ENOENT: no such file or directory, open 'system'")
);
}
async function getAuthenticatedAdmin(teamId: string) {
const session = await getServerSession(authOptions);
if (!session) return null;
const userId = (session.user as CustomUser).id;
const teamAccess = await prisma.userTeam.findUnique({
where: { userId_teamId: { userId, teamId } },
select: { role: true },
});
if (!teamAccess || teamAccess.role !== "ADMIN") return null;
const team = await prisma.team.findUnique({
where: { id: teamId },
select: { id: true, plan: true, ssoEnabled: true, ssoEmailDomain: true, ssoEnforcedAt: true, slug: true },
});
if (!team) return null;
return { userId, team, email: (session.user as CustomUser).email! };
}
// GET /api/teams/:teamId/saml — list SAML connections + issuer/acs info
export async function GET(
req: Request,
{ params }: { params: Promise<{ teamId: string }> },
) {
const { teamId } = await params;
const auth = await getAuthenticatedAdmin(teamId);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
if (!auth.team.ssoEnabled || !SSO_ELIGIBLE_PLANS.includes(auth.team.plan)) {
return NextResponse.json({
connections: [],
issuer: samlAudience,
acs: `${process.env.NEXTAUTH_URL}/api/auth/saml/callback`,
ssoEmailDomain: auth.team.ssoEmailDomain,
ssoEnforcedAt: auth.team.ssoEnforcedAt,
slug: auth.team.slug,
});
}
const { apiController } = await jackson();
const connections = await apiController.getConnections({
tenant: teamId,
product: jacksonProduct,
});
return NextResponse.json({
connections,
issuer: samlAudience,
acs: `${process.env.NEXTAUTH_URL}/api/auth/saml/callback`,
ssoEmailDomain: auth.team.ssoEmailDomain,
ssoEnforcedAt: auth.team.ssoEnforcedAt,
slug: auth.team.slug,
});
} catch (error: any) {
if (isJacksonUnavailableError(error)) {
console.warn("[SAML] Jackson unavailable, returning empty connections", error);
return NextResponse.json({
connections: [],
issuer: samlAudience,
acs: `${process.env.NEXTAUTH_URL}/api/auth/saml/callback`,
ssoEmailDomain: auth.team.ssoEmailDomain,
ssoEnforcedAt: auth.team.ssoEnforcedAt,
slug: auth.team.slug,
});
}
console.error("[SAML] Get connections error:", error);
return NextResponse.json(
{ error: error.message || "Internal server error" },
{ status: 500 },
);
}
}
// POST /api/teams/:teamId/saml — create a new SAML connection
export async function POST(
req: Request,
{ params }: { params: Promise<{ teamId: string }> },
) {
const { teamId } = await params;
const auth = await getAuthenticatedAdmin(teamId);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Plan gate
if (!SSO_ELIGIBLE_PLANS.includes(auth.team.plan)) {
return NextResponse.json(
{ error: "SSO requires a Datarooms Premium plan" },
{ status: 403 },
);
}
// Feature flag gate
if (!auth.team.ssoEnabled) {
return NextResponse.json(
{ error: "SSO is not enabled for this team" },
{ status: 403 },
);
}
try {
const { apiController } = await jackson();
const body = await req.json();
const { rawMetadata, encodedRawMetadata, metadataUrl, domain } = body;
if (!rawMetadata && !metadataUrl && !encodedRawMetadata) {
return NextResponse.json(
{
error:
"Either rawMetadata, encodedRawMetadata, or metadataUrl is required",
},
{ status: 400 },
);
}
// Normalize the explicit domain provided by the admin (if any)
const explicitDomain = typeof domain === "string"
? domain.trim().toLowerCase().replace(/^@/, "")
: undefined;
// Validate explicit domain format if provided
if (explicitDomain && !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/.test(explicitDomain)) {
return NextResponse.json(
{ error: "Invalid domain format. Please provide a valid domain (e.g., example.com)." },
{ status: 400 },
);
}
// Reject public / free email provider domains SSO should only be
// configured for organisation-owned domains.
if (explicitDomain && isGenericDomain(explicitDomain)) {
return NextResponse.json(
{
error:
"Public email domains (e.g., gmail.com, outlook.com) cannot be used for SSO. Please provide your organization's domain.",
},
{ status: 400 },
);
}
const connection = await apiController.createSAMLConnection({
defaultRedirectUrl: `${process.env.NEXTAUTH_URL}/auth/saml`,
redirectUrl: process.env.NEXTAUTH_URL as string,
tenant: teamId,
product: jacksonProduct,
rawMetadata: rawMetadata || undefined,
encodedRawMetadata: encodedRawMetadata || undefined,
metadataUrl: metadataUrl || undefined,
});
// Attempt to extract a domain hint from the IdP metadata returned by the
// SAML connection. Standard IdP entity IDs / SSO URLs sometimes contain
// the organisation's own domain (e.g. for self-hosted IdPs). We use this
// as an additional validation signal if the admin supplied a domain we
// check it is consistent; if not, we fall back to the metadata hint only
// when it looks like a real organisation domain (not a generic IdP host).
let metadataDomain: string | undefined;
try {
const idpMeta = (connection as any)?.idpMetadata;
const candidateUrls: string[] = [
idpMeta?.entityID,
idpMeta?.sso?.postUrl,
idpMeta?.sso?.redirectUrl,
].filter(Boolean);
const genericIdpHosts = new Set([
"accounts.google.com",
"login.microsoftonline.com",
"sts.windows.net",
"idp.ssocircle.com",
"www.okta.com",
"dev.okta.com",
"auth0.com",
"onelogin.com",
"pingone.com",
]);
for (const raw of candidateUrls) {
try {
const host = new URL(raw).hostname.toLowerCase();
// Skip well-known generic IdP hosts and public email domains
if (
[...genericIdpHosts].some((g) => host === g || host.endsWith(`.${g}`)) ||
isGenericDomain(host)
) {
continue;
}
// Must have at least two labels (e.g. "company.com")
if (host.split(".").length >= 2) {
metadataDomain = host;
break;
}
} catch {
// not a valid URL skip
}
}
} catch {
// metadata extraction is best-effort
}
// Determine the validated domain to persist:
// 1. Prefer the explicitly admin-provided domain.
// 2. Fall back to a domain extracted from metadata (if non-generic).
// 3. If neither is available, do NOT store a domain.
const validatedDomain = explicitDomain || metadataDomain || undefined;
// Only persist ssoEmailDomain when we have a validated value
if (validatedDomain) {
await prisma.team.update({
where: { id: teamId },
data: {
ssoEmailDomain: validatedDomain,
},
});
}
return NextResponse.json(connection, { status: 201 });
} catch (error: any) {
console.error("[SAML] Create connection error:", error);
return NextResponse.json(
{ error: error.message || "Internal server error" },
{ status: 500 },
);
}
}
// PATCH /api/teams/:teamId/saml — update SSO enforcement settings
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ teamId: string }> },
) {
const { teamId } = await params;
const auth = await getAuthenticatedAdmin(teamId);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const body = await req.json();
const { enforced } = body;
if (typeof enforced !== "boolean") {
return NextResponse.json(
{ error: "'enforced' must be a boolean" },
{ status: 400 },
);
}
// Can only enforce if there's an ssoEmailDomain set (which is set when SAML is configured)
if (enforced && !auth.team.ssoEmailDomain) {
return NextResponse.json(
{
error:
"Cannot enforce SSO without a configured email domain. Please configure SAML first.",
},
{ status: 400 },
);
}
// Verify there are active SAML connections before enforcing
if (enforced) {
const { apiController } = await jackson();
const connections = await apiController.getConnections({
tenant: teamId,
product: jacksonProduct,
});
if (!connections || connections.length === 0) {
return NextResponse.json(
{
error:
"Cannot enforce SSO without an active SAML connection. Please configure SAML first.",
},
{ status: 400 },
);
}
}
const now = enforced ? new Date() : null;
await prisma.team.update({
where: { id: teamId },
data: {
ssoEnforcedAt: now,
},
});
return NextResponse.json({
enforced,
ssoEnforcedAt: now?.toISOString() ?? null,
});
} catch (error: any) {
console.error("[SAML] Update enforcement error:", error);
return NextResponse.json(
{ error: error.message || "Internal server error" },
{ status: 500 },
);
}
}
// DELETE /api/teams/:teamId/saml — remove a SAML connection
export async function DELETE(
req: Request,
{ params }: { params: Promise<{ teamId: string }> },
) {
const { teamId } = await params;
const auth = await getAuthenticatedAdmin(teamId);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const { apiController } = await jackson();
const body = await req.json();
const { clientID, clientSecret } = body;
if (!clientID || !clientSecret) {
return NextResponse.json(
{ error: "clientID and clientSecret are required" },
{ status: 400 },
);
}
// Ownership check: verify the connection belongs to this team before deleting
const existingConnections = await apiController.getConnections({
clientID,
});
const connection = Array.isArray(existingConnections)
? existingConnections[0]
: existingConnections;
if (!connection) {
return NextResponse.json(
{ error: "SAML connection not found" },
{ status: 404 },
);
}
if (connection.tenant !== teamId) {
return NextResponse.json(
{ error: "You do not have permission to delete this connection" },
{ status: 403 },
);
}
await apiController.deleteConnections({
clientID,
clientSecret,
tenant: teamId,
product: jacksonProduct,
});
// Check if there are remaining connections
const remaining = await apiController.getConnections({
tenant: teamId,
product: jacksonProduct,
});
if (!remaining || (Array.isArray(remaining) && remaining.length === 0)) {
// No more connections — clear SSO domain and enforcement
await prisma.team.update({
where: { id: teamId },
data: {
ssoEmailDomain: null,
ssoEnforcedAt: null,
},
});
}
return NextResponse.json({ ok: true });
} catch (error: any) {
console.error("[SAML] Delete connection error:", error);
return NextResponse.json(
{ error: error.message || "Internal server error" },
{ status: 500 },
);
}
}
+16 -1
View File
@@ -5,6 +5,7 @@ import {
formatZodError,
} from "@/ee/features/workflows/lib/validation";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { customAlphabet } from "nanoid";
import { getServerSession } from "next-auth";
import { z } from "zod";
@@ -282,6 +283,11 @@ export async function DELETE(
select: {
id: true,
entryLinkId: true,
entryLink: {
select: {
slug: true,
},
},
},
});
@@ -292,6 +298,12 @@ export async function DELETE(
);
}
// Generate a random suffix for the deleted slug to free up the original slug
const generateDeletedSuffix = customAlphabet(
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
6,
);
// Delete workflow and entry link in transaction
// Note: Steps and executions will cascade delete via Prisma relations
await prisma.$transaction([
@@ -299,12 +311,15 @@ export async function DELETE(
prisma.workflow.delete({
where: { id: workflowId },
}),
// Delete entry link
// Soft delete entry link and rename slug so it can be reused
prisma.link.update({
where: { id: workflow.entryLinkId },
data: {
deletedAt: new Date(),
isArchived: true,
...(workflow.entryLink?.slug && {
slug: `${workflow.entryLink.slug}-DELETED-${generateDeletedSuffix()}`,
}),
},
}),
]);
+1
View File
@@ -230,6 +230,7 @@ export async function POST(req: NextRequest) {
data: {
linkType: "WORKFLOW_LINK",
teamId,
ownerId: userId,
name: `${name} - Entry Link`,
slug: slug || null,
domainId: domainId,
@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { receiver } from "@/lib/cron";
import { processDataroomDigest } from "@/lib/emails/process-dataroom-digest";
import { log } from "@/lib/utils";
// Runs daily at 9 AM UTC (0 9 * * *)
export const maxDuration = 300;
export async function POST(req: Request) {
const body = await req.json();
if (process.env.VERCEL === "1") {
const isValid = await receiver.verify({
signature: req.headers.get("Upstash-Signature") || "",
body: JSON.stringify(body),
});
if (!isValid) {
return new Response("Unauthorized", { status: 401 });
}
}
try {
const result = await processDataroomDigest("daily");
return NextResponse.json({ success: true, ...result });
} catch (error) {
await log({
message: `Daily dataroom digest cron failed. \n\nError: ${(error as Error).message}`,
type: "cron",
mention: true,
});
return NextResponse.json({ error: (error as Error).message });
}
}
@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { receiver } from "@/lib/cron";
import { processDataroomDigest } from "@/lib/emails/process-dataroom-digest";
import { log } from "@/lib/utils";
// Runs weekly on Monday at 9 AM UTC (0 9 * * 1)
export const maxDuration = 300;
export async function POST(req: Request) {
const body = await req.json();
if (process.env.VERCEL === "1") {
const isValid = await receiver.verify({
signature: req.headers.get("Upstash-Signature") || "",
body: JSON.stringify(body),
});
if (!isValid) {
return new Response("Unauthorized", { status: 401 });
}
}
try {
const result = await processDataroomDigest("weekly");
return NextResponse.json({ success: true, ...result });
} catch (error) {
await log({
message: `Weekly dataroom digest cron failed. \n\nError: ${(error as Error).message}`,
type: "cron",
mention: true,
});
return NextResponse.json({ error: (error as Error).message });
}
}
+31 -23
View File
@@ -46,7 +46,6 @@ export async function POST(request: NextRequest) {
documentName,
hasPages,
ownerId,
dataroomVerified,
linkType,
dataroomViewId,
viewType,
@@ -61,7 +60,6 @@ export async function POST(request: NextRequest) {
documentName: string | undefined;
hasPages: boolean | undefined;
ownerId: string | null;
dataroomVerified: boolean | undefined;
linkType: string;
dataroomViewId?: string;
viewType: "DATAROOM_VIEW" | "DOCUMENT_VIEW";
@@ -149,6 +147,15 @@ export async function POST(request: NextRequest) {
name: true,
},
},
visitorGroups: {
select: {
visitorGroup: {
select: {
emails: true,
},
},
},
},
},
});
@@ -216,7 +223,6 @@ export async function POST(request: NextRequest) {
linkId,
);
console.log("previewSession", previewSession);
if (!previewSession) {
return NextResponse.json(
{
@@ -237,6 +243,7 @@ export async function POST(request: NextRequest) {
link.dataroomId!,
);
// If we have a dataroom session, use its verified status
if (dataroomSession) {
isEmailVerified = dataroomSession.verified;
@@ -314,10 +321,18 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ message: "Access denied" }, { status: 403 });
}
// Build combined allow list from individual emails + visitor groups
const visitorGroupEmails =
link.visitorGroups?.flatMap((vg) => vg.visitorGroup.emails) || [];
const combinedAllowList = [
...(link.allowList || []),
...visitorGroupEmails,
];
// Check if email is allowed to visit the link
if (link.allowList && link.allowList.length > 0) {
if (combinedAllowList.length > 0) {
// Determine if the email or its domain is allowed
const isAllowed = link.allowList.some((allowed) =>
const isAllowed = combinedAllowList.some((allowed) =>
isEmailMatched(email, allowed),
);
@@ -397,7 +412,7 @@ export async function POST(request: NextRequest) {
// Request OTP Code for email verification if
// 1) email verification is required and
// 2) code is not provided or token not provided
if (link.emailAuthenticated && !code && !token && !dataroomVerified) {
if (link.emailAuthenticated && !code && !token) {
const ipAddressValue = ipAddress(request);
// Rate limit per email/link combination (1 per 30 seconds) to prevent OTP flooding
@@ -453,7 +468,7 @@ export async function POST(request: NextRequest) {
);
}
if (link.emailAuthenticated && code && !dataroomVerified) {
if (link.emailAuthenticated && code) {
const ipAddressValue = ipAddress(request);
const { success } = await ratelimit(10, "1 m").limit(
`verify-otp:${ipAddressValue}`,
@@ -522,7 +537,7 @@ export async function POST(request: NextRequest) {
isEmailVerified = true;
}
if (link.emailAuthenticated && token && !dataroomVerified) {
if (link.emailAuthenticated && token) {
const ipAddressValue = ipAddress(request);
const { success } = await ratelimit(10, "1 m").limit(
`verify-email:${ipAddressValue}`,
@@ -572,9 +587,6 @@ export async function POST(request: NextRequest) {
isEmailVerified = true;
}
if (link.emailAuthenticated && dataroomVerified) {
isEmailVerified = true;
}
}
let viewer: { id: string; email: string; verified: boolean } | null = null;
@@ -669,7 +681,6 @@ export async function POST(request: NextRequest) {
// ** DATAROOM_VIEW **
if (viewType === "DATAROOM_VIEW") {
console.log("viewType is DATAROOM_VIEW");
try {
let newDataroomView: { id: string } | null = null;
if (!isPreview) {
@@ -692,7 +703,7 @@ export async function POST(request: NextRequest) {
clickId: newId("linkView"),
viewId: newDataroomView.id,
linkId,
dataroomId,
dataroomId: link.dataroomId!,
teamId: link.teamId!,
enableNotification: link.enableNotification,
isPaused,
@@ -705,7 +716,7 @@ export async function POST(request: NextRequest) {
try {
await notifyDataroomAccess({
teamId: link.teamId!,
dataroomId,
dataroomId: link.dataroomId!,
linkId,
viewerEmail: verifiedEmail ?? email,
viewerId: viewer?.id,
@@ -743,7 +754,7 @@ export async function POST(request: NextRequest) {
// Create a dataroom session token if a dataroom session doesn't exist yet
if (!dataroomSession && !isPreview) {
const newDataroomSession = await createDataroomSession(
dataroomId,
link.dataroomId!,
linkId,
newDataroomView?.id!,
ipAddress(request) ?? LOCALHOST_IP,
@@ -796,9 +807,6 @@ export async function POST(request: NextRequest) {
// if dataroomSession is not present, create a dataroom view first
if (!dataroomSession) {
console.log(
"no dataroom session present, creating new dataroom view",
);
dataroomView = await prisma.view.create({
data: { ...viewFields, viewType: "DATAROOM_VIEW" },
select: { id: true },
@@ -811,7 +819,7 @@ export async function POST(request: NextRequest) {
clickId: newId("linkView"),
viewId: dataroomView.id,
linkId,
dataroomId,
dataroomId: link.dataroomId!,
teamId: link.teamId!,
enableNotification: link.enableNotification,
isPaused,
@@ -839,7 +847,7 @@ export async function POST(request: NextRequest) {
await notifyDocumentView({
teamId: link.teamId!,
documentId,
dataroomId,
dataroomId: link.dataroomId!,
linkId,
viewerEmail: verifiedEmail ?? email,
viewerId: viewer?.id,
@@ -955,12 +963,12 @@ export async function POST(request: NextRequest) {
link.permissionGroupId) &&
effectiveGroupId &&
documentId &&
dataroomId
link.dataroomId
) {
const dataroomDocument = await prisma.dataroomDocument.findUnique({
where: {
dataroomId_documentId: {
dataroomId: dataroomId,
dataroomId: link.dataroomId,
documentId: documentId,
},
},
@@ -1060,7 +1068,7 @@ export async function POST(request: NextRequest) {
// Create a dataroom session token if a dataroom session doesn't exist yet
if (!dataroomSession && !isPreview) {
const newDataroomSession = await createDataroomSession(
dataroomId,
link.dataroomId!,
linkId,
dataroomView?.id!,
ipAddress(request) ?? LOCALHOST_IP,
+19 -2
View File
@@ -123,6 +123,15 @@ export async function POST(request: NextRequest) {
agentsEnabled: true,
},
},
visitorGroups: {
select: {
visitorGroup: {
select: {
emails: true,
},
},
},
},
},
});
@@ -246,10 +255,18 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ message: "Access denied" }, { status: 403 });
}
// Build combined allow list from individual emails + visitor groups
const visitorGroupEmails =
link.visitorGroups?.flatMap((vg) => vg.visitorGroup.emails) || [];
const combinedAllowList = [
...(link.allowList || []),
...visitorGroupEmails,
];
// Check if email is allowed to visit the link
if (link.allowList && link.allowList.length > 0) {
if (combinedAllowList.length > 0) {
// Determine if the email or its domain is allowed
const isAllowed = link.allowList.some((allowed) =>
const isAllowed = combinedAllowList.some((allowed) =>
isEmailMatched(email, allowed),
);
-1
View File
@@ -1,5 +1,4 @@
User-Agent: *
Disallow: /login
Disallow: /register
Disallow: /verify/
Disallow: /auth/
+8 -5
View File
@@ -11,16 +11,19 @@ const CustomTooltip = ({
payload: any;
active: boolean | undefined;
}) => {
const router = useRouter(); // Call useRouter at the top level
const documentId = router.query.id as string;
const router = useRouter();
const routerDocumentId = router.query.id as string;
// Default pageNumber to 0 or a sensible default if payload is not available
const pageNumber =
payload && payload.length > 0 ? parseInt(payload[0].payload.pageNumber) : 0;
// Default versionNumber to 0 or a sensible default if payload is not available
const documentId =
payload && payload.length > 0 && payload[0].payload.documentId
? (payload[0].payload.documentId as string)
: routerDocumentId;
const versionNumber =
payload && payload.length > 0
payload && payload.length > 0 && !isNaN(parseInt(payload[0].payload.versionNumber))
? parseInt(payload[0].payload.versionNumber)
: 1;
const { data, error } = useDocumentThumbnail(
+10 -3
View File
@@ -24,13 +24,18 @@ const renameDummyDurationKey = (data: Data[]): TransformedData[] => {
}, [] as TransformedData[]);
};
const renameSumDurationKey = (data: SumData[], versionNumber?: number) => {
const renameSumDurationKey = (
data: SumData[],
versionNumber?: number,
documentId?: string,
) => {
return data.map((item) => {
return {
...item,
"Time spent per page": item.sum_duration,
sum_duration: undefined,
versionNumber: versionNumber,
versionNumber: versionNumber ?? 1,
documentId: documentId,
};
});
};
@@ -64,16 +69,18 @@ export default function BarChartComponent({
isSum = false,
isDummy = false,
versionNumber,
documentId,
}: {
data: any;
isSum?: boolean;
isDummy?: boolean;
versionNumber?: number;
documentId?: string;
}) {
const [, setValue] = useState<any>(null);
if (isSum) {
const renamedData = renameSumDurationKey(data, versionNumber);
const renamedData = renameSumDurationKey(data, versionNumber, documentId);
return (
<BarChart
@@ -1,68 +1,49 @@
import { useState } from "react";
import { DownloadIcon } from "lucide-react";
import { toast } from "sonner";
import { DownloadProgressModal } from "@/components/datarooms/download-progress-modal";
import { ResponsiveButton } from "@/components/ui/responsive-button";
export default function DownloadDataroomButton({
teamId,
dataroomId,
dataroomName,
}: {
teamId: string;
dataroomId: string;
dataroomName?: string;
}) {
const [isLoading, setIsLoading] = useState(false);
const [showDownloadModal, setShowDownloadModal] = useState(false);
const downloadDataroom = async () => {
setIsLoading(true);
try {
toast.promise(
fetch(`/api/teams/${teamId}/datarooms/${dataroomId}/download/bulk`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
}),
{
loading: "Downloading dataroom...",
success: async (response) => {
const { downloadUrl } = await response.json();
const link = document.createElement("a");
link.href = downloadUrl;
link.rel = "noopener noreferrer";
document.body.appendChild(link);
link.click();
setTimeout(() => {
document.body.removeChild(link);
}, 100);
return "Dataroom downloaded successfully.";
},
error: (error) => {
console.log(error);
return (
error.message || "An error occurred while downloading dataroom."
);
},
},
);
} catch (error) {
console.error(error);
} finally {
setIsLoading(false);
}
const openDownloadModal = () => {
// Open the modal - it will show existing downloads and allow starting new ones
setShowDownloadModal(true);
};
const handleCloseDownloadModal = () => {
setShowDownloadModal(false);
};
return (
<ResponsiveButton
icon={<DownloadIcon className="h-4 w-4" />}
text="Download"
onClick={downloadDataroom}
variant="outline"
size="sm"
loading={isLoading}
/>
<>
<ResponsiveButton
icon={<DownloadIcon className="h-4 w-4" />}
text="Download"
onClick={openDownloadModal}
variant="outline"
size="sm"
/>
{/* Download Progress Modal */}
<DownloadProgressModal
isOpen={showDownloadModal}
onClose={handleCloseDownloadModal}
jobId={null}
dataroomName={dataroomName}
teamId={teamId}
dataroomId={dataroomId}
/>
</>
);
}
+1 -1
View File
@@ -148,7 +148,7 @@ export function AddDataroomModal({
},
{
id: "fund-management",
name: "Fund Management",
name: "Fundraising & Reporting",
icon: BuildingIcon,
},
{
@@ -8,6 +8,7 @@ import { TeamContextType } from "@/context/team-context";
import {
ArchiveXIcon,
BetweenHorizontalStartIcon,
FilePenIcon,
FileSlidersIcon,
FolderInputIcon,
MoreVertical,
@@ -39,6 +40,7 @@ import {
import { AddToDataroomModal } from "../documents/add-document-to-dataroom-modal";
import { DocumentPreviewButton } from "../documents/document-preview-button";
import FileProcessStatusBar from "../documents/file-process-status-bar";
import { EditDataroomDocumentModal } from "./edit-dataroom-document-modal";
import { SetUnifiedPermissionsModal } from "./groups/set-unified-permissions-modal";
import { MoveToDataroomFolderModal } from "./move-dataroom-folder-modal";
@@ -74,6 +76,7 @@ export default function DataroomDocumentCard({
const [isFirstClick, setIsFirstClick] = useState<boolean>(false);
const [menuOpen, setMenuOpen] = useState<boolean>(false);
const [moveFolderOpen, setMoveFolderOpen] = useState<boolean>(false);
const [renameOpen, setRenameOpen] = useState<boolean>(false);
const dropdownRef = useRef<HTMLDivElement | null>(null);
const [addDataRoomOpen, setAddDataRoomOpen] = useState<boolean>(false);
@@ -92,6 +95,14 @@ export default function DataroomDocumentCard({
}
}, [moveFolderOpen]);
useEffect(() => {
if (!renameOpen) {
setTimeout(() => {
document.body.style.pointerEvents = "";
});
}
}, [renameOpen]);
useEffect(() => {
function handleClickOutside(event: { target: any }) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
@@ -180,7 +191,7 @@ export default function DataroomDocumentCard({
};
const handleCardClick = (e: React.MouseEvent) => {
if (isDragging) {
if (isDragging || menuOpen) {
e.preventDefault();
e.stopPropagation();
return;
@@ -277,7 +288,7 @@ export default function DataroomDocumentCard({
<DropdownMenu open={menuOpen} onOpenChange={handleMenuStateChange}>
<DropdownMenuTrigger asChild>
<Button
// size="icon"
onClick={(e) => e.stopPropagation()}
variant="outline"
className="z-10 h-8 w-8 border-gray-200 bg-transparent p-0 hover:bg-gray-200 dark:border-gray-700 hover:dark:bg-gray-700 lg:h-9 lg:w-9"
>
@@ -287,6 +298,15 @@ export default function DataroomDocumentCard({
</DropdownMenuTrigger>
<DropdownMenuContent align="end" ref={dropdownRef}>
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setRenameOpen(true);
}}
>
<FilePenIcon className="mr-2 h-4 w-4" />
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
@@ -353,6 +373,15 @@ export default function DataroomDocumentCard({
/>
)}
</div>
{renameOpen ? (
<EditDataroomDocumentModal
open={renameOpen}
setOpen={setRenameOpen}
documentId={dataroomDocument.document.id}
documentName={dataroomDocument.document.name}
dataroomId={dataroomId}
/>
) : null}
{addDataRoomOpen ? (
<AddToDataroomModal
open={addDataRoomOpen}
@@ -0,0 +1,681 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
AlertCircle,
CheckCircle2,
ChevronDown,
ChevronUp,
Download,
FileArchive,
Loader2,
Plus,
XCircle,
} from "lucide-react";
import { DownloadJob } from "@/lib/redis-download-job-store";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Progress } from "@/components/ui/progress";
export interface DownloadJobStatus {
id: string;
status: "PENDING" | "PROCESSING" | "COMPLETED" | "FAILED";
progress: number;
totalFiles: number;
processedFiles: number;
downloadUrls?: string[];
error?: string;
isReady: boolean;
dataroomName: string;
createdAt: string;
completedAt?: string;
expiresAt?: string;
}
interface DownloadProgressModalProps {
isOpen: boolean;
onClose: () => void;
jobId: string | null;
dataroomName?: string;
// For team member downloads (uses next-auth session)
teamId: string;
dataroomId: string;
}
export function DownloadProgressModal({
isOpen,
onClose,
jobId,
dataroomName,
teamId,
dataroomId,
}: DownloadProgressModalProps) {
const [status, setStatus] = useState<DownloadJobStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const [isPolling, setIsPolling] = useState(false);
const [existingDownloads, setExistingDownloads] = useState<DownloadJob[]>([]);
const [loading, setLoading] = useState(true);
const [showNewDownload, setShowNewDownload] = useState(false);
const [isStartingDownload, setIsStartingDownload] = useState(false);
const [expandedDownloadId, setExpandedDownloadId] = useState<string | null>(
null,
);
const [downloadProgress, setDownloadProgress] = useState<{
downloadId: string;
current: number;
total: number;
} | null>(null);
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
// Cleanup interval on component unmount
useEffect(() => {
return () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
}
};
}, []);
// Fetch existing downloads when modal opens
useEffect(() => {
if (!isOpen || !teamId || !dataroomId) return;
const fetchExistingDownloads = async () => {
try {
setLoading(true);
const endpoint = `/api/teams/${teamId}/datarooms/${dataroomId}/download/jobs`;
const response = await fetch(endpoint, {
method: "GET",
credentials: "include",
});
if (response.ok) {
const downloads = await response.json();
setExistingDownloads(downloads);
// If we have a current jobId, show the new download view
if (jobId) {
setShowNewDownload(true);
}
} else {
console.error("Failed to fetch existing downloads");
}
} catch (error) {
console.error("Error fetching existing downloads:", error);
} finally {
setLoading(false);
}
};
fetchExistingDownloads();
}, [isOpen, dataroomId, teamId, jobId]);
const fetchStatus = useCallback(
async (statusJobId: string) => {
if (!statusJobId || !teamId || !dataroomId) return;
try {
const url = `/api/teams/${teamId}/datarooms/${dataroomId}/download/${statusJobId}`;
const response = await fetch(url, {
credentials: "include",
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || "Failed to fetch download status");
}
const data = await response.json();
setStatus(data);
setError(null);
// Stop polling when job is completed or failed
if (data.status === "COMPLETED" || data.status === "FAILED") {
setIsPolling(false);
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
}
} catch (err) {
setError(err instanceof Error ? err.message : "An error occurred");
setIsPolling(false);
}
},
[teamId, dataroomId],
);
// Start polling when we have a jobId and showNewDownload is true
useEffect(() => {
if (isOpen && jobId && showNewDownload) {
setIsPolling(true);
setStatus(null);
setError(null);
fetchStatus(jobId);
// Start polling interval
pollIntervalRef.current = setInterval(() => fetchStatus(jobId), 2000);
}
return () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
};
}, [isOpen, jobId, showNewDownload, fetchStatus]);
const startNewDownload = async () => {
if (!teamId || !dataroomId) {
setError("Missing required parameters to start download");
return;
}
setIsStartingDownload(true);
setShowNewDownload(true);
try {
const endpoint = `/api/teams/${teamId}/datarooms/${dataroomId}/download/bulk`;
const response = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Failed to start download");
}
if (data.jobId) {
// Start polling for this job
setStatus({
id: data.jobId,
status: data.status || "PENDING",
progress: 0,
totalFiles: 0,
processedFiles: 0,
isReady: false,
dataroomName: dataroomName || "",
createdAt: new Date().toISOString(),
});
setIsPolling(true);
const statusUrl = `/api/teams/${teamId}/datarooms/${dataroomId}/download/${data.jobId}`;
pollIntervalRef.current = setInterval(async () => {
const statusResponse = await fetch(statusUrl, {
credentials: "include",
});
if (statusResponse.ok) {
const statusData = await statusResponse.json();
setStatus(statusData);
if (
statusData.status === "COMPLETED" ||
statusData.status === "FAILED"
) {
setIsPolling(false);
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
}
}
}, 2000);
}
} catch (err) {
setError(err instanceof Error ? err.message : "An error occurred");
setShowNewDownload(false);
} finally {
setIsStartingDownload(false);
}
};
const handleDownload = (url: string) => {
const link = document.createElement("a");
link.href = url;
link.rel = "noopener noreferrer";
document.body.appendChild(link);
link.click();
setTimeout(() => {
document.body.removeChild(link);
}, 100);
};
const handleDownloadAll = async (downloadId: string, urls: string[]) => {
if (downloadProgress) return;
setDownloadProgress({ downloadId, current: 0, total: urls.length });
for (let i = 0; i < urls.length; i++) {
setDownloadProgress({ downloadId, current: i + 1, total: urls.length });
handleDownload(urls[i]);
if (i < urls.length - 1) {
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
setDownloadProgress(null);
};
const handleClose = () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
setStatus(null);
setError(null);
setIsPolling(false);
setShowNewDownload(false);
setExistingDownloads([]);
setExpandedDownloadId(null);
setLoading(true);
onClose();
};
const getStatusIcon = (jobStatus?: string) => {
switch (jobStatus) {
case "PENDING":
return (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
);
case "PROCESSING":
return <FileArchive className="h-4 w-4 animate-pulse text-primary" />;
case "COMPLETED":
return <CheckCircle2 className="h-4 w-4 text-green-500" />;
case "FAILED":
return <XCircle className="h-4 w-4 text-destructive" />;
default:
return (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
);
}
};
const getStatusColor = (jobStatus: string) => {
switch (jobStatus) {
case "COMPLETED":
return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300";
case "PROCESSING":
return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300";
case "PENDING":
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300";
case "FAILED":
return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300";
default:
return "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300";
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
};
const formatExpirationTime = (expiresAt?: string) => {
if (!expiresAt) return null;
const expires = new Date(expiresAt);
const now = new Date();
const diffMs = expires.getTime() - now.getTime();
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffHours / 24);
if (diffDays > 0) {
return `${diffDays} day${diffDays > 1 ? "s" : ""}`;
} else if (diffHours > 0) {
return `${diffHours} hour${diffHours > 1 ? "s" : ""}`;
}
return "less than an hour";
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
Download {dataroomName || status?.dataroomName || "Dataroom"}
</DialogTitle>
<DialogDescription>
{showNewDownload
? status?.status === "COMPLETED"
? "Your files are ready to download."
: "Please wait while we prepare your files..."
: "View previous downloads or start a new one."}
</DialogDescription>
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : showNewDownload ? (
// Show download progress
<div className="flex flex-col items-center space-y-4 py-6">
{/* Status Icon */}
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted">
{status?.status === "COMPLETED" ? (
<CheckCircle2 className="h-8 w-8 text-green-500" />
) : status?.status === "FAILED" ? (
<XCircle className="h-8 w-8 text-destructive" />
) : (
<FileArchive className="h-8 w-8 animate-pulse text-primary" />
)}
</div>
{/* Status Message */}
<p
className={cn(
"text-center text-sm",
status?.status === "FAILED"
? "text-destructive"
: "text-muted-foreground",
)}
>
{!status
? "Starting download..."
: status.status === "PENDING"
? "Preparing your download..."
: status.status === "PROCESSING"
? `Processing ${status.processedFiles} of ${status.totalFiles} files...`
: status.status === "COMPLETED"
? status.downloadUrls && status.downloadUrls.length > 1
? `Your download is ready! ${status.downloadUrls.length} ZIP files have been created.`
: "Your download is ready!"
: status.error || "Download failed. Please try again."}
</p>
{/* Progress Bar */}
{(status?.status === "PROCESSING" ||
status?.status === "PENDING") && (
<div className="w-full space-y-2">
<Progress
value={status?.progress || 0}
text={`${status?.progress || 0}%`}
className="h-4"
/>
<p className="text-center text-xs text-muted-foreground">
{status?.totalFiles
? `${status.processedFiles || 0} / ${status.totalFiles} files`
: "Calculating..."}
</p>
</div>
)}
{/* Download Links */}
{status?.status === "COMPLETED" && status.downloadUrls && (
<div className="w-full space-y-3">
{status.downloadUrls.length === 1 ? (
<Button
className="w-full"
onClick={() => handleDownload(status.downloadUrls![0])}
>
<Download className="mr-2 h-4 w-4" />
Download ZIP
</Button>
) : (
<>
<Button
className="w-full"
disabled={!!downloadProgress}
onClick={() =>
handleDownloadAll(status.id, status.downloadUrls!)
}
>
{downloadProgress?.downloadId === status.id ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Downloading {downloadProgress.current} of{" "}
{downloadProgress.total}...
</>
) : (
<>
<Download className="mr-2 h-4 w-4" />
Download All ({status.downloadUrls.length} parts)
</>
)}
</Button>
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
Or download individually:
</p>
<div className="max-h-48 space-y-1 overflow-y-auto">
{status.downloadUrls.map((url, index) => (
<Button
key={index}
variant="outline"
size="sm"
className="w-full justify-start"
onClick={() => handleDownload(url)}
>
<FileArchive className="mr-2 h-3 w-3" />
Part {index + 1} of {status.downloadUrls!.length}
</Button>
))}
</div>
</div>
</>
)}
{/* Expiration Notice */}
{status.expiresAt && (
<div className="flex items-center justify-center gap-1 text-xs text-muted-foreground">
<AlertCircle className="h-3 w-3" />
<span>
Download expires in{" "}
{formatExpirationTime(status.expiresAt)}
</span>
</div>
)}
</div>
)}
{/* Error State */}
{status?.status === "FAILED" && (
<Button
variant="outline"
onClick={() => setShowNewDownload(false)}
>
Back to Downloads
</Button>
)}
{/* Back button for processing state */}
{(status?.status === "PROCESSING" ||
status?.status === "PENDING") && (
<DialogFooter className="w-full sm:justify-center">
<p className="text-xs text-muted-foreground">
You can close this dialog. We&apos;ll notify you when your
download is ready.
</p>
</DialogFooter>
)}
{/* Loading Error */}
{error && (
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
<span>{error}</span>
</div>
)}
</div>
) : (
// Show existing downloads and new download option
<div className="space-y-4 py-2">
{existingDownloads.length > 0 ? (
<div className="space-y-3">
<h4 className="text-sm font-medium">Recent Downloads</h4>
<div className="max-h-64 space-y-2 overflow-y-auto">
{existingDownloads.map((download) => (
<div key={download.id} className="space-y-2">
<div className="flex items-center justify-between rounded-md border p-3">
<div className="flex-1 space-y-1">
<div className="flex items-center gap-2">
{getStatusIcon(download.status)}
<span
className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${getStatusColor(download.status)}`}
>
{download.status}
</span>
</div>
<div className="text-xs text-muted-foreground">
{formatDate(download.createdAt)}
</div>
{download.totalFiles > 0 && (
<div className="text-xs text-muted-foreground">
{download.totalFiles} files
{download.downloadUrls &&
download.downloadUrls.length > 1 &&
` (${download.downloadUrls.length} ZIPs)`}
</div>
)}
{download.expiresAt && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<AlertCircle className="h-3 w-3" />
Expires in{" "}
{formatExpirationTime(download.expiresAt)}
</div>
)}
{download.error && (
<p className="text-xs text-destructive">
{download.error}
</p>
)}
</div>
{download.status === "COMPLETED" &&
download.downloadUrls &&
download.downloadUrls.length > 0 &&
(download.downloadUrls.length === 1 ? (
<Button
size="sm"
onClick={() =>
handleDownload(download.downloadUrls![0])
}
>
<Download className="mr-1 h-3 w-3" />
Download
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={() =>
setExpandedDownloadId(
expandedDownloadId === download.id
? null
: download.id,
)
}
>
{expandedDownloadId === download.id ? (
<>
<ChevronUp className="mr-1 h-3 w-3" />
Hide
</>
) : (
<>
<ChevronDown className="mr-1 h-3 w-3" />
Show Downloads
</>
)}
</Button>
))}
{(download.status === "PENDING" ||
download.status === "PROCESSING") && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
{download.progress}%
</div>
)}
</div>
{/* Expanded download parts */}
{expandedDownloadId === download.id &&
download.downloadUrls &&
download.downloadUrls.length > 1 && (
<div className="space-y-2 rounded-md border bg-muted/30 p-3">
<Button
size="sm"
className="w-full"
disabled={!!downloadProgress}
onClick={() =>
handleDownloadAll(
download.id,
download.downloadUrls!,
)
}
>
{downloadProgress?.downloadId === download.id ? (
<>
<Loader2 className="mr-2 h-3 w-3 animate-spin" />
Downloading {downloadProgress.current} of{" "}
{downloadProgress.total}...
</>
) : (
<>
<Download className="mr-2 h-3 w-3" />
Download All ({download.downloadUrls.length}{" "}
parts)
</>
)}
</Button>
<p className="text-xs text-muted-foreground">
Or download individually:
</p>
<div className="max-h-32 space-y-1 overflow-y-auto">
{download.downloadUrls.map((url, index) => (
<Button
key={index}
variant="outline"
size="sm"
className="w-full justify-start"
onClick={() => handleDownload(url)}
>
<FileArchive className="mr-2 h-3 w-3" />
Part {index + 1} of{" "}
{download.downloadUrls!.length}
</Button>
))}
</div>
</div>
)}
</div>
))}
</div>
</div>
) : (
<div className="py-4 text-center text-sm text-muted-foreground">
No previous downloads found
</div>
)}
<div className="border-t pt-4">
<Button
className="w-full"
onClick={startNewDownload}
disabled={isStartingDownload}
>
{isStartingDownload ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Plus className="mr-2 h-4 w-4" />
)}
Start New Download
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,138 @@
import { useRouter } from "next/router";
import { useState } from "react";
import { useTeam } from "@/context/team-context";
import { toast } from "sonner";
import { mutate } from "swr";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export function EditDataroomDocumentModal({
open,
setOpen,
documentId,
documentName,
dataroomId,
}: {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
documentId: string;
documentName: string;
dataroomId: string;
}) {
const [name, setName] = useState<string>(documentName);
const [loading, setLoading] = useState<boolean>(false);
const teamInfo = useTeam();
const router = useRouter();
const currentFolderPath = router.query.name as string[] | undefined;
const editDocumentNameSchema = z.object({
name: z
.string()
.min(1, {
message: "Please provide a document name.",
})
.max(255, {
message: "Document name is too long.",
}),
});
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
event.stopPropagation();
const validation = editDocumentNameSchema.safeParse({ name });
if (!validation.success) {
return toast.error(validation.error.errors[0].message);
}
setLoading(true);
try {
const response = await fetch(
`/api/teams/${teamInfo?.currentTeam?.id}/documents/${documentId}/update-name`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: name.trim(),
}),
},
);
if (!response.ok) {
const { error, message } = await response.json();
setLoading(false);
toast.error(error || message || "Failed to update document name");
return;
}
toast.success("Document name updated successfully!");
// Revalidate the dataroom documents cache
mutate(
`/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${dataroomId}/documents`,
);
// Revalidate folder documents if the document is in a folder
if (currentFolderPath) {
mutate(
`/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${dataroomId}/folders/documents/${currentFolderPath.join("/")}`,
);
}
// Revalidate the dataroom folders tree for sidebar
mutate(
`/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${dataroomId}/folders?tree=true`,
);
} catch (error) {
setLoading(false);
toast.error("Error updating document name. Please try again.");
return;
} finally {
setLoading(false);
setOpen(false);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader className="text-start">
<DialogTitle>Rename Document</DialogTitle>
<DialogDescription>Enter a new document name.</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<Label htmlFor="document-name-update" className="opacity-80">
Document Name
</Label>
<Input
id="document-name-update"
value={name}
placeholder="document-name"
className="mb-4 mt-1 w-full"
onChange={(e) => setName(e.target.value)}
/>
<DialogFooter>
<Button type="submit" className="h-9 w-full" loading={loading}>
Update name
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
+37 -5
View File
@@ -1,6 +1,7 @@
import { useRouter } from "next/router";
import { memo, useMemo } from "react";
import type { CSSProperties } from "react";
import { DataroomFolder } from "@prisma/client";
import { HomeIcon } from "lucide-react";
@@ -12,6 +13,7 @@ import {
} from "@/lib/utils/hierarchical-display";
import { FileTree } from "@/components/ui/nextra-filetree";
import { useViewerSurfaceTheme } from "@/components/view/viewer/viewer-surface-theme";
import { buildNestedFolderStructureWithDocs } from "./utils";
@@ -182,15 +184,31 @@ const HomeLink = memo(
folderId: string | null;
setFolderId: React.Dispatch<React.SetStateAction<string | null>>;
}) => {
const { usesLightText, palette } = useViewerSurfaceTheme();
return (
<li
className={cn(
"flex list-none",
"rounded-md text-foreground transition-all duration-200 ease-in-out",
"hover:bg-gray-100 hover:shadow-sm hover:dark:bg-muted",
"rounded-md transition-all duration-200 ease-in-out",
usesLightText
? "text-[var(--viewer-text)] hover:bg-[var(--viewer-control-bg)]"
: "text-foreground hover:bg-gray-100 hover:shadow-sm hover:dark:bg-muted",
"px-3 py-1.5 leading-6",
folderId === null && "bg-gray-100 font-semibold dark:bg-muted",
folderId === null &&
(usesLightText
? "bg-[var(--viewer-panel-active)] font-semibold"
: "bg-gray-100 font-semibold dark:bg-muted"),
)}
style={
usesLightText
? ({
"--viewer-text": palette.textColor,
"--viewer-control-bg": palette.controlBgColor,
"--viewer-panel-active": palette.panelActiveBgColor,
} as CSSProperties)
: undefined
}
>
<span
className="inline-flex w-full cursor-pointer items-center"
@@ -223,6 +241,8 @@ const SidebarFolders = ({
setFolderId: React.Dispatch<React.SetStateAction<string | null>>;
dataroomIndexEnabled?: boolean;
}) => {
const { usesLightText, palette } = useViewerSurfaceTheme();
const nestedFolders = useMemo(() => {
if (folders) {
return buildNestedFolderStructureWithDocs(folders, documents);
@@ -243,10 +263,22 @@ const SidebarFolders = ({
}
return null;
}, [folders, documents, folderId]);
}, [nestedFolders, folderId]);
return (
<FileTree>
<FileTree
prefersLightText={usesLightText}
style={
usesLightText
? ({
"--viewer-text": palette.textColor,
"--viewer-muted-text": palette.mutedTextColor,
"--viewer-control-bg": palette.controlBgColor,
"--viewer-panel-active": palette.panelActiveBgColor,
} as CSSProperties)
: undefined
}
>
<HomeLink folderId={folderId} setFolderId={setFolderId} />
{nestedFolders.map((folder) => (
<FolderComponent
@@ -0,0 +1,766 @@
"use client";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useTeam } from "@/context/team-context";
import { BookOpenIcon, EyeIcon } from "lucide-react";
import { toast } from "sonner";
import { mutate } from "swr";
import { usePlan } from "@/lib/swr/use-billing";
import { uploadImage } from "@/lib/utils";
import PlanBadge from "@/components/billing/plan-badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import LoadingSpinner from "@/components/ui/loading-spinner";
import { RichTextEditor } from "@/components/ui/rich-text-editor";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface IntroductionSettingsProps {
dataroomId: string;
}
interface FolderItem {
id: string;
name: string;
documents?: { document: { name: string } }[];
}
interface DocumentItem {
document: { name: string };
}
// Generate TipTap JSON content for introduction based on dataroom structure
function generateIntroductionContent(
dataroomName: string,
folders: FolderItem[],
rootDocuments: DocumentItem[],
): any {
const content: any[] = [];
// Overview paragraph (no "Welcome to" repetition)
content.push({
type: "paragraph",
content: [
{
type: "text",
text: `This data room contains confidential documents and materials prepared for your review. Please take a moment to familiarize yourself with the structure below.`,
},
],
});
// What's Inside section
content.push({
type: "heading",
attrs: { level: 2 },
content: [{ type: "text", text: "What's Inside" }],
});
// If there are folders, list them
if (folders.length > 0) {
const folderList = {
type: "bulletList",
content: folders.slice(0, 8).map((folder) => ({
type: "listItem",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: folder.name,
marks: [{ type: "bold" }],
},
...(folder.documents && folder.documents.length > 0
? [
{
type: "text",
text: `${folder.documents.length} document${folder.documents.length > 1 ? "s" : ""}`,
},
]
: []),
],
},
],
})),
};
content.push(folderList);
if (folders.length > 8) {
content.push({
type: "paragraph",
content: [
{
type: "text",
text: `...and ${folders.length - 8} more sections.`,
},
],
});
}
} else {
// Show placeholder sections if dataroom is empty
const placeholderList = {
type: "bulletList",
content: [
{
type: "listItem",
content: [
{
type: "paragraph",
content: [
{ type: "text", text: "Section 1", marks: [{ type: "bold" }] },
{ type: "text", text: " — Company Overview & Key Documents" },
],
},
],
},
{
type: "listItem",
content: [
{
type: "paragraph",
content: [
{ type: "text", text: "Section 2", marks: [{ type: "bold" }] },
{ type: "text", text: " — Financial Information" },
],
},
],
},
{
type: "listItem",
content: [
{
type: "paragraph",
content: [
{ type: "text", text: "Section 3", marks: [{ type: "bold" }] },
{ type: "text", text: " — Legal & Compliance" },
],
},
],
},
{
type: "listItem",
content: [
{
type: "paragraph",
content: [
{ type: "text", text: "Section 4", marks: [{ type: "bold" }] },
{ type: "text", text: " — Additional Materials" },
],
},
],
},
],
};
content.push(placeholderList);
}
// If there are root documents, mention them
if (rootDocuments.length > 0) {
content.push({
type: "paragraph",
content: [
{
type: "text",
text: `There ${rootDocuments.length === 1 ? "is" : "are"} also ${rootDocuments.length} document${rootDocuments.length > 1 ? "s" : ""} available at the root level for quick access.`,
},
],
});
}
// How to Navigate section
content.push({
type: "heading",
attrs: { level: 2 },
content: [{ type: "text", text: "How to Navigate" }],
});
content.push({
type: "bulletList",
content: [
{
type: "listItem",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: "Use the sidebar on the left to browse sections and folders",
},
],
},
],
},
{
type: "listItem",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: "Click on any document to open and view it",
},
],
},
],
},
{
type: "listItem",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: "Use the search function to find specific documents quickly",
},
],
},
],
},
],
});
// Placeholder for navigation screenshot
// Q&A and Conversations section
content.push({
type: "heading",
attrs: { level: 2 },
content: [{ type: "text", text: "Q&A and Conversations" }],
});
content.push({
type: "paragraph",
content: [
{
type: "text",
text: "Have questions about specific documents? You can start a conversation directly within the data room. Use the chat feature to ask questions and get answers from our team.",
},
],
});
content.push({
type: "bulletList",
content: [
{
type: "listItem",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: "Click the chat icon to start a new conversation",
},
],
},
],
},
{
type: "listItem",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: "Ask questions about any document or section",
},
],
},
],
},
{
type: "listItem",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: "Receive timely responses from our team",
},
],
},
],
},
],
});
// Placeholder for conversations screenshot
// Need Help section
content.push({
type: "heading",
attrs: { level: 2 },
content: [{ type: "text", text: "Need Help?" }],
});
content.push({
type: "paragraph",
content: [
{
type: "text",
text: "If you have any questions or need assistance, please reach out to your designated contact. We're here to help you navigate this data room effectively.",
},
],
});
return {
type: "doc",
content,
};
}
// Helper to render inline text nodes with marks (bold, italic, etc.)
function renderInlineContent(nodes: any[] | undefined): React.ReactNode {
if (!nodes) return null;
return nodes.map((textNode: any, textIndex: number) => {
if (textNode.type === "text") {
let text: React.ReactNode = textNode.text;
if (textNode.marks) {
textNode.marks.forEach((mark: any) => {
if (mark.type === "bold") {
text = (
<strong key={`bold-${textIndex}`} className="font-semibold">
{text}
</strong>
);
} else if (mark.type === "italic") {
text = (
<em key={`italic-${textIndex}`} className="italic">
{text}
</em>
);
}
});
}
return <React.Fragment key={textIndex}>{text}</React.Fragment>;
} else if (textNode.type === "image") {
return (
<img
key={textIndex}
src={textNode.attrs?.src}
alt={textNode.attrs?.alt || ""}
className="my-2 h-auto max-w-full rounded-md"
/>
);
}
return null;
});
}
// Render TipTap JSON content for preview
function renderContent(content: any): React.ReactNode {
if (!content || !content.content) return null;
return content.content.map((node: any, index: number) => {
if (node.type === "heading") {
const level = node.attrs?.level || 1;
const text = node.content?.[0]?.text || "";
if (level === 1) {
return (
<h1
key={index}
className="mb-3 mt-4 text-xl font-bold text-gray-900 first:mt-0"
>
{text}
</h1>
);
}
return (
<h2
key={index}
className="mb-2 mt-4 text-base font-semibold text-gray-800 first:mt-0"
>
{text}
</h2>
);
} else if (node.type === "paragraph") {
return (
<p key={index} className="mb-3 text-sm leading-relaxed text-gray-700">
{renderInlineContent(node.content)}
</p>
);
} else if (node.type === "bulletList") {
return (
<ul key={index} className="mb-3 list-disc pl-5 text-sm text-gray-700">
{node.content?.map((item: any, itemIndex: number) => (
<li key={itemIndex} className="mb-1">
{renderInlineContent(item.content?.[0]?.content)}
</li>
))}
</ul>
);
} else if (node.type === "orderedList") {
return (
<ol
key={index}
className="mb-3 list-decimal pl-5 text-sm text-gray-700"
>
{node.content?.map((item: any, itemIndex: number) => (
<li key={itemIndex} className="mb-1">
{renderInlineContent(item.content?.[0]?.content)}
</li>
))}
</ol>
);
} else if (node.type === "blockquote") {
return (
<blockquote
key={index}
className="mb-3 border-l-4 border-gray-300 pl-4 italic text-gray-600"
>
{node.content?.map((p: any) =>
p.content?.map((textNode: any) =>
textNode.type === "text" ? textNode.text : null,
),
)}
</blockquote>
);
} else if (node.type === "image") {
return (
<img
key={index}
src={node.attrs?.src}
alt={node.attrs?.alt || ""}
className="my-3 h-auto max-w-full rounded-md"
/>
);
} else if (node.type === "youtube") {
// Extract video ID from the src URL
const src = node.attrs?.src || "";
let videoId = "";
// Handle different YouTube URL formats
const youtubeMatch = src.match(
/(?:youtube(?:-nocookie)?\.com\/(?:embed\/|watch\?v=)|youtu\.be\/)([^?&]+)/,
);
if (youtubeMatch) {
videoId = youtubeMatch[1];
}
if (!videoId) return null;
return (
<div key={index} className="my-4 aspect-video w-full">
<iframe
src={`https://www.youtube-nocookie.com/embed/${videoId}`}
title="YouTube video"
className="h-full w-full rounded-lg"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
);
}
return null;
});
}
export default function IntroductionSettings({
dataroomId,
}: IntroductionSettingsProps) {
const teamInfo = useTeam();
const teamId = teamInfo?.currentTeam?.id;
const { isDataroomsPlus, isTrial } = usePlan();
const [isFetching, setIsFetching] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [introductionEnabled, setIntroductionEnabled] = useState(false);
const [introductionContent, setIntroductionContent] = useState<any>({
type: "doc",
content: [],
});
const [showPreview, setShowPreview] = useState(false);
const [dataroomName, setDataroomName] = useState<string>("Data Room");
const isFeatureAvailable = isDataroomsPlus || isTrial;
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const hasInitialLoadRef = useRef(false);
const skipNextAutosaveRef = useRef(false);
// Fetch current introduction settings from dataroom
useEffect(() => {
const fetchSettings = async () => {
if (!teamId) return;
try {
const response = await fetch(
`/api/teams/${teamId}/datarooms/${dataroomId}`,
);
if (response.ok) {
const data = await response.json();
setIntroductionEnabled(data.introductionEnabled || false);
setDataroomName(data.name || "Data Room");
const existingContent = data.introductionContent;
const hasExistingContent =
existingContent?.content && existingContent.content.length > 0;
if (hasExistingContent) {
setIntroductionContent(existingContent);
} else {
// Auto-generate introduction if empty
try {
const foldersResponse = await fetch(
`/api/teams/${teamId}/datarooms/${dataroomId}/folders?include_documents=true`,
);
let folders: FolderItem[] = [];
let rootDocuments: DocumentItem[] = [];
if (foldersResponse.ok) {
const foldersData = await foldersResponse.json();
folders = foldersData.filter(
(item: any) => item.name && !item.document,
);
rootDocuments = foldersData.filter(
(item: any) => item.document,
);
}
const generatedContent = generateIntroductionContent(
data.name || "Data Room",
folders,
rootDocuments,
);
setIntroductionContent(generatedContent);
} catch (genError) {
console.error("Failed to auto-generate introduction:", genError);
setIntroductionContent({ type: "doc", content: [] });
}
}
}
} catch (error) {
console.error("Failed to fetch introduction settings:", error);
} finally {
setIsFetching(false);
hasInitialLoadRef.current = true;
skipNextAutosaveRef.current = true;
}
};
fetchSettings();
}, [teamId, dataroomId]);
// Auto-save function
const saveSettings = useCallback(
async (enabled: boolean, content: any) => {
if (!teamId || !isFeatureAvailable) return;
setIsSaving(true);
try {
const response = await fetch(
`/api/teams/${teamId}/datarooms/${dataroomId}`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
introductionEnabled: enabled,
introductionContent: content,
}),
},
);
if (response.ok) {
await mutate(`/api/teams/${teamId}/datarooms/${dataroomId}`);
} else {
toast.error("Failed to save introduction settings");
}
} catch (error) {
console.error("Failed to save introduction settings:", error);
toast.error("Failed to save introduction settings");
} finally {
setIsSaving(false);
}
},
[teamId, dataroomId, isFeatureAvailable],
);
// Debounced auto-save on content change
useEffect(() => {
if (!hasInitialLoadRef.current) return;
// Skip the first auto-save pass after initial load
if (skipNextAutosaveRef.current) {
skipNextAutosaveRef.current = false;
return;
}
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
saveTimeoutRef.current = setTimeout(() => {
saveSettings(introductionEnabled, introductionContent);
}, 1000);
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
};
}, [introductionContent, introductionEnabled, saveSettings]);
const handleImageUpload = async (file: File): Promise<string> => {
try {
const imageUrl = await uploadImage(file, "assets");
return imageUrl;
} catch (error) {
console.error("Failed to upload image:", error);
throw new Error("Failed to upload image");
}
};
const handleToggle = (checked: boolean) => {
if (!isFeatureAvailable) {
toast.error("This feature is only available on Data Rooms Plus plan");
return;
}
setIntroductionEnabled(checked);
if (checked) {
toast.success("Introduction page enabled");
}
};
const hasContent =
introductionContent?.content && introductionContent.content.length > 0;
if (isFetching) {
return (
<Card className="bg-transparent">
<CardContent className="flex items-center justify-center py-10">
<LoadingSpinner className="h-6 w-6" />
</CardContent>
</Card>
);
}
return (
<Card className="bg-transparent">
<CardHeader>
<CardTitle className="flex items-center gap-2">
Introduction Page{" "}
{!isFeatureAvailable && <PlanBadge plan="data rooms plus" />}
{isSaving && (
<span className="text-xs font-normal text-muted-foreground">
Saving...
</span>
)}
</CardTitle>
<CardDescription>
Create an introduction page that will be shown to viewers when they
first access your data room. Write your message based on the premade
template below. You can edit, add and remove sections as you see fit.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Toggle and Preview */}
<div className="flex items-center justify-between">
<Label
htmlFor="introduction-toggle"
className="flex items-center gap-2"
>
<BookOpenIcon className="h-4 w-4" />
Show introduction on first visit
</Label>
<div className="flex items-center gap-2">
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => setShowPreview(true)}
disabled={!hasContent}
className="h-8 w-8"
>
<EyeIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Preview introduction</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Switch
id="introduction-toggle"
checked={introductionEnabled}
onCheckedChange={handleToggle}
disabled={!isFeatureAvailable}
/>
</div>
</div>
{/* Rich Text Editor */}
<div className="space-y-2">
<RichTextEditor
content={introductionContent}
onChange={setIntroductionContent}
placeholder="Welcome to our data room! Here you'll find..."
onImageUpload={handleImageUpload}
/>
</div>
{/* Preview Dialog */}
<Dialog open={showPreview} onOpenChange={setShowPreview}>
<DialogContent className="max-h-[85vh] max-w-2xl overflow-hidden border-0 p-0 shadow-2xl sm:max-w-xl sm:rounded-2xl md:max-w-2xl">
<DialogHeader className="border-b border-gray-100 bg-gray-50 px-4 py-6 dark:border-gray-800 dark:bg-gray-900 sm:px-6">
<p className="mb-1 text-xs text-muted-foreground">
This is a preview
</p>
<DialogTitle className="text-xl font-semibold">
Welcome to {dataroomName}
</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[55vh] px-4 py-5 sm:px-6">
<div className="prose prose-sm max-w-none dark:prose-invert">
{renderContent(introductionContent)}
</div>
</ScrollArea>
<div className="flex justify-end border-t border-gray-100 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-900 sm:px-6 sm:py-4">
<Button onClick={() => setShowPreview(false)}>
Continue to Data Room
</Button>
</div>
</DialogContent>
</Dialog>
</CardContent>
<CardFooter className="flex items-center rounded-b-lg border-t bg-muted px-6 py-4">
<p className="text-sm text-muted-foreground">
This page will appear as a welcome popup when visitors first open the
data room. Changes are saved automatically.
</p>
</CardFooter>
</Card>
);
}
@@ -1,7 +1,13 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { BellIcon, CogIcon, DownloadIcon, ShieldIcon } from "lucide-react";
import {
BellIcon,
BookOpenIcon,
CogIcon,
DownloadIcon,
ShieldIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
@@ -27,6 +33,18 @@ export default function SettingsTabs({ dataroomId }: SettingsTabsProps) {
<CogIcon className="h-4 w-4" />
General
</Link>
<Link
href={`/datarooms/${dataroomId}/settings/introduction`}
className={cn(
"flex items-center gap-x-2 rounded-md p-2 text-primary hover:bg-muted",
{
"bg-muted font-medium": router.pathname.includes("introduction"),
},
)}
>
<BookOpenIcon className="h-4 w-4" />
Introduction
</Link>
<Link
href={`/datarooms/${dataroomId}/settings/notifications`}
className={cn(
+37 -4
View File
@@ -85,7 +85,7 @@ export function AddDocumentModal({
}[]
>([]);
const teamInfo = useTeam();
const { canAddDocuments } = useLimits();
const { canAddDocuments, limits } = useLimits();
const { plan, isFree, isTrial, isPaused } = usePlan();
const { dataroom } = useDataroom();
const teamId = teamInfo?.currentTeam?.id as string;
@@ -229,7 +229,18 @@ export function AddDocumentModal({
}
if (!canAddDocuments) {
toast.error("You have reached the maximum number of documents.");
toast.error(
limits?.documents
? `You've reached your plan's document limit (${limits.usage?.documents}/${limits.documents} documents). Upgrade your plan to upload more.`
: "You have reached the maximum number of documents.",
{
action: {
label: "Upgrade",
onClick: () => router.push("/settings/billing"),
},
duration: 8000,
},
);
return;
}
@@ -415,7 +426,18 @@ export function AddDocumentModal({
}
if (!canAddDocuments) {
toast.error("You have reached the maximum number of documents.");
toast.error(
limits?.documents
? `You've reached your plan's document limit (${limits.usage?.documents}/${limits.documents} documents). Upgrade your plan to upload more.`
: "You have reached the maximum number of documents.",
{
action: {
label: "Upgrade",
onClick: () => router.push("/settings/billing"),
},
duration: 8000,
},
);
return;
}
@@ -566,7 +588,18 @@ export function AddDocumentModal({
}
if (!canAddDocuments) {
toast.error("You have reached the maximum number of documents.");
toast.error(
limits?.documents
? `You've reached your plan's document limit (${limits.usage?.documents}/${limits.documents} documents). Upgrade your plan to upload more.`
: "You have reached the maximum number of documents.",
{
action: {
label: "Upgrade",
onClick: () => router.push("/settings/billing"),
},
duration: 8000,
},
);
return;
}
+15 -4
View File
@@ -8,7 +8,6 @@ import {
ClipboardCopyIcon,
CopyIcon,
EyeOffIcon,
FolderIcon,
FolderInputIcon,
FolderPenIcon,
MoreVertical,
@@ -18,6 +17,7 @@ import {
import { toast } from "sonner";
import { mutate } from "swr";
import { getFolderColorClasses, getFolderIcon } from "@/lib/constants/folder-constants";
import { DataroomFolderWithCount } from "@/lib/swr/use-dataroom";
import { FolderWithCount } from "@/lib/swr/use-documents";
import { timeAgo } from "@/lib/utils";
@@ -147,7 +147,7 @@ export default function FolderCard({
};
const handleCardClick = (e: React.MouseEvent) => {
if (isDragging) {
if (isDragging || menuOpen) {
e.preventDefault();
e.stopPropagation();
return;
@@ -200,7 +200,16 @@ export default function FolderCard({
<div className="flex min-w-0 shrink items-center space-x-2 sm:space-x-4">
{!isSelected && !isHovered ? (
<div className="mx-0.5 flex w-8 items-center justify-center text-center sm:mx-1">
<FolderIcon className="h-8 w-8" strokeWidth={1} />
{(() => {
const FolderIconComponent = getFolderIcon(folder.icon);
const colorClasses = getFolderColorClasses(folder.color);
return (
<FolderIconComponent
className={`h-8 w-8 ${colorClasses.iconClass}`}
strokeWidth={1}
/>
);
})()}
</div>
) : (
<div className="mx-0.5 w-8 sm:mx-1"></div>
@@ -249,7 +258,7 @@ export default function FolderCard({
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger asChild>
<Button
// size="icon"
onClick={(e) => e.stopPropagation()}
variant="outline"
className="z-10 h-8 w-8 border-gray-200 bg-transparent p-0 hover:bg-gray-200 dark:border-gray-700 hover:dark:bg-gray-700 lg:h-9 lg:w-9"
>
@@ -355,6 +364,8 @@ export default function FolderCard({
setOpen={setOpenFolder}
folderId={folder.id}
name={folder.name}
icon={folder.icon}
color={folder.color}
isDataroom={isDataroom}
dataroomId={dataroomId}
/>
+261 -55
View File
@@ -1,14 +1,17 @@
import { useState } from "react";
import { type ElementType, useEffect, useRef, useState } from "react";
import { useTeam } from "@/context/team-context";
import { PlanEnum } from "@/ee/stripe/constants";
import { LinkType } from "@prisma/client";
import { AlertTriangleIcon, CircleCheckIcon, InfoIcon } from "lucide-react";
import { toast } from "sonner";
import { z } from "zod";
import { useDebounce } from "use-debounce";
import { useAnalytics } from "@/lib/analytics";
import { validDomainRegex } from "@/lib/domains";
import { usePlan } from "@/lib/swr/use-billing";
import useLimits from "@/lib/swr/use-limits";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
@@ -22,10 +25,71 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import LoadingSpinner from "@/components/ui/loading-spinner";
import { UpgradePlanModal } from "../billing/upgrade-plan-modal";
import { UpgradeButton } from "../ui/upgrade-button";
const sanitizeDomain = (value: string) =>
value
.trim()
.toLowerCase()
.replace(/^(?:https?:\/\/)?(?:www\.)?/i, "")
.split("/")[0];
type DomainStatus =
| "checking"
| "has site"
| "available"
| "idle"
| "invalid"
| "error";
const STATUS_CONFIG: Record<
DomainStatus,
{
prefix?: string;
useStrong?: boolean;
suffix?: string;
icon?: ElementType;
className?: string;
message?: string;
}
> = {
checking: {
prefix: "Checking availability for",
useStrong: true,
suffix: "...",
icon: LoadingSpinner,
className: "bg-neutral-100 text-neutral-500",
},
"has site": {
suffix:
"is currently pointing to an existing website. Only proceed if you're sure you want to use this domain for Papermark links.",
icon: InfoIcon,
className: "bg-blue-100 text-blue-800",
},
available: {
suffix: "is ready to connect.",
icon: CircleCheckIcon,
className: "bg-emerald-100 text-emerald-600",
},
invalid: {
message: "Enter a valid domain to check availability.",
icon: AlertTriangleIcon,
className: "bg-rose-100 text-rose-600",
},
idle: {
message: "Enter a valid domain to check availability.",
className: "bg-neutral-100 text-neutral-500",
},
error: {
message: "We couldn't check this domain right now. Try again.",
icon: AlertTriangleIcon,
className: "bg-rose-100 text-rose-600",
},
};
export function AddDomainModal({
open,
setOpen,
@@ -39,69 +103,150 @@ export function AddDomainModal({
linkType?: Omit<LinkType, "WORKFLOW_LINK">;
children?: React.ReactNode;
}) {
const [domain, setDomain] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
const [domainInput, setDomainInput] = useState<string>("");
const [submitting, setSubmitting] = useState<boolean>(false);
const [domainStatus, setDomainStatus] = useState<DomainStatus>("idle");
const [statusMessageOverride, setStatusMessageOverride] = useState<
string | null
>(null);
const abortRef = useRef<AbortController | null>(null);
const teamInfo = useTeam();
const teamId = teamInfo?.currentTeam?.id;
const { isFree, isPro, isBusiness } = usePlan();
const { limits } = useLimits();
const analytics = useAnalytics();
const addDomainSchema = z.object({
name: z
.string()
.min(3, {
message: "Please provide a domain name with at least 3 characters.",
useEffect(() => {
if (!open) {
setDomainInput("");
setSubmitting(false);
setDomainStatus("idle");
setStatusMessageOverride(null);
}
}, [open]);
const sanitizedDomain = sanitizeDomain(domainInput);
const [debouncedDomain] = useDebounce(sanitizedDomain, 500);
useEffect(() => {
if (!open) return;
if (!teamId) return;
if (!debouncedDomain) {
setDomainStatus("idle");
setStatusMessageOverride(null);
return;
}
if (debouncedDomain.includes("papermark")) {
setDomainStatus("invalid");
setStatusMessageOverride("Domain cannot contain 'papermark'.");
return;
}
if (!validDomainRegex.test(debouncedDomain)) {
setDomainStatus("idle");
setStatusMessageOverride(null);
return;
}
// Abort any in-flight validation request before starting a new one
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setDomainStatus("checking");
setStatusMessageOverride(null);
fetch(
`/api/teams/${teamId}/domains/${encodeURIComponent(
debouncedDomain,
)}/validate`,
{ signal: controller.signal },
)
.then(async (res) => res.json())
.then((data) => {
const nextStatus = data?.status as DomainStatus | undefined;
if (
nextStatus &&
["invalid", "has site", "available"].includes(nextStatus)
) {
setDomainStatus(nextStatus);
} else {
setDomainStatus("error");
}
})
// Add validation for papermark
.refine((name) => !name.toLowerCase().includes("papermark"), {
message: "Domain cannot contain 'papermark'",
}),
});
.catch((err) => {
// Ignore aborted requests they are expected when the user types again
if ((err as DOMException).name === "AbortError") return;
setDomainStatus("error");
});
return () => {
controller.abort();
abortRef.current = null;
};
}, [debouncedDomain, open, teamId]);
const saveDisabled =
!["available", "has site"].includes(domainStatus) || submitting;
const handleSubmit = async (event: any) => {
event.preventDefault();
event.stopPropagation();
const validation = addDomainSchema.safeParse({ name: domain });
if (!validation.success) {
return toast.error(validation.error.errors[0].message);
const normalizedDomain = sanitizeDomain(domainInput);
if (!normalizedDomain || !validDomainRegex.test(normalizedDomain)) {
return toast.error("Please enter a valid domain (e.g., example.com).");
}
setLoading(true);
const response = await fetch(
`/api/teams/${teamInfo?.currentTeam?.id}/domains`,
{
if (normalizedDomain.includes("papermark")) {
return toast.error("Domain cannot contain 'papermark'.");
}
if (saveDisabled) {
return toast.error(
statusMessageOverride ?? "Please enter a valid domain before adding.",
);
}
setSubmitting(true);
try {
const response = await fetch(`/api/teams/${teamId}/domains`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
domain: domain,
domain: normalizedDomain,
}),
},
);
});
if (!response.ok) {
const { message } = await response.json();
toast.error(message);
return;
}
const newDomain = await response.json();
analytics.capture("Domain Added", { slug: normalizedDomain });
toast.success("Domain added successfully! 🎉");
// Update local data with the new link
onAddition && onAddition(newDomain);
if (!response.ok) {
const { message } = await response.json();
setLoading(false);
setOpen(false);
toast.error(message);
return;
!onAddition && window.open("/settings/domains", "_blank");
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : "An unknown error occurred";
toast.error(`Failed to add domain: ${message}`);
} finally {
setSubmitting(false);
}
const newDomain = await response.json();
analytics.capture("Domain Added", { slug: domain });
toast.success("Domain added successfully! 🎉");
// Update local data with the new link
onAddition && onAddition(newDomain);
setOpen(false);
setLoading(false);
!onAddition && window.open("/settings/domains", "_blank");
};
// If the team is
@@ -149,26 +294,87 @@ export function AddDomainModal({
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogContent className="sm:max-w-[520px]">
<DialogHeader className="text-start">
<DialogTitle>Add Domain</DialogTitle>
<DialogDescription>
You can easily add a custom domain.
Add a custom domain and verify it with DNS.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<Label htmlFor="domain" className="opacity-80">
Domain
Your domain
</Label>
<Input
id="domain"
placeholder="docs.yourdomain.com"
className="mb-8 mt-1 w-full"
onChange={(e) => setDomain(e.target.value)}
/>
<DialogFooter>
<Button type="submit" className="h-9 w-full">
Add domain
{(() => {
const currentStatus = STATUS_CONFIG[domainStatus];
const StatusIcon = currentStatus.icon;
return (
<div
className={cn(
"-m-1 mt-2 rounded-[0.625rem] p-1",
currentStatus.className || "bg-neutral-100 text-neutral-500",
)}
>
<div className="flex rounded-md border border-neutral-300 bg-white">
<Input
id="domain"
placeholder="docs.yourdomain.com"
className="border-0 bg-transparent shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
value={domainInput}
onBlur={() => {
const normalized = sanitizeDomain(domainInput);
if (normalized && normalized !== domainInput) {
setDomainInput(normalized);
}
}}
onChange={(e) => {
// Cancel any in-flight validation so stale results
// don't overwrite the reset status below
abortRef.current?.abort();
abortRef.current = null;
setDomainStatus("idle");
setStatusMessageOverride(null);
setDomainInput(e.target.value);
}}
/>
</div>
<div className="flex items-center justify-between gap-4 p-2 text-sm">
<p>
{["checking", "has site", "available"].includes(
domainStatus,
) ? (
<>
{currentStatus.prefix || "The domain"}{" "}
{currentStatus.useStrong ? (
<strong className="font-semibold underline underline-offset-2">
{sanitizedDomain || "this domain"}
</strong>
) : (
<span className="font-semibold underline underline-offset-2">
{sanitizedDomain || "this domain"}
</span>
)}{" "}
{currentStatus.suffix}
</>
) : (
statusMessageOverride ||
currentStatus.message ||
"Enter a valid domain to check availability."
)}
</p>
{StatusIcon && <StatusIcon className="h-5 w-5 shrink-0" />}
</div>
</div>
);
})()}
<DialogFooter className="mt-6">
<Button
type="submit"
className="h-9 w-full"
disabled={saveDisabled}
>
{submitting ? "Adding domain..." : "Add domain"}
</Button>
</DialogFooter>
</form>
+76
View File
@@ -4,6 +4,7 @@ import { useTeam } from "@/context/team-context";
import {
ChevronDownIcon,
CircleCheckIcon,
ExternalLinkIcon,
FlagIcon,
GlobeIcon,
MoreVertical,
@@ -12,6 +13,7 @@ import {
TrashIcon,
} from "lucide-react";
import { motion } from "motion/react";
import { toast } from "sonner";
import { mutate } from "swr";
import { cn } from "@/lib/utils";
@@ -24,6 +26,7 @@ import {
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { StatusBadge } from "@/components/ui/status-badge";
import { useDeleteDomainModal } from "./delete-domain-modal";
@@ -33,15 +36,21 @@ import { useDomainStatus } from "./use-domain-status";
export default function DomainCard({
domain,
isDefault,
redirectUrl: initialRedirectUrl,
redirectsAllowed,
onDelete,
}: {
domain: string;
isDefault: boolean;
redirectUrl?: string | null;
redirectsAllowed: boolean;
onDelete: (deletedDomain: string) => void;
}) {
const [showDetails, setShowDetails] = useState(false);
const [groupHover, setGroupHover] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [redirectUrl, setRedirectUrl] = useState(initialRedirectUrl || "");
const [savingRedirect, setSavingRedirect] = useState(false);
const domainRef = useRef<HTMLDivElement>(null);
@@ -65,6 +74,34 @@ export default function DomainCard({
onDelete,
});
const handleSaveRedirectUrl = async () => {
setSavingRedirect(true);
try {
const response = await fetch(`/api/teams/${teamId}/domains/${domain}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ redirectUrl: redirectUrl || null }),
});
if (!response.ok) {
const data = await response.json();
toast.error(data?.message || "Failed to save redirect URL");
return;
}
mutate(`/api/teams/${teamId}/domains`);
toast.success(
redirectUrl
? "Root redirect URL saved"
: "Root redirect URL removed",
);
} catch {
toast.error("Failed to save redirect URL");
} finally {
setSavingRedirect(false);
}
};
const handleMakeDefault = async () => {
const response = await fetch(`/api/teams/${teamId}/domains/${domain}`, {
method: "PATCH",
@@ -232,6 +269,45 @@ export default function DomainCard({
) : (
<div className="mt-6 h-6 w-32 animate-pulse rounded-md bg-gray-200 dark:bg-gray-400" />
)}
{/* Root domain redirect */}
<div className="mt-4 rounded-lg border border-gray-200 p-4 dark:border-gray-400">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<ExternalLinkIcon className="h-4 w-4" />
Root Domain Redirect
</div>
{redirectsAllowed ? (
<>
<p className="mt-1 text-xs text-muted-foreground">
Redirect visitors who land on{" "}
<span className="font-medium">{domain}</span> to a specific
URL. Leave empty to redirect to papermark.com.
</p>
<div className="mt-3 flex items-center gap-2">
<Input
type="url"
placeholder="https://example.com"
value={redirectUrl}
onChange={(e) => setRedirectUrl(e.target.value)}
className="h-9 flex-1"
/>
<Button
size="sm"
onClick={handleSaveRedirectUrl}
disabled={savingRedirect}
className="h-9 shrink-0"
>
{savingRedirect ? "Saving..." : "Save"}
</Button>
</div>
</>
) : (
<p className="mt-1 text-xs text-muted-foreground">
Root domain redirects require a{" "}
<span className="font-semibold">Business</span> plan or higher.
</p>
)}
</div>
</motion.div>
</div>
<DeleteDomainModal />
+13 -5
View File
@@ -9,19 +9,27 @@ import {
} from "@/lib/types";
import { fetcher } from "@/lib/utils";
export function useDomainStatus({ domain }: { domain: string }) {
export function useDomainStatus({
domain,
enabled = true,
}: {
domain: string;
enabled?: boolean;
}) {
const teamInfo = useTeam();
const key =
enabled && domain
? `/api/teams/${teamInfo?.currentTeam?.id}/domains/${domain}/verify`
: null;
const { data, isValidating, mutate } = useSWR<{
status: DomainVerificationStatusProps;
response: {
domainJson: DomainResponse & { error: { code: string; message: string } };
configJson: DomainConfigResponse;
};
}>(
`/api/teams/${teamInfo?.currentTeam?.id}/domains/${domain}/verify`,
fetcher,
);
}>(key, fetcher);
return {
status: data?.status,
@@ -0,0 +1,114 @@
import React from "react";
import {
Body,
Button,
Container,
Head,
Hr,
Html,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
type DocumentChange = {
documentName: string;
};
export default function DataroomDigestNotification({
dataroomName = "Example Data Room",
documents = [
{ documentName: "Document A" },
{ documentName: "Document B" },
{ documentName: "Document C" },
],
senderEmail = "example@example.com",
url = "https://app.papermark.com/datarooms/123",
preferencesUrl = "https://app.papermark.com/notification-preferences?token=abc",
frequency = "daily",
}: {
dataroomName: string;
documents: DocumentChange[];
senderEmail: string;
url: string;
preferencesUrl: string;
frequency: "daily" | "weekly";
}) {
const count = documents.length;
const periodLabel = frequency === "daily" ? "today" : "this week";
return (
<Html>
<Head />
<Preview>
{`${count} new document${count !== 1 ? "s" : ""} in ${dataroomName} ${periodLabel}`}
</Preview>
<Tailwind>
<Body className="mx-auto my-auto bg-white font-sans">
<Container className="mx-auto my-10 w-[465px] p-5">
<Text className="mb-8 mt-4 text-center text-2xl font-normal">
<span className="font-bold tracking-tighter">Papermark</span>
</Text>
<Text className="font-semibold mb-8 mt-4 text-center text-xl">
{`${count} new document${count !== 1 ? "s" : ""} in ${dataroomName}`}
</Text>
<Text className="text-sm leading-6 text-black">
The following document{count !== 1 ? "s have" : " has"} been added
to <span className="font-semibold">{dataroomName}</span>{" "}
{periodLabel}:
</Text>
<Section className="my-4">
{documents.map((doc, i) => (
<Text
key={i}
className="my-1 text-sm leading-6 text-black"
>
<span className="font-semibold">{doc.documentName}</span>
</Text>
))}
</Section>
<Section className="my-8 text-center">
<Button
className="rounded bg-black text-center text-xs font-semibold text-white no-underline"
href={url}
style={{ padding: "12px 20px" }}
>
View the dataroom
</Button>
</Section>
<Text className="text-sm text-black">
or copy and paste this URL into your browser: <br />
{url}
</Text>
<Text className="text-sm text-gray-400">Papermark</Text>
<Hr />
<Section className="text-gray-400">
<Text className="text-xs">
© {new Date().getFullYear()} Papermark, Inc. All rights
reserved.
</Text>
<Text className="text-xs">
You received this {frequency} digest from{" "}
<span className="font-semibold">{senderEmail}</span> because you
viewed the dataroom{" "}
<span className="font-semibold">{dataroomName}</span> on
Papermark. If you have any feedback or questions about this
email, simply reply to it.{" "}
<a
href={preferencesUrl}
className="text-gray-400 underline underline-offset-2"
>
Manage your notification preferences
</a>
.
</Text>
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
}
+2 -3
View File
@@ -72,13 +72,12 @@ export default function DataroomNotification({
viewed the dataroom{" "}
<span className="font-semibold">{dataroomName}</span> on
Papermark. If you have any feedback or questions about this
email, simply reply to it. To unsubscribe from updates about
this dataroom,{" "}
email, simply reply to it.{" "}
<a
href={unsubscribeUrl}
className="text-gray-400 underline underline-offset-2"
>
click here
Manage your notification preferences
</a>
.
</Text>
@@ -0,0 +1,103 @@
import React from "react";
import {
Body,
Button,
Container,
Head,
Html,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
import { Footer } from "./shared/footer";
export default function DataroomUploadNotification({
dataroomId = "123",
dataroomName = "Example Dataroom",
uploaderEmail = "visitor@example.com",
documentNames = ["Document 1.pdf", "Document 2.pdf"],
linkName = "Link #abc12",
}: {
dataroomId: string;
dataroomName: string;
uploaderEmail: string | null;
documentNames: string[];
linkName: string;
}) {
const documentCount = documentNames.length;
const documentLabel = documentCount === 1 ? "document" : "documents";
return (
<Html>
<Head />
<Preview>
{`${documentCount} new ${documentLabel} uploaded to ${dataroomName}`}
</Preview>
<Tailwind>
<Body className="mx-auto my-auto bg-white font-sans">
<Container className="mx-auto my-10 w-[465px] p-5">
<Text className="mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal">
<span className="font-bold tracking-tighter">Papermark</span>
</Text>
<Text className="mx-0 my-7 p-0 text-center text-xl font-semibold text-black">
New File Upload
</Text>
<Text className="text-sm leading-6 text-black">
{uploaderEmail ? (
<>
<span className="font-semibold">{uploaderEmail}</span> has
uploaded{" "}
</>
) : (
<>A visitor has uploaded </>
)}
<span className="font-semibold">
{documentCount} {documentLabel}
</span>{" "}
to your dataroom{" "}
<span className="font-semibold">{dataroomName}</span> via the link{" "}
<span className="font-semibold">{linkName}</span>.
</Text>
{documentNames.length <= 10 && (
<Section className="my-4">
{documentNames.map((name, index) => (
<Text
key={index}
className="my-1 text-sm leading-6 text-black"
>
{"\u2022"} {name}
</Text>
))}
</Section>
)}
<Section className="my-8 text-center">
<Button
className="rounded bg-black text-center text-xs font-semibold text-white no-underline"
href={`https://app.papermark.com/datarooms/${dataroomId}`}
style={{ padding: "12px 20px" }}
>
View the dataroom
</Button>
</Section>
<Footer
footerText={
<>
If you have any feedback or questions about this email, simply
reply to it. I&apos;d love to hear from you!
<br />
<br />
To stop email notifications for this link, edit the link and
uncheck &quot;Receive email notification&quot;.
</>
}
/>
</Container>
</Body>
</Tailwind>
</Html>
);
}
+108
View File
@@ -0,0 +1,108 @@
import {
Body,
Button,
Container,
Head,
Html,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
import { Footer } from "./shared/footer";
function formatExpirationTime(expiresAt?: string): string {
if (!expiresAt) return "3 days";
const expires = new Date(expiresAt);
const now = new Date();
const diffMs = expires.getTime() - now.getTime();
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffHours / 24);
if (diffDays > 0) {
return `${diffDays} day${diffDays > 1 ? "s" : ""}`;
} else if (diffHours > 0) {
return `${diffHours} hour${diffHours > 1 ? "s" : ""}`;
}
return "less than an hour";
}
export default function DownloadReady({
dataroomName = "Dataroom",
downloadUrl = "https://app.papermark.com",
email = "email@example.com",
expiresAt,
isViewer = false,
}: {
dataroomName?: string;
downloadUrl?: string;
email: string;
expiresAt?: string;
isViewer?: boolean;
}) {
const expirationTime = formatExpirationTime(expiresAt);
return (
<Html>
<Head />
<Preview>Your {dataroomName} download is ready</Preview>
<Tailwind>
<Body className="mx-auto my-auto bg-white font-sans">
<Container className="mx-auto my-10 w-[465px] p-5">
<Text className="mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal">
<span className="font-bold tracking-tighter">Papermark</span>
</Text>
<Text className="text-sm leading-6 text-black">
Your download of <strong>{dataroomName}</strong> is ready!
</Text>
<Text className="text-sm leading-6 text-black">
{isViewer
? "Click the button below to open your downloads page and get your files."
: "Click the button below to download your files. You'll need to be logged in to your Papermark account to access the download."}
</Text>
<Section className="my-8 text-center">
<Button
className="rounded bg-black text-center text-xs font-semibold text-white no-underline"
href={downloadUrl}
style={{ padding: "12px 20px" }}
>
Download Files
</Button>
</Section>
<Text className="text-sm leading-6 text-black">
Download details:
</Text>
<ul className="break-all text-sm leading-6 text-black">
<li className="text-sm leading-6 text-black">
Dataroom: {dataroomName}
</li>
<li className="text-sm leading-6 text-black">
Expires: in {expirationTime}
</li>
</ul>
<Text className="text-sm leading-6 text-black">
Best,
<br />
The Papermark Team
</Text>
<Footer
footerText={
<>
This email was intended for{" "}
<span className="text-black">{email}</span>. If you were not
expecting this email, you can ignore this email. If you have
any feedback or questions about this email, simply reply to
it.
</>
}
/>
</Container>
</Body>
</Tailwind>
</Html>
);
}
+48 -17
View File
@@ -4,11 +4,20 @@ import { useState } from "react";
import { useTeam } from "@/context/team-context";
import { PlanEnum } from "@/ee/stripe/constants";
import slugify from "@sindresorhus/slugify";
import { toast } from "sonner";
import { mutate } from "swr";
import { z } from "zod";
import {
DEFAULT_FOLDER_COLOR,
DEFAULT_FOLDER_ICON,
FolderColorId,
FolderIconId,
} from "@/lib/constants/folder-constants";
import { safeSlugify } from "@/lib/utils";
import { useAnalytics } from "@/lib/analytics";
import { usePlan } from "@/lib/swr/use-billing";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -22,10 +31,8 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useAnalytics } from "@/lib/analytics";
import { usePlan } from "@/lib/swr/use-billing";
import { UpgradePlanModal } from "../billing/upgrade-plan-modal";
import { FolderIconColorPicker } from "./folder-icon-picker";
export function AddFolderModal({
// open,
@@ -44,6 +51,8 @@ export function AddFolderModal({
}) {
const router = useRouter();
const [folderName, setFolderName] = useState<string>("");
const [folderIcon, setFolderIcon] = useState<FolderIconId>(DEFAULT_FOLDER_ICON);
const [folderColor, setFolderColor] = useState<FolderColorId>(DEFAULT_FOLDER_COLOR);
const [loading, setLoading] = useState<boolean>(false);
const [open, setOpen] = useState<boolean>(false);
@@ -56,13 +65,18 @@ export function AddFolderModal({
const folderPath =
isDataroom && dataroomId
? `/datarooms/${dataroomId}/documents/${currentFolderPath ? currentFolderPath?.join("/") : ""}/${"/" + slugify(folderName.trim())}`
: `/documents/tree/${currentFolderPath ? currentFolderPath?.join("/") : ""}${"/" + slugify(folderName.trim())}`;
? `/datarooms/${dataroomId}/documents/${currentFolderPath ? currentFolderPath?.join("/") : ""}/${"/" + safeSlugify(folderName.trim())}`
: `/documents/tree/${currentFolderPath ? currentFolderPath?.join("/") : ""}${"/" + safeSlugify(folderName.trim())}`;
const addFolderSchema = z.object({
name: z.string().min(3, {
message: "Please provide a folder name with at least 3 characters.",
}),
name: z
.string()
.min(3, {
message: "Please provide a folder name with at least 3 characters.",
})
.max(256, {
message: "Folder name must be 256 characters or less.",
}),
});
const validation = addFolderSchema.safeParse({ name: folderName });
@@ -91,6 +105,8 @@ export function AddFolderModal({
body: JSON.stringify({
name: folderName.trim(),
path: currentFolderPath?.join("/"),
icon: folderIcon,
color: folderColor,
}),
},
);
@@ -106,6 +122,8 @@ export function AddFolderModal({
analytics.capture("Folder Added", {
folderName: folderName.trim(),
icon: folderIcon,
color: folderColor,
dataroomId,
});
toast.success(`Folder added successfully!`, {
@@ -130,6 +148,8 @@ export function AddFolderModal({
return;
} finally {
setFolderName("");
setFolderIcon(DEFAULT_FOLDER_ICON);
setFolderColor(DEFAULT_FOLDER_COLOR);
setLoading(false);
setOpen(false);
}
@@ -156,18 +176,29 @@ export function AddFolderModal({
<DialogContent className="sm:max-w-[425px]">
<DialogHeader className="text-start">
<DialogTitle>Add Folder</DialogTitle>
<DialogDescription>You can easily add a folder.</DialogDescription>
<DialogDescription>
Create a new folder with a custom icon and color.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<Label htmlFor="folder-name" className="opacity-80">
Folder Name
</Label>
<Input
id="folder-name"
placeholder="folder-123"
className="mb-4 mt-1 w-full"
onChange={(e) => setFolderName(e.target.value)}
/>
<div className="mb-4 mt-1 flex items-center gap-2">
<FolderIconColorPicker
iconValue={folderIcon}
colorValue={folderColor}
onIconChange={setFolderIcon}
onColorChange={setFolderColor}
/>
<Input
id="folder-name"
placeholder="Choose a helpful name"
className="flex-1"
value={folderName}
onChange={(e) => setFolderName(e.target.value)}
/>
</div>
<DialogFooter>
<Button
type="submit"
@@ -175,7 +206,7 @@ export function AddFolderModal({
disabled={!validation.success}
loading={loading}
>
Add new folder
Create
</Button>
</DialogFooter>
</form>
+82 -12
View File
@@ -5,6 +5,14 @@ import { toast } from "sonner";
import { mutate } from "swr";
import { z } from "zod";
import {
DEFAULT_FOLDER_COLOR,
DEFAULT_FOLDER_ICON,
FolderColorId,
FolderIconId,
} from "@/lib/constants/folder-constants";
import { useAnalytics } from "@/lib/analytics";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -18,11 +26,15 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { FolderIconColorPicker } from "./folder-icon-picker";
export function EditFolderModal({
open,
setOpen,
name,
folderId,
icon,
color,
onAddition,
isDataroom,
dataroomId,
@@ -32,19 +44,43 @@ export function EditFolderModal({
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
name: string;
folderId: string;
icon?: string | null;
color?: string | null;
onAddition?: (folderName: string) => void;
isDataroom?: boolean;
dataroomId?: string;
children?: React.ReactNode;
}) {
const [folderName, setFolderName] = useState<string>(name);
const [folderIcon, setFolderIcon] = useState<FolderIconId>(
(icon as FolderIconId) || DEFAULT_FOLDER_ICON,
);
const [folderColor, setFolderColor] = useState<FolderColorId>(
(color as FolderColorId) || DEFAULT_FOLDER_COLOR,
);
const [loading, setLoading] = useState<boolean>(false);
const teamInfo = useTeam();
const analytics = useAnalytics();
// Reset state when modal opens with new folder data
useEffect(() => {
if (open) {
setFolderName(name);
setFolderIcon((icon as FolderIconId) || DEFAULT_FOLDER_ICON);
setFolderColor((color as FolderColorId) || DEFAULT_FOLDER_COLOR);
}
}, [open, name, icon, color]);
const editFolderSchema = z.object({
name: z.string().min(3, {
message: "Please provide a folder name with at least 3 characters.",
}),
name: z
.string()
.min(3, {
message: "Please provide a folder name with at least 3 characters.",
})
.max(256, {
message: "Folder name must be 256 characters or less.",
}),
});
const handleSubmit = async (event: any) => {
@@ -60,6 +96,12 @@ export function EditFolderModal({
const endpointTargetType =
isDataroom && dataroomId ? `datarooms/${dataroomId}/folders` : "folders";
// Determine what fields have changed for analytics
const changedFields: string[] = [];
if (folderName.trim() !== name) changedFields.push("name");
if (folderIcon !== (icon || DEFAULT_FOLDER_ICON)) changedFields.push("icon");
if (folderColor !== (color || DEFAULT_FOLDER_COLOR)) changedFields.push("color");
try {
const response = await fetch(
`/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}/manage`,
@@ -71,6 +113,8 @@ export function EditFolderModal({
body: JSON.stringify({
folderId: folderId,
name: folderName.trim(),
icon: folderIcon,
color: folderColor,
}),
},
);
@@ -84,7 +128,17 @@ export function EditFolderModal({
const { parentFolderPath } = await response.json();
toast.success("Folder updated successfully! 🎉");
// Track analytics
if (changedFields.length > 0) {
analytics.capture("folder_updated", {
changed: changedFields,
icon: folderIcon,
color: folderColor,
isDataroom,
});
}
toast.success("Folder updated successfully!");
mutate(`/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}`);
mutate(
@@ -109,19 +163,35 @@ export function EditFolderModal({
<DialogContent className="sm:max-w-[425px]">
<DialogHeader className="text-start">
<DialogTitle>Edit Folder</DialogTitle>
<DialogDescription>Enter a new folder name.</DialogDescription>
<DialogDescription>
Update your folder name, icon, and color.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<Label htmlFor="folder-name-update" className="opacity-80">
Folder Name
</Label>
<Input
id="folder-name-update"
value={folderName}
placeholder="folder-123"
className="mb-4 mt-1 w-full"
onChange={(e) => setFolderName(e.target.value)}
/>
<div className="mb-1 mt-1 flex items-center gap-2">
<FolderIconColorPicker
iconValue={folderIcon}
colorValue={folderColor}
onIconChange={setFolderIcon}
onColorChange={setFolderColor}
/>
<Input
id="folder-name-update"
value={folderName}
placeholder="Choose a helpful name"
className="flex-1"
maxLength={256}
onChange={(e) => setFolderName(e.target.value)}
/>
</div>
<p className="mb-4 text-xs text-muted-foreground">
{folderName.length}/256 characters
</p>
<DialogFooter>
<Button type="submit" className="h-9 w-full" loading={loading}>
Update folder
+122
View File
@@ -0,0 +1,122 @@
import { useMemo } from "react";
import {
FOLDER_COLORS,
FolderColorId,
getFolderColorClasses,
} from "@/lib/constants/folder-constants";
import { cn } from "@/lib/utils";
interface FolderColorPickerProps {
value: FolderColorId | null | undefined;
onChange: (colorId: FolderColorId) => void;
}
export function FolderColorPicker({ value, onChange }: FolderColorPickerProps) {
const selectedColor = useMemo(() => {
return value || "gray";
}, [value]);
return (
<div className="flex flex-wrap gap-2">
{FOLDER_COLORS.map((colorOption) => {
const isSelected = selectedColor === colorOption.id;
return (
<button
key={colorOption.id}
type="button"
onClick={() => onChange(colorOption.id)}
className={cn(
"flex h-8 items-center gap-2 rounded-full border px-3 text-sm transition-all",
colorOption.bg,
colorOption.border,
colorOption.text,
isSelected ? "ring-2 ring-offset-1" : "hover:scale-105",
)}
style={
isSelected
? ({
"--tw-ring-color":
colorOption.id === "gray"
? "#6b7280"
: colorOption.id === "red"
? "#ef4444"
: colorOption.id === "orange"
? "#f97316"
: colorOption.id === "yellow"
? "#eab308"
: colorOption.id === "green"
? "#10b981"
: colorOption.id === "blue"
? "#3b82f6"
: colorOption.id === "black"
? "#000000"
: "#6b7280",
} as React.CSSProperties)
: undefined
}
title={colorOption.label}
>
<span
className="h-3 w-3 rounded-full"
style={{
backgroundColor:
colorOption.id === "gray"
? "#6b7280"
: colorOption.id === "red"
? "#ef4444"
: colorOption.id === "orange"
? "#f97316"
: colorOption.id === "yellow"
? "#eab308"
: colorOption.id === "green"
? "#10b981"
: colorOption.id === "blue"
? "#3b82f6"
: colorOption.id === "black"
? "#000000"
: "#6b7280",
}}
/>
{colorOption.label}
</button>
);
})}
</div>
);
}
// Helper component to get color preview dot
interface FolderColorDotProps {
colorId: FolderColorId | null | undefined;
className?: string;
}
export function FolderColorDot({ colorId, className }: FolderColorDotProps) {
const colorClasses = getFolderColorClasses(colorId);
const colorHex =
colorId === "gray"
? "#6b7280"
: colorId === "red"
? "#ef4444"
: colorId === "orange"
? "#f97316"
: colorId === "yellow"
? "#eab308"
: colorId === "green"
? "#10b981"
: colorId === "blue"
? "#3b82f6"
: colorId === "black"
? "#000000"
: "#6b7280";
return (
<span
className={cn("h-3 w-3 rounded-full", className)}
style={{ backgroundColor: colorHex }}
/>
);
}
+218
View File
@@ -0,0 +1,218 @@
import { useMemo, useState } from "react";
import {
FOLDER_COLORS,
FOLDER_ICONS,
FolderColorId,
FolderIconId,
getFolderColorClasses,
getFolderIcon,
} from "@/lib/constants/folder-constants";
import { cn } from "@/lib/utils";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
interface FolderIconPickerProps {
value: FolderIconId | null | undefined;
onChange: (iconId: FolderIconId) => void;
colorClass?: string;
}
export function FolderIconPicker({
value,
onChange,
colorClass = "text-gray-600",
}: FolderIconPickerProps) {
const selectedIcon = useMemo(() => {
return value || "folder";
}, [value]);
return (
<ScrollArea className="h-[200px] rounded-md border p-2">
<div className="grid grid-cols-6 gap-2">
{FOLDER_ICONS.map((iconOption) => {
const IconComponent = iconOption.icon;
const isSelected = selectedIcon === iconOption.id;
return (
<button
key={iconOption.id}
type="button"
onClick={() => onChange(iconOption.id)}
className={cn(
"flex h-10 w-10 items-center justify-center rounded-md border transition-all hover:bg-muted",
isSelected
? "border-primary bg-primary/10 ring-2 ring-primary"
: "border-transparent",
)}
title={iconOption.label}
>
<IconComponent
className={cn(
"h-5 w-5",
isSelected ? colorClass : "text-muted-foreground",
)}
strokeWidth={1.5}
/>
</button>
);
})}
</div>
</ScrollArea>
);
}
// Preview component for displaying selected icon with color
interface FolderIconPreviewProps {
iconId: FolderIconId | null | undefined;
colorClass?: string;
className?: string;
size?: "sm" | "md" | "lg";
}
export function FolderIconPreview({
iconId,
colorClass = "text-gray-600",
className,
size = "md",
}: FolderIconPreviewProps) {
const IconComponent = getFolderIcon(iconId);
const sizeClasses = {
sm: "h-4 w-4",
md: "h-6 w-6",
lg: "h-8 w-8",
};
return (
<IconComponent
className={cn(sizeClasses[size], colorClass, className)}
strokeWidth={1.5}
/>
);
}
// Color hex values for color picker dots
const COLOR_HEX_VALUES: Record<FolderColorId, string> = {
gray: "#6b7280",
red: "#ef4444",
orange: "#f97316",
yellow: "#eab308",
green: "#10b981",
blue: "#3b82f6",
black: "#000000",
};
// Combined Icon and Color Picker Popover
interface FolderIconColorPickerProps {
iconValue: FolderIconId;
colorValue: FolderColorId;
onIconChange: (iconId: FolderIconId) => void;
onColorChange: (colorId: FolderColorId) => void;
}
export function FolderIconColorPicker({
iconValue,
colorValue,
onIconChange,
onColorChange,
}: FolderIconColorPickerProps) {
const [open, setOpen] = useState(false);
const colorClasses = getFolderColorClasses(colorValue);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md border bg-muted/50 transition-colors hover:bg-muted"
aria-label="Choose folder icon and color"
>
<FolderIconPreview
iconId={iconValue}
colorClass={colorClasses.iconClass}
size="md"
/>
</button>
</PopoverTrigger>
<PopoverContent className="w-[280px] p-3" align="start">
{/* Color Picker Row */}
<div className="mb-3">
<p className="mb-2 text-xs font-medium text-muted-foreground">
Colors
</p>
<div className="flex flex-wrap gap-2">
{FOLDER_COLORS.map((colorOption) => {
const isSelected = colorValue === colorOption.id;
const hex = COLOR_HEX_VALUES[colorOption.id as FolderColorId];
return (
<button
key={colorOption.id}
type="button"
onClick={() => onColorChange(colorOption.id as FolderColorId)}
className={cn(
"flex h-7 w-7 items-center justify-center rounded-full transition-all",
isSelected
? "ring-2 ring-offset-2 ring-offset-background"
: "hover:scale-110",
)}
style={{
backgroundColor: hex,
...(isSelected && { '--tw-ring-color': hex }),
} as React.CSSProperties}
title={colorOption.label}
aria-label={`Select ${colorOption.label} color`}
/>
);
})}
</div>
</div>
{/* Icon Picker Grid */}
<div>
<p className="mb-2 text-xs font-medium text-muted-foreground">
Icons
</p>
<ScrollArea className="h-[200px]">
<div className="m-1 grid grid-cols-7 gap-1.5">
{FOLDER_ICONS.map((iconOption) => {
const IconComponent = iconOption.icon;
const isSelected = iconValue === iconOption.id;
return (
<button
key={iconOption.id}
type="button"
onClick={() => onIconChange(iconOption.id)}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-md transition-all hover:bg-muted",
isSelected && "bg-muted ring-1 ring-primary",
)}
title={iconOption.label}
aria-label={`Select ${iconOption.label} icon`}
>
<IconComponent
className={cn(
"h-5 w-5",
isSelected
? colorClasses.iconClass
: "text-muted-foreground",
)}
strokeWidth={1.5}
/>
</button>
);
})}
</div>
</ScrollArea>
</div>
</PopoverContent>
</Popover>
);
}
+74 -12
View File
@@ -1,6 +1,8 @@
import Cookies from "js-cookie";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import Cookies from "js-cookie";
import { AppBreadcrumb } from "@/components/layouts/breadcrumb";
import TrialBanner from "@/components/layouts/trial-banner";
@@ -18,27 +20,87 @@ import {
import { BlockingModal } from "./blocking-modal";
const DATAROOM_SIDEBAR_COOKIE_NAME = "sidebar:dataroom-state";
// Helper to get initial sidebar state synchronously (avoids flash)
function getInitialSidebarState(isDataroom: boolean): boolean {
if (typeof window === "undefined") return false; // SSR: default closed to avoid flash
// For dataroom pages, check dataroom-specific cookie first
if (isDataroom) {
const dataroomCookie = Cookies.get(DATAROOM_SIDEBAR_COOKIE_NAME);
if (dataroomCookie !== undefined) {
return dataroomCookie === "true";
}
// No dataroom preference set yet - default to closed for datarooms
return false;
}
// For non-dataroom pages, use main cookie
const mainCookie = Cookies.get(SIDEBAR_COOKIE_NAME);
if (mainCookie !== undefined) {
return mainCookie === "true";
}
return true; // Default open for non-dataroom pages
}
export default function AppLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const isDataroom = router.pathname.startsWith("/datarooms/[id]");
const [sidebarOpen, setSidebarOpen] = useState(true);
// Use lazy initializer to compute initial state synchronously (avoids flash)
const [sidebarOpen, setSidebarOpen] = useState(() =>
getInitialSidebarState(isDataroom),
);
// Sync with cookie on mount (client-side only)
// Track previous dataroom state for transitions
const prevIsDataroomRef = useRef<boolean>(isDataroom);
const isFirstRenderRef = useRef(true);
// Handle initial mount and transitions between dataroom/non-dataroom
useEffect(() => {
const cookieValue = Cookies.get(SIDEBAR_COOKIE_NAME);
if (cookieValue !== undefined) {
setSidebarOpen(cookieValue === "true");
if (isFirstRenderRef.current) {
isFirstRenderRef.current = false;
// Set cookie on initial mount if in dataroom and no preference exists
if (
isDataroom &&
Cookies.get(DATAROOM_SIDEBAR_COOKIE_NAME) === undefined
) {
Cookies.set(DATAROOM_SIDEBAR_COOKIE_NAME, "false", { expires: 7 });
}
return;
}
}, []);
// Close sidebar when entering a dataroom
useEffect(() => {
if (isDataroom) {
// Transitioning from non-dataroom to dataroom
if (!prevIsDataroomRef.current && isDataroom) {
setSidebarOpen(false);
Cookies.set(DATAROOM_SIDEBAR_COOKIE_NAME, "false", { expires: 7 });
}
// Transitioning from dataroom to non-dataroom
if (prevIsDataroomRef.current && !isDataroom) {
Cookies.remove(DATAROOM_SIDEBAR_COOKIE_NAME);
// Restore main sidebar state
const mainCookie = Cookies.get(SIDEBAR_COOKIE_NAME);
// setSidebarOpen(mainCookie === "true");
setSidebarOpen(mainCookie !== undefined ? mainCookie === "true" : true);
}
prevIsDataroomRef.current = isDataroom;
}, [isDataroom]);
// Handle sidebar state changes - save to appropriate cookie
const handleSidebarOpenChange = useCallback(
(open: boolean) => {
setSidebarOpen(open);
if (isDataroom) {
Cookies.set(DATAROOM_SIDEBAR_COOKIE_NAME, String(open), { expires: 7 });
}
},
[isDataroom],
);
// const { isAnnualPlan, isFree } = usePlan();
// const [showYearlyBanner, setShowYearlyBanner] = useState<boolean | null>(null);
@@ -59,7 +121,7 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
// }, [isFree, isAnnualPlan]);
return (
<SidebarProvider open={sidebarOpen} onOpenChange={setSidebarOpen}>
<SidebarProvider open={sidebarOpen} onOpenChange={handleSidebarOpenChange}>
<div className="flex flex-1 flex-col gap-x-1 bg-gray-50 dark:bg-black md:flex-row">
<AppSidebar />
<SidebarInset className="ring-1 ring-gray-200 dark:ring-gray-800">
@@ -1,11 +1,20 @@
import { useEffect, useState } from "react";
import { LinkPreset } from "@prisma/client";
import { CheckIcon, UsersIcon, XIcon } from "lucide-react";
import { motion } from "motion/react";
import { FADE_IN_ANIMATION_SETTINGS } from "@/lib/constants";
import useVisitorGroups from "@/lib/swr/use-visitor-groups";
import { sanitizeList } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Textarea } from "@/components/ui/textarea";
import { DEFAULT_LINK_TYPE } from ".";
@@ -30,25 +39,29 @@ export default function AllowListSection({
}: LinkUpgradeOptions) => void;
presets: LinkPreset | null;
}) {
const { emailProtected, allowList } = data;
const { emailProtected, allowList, visitorGroupIds } = data;
const { visitorGroups } = useVisitorGroups();
// Initialize enabled state based on whether allowList is not null and not empty
// or if visitor groups are selected
const [enabled, setEnabled] = useState<boolean>(
!!allowList && allowList.length > 0,
(!!allowList && allowList.length > 0) ||
(!!visitorGroupIds && visitorGroupIds.length > 0),
);
const [allowListInput, setAllowListInput] = useState<string>(
allowList?.join("\n") || "",
);
useEffect(() => {
// Update the allowList in the data state when their inputs change
const newAllowList = sanitizeList(allowListInput);
setEnabled((prevEnabled) => prevEnabled && emailProtected);
setData((prevData) => ({
...prevData,
allowList: emailProtected && enabled ? newAllowList : [],
}));
}, [allowListInput, emailProtected, enabled, setData]);
if (!emailProtected && enabled) {
setEnabled(false);
setData((prevData) => ({
...prevData,
allowList: [],
visitorGroupIds: [],
}));
}
}, [emailProtected, enabled, setData]);
useEffect(() => {
if (isAllowed && presets?.allowList && presets.allowList.length > 0) {
@@ -72,6 +85,7 @@ export default function AllowListSection({
setData((prevData) => ({
...prevData,
allowList: [],
visitorGroupIds: [],
}));
}
};
@@ -79,16 +93,46 @@ export default function AllowListSection({
const handleAllowListChange = (
event: React.ChangeEvent<HTMLTextAreaElement>,
) => {
setAllowListInput(event.target.value);
const updatedAllowListInput = event.target.value;
setAllowListInput(updatedAllowListInput);
if (emailProtected && enabled) {
setData((prevData) => ({
...prevData,
allowList: sanitizeList(updatedAllowListInput),
}));
}
};
const toggleVisitorGroup = (groupId: string) => {
setData((prevData) => {
const currentIds = prevData.visitorGroupIds || [];
const newIds = currentIds.includes(groupId)
? currentIds.filter((id) => id !== groupId)
: [...currentIds, groupId];
return { ...prevData, visitorGroupIds: newIds };
});
};
const removeVisitorGroup = (groupId: string) => {
setData((prevData) => ({
...prevData,
visitorGroupIds: (prevData.visitorGroupIds || []).filter(
(id) => id !== groupId,
),
}));
};
const selectedGroups =
visitorGroups?.filter((g) => visitorGroupIds?.includes(g.id)) || [];
return (
<div className="pb-5">
<div className="flex flex-col space-y-4">
<LinkItem
title="Allow specified viewers"
link="https://www.papermark.com/help/article/allow-list"
tooltipContent="Restrict access to a selected group of viewers. Enter allowed emails or domains."
tooltipContent={`Restrict access to a selected group of viewers. Enter allowed emails or domains${visitorGroups && visitorGroups.length > 0 ? ", or select visitor groups" : ""}.`}
enabled={enabled}
isAllowed={isAllowed}
action={handleEnableAllowList}
@@ -105,9 +149,108 @@ export default function AllowListSection({
{enabled && (
<motion.div
className="mt-1 block w-full"
className="mt-1 block w-full space-y-3"
{...FADE_IN_ANIMATION_SETTINGS}
>
{/* Visitor Groups Selector */}
{visitorGroups && visitorGroups.length > 0 && (
<div>
<div className="mb-1.5 flex items-center gap-1.5">
<UsersIcon className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground">
Visitor Groups
</span>
</div>
{/* Selected groups as badges */}
{selectedGroups.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{selectedGroups.map((group) => (
<Badge
key={group.id}
variant="secondary"
className="gap-1 pr-1"
>
{group.name}
<span className="text-muted-foreground">
({group.emails.length})
</span>
<button
type="button"
onClick={() => removeVisitorGroup(group.id)}
className="ml-0.5 rounded-full p-0.5 hover:bg-muted"
>
<XIcon className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className="w-full justify-start text-muted-foreground"
>
<UsersIcon className="mr-2 h-3.5 w-3.5" />
{selectedGroups.length > 0
? `${selectedGroups.length} group${selectedGroups.length > 1 ? "s" : ""} selected`
: "Select visitor groups..."}
</Button>
</PopoverTrigger>
<PopoverContent className="w-72 p-1" align="start">
<div className="max-h-60 overflow-y-auto">
{visitorGroups.map((group) => {
const isSelected = visitorGroupIds?.includes(group.id);
return (
<button
key={group.id}
type="button"
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-muted"
onClick={() => toggleVisitorGroup(group.id)}
>
<div
className={`flex h-4 w-4 items-center justify-center rounded-sm border ${
isSelected
? "border-primary bg-primary text-primary-foreground"
: "border-muted-foreground/30"
}`}
>
{isSelected && (
<CheckIcon className="h-3 w-3" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium">
{group.name}
</div>
<div className="text-xs text-muted-foreground">
{group.emails.length}{" "}
{group.emails.length === 1
? "entry"
: "entries"}
</div>
</div>
</button>
);
})}
</div>
</PopoverContent>
</Popover>
<div className="my-2 flex items-center gap-2">
<div className="h-px flex-1 bg-border" />
<span className="text-xs text-muted-foreground">
plus individual emails
</span>
<div className="h-px flex-1 bg-border" />
</div>
</div>
)}
<Textarea
className="focus:ring-inset"
rows={5}
@@ -40,14 +40,14 @@ export default function DenyListSection({
);
useEffect(() => {
// Update the denyList in the data state when their inputs change
const newDenyList = sanitizeList(denyListInput);
setEnabled((prevEnabled) => prevEnabled && emailProtected);
setData((prevData) => ({
...prevData,
denyList: emailProtected && enabled ? newDenyList : [],
}));
}, [denyListInput, enabled, emailProtected, setData]);
if (!emailProtected && enabled) {
setEnabled(false);
setData((prevData) => ({
...prevData,
denyList: [],
}));
}
}, [emailProtected, enabled, setData]);
useEffect(() => {
if (isAllowed && presets?.denyList && presets.denyList.length > 0) {
@@ -78,7 +78,15 @@ export default function DenyListSection({
const handleDenyListChange = (
event: React.ChangeEvent<HTMLTextAreaElement>,
) => {
setDenyListInput(event.target.value);
const updatedDenyListInput = event.target.value;
setDenyListInput(updatedDenyListInput);
if (emailProtected && enabled) {
setData((prevData) => ({
...prevData,
denyList: sanitizeList(updatedDenyListInput),
}));
}
};
return (
+118 -54
View File
@@ -1,10 +1,18 @@
import Link from "next/link";
import { Dispatch, SetStateAction, useEffect, useState } from "react";
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useState,
} from "react";
import { useTeam } from "@/context/team-context";
import { PlanEnum } from "@/ee/stripe/constants";
import { Domain, LinkType } from "@prisma/client";
import { ShuffleIcon } from "lucide-react";
import { customAlphabet } from "nanoid";
import { mutate } from "swr";
import { BLOCKED_PATHNAMES } from "@/lib/constants";
@@ -14,6 +22,7 @@ import { cn } from "@/lib/utils";
import { UpgradePlanModal } from "@/components/billing/upgrade-plan-modal";
import { AddDomainModal } from "@/components/domains/add-domain-modal";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
@@ -23,9 +32,16 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ButtonTooltip } from "@/components/ui/tooltip";
import { DEFAULT_LINK_TYPE } from ".";
// Unambiguous alphabet: excludes easily confused characters (0/O, 1/l/I)
const generateRandomSlug = customAlphabet(
"23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz",
10,
);
export default function DomainSection({
data,
setData,
@@ -60,6 +76,11 @@ export default function DomainSection({
const isEditingCustomDomain =
editLink && data.domain && data.domain !== "papermark.com" ? true : false;
const generateAndSetSlug = useCallback(() => {
const newSlug = generateRandomSlug();
setData((prev) => ({ ...prev, slug: newSlug }));
}, [setData]);
const handleDomainChange = (value: string) => {
const canChangeCustomDomain =
linkType === "DOCUMENT_LINK"
@@ -74,7 +95,7 @@ export default function DomainSection({
// Handle opening the add domain modal
if (value === "add_domain" || value === "add_dataroom_domain") {
setModalOpen(true);
setData({ ...data, domain: "papermark.com" });
setData((prev) => ({ ...prev, domain: "papermark.com" }));
setDisplayValue("papermark.com");
return;
}
@@ -87,14 +108,23 @@ export default function DomainSection({
(linkType === "DATAROOM_LINK" && !canUseCustomDomainForDataroom)
) {
setUpgradeModalOpen(true);
setData({ ...data, domain: "papermark.com" });
setData((prev) => ({ ...prev, domain: "papermark.com" }));
setDisplayValue("papermark.com");
return;
}
// Auto-generate a slug if there isn't one yet
setData((prev) => ({
...prev,
domain: value,
...(!prev.slug && { slug: generateRandomSlug() }),
}));
setDisplayValue(value);
return;
}
// Update domain normally if allowed
setData({ ...data, domain: value });
setData((prev) => ({ ...prev, domain: value }));
setDisplayValue(value);
};
@@ -116,10 +146,15 @@ export default function DomainSection({
? (defaultDomain?.slug ?? "papermark.com")
: "papermark.com";
setData({
...data,
// Auto-generate a slug when a custom domain is auto-selected as default
const isCustomDomain =
domainValue !== "papermark.com" && canUseCustomDomain;
setData((prev) => ({
...prev,
domain: domainValue,
});
...(isCustomDomain && !prev.slug && { slug: generateRandomSlug() }),
}));
setDisplayValue(domainValue);
}
@@ -149,6 +184,11 @@ export default function DomainSection({
const currentDomain = domains?.find((domain) => domain.slug === data.domain);
const isDomainVerified = currentDomain?.verified;
const isSlugInvalid =
!!data.slug &&
(!/^[a-zA-Z0-9-]+$/.test(data.slug) ||
BLOCKED_PATHNAMES.includes(`/${data.slug}`));
const isDisabled =
linkType === "DOCUMENT_LINK"
? isEditingCustomDomain && !canUseCustomDomainForDocument
@@ -230,57 +270,81 @@ export default function DomainSection({
</Select>
{data.domain && data.domain !== "papermark.com" ? (
<Input
type="text"
name="key"
required
value={data.slug || ""}
disabled={isDisabled}
pattern="^[a-zA-Z0-9-]+$"
onKeyDown={(e) => {
// Allow navigation keys, backspace, delete, etc.
if (e.key.length === 1 && !/^[a-zA-Z0-9-]$/.test(e.key)) {
e.preventDefault();
}
}}
onInvalid={(e) => {
const currentValue = e.currentTarget.value;
const isBlocked = BLOCKED_PATHNAMES.includes(`/${currentValue}`);
if (isBlocked) {
e.currentTarget.setCustomValidity(
"This pathname is blocked. Please choose another one.",
<>
<Input
type="text"
name="key"
required
value={data.slug || ""}
disabled={isDisabled}
pattern="^[a-zA-Z0-9-]+$"
onKeyDown={(e) => {
// Allow navigation keys, backspace, delete, etc.
if (e.key.length === 1 && !/^[a-zA-Z0-9-]$/.test(e.key)) {
e.preventDefault();
}
}}
onInvalid={(e) => {
const currentValue = e.currentTarget.value;
const isBlocked = BLOCKED_PATHNAMES.includes(
`/${currentValue}`,
);
} else {
e.currentTarget.setCustomValidity(
"Only letters, numbers, and '-' are allowed.",
);
}
}}
autoComplete="off"
className={cn(
"hidden rounded-l-none focus:ring-inset",
data.domain && data.domain !== "papermark.com" ? "flex" : "",
isDisabled ? "opacity-50" : "",
)}
placeholder="deck"
onChange={(e) => {
if (isDisabled) return;
const currentValue = e.target.value.replace(/[^a-zA-Z0-9-]/g, "");
const isBlocked = BLOCKED_PATHNAMES.includes(`/${currentValue}`);
if (isBlocked) {
e.currentTarget.setCustomValidity(
"This pathname is blocked. Please choose another one.",
);
} else {
e.currentTarget.setCustomValidity(
"Only letters, numbers, and '-' are allowed.",
);
}
}}
autoComplete="off"
className={cn(
"hidden rounded-none focus:ring-inset",
data.domain && data.domain !== "papermark.com" ? "flex" : "",
isDisabled ? "opacity-50" : "",
)}
placeholder="deck"
onChange={(e) => {
if (isDisabled) return;
if (isBlocked) {
e.currentTarget.setCustomValidity(
"This pathname is blocked. Please choose another one.",
const currentValue = e.target.value.replace(
/[^a-zA-Z0-9-]/g,
"",
);
} else {
e.currentTarget.setCustomValidity("");
}
setData({ ...data, slug: currentValue });
}}
aria-invalid="true"
/>
const isBlocked = BLOCKED_PATHNAMES.includes(
`/${currentValue}`,
);
if (isBlocked) {
e.currentTarget.setCustomValidity(
"This pathname is blocked. Please choose another one.",
);
} else {
e.currentTarget.setCustomValidity("");
}
setData((prev) => ({ ...prev, slug: currentValue }));
}}
aria-invalid={isSlugInvalid}
/>
<ButtonTooltip content="Generate random slug">
<Button
type="button"
variant="outline"
size="icon"
className="h-10 min-w-10 rounded-l-none border-l-0"
disabled={isDisabled}
onClick={(e) => {
e.preventDefault();
generateAndSetSlug();
}}
>
<ShuffleIcon className="h-4 w-4" />
</Button>
</ButtonTooltip>
</>
) : null}
</div>
+113 -3
View File
@@ -3,12 +3,11 @@ import { useRouter } from "next/router";
import { Dispatch, SetStateAction, useEffect, useRef, useState } from "react";
import { useHotkeys } from "react-hotkeys-hook";
import { useTeam } from "@/context/team-context";
import { PlanEnum } from "@/ee/stripe/constants";
import { LinkAudienceType, LinkPreset, LinkType } from "@prisma/client";
import { RefreshCwIcon } from "lucide-react";
import { useHotkeys } from "react-hotkeys-hook";
import { toast } from "sonner";
import { mutate } from "swr";
import useSWR from "swr";
@@ -66,10 +65,11 @@ export const DEFAULT_LINK_PROPS = (
allowDownload: false,
allowList: [],
denyList: [],
visitorGroupIds: [],
enableNotification: true,
enableFeedback: false,
enableScreenshotProtection: false,
enableCustomMetatag: false,
enableCustomMetatag: false,
metaTitle: null,
metaDescription: null,
metaImage: null,
@@ -110,6 +110,7 @@ export type DEFAULT_LINK_TYPE = {
allowDownload: boolean;
allowList: string[];
denyList: string[];
visitorGroupIds: string[];
enableNotification: boolean;
enableFeedback: boolean;
enableScreenshotProtection: boolean;
@@ -426,6 +427,115 @@ export default function LinkSheet({
}
}
// Track what changed for analytics
const changedFields: Record<string, { from: unknown; to: unknown }> = {};
const trackableFields: (keyof DEFAULT_LINK_TYPE)[] = [
"name",
"domain",
"slug",
"expiresAt",
"emailProtected",
"emailAuthenticated",
"allowDownload",
"allowList",
"denyList",
"enableNotification",
"enableFeedback",
"enableScreenshotProtection",
"enableCustomMetatag",
"metaTitle",
"metaDescription",
"welcomeMessage",
"enableQuestion",
"questionText",
"questionType",
"enableAgreement",
"agreementId",
"showBanner",
"enableWatermark",
"audienceType",
"groupId",
"enableConversation",
"enableAIAgents",
"enableUpload",
"isFileRequestOnly",
"uploadFolderId",
"enableIndexFile",
"permissionGroupId",
"tags",
];
for (const field of trackableFields) {
if (
JSON.stringify(currentLink[field]) !== JSON.stringify(data[field])
) {
changedFields[field] = {
from: currentLink[field],
to: data[field],
};
}
}
// Password: log set/unset/changed status only, not actual values
if (!!currentLink.password !== !!data.password) {
changedFields.password = {
from: currentLink.password ? "set" : "unset",
to: data.password ? "set" : "unset",
};
} else if (
currentLink.password &&
data.password &&
currentLink.password !== data.password
) {
changedFields.password = { from: "set", to: "changed" };
}
// Image fields: log set/unset status only, not URLs
if (currentLink.metaImage !== data.metaImage) {
changedFields.metaImage = {
from: currentLink.metaImage ? "set" : "unset",
to: data.metaImage ? "set" : "unset",
};
}
if (currentLink.metaFavicon !== data.metaFavicon) {
changedFields.metaFavicon = {
from: currentLink.metaFavicon ? "set" : "unset",
to: data.metaFavicon ? "set" : "unset",
};
}
// Watermark config: log configured/unset status
if (
JSON.stringify(currentLink.watermarkConfig) !==
JSON.stringify(data.watermarkConfig)
) {
changedFields.watermarkConfig = {
from: currentLink.watermarkConfig ? "configured" : "unset",
to: data.watermarkConfig ? "configured" : "unset",
};
}
// Custom fields: log count change
if (
JSON.stringify(currentLink.customFields) !==
JSON.stringify(data.customFields)
) {
changedFields.customFields = {
from: currentLink.customFields?.length ?? 0,
to: data.customFields?.length ?? 0,
};
}
analytics.capture("Link Updated", {
linkId: currentLink.id,
targetId,
linkType,
teamId: teamInfo?.currentTeam?.id,
customDomain: returnedLink.domainSlug ?? null,
changes: changedFields,
changedProperties: Object.keys(changedFields),
});
toast.success("Link updated successfully");
} else {
setIsOpen(false);
@@ -146,6 +146,7 @@ export const LinkOptions = ({
{data.audienceType === LinkAudienceType.GENERAL ? (
<>
<AllowListSection
key={`allow-list-${data.id ?? "new"}`}
{...{ data, setData }}
isAllowed={
isTrial ||
@@ -158,6 +159,7 @@ export const LinkOptions = ({
presets={currentPreset}
/>
<DenyListSection
key={`deny-list-${data.id ?? "new"}`}
{...{ data, setData }}
isAllowed={
isTrial ||
+464 -405
View File
@@ -1,6 +1,6 @@
import { useRouter } from "next/router";
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTeam } from "@/context/team-context";
import { InviteViewersModal } from "@/ee/features/dataroom-invitations/components/invite-viewers-modal";
@@ -10,6 +10,7 @@ import { DocumentVersion, LinkAudienceType } from "@prisma/client";
import { isWithinInterval, subMinutes } from "date-fns";
import {
BoxesIcon,
ChevronRightIcon,
ClockFadingIcon,
Code2Icon,
CopyCheckIcon,
@@ -368,6 +369,10 @@ export default function LinksTable({
allowDownload: link.allowDownload ? link.allowDownload : false,
allowList: link.allowList,
denyList: link.denyList,
visitorGroupIds:
link.visitorGroups?.map(
(vg: { visitorGroupId: string }) => vg.visitorGroupId,
) || [],
enableNotification: link.enableNotification
? link.enableNotification
: false,
@@ -765,413 +770,467 @@ export default function LinksTable({
[processedLinks],
);
// Collapsible state for "All Links" section (document pages only)
const ALL_LINKS_COLLAPSED_KEY = "papermark-all-links-collapsed";
const [isAllLinksOpen, setIsAllLinksOpen] = useState<boolean>(true);
// Load collapse state from localStorage on mount
useEffect(() => {
if (targetType !== "DOCUMENT") return;
try {
const stored = localStorage.getItem(ALL_LINKS_COLLAPSED_KEY);
if (stored !== null) {
// stored value is "true" if collapsed, so we invert for isOpen
setIsAllLinksOpen(stored !== "true");
}
} catch (e) {
// localStorage might be unavailable
console.warn("Could not read from localStorage:", e);
}
}, [targetType]);
// Handle toggle and persist to localStorage
const handleAllLinksToggle = useCallback((open: boolean) => {
setIsAllLinksOpen(open);
try {
// Store "true" when collapsed, "false" when expanded
localStorage.setItem(ALL_LINKS_COLLAPSED_KEY, String(!open));
} catch (e) {
console.warn("Could not write to localStorage:", e);
}
}, []);
const isDataroom = targetType === "DATAROOM";
const linksTableContent = (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow className="*:whitespace-nowrap *:font-medium hover:bg-transparent">
<TableHead>Name</TableHead>
<TableHead>Link</TableHead>
{hasAnyTags ? (
<TableHead className="w-[250px] 2xl:w-auto">Tags</TableHead>
) : null}
<TableHead className="w-[250px] sm:w-auto">Views</TableHead>
<TableHead>Last Viewed</TableHead>
<TableHead className="w-[80px]">Active</TableHead>
<TableHead className="text-center sm:text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{processedLinks && processedLinks.length > 0 ? (
processedLinks.map((link) => (
<Collapsible key={link.id} asChild>
<>
<TableRow
key={link.id}
className={cn(
"group/row",
popoverOpen === link.id && "bg-gray-100",
link.isArchived &&
"bg-gray-50 opacity-50 dark:bg-gray-700",
)}
>
<TableCell className="w-[200px] truncate font-medium md:w-[220px] lg:w-[250px] xl:w-[280px] 2xl:w-[350px]">
<div className="flex items-center gap-x-2">
{link.groupId ? (
<ButtonTooltip content="Group Link">
<BoxesIcon className="size-4 shrink-0" />
</ButtonTooltip>
) : null}
<ButtonTooltip
content={link.name || `Link #${link.id.slice(-5)}`}
>
<span className="max-w-[150px] truncate md:max-w-[170px] lg:max-w-[200px] xl:max-w-[230px] 2xl:max-w-[300px]">
{link.name || `Link #${link.id.slice(-5)}`}
</span>
</ButtonTooltip>
{link.isNew && !link.isUpdated && (
<Badge
variant="outline"
className="border-emerald-600/80 text-emerald-600/80"
>
New
</Badge>
)}
{link.isUpdated && (
<Badge
variant="outline"
className="border-blue-500/80 text-blue-500/80"
>
Updated
</Badge>
)}
{link.expiresAt &&
(new Date(link.expiresAt) < new Date() ? (
<TimestampTooltip
timestamp={link.expiresAt}
side="right"
rows={["local", "utc"]}
title="Expired at"
fullLabels
>
<span className="flex cursor-default items-center rounded-full bg-destructive/10 p-1 text-destructive ring-1 ring-destructive/20">
<TimerOffIcon className="h-3 w-3" />
</span>
</TimestampTooltip>
) : (
<TimestampTooltip
timestamp={link.expiresAt}
side="right"
rows={["local", "utc"]}
title="Expires at"
fullLabels
>
<span className="flex cursor-default items-center rounded-full bg-orange-100 p-1 text-orange-700 ring-1 ring-orange-200 dark:bg-orange-900/30 dark:text-orange-400 dark:ring-orange-800">
<ClockFadingIcon className="h-3 w-3" />
</span>
</TimestampTooltip>
))}
{link.domainId && isFree ? (
<span className="ml-2 rounded-full bg-destructive px-2.5 py-0.5 text-xs text-foreground ring-1 ring-destructive">
Inactive
</span>
) : null}
</div>
</TableCell>
{/* Link URL Cell */}
<TableCell className="group/link max-w-[250px] pr-1 md:max-w-[300px] lg:max-w-[350px] xl:max-w-[400px] 2xl:max-w-[450px]">
<div className="flex flex-row gap-x-1">
<LinkUrlCell
link={link}
isFree={isFree}
onCopy={handleCopyToClipboard}
isProcessing={isDocumentProcessing(primaryVersion)}
primaryVersion={primaryVersion}
mutateDocument={mutateDocument}
isPopoverOpen={popoverOpen === link.id}
/>
<div className="flex shrink-0 items-center">
<LinkActionsCell
link={link}
onCopy={handleCopyToClipboard}
onPreview={handlePreviewLink}
isProcessing={isDocumentProcessing(primaryVersion)}
/>
{isMobile ? (
<ButtonTooltip content="Edit link">
<Button
variant="link"
size="icon"
className="group h-8 w-8"
onClick={() => handleEditLink(link)}
>
<span className="sr-only">Edit link</span>
<Settings2Icon className="text-gray-400 group-hover:text-gray-500" />
</Button>
</ButtonTooltip>
) : (
<Popover
open={popoverOpen === link.id}
onOpenChange={() => {}}
>
<PopoverTrigger asChild>
<Button
variant="link"
className={cn(
"h-8 w-8 font-normal hover:no-underline focus-visible:ring-0 focus-visible:ring-offset-0",
popoverOpen === link.id
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
size="sm"
onClick={(e) => {
e.preventDefault();
handleEditLink(link);
}}
onMouseDown={(e) => e.preventDefault()}
onMouseEnter={() => {
hoverTimeout.current = setTimeout(
() => setPopoverOpen(link.id),
250,
);
}}
onMouseLeave={() => {
if (hoverTimeout.current)
clearTimeout(hoverTimeout.current);
hoverTimeout.current = setTimeout(
() => setPopoverOpen(null),
100,
);
}}
>
<Settings2Icon />
</Button>
</PopoverTrigger>
<PopoverContent
side="top"
align="start"
className="w-56 p-0"
onMouseEnter={() => {
if (hoverTimeout.current)
clearTimeout(hoverTimeout.current);
setPopoverOpen(link.id);
}}
onMouseLeave={() => {
if (hoverTimeout.current)
clearTimeout(hoverTimeout.current);
hoverTimeout.current = setTimeout(
() => setPopoverOpen(null),
100,
);
}}
>
<LinkActiveControls
link={link}
onEditClick={(e) => {
e.preventDefault();
handleEditLink(link);
}}
/>
</PopoverContent>
</Popover>
)}
{/* File permissions icon (dataroom only) */}
{isDataroom && (
<div className="flex w-8 items-center justify-center">
{link.permissionGroupId && (
<ButtonTooltip content="Limited File Access">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 cursor-default"
>
<FileSlidersIcon className="h-4 w-4 text-muted-foreground hover:text-foreground" />
</Button>
</ButtonTooltip>
)}
</div>
)}
</div>
</div>
</TableCell>
{hasAnyTags ? (
<TableCell className="w-[250px] 2xl:w-auto">
<TagColumn link={link} />
</TableCell>
) : null}
<TableCell>
<CollapsibleTrigger
disabled={isDataroom || link._count.views === 0}
>
<div className="flex items-center space-x-1 [&[data-state=open]>svg.chevron]:rotate-180">
<BarChart className="h-4 w-4 text-muted-foreground" />
<p className="whitespace-nowrap text-sm text-muted-foreground">
{nFormatter(link._count.views)}
<span className="ml-1 hidden sm:inline-block">
views
</span>
</p>
{!isDataroom && link._count.views > 0 ? (
<ChevronDown className="chevron h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
) : null}
</div>
</CollapsibleTrigger>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{link.views[0] ? (
<TimestampTooltip
timestamp={link.views[0].viewedAt}
side="right"
rows={["local", "utc", "unix"]}
>
<time
className="select-none"
dateTime={new Date(
link.views[0].viewedAt,
).toISOString()}
>
{timeAgo(link.views[0].viewedAt)}
</time>
</TimestampTooltip>
) : (
"-"
)}
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-x-1">
<Switch
className="data-[state=checked]:bg-primary/80 data-[state=checked]:hover:bg-primary data-[state=unchecked]:hover:bg-muted-foreground/80"
id={`${link.id}-active-switch`}
checked={!link.isArchived}
onCheckedChange={(checked) =>
handleArchiveLink(
link.id,
link.documentId ?? link.dataroomId ?? "",
checked,
)
}
disabled={loadingLinks.has(link.id)}
/>
<Label
className="font-normal"
htmlFor={`${link.id}-active-switch`}
>
{link.isArchived ? "No" : "Yes"}
</Label>
</div>
</TableCell>
<TableCell className="text-center sm:text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0 group-hover/row:ring-1 group-hover/row:ring-gray-200 group-hover/row:dark:ring-gray-700"
>
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleEditLink(link)}
>
<Settings2Icon className="mr-2 h-4 w-4" />
Edit Link
</DropdownMenuItem>
{/* Dataroom-only: Edit File Permissions */}
{isDataroom &&
link.audienceType !== LinkAudienceType.GROUP && (
<DropdownMenuItem
onClick={() => handleEditPermissions(link)}
disabled={isLoading}
>
<FileSlidersIcon className="mr-2 h-4 w-4" />
Edit File Permissions
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => handlePreviewLink(link)}
>
<EyeIcon className="mr-2 h-4 w-4" />
Preview Link
</DropdownMenuItem>
{/* Dataroom-only: Send Invitations */}
{isDataroom &&
isFeatureEnabled("dataroomInvitations") && (
<DropdownMenuItem
onClick={() => handleSendInvitations(link)}
>
<SendIcon className="mr-2 h-4 w-4" />
Send Invitations
</DropdownMenuItem>
)}
<DropdownMenuItem
disabled={!canAddLinks}
onClick={() => handleDuplicateLink(link)}
>
<CopyPlusIcon className="mr-2 h-4 w-4" />
Duplicate Link
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSelectedEmbedLink({
id: link.id,
name: link.name || `Link #${link.id.slice(-5)}`,
});
setEmbedModalOpen(true);
}}
>
<Code2Icon className="mr-2 h-4 w-4" />
Get Embed Code
</DropdownMenuItem>
{!isFree && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
setLinkToDelete(link);
setShowDeleteLinkModal(true);
}}
className="text-destructive focus:bg-destructive focus:text-destructive-foreground"
>
<Trash2Icon className="mr-2 h-4 w-4" />
Delete Link
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
<CollapsibleContent asChild>
<LinksVisitors
linkName={link.name || "No link name"}
linkId={link.id}
/>
</CollapsibleContent>
</>
</Collapsible>
))
) : (
<TableRow>
<TableCell colSpan={hasAnyTags ? 7 : 6}>
<div className="flex w-full flex-col items-center justify-center gap-4 rounded-xl py-4">
<div className="hidden rounded-full sm:block">
<div
className={cn(
"rounded-full border border-white bg-gradient-to-t from-gray-100 p-1 md:p-3",
)}
>
<LinkIcon className="size-6" />
</div>
</div>
<p>No links found for this {targetType.toLowerCase()}</p>
<AddLinkButton />
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
);
return (
<>
<div className="w-full">
<div className={cn(targetType === "DATAROOM" && "hidden")}>
<h2 className="mb-2 md:mb-4">All links</h2>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow className="*:whitespace-nowrap *:font-medium hover:bg-transparent">
<TableHead>Name</TableHead>
<TableHead>Link</TableHead>
{/* <TableHead className="w-fit !pl-1"></TableHead> */}
{hasAnyTags ? (
<TableHead className="w-[250px] 2xl:w-auto">Tags</TableHead>
) : null}
<TableHead className="w-[250px] sm:w-auto">Views</TableHead>
<TableHead>Last Viewed</TableHead>
<TableHead className="w-[80px]">Active</TableHead>
<TableHead className="text-center sm:text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{processedLinks && processedLinks.length > 0 ? (
processedLinks.map((link) => (
<Collapsible key={link.id} asChild>
<>
<TableRow
key={link.id}
className={cn(
"group/row",
popoverOpen === link.id && "bg-gray-100",
link.isArchived &&
"bg-gray-50 opacity-50 dark:bg-gray-700",
)}
>
<TableCell className="w-[200px] truncate font-medium md:w-[220px] lg:w-[250px] xl:w-[280px] 2xl:w-[350px]">
<div className="flex items-center gap-x-2">
{link.groupId ? (
<ButtonTooltip content="Group Link">
<BoxesIcon className="size-4 shrink-0" />
</ButtonTooltip>
) : null}
<ButtonTooltip
content={
link.name || `Link #${link.id.slice(-5)}`
}
>
<span className="max-w-[150px] truncate md:max-w-[170px] lg:max-w-[200px] xl:max-w-[230px] 2xl:max-w-[300px]">
{link.name || `Link #${link.id.slice(-5)}`}
</span>
</ButtonTooltip>
{link.isNew && !link.isUpdated && (
<Badge
variant="outline"
className="border-emerald-600/80 text-emerald-600/80"
>
New
</Badge>
)}
{link.isUpdated && (
<Badge
variant="outline"
className="border-blue-500/80 text-blue-500/80"
>
Updated
</Badge>
)}
{link.expiresAt &&
(new Date(link.expiresAt) < new Date() ? (
<TimestampTooltip
timestamp={link.expiresAt}
side="right"
rows={["local", "utc"]}
title="Expired at"
fullLabels
>
<span className="flex cursor-default items-center rounded-full bg-destructive/10 p-1 text-destructive ring-1 ring-destructive/20">
<TimerOffIcon className="h-3 w-3" />
</span>
</TimestampTooltip>
) : (
<TimestampTooltip
timestamp={link.expiresAt}
side="right"
rows={["local", "utc"]}
title="Expires at"
fullLabels
>
<span className="flex cursor-default items-center rounded-full bg-orange-100 p-1 text-orange-700 ring-1 ring-orange-200 dark:bg-orange-900/30 dark:text-orange-400 dark:ring-orange-800">
<ClockFadingIcon className="h-3 w-3" />
</span>
</TimestampTooltip>
))}
{link.domainId && isFree ? (
<span className="ml-2 rounded-full bg-destructive px-2.5 py-0.5 text-xs text-foreground ring-1 ring-destructive">
Inactive
</span>
) : null}
</div>
</TableCell>
{/* Link URL Cell */}
<TableCell className="group/link max-w-[250px] pr-1 md:max-w-[300px] lg:max-w-[350px] xl:max-w-[400px] 2xl:max-w-[450px]">
<div className="flex flex-row gap-x-1">
<LinkUrlCell
link={link}
isFree={isFree}
onCopy={handleCopyToClipboard}
isProcessing={isDocumentProcessing(
primaryVersion,
)}
primaryVersion={primaryVersion}
mutateDocument={mutateDocument}
isPopoverOpen={popoverOpen === link.id}
/>
<div className="flex shrink-0 items-center">
<LinkActionsCell
link={link}
onCopy={handleCopyToClipboard}
onPreview={handlePreviewLink}
isProcessing={isDocumentProcessing(
primaryVersion,
)}
/>
{isMobile ? (
<ButtonTooltip content="Edit link">
<Button
variant="link"
size="icon"
className="group h-8 w-8"
onClick={() => handleEditLink(link)}
>
<span className="sr-only">Edit link</span>
<Settings2Icon className="text-gray-400 group-hover:text-gray-500" />
</Button>
</ButtonTooltip>
) : (
<Popover
open={popoverOpen === link.id}
onOpenChange={() => {}}
>
<PopoverTrigger asChild>
<Button
variant="link"
className={cn(
"h-8 w-8 font-normal hover:no-underline focus-visible:ring-0 focus-visible:ring-offset-0",
popoverOpen === link.id
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
size="sm"
onClick={(e) => {
e.preventDefault();
handleEditLink(link);
}}
onMouseDown={(e) => e.preventDefault()}
onMouseEnter={() => {
hoverTimeout.current = setTimeout(
() => setPopoverOpen(link.id),
250,
);
}}
onMouseLeave={() => {
if (hoverTimeout.current)
clearTimeout(hoverTimeout.current);
hoverTimeout.current = setTimeout(
() => setPopoverOpen(null),
100,
);
}}
>
<Settings2Icon />
</Button>
</PopoverTrigger>
<PopoverContent
side="top"
align="start"
className="w-56 p-0"
onMouseEnter={() => {
if (hoverTimeout.current)
clearTimeout(hoverTimeout.current);
setPopoverOpen(link.id);
}}
onMouseLeave={() => {
if (hoverTimeout.current)
clearTimeout(hoverTimeout.current);
hoverTimeout.current = setTimeout(
() => setPopoverOpen(null),
100,
);
}}
>
<LinkActiveControls
link={link}
onEditClick={(e) => {
e.preventDefault();
handleEditLink(link);
}}
/>
</PopoverContent>
</Popover>
)}
{/* Reserve space for file permissions icon in dataroom */}
{targetType === "DATAROOM" && (
<div className="flex w-8 items-center justify-center">
{link.permissionGroupId && (
<ButtonTooltip content="Limited File Access">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 cursor-default"
>
<FileSlidersIcon className="h-4 w-4 text-muted-foreground hover:text-foreground" />
</Button>
</ButtonTooltip>
)}
</div>
)}
</div>
</div>
</TableCell>
{hasAnyTags ? (
<TableCell className="w-[250px] 2xl:w-auto">
<TagColumn link={link} />
</TableCell>
) : null}
<TableCell>
<CollapsibleTrigger
disabled={
Number(nFormatter(link._count.views)) === 0 ||
targetType === "DATAROOM"
}
>
<div className="flex items-center space-x-1 [&[data-state=open]>svg.chevron]:rotate-180">
<BarChart className="h-4 w-4 text-muted-foreground" />
<p className="whitespace-nowrap text-sm text-muted-foreground">
{nFormatter(link._count.views)}
<span className="ml-1 hidden sm:inline-block">
views
</span>
</p>
{Number(nFormatter(link._count.views)) > 0 &&
targetType !== "DATAROOM" ? (
<ChevronDown className="chevron h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
) : null}
</div>
</CollapsibleTrigger>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{link.views[0] ? (
<TimestampTooltip
timestamp={link.views[0].viewedAt}
side="right"
rows={["local", "utc", "unix"]}
>
<time
className="select-none"
dateTime={new Date(
link.views[0].viewedAt,
).toISOString()}
>
{timeAgo(link.views[0].viewedAt)}
</time>
</TimestampTooltip>
) : (
"-"
)}
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-x-1">
<Switch
className="data-[state=checked]:bg-primary/80 data-[state=checked]:hover:bg-primary data-[state=unchecked]:hover:bg-muted-foreground/80"
id={`${link.id}-active-switch`}
checked={!link.isArchived}
onCheckedChange={(checked) =>
handleArchiveLink(
link.id,
link.documentId ?? link.dataroomId ?? "",
checked,
)
}
disabled={loadingLinks.has(link.id)}
/>
<Label
className="font-normal"
htmlFor={`${link.id}-active-switch`}
>
{link.isArchived ? "No" : "Yes"}
</Label>
</div>
</TableCell>
<TableCell className="text-center sm:text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0 group-hover/row:ring-1 group-hover/row:ring-gray-200 group-hover/row:dark:ring-gray-700"
>
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleEditLink(link)}
>
<Settings2Icon className="mr-2 h-4 w-4" />
Edit Link
</DropdownMenuItem>
{targetType === "DATAROOM" &&
link.audienceType !==
LinkAudienceType.GROUP && (
<DropdownMenuItem
onClick={() => handleEditPermissions(link)}
disabled={isLoading}
>
<FileSlidersIcon className="mr-2 h-4 w-4" />
Edit File Permissions
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => handlePreviewLink(link)}
>
<EyeIcon className="mr-2 h-4 w-4" />
Preview Link
</DropdownMenuItem>
{targetType === "DATAROOM" &&
isFeatureEnabled("dataroomInvitations") && (
<DropdownMenuItem
onClick={() => handleSendInvitations(link)}
>
<SendIcon className="mr-2 h-4 w-4" />
Send Invitations
</DropdownMenuItem>
)}
<DropdownMenuItem
disabled={!canAddLinks}
onClick={() => handleDuplicateLink(link)}
>
<CopyPlusIcon className="mr-2 h-4 w-4" />
Duplicate Link
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSelectedEmbedLink({
id: link.id,
name:
link.name || `Link #${link.id.slice(-5)}`,
});
setEmbedModalOpen(true);
}}
>
<Code2Icon className="mr-2 h-4 w-4" />
Get Embed Code
</DropdownMenuItem>
{!isFree && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
setLinkToDelete(link);
setShowDeleteLinkModal(true);
}}
className="text-destructive focus:bg-destructive focus:text-destructive-foreground"
>
<Trash2Icon className="mr-2 h-4 w-4" />
Delete Link
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
<CollapsibleContent asChild>
<LinksVisitors
linkName={link.name || "No link name"}
linkId={link.id}
/>
</CollapsibleContent>
</>
</Collapsible>
))
) : (
<TableRow>
<TableCell colSpan={hasAnyTags ? 7 : 6}>
<div className="flex w-full flex-col items-center justify-center gap-4 rounded-xl py-4">
<div className="hidden rounded-full sm:block">
<div
className={cn(
"rounded-full border border-white bg-gradient-to-t from-gray-100 p-1 md:p-3",
)}
>
<LinkIcon className="size-6" />
</div>
</div>
<p>No links found for this {targetType.toLowerCase()}</p>
<AddLinkButton />
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* Collapsible wrapper for DOCUMENT type, plain div for DATAROOM */}
{targetType === "DOCUMENT" ? (
<Collapsible
open={isAllLinksOpen}
onOpenChange={handleAllLinksToggle}
className="w-full"
>
<CollapsibleTrigger asChild>
<button
type="button"
className="mb-2 flex w-full cursor-pointer items-center gap-2 text-left md:mb-4"
>
<ChevronRightIcon
className={cn(
"h-5 w-5 text-muted-foreground transition-transform duration-200",
isAllLinksOpen && "rotate-90",
)}
/>
<h2 className="m-0">All links</h2>
{processedLinks && processedLinks.length > 0 && (
<Badge variant="outline" className="text-muted-foreground">
{processedLinks.length}
</Badge>
)}
</button>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down">
{linksTableContent}
</CollapsibleContent>
</Collapsible>
) : (
linksTableContent
)}
{targetType === "DATAROOM" ? (
<>
+19 -4
View File
@@ -3,6 +3,7 @@
import { useRouter } from "next/router";
import {
memo,
forwardRef,
useCallback,
useEffect,
@@ -26,6 +27,8 @@ type SearchBoxProps = {
onChangeDebounced?: (value: string) => void;
debounceTimeoutMs?: number;
inputClassName?: string;
leftIconClassName?: string;
clearIconClassName?: string;
placeholder?: string;
};
@@ -39,6 +42,8 @@ const SearchBox = forwardRef(
onChangeDebounced,
debounceTimeoutMs = 500,
inputClassName,
leftIconClassName,
clearIconClassName,
placeholder = "Search...",
}: SearchBoxProps,
forwardedRef,
@@ -77,14 +82,16 @@ const SearchBox = forwardRef(
{loading && value.length > 0 ? (
<LoadingSpinner className="h-4 w-4" />
) : (
<SearchIcon className="h-4 w-4 text-muted-foreground" />
<SearchIcon
className={cn("h-4 w-4 text-muted-foreground", leftIconClassName)}
/>
)}
</div>
<input
ref={inputRef}
type="text"
className={cn(
"peer w-full rounded-md border border-border bg-white px-10 text-foreground outline-none placeholder:text-muted-foreground dark:bg-gray-800 sm:text-sm",
"peer w-full cursor-text rounded-md border border-border bg-white px-10 text-foreground outline-none placeholder:text-muted-foreground dark:bg-gray-800 sm:text-sm",
"transition-all focus:border-gray-500 focus:ring-0",
inputClassName,
)}
@@ -104,7 +111,9 @@ const SearchBox = forwardRef(
}}
className="pointer-events-auto absolute inset-y-0 right-0 flex items-center pr-4"
>
<CircleXIcon className="h-4 w-4 text-muted-foreground" />
<CircleXIcon
className={cn("h-4 w-4 text-muted-foreground", clearIconClassName)}
/>
</button>
)}
</div>
@@ -112,6 +121,7 @@ const SearchBox = forwardRef(
},
);
SearchBox.displayName = "SearchBox";
const MemoizedSearchBox = memo(SearchBox);
export function SearchBoxPersisted({
urlParam = "search",
@@ -148,6 +158,9 @@ export function SearchBoxPersisted({
isCustomDomain ? `/${router.query.slug}` : undefined, // Preserve custom domain URL
{ shallow: true },
);
// This is intentionally keyed only by debounced input value.
// Adding router/query deps can cause feedback loops with shallow routing.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedValue]);
useEffect(() => {
@@ -157,10 +170,12 @@ export function SearchBoxPersisted({
setValue(queryValue);
setDebouncedValue(queryValue);
}
// Keep this tied to the specific URL param only.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [queryParams[urlParam]]);
return (
<SearchBox
<MemoizedSearchBox
value={value}
onChange={setValue}
onChangeDebounced={setDebouncedValue}
+9
View File
@@ -1,9 +1,11 @@
import { useFeatureFlags } from "@/lib/hooks/use-feature-flags";
import { useIsAdmin } from "@/lib/hooks/use-is-admin";
import { NavMenu } from "../navigation-menu";
export function SettingsHeader() {
const { features } = useFeatureFlags();
const { isAdmin } = useIsAdmin();
return (
<header>
@@ -78,10 +80,17 @@ export function SettingsHeader() {
segment: "incoming-webhooks",
disabled: !features?.incomingWebhooks,
},
{
label: "Security",
href: `/settings/security`,
segment: "security",
disabled: !isAdmin,
},
{
label: "Billing",
href: `/settings/billing`,
segment: "billing",
disabled: !isAdmin,
},
]}
/>
+300
View File
@@ -0,0 +1,300 @@
"use client";
import { useEffect, useState } from "react";
import { useTeam } from "@/context/team-context";
import { CheckCircleIcon, PencilIcon, XIcon } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
const DEAL_TYPE_OPTIONS = [
{ value: "startup-fundraising", label: "Startup Fundraising" },
{ value: "fund-management", label: "Fundraising & Reporting" },
{ value: "mergers-acquisitions", label: "Mergers & Acquisitions" },
{ value: "financial-operations", label: "Financial Operations" },
{ value: "real-estate", label: "Real Estate" },
{ value: "project-management", label: "Project Management" },
];
const DEAL_SIZE_OPTIONS = [
{ value: "0-500k", label: "$0 - $500K" },
{ value: "500k-5m", label: "$500K - $5M" },
{ value: "5m-10m", label: "$5M - $10M" },
{ value: "10m-100m", label: "$10M - $100M" },
{ value: "100m+", label: "$100M+" },
];
export function SurveySettings() {
const teamInfo = useTeam();
const teamId = teamInfo?.currentTeam?.id;
const [dealType, setDealType] = useState<string | null>(null);
const [dealTypeOther, setDealTypeOther] = useState<string | null>(null);
const [dealSize, setDealSize] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [step, setStep] = useState<1 | 2 | 3>(1);
const [editDealType, setEditDealType] = useState<string | null>(null);
const [editDealTypeOther, setEditDealTypeOther] = useState<string>("");
const [showOtherInput, setShowOtherInput] = useState(false);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
const fetchSurvey = async () => {
if (!teamId) return;
try {
const response = await fetch(`/api/teams/${teamId}/survey`);
if (response.ok) {
const data = await response.json();
setDealType(data.dealType);
setDealTypeOther(data.dealTypeOther);
setDealSize(data.dealSize);
// Set initial step based on existing data
if (data.dealType && (data.dealSize || data.dealType === "project-management")) {
setStep(3); // Show completed state
} else if (data.dealType) {
setEditDealType(data.dealType);
setEditDealTypeOther(data.dealTypeOther || "");
setStep(2); // Show deal size question
} else {
setStep(1); // Show deal type question
}
}
} catch (error) {
console.error("Failed to fetch survey data:", error);
} finally {
setIsLoading(false);
}
};
fetchSurvey();
}, [teamId]);
const getDealTypeLabel = (value: string | null, otherText?: string | null) => {
if (!value) return null;
if (value === "other" && otherText) {
return `Other: ${otherText}`;
}
return DEAL_TYPE_OPTIONS.find((opt) => opt.value === value)?.label || value;
};
const getDealSizeLabel = (value: string | null) => {
if (!value) return null;
return DEAL_SIZE_OPTIONS.find((opt) => opt.value === value)?.label || value;
};
const handleEdit = () => {
setEditDealType(dealType);
setEditDealTypeOther(dealTypeOther || "");
setShowOtherInput(false);
setStep(1);
};
const handleDealTypeSelect = async (value: string) => {
setEditDealType(value);
setShowOtherInput(false);
if (value === "project-management") {
// Project management doesn't need deal size - save directly
await handleSave(value, null, null);
} else {
setStep(2);
}
};
const handleOtherSubmit = () => {
if (!editDealTypeOther.trim()) return;
setEditDealType("other");
setShowOtherInput(false);
setStep(2);
};
const handleDealSizeSelect = async (value: string) => {
await handleSave(editDealType, value, editDealTypeOther || null);
};
const handleSave = async (
type?: string | null,
size?: string | null,
otherText?: string | null,
) => {
const finalDealType = type || editDealType;
if (!finalDealType || !teamId) return;
setIsSaving(true);
try {
const response = await fetch(`/api/teams/${teamId}/survey`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
dealType: finalDealType,
dealTypeOther: otherText,
dealSize: size,
}),
});
if (!response.ok) {
throw new Error("Failed to save");
}
setDealType(finalDealType);
setDealTypeOther(otherText || null);
setDealSize(size || null);
setStep(3);
toast.success("Survey answers saved!");
} catch (error) {
console.error("Failed to save survey:", error);
toast.error("Failed to save changes");
} finally {
setIsSaving(false);
}
};
const getDealSizeQuestion = () => {
switch (editDealType) {
case "startup-fundraising":
case "fund-management":
return "How much are you raising?";
case "mergers-acquisitions":
case "real-estate":
case "financial-operations":
return "What's the deal size?";
default:
return "What's the typical deal size?";
}
};
return (
<Card id="team-survey">
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Team Survey</CardTitle>
<CardDescription>
This will help us tailor your Papermark experience
</CardDescription>
</div>
{step === 3 && (
<Button variant="outline" size="sm" onClick={handleEdit}>
<PencilIcon className="mr-2 h-4 w-4" />
Edit
</Button>
)}
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : step === 1 ? (
<>
<div className="mb-4">
<h3 className="text-lg font-semibold">What do you use Papermark for?</h3>
</div>
<div className="grid gap-2">
{DEAL_TYPE_OPTIONS.map((option) => (
<button
key={option.value}
onClick={() => handleDealTypeSelect(option.value)}
disabled={isSaving}
className="flex items-center justify-between rounded-lg border border-border px-3 py-2 text-left text-sm transition-all hover:border-primary/50 hover:bg-muted/50"
>
<span className="font-medium">{option.label}</span>
</button>
))}
{/* Other - inline input */}
{showOtherInput ? (
<div className="flex gap-2">
<input
type="text"
value={editDealTypeOther}
onChange={(e) => setEditDealTypeOther(e.target.value)}
placeholder="Please specify..."
className="flex-1 rounded-lg border border-border bg-background px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter" && editDealTypeOther.trim()) {
handleOtherSubmit();
}
}}
/>
<button
onClick={handleOtherSubmit}
disabled={!editDealTypeOther.trim() || isSaving}
className="rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
>
</button>
</div>
) : (
<button
onClick={() => setShowOtherInput(true)}
disabled={isSaving}
className="flex items-center justify-between rounded-lg border border-border px-3 py-2 text-left text-sm transition-all hover:border-primary/50 hover:bg-muted/50"
>
<span className="font-medium">Other</span>
</button>
)}
</div>
</>
) : step === 2 ? (
<>
<div className="mb-4">
<h3 className="text-lg font-semibold">
{getDealSizeQuestion()}
</h3>
</div>
<div className="grid gap-2">
{DEAL_SIZE_OPTIONS.map((option) => (
<button
key={option.value}
onClick={() => handleDealSizeSelect(option.value)}
disabled={isSaving}
className="flex items-center justify-between rounded-lg border border-border px-3 py-2 text-left text-sm transition-all hover:border-primary/50 hover:bg-muted/50"
>
<span className="font-medium">{option.label}</span>
</button>
))}
</div>
</>
) : (
<>
<div className="mb-4 flex items-center gap-3">
<CheckCircleIcon className="h-6 w-6 text-green-500" />
<h3 className="text-lg font-semibold">Thanks for sharing!</h3>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between rounded-lg border border-border px-3 py-2">
<span className="text-sm text-muted-foreground">Use case</span>
<span className="text-sm font-medium">
{getDealTypeLabel(dealType, dealTypeOther)}
</span>
</div>
{dealSize && (
<div className="flex items-center justify-between rounded-lg border border-border px-3 py-2">
<span className="text-sm text-muted-foreground">Deal size</span>
<span className="text-sm font-medium">
{getDealSizeLabel(dealSize)}
</span>
</div>
)}
</div>
</>
)}
</CardContent>
</Card>
);
}
+333
View File
@@ -0,0 +1,333 @@
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { useTeam } from "@/context/team-context";
import { CheckCircleIcon, XIcon } from "lucide-react";
import { toast } from "sonner";
const DEAL_TYPE_OPTIONS = [
{ value: "startup-fundraising", label: "Startup Fundraising" },
{ value: "fund-management", label: "Fundraising & Reporting" },
{ value: "mergers-acquisitions", label: "Mergers & Acquisitions" },
{ value: "financial-operations", label: "Financial Operations" },
{ value: "real-estate", label: "Real Estate" },
{ value: "project-management", label: "Project Management" },
];
const DEAL_SIZE_OPTIONS = [
{ value: "0-500k", label: "$0 - $500K" },
{ value: "500k-5m", label: "$500K - $5M" },
{ value: "5m-10m", label: "$5M - $10M" },
{ value: "10m-100m", label: "$10M - $100M" },
{ value: "100m+", label: "$100M+" },
];
export function DealflowPopup() {
const router = useRouter();
const teamInfo = useTeam();
const [isOpen, setIsOpen] = useState(false);
const [step, setStep] = useState(1);
const [dealType, setDealType] = useState<string | null>(null);
const [dealTypeOther, setDealTypeOther] = useState<string>("");
const [showOtherInput, setShowOtherInput] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const teamId = teamInfo?.currentTeam?.id;
const storageKey = `dealflow-survey-dismissed-${teamId}`;
// Check if we're on onboarding/welcome pages
const isOnboarding = router.pathname.startsWith("/welcome");
useEffect(() => {
if (isOnboarding) return;
if (!teamId) return;
const dismissed = localStorage.getItem(storageKey);
if (dismissed) return;
let timeoutId: NodeJS.Timeout;
const checkSurvey = async () => {
try {
const response = await fetch(`/api/teams/${teamId}/survey`);
if (response.ok) {
const data = await response.json();
if (data.dismissed) {
localStorage.setItem(storageKey, "true");
return;
}
if (!data.dealType) {
timeoutId = setTimeout(() => {
setStep(1);
setIsOpen(true);
}, 2000);
} else if (!data.dealSize && data.dealType !== "project-management") {
timeoutId = setTimeout(() => {
setDealType(data.dealType);
setDealTypeOther(data.dealTypeOther || "");
setStep(2);
setIsOpen(true);
}, 2000);
}
}
} catch (error) {
console.error("Failed to check survey status:", error);
}
};
checkSurvey();
return () => clearTimeout(timeoutId);
}, [teamId, storageKey, isOnboarding]);
const handleDealTypeSelect = (value: string) => {
setDealType(value);
setShowOtherInput(false);
if (value === "project-management") {
// Project management doesn't need a second step - go straight to thank you
handleSubmit(value, null, null);
} else {
setStep(2);
}
};
const handleOtherSubmit = () => {
if (!dealTypeOther.trim()) return;
setDealType("other");
setShowOtherInput(false);
// After entering "Other" text, go to deal size question
setStep(2);
};
const handleDealSizeSelect = async (value: string) => {
await handleSubmit(dealType, value, dealTypeOther || null);
};
const handleSubmit = async (
type?: string | null,
size?: string | null,
otherText?: string | null,
) => {
const finalDealType = type || dealType;
if (!finalDealType) return;
setIsSubmitting(true);
try {
const response = await fetch(
`/api/teams/${teamInfo?.currentTeam?.id}/survey`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
dealType: finalDealType,
dealTypeOther:
otherText !== undefined
? otherText
: dealTypeOther || null,
dealSize: size ?? null,
}),
},
);
if (!response.ok) {
throw new Error("Failed to save response");
}
localStorage.setItem(storageKey, "true");
setStep(3); // Go to thank you screen
} catch (error) {
console.error("Failed to save survey response:", error);
toast.error("Failed to save your response");
} finally {
setIsSubmitting(false);
}
};
const handleClose = () => {
setIsOpen(false);
};
const handleDismiss = async () => {
localStorage.setItem(storageKey, "true");
setIsOpen(false);
toast("Survey dismissed", {
description: "You can complete it anytime from Settings → General.",
action: {
label: "Go to Settings",
onClick: () => router.push("/settings/general#team-survey"),
},
});
if (teamId) {
try {
await fetch(`/api/teams/${teamId}/survey`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
dismissed: true,
dismissedAt: new Date().toISOString(),
}),
});
} catch (error) {
console.error("Failed to save survey dismissal:", error);
}
}
};
const getDealSizeQuestion = () => {
switch (dealType) {
case "startup-fundraising":
case "fund-management":
return "How much are you raising?";
case "mergers-acquisitions":
case "real-estate":
case "financial-operations":
return "What's the deal size?";
default:
return "What's the typical deal size?";
}
};
if (!isOpen) return null;
return (
<div className="fixed bottom-4 right-4 z-50 w-full max-w-sm animate-in fade-in slide-in-from-bottom-4 duration-300">
<div className="rounded-lg border-2 border-black bg-background p-4 shadow-lg">
{step === 1 ? (
<>
<div className="mb-4 flex items-start justify-between">
<div>
<h3 className="text-lg font-semibold">What do you use Papermark for?</h3>
</div>
<button
onClick={handleDismiss}
className="rounded-full p-1 hover:bg-muted"
>
<XIcon className="h-4 w-4 text-muted-foreground" />
</button>
</div>
<div className="grid gap-2">
{DEAL_TYPE_OPTIONS.map((option) => (
<button
key={option.value}
onClick={() => handleDealTypeSelect(option.value)}
disabled={isSubmitting}
className="flex items-center justify-between rounded-lg border border-border px-3 py-2 text-left text-sm transition-all hover:border-primary/50 hover:bg-muted/50"
>
<span className="font-medium">{option.label}</span>
</button>
))}
{/* Other - inline input */}
{showOtherInput ? (
<div className="flex gap-2">
<input
type="text"
value={dealTypeOther}
onChange={(e) => setDealTypeOther(e.target.value)}
placeholder="Please specify..."
className="flex-1 rounded-lg border border-border bg-background px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter" && dealTypeOther.trim()) {
handleOtherSubmit();
}
}}
/>
<button
onClick={handleOtherSubmit}
disabled={!dealTypeOther.trim() || isSubmitting}
className="rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
>
</button>
</div>
) : (
<button
onClick={() => setShowOtherInput(true)}
disabled={isSubmitting}
className="flex items-center justify-between rounded-lg border border-border px-3 py-2 text-left text-sm transition-all hover:border-primary/50 hover:bg-muted/50"
>
<span className="font-medium">Other</span>
</button>
)}
</div>
<p className="mt-3 text-xs text-muted-foreground">
This will help us tailor your Papermark experience
</p>
</>
) : step === 2 ? (
<>
<div className="mb-4 flex items-start justify-between">
<div>
<h3 className="text-lg font-semibold">
{getDealSizeQuestion()}
</h3>
</div>
<button
onClick={handleDismiss}
className="rounded-full p-1 hover:bg-muted"
>
<XIcon className="h-4 w-4 text-muted-foreground" />
</button>
</div>
<div className="grid gap-2">
{DEAL_SIZE_OPTIONS.map((option) => (
<button
key={option.value}
onClick={() => handleDealSizeSelect(option.value)}
disabled={isSubmitting}
className="flex items-center justify-between rounded-lg border border-border px-3 py-2 text-left text-sm transition-all hover:border-primary/50 hover:bg-muted/50"
>
<span className="font-medium">{option.label}</span>
</button>
))}
</div>
<p className="mt-3 text-xs text-muted-foreground">
This will help us tailor your Papermark experience
</p>
</>
) : (
<>
<div className="mb-4 flex items-start justify-between">
<div className="flex items-center gap-3">
<CheckCircleIcon className="h-6 w-6 text-green-500" />
<h3 className="text-lg font-semibold">Thanks for sharing!</h3>
</div>
<button
onClick={handleClose}
className="rounded-full p-1 hover:bg-muted"
>
<XIcon className="h-4 w-4 text-muted-foreground" />
</button>
</div>
<p className="mb-4 text-sm text-muted-foreground">
You can find and update your responses in settings.
</p>
<button
onClick={() => {
router.push("/settings/general#team-survey");
handleClose();
}}
className="w-full rounded-lg border border-black bg-white px-3 py-2 text-sm font-medium text-black transition-colors hover:bg-gray-50"
>
Go to Team Survey
</button>
</>
)}
</div>
</div>
);
}
+7 -1
View File
@@ -1,4 +1,9 @@
export default function EyeOff({ className }: { className?: string }) {
import type { SVGProps } from "react";
export default function EyeOff({
className,
...props
}: SVGProps<SVGSVGElement>) {
return (
<svg
width="14"
@@ -11,6 +16,7 @@ export default function EyeOff({ className }: { className?: string }) {
strokeLinejoin="round"
shapeRendering="geometricPrecision"
className={className}
{...props}
>
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" />
<path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68" />
+4 -1
View File
@@ -1,4 +1,6 @@
export default function Eye({ className }: { className?: string }) {
import type { SVGProps } from "react";
export default function Eye({ className, ...props }: SVGProps<SVGSVGElement>) {
return (
<svg
width="14"
@@ -11,6 +13,7 @@ export default function Eye({ className }: { className?: string }) {
strokeLinejoin="round"
shapeRendering="geometricPrecision"
className={className}
{...props}
>
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
<circle cx="12" cy="12" r="3" />
+19 -6
View File
@@ -23,6 +23,7 @@ import {
} from "lucide-react";
import { useFeatureFlags } from "@/lib/hooks/use-feature-flags";
import { useIsAdmin } from "@/lib/hooks/use-is-admin";
import { usePlan } from "@/lib/swr/use-billing";
import useDataroomsSimple from "@/lib/swr/use-datarooms-simple";
import useLimits from "@/lib/swr/use-limits";
@@ -77,6 +78,9 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
// Check feature flags
const { features } = useFeatureFlags();
// Check if current user is admin (for gating Security & Billing)
const { isAdmin } = useIsAdmin();
// Fetch datarooms for the current team (simple mode - no filters or extra data)
const { datarooms } = useDataroomsSimple();
@@ -201,11 +205,20 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
url: "/settings/slack",
current: router.pathname.includes("settings/slack"),
},
{
title: "Billing",
url: "/settings/billing",
current: router.pathname.includes("settings/billing"),
},
...(isAdmin
? [
{
title: "Security",
url: "/settings/security",
current: router.pathname.includes("settings/security"),
},
{
title: "Billing",
url: "/settings/billing",
current: router.pathname.includes("settings/billing"),
},
]
: []),
],
},
// {
@@ -251,7 +264,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
) : null}
</span>
) : null}
{isDataroomsPlus ? (
{isDataroomsPlus && !isDataroomsPremium ? (
<span className="relative ml-4 inline-flex items-center rounded-full bg-background px-2.5 py-1 text-xs tracking-normal text-foreground ring-1 ring-gray-800">
Datarooms+
{isPaused ? (
+1 -1
View File
@@ -17,7 +17,7 @@ export const Gauge = ({
xs: {
width: "24",
height: "24",
textSize: "text-xs",
textSize: "text-[10px]",
},
small: {
width: "36",
+3 -2
View File
@@ -4,7 +4,7 @@ import { OTPInput, OTPInputContext } from "input-otp";
import { Dot } from "lucide-react";
import { cn } from "@/lib/utils";
import { determineTextColor } from "@/lib/utils/determine-text-color";
import { createAdaptiveSurfacePalette } from "@/lib/utils/create-adaptive-surface-palette";
// Create a context to pass accentColor to InputOTPSlot components
const AccentColorContext = React.createContext<string | null | undefined>(
@@ -50,7 +50,8 @@ const InputOTPSlot = React.forwardRef<
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
const accentColor = React.useContext(AccentColorContext);
const textColor = determineTextColor(accentColor);
const otpPalette = createAdaptiveSurfacePalette(accentColor);
const textColor = otpPalette.textColor;
return (
<div
+42 -9
View File
@@ -6,6 +6,7 @@
*
*/
import React, {
CSSProperties,
createContext,
memo,
useCallback,
@@ -25,11 +26,16 @@ import {
import { cn } from "@/lib/utils";
const ctx = createContext(0);
const prefersLightTextCtx = createContext(false);
function useIndent() {
return useContext(ctx);
}
export function usePrefersLightText() {
return useContext(prefersLightTextCtx);
}
interface FolderProps {
name: string;
label?: ReactElement;
@@ -50,11 +56,24 @@ interface FileProps {
onToggle?: (active: boolean) => void;
}
function Tree({ children }: { children: ReactNode }): ReactElement {
function Tree({
children,
prefersLightText,
style,
}: {
children: ReactNode;
prefersLightText?: boolean;
style?: CSSProperties;
}): ReactElement {
return (
<div className={cn("nextra-filetree !mt-0 w-full select-none text-sm")}>
<div className="block space-y-1 rounded-lg">{children}</div>
</div>
<prefersLightTextCtx.Provider value={prefersLightText ?? false}>
<div
className={cn("nextra-filetree !mt-0 w-full select-none text-sm")}
style={style}
>
<div className="block space-y-1 rounded-lg">{children}</div>
</div>
</prefersLightTextCtx.Provider>
);
}
@@ -83,6 +102,7 @@ const Folder = memo<FolderProps>(
disable,
}) => {
const indent = useIndent();
const prefersLightText = usePrefersLightText();
const [isOpen, setIsOpen] = useState(defaultOpen || childActive);
useEffect(() => {
@@ -121,9 +141,15 @@ const Folder = memo<FolderProps>(
title={name}
className={cn(
"inline-flex w-full cursor-pointer items-center overflow-hidden",
"rounded-md text-foreground duration-100 hover:bg-gray-100 hover:dark:bg-muted",
"rounded-md duration-100",
prefersLightText
? "text-[var(--viewer-text)] hover:bg-[var(--viewer-control-bg)]"
: "text-foreground hover:bg-gray-100 hover:dark:bg-muted",
"px-3 py-1.5 leading-6",
active && "bg-gray-100 font-semibold dark:bg-muted",
active &&
(prefersLightText
? "bg-[var(--viewer-panel-active)] font-semibold"
: "bg-gray-100 font-semibold dark:bg-muted"),
disable && "pointer-events-none cursor-auto opacity-50",
className,
)}
@@ -169,17 +195,24 @@ Folder.displayName = "Folder";
const File = memo<FileProps>(({ label, name, active, onToggle }) => {
const indent = useIndent();
const prefersLightText = usePrefersLightText();
const toggle = useCallback(() => {
onToggle?.(!active);
}, [onToggle]);
}, [active, onToggle]);
return (
<li
className={cn(
"flex list-none",
"rounded-md text-foreground duration-100 hover:bg-gray-100 hover:dark:bg-muted",
"rounded-md duration-100",
prefersLightText
? "text-[var(--viewer-muted-text)] hover:bg-[var(--viewer-control-bg)]"
: "text-foreground hover:bg-gray-100 hover:dark:bg-muted",
"px-3 py-1.5 leading-6",
active && "bg-gray-100 font-semibold dark:bg-muted",
active &&
(prefersLightText
? "bg-[var(--viewer-panel-active)] text-[var(--viewer-text)] font-semibold"
: "bg-gray-100 font-semibold dark:bg-muted"),
)}
>
<span
+202 -1
View File
@@ -1,13 +1,16 @@
"use client";
import { useCallback } from "react";
import { useCallback, useMemo, useState } from "react";
import Image from "@tiptap/extension-image";
import Placeholder from "@tiptap/extension-placeholder";
import Youtube from "@tiptap/extension-youtube";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import {
Bold,
Heading1,
Heading2,
ImageIcon,
Italic,
List,
@@ -15,9 +18,78 @@ import {
Quote,
Redo,
Undo,
Youtube as YoutubeIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
/**
* Validates if a given URL is a valid YouTube URL.
* Supports:
* - youtube.com/watch?v=VIDEO_ID
* - youtu.be/VIDEO_ID
* - youtube.com/embed/VIDEO_ID
* - www.youtube.com variants
* - youtube-nocookie.com variants
*/
function isValidYouTubeUrl(url: string): boolean {
if (!url || typeof url !== "string") return false;
try {
const parsedUrl = new URL(url.trim());
const hostname = parsedUrl.hostname.toLowerCase();
// Check for valid YouTube hostnames
const validHostnames = [
"youtube.com",
"www.youtube.com",
"youtu.be",
"www.youtu.be",
"youtube-nocookie.com",
"www.youtube-nocookie.com",
];
if (!validHostnames.includes(hostname)) return false;
// For youtu.be short URLs, the video ID is in the pathname
if (hostname === "youtu.be" || hostname === "www.youtu.be") {
const videoId = parsedUrl.pathname.slice(1); // Remove leading slash
return videoId.length > 0 && /^[\w-]+$/.test(videoId);
}
// For youtube.com/watch URLs, check for 'v' parameter
if (parsedUrl.pathname === "/watch") {
const videoId = parsedUrl.searchParams.get("v");
return videoId !== null && videoId.length > 0 && /^[\w-]+$/.test(videoId);
}
// For youtube.com/embed/VIDEO_ID URLs
if (parsedUrl.pathname.startsWith("/embed/")) {
const videoId = parsedUrl.pathname.replace("/embed/", "").split("/")[0];
return videoId.length > 0 && /^[\w-]+$/.test(videoId);
}
// For youtube.com/v/VIDEO_ID URLs (legacy)
if (parsedUrl.pathname.startsWith("/v/")) {
const videoId = parsedUrl.pathname.replace("/v/", "").split("/")[0];
return videoId.length > 0 && /^[\w-]+$/.test(videoId);
}
return false;
} catch {
// URL parsing failed
return false;
}
}
interface RichTextEditorProps {
content?: any;
@@ -32,6 +104,16 @@ export function RichTextEditor({
placeholder = "Start typing...",
onImageUpload,
}: RichTextEditorProps) {
const [youtubeDialogOpen, setYoutubeDialogOpen] = useState(false);
const [youtubeUrl, setYoutubeUrl] = useState("");
const [youtubeUrlError, setYoutubeUrlError] = useState<string | null>(null);
// Memoize URL validation to avoid recalculating on every render
const isYoutubeUrlValid = useMemo(
() => isValidYouTubeUrl(youtubeUrl),
[youtubeUrl],
);
const editor = useEditor({
extensions: [
StarterKit,
@@ -42,6 +124,13 @@ export function RichTextEditor({
class: "rounded-lg max-w-full h-auto",
},
}),
Youtube.configure({
controls: true,
nocookie: true,
HTMLAttributes: {
class: "rounded-lg w-full aspect-video",
},
}),
Placeholder.configure({
placeholder,
}),
@@ -151,6 +240,37 @@ export function RichTextEditor({
[editor, onImageUpload],
);
const addYoutubeVideo = useCallback(() => {
if (!editor || !youtubeUrl) return;
const trimmed = youtubeUrl.trim();
// Re-validate URL before inserting
if (!isValidYouTubeUrl(trimmed)) {
setYoutubeUrlError(
"Please enter a valid YouTube URL (e.g., youtube.com/watch?v=..., youtu.be/...)",
);
return;
}
editor.commands.setYoutubeVideo({
src: trimmed,
});
setYoutubeUrl("");
setYoutubeUrlError(null);
setYoutubeDialogOpen(false);
}, [editor, youtubeUrl]);
// Clear error and URL when dialog closes
const handleYoutubeDialogChange = useCallback((open: boolean) => {
setYoutubeDialogOpen(open);
if (!open) {
setYoutubeUrl("");
setYoutubeUrlError(null);
}
}, []);
if (!editor) {
return null;
}
@@ -159,6 +279,29 @@ export function RichTextEditor({
<div className="rounded-md border border-input">
{/* Toolbar */}
<div className="flex flex-wrap gap-1 border-b border-input p-2">
<Button
variant="ghost"
size="sm"
type="button"
onClick={() =>
editor.chain().focus().toggleHeading({ level: 1 }).run()
}
className={editor.isActive("heading", { level: 1 }) ? "bg-muted" : ""}
>
<Heading1 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
type="button"
onClick={() =>
editor.chain().focus().toggleHeading({ level: 2 }).run()
}
className={editor.isActive("heading", { level: 2 }) ? "bg-muted" : ""}
>
<Heading2 className="h-4 w-4" />
</Button>
<div className="mx-1 h-6 w-px bg-border" />
<Button
variant="ghost"
size="sm"
@@ -213,6 +356,14 @@ export function RichTextEditor({
</Button>
</>
)}
<Button
variant="ghost"
size="sm"
type="button"
onClick={() => setYoutubeDialogOpen(true)}
>
<YoutubeIcon className="h-4 w-4" />
</Button>
<div className="mx-1 h-6 w-px bg-border" />
<Button
variant="ghost"
@@ -241,6 +392,56 @@ export function RichTextEditor({
className="prose prose-sm max-w-none focus:outline-none [&_.ProseMirror]:min-h-[150px] [&_.ProseMirror]:focus:outline-none"
/>
</div>
{/* YouTube Dialog */}
<Dialog open={youtubeDialogOpen} onOpenChange={handleYoutubeDialogChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add YouTube Video</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="youtube-url">YouTube URL</Label>
<Input
id="youtube-url"
placeholder="https://www.youtube.com/watch?v=..."
value={youtubeUrl}
onChange={(e) => {
setYoutubeUrl(e.target.value);
// Clear error when user starts typing
if (youtubeUrlError) setYoutubeUrlError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addYoutubeVideo();
}
}}
className={youtubeUrlError ? "border-destructive" : ""}
/>
{youtubeUrlError ? (
<p className="text-xs text-destructive">{youtubeUrlError}</p>
) : (
<p className="text-xs text-muted-foreground">
Paste a YouTube video URL (e.g., youtube.com/watch?v=...,
youtu.be/...)
</p>
)}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => handleYoutubeDialogChange(false)}
>
Cancel
</Button>
<Button onClick={addYoutubeVideo} disabled={!isYoutubeUrlValid}>
Add Video
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+98 -6
View File
@@ -100,9 +100,10 @@ export default function UploadZone({
const teamInfo = useTeam();
const { data: session } = useSession();
const { limits, canAddDocuments, isPaused } = useLimits();
const remainingDocuments = limits?.documents
? limits?.documents - limits?.usage?.documents
: 0;
const hasDocumentLimit = limits?.documents != null && limits.documents > 0;
const remainingDocuments = hasDocumentLimit
? limits.documents - (limits?.usage?.documents ?? 0)
: Infinity;
// Fetch team settings with proper revalidation - ensures settings stay fresh across tabs
const { settings: teamSettings } = useTeamSettings(teamInfo?.currentTeam?.id);
@@ -113,6 +114,7 @@ export default function UploadZone({
// Using promise-lock pattern to prevent race conditions during concurrent folder creation
const dataroomFolderPathRef = useRef<string | null>(null);
const dataroomFolderCreationPromiseRef = useRef<Promise<string> | null>(null);
const fileLimitTruncatedRef = useRef(false);
// Reset the cached dataroom folder path when the replication setting changes
// This ensures we don't use stale cached paths if the setting is toggled
@@ -222,6 +224,25 @@ export default function UploadZone({
const onDropRejected = useCallback(
(rejectedFiles: FileRejection[]) => {
const hasTooManyFiles = rejectedFiles.some(({ errors }) =>
errors.some(({ code }) => code === "too-many-files"),
);
if (hasTooManyFiles) {
const maxFiles = fileSizeLimits.maxFiles ?? 150;
toast.error(
`You're trying to upload ${rejectedFiles.length} files, but you can only upload up to ${maxFiles} files at once. Please upload in smaller batches.`,
{ duration: 8000 },
);
onUploadRejected([
{
fileName: `${rejectedFiles.length} files selected`,
message: `Maximum ${maxFiles} files per upload`,
},
]);
return;
}
const rejected = rejectedFiles.map(({ file, errors }) => {
let message = "";
if (errors.find(({ code }) => code === "file-too-large")) {
@@ -256,13 +277,55 @@ export default function UploadZone({
return;
}
if (!canAddDocuments && acceptedFiles.length > remainingDocuments) {
toast.error("You have reached the maximum number of documents.");
if (hasDocumentLimit && remainingDocuments <= 0) {
toast.error(
`You've reached your plan's document limit (${limits?.usage?.documents}/${limits?.documents} documents). Upgrade your plan to upload more.`,
{
action: {
label: "Upgrade",
onClick: () => router.push("/settings/billing"),
},
duration: 8000,
},
);
return;
}
let filesToUpload = acceptedFiles;
if (fileLimitTruncatedRef.current) {
// Folder traversal was already capped at remainingDocuments
// no extra folders were created, just show the warning.
fileLimitTruncatedRef.current = false;
toast.warning(
`Your upload was limited to ${acceptedFiles.length} file${acceptedFiles.length === 1 ? "" : "s"} because your plan only allows ${remainingDocuments} more document${remainingDocuments === 1 ? "" : "s"} (${limits?.usage?.documents}/${limits?.documents} used).`,
{
action: {
label: "Upgrade",
onClick: () => router.push("/settings/billing"),
},
duration: 10000,
},
);
} else if (hasDocumentLimit && acceptedFiles.length > remainingDocuments) {
// Safety net for the file-picker path (no folder traversal) or
// race conditions where the cap was slightly exceeded.
const skippedCount = acceptedFiles.length - remainingDocuments;
toast.warning(
`You're trying to upload ${acceptedFiles.length} files, but your plan only allows ${remainingDocuments} more document${remainingDocuments === 1 ? "" : "s"} (${limits?.usage?.documents}/${limits?.documents} used). ${skippedCount} file${skippedCount === 1 ? "" : "s"} will be skipped.`,
{
action: {
label: "Upgrade",
onClick: () => router.push("/settings/billing"),
},
duration: 10000,
},
);
filesToUpload = acceptedFiles.slice(0, remainingDocuments);
}
// Validate files and separate into valid and invalid
const validatedFiles = acceptedFiles.reduce<{
const validatedFiles = filesToUpload.reduce<{
valid: FileWithPaths[];
invalid: { fileName: string; message: string }[];
}>(
@@ -530,6 +593,8 @@ export default function UploadZone({
isFree,
isTrial,
isPaused,
hasDocumentLimit,
remainingDocuments,
],
);
@@ -540,6 +605,18 @@ export default function UploadZone({
return [];
}
fileLimitTruncatedRef.current = false;
const fileLimit =
hasDocumentLimit && isFinite(remainingDocuments)
? Math.max(0, remainingDocuments)
: Infinity;
let collectedFileCount = 0;
// Early check: skip folder traversal (and folder creation) if document limit is already reached
if (fileLimit <= 0) {
return [];
}
let filesToBePassedToOnDrop: FileWithPaths[] = [];
/** *********** START OF `traverseFolder` *********** */
@@ -561,6 +638,10 @@ export default function UploadZone({
return files;
}
if (collectedFileCount >= fileLimit) {
return files;
}
if (entry.isDirectory) {
/**
* Let's create the folder.
@@ -706,6 +787,10 @@ export default function UploadZone({
return files;
}
if (collectedFileCount >= fileLimit) {
return files;
}
let file = await new Promise<FileWithPaths>((resolve) =>
(entry as FileSystemFileEntry).file(resolve),
);
@@ -760,6 +845,7 @@ export default function UploadZone({
file.dataroomUploadPath = dataroomParentPath;
files.push(file);
collectedFileCount++;
}
return files;
@@ -805,6 +891,10 @@ export default function UploadZone({
}
}
if (isFinite(fileLimit) && collectedFileCount >= fileLimit) {
fileLimitTruncatedRef.current = true;
}
return filesToBePassedToOnDrop;
},
[
@@ -817,6 +907,8 @@ export default function UploadZone({
setRejectedFiles,
acceptableDropZoneFileTypes,
getOrCreateDataroomFolder,
hasDocumentLimit,
remainingDocuments,
],
);
@@ -0,0 +1,32 @@
import { createContext, useContext } from "react";
import type { ReactNode } from "react";
import { createAdaptiveSurfacePalette } from "@/lib/utils/create-adaptive-surface-palette";
export type AccessFormTheme = ReturnType<typeof createAdaptiveSurfacePalette>;
const defaultTheme = createAdaptiveSurfacePalette(undefined);
const AccessFormThemeContext = createContext<AccessFormTheme>(defaultTheme);
export function createAccessFormTheme(accentColor: string | null | undefined) {
return createAdaptiveSurfacePalette(accentColor || "#000000");
}
export function AccessFormThemeProvider({
value,
children,
}: {
value: AccessFormTheme;
children: ReactNode;
}) {
return (
<AccessFormThemeContext.Provider value={value}>
{children}
</AccessFormThemeContext.Provider>
);
}
export function useAccessFormTheme() {
return useContext(AccessFormThemeContext);
}
@@ -2,11 +2,10 @@ import { Dispatch, SetStateAction } from "react";
import { Brand, DataroomBrand } from "@prisma/client";
import { determineTextColor } from "@/lib/utils/determine-text-color";
import { Checkbox } from "@/components/ui/checkbox";
import { DEFAULT_ACCESS_FORM_TYPE } from ".";
import { useAccessFormTheme } from "./access-form-theme";
export default function AgreementSection({
data,
@@ -25,9 +24,15 @@ export default function AgreementSection({
brand?: Partial<Brand> | Partial<DataroomBrand> | null;
useCustomAccessForm?: boolean;
}) {
const theme = useAccessFormTheme();
const isChecked = !!data.hasConfirmedAgreement;
const handleCheckChange = (checked: boolean) => {
setData((prevData) => ({ ...prevData, hasConfirmedAgreement: checked }));
};
const toggleAgreement = () => {
handleCheckChange(!isChecked);
};
const isTextContent = agreementContentType === "TEXT";
@@ -35,39 +40,47 @@ export default function AgreementSection({
<div className="relative flex items-start space-x-2 pt-5">
<Checkbox
id="agreement"
checked={isChecked}
onCheckedChange={handleCheckChange}
className="border border-gray-400 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-gray-300 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-[var(--dynamic-accent-color)] data-[state=checked]:bg-[var(--dynamic-accent-color)] data-[state=checked]:text-[var(--dynamic-accent-color)]"
className="border border-gray-400 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-gray-300 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-[var(--agreement-checked-bg)] data-[state=checked]:bg-[var(--agreement-checked-bg)] data-[state=checked]:text-[var(--agreement-check-color)]"
style={
{
borderColor: determineTextColor(brand?.accentColor),
color: brand?.accentColor || undefined,
"--dynamic-accent-color": determineTextColor(brand?.accentColor),
borderColor: theme.controlBorderStrongColor,
color: theme.backgroundColor || undefined,
"--agreement-checked-bg": theme.textColor,
"--agreement-check-color": theme.inverseTextColor,
} as React.CSSProperties
}
/>
<label
className="text-sm font-normal leading-5 text-white peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
style={{
color: determineTextColor(brand?.accentColor),
}}
style={{ color: theme.textColor }}
>
{isTextContent ? (
<span className="whitespace-pre-line">{agreementContent}</span>
<span
className="cursor-pointer whitespace-pre-line"
onClick={toggleAgreement}
>
{agreementContent}
</span>
) : (
<>
I have reviewed and agree to the terms of this{" "}
<span className="cursor-pointer" onClick={toggleAgreement}>
I have reviewed and agree to the terms of this{" "}
</span>
<a
href={`${agreementContent}`}
target="_blank"
rel="noreferrer noopener"
className="underline hover:text-gray-200"
style={{
color: determineTextColor(brand?.accentColor),
}}
onClick={(e) => e.stopPropagation()}
style={{ color: theme.textColor }}
>
{agreementName}
</a>
.
<span className="cursor-pointer" onClick={toggleAgreement}>
.
</span>
</>
)}
</label>
@@ -2,12 +2,12 @@ import { Brand, CustomField, DataroomBrand } from "@prisma/client";
import { E164Number } from "libphonenumber-js";
import { cn } from "@/lib/utils";
import { determineTextColor } from "@/lib/utils/determine-text-color";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { PhoneInput } from "@/components/ui/phone-input";
import { Textarea } from "@/components/ui/textarea";
import { useAccessFormTheme } from "./access-form-theme";
export default function CustomFieldsSection({
fields,
@@ -20,6 +20,8 @@ export default function CustomFieldsSection({
setData: (data: { [key: string]: string }) => void;
brand?: Partial<Brand> | Partial<DataroomBrand> | null;
}) {
const theme = useAccessFormTheme();
const handleInputChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
identifier: string,
@@ -53,9 +55,7 @@ export default function CustomFieldsSection({
<label
htmlFor={field.identifier}
className="block text-sm font-medium leading-6 text-white"
style={{
color: determineTextColor(brand?.accentColor),
}}
style={{ color: theme.textColor }}
>
{field.label}
</label>
@@ -73,17 +73,13 @@ export default function CustomFieldsSection({
"border-gray-600 data-[state=checked]:bg-gray-300 data-[state=checked]:text-black",
)}
style={{
borderColor: brand?.accentColor
? determineTextColor(brand.accentColor)
: undefined,
borderColor: theme.controlBorderColor,
}}
/>
<label
htmlFor={field.identifier}
className="cursor-pointer text-sm font-medium leading-none text-white peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
style={{
color: determineTextColor(brand?.accentColor),
}}
style={{ color: theme.textColor }}
>
{field.label}
{field.required && (
@@ -103,15 +99,13 @@ export default function CustomFieldsSection({
disabled={field.disabled}
translate="no"
className={cn(
"notranslate flex w-full rounded-md border-0 bg-black text-gray-500 placeholder:text-gray-400 sm:text-sm sm:leading-6",
"notranslate flex w-full cursor-text rounded-md border-0 bg-black text-gray-500 placeholder:text-[var(--access-placeholder)] sm:text-sm sm:leading-6",
)}
style={
{
"--phone-input-bg":
brand && brand.accentColor ? brand.accentColor : "black",
"--phone-input-color": determineTextColor(
brand?.accentColor,
),
"--phone-input-bg": theme.controlBgColor,
"--phone-input-color": theme.textColor,
"--access-placeholder": theme.controlPlaceholderColor,
} as React.CSSProperties
}
/>
@@ -144,16 +138,19 @@ export default function CustomFieldsSection({
disabled={field.disabled}
translate="no"
className={cn(
"notranslate flex w-full rounded-md border-0 bg-black py-1.5 text-gray-500 shadow-sm ring-1 ring-inset ring-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-gray-300 sm:text-sm sm:leading-6",
"notranslate flex w-full cursor-text rounded-md border-0 bg-black py-1.5 text-gray-500 shadow-sm ring-1 ring-inset ring-gray-600 placeholder:text-[var(--access-placeholder)] focus:ring-2 focus:ring-inset focus:ring-[var(--access-input-focus)] sm:text-sm sm:leading-6",
isLongText && "min-h-[100px] resize-none",
)}
style={{
backgroundColor:
brand && brand.accentColor
? brand.accentColor
: "black",
color: determineTextColor(brand?.accentColor),
}}
style={
{
backgroundColor: theme.controlBgColor,
borderColor: theme.controlBorderColor,
"--access-placeholder":
theme.controlPlaceholderColor,
"--access-input-focus": theme.controlBorderStrongColor,
color: theme.textColor,
} as React.CSSProperties
}
value={value}
placeholder={field.placeholder || ""}
onChange={(e) => handleInputChange(e, field.identifier!)}
+21 -15
View File
@@ -1,13 +1,20 @@
import { Dispatch, SetStateAction, useEffect, useRef, useState } from "react";
import {
Dispatch,
SetStateAction,
useEffect,
useRef,
useState,
type CSSProperties,
} from "react";
import { Brand, DataroomBrand } from "@prisma/client";
import { useDebouncedCallback } from "use-debounce";
import { cn } from "@/lib/utils";
import { determineTextColor } from "@/lib/utils/determine-text-color";
import { validateEmail } from "@/lib/utils/validate-email";
import { DEFAULT_ACCESS_FORM_TYPE } from ".";
import { useAccessFormTheme } from "./access-form-theme";
export default function EmailSection({
data,
@@ -25,6 +32,7 @@ export default function EmailSection({
onValidationChange: (isValid: boolean) => void;
}) {
const { email } = data;
const theme = useAccessFormTheme();
const [emailError, setEmailError] = useState<string | null>(null);
const [isDirty, setIsDirty] = useState(false);
@@ -95,9 +103,7 @@ export default function EmailSection({
<label
htmlFor="email"
className="block text-sm font-medium leading-6 text-white"
style={{
color: determineTextColor(brand?.accentColor),
}}
style={{ color: theme.textColor }}
>
Email address
</label>
@@ -111,16 +117,18 @@ export default function EmailSection({
required
translate="no"
className={cn(
"notranslate flex w-full rounded-md border-0 bg-black py-1.5 text-gray-500 shadow-sm ring-1 ring-inset ring-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-gray-300 sm:text-sm sm:leading-6",
"notranslate flex w-full cursor-text rounded-md border-0 bg-black py-1.5 text-gray-500 shadow-sm ring-1 ring-inset ring-gray-600 placeholder:text-[var(--access-placeholder)] focus:ring-2 focus:ring-inset focus:ring-[var(--access-input-focus)] sm:text-sm sm:leading-6",
emailError && isDirty && "ring-red-500",
)}
style={{
backgroundColor:
brand && brand.accentColor ? brand.accentColor : "black",
backgroundColor: theme.controlBgColor,
borderColor: theme.controlBorderColor,
"--access-placeholder": theme.controlPlaceholderColor,
"--access-input-focus": theme.controlBorderStrongColor,
color: disableEditEmail
? "hsl(var(--muted-foreground))"
: determineTextColor(brand?.accentColor),
}}
? theme.subtleTextColor
: theme.textColor,
} as CSSProperties}
value={email || ""}
placeholder="Enter email"
onChange={handleEmailChange}
@@ -136,14 +144,12 @@ export default function EmailSection({
<p
id="email-error"
className="mt-1 text-sm text-red-500"
style={{
color: determineTextColor(brand?.accentColor),
}}
style={{ color: theme.textColor }}
>
{emailError}
</p>
)}
<p className="text-sm text-gray-500">
<p className="text-sm" style={{ color: theme.subtleTextColor }}>
{useCustomAccessForm
? "This data will be shared with the content provider."
: "This data will be shared with the sender."}

Some files were not shown because too many files have changed in this diff Show More