Compare commits

..
343 Commits
Author SHA1 Message Date
Alex Turchyn 44dcc7f312 Merge from docusealco/wip 2026-09-28 14:39:31 +03:00
Pete Matsyburka 218c22da21 add ratelimit 2026-09-28 09:40:15 +03:00
Pete Matsyburka a8433e9bd3 adjust devise lock 2026-09-28 09:26:40 +03:00
Pete Matsyburka 800101f747 add i18n 2026-09-27 11:19:32 +03:00
Pete Matsyburka f637ee907c update routes 2026-09-27 09:43:48 +03:00
Pete Matsyburka 6a9b123cdc adjust verify pdf 2026-09-26 19:57:12 +03:00
Pete Matsyburka 0a2f8dfab3 validate 2fa email 2026-09-26 17:16:37 +03:00
Pete Matsyburka 3dc00ed7fc adjust 2fa rate limit 2026-09-26 17:09:36 +03:00
Pete Matsyburka af160b3f3f fix edit image 2026-09-26 17:07:47 +03:00
Pete Matsyburka 1a9e4026d9 guard test mode key only 2026-09-26 17:06:38 +03:00
Pete Matsyburka 5ef32bcf9e non prefillable types 2026-09-26 16:00:28 +03:00
Pete Matsyburka 2d6f4c521e refactor prefill 2026-09-26 15:17:33 +03:00
Pete Matsyburka 439f95d87e date formulas 2026-09-26 11:25:22 +03:00
Pete Matsyburka ca7b9671f2 refactor routes 2026-09-26 11:25:21 +03:00
Pete Matsyburka 20ec614d3a remove enquiries_controller 2026-09-25 17:18:29 +03:00
Pete Matsyburka 887ebbbcf6 adjust zoom 2026-09-25 17:06:49 +03:00
Pete Matsyburka 4225295295 fix editor 2026-09-25 10:36:26 +03:00
Pete Matsyburka 89367bc905 add i18n 2026-09-25 09:34:39 +03:00
Pete Matsyburka 4bacaa26e1 fix revisions 2026-09-25 08:52:35 +03:00
Pete Matsyburka 0f9fcf3fdc one field per party validation 2026-09-24 19:56:31 +03:00
Pete Matsyburka 437f61dc26 adjust html title 2026-09-24 16:13:25 +03:00
Pete Matsyburka e55542a000 adjust editor 2026-09-24 10:27:11 +03:00
Pete Matsyburka e004ac2d40 fix ui 2026-09-23 19:57:08 +03:00
Pete Matsyburka 92bfedd01d fix payment links 2026-09-23 18:48:24 +03:00
Pete Matsyburka e7fd1e4006 fix search button 2026-09-23 17:00:17 +03:00
Pete Matsyburka 56bba60d3f use open mobile modal 2026-09-23 16:38:05 +03:00
Pete Matsyburka 6b6a917f60 remove respond_to html 2026-09-23 11:00:18 +03:00
Pete Matsyburka df7b3f9dfe format html 2026-09-23 11:00:18 +03:00
Pete Matsyburka 1b21b0c65b adjust ui 2026-09-23 11:00:18 +03:00
Pete Matsyburka 258133d65c fix ci 2026-09-23 11:00:18 +03:00
Pete Matsyburka 8f33c8d8b6 adjust ui 2026-09-23 11:00:18 +03:00
Pete Matsyburka e11f4bddbe review fixes 2026-09-23 11:00:18 +03:00
Pete Matsyburka d071e8c429 ui fixes 2026-09-23 11:00:17 +03:00
Pete Matsyburka 79aa034183 native prefetch 2026-09-23 11:00:17 +03:00
Pete Matsyburka f5dad673de adjust ui 2026-09-23 11:00:17 +03:00
Pete Matsyburka 7e8375aee8 fix dropdowns 2026-09-23 11:00:17 +03:00
Pete Matsyburka 1999611b26 fix share modal 2026-09-23 11:00:17 +03:00
Pete Matsyburka 188c3a8c84 adjust template dropdown 2026-09-23 11:00:17 +03:00
Pete Matsyburka d35538e2ba adjust native 2026-09-23 11:00:17 +03:00
Pete Matsyburka a86f896694 remove focus lost 2026-09-23 11:00:17 +03:00
Pete Matsyburka 35eeeba0c3 adjust settings ui 2026-09-23 11:00:17 +03:00
Alex Turchyn e3e5c237d4 adjust mobile tables 2026-09-23 11:00:17 +03:00
Pete Matsyburka 9e89cb92a9 fix morph 2026-09-23 11:00:17 +03:00
Pete Matsyburka 827850646d use background color 2026-09-23 11:00:17 +03:00
Pete Matsyburka eafae4cda2 add haptics 2026-09-23 11:00:17 +03:00
Pete Matsyburka e012b7f5ac adjust title 2026-09-23 11:00:17 +03:00
Pete Matsyburka da9e7c6366 add send menu 2026-09-23 11:00:17 +03:00
Pete Matsyburka 20cc5d1e2e adjust mobile dropdown 2026-09-23 11:00:17 +03:00
Pete Matsyburka ae2cedbd70 drawer close 2026-09-23 11:00:17 +03:00
Pete Matsyburka 1f46f74400 adjust modal title 2026-09-23 11:00:17 +03:00
Pete Matsyburka 930445258d native dropdown 2026-09-23 11:00:17 +03:00
Pete Matsyburka be533e492e visit replace 2026-09-23 11:00:17 +03:00
Pete Matsyburka 66758d45bc adjust native 2026-09-23 11:00:17 +03:00
Alex Turchyn 6f31bd7af8 hide google drive in native app 2026-09-23 11:00:17 +03:00
Pete Matsyburka f4d2fa4c28 lock swipe 2026-09-23 11:00:17 +03:00
Pete Matsyburka 5d3369d556 fix download 2026-09-23 11:00:17 +03:00
Pete Matsyburka 5ea55c733f fix style 2026-09-23 11:00:17 +03:00
Pete Matsyburka 547bc9aa94 adjust mobile ui 2026-09-23 11:00:17 +03:00
Pete Matsyburka 53ca45a67a adjust flash 2026-09-23 11:00:17 +03:00
Pete Matsyburka ef28b80589 fix infinite scroll reload 2026-09-23 11:00:17 +03:00
Pete Matsyburka 05fa75f9a2 adjust downlaod 2026-09-23 11:00:17 +03:00
Pete Matsyburka 87b57279a4 update submission card 2026-09-23 11:00:17 +03:00
Pete Matsyburka f09732df90 share via copy 2026-09-23 11:00:17 +03:00
Pete Matsyburka a588c2f4a4 adjust signing form 2026-09-23 11:00:17 +03:00
Pete Matsyburka 960d36f628 add submission tabs 2026-09-23 11:00:17 +03:00
Pete Matsyburka 456dbae1cd use turbo remove 2026-09-23 11:00:17 +03:00
Pete Matsyburka 0fc1c0d1ed fix owerflow 2026-09-23 11:00:17 +03:00
Pete Matsyburka 714324ea99 native refresh 2026-09-23 11:00:17 +03:00
Pete Matsyburka 5a29ed22db turbo cache control 2026-09-23 11:00:17 +03:00
Pete Matsyburka 061580594c fix flash and morph 2026-09-23 11:00:17 +03:00
Alex Turchyn 8723ce7f7a migrate to turbo 8 2026-09-23 10:59:54 +03:00
Pete Matsyburka 522df7f476 fix overlap 2026-09-23 10:59:54 +03:00
Pete Matsyburka 026637c5ab show fields list on tablet 2026-09-23 10:59:54 +03:00
Pete Matsyburka fde993a579 adjust payment settings 2026-09-23 10:59:54 +03:00
Pete Matsyburka a960ea268a adjust builder 2026-09-23 10:59:54 +03:00
Pete Matsyburka d59776e8f5 adjust template preview 2026-09-23 10:59:54 +03:00
Pete Matsyburka 27e5c2c81e native modals 2026-09-23 10:59:54 +03:00
Alex Turchyn 0609a70544 upload native files via the page form 2026-09-23 10:59:54 +03:00
Pete Matsyburka 3ed15e383b adjust mobile builder 2026-09-23 10:59:54 +03:00
Pete Matsyburka d29be7c2cf add builder drawer 2026-09-23 10:59:54 +03:00
Pete Matsyburka 79f72c92b3 native platform prop 2026-09-23 10:59:54 +03:00
Pete Matsyburka 7e6331d1ad fix modal size 2026-09-23 10:59:54 +03:00
Pete Matsyburka 0986dfba73 fix native title 2026-09-23 10:59:54 +03:00
Alex Turchyn 7cf0b5aaf7 hide pro badge in the iOS app 2026-09-23 10:59:54 +03:00
Pete Matsyburka ae5f87f8fb add search 2026-09-23 10:59:54 +03:00
Pete Matsyburka 8259ebdb6c archived nav 2026-09-23 10:59:54 +03:00
Pete Matsyburka a4ca0abba1 adjsut card size 2026-09-23 10:59:54 +03:00
Pete Matsyburka ca009252ac rename template 2026-09-23 10:59:54 +03:00
Pete Matsyburka 9678cd1286 add sort menu 2026-09-23 10:59:54 +03:00
Pete Matsyburka 8cb0a7a020 add infinite scroll 2026-09-23 10:59:54 +03:00
Pete Matsyburka d3cc2e1ca8 adjust style 2026-09-23 10:59:54 +03:00
Pete Matsyburka 08ac60c7e6 adjust signign form 2026-09-23 10:59:54 +03:00
Alex Turchyn 49afb2e87c make template form preview native 2026-09-23 10:59:54 +03:00
Alex Turchyn be621339c3 expand native sheet when content overflows 2026-09-23 10:59:54 +03:00
Alex Turchyn 5f5b49a1ba open self sign form in the app 2026-09-23 10:59:54 +03:00
Alex Turchyn c0dd4b85ed hide pro badges only in iOS app 2026-09-23 10:59:54 +03:00
Alex Turchyn 95f7cd5220 ignore get forms in native-modal 2026-09-23 10:59:54 +03:00
Alex Turchyn f0753de99e hide pro badge in the native app 2026-09-23 10:59:54 +03:00
Pete Matsyburka 38ecda82fa adjust nav 2026-09-23 10:59:54 +03:00
Pete Matsyburka 073859a4b7 adjust nav 2026-09-23 10:59:54 +03:00
Pete Matsyburka 44972fcc39 fix folder nav 2026-09-23 10:59:54 +03:00
Pete Matsyburka 1232dfb248 fix folder nav 2026-09-23 10:59:54 +03:00
Pete Matsyburka 5de334522c adjust settings 2026-09-23 10:59:54 +03:00
Pete Matsyburka 7a6b4acea6 native navbar 2026-09-23 10:59:54 +03:00
Alex Turchyn 198a599a2b add native-modal element 2026-09-23 10:59:54 +03:00
Alex Turchyn d36f40cafd make template editor header native 2026-09-23 10:59:54 +03:00
Alex Turchyn 9abdb68bc5 make submission header buttons native 2026-09-23 10:59:54 +03:00
Alex Turchyn 359f695eb5 declare search for native app 2026-09-23 10:59:54 +03:00
Alex Turchyn 2b9e5a86b6 align native sheet padding with pages 2026-09-23 10:59:54 +03:00
Alex Turchyn c07d76e2a4 open all modals as sheets in the native app 2026-09-23 10:59:54 +03:00
Alex Turchyn f5b29642ee make template preferences button native 2026-09-23 10:59:54 +03:00
Alex Turchyn 3f6cdcd15b remove native-header element 2026-09-23 10:59:53 +03:00
Alex Turchyn bde8097288 add json sign in for native app 2026-09-23 10:59:53 +03:00
Alex Turchyn 617d75a4ed add native titles 2026-09-23 10:59:53 +03:00
Alex Turchyn aa1874cdb4 adjust more UI elevent for native app 2026-09-23 10:59:53 +03:00
Alex Turchyn 22b2e7475a add native app 2026-09-23 10:59:53 +03:00
Pete Matsyburka ad3367dae0 add date placeholder 2026-09-23 10:01:51 +03:00
Alex Turchyn 47c090e1f1 Merge from docusealco/wip 2026-09-21 14:53:39 +03:00
Pete Matsyburka 678997fb36 adjust embed script 2026-09-21 09:06:26 +03:00
Pete Matsyburka 24f195c7e5 normalize extension 2026-09-21 09:06:25 +03:00
Pete Matsyburka 5741bb4db2 normalize page index 2026-09-20 17:37:02 +03:00
Pete Matsyburka 1d169668f0 fix ci 2026-09-20 14:58:06 +03:00
Pete Matsyburka ef3d5bec7e mcp use template access 2026-09-20 13:30:28 +03:00
Pete Matsyburka d578ca3cc9 only dss changes 2026-09-20 13:30:28 +03:00
Pete Matsyburka 5432978c0d adjust load vips 2026-09-20 13:30:27 +03:00
Pete Matsyburka de1af49348 fix variable highlight 2026-09-18 09:18:50 +03:00
Alex Turchyn 2f0bcb4a80 hide tooltips on mobile 2026-09-17 23:45:59 +03:00
Pete Matsyburka 11f27c8cf0 reset rotation 2026-09-14 16:06:16 +03:00
Alex Turchyn 6e2430e5a2 Merge from docusealco/wip 2026-09-14 15:54:52 +03:00
Pete Matsyburka 85fbae1813 validate webhook header 2026-09-14 15:07:56 +03:00
Pete Matsyburka 9941122d90 adjust i18n 2026-09-14 11:08:40 +03:00
Pete Matsyburka 71be6db358 adjust delegated values 2026-09-12 18:34:39 +03:00
Pete Matsyburka 994c1b3155 keep pasted field roles 2026-09-12 18:29:56 +03:00
Pete Matsyburka 92b297a620 fix friendly name 2026-09-11 13:00:26 +03:00
Alex Turchyn bd03498b0f adjust show archived 2026-09-11 09:56:10 +03:00
Pete Matsyburka 660b24710e do not email archived user 2026-09-10 19:17:27 +03:00
Pete Matsyburka 1416cf72e8 sms retry 30 seconds 2026-09-10 15:29:27 +03:00
Alex Turchyn ae7b0ce608 fix settings collapse spacing 2026-09-09 16:09:19 +03:00
Pete Matsyburka a3828ec390 xlsx write native boolean 2026-09-09 13:35:20 +03:00
Pete Matsyburka b8c1691bd7 optimize xlsx export 2026-09-09 12:46:28 +03:00
Pete Matsyburka 4b00c896e4 reset signature on delegate 2026-09-08 18:52:12 +03:00
Pete Matsyburka 781da32a07 replace filename slashes 2026-09-08 13:53:35 +03:00
Pete Matsyburka 307036eae6 fix invite party 2026-09-08 09:12:47 +03:00
Pete Matsyburka 9944a9b1a5 adjust bind address 2026-09-07 17:57:07 +03:00
Alex Turchyn 11856a17ac Merge from docusealco/wip 2026-09-07 12:53:23 +03:00
Pete Matsyburka 7b4c74d711 safe page cycle 2026-09-06 19:25:34 +03:00
Pete Matsyburka e0b0d4e803 add authorize 2026-09-06 19:25:34 +03:00
Pete Matsyburka ec04a95e36 fix pdf validation 2026-09-06 09:58:19 +03:00
Pete Matsyburka 887d80f6d7 raw page size 2026-09-05 13:09:09 +03:00
Pete Matsyburka 9ad281d457 handle 2fa email bounce 2026-09-04 09:01:51 +03:00
Pete Matsyburka e316d2006f subject only email message 2026-09-03 18:59:33 +03:00
Pete Matsyburka f9669b0e16 process pdf v2 2026-09-03 11:48:54 +03:00
Pete Matsyburka 785eb789ad fix undo autosave false 2026-09-02 20:42:08 +03:00
Pete Matsyburka 436d947f6a detect fields autosave false 2026-09-02 20:08:42 +03:00
Pete Matsyburka 033ce3a169 fix text align 2026-09-02 20:02:57 +03:00
Alex Turchyn c216e43d24 Merge from docusealco/wip 2026-08-31 11:35:27 +03:00
Pete Matsyburka d2373c1578 fix normalize email 2026-08-31 10:13:17 +03:00
Pete Matsyburka 474603e1c9 hide list form buttons 2026-08-30 18:51:35 +03:00
Pete Matsyburka 1fc1444b8f optimize load bmp 2026-08-30 15:49:54 +03:00
Pete Matsyburka 47361da26f adjust validate required 2026-08-30 15:36:49 +03:00
Pete Matsyburka 11dea042ad adjust test mode auth 2026-08-30 12:07:05 +03:00
Pete Matsyburka 8756931638 render locked page 2026-08-30 12:01:54 +03:00
Pete Matsyburka 180b201dcf fix mfa rate limit 2026-08-30 12:01:54 +03:00
Pete Matsyburka a1c30c2093 update tools verify 2026-08-30 11:04:45 +03:00
Pete Matsyburka 66bf6af7ba adjust verify pdf signature 2026-08-30 10:56:34 +03:00
Pete Matsyburka 1a65e7bcff adjust download route 2026-08-29 21:53:01 +03:00
Pete Matsyburka 59b5f03948 fix buttons order 2026-08-29 20:18:34 +03:00
Pete Matsyburka 4626518c83 fix search 2026-08-29 20:06:14 +03:00
Pete Matsyburka c18e263f34 adjust verify pdf signature 2026-08-29 20:06:13 +03:00
Pete Matsyburka c3dae1bfe4 add size checks 2026-08-29 16:29:52 +03:00
Pete Matsyburka b44152fd52 fix search index 2026-08-29 16:29:52 +03:00
Pete Matsyburka 2889a7c937 squish name 2026-08-29 16:29:52 +03:00
Pete Matsyburka 91e65e4a19 adjust prefill 2026-08-29 16:29:52 +03:00
Pete Matsyburka 2a1a2cbd82 fix generation 2026-08-28 19:15:48 +03:00
Pete Matsyburka 99b3247cf7 archive account 2026-08-25 18:07:48 +03:00
Pete Matsyburka fa91c6d6be fix mobile filters 2026-08-25 15:12:19 +03:00
Alex Turchyn 27a0d10525 Merge from docusealco/wip 2026-08-25 13:46:49 +03:00
Pete Matsyburka d25f0812ca reset decline values 2026-08-24 16:38:05 +03:00
Pete Matsyburka 9e2aa6f8c8 pass fetch options 2026-08-24 14:43:13 +03:00
Pete Matsyburka 11c76bf554 refactor 2026-08-24 09:47:34 +03:00
Pete Matsyburka e5733d88c0 remove uuids scope 2026-08-23 22:15:02 +03:00
Pete Matsyburka 6d38c9f375 adjust resubmit 2026-08-23 21:23:27 +03:00
Pete Matsyburka 91ec29ad2c add i18n 2026-08-23 19:23:05 +03:00
Pete Matsyburka d374c7fa10 fix log 2026-08-23 19:04:18 +03:00
Pete Matsyburka ca0daf6d0b normalize 2fa email 2026-08-23 18:52:12 +03:00
Pete Matsyburka 076c3d805a fix find_or_initialize_submitter 2026-08-23 18:49:32 +03:00
Pete Matsyburka 1f149ac67a new form if fields changed 2026-08-23 18:01:00 +03:00
Pete Matsyburka 616f7b4fd8 adjsut resubmit 2026-08-23 17:48:04 +03:00
Pete Matsyburka 6f4527038b fix redirect 2026-08-23 17:38:17 +03:00
Pete Matsyburka 8ed2883504 adjust email 2fa form 2026-08-23 17:34:17 +03:00
Pete Matsyburka 651b84a460 fix redirect status 2026-08-23 17:24:44 +03:00
Pete Matsyburka 73c4f158ff add self source 2026-08-23 17:19:50 +03:00
Pete Matsyburka 6f1cc34433 adjust log 2026-08-23 16:51:21 +03:00
Pete Matsyburka a77029ffcb start form remember session 2026-08-23 16:51:15 +03:00
Pete Matsyburka 0480572ef5 refactor start form 2026-08-23 15:45:43 +03:00
Pete Matsyburka 807463c465 optimize shared link 2026-08-23 15:45:43 +03:00
Pete Matsyburka 0f61053976 remove secrets yml 2026-08-23 11:50:12 +03:00
Pete Matsyburka b8cf525034 fix cycle safe 2026-08-21 18:56:22 +03:00
Pete Matsyburka fef203daed adjust notification settings 2026-08-21 17:45:19 +03:00
Pete Matsyburka 41b38cc594 fix js error on redirect 2026-08-21 12:00:29 +03:00
Pete Matsyburka 4e4354af99 fix RTL reverse 2026-08-20 17:26:54 +03:00
Pete Matsyburka 113b61588b process pdf from doc 2026-08-20 13:21:24 +03:00
Pete Matsyburka c1aac6b763 add stats 2026-08-19 12:58:24 +03:00
Alex Turchyn f85fd3cff7 add personalization tooltips 2026-08-18 14:24:55 +03:00
Alex Turchyn 004a22c1c8 Merge from docusealco/wip 2026-08-17 12:31:40 +03:00
Pete Matsyburka 69343b1bdd fix send completed emails 2026-08-17 09:16:20 +03:00
Pete Matsyburka 81a306928c fix ci 2026-08-16 10:17:48 +03:00
Alex Turchyn 2bf3d42ed6 add translations 2026-08-16 09:51:41 +03:00
Pete Matsyburka eec08266c9 fix page preview open 2026-08-16 09:45:04 +03:00
Pete Matsyburka cbbc75b485 fix CI 2026-08-15 22:09:08 +03:00
Pete Matsyburka 3d93bbe5aa improve flatten 2026-08-15 21:08:51 +03:00
Pete Matsyburka 5308e3e15b process attachment v2 2026-08-15 21:08:35 +03:00
Pete Matsyburka 22c146d564 adjust pdfium 2026-08-15 19:08:54 +03:00
Pete Matsyburka c8d3af9841 import acro form 2026-08-15 19:08:54 +03:00
Pete Matsyburka ee1d45d461 update dep 2026-08-15 19:08:54 +03:00
Pete Matsyburka 27d075ed63 optimize merge pdf 2026-08-15 19:08:54 +03:00
Pete Matsyburka df63acf0c8 optimize decrypt 2026-08-15 19:08:52 +03:00
Alex Turchyn 185827c951 add Pdfium acro fields detector 2026-08-14 15:29:41 +03:00
Pete Matsyburka e361cbabf4 adjust verify signature 2026-08-14 15:29:41 +03:00
Pete Matsyburka e867bf5c48 use json serializer 2026-08-14 12:16:25 +03:00
Pete Matsyburka 726d0ea4f3 validate url 2026-08-13 15:46:43 +03:00
Pete Matsyburka 90be34b243 update gem 2026-08-11 22:05:14 +03:00
Pete Matsyburka e87c06d811 uniq bcc addresses 2026-08-11 17:23:28 +03:00
Alex Turchyn 54454d1dfc Merge from docusealco/wip 2026-08-11 17:16:45 +03:00
Pete Matsyburka 726174deff authorize reminder config 2026-08-11 08:55:17 +03:00
Pete Matsyburka 52eb8ac863 add log 2026-08-10 15:21:22 +03:00
Pete Matsyburka 97d91d8f27 fix view only download 2026-08-10 12:06:42 +03:00
Pete Matsyburka dd358a8401 fix infinite loop 2026-08-10 11:48:05 +03:00
Pete Matsyburka 8e254b7d3c fix infinite loop 2026-08-10 06:01:20 +03:00
Pete Matsyburka b9018fa442 add completed download scope 2026-08-09 20:14:31 +03:00
Pete Matsyburka 09310f3690 guard save template message 2026-08-09 19:53:19 +03:00
Pete Matsyburka 2dd38260ba guard email 2fa send 2026-08-09 19:51:07 +03:00
Pete Matsyburka ffb3247cdc add authorize user 2026-08-09 18:37:53 +03:00
Pete Matsyburka c59c470321 sanitize search query 2026-08-09 16:01:12 +03:00
Pete Matsyburka f35510ffd6 adjust with reason 2026-08-08 09:00:35 +03:00
Pete Matsyburka eeec852b3b adjust preview cache 2026-08-07 21:54:55 +03:00
Pete Matsyburka 485b7f2c8f fix dynamic variable parse 2026-08-07 14:19:28 +03:00
Alex Turchyn b6498ec049 add PayPal translations 2026-08-07 10:12:52 +03:00
Pete Matsyburka dc1de72e18 fix mysql migrations 2026-08-06 17:50:03 +03:00
Pete Matsyburka 7dddef4c94 fix date time formats 2026-08-04 11:07:13 +03:00
Alex Turchyn a2d8b85549 Merge from docusealco/wip 2026-08-03 12:53:50 +03:00
Pete Matsyburka 4d68783b82 add counters 2026-08-03 10:05:09 +03:00
Pete Matsyburka b05c3b2030 fix autocomplete 2026-08-03 09:41:08 +03:00
Pete Matsyburka 784ae9aa4b fix dynamic document preview 2026-08-03 08:19:35 +03:00
Pete Matsyburka 03b5956cbc bcc limit 2026-08-03 07:42:53 +03:00
Pete Matsyburka 2876c9a18e adjust pagination 2026-08-01 08:38:49 +03:00
Pete Matsyburka c4b7be3925 fix mobile tabs 2026-07-31 12:46:34 +03:00
Pete Matsyburka e2ffbdac1d update gem 2026-07-31 11:43:40 +03:00
Pete Matsyburka 0d197bb2eb fix mobile modal 2026-07-31 10:57:37 +03:00
Pete Matsyburka 55089bee7f adjust pagination 2026-07-31 10:53:34 +03:00
Pete Matsyburka b96fe74db1 adjust filters 2026-07-31 10:46:24 +03:00
Pete Matsyburka 2fc1ba3037 fix layout 2026-07-31 09:23:18 +03:00
Pete Matsyburka c67d37207e update deps 2026-07-30 20:51:46 +03:00
Pete Matsyburka dba50cd1bf mobile fixes 2026-07-30 20:21:40 +03:00
Pete Matsyburka edf2c0d9ed update gem 2026-07-30 18:47:44 +03:00
Pete Matsyburka 35f7c36f28 rename settings index 2026-07-30 18:13:55 +03:00
Pete Matsyburka 3a24ecac63 Revert "break long names in template and folder titles"
This reverts commit 59fe8a7917.
2026-07-30 18:13:55 +03:00
Pete Matsyburka 50ec7682b5 fix open modal mobile 2026-07-30 18:13:54 +03:00
Pete Matsyburka 3667993caf center settings footer 2026-07-30 16:54:36 +03:00
Alex Turchyn 68e8cd1188 fix settings nav offset 2026-07-30 16:45:56 +03:00
Alex Turchyn 3eebf6d2cf unify tooltip and breakpoint classes 2026-07-30 16:45:56 +03:00
Alex Turchyn 34d0e00910 use numeric keyboard for otp code inputs 2026-07-30 16:45:56 +03:00
Alex Turchyn b8ab7190d2 use dvh for modal and drawer 2026-07-30 16:45:56 +03:00
Alex Turchyn 59fe8a7917 break long names in template and folder titles 2026-07-30 16:45:56 +03:00
Alex Turchyn d56070827b fix broken svg 2026-07-30 16:45:56 +03:00
Alex Turchyn 74dbd4bed3 fix trusted signature text 2026-07-30 16:45:56 +03:00
Alex Turchyn 807e82b21e open modals as pages on mobile devices 2026-07-30 16:45:56 +03:00
Alex Turchyn 66862e93c8 unify breakpoint classes 2026-07-30 16:45:56 +03:00
Alex Turchyn 270461ad3b add mobile filters 2026-07-30 16:45:56 +03:00
Alex Turchyn 3edd1ab32d fix invalid classes and html attributes 2026-07-30 16:45:56 +03:00
Alex Turchyn e77998e53c scale down settings titles on mobile 2026-07-30 16:45:56 +03:00
Alex Turchyn 1421bc2bce fix horizontal scroll from preferences tooltip 2026-07-30 16:45:56 +03:00
Alex Turchyn cbbbfdda80 add mobile settings navigation 2026-07-30 16:45:56 +03:00
Alex Turchyn 2d510081be fix esign signature row layout 2026-07-30 16:45:56 +03:00
Alex Turchyn 704893ee1d show view archived link in mobile pagination 2026-07-30 16:45:56 +03:00
Alex Turchyn 88612913a6 wrap smtp security radios on small screens 2026-07-30 16:45:56 +03:00
Alex Turchyn ce6823ed38 hide tooltip wrappers with hidden content 2026-07-30 16:45:56 +03:00
Alex Turchyn 9074d23b3c fix uneven template action buttons 2026-07-30 16:45:56 +03:00
Alex Turchyn 76d0e76fd5 no touch tooltips on nav elements 2026-07-30 16:45:56 +03:00
Pete Matsyburka 5ec8e4ccc7 text formula 2026-07-30 11:55:59 +03:00
Alex TurchynandPete Matsyburka b8c29324b8 paypal configs
Co-authored-by: Pete Matsyburka <pete@docuseal.com>
2026-07-30 07:56:07 +03:00
Pete Matsyburka 972f38242e update rails 2026-07-29 21:53:03 +03:00
Alex Turchyn 7ce6c29f4d Merge from docusealco/wip 2026-07-27 15:56:18 +03:00
Pete Matsyburka 2c6ddd7f90 rate limit remove 2fa 2026-07-27 15:48:04 +03:00
Pete Matsyburka ae50b2e323 add i18n 2026-07-27 14:56:08 +03:00
Pete Matsyburka 152cbc11a6 fix specs 2026-07-26 17:44:01 +03:00
Pete Matsyburka 3a406de655 adjust release tag 2026-07-26 09:37:03 +03:00
Pete Matsyburka 2e31cda08c adjust url pattern 2026-07-26 08:26:32 +03:00
Pete Matsyburka 094b7f47bd set forwarded priority 2026-07-25 20:12:18 +03:00
Pete Matsyburka 99ac349ecc sanitize dynamic document attributes 2026-07-25 19:46:41 +03:00
Pete Matsyburka 8830e03cf3 adjust cert config 2026-07-25 14:46:45 +03:00
Pete Matsyburka 706f3d6d65 fix image load 2026-07-25 13:51:55 +03:00
Pete Matsyburka 12b49f5fa7 log os 2026-07-25 10:53:41 +03:00
Pete Matsyburka 85327760b9 refactor mcp 2026-07-25 10:04:44 +03:00
Pete Matsyburka c94ed9c3d4 update gem 2026-07-24 23:05:57 +03:00
Pete Matsyburka bc50735d2a update gem 2026-07-23 14:13:55 +03:00
Pete Matsyburka d05e125fa9 use :has selectors for search input 2026-07-23 11:13:10 +03:00
Alex Turchyn 48d595ca81 full width search input on mobile devices 2026-07-23 10:36:45 +03:00
Pete Matsyburka a156de5e75 optional initials 2026-07-22 08:45:20 +03:00
Pete Matsyburka a7003bf4d0 bump stars 2026-07-21 13:10:36 +03:00
Alex Turchyn 5fe75c84ff Merge from docusealco/wip 2026-07-20 14:01:14 +03:00
Alex Turchyn 4549a04fc5 disable turbo on sign out form 2026-07-18 14:27:45 +03:00
Pete Matsyburka 0ea6b912a3 file download variables 2026-07-17 12:30:15 +03:00
Pete Matsyburka 9b86fa3d40 add date formats config 2026-07-17 11:27:44 +03:00
Pete Matsyburka 374f66c59f send view only 2026-07-17 11:12:47 +03:00
Pete Matsyburka da43d881f1 edit message per 10 parties 2026-07-17 09:34:43 +03:00
Pete Matsyburka 9cbfa3a0ba fix condition on complete 2026-07-17 00:30:06 +03:00
Pete Matsyburka 77a925f33b fix invite form return to fields 2026-07-16 15:55:21 +03:00
Pete Matsyburka 3f7d7beedd fix sharing toggle 2026-07-15 10:55:04 +03:00
Pete Matsyburka 40d91df732 Revert "adjust shared query"
This reverts commit 56da326709.
2026-07-15 10:49:09 +03:00
Pete Matsyburka c8e783a210 multi select context menu readonly and required 2026-07-14 17:30:41 +03:00
Pete Matsyburka 61fc24d35d add with signature id completed config 2026-07-14 12:47:38 +03:00
Pete Matsyburka 73734b0de7 add variables_schema response 2026-07-13 19:34:09 +03:00
Pete Matsyburka 09bd831d54 adjust submission.name variable 2026-07-13 19:13:46 +03:00
Pete Matsyburka 2c9f1b999e update icon 2026-07-13 17:16:42 +03:00
Alex Turchyn 6a3a185310 Merge from docusealco/wip 2026-07-13 13:23:35 +03:00
Pete Matsyburka 7d381b7aee add reset smtp action 2026-07-13 10:58:08 +03:00
Alex Turchyn bba5626dea add ability to edit html email 2026-07-10 21:57:23 +03:00
Pete Matsyburka bdf5e58929 fix autocomplete index use 2026-07-10 10:32:04 +03:00
Pete Matsyburka 035168230e match generated font frontend 2026-07-09 15:44:34 +03:00
Pete Matsyburka 28168f082d adjust view only 2026-07-09 12:46:38 +03:00
Pete Matsyburka a29e09ed2f add submission update api 2026-07-08 11:16:56 +03:00
Pete Matsyburka e88874e757 view only party 2026-07-07 17:34:23 +03:00
Pete Matsyburka b6fb7c6977 fix find blob by checksum 2026-07-07 09:50:11 +03:00
Pete Matsyburka 8a60f17d13 optimize created_at filter 2026-07-07 09:41:37 +03:00
Pete Matsyburka e1d860a42c add submission created_at index 2026-07-07 09:14:15 +03:00
Pete Matsyburka 56da326709 adjust shared query 2026-07-06 18:45:37 +03:00
Pete Matsyburka 672008f8af add shared templates index param 2026-07-06 17:56:35 +03:00
Alex Turchyn d86b16de6b Merge from docusealco/wip 2026-07-06 13:25:54 +03:00
Pete Matsyburka d41710f507 fix spec 2026-07-04 10:12:08 +03:00
Pete Matsyburka 2b53166763 add completed check 2026-07-04 09:59:35 +03:00
Pete Matsyburka 4e56a16a58 adjust filters 2026-07-04 09:59:35 +03:00
Pete Matsyburka ac7cc77017 use submission completed_at 2026-07-04 09:59:35 +03:00
Pete Matsyburka 9194879c43 populate completed_at 2026-07-04 09:58:56 +03:00
Pete Matsyburka 1902cdaa55 submission completed_at 2026-07-04 09:58:56 +03:00
Alex Turchyn 6314987e69 make possible to unarchive completed submission 2026-07-04 09:57:29 +03:00
Pete Matsyburka 65bcbd7737 adjust authorize 2026-07-04 09:30:43 +03:00
Pete Matsyburka 9b25b8538e radio group font settings 2026-07-02 10:14:43 +03:00
Pete Matsyburka bbf8bb2a94 fix autocomplete index use 2026-07-01 13:06:00 +03:00
Pete Matsyburka 2085227cd9 fix dynamic document areas 2026-06-30 17:55:28 +03:00
Alex Turchyn 93e0730210 update docs 2026-06-30 13:31:31 +03:00
Pete Matsyburka 25c5c11ab3 change footer url 2026-06-30 08:39:26 +03:00
Pete Matsyburka 6ed5b5d35d extract email assets 2026-06-29 13:21:03 +03:00
430 changed files with 19699 additions and 6995 deletions

No files matched your search

+5
View File
@@ -1,6 +1,11 @@
---
EnableDefaultLinters: true
linters:
AllowedScriptType:
enabled: true
allowed_types:
- text/javascript
- application/json
ErbSafety:
enabled: false
HardCodedString:
+15 -25
View File
@@ -111,48 +111,39 @@ jobs:
rspec:
name: RSpec
runs-on: ubuntu-latest
timeout-minutes: 10
timeout-minutes: 15
container:
image: ruby:4.0.5-alpine
services:
postgres:
image: postgres:14
image: postgres:18
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: docuseal_test
ports: ["5432:5432"]
options: >-
--health-cmd pg_isready
--health-cmd "pg_isready -U postgres -d docuseal_test"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Install packages
run: apk add --no-cache build-base git libpq-dev yaml-dev nodejs yarn tar zstd gzip wget unzip vips leptonica chromium
- uses: actions/checkout@v4
- name: Install Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 4.0.5
- name: Set up Node
uses: actions/setup-node@v1
with:
node-version: 20.19.0
- name: Install Chrome
uses: browser-actions/setup-chrome@latest
with:
chrome-version: 125
- name: Cache node_modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-${{ hashFiles('**/yarn.lock') }}
key: ${{ runner.os }}-musl-node-${{ hashFiles('**/yarn.lock') }}
- name: Cache gems
uses: actions/cache@v4
with:
path: vendor/bundle
key: ${{ runner.os }}-gem-${{ hashFiles('**/Gemfile.lock') }}
key: ${{ runner.os }}-musl-gem-${{ hashFiles('**/Gemfile.lock') }}
restore-keys: |
${{ runner.os }}-gem-
${{ runner.os }}-musl-gem-
- name: Install dependencies
env:
RAILS_ENV: test
@@ -161,17 +152,16 @@ jobs:
bundle config path vendor/bundle
bundle install --jobs 4 --retry 4
yarn install
sudo apt-get update
sudo apt-get install -y libvips liblept5
wget -O pdfium-linux.tgz "https://github.com/bblanchon/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz"
sudo tar -xzf pdfium-linux.tgz --strip-components=1 -C /usr/lib lib/libpdfium.so
rm -f pdfium-linux.tgz
wget -O pdfium-linux.zip "https://github.com/docusealco/pdfium-binaries/releases/latest/download/pdfium-musl-$(uname -m).zip"
unzip -q pdfium-linux.zip -d /tmp/pdfium-linux
cp /tmp/pdfium-linux/lib/libpdfium.so /usr/lib/libpdfium.so
rm -rf pdfium-linux.zip /tmp/pdfium-linux
- name: Run
env:
RAILS_ENV: test
NODE_ENV: test
COVERAGE: true
DATABASE_URL: postgres://postgres:postgres@localhost:5432/docuseal_test
DATABASE_URL: postgres://postgres:postgres@postgres:5432/docuseal_test
run: |
bundle exec rake db:create
bundle exec rake db:migrate
+3 -1
View File
@@ -30,7 +30,9 @@ jobs:
uses: docker/setup-buildx-action@v3
- name: Create .version file
run: echo ${{ github.ref_name }} > .version
env:
REF_NAME: ${{ github.ref_name }}
run: echo "$REF_NAME" > .version
- name: Login to Docker Hub
uses: docker/login-action@v3
+3 -3
View File
@@ -9,10 +9,10 @@ RUN apk --no-cache add wget unzip && \
wget https://raw.githubusercontent.com/impallari/DancingScript/master/OFL.txt && \
wget https://raw.githubusercontent.com/notofonts/noto-fonts/refs/heads/main/LICENSE && \
wget -O /model.onnx "https://github.com/docusealco/fields-detection/releases/download/2.0.0/model_704_int8.onnx" && \
wget -O pdfium-linux.zip "https://github.com/docusealco/pdfium-binaries/releases/download/20260613/pdfium-musl-$(uname -m).zip" && \
wget -O pdfium-linux.zip "https://github.com/docusealco/pdfium-binaries/releases/download/20260920/pdfium-musl-$(uname -m).zip" && \
case "$(uname -m)" in \
x86_64) echo "2c953ff72ee2dda07e7fc577e25841cc3d6464468a7c5adfaea574efcbc3b90b pdfium-linux.zip" ;; \
aarch64) echo "23bbe287d2753fdb05741c7660647eb0ef0d2e4da2ce0722bfa9d9d455bd64e2 pdfium-linux.zip" ;; \
x86_64) echo "bbca8a648dbd1ba81f9c6c223f21dc394cf1ad381c6c17d60c6dcc4a7d3a2ae0 pdfium-linux.zip" ;; \
aarch64) echo "73b72ed79b9bfdd494dee7e863482c24971a8d4b11f6cd1289aa170d31304a1f pdfium-linux.zip" ;; \
esac | sha256sum -c - && \
mkdir -p /pdfium-linux && \
unzip -q pdfium-linux.zip -d /pdfium-linux
+2 -1
View File
@@ -36,7 +36,7 @@ gem 'rotp'
gem 'rouge', require: false
gem 'rqrcode', require: false
gem 'ruby-vips'
gem 'rubyXL', require: false
gem 'rubyzip', require: false
gem 'shakapacker'
gem 'sidekiq'
gem 'sqlite3', require: false
@@ -45,6 +45,7 @@ gem 'trilogy', require: false
gem 'turbo-rails'
gem 'twitter_cldr', require: false
gem 'tzinfo-data'
gem 'xlsxtream', require: false
group :development, :test do
gem 'better_html'
+88 -86
View File
@@ -1,31 +1,31 @@
GEM
remote: https://rubygems.org/
specs:
action_text-trix (2.1.18)
action_text-trix (2.1.19)
railties
actioncable (8.1.3)
actionpack (= 8.1.3)
activesupport (= 8.1.3)
actioncable (8.1.3.1)
actionpack (= 8.1.3.1)
activesupport (= 8.1.3.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (8.1.3)
actionpack (= 8.1.3)
activejob (= 8.1.3)
activerecord (= 8.1.3)
activestorage (= 8.1.3)
activesupport (= 8.1.3)
actionmailbox (8.1.3.1)
actionpack (= 8.1.3.1)
activejob (= 8.1.3.1)
activerecord (= 8.1.3.1)
activestorage (= 8.1.3.1)
activesupport (= 8.1.3.1)
mail (>= 2.8.0)
actionmailer (8.1.3)
actionpack (= 8.1.3)
actionview (= 8.1.3)
activejob (= 8.1.3)
activesupport (= 8.1.3)
actionmailer (8.1.3.1)
actionpack (= 8.1.3.1)
actionview (= 8.1.3.1)
activejob (= 8.1.3.1)
activesupport (= 8.1.3.1)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (8.1.3)
actionview (= 8.1.3)
activesupport (= 8.1.3)
actionpack (8.1.3.1)
actionview (= 8.1.3.1)
activesupport (= 8.1.3.1)
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
@@ -33,36 +33,36 @@ GEM
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (8.1.3)
actiontext (8.1.3.1)
action_text-trix (~> 2.1.15)
actionpack (= 8.1.3)
activerecord (= 8.1.3)
activestorage (= 8.1.3)
activesupport (= 8.1.3)
actionpack (= 8.1.3.1)
activerecord (= 8.1.3.1)
activestorage (= 8.1.3.1)
activesupport (= 8.1.3.1)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (8.1.3)
activesupport (= 8.1.3)
actionview (8.1.3.1)
activesupport (= 8.1.3.1)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (8.1.3)
activesupport (= 8.1.3)
activejob (8.1.3.1)
activesupport (= 8.1.3.1)
globalid (>= 0.3.6)
activemodel (8.1.3)
activesupport (= 8.1.3)
activerecord (8.1.3)
activemodel (= 8.1.3)
activesupport (= 8.1.3)
activemodel (8.1.3.1)
activesupport (= 8.1.3.1)
activerecord (8.1.3.1)
activemodel (= 8.1.3.1)
activesupport (= 8.1.3.1)
timeout (>= 0.4.0)
activestorage (8.1.3)
actionpack (= 8.1.3)
activejob (= 8.1.3)
activerecord (= 8.1.3)
activesupport (= 8.1.3)
activestorage (8.1.3.1)
actionpack (= 8.1.3.1)
activejob (= 8.1.3.1)
activerecord (= 8.1.3.1)
activesupport (= 8.1.3.1)
marcel (~> 1.0)
activesupport (8.1.3)
activesupport (8.1.3.1)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
@@ -144,12 +144,12 @@ GEM
cldr-plurals-runtime-rb (1.1.0)
cmdparse (3.0.7)
coderay (1.1.3)
concurrent-ruby (1.3.7)
concurrent-ruby (1.3.8)
connection_pool (3.0.2)
crack (1.0.1)
bigdecimal
rexml
crass (1.0.6)
crass (1.0.7)
csv (3.3.5)
csv-safe (3.3.1)
csv (~> 3.0)
@@ -179,7 +179,7 @@ GEM
dotenv (3.2.0)
drb (2.2.3)
email_typo (0.2.3)
erb (6.0.4)
erb (6.0.6)
erb_lint (0.9.0)
activesupport
better_html (>= 2.0.1)
@@ -217,7 +217,7 @@ GEM
foreman (0.90.0)
thor (~> 1.4)
geom2d (0.4.1)
globalid (1.3.0)
globalid (1.4.0)
activesupport (>= 6.1)
google-apis-core (1.0.2)
addressable (~> 2.8, >= 2.8.7)
@@ -262,7 +262,7 @@ GEM
geom2d (~> 0.4, >= 0.4.1)
openssl (>= 2.2.1)
strscan (>= 3.1.2)
i18n (1.14.8)
i18n (1.15.2)
concurrent-ruby (~> 1.0)
io-console (0.8.2)
irb (1.18.0)
@@ -271,7 +271,7 @@ GEM
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jmespath (1.6.2)
json (2.19.9)
json (2.21.2)
jwt (3.2.0)
base64
language_server-protocol (3.17.0.5)
@@ -293,27 +293,27 @@ GEM
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.25.1)
loofah (2.25.2)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.9.0)
mail (2.9.1)
logger
mini_mime (>= 0.1.1)
net-imap
net-pop
net-smtp
marcel (1.1.0)
marcel (1.2.1)
matrix (0.4.3)
method_source (1.1.0)
mini_mime (1.1.5)
minitest (6.0.6)
drb (~> 2.0)
prism (~> 1.5)
msgpack (1.8.0)
msgpack (1.8.4)
multi_json (1.19.1)
net-http (0.9.1)
uri (>= 0.11.1)
net-imap (0.6.4.1)
net-imap (0.6.6)
date
net-protocol
net-pop (0.1.2)
@@ -344,7 +344,7 @@ GEM
orm_adapter (0.5.0)
os (1.1.4)
package_json (0.2.0)
pagy (43.4.4)
pagy (43.6.1)
json
uri
yaml
@@ -357,7 +357,7 @@ GEM
pg (1.6.3-arm64-darwin)
pg (1.6.3-x86_64-linux)
pg (1.6.3-x86_64-linux-musl)
pp (0.6.3)
pp (0.6.4)
prettyprint
pretender (1.0.0)
actionpack (>= 7.2)
@@ -369,9 +369,6 @@ GEM
reline (>= 0.6.0)
pry-rails (0.3.11)
pry (>= 0.13.0)
psych (5.3.1)
date
stringio
public_suffix (7.0.5)
puma (8.0.2)
nio4r (~> 2.0)
@@ -386,33 +383,33 @@ GEM
rack (>= 1.3)
rackup (2.3.1)
rack (>= 3)
rails (8.1.3)
actioncable (= 8.1.3)
actionmailbox (= 8.1.3)
actionmailer (= 8.1.3)
actionpack (= 8.1.3)
actiontext (= 8.1.3)
actionview (= 8.1.3)
activejob (= 8.1.3)
activemodel (= 8.1.3)
activerecord (= 8.1.3)
activestorage (= 8.1.3)
activesupport (= 8.1.3)
rails (8.1.3.1)
actioncable (= 8.1.3.1)
actionmailbox (= 8.1.3.1)
actionmailer (= 8.1.3.1)
actionpack (= 8.1.3.1)
actiontext (= 8.1.3.1)
actionview (= 8.1.3.1)
activejob (= 8.1.3.1)
activemodel (= 8.1.3.1)
activerecord (= 8.1.3.1)
activestorage (= 8.1.3.1)
activesupport (= 8.1.3.1)
bundler (>= 1.15.0)
railties (= 8.1.3)
railties (= 8.1.3.1)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.7.0)
loofah (~> 2.25)
rails-html-sanitizer (1.7.1)
loofah (~> 2.25, >= 2.25.2)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails-i18n (8.1.0)
i18n (>= 0.7, < 2)
railties (>= 8.0.0, < 9)
railties (8.1.3)
actionpack (= 8.1.3)
activesupport (= 8.1.3)
railties (8.1.3.1)
actionpack (= 8.1.3.1)
activesupport (= 8.1.3.1)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
@@ -421,9 +418,14 @@ GEM
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.4.2)
rdoc (7.2.0)
rbs (4.1.0)
logger
prism (>= 1.6.0)
tsort
rdoc (8.0.0)
erb
psych (>= 4.0.0)
prism (>= 1.6.0)
rbs (>= 4.0.0)
tsort
redis-client (0.29.0)
connection_pool
@@ -495,9 +497,6 @@ GEM
ruby-vips (2.3.0)
ffi (~> 1.12)
logger
rubyXL (3.4.35)
nokogiri (>= 1.10.8)
rubyzip (>= 3.2.2)
rubyzip (3.2.2)
securerandom (0.4.1)
semantic_range (3.1.1)
@@ -525,12 +524,11 @@ GEM
simplecov-html (0.13.2)
simplecov_json_formatter (0.1.4)
smart_properties (1.17.0)
sqlite3 (2.9.2-aarch64-linux-gnu)
sqlite3 (2.9.2-aarch64-linux-musl)
sqlite3 (2.9.2-arm64-darwin)
sqlite3 (2.9.2-x86_64-linux-gnu)
sqlite3 (2.9.2-x86_64-linux-musl)
stringio (3.2.0)
sqlite3 (2.9.5-aarch64-linux-gnu)
sqlite3 (2.9.5-aarch64-linux-musl)
sqlite3 (2.9.5-arm64-darwin)
sqlite3 (2.9.5-x86_64-linux-gnu)
sqlite3 (2.9.5-x86_64-linux-musl)
strip_attributes (2.0.1)
activemodel (>= 3.0, < 9.0)
strscan (3.1.8)
@@ -570,14 +568,17 @@ GEM
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
webrick (1.9.2)
websocket-driver (0.8.0)
websocket-driver (0.8.2)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
xlsxtream (3.1.0)
zip_kit (>= 6.2, < 7)
xpath (3.2.0)
nokogiri (~> 1.8)
yaml (0.4.0)
zeitwerk (2.7.5)
zeitwerk (2.8.2)
zip_kit (6.3.4)
PLATFORMS
aarch64-linux
@@ -637,7 +638,7 @@ DEPENDENCIES
rubocop-rails
rubocop-rspec
ruby-vips
rubyXL
rubyzip
shakapacker
sidekiq
simplecov
@@ -649,6 +650,7 @@ DEPENDENCIES
tzinfo-data
web-console
webmock
xlsxtream
RUBY VERSION
ruby 4.0.5
@@ -37,7 +37,7 @@ class AccountConfigsController < ApplicationController
end
def destroy
raise InvalidKey unless allowed_keys.include?(@account_config.key)
raise InvalidKey unless allowed_destroy_keys.include?(@account_config.key)
@account_config.destroy!
@@ -50,6 +50,10 @@ class AccountConfigsController < ApplicationController
ALLOWED_KEYS
end
def allowed_destroy_keys
ALLOWED_KEYS
end
def load_account_config
raise InvalidKey unless allowed_keys.include?(account_config_params[:key])
+13 -9
View File
@@ -23,17 +23,20 @@ class AccountsController < ApplicationController
unless Docuseal.multitenant?
@encrypted_config = EncryptedConfig.find_or_initialize_by(account: current_account,
key: EncryptedConfig::APP_URL_KEY)
@encrypted_config.assign_attributes(app_url_params)
unless URI.parse(@encrypted_config.value.to_s).class.in?([URI::HTTP, URI::HTTPS])
@encrypted_config.errors.add(:value, I18n.t('should_be_a_valid_url'))
if can?(:manage, @encrypted_config)
@encrypted_config.assign_attributes(app_url_params)
return render :show, status: :unprocessable_content
unless URI.parse(@encrypted_config.value.to_s).class.in?([URI::HTTP, URI::HTTPS])
@encrypted_config.errors.add(:value, I18n.t('should_be_a_valid_url'))
return render :show, status: :unprocessable_content
end
@encrypted_config.save!
Docuseal.refresh_default_url_options!
end
@encrypted_config.save!
Docuseal.refresh_default_url_options!
end
with_locale do
@@ -47,7 +50,8 @@ class AccountsController < ApplicationController
authorize!(:manage, current_account)
true_user.skip_reconfirmation!
true_user.update!(locked_at: Time.current, email: true_user.email.sub('@', '+removed@'))
true_user.update!(locked_at: Time.current, archived_at: Time.current,
email: true_user.email.sub('@', '+removed@'))
true_user.account.update!(archived_at: Time.current)
# rubocop:disable Layout/LineLength
@@ -23,7 +23,7 @@ module Api
blob = ActiveStorage::Blob.find_by!(uuid: blob_uuid)
if Submitters::DANGEROUS_EXTENSIONS.include?(blob.filename.extension.to_s.downcase)
if FilenameUtils.dangerous_extension(blob.filename)
Rollbar.error('Dangerous extension') if defined?(Rollbar)
return head :unprocessable_content
@@ -19,7 +19,7 @@ module Api
return head :not_found unless blob
if Submitters::DANGEROUS_EXTENSIONS.include?(blob.filename.extension.to_s.downcase)
if FilenameUtils.dangerous_extension(blob.filename)
Rollbar.error('Dangerous extension') if defined?(Rollbar)
return head :unprocessable_content
@@ -9,7 +9,7 @@ module Api
(@submission.schema_documents || @submission.template.schema_documents).size > 1
documents =
if @submission.submitters.all?(&:completed_at?)
if @submission.completed_at?
build_completed_documents(@submission, merge: is_merge)
else
build_preview_documents(@submission, merge: is_merge)
@@ -30,7 +30,7 @@ module Api
private
def build_completed_documents(submission, merge: false)
last_submitter = submission.submitters.max_by(&:completed_at)
last_submitter = submission.submitters.select(&:completed_at?).max_by(&:completed_at)
if merge
if submission.merged_document_attachment.blank?
@@ -5,7 +5,7 @@ module Api
load_and_authorize_resource :submission, parent: false
def index
submissions = build_completed_query(@submissions)
submissions = @submissions.active.where.not(completed_at: nil)
params[:after] = Time.zone.at(params[:after].to_i) if params[:after].present?
params[:before] = Time.zone.at(params[:before].to_i) if params[:before].present?
@@ -36,20 +36,5 @@ module Api
}
}
end
private
def build_completed_query(submissions)
submissions = submissions.where(
Submitter.where(completed_at: nil).where(
Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id])
).select(1).arel.exists.not
)
submissions.joins(:submitters)
.group(:id)
.select(Submission.arel_table[Arel.star],
Submitter.arel_table[:completed_at].maximum.as('completed_at'))
end
end
end
+67 -19
View File
@@ -2,17 +2,19 @@
module Api
class SubmissionsController < ApiBaseController
SUBMISSION_COLUMNS = %i[id name slug source submitters_order expire_at created_at updated_at
SUBMISSION_COLUMNS = %i[id name slug source submitters_order expire_at completed_at created_at updated_at
archived_at variables template_id template_submitters created_by_user_id].freeze
TEMPLATE_COLUMNS = %i[id name external_id created_at updated_at folder_id submitters].freeze
load_and_authorize_resource :template, only: :create
load_and_authorize_resource :submission, only: %i[show index destroy]
load_and_authorize_resource :submission, only: %i[show index update destroy]
before_action only: :create do
authorize!(:create, Submission)
end
before_action :maybe_return_template_error, only: :create
def index
submissions = Submissions.search(current_user, @submissions, params[:q])
submissions = filter_submissions(submissions, params)
@@ -58,7 +60,7 @@ module Api
end
end
if @submission.audit_trail_attachment.blank? && submitters.all?(&:completed_at?)
if @submission.audit_trail_attachment.blank? && @submission.completed_at?
@submission.audit_trail_attachment = Submissions::EnsureAuditGenerated.call(@submission)
end
@@ -68,20 +70,6 @@ module Api
def create
Params::SubmissionCreateValidator.call(params)
return render json: { error: 'Template not found' }, status: :unprocessable_content if @template.nil?
if @template.archived_at?
Rollbar.warning("Archived template submission: #{@template.id}") if defined?(Rollbar)
return render json: { error: 'Template has been archived' }, status: :unprocessable_content
end
if @template.fields.blank?
Rollbar.warning("Template does not contain fields: #{@template.id}") if defined?(Rollbar)
return render json: { error: 'Template does not contain fields' }, status: :unprocessable_content
end
params[:send_email] = true unless params.key?(:send_email)
params[:send_sms] = false unless params.key?(:send_sms)
@@ -92,10 +80,17 @@ module Api
Submissions.send_signature_requests(submissions)
submissions.each do |submission|
if submission.submitters.all? { |s| s.viewer? || s.completed_at? } &&
Submissions.maybe_update_completed_at(submission)
last_submitter = submission.submitters.reject(&:viewer?).max_by(&:completed_at)
end
submission.submitters.each do |submitter|
next unless submitter.completed_at?
ProcessSubmitterCompletionJob.perform_async('submitter_id' => submitter.id, 'send_invitation_email' => false)
ProcessSubmitterCompletionJob.perform_async('submitter_id' => submitter.id,
'is_last' => submitter == last_submitter,
'send_invitation_email' => false)
end
end
@@ -109,6 +104,25 @@ module Api
render json: { error: e.message }, status: :unprocessable_content
end
def update
@submission = assign_submission_attrs(@submission, submission_params)
@submission.save!
if @submission.saved_change_to_archived_at? && @submission.archived_at?
WebhookUrls.enqueue_events(@submission, 'submission.archived')
end
if @submission.saved_change_to_expire_at? && @submission.expire_at?
ProcessSubmissionExpiredJob.perform_at(@submission.expire_at, 'submission_id' => @submission.id,
'expire_at' => @submission.expire_at.to_i)
end
SearchEntries.enqueue_reindex(@submission) if @submission.saved_change_to_name?
render json: Submissions::SerializeForApi.call(@submission, nil, params, with_events: false)
end
def destroy
if params[:permanently].in?(['true', true])
@submission.destroy!
@@ -123,8 +137,42 @@ module Api
private
def assign_submission_attrs(submission, attrs)
archived = attrs.key?(:archived) ? attrs[:archived] : attrs[:archived_at]
if archived.in?([true, false, 'true', 'false']) && current_ability.can?(:destroy, submission)
submission.archived_at = archived.in?(Submitters::TRUE_VALUES) ? Time.current : nil
end
submission.name = attrs[:name] if attrs.key?(:name)
submission.expire_at = attrs[:expire_at].presence if attrs.key?(:expire_at)
submission
end
def submission_params
submission_params = params.key?(:submission) ? params.require(:submission) : params
submission_params.permit(:name, :expire_at, :archived, :archived_at)
end
def maybe_return_template_error
return render json: { error: 'Template not found' }, status: :unprocessable_content if @template.nil?
if @template.archived_at?
Rollbar.warning("Archived template submission: #{@template.id}") if defined?(Rollbar)
return render json: { error: 'Template has been archived' }, status: :unprocessable_content
end
return if @template.fields.present?
Rollbar.warning("Template does not contain fields: #{@template.id}") if defined?(Rollbar)
render json: { error: 'Template does not contain fields' }, status: :unprocessable_content
end
def filter_submissions(submissions, params)
submissions = submissions.where(template_id: params[:template_id]) if params[:template_id].present?
submissions = submissions.where(slug: params[:slug]) if params[:slug].present?
if params[:template_folder].present?
@@ -1,10 +1,7 @@
# frozen_string_literal: true
module Api
class SubmitterEmailClicksController < ApiBaseController
skip_before_action :authenticate_user!
skip_authorization_check
class SubmitterEmailClicksController < ActionController::API
def create
@submitter = Submitter.find_by!(slug: params[:submitter_slug])
@@ -1,10 +1,7 @@
# frozen_string_literal: true
module Api
class SubmitterFormViewsController < ApiBaseController
skip_before_action :authenticate_user!
skip_authorization_check
class SubmitterFormViewsController < ActionController::API
def create
@submitter = Submitter.find_by!(slug: params[:submitter_slug])
+15 -9
View File
@@ -4,6 +4,8 @@ module Api
class SubmittersController < ApiBaseController
load_and_authorize_resource :submitter
before_action :maybe_return_submitter_error, only: :update
def index
submitters = Submitters.search(current_user, @submitters, params[:q])
@@ -36,14 +38,6 @@ module Api
# rubocop:disable Metrics/MethodLength
def update
if @submitter.completed_at?
return render json: { error: 'Submitter has already completed the submission.' }, status: :unprocessable_content
end
if @submitter.declined_at?
return render json: { error: 'Submitter has already declined the submission.' }, status: :unprocessable_content
end
submission = @submitter.submission
role = submission.template_submitters.find { |e| e['uuid'] == @submitter.uuid }['name']
@@ -73,7 +67,9 @@ module Api
end
if @submitter.completed_at?
ProcessSubmitterCompletionJob.perform_async('submitter_id' => @submitter.id)
is_last = Submissions.maybe_update_completed_at(@submitter.submission)
ProcessSubmitterCompletionJob.perform_async('submitter_id' => @submitter.id, 'is_last' => is_last)
elsif normalized_params[:send_email] || normalized_params[:send_sms]
Submitters.send_signature_requests([@submitter])
end
@@ -104,6 +100,16 @@ module Api
private
def maybe_return_submitter_error
if @submitter.completed_at? || @submitter.submission.completed_at?
return render json: { error: 'Submitter has already completed the submission.' }, status: :unprocessable_content
end
return unless @submitter.declined_at?
render json: { error: 'Submitter has already declined the submission.' }, status: :unprocessable_content
end
def maybe_filter_by_completed_at(submitters, params)
if params[:completed_after].present?
submitters = submitters.where(completed_at: Time.zone.parse(params[:completed_after])..)
+10 -2
View File
@@ -5,6 +5,8 @@ module Api
load_and_authorize_resource :template
def index
@templates = Templates.shared(current_user) if params[:shared].in?(['true', true])
templates = filter_templates(@templates, params)
templates = paginate(templates.preload(:author, folder: :parent_folder))
@@ -54,7 +56,7 @@ module Api
@template.update!(template_params)
SearchEntries.enqueue_reindex(@template)
SearchEntries.enqueue_reindex(@template) if @template.saved_change_to_name?
WebhookUrls.enqueue_events(@template, 'template.updated')
@@ -115,7 +117,13 @@ module Api
end
def filter_templates(templates, params)
templates = Templates.search(current_user, templates, params[:q])
templates =
if params[:shared].in?(['true', true])
Templates.search_shared(current_user, templates, params[:q])
else
Templates.search(current_user, templates, params[:q])
end
templates = params[:archived].in?(['true', true]) ? templates.archived : templates.active
templates = templates.where(external_id: params[:application_key]) if params[:application_key].present?
templates = templates.where(external_id: params[:external_id]) if params[:external_id].present?
+6 -7
View File
@@ -17,24 +17,23 @@ module Api
def verify
file = Base64.decode64(params[:file])
pdf = HexaPDF::Document.new(io: StringIO.new(file))
trusted_certs = Accounts.load_trusted_certs(current_account)
is_checksum_found = CompletedDocument.exists?(sha256: Base64.urlsafe_encode64(Digest::SHA256.digest(file)))
render json: {
checksum_status: is_checksum_found ? 'verified' : 'not_found',
signatures: pdf.signatures.map do |sig|
signatures: VerifyPdfSignature.call(StringIO.new(file), trusted_certs).map do |sig|
{
verification_result: sig.verify(trusted_certs:).messages,
signer_name: sig.signer_name,
signing_reason: sig.signing_reason,
verification_result: sig.messages.map { |m| { type: m.status || :info, content: m.text } },
signer_name: sig.common_name,
signing_reason: sig.reason,
signing_time: sig.signing_time,
signature_type: sig.signature_type
signature_type: sig.type
}
end
}
rescue HexaPDF::MalformedPDFError
rescue Pdfium::PdfiumError
render json: { error: 'Malformed PDF' }, status: :unprocessable_content
end
end
+29 -3
View File
@@ -13,13 +13,17 @@ class ApplicationController < ActionController::Base
before_action :maybe_redirect_to_setup, unless: :signed_in?
before_action :authenticate_user!, unless: :devise_controller?
before_action :set_csp, if: -> { request.get? && !request.headers['HTTP_X_TURBO'] }
before_action :set_csp, if: lambda {
request.get? && !request.headers['HTTP_X_TURBO_REQUEST_ID'] && !turbo_infinite_scroll?
}
helper_method :button_title,
:current_account,
:true_ability,
:form_link_host,
:svg_icon
:svg_icon,
:turbo_native_ios?,
:turbo_native_android?
impersonates :user, with: ->(uuid) { User.find_by(uuid:) }
@@ -30,7 +34,7 @@ class ApplicationController < ActionController::Base
rescue_from RateLimit::LimitApproached do |e|
Rollbar.error(e) if defined?(Rollbar)
redirect_to request.referer, alert: 'Too many requests', status: :too_many_requests
redirect_back_or_to root_path, alert: 'Too many requests'
end
if Rails.env.production? || Rails.env.test?
@@ -64,6 +68,28 @@ class ApplicationController < ActionController::Base
private
def turbo_infinite_scroll?
request.headers['HTTP_X_TURBO_INFINITE_SCROLL'].present?
end
def turbo_native_app?
return @turbo_native_app if defined?(@turbo_native_app)
@turbo_native_app = request.user_agent.to_s.include?('Hotwire Native')
end
def turbo_native_ios?
return @turbo_native_ios if defined?(@turbo_native_ios)
@turbo_native_ios = request.user_agent.to_s.include?('Hotwire Native iOS')
end
def turbo_native_android?
return @turbo_native_android if defined?(@turbo_native_android)
@turbo_native_android = request.user_agent.to_s.include?('Hotwire Native Android')
end
def with_locale(&)
return yield unless current_account
@@ -3,7 +3,7 @@
class EmailSmtpSettingsController < ApplicationController
before_action :load_encrypted_config
authorize_resource :encrypted_config, only: :index
authorize_resource :encrypted_config, parent: false, only: :create
authorize_resource :encrypted_config, parent: false, only: %i[create destroy]
def index; end
@@ -23,6 +23,12 @@ class EmailSmtpSettingsController < ApplicationController
render :index, status: :unprocessable_content
end
def destroy
@encrypted_config.destroy!
redirect_to settings_email_index_path, notice: I18n.t('smtp_settings_have_been_reset')
end
private
def load_encrypted_config
-22
View File
@@ -1,22 +0,0 @@
# frozen_string_literal: true
class EnquiriesController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
def create
if params[:talk_to_sales] == 'on'
Faraday.post(Docuseal::ENQUIRIES_URL,
enquiry_params.merge(type: :talk_to_sales).to_json,
'Content-Type' => 'application/json')
end
head :ok
end
private
def enquiry_params
params.require(:user).permit(:email)
end
end
+1 -2
View File
@@ -14,8 +14,7 @@ class EsignSettingsController < ApplicationController
prepend_before_action :maybe_redirect_com, only: %i[show]
before_action :load_encrypted_config
authorize_resource :encrypted_config, parent: false, only: %i[new create]
authorize_resource :encrypted_config, only: %i[update destroy show]
authorize_resource :encrypted_config, parent: false
def show
cert_data = @encrypted_config.value || {}
@@ -0,0 +1,92 @@
# frozen_string_literal: true
module Mcp
class CreateTemplateController < McpBaseController
SCHEMA = {
name: 'create_template',
title: 'Create Template',
description: 'Create a document template. Provide a URL to upload a PDF/DOCX file, or provide only a name ' \
'to create an empty template and receive an edit URL where the file can be uploaded via the UI.',
inputSchema: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'Template name (used as the template name and required when url is not provided)'
},
url: {
type: 'string',
description: 'Optional URL of a PDF or DOCX file to upload. If omitted, an empty template is ' \
'created and the returned edit_url can be used to upload a file via the UI.'
}
},
required: %w[name]
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: true
}
}.freeze
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
def call
account = current_user.account
@template = Template.new(
account:,
author: current_user,
folder: account.default_template_folder,
source: :mcp,
name: mcp_params['name'].to_s.presence || 'New Template',
fields: [],
schema: []
)
authorize!(:create, @template)
Templates.maybe_assign_access(@template)
if mcp_params['url'].present?
tempfile = Tempfile.new
tempfile.binmode
tempfile.write(DownloadUtils.call(mcp_params['url'], validate: true).body)
tempfile.rewind
filename = File.basename(URI.decode_www_form_component(mcp_params['url']))
file = ActionDispatch::Http::UploadedFile.new(
tempfile:,
filename:,
type: Marcel::MimeType.for(tempfile)
)
@template.name = mcp_params['name'].presence || File.basename(filename, '.*')
@template.save!
documents, = Templates::CreateAttachments.call(@template, { files: [file] }, extract_fields: true)
schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } }
if @template.fields.blank?
@template.fields = Templates::ProcessDocument.normalize_attachment_fields(@template, documents)
end
@template.update!(schema:)
else
@template.save!
end
WebhookUrls.enqueue_events(@template, 'template.created')
SearchEntries.enqueue_reindex(@template)
render_tool_result(
id: @template.id,
name: @template.name,
edit_url: edit_template_url(@template)
)
end
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
end
end
@@ -0,0 +1,54 @@
# frozen_string_literal: true
module Mcp
class LoadTemplateController < McpBaseController
SCHEMA = {
name: 'load_template',
title: 'Load Template',
description: 'Load a template with its fields. Each field includes name, type, and the signing role name.',
inputSchema: {
type: 'object',
properties: {
template_id: {
type: 'integer',
description: 'Template identifier'
}
},
required: %w[template_id]
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false
}
}.freeze
def call
@template = Template.accessible_by(current_ability).find(mcp_params['template_id'])
authorize!(:read, @template)
submitters_index = @template.submitters.index_by { |s| s['uuid'] }
roles = @template.submitters.pluck('name')
fields = @template.fields.filter_map do |field|
next if field['name'].blank?
{
name: field['name'],
type: field['type'],
role: submitters_index[field['submitter_uuid']]&.dig('name')
}
end
render_tool_result(
id: @template.id,
name: @template.name,
roles: roles,
fields: fields
)
end
end
end
@@ -0,0 +1,81 @@
# frozen_string_literal: true
module Mcp
class McpBaseController < ActionController::API
wrap_parameters false
before_action :authenticate_user!
before_action :verify_mcp_enabled!
check_authorization
before_action do
raise CanCan::AccessDenied unless can?(:manage, :mcp)
end
rescue_from CanCan::AccessDenied do
render_error(-32_603, 'Forbidden', status: :forbidden)
end
rescue_from ActiveRecord::RecordNotFound do
render_tool_error('Not found')
end
private
def default_url_options
Docuseal.default_url_options
end
def mcp_body
request.request_parameters
end
def mcp_params
mcp_body.dig('params', 'arguments') || {}
end
def render_result(result)
render json: { jsonrpc: '2.0', id: mcp_body['id'], result: }
end
def render_error(code, message, id: nil, status: :ok)
render json: { jsonrpc: '2.0', id:, error: { code:, message: } }, status:
end
def render_tool_result(data)
render_result(content: [{ type: 'text', text: data.to_json }])
end
def render_tool_error(message)
render_result(content: [{ type: 'text', text: message }], isError: true)
end
def authenticate_user!
render json: { error: 'Not authenticated' }, status: :unauthorized unless current_user
end
def verify_mcp_enabled!
return if Docuseal.multitenant?
return if AccountConfig.exists?(account_id: current_user.account_id,
key: AccountConfig::ENABLE_MCP_KEY,
value: true)
render json: { error: 'MCP is disabled' }, status: :forbidden
end
def current_user
@current_user ||= user_from_api_key
end
def user_from_api_key
token = request.headers['Authorization'].to_s[/\ABearer\s+(.+)\z/, 1]
return if token.blank?
sha256 = Digest::SHA256.hexdigest(token)
User.joins(:mcp_tokens).active.find_by(mcp_tokens: { sha256:, archived_at: nil })
end
end
end
@@ -0,0 +1,50 @@
# frozen_string_literal: true
module Mcp
class ProtocolController < McpBaseController
skip_authorization_check
def ok
head :ok
end
def initialize_request
render_result(
protocolVersion: '2025-11-25',
serverInfo: {
name: 'DocuSeal',
version: Docuseal.version.to_s
},
capabilities: {
tools: {
listChanged: false
}
}
)
end
def initialized_notification
head :accepted
end
def ping
render_result({})
end
def tools_list
render_result(tools: McpController::TOOLS)
end
def method_not_found
render_error(-32_601, "Method not found: #{mcp_body['method']}", id: mcp_body['id'])
end
def tool_not_found
render_error(-32_602, "Unknown tool: #{mcp_body.dig('params', 'name')}", id: mcp_body['id'])
end
def parse_error
render_error(-32_700, 'Parse error', status: :bad_request)
end
end
end
@@ -0,0 +1,59 @@
# frozen_string_literal: true
module Mcp
class SearchDocumentsController < McpBaseController
SCHEMA = {
name: 'search_documents',
title: 'Search Documents',
description: 'Search signed or pending documents by submitter name, email, phone, or template name',
inputSchema: {
type: 'object',
properties: {
q: {
type: 'string',
description: 'Search by submitter name, email, phone, or template name'
},
limit: {
type: 'integer',
description: 'The number of results to return (default 10)'
}
},
required: %w[q]
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false
}
}.freeze
def call
authorize!(:read, Submission)
submissions = Submissions.search(current_user, Submission.accessible_by(current_ability).active,
mcp_params['q'], search_template: true)
limit = mcp_params.fetch('limit', 10).to_i
limit = 10 if limit <= 0
limit = [limit, 100].min
submissions = submissions.preload(:submitters, :template)
.order(id: :desc)
.limit(limit)
data = submissions.map do |submission|
{
id: submission.id,
template_name: submission.template&.name,
status: Submissions::SerializeForApi.build_status(submission, submission.submitters),
submitters: submission.submitters.map do |s|
{ email: s.email, name: s.name, phone: s.phone, status: s.status }
end,
documents_url: submission_url(submission.id)
}
end
render_tool_result(data)
end
end
end
@@ -0,0 +1,44 @@
# frozen_string_literal: true
module Mcp
class SearchTemplatesController < McpBaseController
SCHEMA = {
name: 'search_templates',
title: 'Search Templates',
description: 'Search document templates by name',
inputSchema: {
type: 'object',
properties: {
q: {
type: 'string',
description: 'Search query to filter templates by name'
},
limit: {
type: 'integer',
description: 'The number of templates to return (default 10)'
}
},
required: %w[q]
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false
}
}.freeze
def call
authorize!(:read, Template)
templates = Templates.search(current_user, Template.accessible_by(current_ability).active, mcp_params['q'])
limit = mcp_params.fetch('limit', 10).to_i
limit = 10 if limit <= 0
limit = [limit, 100].min
templates = templates.order(id: :desc).limit(limit)
render_tool_result(templates.map { |t| { id: t.id, name: t.name } })
end
end
end
@@ -0,0 +1,120 @@
# frozen_string_literal: true
module Mcp
class SendDocumentsController < McpBaseController
SCHEMA = {
name: 'send_documents',
title: 'Send Documents',
description: 'Send a document template for signing to specified submitters',
inputSchema: {
type: 'object',
properties: {
template_id: {
type: 'integer',
description: 'Template identifier'
},
submitters: {
type: 'array',
description: 'The list of submitters (signers)',
items: {
type: 'object',
properties: {
email: {
type: 'string',
description: 'Submitter email address'
},
name: {
type: 'string',
description: 'Submitter name'
},
phone: {
type: 'string',
description: 'Submitter phone number in E.164 format'
},
role: {
type: 'string',
description: 'Signing role name from the template'
},
fields: {
type: 'array',
description: 'Prefill field values for this submitter (fields become readonly)',
items: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'Field name'
},
value: {
description: 'Prefilled value for the field'
}
},
required: %w[name value]
}
}
}
}
}
},
required: %w[template_id submitters]
},
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
openWorldHint: true
}
}.freeze
# rubocop:disable Metrics
def call
@template = Template.accessible_by(current_ability).find(mcp_params['template_id'])
authorize!(:read, @template)
return render_tool_error('Template has been archived') if @template.archived_at?
authorize!(:create, Submission.new(template: @template, account_id: current_user.account_id))
return render_tool_error('Template has no fields') if @template.fields.blank?
submitters = (mcp_params['submitters'] || []).map do |s|
attrs = s.slice('email', 'name', 'role', 'phone').compact_blank
fields = Array.wrap(s['fields']).filter_map do |f|
next if f['name'].blank?
{ 'name' => f['name'], 'default_value' => f['value'], 'readonly' => true }
end
attrs['fields'] = fields if fields.present?
attrs.with_indifferent_access
end
submissions = Submissions.create_from_submitters(
template: @template,
user: current_user,
source: :mcp,
submitters_order: @template.preferences['submitters_order'].presence || 'random',
submissions_attrs: { submitters: },
params: { 'send_email' => true, 'submitters' => submitters }
)
return render_tool_error('No valid submitters provided') if submissions.blank?
WebhookUrls.enqueue_events(submissions, 'submission.created')
Submissions.send_signature_requests(submissions)
SearchEntries.enqueue_reindex(submissions)
submission = submissions.first
render_tool_result(id: submission.id, status: 'pending')
rescue Submissions::CreateFromSubmitters::BaseError => e
render_tool_error(e.message)
end
# rubocop:enable Metrics
end
end
+31 -45
View File
@@ -1,58 +1,44 @@
# frozen_string_literal: true
class McpController < ActionController::API
before_action :authenticate_user!
before_action :verify_mcp_enabled!
class McpController < ActionController::Metal
TOOL_CONTROLLERS = {
'search_templates' => Mcp::SearchTemplatesController,
'load_template' => Mcp::LoadTemplateController,
'create_template' => Mcp::CreateTemplateController,
'send_documents' => Mcp::SendDocumentsController,
'search_documents' => Mcp::SearchDocumentsController
}.freeze
before_action do
authorize!(:manage, :mcp)
end
TOOLS = TOOL_CONTROLLERS.map { |_, controller| controller::SCHEMA }.freeze
def call
return head :ok if request.raw_post.blank?
return Mcp::ProtocolController.dispatch(:ok, request, response) if request.raw_post.blank?
body = JSON.parse(request.raw_post)
body = nil unless body.is_a?(Hash)
result = Mcp::HandleRequest.call(body, current_user, current_ability)
request.request_parameters = body || {}
if result
render json: result
else
head :accepted
end
rescue CanCan::AccessDenied
render json: { jsonrpc: '2.0', id: nil, error: { code: -32_603, message: 'Forbidden' } }, status: :forbidden
action =
case body&.dig('method')
when 'initialize' then :initialize_request
when 'notifications/initialized' then :initialized_notification
when 'ping' then :ping
when 'tools/list' then :tools_list
when 'tools/call'
tool = TOOL_CONTROLLERS[body.dig('params', 'name')]
return tool.dispatch(:call, request, response) if tool
:tool_not_found
else
:method_not_found
end
Mcp::ProtocolController.dispatch(action, request, response)
rescue JSON::ParserError
render json: { jsonrpc: '2.0', id: nil, error: { code: -32_700, message: 'Parse error' } }, status: :bad_request
end
request.request_parameters = {}
private
def authenticate_user!
render json: { error: 'Not authenticated' }, status: :unauthorized unless current_user
end
def verify_mcp_enabled!
return if Docuseal.multitenant?
return if AccountConfig.exists?(account_id: current_user.account_id,
key: AccountConfig::ENABLE_MCP_KEY,
value: true)
render json: { error: 'MCP is disabled' }, status: :forbidden
end
def current_user
@current_user ||= user_from_api_key
end
def user_from_api_key
token = request.headers['Authorization'].to_s[/\ABearer\s+(.+)\z/, 1]
return if token.blank?
sha256 = Digest::SHA256.hexdigest(token)
User.joins(:mcp_tokens).active.find_by(mcp_tokens: { sha256:, archived_at: nil })
Mcp::ProtocolController.dispatch(:parse_error, request, response)
end
end
+5 -1
View File
@@ -14,6 +14,8 @@ class MfaSetupController < ApplicationController
def edit; end
def create
RateLimit.call("mfa-setup-otp-#{current_user.id}", limit: 5, ttl: 5.minutes, enabled: true)
if current_user.validate_and_consume_otp!(params[:otp_attempt])
current_user.otp_required_for_login = true
current_user.save!
@@ -29,7 +31,9 @@ class MfaSetupController < ApplicationController
end
def destroy
if current_user.validate_and_consume_otp!(params[:otp_attempt])
RateLimit.call("mfa-setup-otp-#{current_user.id}", limit: 5, ttl: 5.minutes, enabled: true)
if current_user.valid_for_authentication? { current_user.validate_and_consume_otp!(params[:otp_attempt]) }
current_user.update!(otp_required_for_login: false, otp_secret: nil)
redirect_to settings_profile_index_path, notice: I18n.t('2fa_has_been_removed')
@@ -8,6 +8,7 @@ class NotificationsSettingsController < ApplicationController
before_action :build_account_config, only: :create
authorize_resource :account_config, only: :create
before_action :authorize_email_reminders!, only: :create
def index; end
@@ -21,6 +22,14 @@ class NotificationsSettingsController < ApplicationController
private
def authorize_email_reminders!
return unless Docuseal.multitenant?
return if @account_config.key != AccountConfig::SUBMITTER_REMINDERS
return if can?(:manage, :email_reminders)
redirect_back fallback_location: settings_notifications_path, alert: I18n.t('unlock_with_docuseal_pro')
end
def build_account_config
@account_config =
AccountConfig.find_or_initialize_by(account: current_account, key: email_config_params[:key])
@@ -5,6 +5,10 @@ class PreviewDocumentPageController < ActionController::API
FORMAT = Templates::ProcessDocument::FORMAT
TMPFILE_PREFIX = 'attachment-'
TMPFILE_TTL = 5.minutes
TMPFILE_MAX_TOTAL_SIZE = Docuseal.multitenant? ? 400.megabytes : 1.gigabyte
def show
result_data =
ApplicationRecord.signed_id_verifier.verified(params[:signed_key], purpose: :attachment)
@@ -22,42 +26,72 @@ class PreviewDocumentPageController < ActionController::API
@template = attachment.record
preview_image = attachment.preview_images.joins(:blob)
.find_by(blob: { filename: ["#{params[:id]}.png", "#{params[:id]}.jpg"] })
preview_image =
attachment.preview_images.joins(:blob)
.find_by(blob: { filename: ["#{params[:id].to_i}.png", "#{params[:id].to_i}.jpg"] })
if preview_image
return redirect_to preview_image.url(time: ActiveStorage::Attachment.service_url_time),
allow_other_host: true
end
file_path =
if attachment.service.name == :disk
ActiveStorage::Blob.service.path_for(attachment.key)
else
find_or_create_document_tempfile_path(attachment)
end
preview_image =
Templates::ProcessDocument.generate_pdf_preview_from_file(attachment, file_path, params[:id].to_i)
open_attachment_io(attachment) do |io|
Templates::ProcessDocument.generate_pdf_preview_from_io(attachment, io, params[:id].to_i)
end
redirect_to preview_image.url(time: ActiveStorage::Attachment.service_url_time), allow_other_host: true
end
def find_or_create_document_tempfile_path(attachment)
file_path = "#{Dir.tmpdir}/attachment-#{Digest::SHA1.hexdigest("#{attachment.id}-#{attachment.uuid}")}"
def open_attachment_io(attachment, &)
return File.open(ActiveStorage::Blob.service.path_for(attachment.key), 'rb', &) if attachment.service.name == :disk
File.open(file_path, File::RDWR | File::CREAT, 0o644) do |f|
f.flock(File::LOCK_EX)
file_name = "#{TMPFILE_PREFIX}#{Digest::SHA1.hexdigest("#{attachment.id}-#{attachment.uuid}")}"
file_path = File.join(Dir.tmpdir, file_name)
File.open(file_path, File::RDWR | File::CREAT, 0o644) do |file|
file.flock(File::LOCK_EX)
# rubocop:disable Style/ZeroLengthPredicate
if f.size.zero?
f.binmode
if file.size.zero?
cleanup_stale_tempfiles
f.write(attachment.download)
file.binmode
file.write(attachment.download)
else
FileUtils.touch(file_path)
end
# rubocop:enable Style/ZeroLengthPredicate
end
file_path
file.flock(File::LOCK_UN)
yield file
end
end
def cleanup_stale_tempfiles
entries =
Dir.glob(File.join(Dir.tmpdir, "#{TMPFILE_PREFIX}*")).filter_map do |path|
stat = File.stat(path)
[path, stat.mtime, stat.size]
rescue Errno::ENOENT
nil
end
total_size = entries.sum(&:last)
stale_time = TMPFILE_TTL.ago
entries.sort_by(&:second).each do |path, mtime, size|
break if mtime > stale_time && total_size <= TMPFILE_MAX_TOTAL_SIZE
File.unlink(path)
total_size -= size
rescue Errno::ENOENT
nil
end
end
end
+132
View File
@@ -0,0 +1,132 @@
# frozen_string_literal: true
class SearchController < ApplicationController
load_and_authorize_resource :template_folder, parent: false
load_and_authorize_resource :template, parent: false
load_and_authorize_resource :submission, parent: false
load_and_authorize_resource :template, instance_name: :filter_template, id_param: :template_id, parent: true
FOLDERS_LIMIT = 4
LIMIT = 12
def index
@query = params[:q].to_s.squish
return render_infinite_scroll(@template_folders, @templates, @submissions, @query) if turbo_infinite_scroll?
if @query.blank?
@template_folders = @templates = @submissions = []
elsif params[:scope] == 'folders'
@folders_pagy, @template_folders =
pagy(:countless, filter_folders(@template_folders, @templates, @query), limit: LIMIT)
@templates = @submissions = []
else
folders = filter_folders(@template_folders, @templates, @query)
@pagy, @template_folders, @templates, @submissions =
if params[:scope] == 'submissions'
load_submissions_page(folders, @templates, @submissions, @query)
else
load_templates_page(folders, @templates, @submissions, @query)
end
if @templates.blank? && @submissions.blank? && params[:template_id].blank?
@folders_pagy, @template_folders = pagy(:countless, folders, limit: LIMIT)
end
end
end
private
def load_submissions_page(folders, templates, submissions, query)
pagy, submissions = pagy(:countless, filter_submissions(submissions, query), limit: LIMIT)
return [pagy, [], [], submissions] if pagy.next || params[:template_id].present?
[pagy, folders.limit(FOLDERS_LIMIT).to_a, filter_templates(templates, query).limit(LIMIT).to_a, submissions]
end
def load_templates_page(folders, templates, submissions, query)
pagy, templates = pagy(:countless, filter_templates(templates, query), limit: LIMIT)
submissions = pagy.next ? [] : filter_submissions(submissions, query).limit(LIMIT).to_a
[pagy, folders.limit(FOLDERS_LIMIT).to_a, templates, submissions]
end
def render_infinite_scroll(folders, templates, submissions, query)
target, partial, collection =
case params[:scope]
when 'folders'
['search_folders', 'search/folder', filter_folders(folders, templates, query)]
when 'submissions'
['search_submissions', 'search/submission', filter_submissions(submissions, query)]
else
['search_templates', 'search/template', filter_templates(templates, query)]
end
pagy, records = pagy(:countless, collection, limit: LIMIT)
render turbo_stream: [
turbo_stream.append(target, partial:, collection: records),
turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: })
]
end
def filter_folders(folders, templates, query)
return folders.none if params[:archived] == 'true'
folders = folders.where.not(id: current_account.default_template_folder.id)
folders = folders.where(parent_folder_id: filter_folder_ids(params[:folder])) if params[:folder].present?
folders = folders.where(author_id: filter_author_id(params[:author])) if params[:author].present?
folders = TemplateFolders.filter_active_folders(folders, templates)
folders = TemplateFolders.search(folders, query)
folders.preload(:parent_folder).order(:name)
end
def filter_templates(templates, query)
templates = params[:archived] == 'true' ? templates.where.not(archived_at: nil) : templates.active
templates = templates.select_for_list.preload(:author, :template_accesses, folder: :parent_folder)
templates = templates.where(folder_id: filter_folder_ids(params[:folder])) if params[:folder].present?
templates = templates.where(author_id: filter_author_id(params[:author])) if params[:author].present?
Templates.search(current_user, templates, query).order(id: :desc)
end
def filter_folder_ids(folder_name)
TemplateFolders.filter_by_full_name(current_account.template_folders, folder_name)
.preload(:subfolders)
.flat_map { |folder| [folder.id, *folder.subfolders.map(&:id)] }
end
def filter_author_id(email)
current_account.users.find_by(email:)&.id || -1
end
def filter_submissions(submissions, query)
submissions = submissions.left_joins(:template)
submissions =
if params[:archived] == 'true'
submissions.where.not(archived_at: nil).or(submissions.where.not(templates: { archived_at: nil }))
else
submissions.where(archived_at: nil).where(templates: { archived_at: nil })
end
submissions = Submissions.search(current_user, submissions, query, search_template: true)
submissions = Submissions::Filter.call(submissions, current_user, params)
submissions =
if params[:status] == 'completed' || params[:completed_at_from].present? || params[:completed_at_to].present?
submissions.order(completed_at: :desc)
else
submissions.order(id: :desc)
end
submissions.select_for_list.preload(:submitters, :template)
end
end
@@ -16,18 +16,16 @@ class SendSubmissionEmailController < ApplicationController
@submitter =
Submitter.completed.where(submission: template.submissions).find_by(email: params[:email].to_s.downcase)
elsif params[:submission_slug]
submission = Submission.find_by(slug: params[:submission_slug])
submission = Submission.find_by!(slug: params[:submission_slug])
if submission
@submitter = Submitter.completed.find_by(submission: submission, email: params[:email].to_s.downcase)
end
@submitter = submission.submitters.order(:completed_at).find_by(email: params[:email].to_s.downcase)
return redirect_to submissions_preview_completed_path(params[:submission_slug], status: :error) unless @submitter
else
@submitter = Submitter.completed.find_by!(slug: params[:submitter_slug])
@submitter = Submitter.find_by!(slug: params[:submitter_slug])
end
if @submitter
if @submitter && completed_submitter?(@submitter)
RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes)
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! if can_send?(@submitter)
@@ -41,6 +39,10 @@ class SendSubmissionEmailController < ApplicationController
private
def completed_submitter?(submitter)
submitter.completed_at? || (submitter.viewer? && submitter.submission.completed_at?)
end
def can_send?(submitter)
return false if submitter.account.archived_at?
return false if EmailEvent.exists?(tag: :submitter_documents_copy, email: submitter.email, emailable: submitter,
+21 -3
View File
@@ -3,6 +3,10 @@
class SessionsController < Devise::SessionsController
before_action :configure_permitted_parameters
skip_before_action :verify_authenticity_token, if: -> { request.xhr? && request.format.json? }
respond_to :json, only: :create
around_action :with_browser_locale
def create
@@ -11,12 +15,26 @@ class SessionsController < Devise::SessionsController
if Docuseal.multitenant? && !User.exists?(email:)
Rollbar.warning('Sign in new user') if defined?(Rollbar)
return redirect_to new_registration_path(sign_up: true, user: sign_in_params.slice(:email)),
notice: I18n.t('create_a_new_account')
respond_to do |format|
format.html do
redirect_to new_registration_path(sign_up: true, user: sign_in_params.slice(:email)),
notice: t('create_a_new_account')
end
format.json do
render json: { sign_up: true, notice: t('create_a_new_account') }, status: :unprocessable_content
end
end
return
end
if User.exists?(email:, otp_required_for_login: true) && sign_in_params[:otp_attempt].blank?
return render :otp, locals: { resource: User.new(sign_in_params) }, status: :unprocessable_content
respond_to do |format|
format.html { render :otp, locals: { resource: User.new(sign_in_params) }, status: :unprocessable_content }
format.json { render json: { otp_required: true, email_otp: false }, status: :unprocessable_content }
end
return
end
super
+7
View File
@@ -0,0 +1,7 @@
# frozen_string_literal: true
class SettingsController < ApplicationController
skip_authorization_check
def index; end
end
+48 -129
View File
@@ -8,13 +8,9 @@ class StartFormController < ApplicationController
around_action :with_browser_locale, only: %i[show update completed]
before_action :maybe_redirect_com, only: %i[show completed]
before_action :load_resubmit_submitter, only: :update
before_action :load_template
before_action :authorize_start!, only: :update
COOKIES_TTL = 12.hours
COOKIES_DEFAULTS = { httponly: true, secure: Rails.env.production? }.freeze
def show
if @template.preferences['require_phone_2fa'] || @template.preferences['require_email_2fa']
raise ActionController::RoutingError, I18n.t('not_found')
@@ -23,8 +19,7 @@ class StartFormController < ApplicationController
if @template.shared_link?
@submitter = @template.submissions.new(account_id: @template.account_id)
.submitters.new(account_id: @template.account_id,
uuid: (filter_undefined_submitters(@template).first ||
@template.submitters.first)['uuid'])
uuid: Submitters::StartForm.first_submitter_uuid(@template))
render :email_verification if params[:email_verification]
else
Rollbar.warning("Not shared template: #{@template.id}") if defined?(Rollbar)
@@ -41,24 +36,28 @@ class StartFormController < ApplicationController
if @submitter.completed_at?
redirect_to start_form_completed_path(@template.slug, submitter_params.compact_blank)
else
if filter_undefined_submitters(@template).size > 1 && @submitter.new_record?
if Templates.filter_undefined_submitters(@template.submitters).size > 1 && @submitter.new_record?
@error_message = multiple_submitters_error_message
return render :show, status: :unprocessable_content
end
if (is_new_record = @submitter.new_record?)
assign_submission_attributes(@submitter, @template)
Submitters::StartForm.assign_submission_attributes(
@submitter, @template, ip: request.remote_ip, user_agent: request.user_agent
)
Submissions::AssignDefinedSubmitters.call(@submitter.submission)
else
@submitter.assign_attributes(ip: request.remote_ip, ua: request.user_agent)
end
if @template.preferences['shared_link_2fa'] == true
handle_require_2fa(@submitter, is_new_record:)
if require_link_2fa?(@template, @submitter)
handle_require_2fa(@template, @submitter)
elsif @submitter.errors.blank? && @submitter.save
enqueue_new_submitter_jobs(@submitter) if is_new_record
Submitters::StartForm.enqueue_new_submitter_jobs(@submitter) if is_new_record
Submitters::StartForm.assign_start_form_cookie(@submitter, request)
redirect_to submit_form_path(@submitter.slug)
else
@@ -88,38 +87,40 @@ class StartFormController < ApplicationController
private
def enqueue_new_submitter_jobs(submitter)
WebhookUrls.enqueue_events(submitter.submission, 'submission.created')
def find_or_initialize_submitter(template, submitter_params)
required_fields = template.preferences.fetch('link_form_fields', ['email'])
SearchEntries.enqueue_reindex(submitter)
blank_fields = required_fields.select { |key| submitter_params[key].blank? }
return unless submitter.submission.expire_at?
ProcessSubmissionExpiredJob.perform_at(submitter.submission.expire_at, 'submission_id' => submitter.submission_id)
end
def load_resubmit_submitter
@resubmit_submitter =
if params[:resubmit].present? && !params[:resubmit].in?([true, 'true'])
submitter = Submitter.find_by(slug: params[:resubmit])
submitter if submitter && can_resubmit?(submitter)
submitter =
if blank_fields.present?
Submitter.new(submitter_params)
else
Submitters::StartForm.find_or_initialize_submitter(
template, submitter_params, exclude_completed: params[:resubmit].present?,
request:, current_user:
)
end
blank_fields.each { |key| submitter.errors.add(key.to_sym, :blank) }
submitter
end
def can_resubmit?(submitter)
submitter.completed_at? && submitter.completed_at > 14.days.ago &&
%w[api embed mcp].exclude?(submitter.submission.source) &&
submitter.account.account_configs.find_or_initialize_by(key: AccountConfig::ALLOW_TO_RESUBMIT).value != false
def require_link_2fa?(template, submitter)
return true if template.preferences['shared_link_2fa'] == true
return false if cookies.encrypted[:start_form_slug] == submitter.slug
return false if current_user && submitter.email == current_user.email &&
current_user.account_id == submitter.account_id
!submitter.new_record?
end
def authorize_start!
is_archived = @template.archived_at? || @template.account.archived_at?
return redirect_to submit_form_path(@resubmit_submitter.slug) if @resubmit_submitter && is_archived
return redirect_to start_form_path(@template.slug) if is_archived
return if @resubmit_submitter
return if @template.shared_link? || (current_user && current_ability.can?(:read, @template))
Rollbar.warning("Not shared template: #{@template.id}") if defined?(Rollbar)
@@ -127,89 +128,14 @@ class StartFormController < ApplicationController
redirect_to start_form_path(@template.slug)
end
def find_or_initialize_submitter(template, submitter_params)
required_fields = template.preferences.fetch('link_form_fields', ['email'])
required_params = required_fields.index_with { |key| submitter_params[key] }
find_params = required_params.except('name')
submitter = Submitter.new if find_params.compact_blank.blank?
submitter ||=
Submitter
.where(submission: template.submissions.where(expire_at: Time.current..)
.or(template.submissions.where(expire_at: nil)).where(archived_at: nil))
.order(id: :desc)
.where(declined_at: nil)
.where(external_id: nil)
.where(template.preferences['shared_link_2fa'] == true ? {} : { ip: [nil, request.remote_ip] })
.then { |rel| params[:resubmit].present? || params[:selfsign].present? ? rel.where(completed_at: nil) : rel }
.find_or_initialize_by(find_params)
submitter.name = required_params['name'] if submitter.new_record?
unless @resubmit_submitter
required_params.each do |key, value|
submitter.errors.add(key.to_sym, :blank) if value.blank?
end
end
submitter
end
def assign_submission_attributes(submitter, template)
submitter.assign_attributes(
uuid: (filter_undefined_submitters(template).first || @template.submitters.first)['uuid'],
ip: request.remote_ip,
ua: request.user_agent,
values: @resubmit_submitter&.preferences&.fetch('default_values', nil) || {},
preferences: @resubmit_submitter&.preferences.presence || { 'send_email' => true },
metadata: @resubmit_submitter&.metadata.presence || {}
)
submitter.assign_attributes(@resubmit_submitter.slice(:name, :email, :phone)) if @resubmit_submitter
if submitter.values.present?
@resubmit_submitter.attachments.each do |attachment|
submitter.attachments << attachment.dup if submitter.values.value?(attachment.uuid)
end
end
submitter.submission ||= Submission.new(template:,
account_id: template.account_id,
template_submitters: template.submitters,
expire_at: Templates.build_default_expire_at(template),
submitters: [submitter],
source: :link)
Submissions::CreateFromSubmitters.maybe_set_dynamic_documents(submitter.submission)
submitter.account_id = submitter.submission.account_id
submitter
end
def filter_undefined_submitters(template)
Templates.filter_undefined_submitters(template.submitters)
end
def submitter_params
return { 'email' => current_user.email, 'name' => current_user.full_name } if params[:selfsign]
return @resubmit_submitter.slice(:name, :phone, :email) if @resubmit_submitter.present?
params.require(:submitter).permit(:email, :phone, :name).tap do |attrs|
attrs[:email] = Submissions.normalize_email(attrs[:email])
end
end
def load_template
@template =
if @resubmit_submitter
@resubmit_submitter.template
else
Template.find_by!(slug: params[:slug] || params[:start_form_slug])
end
@template = Template.find_by!(slug: params[:slug] || params[:start_form_slug])
end
def multiple_submitters_error_message
@@ -220,38 +146,31 @@ class StartFormController < ApplicationController
end
end
def handle_require_2fa(submitter, is_new_record:)
def handle_require_2fa(template, submitter)
return render :show, status: :unprocessable_content if submitter.errors.present?
is_otp_verified = Submitters.verify_link_otp!(params[:one_time_code], submitter)
if cookies.encrypted[:email_2fa_slug] == submitter.slug || is_otp_verified
if submitter.save
enqueue_new_submitter_jobs(submitter) if is_new_record
if is_otp_verified
SubmissionEvents.create_with_tracking_data(submitter, 'email_verified', request)
cookies.encrypted[:email_2fa_slug] =
{ value: submitter.slug, expires: COOKIES_TTL.from_now, **COOKIES_DEFAULTS }
end
redirect_to submit_form_path(submitter.slug)
else
render :show, status: :unprocessable_content
end
if Submitters::StartForm.verify_2fa_and_save_submitter(submitter, request, is_new_record: submitter.new_record?)
redirect_to submit_form_path(submitter.slug)
else
if defined?(Rollbar) && template.preferences['shared_link_2fa'] != true
Rollbar.info("2FA link requested: #{submitter.id}")
end
Submitters.send_shared_link_email_verification_code(submitter, request:)
render :email_verification
end
rescue Submitters::StartForm::NotSaved
render :show, status: :unprocessable_content
rescue Submitters::BouncedEmail => e
@error_message = e.message
render :error, status: :unprocessable_content
rescue Submitters::UnableToSendCode, Submitters::InvalidOtp => e
redirect_to start_form_path(submitter.submission.template.slug,
params: submitter_params.merge(email_verification: true)),
redirect_to start_form_path(template.slug, params: submitter_params.merge(email_verification: true)),
alert: e.message
rescue RateLimit::LimitApproached
redirect_to start_form_path(submitter.submission.template.slug,
params: submitter_params.merge(email_verification: true)),
redirect_to start_form_path(template.slug, params: submitter_params.merge(email_verification: true)),
alert: I18n.t(:too_many_attempts)
end
end
@@ -1,23 +1,34 @@
# frozen_string_literal: true
class StartFormEmail2faSendController < ApplicationController
layout 'form'
around_action :with_browser_locale
skip_before_action :authenticate_user!
skip_authorization_check
def create
@template = Template.find_by!(slug: params[:slug])
before_action :load_template
before_action :authorize_start!
def create
@submitter = @template.submissions.new(account_id: @template.account_id)
.submitters.new(**submitter_params, account_id: @template.account_id)
if @submitter.email.blank?
return redirect_to start_form_path(@template.slug), alert: I18n.t(:provide_your_email_to_start)
end
Submitters.send_shared_link_email_verification_code(@submitter, request:)
redir_params = { notice: I18n.t(:code_has_been_resent) } if params[:resend]
redirect_to start_form_path(@template.slug, params: submitter_params.merge(email_verification: true)),
**redir_params
rescue Submitters::BouncedEmail => e
@error_message = e.message
render 'start_form/error', status: :unprocessable_content
rescue Submitters::UnableToSendCode => e
redirect_to start_form_path(@template.slug, params: submitter_params.merge(email_verification: true)),
alert: e.message
@@ -25,7 +36,34 @@ class StartFormEmail2faSendController < ApplicationController
private
def load_template
@template = Template.find_by!(slug: params[:slug])
end
def authorize_start!
is_archived = @template.archived_at? || @template.account.archived_at?
return redirect_to start_form_path(@template.slug) if is_archived
return if (@template.shared_link? || (current_user && current_ability.can?(:read, @template))) &&
(@template.preferences['shared_link_2fa'] == true || submitter_exists?(@template, params))
Rollbar.warning("Not 2FA shared template: #{@template.id}") if defined?(Rollbar)
redirect_to start_form_path(@template.slug)
end
def submitter_exists?(template, params)
email = Submissions.normalize_email(params.dig(:submitter, :email))
return false if email.blank?
Submitters::StartForm.build_submitters_scope(template, exclude_completed: true).exists?(email:)
end
def submitter_params
params.require(:submitter).permit(:name, :email, :phone)
params.require(:submitter).permit(:name, :email, :phone).tap do |attrs|
attrs[:email] = Submissions.normalize_email(attrs[:email])
end
end
end
@@ -0,0 +1,103 @@
# frozen_string_literal: true
class StartFormResubmitController < ApplicationController
layout 'form'
skip_before_action :authenticate_user!
skip_authorization_check
around_action :with_browser_locale
before_action :load_resubmit_submitter
before_action :load_template
before_action :authorize_start!
def update
@submitter = find_or_initialize_submitter(@template, @resubmit_submitter)
if Templates.filter_undefined_submitters(@template.submitters).size > 1 && @submitter.new_record?
@error_message = multiple_submitters_error_message
return render 'start_form/show', status: :unprocessable_content
end
if (is_new_record = @submitter.new_record?)
Submitters::StartForm.assign_submission_attributes(
@submitter, @template,
ip: request.remote_ip, user_agent: request.user_agent, resubmit_submitter: @resubmit_submitter,
source: @resubmit_submitter.submission.source_self? ? :self : :link
)
Submissions::AssignDefinedSubmitters.call(@submitter.submission)
else
@submitter.assign_attributes(ip: request.remote_ip, ua: request.user_agent)
end
if @template.preferences['shared_link_2fa'] == true
handle_require_2fa(@submitter)
elsif @submitter.save
Submitters::StartForm.enqueue_new_submitter_jobs(@submitter) if is_new_record
Submitters::StartForm.assign_start_form_cookie(@submitter, request)
redirect_to submit_form_path(@submitter.slug)
else
render 'start_form/show', status: :unprocessable_content
end
end
private
def find_or_initialize_submitter(template, resubmit_submitter)
Submitters::StartForm.build_submitters_scope(template, exclude_completed: true)
.find_by(slug: cookies.encrypted[:start_form_slug],
email: resubmit_submitter.email) || Submitter.new
end
def load_resubmit_submitter
@resubmit_submitter = Submitter.find_by(slug: params[:resubmit])
raise ActiveRecord::RecordNotFound if @resubmit_submitter.blank? ||
!Submitters::StartForm.can_resubmit?(@resubmit_submitter)
end
def load_template
@template = @resubmit_submitter.template
end
def authorize_start!
redirect_to submit_form_path(@resubmit_submitter.slug) if @template.archived_at? || @template.account.archived_at?
end
def multiple_submitters_error_message
if current_user&.account_id == @template.account_id
helpers.t('this_submission_has_multiple_signers_which_prevents_the_use_of_a_sharing_link_html')
else
I18n.t('not_found')
end
end
def handle_require_2fa(submitter)
if Submitters::StartForm.verify_2fa_and_save_submitter(submitter, request,
is_new_record: submitter.new_record?)
redirect_to submit_form_path(submitter.slug)
else
Submitters.send_shared_link_email_verification_code(submitter, request:)
render 'start_form/email_verification'
end
rescue Submitters::StartForm::NotSaved
render 'start_form/show', status: :unprocessable_content
rescue Submitters::BouncedEmail => e
@error_message = e.message
render 'start_form/error', status: :unprocessable_content
rescue Submitters::UnableToSendCode, Submitters::InvalidOtp => e
flash.now[:alert] = e.message
render 'start_form/email_verification'
rescue RateLimit::LimitApproached
flash.now[:alert] = I18n.t(:too_many_attempts)
render 'start_form/email_verification'
end
end
@@ -0,0 +1,50 @@
# frozen_string_literal: true
class StartFormSelfController < ApplicationController
load_resource :template, parent: false
around_action :with_browser_locale
before_action :authorize_start!
def update
@submitter = Submitters::StartForm.build_submitters_scope(@template, exclude_completed: true, source: :self)
.find_or_initialize_by(email: current_user.email)
@submitter = Submitter.new(email: current_user.email) if @submitter.persisted? &&
outdated_template_fields?(@submitter, @template)
if (is_new_record = @submitter.new_record?)
@submitter.name = current_user.full_name
Submitters::StartForm.assign_submission_attributes(
@submitter, @template, ip: request.remote_ip, user_agent: request.user_agent, source: :self
)
Submissions::AssignDefinedSubmitters.call(@submitter.submission)
else
@submitter.assign_attributes(ip: request.remote_ip, ua: request.user_agent)
end
@submitter.save!
Submitters::StartForm.enqueue_new_submitter_jobs(@submitter) if is_new_record
Submitters::StartForm.assign_start_form_cookie(@submitter, request)
redirect_to submit_form_path(@submitter.slug)
end
private
def outdated_template_fields?(submitter, template)
template_fields = submitter.submission.template_fields
template_fields.present? && template_fields != template.fields
end
def authorize_start!
authorize!(:read, @template)
redirect_to template_path(@template) if @template.archived_at?
end
end
@@ -24,5 +24,7 @@ class SubmissionEventsController < ApplicationController
load_and_authorize_resource :submission
def index; end
def index
render :index, layout: 'plain'
end
end
@@ -12,11 +12,12 @@ class SubmissionsArchivedController < ApplicationController
@submissions = Submissions.search(current_user, @submissions, params[:q], search_template: true)
@submissions = Submissions::Filter.call(@submissions, current_user, params)
@submissions = if params[:completed_at_from].present? || params[:completed_at_to].present?
@submissions.order(Submitter.arel_table[:completed_at].maximum.desc)
else
@submissions.order(id: :desc)
end
@submissions =
if params[:status] == 'completed' || params[:completed_at_from].present? || params[:completed_at_to].present?
@submissions.order(completed_at: :desc)
else
@submissions.order(id: :desc)
end
@pagy, @submissions = pagy_auto(@submissions.select_for_list.preload(submitters: :start_form_submission_events))
@@ -28,5 +29,17 @@ class SubmissionsArchivedController < ApplicationController
ActiveRecord::Associations::Preloader.new(records: @submissions.filter_map(&:template),
associations: :author).call
render_infinite_scroll(@pagy, @submissions) if turbo_infinite_scroll?
end
private
def render_infinite_scroll(pagy, submissions)
render turbo_stream: [
turbo_stream.before('infinite_scroll', partial: 'templates/submission', collection: submissions,
locals: { with_template: true, archived: true }),
turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: })
]
end
end
+43 -3
View File
@@ -18,10 +18,20 @@ class SubmissionsController < ApplicationController
'payment' => 'credit_card', 'verification' => 'id', 'kba' => 'user_scan'
}.freeze
STATUS_BADGES = {
'awaiting' => 'badge-info', 'sent' => 'badge-info', 'completed' => 'badge-success',
'opened' => 'badge-warning', 'declined' => 'badge-error'
}.freeze
STATUS_ICONS = {
'awaiting' => 'clock', 'sent' => 'send', 'completed' => 'circle_check',
'opened' => 'mail_opened', 'declined' => 'x_circle'
}.freeze
def show
@submission = Submissions.preload_with_pages(@submission)
unless @submission.submitters.all?(&:completed_at?)
unless @submission.completed_at?
ActiveRecord::Associations::Preloader.new(
records: [@submission],
associations: [{ submitters: :start_form_submission_events }]
@@ -33,12 +43,14 @@ class SubmissionsController < ApplicationController
def new
authorize!(:new, Submission)
render :new, layout: 'plain'
end
def create
return redirect_to template_path(@template), alert: I18n.t('template_has_been_archived') if @template.archived_at?
save_template_message(@template, params) if params[:save_message] == '1'
save_template_message(@template, params) if params[:save_message] == '1' && can?(:update, @template)
[params.delete(:subject), params.delete(:body)] if params[:is_custom_message] != '1'
@@ -81,12 +93,21 @@ class SubmissionsController < ApplicationController
I18n.t('submission_has_been_archived')
end
redirect_back(fallback_location: @submission.template_id ? template_path(@submission.template) : root_path, notice:)
if turbo_native_app? && request.format.turbo_stream?
flash.now[:notice] = notice
render turbo_stream: [turbo_stream.remove(@submission), turbo_stream.append_all('body', partial: 'shared/flash')]
else
redirect_back(fallback_location: @submission.template_id ? template_path(@submission.template) : root_path,
notice:)
end
end
private
def create_submissions(template, submissions_params, params)
normalize_message_submitter_uuids!(params)
submissions_attrs = submissions_params[:submission].to_h.values
submissions_attrs, _, new_fields =
@@ -111,4 +132,23 @@ class SubmissionsController < ApplicationController
def submissions_params
params.permit(submission: { submitters: [:uuid, :email, :phone, :name, { values: {} }] })
end
def normalize_message_submitter_uuids!(params)
return if params[:request_email_per_submitter] == '1'
uuids = params[:email_message_submitter_uuids]
return if uuids.blank?
return if params[:subject].blank? && params[:body].blank?
params[:submitter_preferences] =
Array.wrap(uuids).index_with { { 'subject' => params[:subject], 'body' => params[:body] } }
params[:request_email_per_submitter] = '1'
params.delete(:subject)
params.delete(:body)
params
end
end
@@ -13,11 +13,12 @@ class SubmissionsDashboardController < ApplicationController
@submissions = Submissions.search(current_user, @submissions, params[:q], search_template: true)
@submissions = Submissions::Filter.call(@submissions, current_user, params)
@submissions = if params[:completed_at_from].present? || params[:completed_at_to].present?
@submissions.order(Submitter.arel_table[:completed_at].maximum.desc)
else
@submissions.order(id: :desc)
end
@submissions =
if params[:status] == 'completed' || params[:completed_at_from].present? || params[:completed_at_to].present?
@submissions.order(completed_at: :desc)
else
@submissions.order(id: :desc)
end
@pagy, @submissions = pagy_auto(@submissions.select_for_list.preload(submitters: :start_form_submission_events))
@@ -29,5 +30,17 @@ class SubmissionsDashboardController < ApplicationController
ActiveRecord::Associations::Preloader.new(records: @submissions.filter_map(&:template),
associations: :author).call
render_infinite_scroll(@pagy, @submissions) if turbo_infinite_scroll?
end
private
def render_infinite_scroll(pagy, submissions)
render turbo_stream: [
turbo_stream.before('infinite_scroll', partial: 'templates/submission', collection: submissions,
locals: { with_template: true }),
turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: })
]
end
end
@@ -25,7 +25,7 @@ class SubmissionsPreviewController < ApplicationController
raise ActionController::RoutingError, I18n.t('not_found') if @submission.account.archived_at?
if !@submission.submitters.all?(&:completed_at?) && !signature_valid &&
if !@submission.completed_at? && !signature_valid &&
(!current_user || !current_ability.can?(:read, @submission))
raise ActionController::RoutingError, I18n.t('not_found')
end
@@ -4,7 +4,7 @@ class SubmissionsUnarchiveController < ApplicationController
load_and_authorize_resource :submission
def create
authorize!(:update, @submission)
authorize!(:destroy, @submission)
@submission.update!(archived_at: nil)
@@ -0,0 +1,13 @@
# frozen_string_literal: true
class SubmitFormClickEmailController < ActionController::API
def create
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
if params[:t].present? && params[:t] == SubmissionEvents.build_tracking_param(@submitter, 'click_email')
SubmissionEvents.create_with_tracking_data(@submitter, 'click_email', request)
end
render json: {}
end
end
@@ -10,27 +10,26 @@ class SubmitFormCompletedDownloadController < ApplicationController
def index
@submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present?
signature_valid =
if @submitter&.slug == submitter_slug
true
else
@submitter = nil
end
signature_valid = @submitter&.slug == submitter_slug
@submitter ||= Submitter.find_by!(slug: submitter_slug)
@submitter = Submitter.find_by!(slug: submitter_slug) unless signature_valid
Submissions::EnsureResultGenerated.call(@submitter)
unless completed_submitter?(@submitter)
Rollbar.error("Not completed: #{@submitter.id}") if defined?(Rollbar)
last_submitter = @submitter.submission.submitters.where.not(completed_at: nil).order(:completed_at).last
return head :not_found
end
Submissions::EnsureResultGenerated.call(@submitter) if @submitter.completed_at?
last_submitter = @submitter.submission.submitters.completed.order(:completed_at).last
return head :not_found unless last_submitter
Submissions::EnsureResultGenerated.call(last_submitter)
if !signature_valid && !current_user_submitter?(last_submitter)
unless Submitters::AuthorizedForForm.call(@submitter, current_user, request)
Rollbar.info("2FA download error: #{last_submitter.id}") if defined?(Rollbar)
unless Submitters::AuthorizedForForm.call(@submitter, current_user, request, with_order: true)
return head :not_found
end
@@ -51,7 +50,7 @@ class SubmitFormCompletedDownloadController < ApplicationController
private
def submitter_slug
params[:submit_form_slug] || params[:submitter_slug] || params[:submitter_id]
params[:submit_form_slug] || params[:submitter_slug]
end
def respond_with_combined(submitter)
@@ -64,6 +63,10 @@ class SubmitFormCompletedDownloadController < ApplicationController
end
end
def completed_submitter?(submitter)
submitter.completed_at? || (submitter.viewer? && submitter.submission.completed_at?)
end
def current_user_submitter?(submitter)
current_user && current_ability.can?(:read, submitter)
end
+23 -14
View File
@@ -18,7 +18,10 @@ class SubmitFormController < ApplicationController
submission = @submitter.submission
return render :email_2fa unless Submitters::AuthorizedForForm.pass_email_2fa?(@submitter, request)
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
if @submitter.completed_at? || submission.completed_at?
return redirect_to submit_form_completed_path(@submitter.slug)
end
@form_configs = Submitters::FormConfigs.call(@submitter, CONFIG_KEYS)
@@ -28,24 +31,22 @@ class SubmitFormController < ApplicationController
Submissions.preload_with_pages(submission)
Submitters::MaybeUpdateDefaultValues.call(@submitter, current_user)
@attachments_index = build_attachments_index(submission)
if current_user && current_user.email == @submitter.email
@current_user_data = current_user.as_json(only: %i[first_name last_name], methods: %i[full_name])
initials = UserConfigs.load_initials(current_user)
@prefill_initials = Submitters.build_prefill_attachment_data(@submitter, initials) if initials
end
return unless @form_configs[:prefill_signature]
if (user_signature = UserConfigs.load_signature(current_user))
@signature_attachment = ActiveStorage::Attachment.find_or_create_by!(
blob_id: user_signature.blob_id,
name: 'attachments',
record: @submitter
)
end
signature = UserConfigs.load_signature(current_user) ||
Submitters::FindRememberedSignature.call(@submitter, params, cookies)
@signature_attachment ||=
Submitters::MaybeAssignDefaultBrowserSignature.call(@submitter, params, cookies, @attachments_index.values)
@attachments_index[@signature_attachment.uuid] = @signature_attachment if @signature_attachment
@prefill_signature = Submitters.build_prefill_attachment_data(@submitter, signature) if signature
end
def update
@@ -71,6 +72,12 @@ class SubmitFormController < ApplicationController
status: :unprocessable_content
end
if @submitter.viewer?
Rollbar.warning("Submit viewer: #{@submitter.id}") if defined?(Rollbar)
return render json: { error: I18n.t('form_is_view_only') }, status: :unprocessable_content
end
Submitters::SubmitValues.call(@submitter, params, request)
head :ok
@@ -98,6 +105,8 @@ class SubmitFormController < ApplicationController
submitter_version = SubmitterVersion.find_by!(slug: params[:slug] || params[:submit_form_slug])
@submitter = submitter_version.submitter
maybe_render_locked_page
end
private
@@ -13,19 +13,21 @@ class SubmitFormDeclineController < ApplicationController
@submitter.submission.archived_at? ||
@submitter.submission.expired? ||
@submitter.submission.template&.archived_at? ||
@submitter.viewer? ||
!Submitters::AuthorizedForForm.call(@submitter,
current_user,
request)
ApplicationRecord.transaction do
@submitter.update!(declined_at: Time.current)
@submitter.update!(declined_at: Time.current, values: @submitter.preferences['default_values'] || {})
SubmissionEvents.create_with_tracking_data(@submitter, 'decline_form', request, { reason: params[:reason] })
end
user = @submitter.submission.created_by_user || @submitter.template.author
if user.user_configs.find_by(key: UserConfig::RECEIVE_DECLINED_EMAIL)&.value != false
if Users.send_emails?(user) &&
user.user_configs.find_by(key: UserConfig::RECEIVE_DECLINED_EMAIL)&.value != false
SubmitterMailer.declined_email(@submitter, user).deliver_later!
end
@@ -12,9 +12,11 @@ class SubmitFormDelegateController < ApplicationController
@submitter.submission.archived_at? ||
@submitter.submission.expired? ||
@submitter.submission.template&.archived_at? ||
@submitter.viewer? ||
!Submitters::AuthorizedForForm.call(@submitter,
current_user,
request)
request,
with_order: true)
@submitter.account.account_configs.find_by!(key: AccountConfig::ALLOW_TO_DELEGATE_KEY, value: true)
@@ -31,7 +33,8 @@ class SubmitFormDelegateController < ApplicationController
SubmissionEvents.create_with_tracking_data(@submitter, 'delegate_form', request,
{ old_email: @submitter.email, email: })
@submitter.update!(email:, phone: nil, name: nil, slug: SecureRandom.base58(14))
@submitter.update!(email:, phone: nil, name: nil, slug: SecureRandom.base58(14),
values: Submitters.fetch_values_for_delegate(@submitter))
end
SendSubmitterInvitationEmailJob.perform_async('submitter_id' => @submitter.id)
@@ -14,11 +14,13 @@ class SubmitFormDownloadController < ApplicationController
return head :unprocessable_content if @submitter.declined_at? ||
@submitter.submission.archived_at? ||
@submitter.submission.expired? ||
@submitter.submission.completed_at? ||
@submitter.submission.template&.archived_at? ||
AccountConfig.exists?(account_id: @submitter.account_id,
key: AccountConfig::ALLOW_TO_PARTIAL_DOWNLOAD_KEY,
value: false) ||
!Submitters::AuthorizedForForm.call(@submitter, current_user, request)
!Submitters::AuthorizedForForm.call(@submitter, current_user, request,
with_order: true)
last_completed_submitter = @submitter.submission.submitters
.where.not(id: @submitter.id)
@@ -12,8 +12,10 @@ class SubmitFormDrawSignatureController < ApplicationController
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
return redirect_to submit_form_path(@submitter.slug) if @submitter.viewer?
if @submitter.submission.template&.archived_at? || @submitter.submission.archived_at? ||
!Submitters::AuthorizedForForm.call(@submitter, current_user, request)
!Submitters::AuthorizedForForm.call(@submitter, current_user, request, with_order: true)
return redirect_to submit_form_path(@submitter.slug)
end
@@ -8,11 +8,10 @@ class SubmitFormEmail2fasController < ApplicationController
before_action :load_submitter
COOKIES_TTL = 12.hours
COOKIES_DEFAULTS = { httponly: true, secure: Rails.env.production? }.freeze
def create
RateLimit.call("verify-2fa-code-#{@submitter.id}", limit: 2, ttl: 45.seconds, enabled: true)
RateLimit.call("verify-2fa-code-1h-#{@submitter.id}", limit: 10, ttl: 1.hour, enabled: true)
RateLimit.call("verify-2fa-code-1d-#{@submitter.id}", limit: 20, ttl: 1.day, enabled: true)
value = [@submitter.email.downcase.strip, @submitter.slug].join(':')
@@ -20,7 +19,8 @@ class SubmitFormEmail2fasController < ApplicationController
SubmissionEvents.create_with_tracking_data(@submitter, 'email_verified', request, { email: @submitter.email })
cookies.encrypted[:email_2fa_slug] =
{ value: @submitter.slug, expires: COOKIES_TTL.from_now, **COOKIES_DEFAULTS }
{ value: @submitter.slug, expires: Submitters::StartForm::COOKIES_TTL.from_now,
**Submitters::StartForm::COOKIES_DEFAULTS }
redirect_to submit_form_path(@submitter.slug)
else
@@ -37,6 +37,12 @@ class SubmitFormEmail2fasController < ApplicationController
RateLimit.call("send-email-code-#{@submitter.id}", limit: 2, ttl: 45.seconds, enabled: true)
if Docuseal.multitenant? && Submitters.email_bounced_recently?(@submitter.email)
Rollbar.warning("Bounced OTP email for submitter: #{@submitter.id}") if defined?(Rollbar)
return redirect_to submit_form_path(@submitter.slug, status: :error), alert: I18n.t(:verification_email_bounced)
end
SendSubmitterVerificationEmailJob.perform_async('submitter_id' => @submitter.id, 'locale' => I18n.locale.to_s)
redir_params = params[:resend] ? { alert: I18n.t(:code_has_been_resent) } : {}
@@ -23,7 +23,7 @@ class SubmitFormInviteController < ApplicationController
@submitter.submission.submitters.create!(uuid: attrs[:uuid], email:, account_id: @submitter.account_id)
SubmissionEvents.create_with_tracking_data(@submitter, 'invite_party', request, { uuid: @submitter.uuid })
SubmissionEvents.create_with_tracking_data(@submitter, 'invite_party', request, { uuid: attrs[:uuid] })
end
@submitter.submission.update!(submitters_order: :preserved)
@@ -48,6 +48,7 @@ class SubmitFormInviteController < ApplicationController
!submitter.submission.archived_at? &&
!submitter.submission.expired? &&
!submitter.submission.template&.archived_at? &&
!submitter.viewer? &&
Submitters::AuthorizedForForm.call(submitter, current_user, request)
end
@@ -7,13 +7,7 @@ class SubmitFormMetadataController < ApplicationController
def index
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
return head :not_found if @submitter.declined_at? ||
@submitter.completed_at? ||
@submitter.submission.archived_at? ||
@submitter.submission.expired? ||
@submitter.submission.template&.archived_at? ||
@submitter.account.archived_at? ||
!Submitters::AuthorizedForForm.call(@submitter, current_user, request)
return head :not_found unless authorized_submitter?(@submitter)
submission = @submitter.submission
values = submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) }
@@ -34,4 +28,17 @@ class SubmitFormMetadataController < ApplicationController
render json: { text_runs: }
end
private
def authorized_submitter?(submitter)
!submitter.declined_at? &&
!submitter.completed_at? &&
!submitter.submission.archived_at? &&
!submitter.submission.completed_at? &&
!submitter.submission.expired? &&
!submitter.submission.template&.archived_at? &&
!submitter.account.archived_at? &&
Submitters::AuthorizedForForm.call(submitter, current_user, request, with_order: true)
end
end
@@ -0,0 +1,33 @@
# frozen_string_literal: true
class SubmitFormPrefillAttachmentsController < ActionController::API
include ActiveStorage::SetCurrent
def create
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
unless can_upload?(submitter)
return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content
end
uuid, slug = ApplicationRecord.signed_id_verifier.verify(params[:prefill_token], purpose: :prefill_attachment)
raise ActiveRecord::RecordNotFound if slug != submitter.slug
source_attachment = ActiveStorage::Attachment.find_by!(uuid:)
attachment = submitter.attachments_attachments.find_or_create_by!(blob_id: source_attachment.blob_id)
render json: attachment.as_json(only: %i[uuid created_at], methods: %i[url filename content_type])
end
private
def can_upload?(submitter)
!submitter.declined_at? &&
!submitter.completed_at? &&
!submitter.submission.archived_at? &&
!submitter.submission.expired? &&
!submitter.submission.template&.archived_at?
end
end
@@ -0,0 +1,86 @@
# frozen_string_literal: true
class SubmitFormUploadController < ActionController::API
include ActionController::Cookies
include ActiveStorage::SetCurrent
before_action :load_submitter
before_action :authorize_upload!
COOKIE_STORE_LIMIT = 10
def create
file = params[:file]
if params[:type].in?(%w[initials signature])
image = ImageUtils.load_vips(file.read, content_type: file.content_type)
if ImageUtils.blank?(image)
Rollbar.error("Empty signature: #{@submitter.id}") if defined?(Rollbar)
return render json: { error: "#{params[:type]} is empty" }, status: :unprocessable_content
end
if ImageUtils.error?(image)
Rollbar.error("Error signature: #{@submitter.id}") if defined?(Rollbar)
return render json: { error: "#{params[:type]} error, try to sign on another device" },
status: :unprocessable_content
end
metadata = { analyzed: true, identified: true, width: image.width, height: image.height }
end
attachment = Submitters.create_attachment!(@submitter, file, metadata:)
if params[:remember_signature] == 'true' && @submitter.email.present?
cookies.encrypted[:signature_uuids] = build_new_cookie_signatures_json(@submitter, attachment)
end
render json: attachment.as_json(only: %i[uuid created_at], methods: %i[url filename content_type])
rescue Submitters::MaliciousFileExtension => e
Rollbar.error(e) if defined?(Rollbar)
render json: { error: e.message }, status: :unprocessable_content
end
private
def load_submitter
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
end
def authorize_upload!
unless can_upload?(@submitter)
return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content
end
return if Submitters::AuthorizedForForm.call(@submitter, current_user, request)
render json: { error: I18n.t('verification_required_refresh_the_page_and_pass_2fa') },
status: :unprocessable_content
end
def can_upload?(submitter)
!submitter.declined_at? &&
!submitter.completed_at? &&
!submitter.submission.archived_at? &&
!submitter.submission.expired? &&
!submitter.submission.template&.archived_at?
end
def build_new_cookie_signatures_json(submitter, attachment)
values =
begin
JSON.parse(cookies.encrypted[:signature_uuids].presence || '{}')
rescue JSON::ParserError
{}
end
values[submitter.email] = attachment.uuid
values = values.to_a.last(COOKIE_STORE_LIMIT).to_h if values.size > COOKIE_STORE_LIMIT
values.to_json
end
end
@@ -0,0 +1,18 @@
# frozen_string_literal: true
class SubmitFormViewController < ActionController::API
def create
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
return head :forbidden if Submitter.signed_id_verifier.verified(params[:v], purpose: :view_form) != @submitter.slug
@submitter.opened_at = Time.current
@submitter.save
SubmissionEvents.create_with_tracking_data(@submitter, 'view_form', request)
WebhookUrls.enqueue_events(@submitter, 'form.viewed')
render json: {}
end
end
@@ -36,7 +36,7 @@ class SubmittersAutocompleteController < ApplicationController
else
column = Submitter.arel_table[field.to_sym]
term = "#{params[:q].downcase}%"
term = "#{ActiveRecord::Base.sanitize_sql_like(params[:q].downcase)}%"
submitters.where(column.matches(term))
end
+4 -2
View File
@@ -8,6 +8,8 @@ class SubmittersController < ApplicationController
if @submitter.preferences['email_message_uuid'].present?
@submitter.account.email_messages.find_by(uuid: @submitter.preferences['email_message_uuid'])
end
render :edit, layout: 'plain'
end
def update
@@ -50,7 +52,7 @@ class SubmittersController < ApplicationController
def submitter_editable?(submission)
!@submitter.submission_events.exists?(event_type: 'start_form') &&
!@submitter.completed_at? && !@submitter.declined_at? &&
!@submitter.completed_at? && !@submitter.declined_at? && !submission.completed_at? &&
!submission.archived_at? && !submission.expired? && !submission.template&.archived_at?
end
@@ -58,7 +60,7 @@ class SubmittersController < ApplicationController
if params[:send_email] == '1' && submitter.email.present?
is_sent_recently = Docuseal.multitenant? &&
EmailEvent.exists?(email: submitter.email,
tag: 'submitter_invitation',
tag: %w[submitter_invitation submitter_view_invitation],
emailable: submitter,
event_type: 'send',
created_at: 4.hours.ago..Time.current)
+24 -4
View File
@@ -13,10 +13,7 @@ class TemplateFoldersController < ApplicationController
.where(folder: [@template_folder, *(params[:q].present? ? @template_folder.subfolders : [])])
.preload(:author, :template_accesses)
@template_folders =
@template_folder.subfolders.where(id: Template.accessible_by(current_ability).active.select(:folder_id))
@template_folders = TemplateFolders.search(@template_folders, params[:q])
@template_folders = filter_folders(@template_folder)
@template_folders = TemplateFolders.sort(@template_folders, current_user, selected_order)
if @templates.exists?
@@ -40,6 +37,8 @@ class TemplateFoldersController < ApplicationController
@templates = @templates.none
end
render_infinite_scroll(@pagy, @templates, @template_folders) if turbo_infinite_scroll?
end
def edit; end
@@ -55,6 +54,27 @@ class TemplateFoldersController < ApplicationController
private
def filter_folders(template_folder)
template_folders =
template_folder.subfolders.where(id: Template.accessible_by(current_ability).active.select(:folder_id))
TemplateFolders.search(template_folders, params[:q])
end
def render_infinite_scroll(pagy, templates, template_folders)
render_params =
if templates.present?
{ partial: 'templates/template', collection: templates }
else
{ partial: 'template_folders/folder', collection: template_folders, as: :folder }
end
render turbo_stream: [
turbo_stream.before('infinite_scroll', **render_params),
turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: })
]
end
def selected_order
@selected_order ||=
if can?(:manage, :countless)
@@ -12,13 +12,23 @@ class TemplatesArchivedController < ApplicationController
@pagy, @templates = pagy_auto(@templates.select_for_list, limit: 12)
return unless params[:q].present? && @templates.blank?
if params[:q].present? && @templates.blank?
@related_submissions_pagy, @related_submissions = load_related_submissions
end
@related_submissions_pagy, @related_submissions = load_related_submissions
render_infinite_scroll(@pagy, @templates) if turbo_infinite_scroll?
end
private
def render_infinite_scroll(pagy, templates)
render turbo_stream: [
turbo_stream.before('infinite_scroll', partial: 'templates/template', collection: templates,
locals: { with_folder: true }),
turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: })
]
end
def load_related_submissions
related_submissions =
Submission.accessible_by(current_ability)
@@ -10,13 +10,25 @@ class TemplatesArchivedSubmissionsController < ApplicationController
@submissions = Submissions::Filter.call(@submissions, current_user, params)
@submissions = if params[:completed_at_from].present? || params[:completed_at_to].present?
@submissions.order(Submitter.arel_table[:completed_at].maximum.desc)
@submissions.order(completed_at: :desc)
else
@submissions.order(id: :desc)
end
@pagy, @submissions = pagy_auto(@submissions.select_for_list.preload(submitters: :start_form_submission_events))
render_infinite_scroll(@pagy, @submissions, @template) if turbo_infinite_scroll?
rescue ActiveRecord::RecordNotFound
redirect_to root_path
end
private
def render_infinite_scroll(pagy, submissions, template)
render turbo_stream: [
turbo_stream.before('infinite_scroll', partial: 'templates/submission', collection: submissions,
locals: { template:, archived: true }),
turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: })
]
end
end
+19 -3
View File
@@ -14,18 +14,22 @@ class TemplatesController < ApplicationController
submissions = Submissions::Filter.filter_by_status(submissions, params)
submissions = if params[:completed_at_from].present? || params[:completed_at_to].present?
submissions.order(Submitter.arel_table[:completed_at].maximum.desc)
submissions.order(completed_at: :desc)
else
submissions.order(id: :desc)
end
@pagy, @submissions =
pagy_auto(submissions.select_for_list.preload(:template_accesses, submitters: :start_form_submission_events))
render_infinite_scroll(@pagy, @submissions, @template) if turbo_infinite_scroll?
rescue ActiveRecord::RecordNotFound
redirect_to root_path
end
def new; end
def new
render :new, layout: 'plain'
end
def edit
@template_data = Templates.serialize_for_builder(@template)
@@ -79,11 +83,23 @@ class TemplatesController < ApplicationController
I18n.t('template_has_been_archived')
end
redirect_back(fallback_location: root_path, notice:)
if @template.destroyed? && turbo_native_app?
redirect_to templates_archived_index_path, notice:
else
redirect_back(fallback_location: root_path, notice:)
end
end
private
def render_infinite_scroll(pagy, submissions, template)
render turbo_stream: [
turbo_stream.before('infinite_scroll', partial: 'templates/submission', collection: submissions,
locals: { template:, archived: template.archived_at? }),
turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: })
]
end
def template_params
params.require(:template).permit(
:name,
@@ -43,10 +43,26 @@ class TemplatesDashboardController < ApplicationController
@related_submissions_pagy, @related_submissions = load_related_submissions
end
end
render_infinite_scroll(@pagy, @templates, @template_folders) if turbo_infinite_scroll?
end
private
def render_infinite_scroll(pagy, templates, template_folders)
render_params =
if templates.present?
{ partial: 'templates/template', collection: templates }
else
{ partial: 'template_folders/folder', collection: template_folders, as: :folder }
end
render turbo_stream: [
turbo_stream.before('infinite_scroll', **render_params),
turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: })
]
end
def load_templates(templates, folders_count, show_shared_inline: false)
templates = templates.preload(:author, :template_accesses)
@@ -94,16 +110,16 @@ class TemplatesDashboardController < ApplicationController
pinned_count = (show_default_in_grid ? 1 : 0) + (show_shared_in_grid ? 1 : 0)
pagy = Pagy::Offset.new(count: folders_count + pinned_count,
page: show_inline_folders ? 1 : [params[:page].to_s.to_i, 1].max,
limit: FOLDERS_PER_PAGE,
raise_range_error: true)
pagy, = pagy(template_folders, count: folders_count + pinned_count,
page: show_inline_folders ? 1 : [params[:page].to_s.to_i, 1].max,
limit: FOLDERS_PER_PAGE,
raise_range_error: true)
show_default_folder = show_default_in_grid && pagy.page == 1
show_shared_folder = show_shared_in_grid && pagy.page == 1
folder_offset = pagy.page == 1 ? 0 : pagy.offset - pinned_count
folder_limit = pagy.page == 1 ? FOLDERS_PER_PAGE - pinned_count : FOLDERS_PER_PAGE
folder_limit = pagy.page == 1 ? pagy.limit - pinned_count : pagy.limit
template_folders = template_folders.offset(folder_offset).limit(folder_limit)
@@ -13,9 +13,10 @@ class TemplatesDebugController < ApplicationController
data = attachment.download
unless attachment.image?
pdf = HexaPDF::Document.new(io: StringIO.new(data))
fields = Templates::FindAcroFields.call(pdf, attachment, data)
fields =
Pdfium::Document.open_io(StringIO.new(data)) do |doc|
Templates::FindPdfiumAcroFields.call(attachment, doc, data)
end
end
# fields, = Templates::DetectFields.call(StringIO.new(data), attachment:) if fields.blank?
@@ -10,8 +10,14 @@ class TemplatesDetectFieldsController < ApplicationController
sse = SSE.new(response.stream)
documents = @template.schema_documents.preload(:blob)
documents = documents.where(uuid: params[:attachment_uuid]) if params[:attachment_uuid].present?
documents =
if params[:attachment_uuid].present?
@template.documents.where(uuid: params[:attachment_uuid])
else
@template.schema_documents
end
documents = documents.preload(:blob)
page_number = params[:page].presence&.to_i
@@ -13,11 +13,13 @@ class TemplatesFormPreviewController < ApplicationController
@submitter.submission.submitters = @template.submitters.map { |item| Submitter.new(uuid: item['uuid']) }
Submissions::CreateFromSubmitters.maybe_set_dynamic_documents(@submitter.submission, preview: true)
Submissions.preload_with_pages(@submitter.submission)
@attachments_index = ActiveStorage::Attachment.where(record: @submitter.submission.submitters, name: :attachments)
.preload(:blob).index_by(&:uuid)
@form_configs = Submitters::FormConfigs.call(@submitter)
@form_configs = Submitters::FormConfigs.call(@submitter, SubmitFormController::CONFIG_KEYS)
end
end
@@ -5,6 +5,8 @@ class TemplatesPreferencesController < ApplicationController
RESETTABLE_PREFERENCE_KEYS = {
AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY => %w[request_email_subject request_email_body submitters],
AccountConfig::SUBMITTER_VIEW_INVITATION_EMAIL_KEY => %w[invitation_view_email_subject
invitation_view_email_body],
AccountConfig::SUBMITTER_INVITATION_REMINDER_EMAIL_KEY => %w[invitation_reminder_email_subject
invitation_reminder_email_body],
AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY => %w[documents_copy_email_subject documents_copy_email_body],
@@ -12,7 +14,9 @@ class TemplatesPreferencesController < ApplicationController
completed_notification_email_body]
}.freeze
def show; end
def show
render :show, layout: 'plain'
end
def create
authorize!(:update, @template)
@@ -48,12 +52,12 @@ class TemplatesPreferencesController < ApplicationController
def template_params
params.require(:template).permit(
preferences: %i[bcc_completed request_email_subject request_email_body
invitation_view_email_subject invitation_view_email_body
invitation_reminder_email_subject invitation_reminder_email_body
documents_copy_email_subject documents_copy_email_body
documents_copy_email_enabled documents_copy_email_attach_audit
documents_copy_email_attach_documents documents_copy_email_reply_to
completed_notification_email_attach_documents
completed_redirect_url validate_unique_submitters
completed_notification_email_attach_documents completed_redirect_url validate_unique_submitters
require_all_submitters submitters_order require_phone_2fa require_email_2fa
default_expire_at_duration shared_link_2fa default_expire_at request_email_enabled
completed_notification_email_subject completed_notification_email_body
@@ -3,7 +3,9 @@
class TemplatesShareLinkController < ApplicationController
load_and_authorize_resource :template
def show; end
def show
render :show, layout: 'plain'
end
def create
authorize!(:update, @template)
+11 -2
View File
@@ -19,13 +19,22 @@ class TemplatesSharedController < ApplicationController
@pagy, @templates = pagy_auto(@templates.select_for_list, limit: 12)
return unless params[:q].present? && @templates.blank?
if params[:q].present? && @templates.blank?
@related_submissions_pagy, @related_submissions = load_related_submissions(is_archived: @is_archived)
end
@related_submissions_pagy, @related_submissions = load_related_submissions(is_archived: @is_archived)
render_infinite_scroll(@pagy, @templates) if turbo_infinite_scroll?
end
private
def render_infinite_scroll(pagy, templates)
render turbo_stream: [
turbo_stream.before('infinite_scroll', partial: 'templates/template', collection: templates),
turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: })
]
end
def load_related_submissions(is_archived:)
shared_templates = Templates.shared(current_user)
shared_templates = is_archived ? shared_templates.archived : shared_templates.active
@@ -6,7 +6,9 @@ class TemplatesUploadsController < ApplicationController
layout 'plain'
def show
redirect_to root_path if params[:url].blank?
url_uri = Addressable::URI.parse(params[:url].to_s)
redirect_to root_path if url_uri.normalized_scheme != 'https' || url_uri.host.blank?
end
def create
@@ -61,7 +63,7 @@ class TemplatesUploadsController < ApplicationController
tempfile.write(DownloadUtils.call(params[:url], validate: true).body)
tempfile.rewind
filename = URI.decode_www_form_component(params[:filename]) if params[:filename].present?
filename = URI.decode_www_form_component(params[:filename]).tr('/', '-') if params[:filename].present?
filename ||= File.basename(URI.decode_www_form_component(params[:url]))
file = ActionDispatch::Http::UploadedFile.new(
@@ -6,6 +6,7 @@ class TestingAccountsController < ApplicationController
def create
authorize!(:manage, current_account)
authorize!(:manage, current_user)
authorize!(:manage, EncryptedConfig)
impersonate_user(Accounts.find_or_create_testing_user(true_user.account))
@@ -2,6 +2,8 @@
class TestingApiSettingsController < ApplicationController
def index
raise CanCan::AccessDenied unless current_account.testing?
authorize!(:manage, current_user.access_token)
@webhook_url = current_account.webhook_urls.first_or_initialize
+2 -4
View File
@@ -11,11 +11,9 @@ class UserInitialsController < ApplicationController
return redirect_to settings_profile_index_path, notice: I18n.t('unable_to_save_initials') if file.blank?
extension = File.extname(file.original_filename).delete_prefix('.').downcase
extension = FilenameUtils.dangerous_extension(file.original_filename)
if Submitters::DANGEROUS_EXTENSIONS.include?(extension)
raise Submitters::MaliciousFileExtension, "File type '.#{extension}' is not allowed."
end
raise Submitters::MaliciousFileExtension, "File type '.#{extension}' is not allowed." if extension
blob = ActiveStorage::Blob.create_and_upload!(io: file.open,
filename: file.original_filename,
@@ -11,11 +11,9 @@ class UserSignaturesController < ApplicationController
return redirect_to settings_profile_index_path, notice: I18n.t('unable_to_save_signature') if file.blank?
extension = File.extname(file.original_filename).delete_prefix('.').downcase
extension = FilenameUtils.dangerous_extension(file.original_filename)
if Submitters::DANGEROUS_EXTENSIONS.include?(extension)
raise Submitters::MaliciousFileExtension, "File type '.#{extension}' is not allowed."
end
raise Submitters::MaliciousFileExtension, "File type '.#{extension}' is not allowed." if extension
blob = ActiveStorage::Blob.create_and_upload!(io: file.open,
filename: file.original_filename,
+3 -1
View File
@@ -20,7 +20,7 @@ class UsersController < ApplicationController
respond_to do |format|
format.html do
@pagy, @users = pagy(@users)
@pagy, @users = pagy(@users, limit: turbo_native_app? ? 1_000 : Pagy::OPTIONS[:limit])
end
if current_ability.can?(:manage, current_account)
@@ -75,6 +75,8 @@ class UsersController < ApplicationController
authorize!(:manage, account)
@user.account = account
authorize!(:create, @user)
end
if @user.update(attrs.except(*(current_user == @user ? %i[password otp_required_for_login role] : %i[password])))
@@ -4,16 +4,16 @@ class VerifyPdfSignatureController < ApplicationController
skip_authorization_check
def create
pdfs =
params[:files].map do |file|
HexaPDF::Document.new(io: file.open)
end
trusted_certs = Accounts.load_trusted_certs(current_account)
signatures =
params[:files].map do |file|
VerifyPdfSignature.call(file.open, trusted_certs)
end
render turbo_stream: turbo_stream.replace('result', partial: 'result',
locals: { pdfs:, files: params[:files], trusted_certs: })
rescue HexaPDF::MalformedPDFError
locals: { signatures:, files: params[:files] })
rescue Pdfium::PdfiumError
render turbo_stream: turbo_stream.replace('result', html: helpers.tag.div(I18n.t('invalid_pdf'), id: 'result'))
end
end
+9 -1
View File
@@ -3,11 +3,19 @@
class WebhookSecretController < ApplicationController
load_and_authorize_resource :webhook_url, parent: false
HEADER_NAME_REGEXP = /\A[\w-]+\z/
def show; end
def update
key = webhook_secret_params[:key]
if key.present? && !HEADER_NAME_REGEXP.match?(key)
return redirect_back(fallback_location: settings_webhook_path(@webhook_url), alert: I18n.t('unable_to_save'))
end
@webhook_url.update!(secret: {
webhook_secret_params[:key] => webhook_secret_params[:value]
key => webhook_secret_params[:value]
}.compact_blank)
redirect_back(fallback_location: settings_webhook_path(@webhook_url),
+218 -39
View File
@@ -1,5 +1,5 @@
import '@hotwired/turbo'
import { encodeMethodIntoRequestBody } from '@hotwired/turbo-rails/app/javascript/turbo/fetch_requests'
import '@hotwired/turbo-rails'
import './native'
import { createApp, reactive } from 'vue'
import TemplateBuilder from './template_builder/builder'
@@ -9,6 +9,15 @@ import ToggleVisible from './elements/toggle_visible'
import ToggleCookies from './elements/toggle_cookies'
import DisableHidden from './elements/disable_hidden'
import TurboModal from './elements/turbo_modal'
import NativeAction from './elements/native_action'
import NativeMenu from './elements/native_menu'
import NativeSearch from './elements/native_search'
import NativeModal from './elements/native_modal'
import NativeEvent from './elements/native_event'
import NativeUpload from './elements/native_upload'
import FormPrompt from './elements/form_prompt'
import InfiniteScroll from './elements/infinite_scroll'
import ModalButton from './elements/modal_button'
import FileDropzone from './elements/file_dropzone'
import MenuActive from './elements/menu_active'
import ClipboardCopy from './elements/clipboard_copy'
@@ -42,6 +51,7 @@ import RequiredCheckboxGroup from './elements/required_checkbox_group'
import PageContainer from './elements/page_container'
import EmailEditor from './elements/email_editor'
import MarkdownEditor from './elements/markdown_editor'
import HtmlEditor from './elements/html_editor'
import MountOnClick from './elements/mount_on_click'
import RemoveOnEvent from './elements/remove_on_event'
import ScrollTo from './elements/scroll_to'
@@ -55,10 +65,11 @@ import OpenModal from './elements/open_modal'
import BarChart from './elements/bar_chart'
import FieldCondition from './elements/field_condition'
import ConfirmUpload from './elements/confirm_upload'
import * as TurboInstantClick from './lib/turbo_instant_click'
TurboInstantClick.start()
import ScrollFade from './elements/scroll_fade'
import OpenModalMobile from './elements/open_modal_mobile'
import HistoryBack from './elements/history_back'
import DatePlaceholder from './elements/date_placeholder'
import FlashMessage from './elements/flash_message'
document.addEventListener('turbo:before-cache', () => {
window.flash?.remove()
@@ -70,10 +81,18 @@ document.addEventListener('keyup', (e) => {
}
})
document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody)
document.addEventListener('turbo:before-fetch-request', (event) => {
event.detail.fetchOptions.headers['X-Turbo'] = 'true'
document.addEventListener('turbo:morph', () => {
if (document.activeElement?.closest('.dropdown')) {
document.activeElement.blur()
}
})
document.addEventListener('turbo:before-fetch-response', (event) => {
if (event.detail.fetchResponse.header('content-disposition')?.includes('attachment')) {
event.preventDefault()
}
})
document.addEventListener('turbo:submit-end', async (event) => {
const resp = event.detail?.formSubmission?.result?.fetchResponse?.response
@@ -81,11 +100,23 @@ document.addEventListener('turbo:submit-end', async (event) => {
return
}
const filename = decodeURIComponent(resp.headers.get('content-disposition').split('"')[1])
const download = window.webkit?.messageHandlers?.download
if (download) {
const reader = new FileReader()
reader.onload = () => download.postMessage({ name: filename, data: reader.result.split(',')[1] })
reader.readAsDataURL(await resp.blob())
return
}
const url = URL.createObjectURL(await resp.blob())
const link = document.createElement('a')
link.href = url
link.setAttribute('download', decodeURIComponent(resp.headers.get('content-disposition').split('"')[1]))
link.setAttribute('download', filename)
document.body.appendChild(link)
@@ -101,6 +132,15 @@ const safeRegisterElement = (name, element, options = {}) => !window.customEleme
safeRegisterElement('toggle-visible', ToggleVisible)
safeRegisterElement('disable-hidden', DisableHidden)
safeRegisterElement('turbo-modal', TurboModal)
safeRegisterElement('native-action', NativeAction)
safeRegisterElement('native-menu', NativeMenu)
safeRegisterElement('native-search', NativeSearch)
safeRegisterElement('native-modal', NativeModal)
safeRegisterElement('native-event', NativeEvent)
safeRegisterElement('native-upload', NativeUpload)
safeRegisterElement('form-prompt', FormPrompt)
safeRegisterElement('infinite-scroll', InfiniteScroll)
safeRegisterElement('modal-button', ModalButton)
safeRegisterElement('file-dropzone', FileDropzone)
safeRegisterElement('menu-active', MenuActive)
safeRegisterElement('clipboard-copy', ClipboardCopy)
@@ -135,6 +175,7 @@ safeRegisterElement('required-checkbox-group', RequiredCheckboxGroup)
safeRegisterElement('page-container', PageContainer)
safeRegisterElement('email-editor', EmailEditor)
safeRegisterElement('markdown-editor', MarkdownEditor)
safeRegisterElement('html-editor', HtmlEditor)
safeRegisterElement('mount-on-click', MountOnClick)
safeRegisterElement('remove-on-event', RemoveOnEvent)
safeRegisterElement('scroll-to', ScrollTo)
@@ -148,20 +189,39 @@ safeRegisterElement('open-modal', OpenModal)
safeRegisterElement('bar-chart', BarChart)
safeRegisterElement('field-condition', FieldCondition)
safeRegisterElement('confirm-upload', ConfirmUpload)
safeRegisterElement('scroll-fade', ScrollFade)
safeRegisterElement('open-modal-mobile', OpenModalMobile)
safeRegisterElement('history-back', HistoryBack)
safeRegisterElement('date-placeholder', DatePlaceholder)
safeRegisterElement('flash-message', FlashMessage)
safeRegisterElement('template-builder', class extends HTMLElement {
connectedCallback () {
document.addEventListener('turbo:submit-end', this.onSubmit)
document.addEventListener('template-builder:update', this.onSheetSubmit)
document.addEventListener('template-builder:sync', this.onSync)
document.addEventListener('template-builder:scroll-to', this.onScrollTo)
document.addEventListener('template-builder:select-field', this.onSelectField)
document.addEventListener('template-builder:select-submitter', this.onSelectSubmitter)
document.addEventListener('template-builder:sync-custom-fields', this.onSyncCustomFields)
document.addEventListener('template-builder:draw-field', this.onDrawField)
document.addEventListener('native:builder-view', this.onBuilderView)
document.addEventListener('turbo:before-cache', this.onBeforeCache)
this.appElem = document.createElement('div')
this.appElem.classList.add('md:h-screen')
if (this.dataset.nativeView) {
this.appElem.classList.add('min-w-0')
}
const template = reactive(JSON.parse(this.dataset.template))
this.app = createApp(TemplateBuilder, {
template,
customFields: reactive(JSON.parse(this.dataset.customFields || '[]')),
dateFormats: JSON.parse(this.dataset.dateFormats || '[]'),
dynamicDocuments: reactive(JSON.parse(this.dataset.dynamicDocuments || '[]')),
backgroundColor: '#faf7f5',
locale: this.dataset.locale,
@@ -178,7 +238,10 @@ safeRegisterElement('template-builder', class extends HTMLElement {
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
withCustomFields: true,
withPayment: this.dataset.withPayment === 'true',
isPaymentConnected: this.dataset.isPaymentConnected === 'true',
isStripeConnected: this.dataset.isPaymentConnected === 'true' || this.dataset.isStripeConnected === 'true',
withStripe: this.dataset.withStripe !== 'false',
withPaypal: this.dataset.withPaypal === 'true',
isPaypalConnected: this.dataset.isPaypalConnected === 'true',
withFormula: this.dataset.withFormula === 'true',
withSendButton: this.dataset.withSendButton !== 'false',
withSignYourselfButton: this.dataset.withSignYourselfButton !== 'false',
@@ -190,7 +253,9 @@ safeRegisterElement('template-builder', class extends HTMLElement {
withDownload: true,
currencies: (this.dataset.currencies || '').split(',').filter(Boolean),
acceptFileTypes: this.dataset.acceptFileTypes,
showTourStartForm: this.dataset.showTourStartForm === 'true'
showTourStartForm: this.dataset.showTourStartForm === 'true',
nativePlatform: this.dataset.nativePlatform,
nativeView: this.dataset.nativeView
})
this.component = this.app.mount(this.appElem)
@@ -198,41 +263,146 @@ safeRegisterElement('template-builder', class extends HTMLElement {
this.appendChild(this.appElem)
}
onSubmit = (e) => {
if (e.detail.success) {
if (e.detail?.formSubmission?.formElement?.id === 'submitters_form') {
e.detail.fetchResponse.response.json().then((data) => {
this.component.template.submitters = data.submitters
})
onSubmit = async (e) => {
const form = e.detail.formSubmission?.formElement
if (!e.detail.success || !form) return
const data = {}
new FormData(form).forEach((value, key) => { data[key] = value })
this.applySubmission(form.id, form.action, await e.detail.fetchResponse.responseText, data)
}
onSheetSubmit = (e) => {
this.applySubmission(e.detail.form, e.detail.action, e.detail.body, {})
this.component.syncNative()
}
onSync = (e) => {
const { schema, documents, fields, submitters, pendingFieldAttachmentUuids } = e.detail
const selectedAreas = this.component.selectedAreasRef.value.map((area) => {
const field = this.component.template.fields.find((f) => f.areas?.includes(area))
return field && { uuid: field.uuid, index: field.areas.indexOf(area) }
})
const selectedSubmitterUuid = this.component.selectedSubmitter?.uuid
Object.assign(this.component.template, { schema, documents, fields, submitters })
this.component.selectedAreasRef.value = selectedAreas.map((item) => item && fields.find((f) => f.uuid === item.uuid)?.areas?.[item.index]).filter(Boolean)
this.component.selectedSubmitter = submitters.find((s) => s.uuid === selectedSubmitterUuid) || submitters[0]
this.component.pendingFieldAttachmentUuids = pendingFieldAttachmentUuids
}
onScrollTo = (e) => {
this.component.scrollIntoDocument(e.detail)
}
onSyncCustomFields = (e) => {
this.component.customFields.splice(0, this.component.customFields.length, ...e.detail)
}
onSelectSubmitter = (e) => {
const submitter = this.component.template.submitters.find((s) => s.uuid === e.detail.uuid)
if (submitter) this.component.selectedSubmitter = submitter
}
onSelectField = (e) => {
const { uuid, attachment_uuid: attachmentUuid, page, x, y } = e.detail
const field = this.component.template.fields.find((f) => f.uuid === uuid)
const areas = field?.areas || []
const area = areas.find((a) => a.attachment_uuid === attachmentUuid && a.page === page && a.x === x && a.y === y) || areas[0]
if (area) this.component.scrollToArea(area)
}
onDrawField = (e) => {
const { uuid, type, option, custom_field_uuid: customFieldUuid } = e.detail
if (customFieldUuid) {
const field = this.component.customFields.find((f) => f.uuid === customFieldUuid)
if (field) {
this.component.drawCustomField = field
this.component.showDrawField = true
}
} else if (uuid) {
const field = this.component.template.fields.find((f) => f.uuid === uuid)
if (e.detail?.formSubmission?.formElement?.action?.endsWith('/prefillable_fields')) {
e.detail.fetchResponse.response.text().then((data) => {
const doc = new DOMParser().parseFromString(data, 'text/html')
const fragment = doc.querySelector('turbo-stream template').content
const prefillableUuidsIndex = {}
fragment.querySelectorAll('[name="field_uuid"]').forEach((field) => {
prefillableUuidsIndex[field.value] = true
})
this.component.template.fields.forEach((field) => {
if (prefillableUuidsIndex[field.uuid]) {
field.prefillable = true
field.readonly = true
} else if (field.prefillable) {
delete field.prefillable
delete field.readonly
}
})
})
if (field) {
this.component.drawField = field
this.component.drawOption = option || null
}
} else if (type) {
this.component.startFieldDraw({ type })
}
}
applySubmission (formId, action, body, data) {
if (formId === 'submitters_form') {
this.component.template.submitters = JSON.parse(body).submitters
} else if (action.endsWith('/prefillable_fields')) {
const doc = new DOMParser().parseFromString(body, 'text/html')
const fragment = doc.querySelector('turbo-stream template').content
const prefillableUuidsIndex = {}
fragment.querySelectorAll('[name="field_uuid"]').forEach((field) => {
prefillableUuidsIndex[field.value] = true
})
this.component.template.fields.forEach((field) => {
if (prefillableUuidsIndex[field.uuid]) {
field.prefillable = true
field.readonly = true
} else if (field.prefillable) {
delete field.prefillable
delete field.readonly
}
})
} else if (data['template[name]']) {
this.component.template.name = data['template[name]']
document.title = data['template[name]']
}
}
onBuilderView = (event) => {
const root = document.documentElement.cloneNode(true)
const builder = root.querySelector('template-builder')
const style = document.createElement('style')
builder.innerHTML = ''
builder.setAttribute('data-native-view', event.detail.view)
root.querySelector('meta[name="viewport"]')?.setAttribute('content', 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no')
style.textContent = 'html { overflow-x: hidden; overscroll-behavior-x: none; } html, body { max-width: 100%; } body { -webkit-user-select: none; user-select: none; } input, textarea, [contenteditable] { -webkit-user-select: text; user-select: text; }'
root.querySelector('head').appendChild(style)
event.detail.result = '<!DOCTYPE html>' + root.outerHTML
}
onBeforeCache = () => {
this.app?.unmount()
this.appElem?.remove()
}
disconnectedCallback () {
document.removeEventListener('turbo:submit-end', this.onSubmit)
document.removeEventListener('template-builder:update', this.onSheetSubmit)
document.removeEventListener('template-builder:sync', this.onSync)
document.removeEventListener('template-builder:scroll-to', this.onScrollTo)
document.removeEventListener('template-builder:select-field', this.onSelectField)
document.removeEventListener('template-builder:select-submitter', this.onSelectSubmitter)
document.removeEventListener('template-builder:sync-custom-fields', this.onSyncCustomFields)
document.removeEventListener('template-builder:draw-field', this.onDrawField)
document.removeEventListener('native:builder-view', this.onBuilderView)
document.removeEventListener('turbo:before-cache', this.onBeforeCache)
this.app?.unmount()
this.appElem?.remove()
@@ -241,6 +411,8 @@ safeRegisterElement('template-builder', class extends HTMLElement {
safeRegisterElement('import-list', class extends HTMLElement {
connectedCallback () {
document.addEventListener('turbo:before-cache', this.onBeforeCache)
this.appElem = document.createElement('div')
this.app = createApp(ImportList, {
@@ -255,7 +427,14 @@ safeRegisterElement('import-list', class extends HTMLElement {
this.appendChild(this.appElem)
}
onBeforeCache = () => {
this.app?.unmount()
this.appElem?.remove()
}
disconnectedCallback () {
document.removeEventListener('turbo:before-cache', this.onBeforeCache)
this.app?.unmount()
this.appElem?.remove()
}
+75
View File
@@ -114,6 +114,13 @@ button[disabled] .enabled, button.btn-disabled .enabled {
bottom: auto;
}
@media (hover: none), (pointer: coarse) {
.tooltip-no-touch:before,
.tooltip-no-touch:after {
display: none;
}
}
.autocomplete {
background: white;
z-index: 1000;
@@ -159,3 +166,71 @@ button[disabled] .enabled, button.btn-disabled .enabled {
markdown-editor [contenteditable] p {
margin-bottom: 18px;
}
native-action {
display: contents;
}
@media (max-width: 767px) {
.table-stack,
.table-stack tbody,
.table-stack tr,
.table-stack td {
display: block;
}
.table-stack,
div:has(> .table-stack) {
overflow: visible;
}
.table-stack thead {
display: none;
}
.table-stack tr {
@apply bg-base-200 rounded-2xl py-3 mb-3 border-0;
}
.table-stack td {
@apply px-4 py-1;
}
.table-stack td[data-label] {
@apply flow-root text-right;
overflow-wrap: anywhere;
}
.table-stack td[data-label]::before {
content: attr(data-label);
@apply float-left mr-4 text-sm leading-6 text-base-content/60;
}
.table-stack td:first-child {
@apply font-semibold;
}
.table-stack td:last-child {
@apply grid grid-cols-2 gap-2 pt-3;
}
.table-stack td:last-child:has(> .dropdown) {
grid-template-columns: repeat(2, minmax(0, 1fr)) auto;
}
.table-stack td:last-child:not(:has(*)) {
display: none;
}
.table-stack td:last-child > * {
margin-left: 0 !important;
}
.table-stack td:last-child > :last-child:nth-child(odd):not(.dropdown) {
@apply col-span-2;
}
.table-stack td:last-child .btn {
@apply btn-sm w-full;
}
}
+1 -2
View File
@@ -126,11 +126,10 @@ window.customElements.define('draw-signature', class extends HTMLElement {
const formData = new FormData()
formData.append('file', file)
formData.append('submitter_slug', this.dataset.slug)
formData.append('name', 'attachments')
formData.append('remember_signature', 'true')
return fetch('/api/attachments', {
return fetch(`/s/${this.dataset.slug}/upload`, {
method: 'POST',
body: formData
}).then(resp => resp.json())
@@ -1,5 +1,7 @@
export default class extends HTMLElement {
connectedCallback () {
this.id = `autoresize-textarea-${Math.random().toString(32).split('.')[1]}`
this.resize()
this.textarea.addEventListener('input', () => this.resize())
+2
View File
@@ -1,5 +1,7 @@
export default class extends HTMLElement {
connectedCallback () {
this.id = `bar-chart-${Math.random().toString(32).split('.')[1]}`
this.chartLabels = JSON.parse(this.dataset.labels || '[]')
this.chartDatasets = JSON.parse(this.dataset.datasets || '[]')
+10 -1
View File
@@ -4,9 +4,18 @@ export default class extends HTMLElement {
this.addEventListener('click', (e) => {
const text = this.dataset.text || this.innerText.trim()
const download = window.webkit?.messageHandlers?.download
if (download && /^https?:\/\//.test(text)) {
e.preventDefault()
return download.postMessage({ url: text })
}
if (navigator.clipboard) {
navigator.clipboard.writeText(text)
navigator.clipboard.writeText(text).then(() => {
window.webkit?.messageHandlers?.flash?.postMessage({ style: 'notice', message: this.dataset.copied })
})
} else {
if (e.target.tagName !== 'INPUT') {
alert(`Clipboard not available. Make sure you're using https://\nCopy text: ${text}`)
+3 -3
View File
@@ -59,10 +59,10 @@ export async function convertImagesInInput (input) {
export default class extends HTMLElement {
connectedCallback () {
const input = this.querySelector('input[type="file"]')
const form = input.form
this.addEventListener('change', async (e) => {
const input = e.target
const form = input.form
input.addEventListener('change', async () => {
await convertImagesInInput(input)
form.querySelector('[type="submit"]')?.setAttribute('disabled', true)
+63 -31
View File
@@ -26,17 +26,41 @@ export default targetable(class extends HTMLElement {
window.addEventListener('dragleave', this.onWindowDragleave)
this.fileDropzone?.addEventListener('drop', this.onDropFile)
this.folderDropzone?.addEventListener('drop', this.onDropNewFolder)
this.addEventListener('drop', this.onDrop)
this.addEventListener('dragstart', this.onDragstart)
this.addEventListener('dragover', this.onDragover)
this.addEventListener('dragleave', this.onDragleave)
}
this.folderCards.forEach((el) => el.addEventListener('drop', (e) => this.onDropFolder(e, el)))
this.templateCards.forEach((el) => el.addEventListener('drop', this.onDropTemplate))
this.templateCards.forEach((el) => el.addEventListener('dragstart', this.onTemplateDragStart))
dropTarget (e) {
return e.target.closest([
'[data-target="dashboard-dropzone.fileDropzone"]',
'[data-target="dashboard-dropzone.folderDropzone"]',
'[data-targets="dashboard-dropzone.folderCards"]',
'[data-targets="dashboard-dropzone.templateCards"]'
].join(', '))
}
return [this.fileDropzone, this.folderDropzone, ...this.folderCards, ...this.templateCards].forEach((el) => {
el?.addEventListener('dragover', this.onDragover)
el?.addEventListener('dragleave', this.onDragleave)
})
onDrop = (e) => {
const el = this.dropTarget(e)
if (!el) return
if (el === this.fileDropzone) {
this.onDropFile(e)
} else if (el === this.folderDropzone) {
this.onDropNewFolder(e)
} else if (el.dataset.targets === 'dashboard-dropzone.folderCards') {
this.onDropFolder(e, el)
} else {
this.onDropTemplate(e, el)
}
}
onDragstart = (e) => {
if (e.target.closest('[data-targets="dashboard-dropzone.templateCards"]')) {
this.onTemplateDragStart(e)
}
}
disconnectedCallback () {
@@ -122,13 +146,13 @@ export default targetable(class extends HTMLElement {
}
}).finally(() => {
window.Turbo.cache.clear()
window.Turbo.visit(location.href)
window.Turbo.visit(location.href, { action: 'replace' })
})
}
}
}
onDropTemplate = (e) => {
onDropTemplate = (e, el) => {
e.preventDefault()
if (e.dataTransfer.files.length) {
@@ -136,10 +160,10 @@ export default targetable(class extends HTMLElement {
loading.classList.add('bottom-5', 'left-0', 'flex', 'justify-center', 'w-full', 'absolute')
loading.innerHTML = loadingIconHtml
e.target.appendChild(loading)
e.target.classList.add('opacity-50')
el.appendChild(loading)
el.classList.add('opacity-50')
const id = e.target.href.split('/').pop()
const id = el.href.split('/').pop()
this.uploadFiles(e.dataTransfer.files, `/templates/${id}/clone_and_replace`)
}
@@ -173,16 +197,20 @@ export default targetable(class extends HTMLElement {
}
}
onDragover (e) {
if (e.dataTransfer?.types?.includes('Files') || this.dataset.targets !== 'dashboard-dropzone.templateCards') {
this.style.backgroundColor = '#F7F3F0'
onDragover = (e) => {
const el = this.dropTarget(e)
if (this.classList.contains('before:border-base-300')) {
this.classList.remove('before:border-base-300')
this.classList.add('before:border-base-content/30')
} else if (this.classList.contains('border-base-300')) {
this.classList.remove('border-base-300')
this.classList.add('border-base-content/30')
if (!el) return
if (e.dataTransfer?.types?.includes('Files') || el.dataset.targets !== 'dashboard-dropzone.templateCards') {
el.style.backgroundColor = '#F7F3F0'
if (el.classList.contains('before:border-base-300')) {
el.classList.remove('before:border-base-300')
el.classList.add('before:border-base-content/30')
} else if (el.classList.contains('border-base-300')) {
el.classList.remove('border-base-300')
el.classList.add('border-base-content/30')
}
}
}
@@ -205,15 +233,19 @@ export default targetable(class extends HTMLElement {
a.remove()
}
onDragleave () {
this.style.backgroundColor = null
onDragleave = (e) => {
const el = this.dropTarget(e)
if (this.classList.contains('before:border-base-content/30')) {
this.classList.remove('before:border-base-content/30')
this.classList.add('before:border-base-300')
} else if (this.classList.contains('border-base-content/30')) {
this.classList.remove('border-base-content/30')
this.classList.add('border-base-300')
if (!el) return
el.style.backgroundColor = null
if (el.classList.contains('before:border-base-content/30')) {
el.classList.remove('before:border-base-content/30')
el.classList.add('before:border-base-300')
} else if (el.classList.contains('border-base-content/30')) {
el.classList.remove('border-base-content/30')
el.classList.add('border-base-300')
}
}
@@ -0,0 +1,10 @@
export default class extends HTMLElement {
connectedCallback () {
const input = this.querySelector('input')
const placeholder = this.querySelector('span')
input.addEventListener('change', () => {
placeholder.classList.toggle('hidden', !!input.value)
})
}
}
+12
View File
@@ -2,6 +2,18 @@ export default class extends HTMLElement {
static observedAttributes = ['class']
connectedCallback () {
this.addEventListener('turbo:morph-element', this.onMorph)
this.trigger()
}
disconnectedCallback () {
this.removeEventListener('turbo:morph-element', this.onMorph)
}
onMorph = (event) => {
if (event.target !== this) return
this.trigger()
}
@@ -21,6 +21,16 @@ export default targetable(class extends HTMLElement {
downloadFiles () {
if (!this.dataset.src) return
const download = window.webkit?.messageHandlers?.download
if (download) {
this.toggleState()
document.addEventListener('native:download-end', () => this.toggleState(), { once: true })
return download.postMessage({ src: this.dataset.src })
}
this.toggleState()
fetch(this.dataset.src).then(async (response) => {
+77 -3
View File
@@ -9,8 +9,9 @@ function loadCodeMirror () {
import(/* webpackChunkName: "email-editor" */ '@codemirror/commands'),
import(/* webpackChunkName: "email-editor" */ '@codemirror/language'),
import(/* webpackChunkName: "email-editor" */ '@codemirror/lang-html'),
import(/* webpackChunkName: "email-editor" */ '@codemirror/lint'),
import(/* webpackChunkName: "email-editor" */ '@specious/htmlflow')
]).then(([view, commands, language, html, htmlflow]) => {
]).then(([view, commands, language, html, lint, htmlflow]) => {
return {
minimalSetup: [
commands.history(),
@@ -19,6 +20,8 @@ function loadCodeMirror () {
],
EditorView: view.EditorView,
html: html.html,
htmlLanguage: html.htmlLanguage,
linter: lint.linter,
htmlflow: htmlflow.default || htmlflow
}
})
@@ -36,6 +39,8 @@ export default targetable(class extends HTMLElement {
]
connectedCallback () {
this.id = `email-editor-${Math.random().toString(32).split('.')[1]}`
this.mount()
if (this.input.value) {
@@ -46,6 +51,70 @@ export default targetable(class extends HTMLElement {
this.previewViewTab.addEventListener('click', this.showPreviewView)
this.codeViewTab.addEventListener('click', this.showCodeView)
this.form = this.closest('form')
this.form?.addEventListener('submit', this.validateOnSubmit)
}
disconnectedCallback () {
this.form?.removeEventListener('submit', this.validateOnSubmit)
}
validateOnSubmit = (e) => {
if (!this.htmlLanguage) return
const bodyType = this.form.querySelector('input[name$="[body_type]"]:checked')?.value
if (bodyType && bodyType !== 'html') return
const diagnostics = this.buildDiagnostics(this.input.value)
if (diagnostics.length === 0) return
e.preventDefault()
this.showCodeView()
const pos = Math.min(diagnostics[0].from, this.editorView.state.doc.length)
this.editorView.dispatch({ selection: { anchor: pos }, scrollIntoView: true })
this.editorView.focus()
alert(diagnostics[0].message)
}
buildDiagnostics (value) {
const diagnostics = []
if (!value.trim()) return diagnostics
if (!/^\s*(<!doctype[^>]*>\s*)?<html/i.test(value)) {
diagnostics.push({
from: 0,
to: Math.min(5, value.length),
severity: 'error',
message: 'The email template must start with the <html> tag'
})
}
const seen = new Set()
this.htmlLanguage.parser.parse(value).iterate({
enter: (node) => {
if (!node.type.isError || seen.has(node.from) || seen.size >= 20) return
seen.add(node.from)
diagnostics.push({
from: node.from,
to: Math.min(node.to + 1, value.length),
severity: 'error',
message: 'The email template contains invalid HTML'
})
}
})
return diagnostics
}
showCodeView = () => {
@@ -76,7 +145,9 @@ export default targetable(class extends HTMLElement {
this.input = this.querySelector('input[type="hidden"]')
this.input.style.display = 'none'
const { EditorView, minimalSetup, html, htmlflow } = await loadCodeMirror()
const { EditorView, minimalSetup, html, htmlLanguage, linter, htmlflow } = await loadCodeMirror()
this.htmlLanguage = htmlLanguage
this.editorView = new EditorView({
doc: this.input.value,
@@ -85,8 +156,11 @@ export default targetable(class extends HTMLElement {
html(),
minimalSetup,
EditorView.lineWrapping,
linter((view) => this.buildDiagnostics(view.state.doc.toString()), { delay: 600 }),
EditorView.updateListener.of(update => {
if (update.docChanged) this.input.value = update.state.doc.toString()
if (update.docChanged) {
this.input.value = update.state.doc.toString()
}
}),
EditorView.theme({
'&': {
+1 -1
View File
@@ -35,7 +35,7 @@ export default class extends HTMLElement {
counter.innerText = `${count} / ${this.dataset.limit}`
}
if (this.dataset.bulkEnabled !== 'true') {
if (this.dataset.bulkEnabled !== 'true' && !/Hotwire Native iOS/.test(navigator.userAgent)) {
if (!bulkMessage) {
bulkMessage = document.createElement('span')
+9
View File
@@ -14,6 +14,15 @@ export default actionable(targetable(class extends HTMLElement {
this.addEventListener('dragover', (e) => e.preventDefault())
this.addEventListener('drop', this.onDrop)
document.addEventListener('turbo:submit-end', this.toggleLoading)
if (this.area) {
this.initArea()
} else {
queueMicrotask(() => this.initArea())
}
}
initArea () {
this.area?.addEventListener('dragover', this.onDragover)
this.area?.addEventListener('dragleave', this.onDragleave)
}
+43
View File
@@ -0,0 +1,43 @@
export default class extends HTMLElement {
connectedCallback () {
this.addEventListener('turbo:morph-element', this.onMorph)
this.show()
}
disconnectedCallback () {
this.removeEventListener('turbo:morph-element', this.onMorph)
clearTimeout(this.closeTimeout)
}
onMorph = (event) => {
if (event.target !== this) return
this.show()
}
show () {
clearTimeout(this.closeTimeout)
this.hideAnimation?.cancel()
this.animate([
{ transform: 'translateY(-100%)', opacity: 0 },
{ transform: 'translateY(0)', opacity: 1 }
], { duration: 200, easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)' })
this.closeTimeout = setTimeout(this.close, 2000)
}
close = () => {
clearTimeout(this.closeTimeout)
this.hideAnimation = this.animate([
{ transform: 'translateY(0)', opacity: 1 },
{ transform: 'translateY(-100%)', opacity: 0 }
], { duration: 150, easing: 'ease-in', fill: 'forwards' })
this.hideAnimation.onfinish = () => this.remove()
}
}
+33
View File
@@ -0,0 +1,33 @@
export default class extends HTMLElement {
connectedCallback () {
this.addEventListener('click', this.prompt)
document.addEventListener('turbo:submit-end', this.onSubmitEnd)
}
disconnectedCallback () {
this.removeEventListener('click', this.prompt)
document.removeEventListener('turbo:submit-end', this.onSubmitEnd)
}
get form () {
return this.querySelector('form')
}
prompt = () => {
const label = this.form.querySelector('label')
const input = this.form.querySelector(`#${CSS.escape(label.htmlFor)}`)
const value = window.prompt(label.textContent.trim(), input.value)?.trim()
if (!value || value === input.value) return
input.value = value
this.form.requestSubmit()
}
onSubmitEnd = (e) => {
if (e.detail.success && e.detail.formSubmission?.formElement === this.form && this.dataset.refresh !== 'false') {
window.Turbo.visit(window.location.href, { action: 'replace' })
}
}
}
+15
View File
@@ -0,0 +1,15 @@
export default class extends HTMLElement {
connectedCallback () {
this.addEventListener('click', this.onClick)
}
disconnectedCallback () {
this.removeEventListener('click', this.onClick)
}
onClick = (e) => {
e.preventDefault()
window.history.back()
}
}
+669
View File
@@ -0,0 +1,669 @@
import { target, targetable } from '@github/catalyst/lib/targetable'
import { actionable } from '@github/catalyst/lib/actionable'
import { LinkTooltip } from './markdown_editor'
async function loadTiptap () {
const [core, document, text, hardBreak, gapcursor, dropcursor, extensions, pmState, pmView] = await Promise.all([
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/core'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-document'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-text'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-hard-break'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-gapcursor'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-dropcursor'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extensions'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/pm/state'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/pm/view')
])
return {
Editor: core.Editor,
Extension: core.Extension,
Node: core.Node,
Mark: core.Mark,
Document: document.default || document,
Text: text.default || text,
HardBreak: hardBreak.default || hardBreak,
Gapcursor: gapcursor.default || gapcursor,
Dropcursor: dropcursor.default || dropcursor,
UndoRedo: extensions.UndoRedo,
Plugin: pmState.Plugin,
Decoration: pmView.Decoration,
DecorationSet: pmView.DecorationSet
}
}
const editorStylesheet = new CSSStyleSheet()
editorStylesheet.replaceSync(`
:host {
display: block;
max-height: 360px;
overflow: auto;
border-radius: 0 0 1rem 1rem;
}
.ProseMirror {
word-wrap: break-word;
-webkit-font-variant-ligatures: none;
font-variant-ligatures: none;
font-feature-settings: "liga" 0;
outline: none;
min-height: 220px;
padding: 12px;
}
img.ProseMirror-separator {
display: inline !important;
border: none !important;
margin: 0 !important;
width: 0 !important;
height: 0 !important;
}
.ProseMirror-gapcursor {
display: none;
pointer-events: none;
position: absolute;
margin: 0;
}
.ProseMirror-gapcursor:after {
content: "";
display: block;
position: absolute;
top: -2px;
width: 20px;
border-top: 1px solid black;
animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite;
}
@keyframes ProseMirror-cursor-blink {
to {
visibility: hidden;
}
}
.ProseMirror-hideselection *::selection {
background: transparent;
}
.ProseMirror-hideselection *::-moz-selection {
background: transparent;
}
.ProseMirror-hideselection * {
caret-color: transparent;
}
.ProseMirror-focused .ProseMirror-gapcursor {
display: block;
}
.variable-highlight {
background-color: #fef3c7;
padding: 1px 2px;
border-radius: 4px;
}
`)
const DROP_ATTRS = [
'srcdoc', 'xlink:href', 'srcset', 'action', 'formaction', 'poster',
'background', 'data', 'cite', 'ping', 'longdesc', 'manifest', 'profile'
]
const SAFE_URL_REGEXP = /^(?:https?:\/\/|data:image\/|blob:|mailto:|tel:|\{|#)/i
function isSafeAttr (name, value) {
const lowerName = name.toLowerCase()
if (lowerName.startsWith('on') || DROP_ATTRS.includes(lowerName)) return false
if ((lowerName === 'href' || lowerName === 'src') && !SAFE_URL_REGEXP.test(value.trim())) return false
return true
}
function collectDomAttrs (dom) {
const attrs = {}
for (let i = 0; i < dom.attributes.length; i++) {
const { name, value } = dom.attributes[i]
if (isSafeAttr(name, value)) attrs[name] = value
}
return { htmlAttrs: attrs }
}
function collectSpanDomAttrs (dom) {
const result = collectDomAttrs(dom)
if (result.htmlAttrs.style) {
const temp = document.createElement('span')
temp.style.cssText = result.htmlAttrs.style
if (['bold', '700'].includes(temp.style.fontWeight)) {
temp.style.removeProperty('font-weight')
}
if (temp.style.fontStyle === 'italic') {
temp.style.removeProperty('font-style')
}
if (temp.style.textDecoration === 'underline') {
temp.style.removeProperty('text-decoration')
}
if (temp.style.cssText) {
result.htmlAttrs.style = temp.style.cssText
} else {
delete result.htmlAttrs.style
}
}
return result
}
function buildExtensions ({ Node, Mark, Extension, Plugin, Decoration, DecorationSet }) {
const blockNode = (name, tag, content, extra = {}) => Node.create({
name,
group: 'block',
content: content || 'block+',
...extra,
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag, getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return [tag, node.attrs.htmlAttrs, 0]
}
})
const attrsMark = (name, tag) => Mark.create({
name,
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag, getAttrs: collectDomAttrs }]
},
renderHTML ({ mark }) {
return [tag, mark.attrs.htmlAttrs, 0]
}
})
const SpanMark = Mark.create({
name: 'span',
excludes: '',
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'span', getAttrs: collectSpanDomAttrs }]
},
renderHTML ({ mark }) {
return ['span', mark.attrs.htmlAttrs, 0]
}
})
const toggleMark = (name, renderTag, parseRules, shortcuts) => Mark.create({
name,
parseHTML () {
return parseRules
},
renderHTML () {
return [renderTag, 0]
},
addCommands () {
const commandName = `toggle${name[0].toUpperCase()}${name.slice(1)}`
return {
[commandName]: () => ({ commands }) => commands.toggleMark(name)
}
},
addKeyboardShortcuts () {
return {
[shortcuts]: () => this.editor.commands.toggleMark(name)
}
}
})
const Heading = Node.create({
name: 'heading',
group: 'block',
content: 'inline*',
addAttributes () {
return {
htmlAttrs: { default: {} },
level: { default: 1 }
}
},
parseHTML () {
return [1, 2, 3, 4, 5, 6].map((level) => ({
tag: `h${level}`,
getAttrs: (dom) => ({ ...collectDomAttrs(dom), level })
}))
},
renderHTML ({ node }) {
return [`h${node.attrs.level}`, node.attrs.htmlAttrs, 0]
}
})
const ImageNode = Node.create({
name: 'image',
inline: true,
group: 'inline',
draggable: true,
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'img', getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return ['img', node.attrs.htmlAttrs]
}
})
const HrNode = Node.create({
name: 'horizontalRule',
group: 'block',
atom: true,
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'hr', getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return ['hr', node.attrs.htmlAttrs]
}
})
const StyleNode = Node.create({
name: 'style',
group: 'block',
atom: true,
selectable: false,
addAttributes () {
return {
htmlAttrs: { default: {} },
css: { default: '' }
}
},
parseHTML () {
return [{ tag: 'style', getAttrs: (dom) => ({ ...collectDomAttrs(dom), css: dom.textContent }) }]
},
renderHTML ({ node }) {
return ['style', node.attrs.htmlAttrs, node.attrs.css]
}
})
const EmptySpanNode = Node.create({
name: 'emptySpan',
inline: true,
group: 'inline',
atom: true,
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{
tag: 'span',
priority: 60,
getAttrs (dom) {
if (dom.childNodes.length === 0 && dom.attributes.length > 0) {
return collectDomAttrs(dom)
}
return false
}
}]
},
renderHTML ({ node }) {
return ['span', node.attrs.htmlAttrs]
}
})
const LinkMark = Mark.create({
name: 'link',
inclusive: true,
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'a', getAttrs: collectDomAttrs }]
},
renderHTML ({ mark }) {
return ['a', mark.attrs.htmlAttrs, 0]
},
addCommands () {
return {
setLink: ({ href }) => ({ editor, commands }) => {
const htmlAttrs = { ...(editor.getAttributes('link').htmlAttrs || {}), href }
return commands.setMark('link', { htmlAttrs })
},
unsetLink: () => ({ commands }) => commands.unsetMark('link', { extendEmptyMarkRange: true })
}
}
})
const buildDecorations = (doc) => {
const decorations = []
const regex = /\{\{?[^{}\n]+\}\}?/g
doc.descendants((node, pos) => {
if (!node.isText) return
let match
while ((match = regex.exec(node.text)) !== null) {
decorations.push(
Decoration.inline(pos + match.index, pos + match.index + match[0].length, {
class: 'variable-highlight'
})
)
}
})
return DecorationSet.create(doc, decorations)
}
const VariableHighlight = Extension.create({
name: 'variableHighlight',
addProseMirrorPlugins () {
return [new Plugin({
state: {
init (_, { doc }) {
return buildDecorations(doc)
},
apply (tr, oldSet) {
return tr.docChanged ? buildDecorations(tr.doc) : oldSet
}
},
props: {
decorations (state) {
return this.getState(state)
}
}
})]
}
})
return [
blockNode('paragraph', 'p', 'inline*'),
Heading,
blockNode('section', 'section'),
blockNode('article', 'article', null, { isolating: true }),
blockNode('header', 'header', null, { isolating: true }),
blockNode('footer', 'footer', null, { isolating: true }),
blockNode('div', 'div'),
blockNode('center', 'center'),
blockNode('blockquote', 'blockquote'),
blockNode('pre', 'pre'),
blockNode('orderedList', 'ol', '(listItem | block)+'),
blockNode('bulletList', 'ul', '(listItem | block)+'),
blockNode('listItem', 'li', 'block+', { group: null }),
blockNode('table', 'table', '(colgroup | tableHead | tableBody | tableFoot | tableRow)+'),
blockNode('tableHead', 'thead', 'tableRow+', { group: null }),
blockNode('tableBody', 'tbody', 'tableRow+', { group: null }),
blockNode('tableFoot', 'tfoot', 'tableRow+', { group: null }),
blockNode('tableRow', 'tr', '(tableCell | tableHeader)+', { group: null }),
blockNode('tableCell', 'td', 'block*', { group: null }),
blockNode('tableHeader', 'th', 'block*', { group: null }),
blockNode('colgroup', 'colgroup', 'col*', { group: null }),
Node.create({
name: 'col',
atom: true,
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'col', getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return ['col', node.attrs.htmlAttrs]
}
}),
ImageNode,
HrNode,
StyleNode,
EmptySpanNode,
SpanMark,
LinkMark,
toggleMark('bold', 'strong', [{ tag: 'strong' }, { tag: 'b' }, { style: 'font-weight=bold' }, { style: 'font-weight=700' }], 'Mod-b'),
toggleMark('italic', 'em', [{ tag: 'em' }, { tag: 'i' }, { style: 'font-style=italic' }], 'Mod-i'),
toggleMark('underline', 'u', [{ tag: 'u' }, { style: 'text-decoration=underline' }], 'Mod-u'),
toggleMark('strike', 's', [{ tag: 's' }, { tag: 'del' }, { tag: 'strike' }, { style: 'text-decoration=line-through' }], 'Mod-Shift-s'),
attrsMark('subscript', 'sub'),
attrsMark('superscript', 'sup'),
VariableHighlight
]
}
export default actionable(targetable(class extends HTMLElement {
static [target.static] = [
'textarea',
'editorElement',
'boldButton',
'italicButton',
'underlineButton',
'linkButton',
'linkTooltipTemplate'
]
async connectedCallback () {
if (!this.textarea || !this.editorElement) return
this.id = `html-editor-${Math.random().toString(32).split('.')[1]}`
this.textarea.style.display = 'none'
this.adjustShortcutsForPlatform()
const tiptap = await loadTiptap()
const { Editor, Extension, Document, Text, HardBreak, UndoRedo, Gapcursor, Dropcursor } = tiptap
this.emailDocument = new DOMParser().parseFromString(this.textarea.value, 'text/html')
const shadow = this.editorElement.attachShadow({ mode: 'open' })
shadow.adoptedStyleSheets = [editorStylesheet]
this.emailDocument.head.querySelectorAll('style').forEach((style) => {
shadow.appendChild(style.cloneNode(true))
})
const container = document.createElement('div')
const bodyStyle = this.emailDocument.body.getAttribute('style')
if (bodyStyle) container.setAttribute('style', bodyStyle)
shadow.appendChild(container)
const LinkShortcut = Extension.create({
name: 'linkShortcut',
addKeyboardShortcuts: () => ({
'Mod-k': () => {
this.toggleLink()
return true
}
})
})
this.editor = new Editor({
element: container,
extensions: [
Document,
Text,
HardBreak,
UndoRedo,
Gapcursor,
Dropcursor,
...buildExtensions(tiptap),
LinkShortcut
],
content: this.emailDocument.body.innerHTML,
injectCSS: false,
editorProps: {
attributes: {
dir: 'auto'
},
handleDOMEvents: {
click: (_, event) => {
if (event.target.closest('a')) event.preventDefault()
return false
}
}
},
onUpdate: ({ editor }) => {
this.emailDocument.body.innerHTML = editor.getHTML()
this.textarea.value = this.emailDocument.documentElement.outerHTML
this.textarea.dispatchEvent(new Event('input', { bubbles: true }))
},
onSelectionUpdate: ({ editor }) => {
this.updateToolbarState()
this.handleLinkTooltip(editor)
},
onBlur: () => {
setTimeout(() => {
if (!this.linkTooltip.tooltip.contains(document.activeElement)) {
this.linkTooltip.hide()
}
}, 0)
}
})
this.linkTooltip = new LinkTooltip(this, this.editor, this.linkTooltipTemplate)
}
adjustShortcutsForPlatform () {
if ((navigator.userAgentData?.platform)?.toLowerCase()?.includes('mac')) {
this.querySelectorAll('.tooltip[data-tip]').forEach(tooltip => {
const tip = tooltip.getAttribute('data-tip')
if (tip && tip.includes('Ctrl')) {
tooltip.setAttribute('data-tip', tip.replace(/Ctrl/g, '⌘'))
}
})
}
}
bold (e) {
e.preventDefault()
this.editor.chain().focus().toggleBold().run()
this.updateToolbarState()
}
italic (e) {
e.preventDefault()
this.editor.chain().focus().toggleItalic().run()
this.updateToolbarState()
}
underline (e) {
e.preventDefault()
this.editor.chain().focus().toggleUnderline().run()
this.updateToolbarState()
}
linkSelection (e) {
e.preventDefault()
this.toggleLink()
this.updateToolbarState()
}
undo (e) {
e.preventDefault()
this.editor.chain().focus().undo().run()
this.updateToolbarState()
}
redo (e) {
e.preventDefault()
this.editor.chain().focus().redo().run()
this.updateToolbarState()
}
updateToolbarState () {
this.boldButton.classList.toggle('bg-base-200', this.editor.isActive('bold'))
this.italicButton.classList.toggle('bg-base-200', this.editor.isActive('italic'))
this.underlineButton.classList.toggle('bg-base-200', this.editor.isActive('underline'))
this.linkButton.classList.toggle('bg-base-200', this.editor.isActive('link'))
}
handleLinkTooltip (editor) {
const { from } = editor.state.selection
const mark = editor.state.doc.resolve(from).marks().find(m => m.type.name === 'link')
if (!mark) {
if (this.linkTooltip.isVisible()) this.linkTooltip.hide()
return
}
if (this.linkTooltip.isVisible() && this.linkTooltip.currentMark === mark) return
let linkStart = from
const start = editor.state.doc.resolve(from).start()
for (let i = from - 1; i >= start; i--) {
if (editor.state.doc.resolve(i).marks().some(m => m.eq(mark))) {
linkStart = i
} else {
break
}
}
this.linkTooltip.hide()
this.linkTooltip.show(mark.attrs.htmlAttrs?.href, linkStart > start ? linkStart - 1 : linkStart)
this.linkTooltip.currentMark = mark
}
toggleLink () {
if (this.editor.isActive('link')) {
this.linkTooltip.hide()
this.editor.chain().focus().extendMarkRange('link').unsetLink().run()
this.updateToolbarState()
} else {
const { from } = this.editor.state.selection
this.linkTooltip.hide()
this.linkTooltip.show(this.editor.getAttributes('link').htmlAttrs?.href, from, { focus: true })
}
}
insertVariable (e) {
const variable = e.target.closest('[data-variable]')?.dataset.variable
if (variable) {
const { from, to } = this.editor.state.selection
if (variable.includes('link') && from !== to) {
this.editor.chain().focus().setLink({ href: `{${variable}}` }).run()
} else {
this.editor.chain().focus().insertContent(`{${variable}}`).run()
}
}
}
disconnectedCallback () {
this.linkTooltip?.hide()
if (this.editor) {
this.editor.destroy()
}
}
}))
@@ -0,0 +1,52 @@
import { renderStreamMessage, session } from '@hotwired/turbo'
export default class extends HTMLElement {
connectedCallback () {
if (this.dataset.limit) this.replaceLimitParam()
if (!this.dataset.src) {
this.remove()
return
}
this.observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
this.load()
}
}, { rootMargin: '400px' })
this.observer.observe(this)
}
disconnectedCallback () {
this.observer?.disconnect()
}
replaceLimitParam () {
const url = new URL(window.location.href)
url.searchParams.set('limit', this.dataset.limit)
session.history.replace(url, session.history.restorationIdentifier)
session.view.lastRenderedLocation = url
}
load () {
if (this.loading || !this.dataset.src) return
this.loading = true
fetch(this.dataset.src, {
headers: { 'X-Turbo-Infinite-Scroll': 'true' }
}).then(async (response) => {
if (!response.ok) return
delete this.dataset.src
renderStreamMessage(await response.text())
}).finally(() => {
this.loading = false
})
}
}
Loaded 100 of 430 files, more files were not shown because too many files have changed in this diff. Show more