Compare commits

...
239 Commits
Author SHA1 Message Date
Alex Turchyn c6a7555f54 Merge from docusealco/wip 2026-10-05 12:27:31 +03:00
Pete Matsyburka 0772880bbc download dynamic document 2026-10-05 11:53:57 +03:00
Pete Matsyburka 73339d8cd9 fix editor undo 2026-10-05 11:45:16 +03:00
Pete Matsyburka 25504e8cb1 update routes 2026-10-04 11:04:14 +03:00
Pete Matsyburka 63a874528a improve text selection 2026-10-03 20:07:24 +03:00
Pete Matsyburka c1c99621ff sticky table tools 2026-10-03 18:35:29 +03:00
Pete Matsyburka 5c168f4a29 add page number style 2026-10-03 14:57:11 +03:00
Pete Matsyburka bef825c4e0 optimize add list style 2026-10-03 14:05:38 +03:00
Pete Matsyburka a990d22831 remove pinch to zoom 2026-10-03 14:05:38 +03:00
Pete Matsyburka 1687a372ae adjust editor 2026-10-03 14:05:38 +03:00
Alex Turchyn 9b267d4a07 upload inserted images as attachments 2026-10-03 14:05:38 +03:00
Pete Matsyburka 2189c2e9fe adjust fonts 2026-10-03 14:05:38 +03:00
Alex Turchyn 1e43fe8969 use Word line spacing units and join adjacent lists 2026-10-03 14:05:38 +03:00
Alex Turchyn f77a716658 remove spacing between list items like Word 2026-10-03 14:05:38 +03:00
Alex Turchyn a70ac571e3 simplify dynamic editor code 2026-10-03 14:05:38 +03:00
Alex Turchyn 4f5e72f983 fix table editing 2026-10-03 14:05:38 +03:00
Alex Turchyn 8f47ec6d4e add accessible names to table size counters 2026-10-03 14:05:38 +03:00
Alex Turchyn 9e1bbf2944 fix image resize handles 2026-10-03 14:05:38 +03:00
Alex Turchyn aa7d6e9aaa add pinch zoom and fix touch editing 2026-10-03 14:05:38 +03:00
Alex Turchyn 57c8be33ce adjust dynamic editor menus on mobile 2026-10-03 14:05:38 +03:00
Alex Turchyn ed7109bfc6 store list styles in dynamic document 2026-10-03 14:05:38 +03:00
Alex Turchyn 9e2590489b improve insert menu accessibility 2026-10-03 14:05:38 +03:00
Alex Turchyn d793f00b37 improve dynamic editor 2026-10-03 14:05:38 +03:00
Pete Matsyburka 5896c9a2aa strip edited image 2026-10-03 09:10:31 +03:00
Pete Matsyburka 78b0ce9828 add iban validation 2026-10-01 19:45:55 +03:00
Pete Matsyburka e187644716 refactor otp 2026-10-01 08:57:19 +03:00
Pete Matsyburka a847e7aa9c custom otp email 2026-09-30 19:56:12 +03:00
Pete Matsyburka 0905e7d215 reduce limit 2026-09-30 19:23:58 +03:00
Pete Matsyburka 91147d2f48 fix verify pdf 2026-09-30 19:22:41 +03:00
Pete Matsyburka acce6195b0 rescue validation errors 2026-09-30 13:38:47 +03:00
Pete Matsyburka ce9ab691ec fix document metadata 2026-09-30 12:57:48 +03:00
Pete Matsyburka 4190544edc use ivar 2026-09-28 23:28:13 +03:00
Pete Matsyburka db8e5efbd5 adjust i18n 2026-09-28 17:54:47 +03:00
Pete Matsyburka a5c6f4e40c update gem 2026-09-28 15:32:26 +03:00
Pete Matsyburka 7537d05d1f fix builder resize 2026-09-28 15:30:09 +03:00
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
323 changed files with 10531 additions and 3855 deletions

No files matched your search

+5
View File
@@ -1,6 +1,11 @@
--- ---
EnableDefaultLinters: true EnableDefaultLinters: true
linters: linters:
AllowedScriptType:
enabled: true
allowed_types:
- text/javascript
- application/json
ErbSafety: ErbSafety:
enabled: false enabled: false
HardCodedString: HardCodedString:
+1 -5
View File
@@ -152,11 +152,7 @@ jobs:
bundle config path vendor/bundle bundle config path vendor/bundle
bundle install --jobs 4 --retry 4 bundle install --jobs 4 --retry 4
yarn install yarn install
wget -O pdfium-linux.zip "https://github.com/docusealco/pdfium-binaries/releases/download/20260813/pdfium-musl-$(uname -m).zip" wget -O pdfium-linux.zip "https://github.com/docusealco/pdfium-binaries/releases/latest/download/pdfium-musl-$(uname -m).zip"
case "$(uname -m)" in
x86_64) echo "c5c7dde243ecb66ab0819c8193515ef38ad53549fe260f3c2dfd93ea56eda2e7 pdfium-linux.zip" ;;
aarch64) echo "64c4483449b1b4dccc696ad0c5c96e0b7f74dcc57b4f23c676b7a70671b0bbb5 pdfium-linux.zip" ;;
esac | sha256sum -c -
unzip -q pdfium-linux.zip -d /tmp/pdfium-linux unzip -q pdfium-linux.zip -d /tmp/pdfium-linux
cp /tmp/pdfium-linux/lib/libpdfium.so /usr/lib/libpdfium.so cp /tmp/pdfium-linux/lib/libpdfium.so /usr/lib/libpdfium.so
rm -rf pdfium-linux.zip /tmp/pdfium-linux rm -rf pdfium-linux.zip /tmp/pdfium-linux
+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/impallari/DancingScript/master/OFL.txt && \
wget https://raw.githubusercontent.com/notofonts/noto-fonts/refs/heads/main/LICENSE && \ 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 /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/20260813/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 \ case "$(uname -m)" in \
x86_64) echo "c5c7dde243ecb66ab0819c8193515ef38ad53549fe260f3c2dfd93ea56eda2e7 pdfium-linux.zip" ;; \ x86_64) echo "bbca8a648dbd1ba81f9c6c223f21dc394cf1ad381c6c17d60c6dcc4a7d3a2ae0 pdfium-linux.zip" ;; \
aarch64) echo "64c4483449b1b4dccc696ad0c5c96e0b7f74dcc57b4f23c676b7a70671b0bbb5 pdfium-linux.zip" ;; \ aarch64) echo "73b72ed79b9bfdd494dee7e863482c24971a8d4b11f6cd1289aa170d31304a1f pdfium-linux.zip" ;; \
esac | sha256sum -c - && \ esac | sha256sum -c - && \
mkdir -p /pdfium-linux && \ mkdir -p /pdfium-linux && \
unzip -q pdfium-linux.zip -d /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 'rouge', require: false
gem 'rqrcode', require: false gem 'rqrcode', require: false
gem 'ruby-vips' gem 'ruby-vips'
gem 'rubyXL', require: false gem 'rubyzip', require: false
gem 'shakapacker' gem 'shakapacker'
gem 'sidekiq' gem 'sidekiq'
gem 'sqlite3', require: false gem 'sqlite3', require: false
@@ -45,6 +45,7 @@ gem 'trilogy', require: false
gem 'turbo-rails' gem 'turbo-rails'
gem 'twitter_cldr', require: false gem 'twitter_cldr', require: false
gem 'tzinfo-data' gem 'tzinfo-data'
gem 'xlsxtream', require: false
group :development, :test do group :development, :test do
gem 'better_html' gem 'better_html'
+6 -5
View File
@@ -497,10 +497,7 @@ GEM
ruby-vips (2.3.0) ruby-vips (2.3.0)
ffi (~> 1.12) ffi (~> 1.12)
logger logger
rubyXL (3.4.35) rubyzip (3.7.0)
nokogiri (>= 1.10.8)
rubyzip (>= 3.2.2)
rubyzip (3.2.2)
securerandom (0.4.1) securerandom (0.4.1)
semantic_range (3.1.1) semantic_range (3.1.1)
shakapacker (9.7.0) shakapacker (9.7.0)
@@ -575,10 +572,13 @@ GEM
base64 base64
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
xlsxtream (3.1.0)
zip_kit (>= 6.2, < 7)
xpath (3.2.0) xpath (3.2.0)
nokogiri (~> 1.8) nokogiri (~> 1.8)
yaml (0.4.0) yaml (0.4.0)
zeitwerk (2.8.2) zeitwerk (2.8.2)
zip_kit (6.3.4)
PLATFORMS PLATFORMS
aarch64-linux aarch64-linux
@@ -638,7 +638,7 @@ DEPENDENCIES
rubocop-rails rubocop-rails
rubocop-rspec rubocop-rspec
ruby-vips ruby-vips
rubyXL rubyzip
shakapacker shakapacker
sidekiq sidekiq
simplecov simplecov
@@ -650,6 +650,7 @@ DEPENDENCIES
tzinfo-data tzinfo-data
web-console web-console
webmock webmock
xlsxtream
RUBY VERSION RUBY VERSION
ruby 4.0.5 ruby 4.0.5
+1 -1
View File
@@ -40,7 +40,7 @@ DocuSeal is an open source platform that provides secure and efficient digital d
- Files storage on disk or AWS S3, Google Storage, Azure Cloud - Files storage on disk or AWS S3, Google Storage, Azure Cloud
- Automatic PDF eSignature - Automatic PDF eSignature
- PDF signature verification - PDF signature verification
- Users management - User management
- Mobile-optimized - Mobile-optimized
- 7 UI languages with signing available in 14 languages - 7 UI languages with signing available in 14 languages
- API and Webhooks for integrations - API and Webhooks for integrations
+13 -9
View File
@@ -23,17 +23,20 @@ class AccountsController < ApplicationController
unless Docuseal.multitenant? unless Docuseal.multitenant?
@encrypted_config = EncryptedConfig.find_or_initialize_by(account: current_account, @encrypted_config = EncryptedConfig.find_or_initialize_by(account: current_account,
key: EncryptedConfig::APP_URL_KEY) 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]) if can?(:manage, @encrypted_config)
@encrypted_config.errors.add(:value, I18n.t('should_be_a_valid_url')) @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 end
@encrypted_config.save!
Docuseal.refresh_default_url_options!
end end
with_locale do with_locale do
@@ -47,7 +50,8 @@ class AccountsController < ApplicationController
authorize!(:manage, current_account) authorize!(:manage, current_account)
true_user.skip_reconfirmation! 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) true_user.account.update!(archived_at: Time.current)
# rubocop:disable Layout/LineLength # rubocop:disable Layout/LineLength
@@ -23,7 +23,7 @@ module Api
blob = ActiveStorage::Blob.find_by!(uuid: blob_uuid) 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) Rollbar.error('Dangerous extension') if defined?(Rollbar)
return head :unprocessable_content return head :unprocessable_content
@@ -19,7 +19,7 @@ module Api
return head :not_found unless blob 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) Rollbar.error('Dangerous extension') if defined?(Rollbar)
return head :unprocessable_content return head :unprocessable_content
@@ -98,7 +98,7 @@ module Api
render json: build_create_json(submissions) render json: build_create_json(submissions)
rescue Submitters::NormalizeValues::BaseError, Submissions::CreateFromSubmitters::BaseError, rescue Submitters::NormalizeValues::BaseError, Submissions::CreateFromSubmitters::BaseError,
DownloadUtils::UnableToDownload => e Submitters::SubmitValues::ValidationError, DownloadUtils::UnableToDownload => e
Rollbar.warning(e) if defined?(Rollbar) Rollbar.warning(e) if defined?(Rollbar)
render json: { error: e.message }, status: :unprocessable_content render json: { error: e.message }, status: :unprocessable_content
@@ -173,7 +173,6 @@ module Api
end end
def filter_submissions(submissions, params) 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? submissions = submissions.where(slug: params[:slug]) if params[:slug].present?
if params[:template_folder].present? if params[:template_folder].present?
@@ -1,10 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
module Api module Api
class SubmitterEmailClicksController < ApiBaseController class SubmitterEmailClicksController < ActionController::API
skip_before_action :authenticate_user!
skip_authorization_check
def create def create
@submitter = Submitter.find_by!(slug: params[:submitter_slug]) @submitter = Submitter.find_by!(slug: params[:submitter_slug])
@@ -1,10 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
module Api module Api
class SubmitterFormViewsController < ApiBaseController class SubmitterFormViewsController < ActionController::API
skip_before_action :authenticate_user!
skip_authorization_check
def create def create
@submitter = Submitter.find_by!(slug: params[:submitter_slug]) @submitter = Submitter.find_by!(slug: params[:submitter_slug])
+2 -1
View File
@@ -78,7 +78,8 @@ module Api
render json: Submitters::SerializeForApi.call(@submitter, with_template: false, with_urls: true, render json: Submitters::SerializeForApi.call(@submitter, with_template: false, with_urls: true,
with_events: false, params:) with_events: false, params:)
rescue Submitters::NormalizeValues::BaseError, DownloadUtils::UnableToDownload => e rescue Submitters::NormalizeValues::BaseError, Submitters::SubmitValues::ValidationError,
DownloadUtils::UnableToDownload => e
Rollbar.warning(e) if defined?(Rollbar) Rollbar.warning(e) if defined?(Rollbar)
render json: { error: e.message }, status: :unprocessable_content render json: { error: e.message }, status: :unprocessable_content
+6 -7
View File
@@ -17,24 +17,23 @@ module Api
def verify def verify
file = Base64.decode64(params[:file]) file = Base64.decode64(params[:file])
pdf = HexaPDF::Document.new(io: StringIO.new(file))
trusted_certs = Accounts.load_trusted_certs(current_account) trusted_certs = Accounts.load_trusted_certs(current_account)
is_checksum_found = CompletedDocument.exists?(sha256: Base64.urlsafe_encode64(Digest::SHA256.digest(file))) is_checksum_found = CompletedDocument.exists?(sha256: Base64.urlsafe_encode64(Digest::SHA256.digest(file)))
render json: { render json: {
checksum_status: is_checksum_found ? 'verified' : 'not_found', 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, verification_result: sig.messages.map { |m| { type: m.status || :info, content: m.text } },
signer_name: sig.signer_name, signer_name: sig.common_name,
signing_reason: sig.signing_reason, signing_reason: sig.reason,
signing_time: sig.signing_time, signing_time: sig.signing_time,
signature_type: sig.signature_type signature_type: sig.type
} }
end end
} }
rescue HexaPDF::MalformedPDFError rescue Pdfium::PdfiumError
render json: { error: 'Malformed PDF' }, status: :unprocessable_content render json: { error: 'Malformed PDF' }, status: :unprocessable_content
end end
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 :maybe_redirect_to_setup, unless: :signed_in?
before_action :authenticate_user!, unless: :devise_controller? 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, helper_method :button_title,
:current_account, :current_account,
:true_ability, :true_ability,
:form_link_host, :form_link_host,
:svg_icon :svg_icon,
:turbo_native_ios?,
:turbo_native_android?
impersonates :user, with: ->(uuid) { User.find_by(uuid:) } impersonates :user, with: ->(uuid) { User.find_by(uuid:) }
@@ -30,7 +34,7 @@ class ApplicationController < ActionController::Base
rescue_from RateLimit::LimitApproached do |e| rescue_from RateLimit::LimitApproached do |e|
Rollbar.error(e) if defined?(Rollbar) 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 end
if Rails.env.production? || Rails.env.test? if Rails.env.production? || Rails.env.test?
@@ -64,6 +68,28 @@ class ApplicationController < ActionController::Base
private 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(&) def with_locale(&)
return yield unless current_account return yield unless current_account
-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
@@ -46,6 +46,8 @@ module Mcp
authorize!(:create, @template) authorize!(:create, @template)
Templates.maybe_assign_access(@template)
if mcp_params['url'].present? if mcp_params['url'].present?
tempfile = Tempfile.new tempfile = Tempfile.new
tempfile.binmode tempfile.binmode
+5 -5
View File
@@ -14,14 +14,14 @@ class MfaSetupController < ApplicationController
def edit; end def edit; end
def create 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]) if current_user.validate_and_consume_otp!(params[:otp_attempt])
current_user.otp_required_for_login = true current_user.otp_required_for_login = true
current_user.save! current_user.save!
redirect_to settings_profile_index_path, notice: I18n.t('2fa_has_been_configured') redirect_to settings_profile_index_path, notice: I18n.t('2fa_has_been_configured')
else else
RateLimit.call("mfa-setup-otp-#{current_user.id}", limit: 5, ttl: 5.minutes, enabled: true)
@provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Docuseal.product_name) @provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Docuseal.product_name)
@error_message = I18n.t('code_is_invalid') @error_message = I18n.t('code_is_invalid')
@@ -31,13 +31,13 @@ class MfaSetupController < ApplicationController
end end
def destroy 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) current_user.update!(otp_required_for_login: false, otp_secret: nil)
redirect_to settings_profile_index_path, notice: I18n.t('2fa_has_been_removed') redirect_to settings_profile_index_path, notice: I18n.t('2fa_has_been_removed')
else else
RateLimit.call("mfa-setup-otp-#{current_user.id}", limit: 5, ttl: 5.minutes, enabled: true)
@error_message = I18n.t('code_is_invalid') @error_message = I18n.t('code_is_invalid')
render turbo_stream: turbo_stream.replace(:modal, template: 'mfa_setup/edit'), status: :unprocessable_content render turbo_stream: turbo_stream.replace(:modal, template: 'mfa_setup/edit'), status: :unprocessable_content
@@ -26,8 +26,9 @@ class PreviewDocumentPageController < ActionController::API
@template = attachment.record @template = attachment.record
preview_image = attachment.preview_images.joins(:blob) preview_image =
.find_by(blob: { filename: ["#{params[:id]}.png", "#{params[:id]}.jpg"] }) attachment.preview_images.joins(:blob)
.find_by(blob: { filename: ["#{params[:id].to_i}.png", "#{params[:id].to_i}.jpg"] })
if preview_image if preview_image
return redirect_to preview_image.url(time: ActiveStorage::Attachment.service_url_time), return redirect_to preview_image.url(time: ActiveStorage::Attachment.service_url_time),
+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
+21 -3
View File
@@ -3,6 +3,10 @@
class SessionsController < Devise::SessionsController class SessionsController < Devise::SessionsController
before_action :configure_permitted_parameters 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 around_action :with_browser_locale
def create def create
@@ -11,12 +15,26 @@ class SessionsController < Devise::SessionsController
if Docuseal.multitenant? && !User.exists?(email:) if Docuseal.multitenant? && !User.exists?(email:)
Rollbar.warning('Sign in new user') if defined?(Rollbar) Rollbar.warning('Sign in new user') if defined?(Rollbar)
return redirect_to new_registration_path(sign_up: true, user: sign_in_params.slice(:email)), respond_to do |format|
notice: I18n.t('create_a_new_account') 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 end
if User.exists?(email:, otp_required_for_login: true) && sign_in_params[:otp_attempt].blank? 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 end
super super
+48 -133
View File
@@ -8,13 +8,9 @@ class StartFormController < ApplicationController
around_action :with_browser_locale, only: %i[show update completed] around_action :with_browser_locale, only: %i[show update completed]
before_action :maybe_redirect_com, only: %i[show completed] before_action :maybe_redirect_com, only: %i[show completed]
before_action :load_resubmit_submitter, only: :update
before_action :load_template before_action :load_template
before_action :authorize_start!, only: :update before_action :authorize_start!, only: :update
COOKIES_TTL = 12.hours
COOKIES_DEFAULTS = { httponly: true, secure: Rails.env.production? }.freeze
def show def show
if @template.preferences['require_phone_2fa'] || @template.preferences['require_email_2fa'] if @template.preferences['require_phone_2fa'] || @template.preferences['require_email_2fa']
raise ActionController::RoutingError, I18n.t('not_found') raise ActionController::RoutingError, I18n.t('not_found')
@@ -23,8 +19,7 @@ class StartFormController < ApplicationController
if @template.shared_link? if @template.shared_link?
@submitter = @template.submissions.new(account_id: @template.account_id) @submitter = @template.submissions.new(account_id: @template.account_id)
.submitters.new(account_id: @template.account_id, .submitters.new(account_id: @template.account_id,
uuid: (filter_undefined_submitters(@template).first || uuid: Submitters::StartForm.first_submitter_uuid(@template))
@template.submitters.first)['uuid'])
render :email_verification if params[:email_verification] render :email_verification if params[:email_verification]
else else
Rollbar.warning("Not shared template: #{@template.id}") if defined?(Rollbar) Rollbar.warning("Not shared template: #{@template.id}") if defined?(Rollbar)
@@ -41,24 +36,28 @@ class StartFormController < ApplicationController
if @submitter.completed_at? if @submitter.completed_at?
redirect_to start_form_completed_path(@template.slug, submitter_params.compact_blank) redirect_to start_form_completed_path(@template.slug, submitter_params.compact_blank)
else 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 @error_message = multiple_submitters_error_message
return render :show, status: :unprocessable_content return render :show, status: :unprocessable_content
end end
if (is_new_record = @submitter.new_record?) 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) Submissions::AssignDefinedSubmitters.call(@submitter.submission)
else else
@submitter.assign_attributes(ip: request.remote_ip, ua: request.user_agent) @submitter.assign_attributes(ip: request.remote_ip, ua: request.user_agent)
end end
if @template.preferences['shared_link_2fa'] == true if require_link_2fa?(@template, @submitter)
handle_require_2fa(@submitter, is_new_record:) handle_require_2fa(@template, @submitter)
elsif @submitter.errors.blank? && @submitter.save 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) redirect_to submit_form_path(@submitter.slug)
else else
@@ -88,41 +87,40 @@ class StartFormController < ApplicationController
private private
def enqueue_new_submitter_jobs(submitter) def find_or_initialize_submitter(template, submitter_params)
WebhookUrls.enqueue_events(submitter.submission, 'submission.created') required_fields = template.preferences.fetch('link_form_fields', ['email'])
SearchEntries.enqueue_reindex(submitter) blank_fields = required_fields.select { |key| submitter_params[key].blank? }
expire_at = submitter.submission.expire_at submitter =
if blank_fields.present?
return unless expire_at Submitter.new(submitter_params)
else
ProcessSubmissionExpiredJob.perform_at(expire_at, 'submission_id' => submitter.submission_id, Submitters::StartForm.find_or_initialize_submitter(
'expire_at' => expire_at.to_i) template, submitter_params, exclude_completed: params[:resubmit].present?,
end request:, current_user:
)
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)
end end
blank_fields.each { |key| submitter.errors.add(key.to_sym, :blank) }
submitter
end end
def can_resubmit?(submitter) def require_link_2fa?(template, submitter)
submitter.completed_at? && submitter.completed_at > 14.days.ago && return true if template.preferences['shared_link_2fa'] == true
%w[api embed mcp].exclude?(submitter.submission.source) && return false if cookies.encrypted[:start_form_slug] == submitter.slug
submitter.account.account_configs.find_or_initialize_by(key: AccountConfig::ALLOW_TO_RESUBMIT).value != false return false if current_user && submitter.email == current_user.email &&
current_user.account_id == submitter.account_id
!submitter.new_record?
end end
def authorize_start! def authorize_start!
is_archived = @template.archived_at? || @template.account.archived_at? 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 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)) return if @template.shared_link? || (current_user && current_ability.can?(:read, @template))
Rollbar.warning("Not shared template: #{@template.id}") if defined?(Rollbar) Rollbar.warning("Not shared template: #{@template.id}") if defined?(Rollbar)
@@ -130,90 +128,14 @@ class StartFormController < ApplicationController
redirect_to start_form_path(@template.slug) redirect_to start_form_path(@template.slug)
end 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.non_expired.active)
.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 = Submitter.new(find_params) if submitter.submission&.completed_at? && submitter.viewer?
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 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| params.require(:submitter).permit(:email, :phone, :name).tap do |attrs|
attrs[:email] = Submissions.normalize_email(attrs[:email]) attrs[:email] = Submissions.normalize_email(attrs[:email])
end end
end end
def load_template def load_template
@template = @template = Template.find_by!(slug: params[:slug] || params[:start_form_slug])
if @resubmit_submitter
@resubmit_submitter.template
else
Template.find_by!(slug: params[:slug] || params[:start_form_slug])
end
end end
def multiple_submitters_error_message def multiple_submitters_error_message
@@ -224,38 +146,31 @@ class StartFormController < ApplicationController
end end
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? return render :show, status: :unprocessable_content if submitter.errors.present?
is_otp_verified = Submitters.verify_link_otp!(params[:one_time_code], submitter) if Submitters::StartForm.verify_2fa_and_save_submitter(submitter, request, is_new_record: submitter.new_record?)
redirect_to submit_form_path(submitter.slug)
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
else 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:) Submitters.send_shared_link_email_verification_code(submitter, request:)
render :email_verification render :email_verification
end 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 rescue Submitters::UnableToSendCode, Submitters::InvalidOtp => e
redirect_to start_form_path(submitter.submission.template.slug, redirect_to start_form_path(template.slug, params: submitter_params.merge(email_verification: true)),
params: submitter_params.merge(email_verification: true)),
alert: e.message alert: e.message
rescue RateLimit::LimitApproached rescue RateLimit::LimitApproached
redirect_to start_form_path(submitter.submission.template.slug, redirect_to start_form_path(template.slug, params: submitter_params.merge(email_verification: true)),
params: submitter_params.merge(email_verification: true)),
alert: I18n.t(:too_many_attempts) alert: I18n.t(:too_many_attempts)
end end
end end
@@ -1,6 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class StartFormEmail2faSendController < ApplicationController class StartFormEmail2faSendController < ApplicationController
layout 'form'
around_action :with_browser_locale around_action :with_browser_locale
skip_before_action :authenticate_user! skip_before_action :authenticate_user!
@@ -13,12 +15,20 @@ class StartFormEmail2faSendController < ApplicationController
@submitter = @template.submissions.new(account_id: @template.account_id) @submitter = @template.submissions.new(account_id: @template.account_id)
.submitters.new(**submitter_params, 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:) Submitters.send_shared_link_email_verification_code(@submitter, request:)
redir_params = { notice: I18n.t(:code_has_been_resent) } if params[:resend] 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)), redirect_to start_form_path(@template.slug, params: submitter_params.merge(email_verification: true)),
**redir_params **redir_params
rescue Submitters::BouncedEmail => e
@error_message = e.message
render 'start_form/error', status: :unprocessable_content
rescue Submitters::UnableToSendCode => e rescue Submitters::UnableToSendCode => e
redirect_to start_form_path(@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 alert: e.message
@@ -36,14 +46,24 @@ class StartFormEmail2faSendController < ApplicationController
return redirect_to start_form_path(@template.slug) if is_archived return redirect_to start_form_path(@template.slug) if is_archived
return if (@template.shared_link? || (current_user && current_ability.can?(:read, @template))) && return if (@template.shared_link? || (current_user && current_ability.can?(:read, @template))) &&
@template.preferences['shared_link_2fa'] == true (@template.preferences['shared_link_2fa'] == true || submitter_exists?(@template, params))
Rollbar.warning("Not shared template: #{@template.id}") if defined?(Rollbar) Rollbar.warning("Not 2FA shared template: #{@template.id}") if defined?(Rollbar)
redirect_to start_form_path(@template.slug) redirect_to start_form_path(@template.slug)
end 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 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
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
@@ -29,5 +29,17 @@ class SubmissionsArchivedController < ApplicationController
ActiveRecord::Associations::Preloader.new(records: @submissions.filter_map(&:template), ActiveRecord::Associations::Preloader.new(records: @submissions.filter_map(&:template),
associations: :author).call 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
end end
+18 -1
View File
@@ -18,6 +18,16 @@ class SubmissionsController < ApplicationController
'payment' => 'credit_card', 'verification' => 'id', 'kba' => 'user_scan' 'payment' => 'credit_card', 'verification' => 'id', 'kba' => 'user_scan'
}.freeze }.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 def show
@submission = Submissions.preload_with_pages(@submission) @submission = Submissions.preload_with_pages(@submission)
@@ -83,7 +93,14 @@ class SubmissionsController < ApplicationController
I18n.t('submission_has_been_archived') I18n.t('submission_has_been_archived')
end 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 end
private private
@@ -30,5 +30,17 @@ class SubmissionsDashboardController < ApplicationController
ActiveRecord::Associations::Preloader.new(records: @submissions.filter_map(&:template), ActiveRecord::Associations::Preloader.new(records: @submissions.filter_map(&:template),
associations: :author).call 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
end end
@@ -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
@@ -29,7 +29,9 @@ class SubmitFormCompletedDownloadController < ApplicationController
Submissions::EnsureResultGenerated.call(last_submitter) Submissions::EnsureResultGenerated.call(last_submitter)
if !signature_valid && !current_user_submitter?(last_submitter) if !signature_valid && !current_user_submitter?(last_submitter)
return head :not_found unless Submitters::AuthorizedForForm.call(@submitter, current_user, request) unless Submitters::AuthorizedForForm.call(@submitter, current_user, request, with_order: true)
return head :not_found
end
if last_submitter.completed_at < TTL.ago if last_submitter.completed_at < TTL.ago
Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar) Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar)
@@ -48,7 +50,7 @@ class SubmitFormCompletedDownloadController < ApplicationController
private private
def submitter_slug def submitter_slug
params[:submit_form_slug] || params[:submitter_slug] || params[:submitter_id] params[:submit_form_slug] || params[:submitter_slug]
end end
def respond_with_combined(submitter) def respond_with_combined(submitter)
+13 -13
View File
@@ -31,24 +31,22 @@ class SubmitFormController < ApplicationController
Submissions.preload_with_pages(submission) Submissions.preload_with_pages(submission)
Submitters::MaybeUpdateDefaultValues.call(@submitter, current_user)
@attachments_index = build_attachments_index(submission) @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] return unless @form_configs[:prefill_signature]
if (user_signature = UserConfigs.load_signature(current_user)) signature = UserConfigs.load_signature(current_user) ||
@signature_attachment = ActiveStorage::Attachment.find_or_create_by!( Submitters::FindRememberedSignature.call(@submitter, params, cookies)
blob_id: user_signature.blob_id,
name: 'attachments',
record: @submitter
)
end
@signature_attachment ||= @prefill_signature = Submitters.build_prefill_attachment_data(@submitter, signature) if signature
Submitters::MaybeAssignDefaultBrowserSignature.call(@submitter, params, cookies, @attachments_index.values)
@attachments_index[@signature_attachment.uuid] = @signature_attachment if @signature_attachment
end end
def update def update
@@ -107,6 +105,8 @@ class SubmitFormController < ApplicationController
submitter_version = SubmitterVersion.find_by!(slug: params[:slug] || params[:submit_form_slug]) submitter_version = SubmitterVersion.find_by!(slug: params[:slug] || params[:submit_form_slug])
@submitter = submitter_version.submitter @submitter = submitter_version.submitter
maybe_render_locked_page
end end
private private
@@ -19,14 +19,15 @@ class SubmitFormDeclineController < ApplicationController
request) request)
ApplicationRecord.transaction do 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] }) SubmissionEvents.create_with_tracking_data(@submitter, 'decline_form', request, { reason: params[:reason] })
end end
user = @submitter.submission.created_by_user || @submitter.template.author 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! SubmitterMailer.declined_email(@submitter, user).deliver_later!
end end
@@ -15,7 +15,8 @@ class SubmitFormDelegateController < ApplicationController
@submitter.viewer? || @submitter.viewer? ||
!Submitters::AuthorizedForForm.call(@submitter, !Submitters::AuthorizedForForm.call(@submitter,
current_user, current_user,
request) request,
with_order: true)
@submitter.account.account_configs.find_by!(key: AccountConfig::ALLOW_TO_DELEGATE_KEY, value: true) @submitter.account.account_configs.find_by!(key: AccountConfig::ALLOW_TO_DELEGATE_KEY, value: true)
@@ -32,7 +33,8 @@ class SubmitFormDelegateController < ApplicationController
SubmissionEvents.create_with_tracking_data(@submitter, 'delegate_form', request, SubmissionEvents.create_with_tracking_data(@submitter, 'delegate_form', request,
{ old_email: @submitter.email, email: }) { 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 end
SendSubmitterInvitationEmailJob.perform_async('submitter_id' => @submitter.id) SendSubmitterInvitationEmailJob.perform_async('submitter_id' => @submitter.id)
@@ -19,7 +19,8 @@ class SubmitFormDownloadController < ApplicationController
AccountConfig.exists?(account_id: @submitter.account_id, AccountConfig.exists?(account_id: @submitter.account_id,
key: AccountConfig::ALLOW_TO_PARTIAL_DOWNLOAD_KEY, key: AccountConfig::ALLOW_TO_PARTIAL_DOWNLOAD_KEY,
value: false) || 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 last_completed_submitter = @submitter.submission.submitters
.where.not(id: @submitter.id) .where.not(id: @submitter.id)
@@ -15,7 +15,7 @@ class SubmitFormDrawSignatureController < ApplicationController
return redirect_to submit_form_path(@submitter.slug) if @submitter.viewer? return redirect_to submit_form_path(@submitter.slug) if @submitter.viewer?
if @submitter.submission.template&.archived_at? || @submitter.submission.archived_at? || 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) return redirect_to submit_form_path(@submitter.slug)
end end
@@ -8,19 +8,17 @@ class SubmitFormEmail2fasController < ApplicationController
before_action :load_submitter before_action :load_submitter
COOKIES_TTL = 12.hours
COOKIES_DEFAULTS = { httponly: true, secure: Rails.env.production? }.freeze
def create def create
RateLimit.call("verify-2fa-code-#{@submitter.id}", limit: 2, ttl: 45.seconds, enabled: true) RateLimit.call("verify-2fa-code-#{@submitter.id}", limit: 2, ttl: 45.seconds, enabled: true)
value = [@submitter.email.downcase.strip, @submitter.slug].join(':') value = [@submitter.email.downcase.squish, @submitter.slug].join(':')
if EmailVerificationCodes.verify(params[:one_time_code].to_s.gsub(/\D/, ''), value) if OtpChallenges.verify(params[:one_time_code].to_s.gsub(/\D/, ''), value, purpose: 'form_email_2fa')
SubmissionEvents.create_with_tracking_data(@submitter, 'email_verified', request, { email: @submitter.email }) SubmissionEvents.create_with_tracking_data(@submitter, 'email_verified', request, { email: @submitter.email })
cookies.encrypted[:email_2fa_slug] = 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) redirect_to submit_form_path(@submitter.slug)
else else
@@ -37,6 +35,12 @@ class SubmitFormEmail2fasController < ApplicationController
RateLimit.call("send-email-code-#{@submitter.id}", limit: 2, ttl: 45.seconds, enabled: true) 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) SendSubmitterVerificationEmailJob.perform_async('submitter_id' => @submitter.id, 'locale' => I18n.locale.to_s)
redir_params = params[:resend] ? { alert: I18n.t(:code_has_been_resent) } : {} 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) @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 end
@submitter.submission.update!(submitters_order: :preserved) @submitter.submission.update!(submitters_order: :preserved)
@@ -17,12 +17,12 @@ class SubmitFormMetadataController < ApplicationController
submission.schema_documents.find { |a| a.uuid == item['attachment_uuid'] } submission.schema_documents.find { |a| a.uuid == item['attachment_uuid'] }
end end
ActiveRecord::Associations::Preloader.new(records: documents, associations: %i[blob record]).call ActiveRecord::Associations::Preloader.new(records: documents, associations: :blob).call
text_runs = documents.to_h do |document| text_runs = documents.to_h do |document|
[ [
document.uuid, document.uuid,
DocumentMetadatas.find_or_create_for_document(document, account_id: document.record.account_id).text_runs DocumentMetadatas.find_or_create_for_document(document, account_id: submission.account_id).text_runs
] ]
end end
@@ -39,6 +39,6 @@ class SubmitFormMetadataController < ApplicationController
!submitter.submission.expired? && !submitter.submission.expired? &&
!submitter.submission.template&.archived_at? && !submitter.submission.template&.archived_at? &&
!submitter.account.archived_at? && !submitter.account.archived_at? &&
Submitters::AuthorizedForForm.call(submitter, current_user, request) Submitters::AuthorizedForForm.call(submitter, current_user, request, with_order: true)
end end
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
@@ -6,7 +6,17 @@ class TemplateDocumentsController < ApplicationController
FILES_TTL = 5.minutes FILES_TTL = 5.minutes
def index def index
render json: @template.schema_documents.map { |d| ActiveStorage::Blob.proxy_path(d.blob, expires_at: FILES_TTL.from_now.to_i) } documents = @template.schema.map do |item|
if item['dynamic']
dynamic_document = @template.schema_dynamic_documents.find { |e| e.uuid == item['attachment_uuid'] }
DynamicDocuments::EnsureVersionGenerated.call(dynamic_document).document_attachment
else
@template.schema_documents.find { |e| e.uuid == item['attachment_uuid'] }
end
end
render json: documents.map { |d| ActiveStorage::Blob.proxy_path(d.blob, expires_at: FILES_TTL.from_now.to_i) }
end end
def create def create
+24 -4
View File
@@ -13,10 +13,7 @@ class TemplateFoldersController < ApplicationController
.where(folder: [@template_folder, *(params[:q].present? ? @template_folder.subfolders : [])]) .where(folder: [@template_folder, *(params[:q].present? ? @template_folder.subfolders : [])])
.preload(:author, :template_accesses) .preload(:author, :template_accesses)
@template_folders = @template_folders = filter_folders(@template_folder)
@template_folder.subfolders.where(id: Template.accessible_by(current_ability).active.select(:folder_id))
@template_folders = TemplateFolders.search(@template_folders, params[:q])
@template_folders = TemplateFolders.sort(@template_folders, current_user, selected_order) @template_folders = TemplateFolders.sort(@template_folders, current_user, selected_order)
if @templates.exists? if @templates.exists?
@@ -40,6 +37,8 @@ class TemplateFoldersController < ApplicationController
@templates = @templates.none @templates = @templates.none
end end
render_infinite_scroll(@pagy, @templates, @template_folders) if turbo_infinite_scroll?
end end
def edit; end def edit; end
@@ -55,6 +54,27 @@ class TemplateFoldersController < ApplicationController
private 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 def selected_order
@selected_order ||= @selected_order ||=
if can?(:manage, :countless) if can?(:manage, :countless)
@@ -12,13 +12,23 @@ class TemplatesArchivedController < ApplicationController
@pagy, @templates = pagy_auto(@templates.select_for_list, limit: 12) @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 end
private 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 def load_related_submissions
related_submissions = related_submissions =
Submission.accessible_by(current_ability) Submission.accessible_by(current_ability)
@@ -16,7 +16,19 @@ class TemplatesArchivedSubmissionsController < ApplicationController
end end
@pagy, @submissions = pagy_auto(@submissions.select_for_list.preload(submitters: :start_form_submission_events)) @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 rescue ActiveRecord::RecordNotFound
redirect_to root_path redirect_to root_path
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: true }),
turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: })
]
end
end end
+15 -1
View File
@@ -21,6 +21,8 @@ class TemplatesController < ApplicationController
@pagy, @submissions = @pagy, @submissions =
pagy_auto(submissions.select_for_list.preload(:template_accesses, submitters: :start_form_submission_events)) 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 rescue ActiveRecord::RecordNotFound
redirect_to root_path redirect_to root_path
end end
@@ -81,11 +83,23 @@ class TemplatesController < ApplicationController
I18n.t('template_has_been_archived') I18n.t('template_has_been_archived')
end 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 end
private 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 def template_params
params.require(:template).permit( params.require(:template).permit(
:name, :name,
@@ -43,10 +43,26 @@ class TemplatesDashboardController < ApplicationController
@related_submissions_pagy, @related_submissions = load_related_submissions @related_submissions_pagy, @related_submissions = load_related_submissions
end end
end end
render_infinite_scroll(@pagy, @templates, @template_folders) if turbo_infinite_scroll?
end end
private 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) def load_templates(templates, folders_count, show_shared_inline: false)
templates = templates.preload(:author, :template_accesses) 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) pinned_count = (show_default_in_grid ? 1 : 0) + (show_shared_in_grid ? 1 : 0)
pagy = Pagy::Offset.new(count: folders_count + pinned_count, pagy, = pagy(template_folders, count: folders_count + pinned_count,
page: show_inline_folders ? 1 : [params[:page].to_s.to_i, 1].max, page: show_inline_folders ? 1 : [params[:page].to_s.to_i, 1].max,
limit: FOLDERS_PER_PAGE, limit: FOLDERS_PER_PAGE,
raise_range_error: true) raise_range_error: true)
show_default_folder = show_default_in_grid && pagy.page == 1 show_default_folder = show_default_in_grid && pagy.page == 1
show_shared_folder = show_shared_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_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) template_folders = template_folders.offset(folder_offset).limit(folder_limit)
@@ -13,9 +13,10 @@ class TemplatesDebugController < ApplicationController
data = attachment.download data = attachment.download
unless attachment.image? unless attachment.image?
pdf = HexaPDF::Document.new(io: StringIO.new(data)) fields =
Pdfium::Document.open_io(StringIO.new(data)) do |doc|
fields = Templates::FindAcroFields.call(pdf, attachment, data) Templates::FindPdfiumAcroFields.call(attachment, doc, data)
end
end end
# fields, = Templates::DetectFields.call(StringIO.new(data), attachment:) if fields.blank? # fields, = Templates::DetectFields.call(StringIO.new(data), attachment:) if fields.blank?
@@ -10,8 +10,14 @@ class TemplatesDetectFieldsController < ApplicationController
sse = SSE.new(response.stream) sse = SSE.new(response.stream)
documents = @template.schema_documents.preload(:blob) documents =
documents = documents.where(uuid: params[:attachment_uuid]) if params[:attachment_uuid].present? 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 page_number = params[:page].presence&.to_i
+11 -2
View File
@@ -19,13 +19,22 @@ class TemplatesSharedController < ApplicationController
@pagy, @templates = pagy_auto(@templates.select_for_list, limit: 12) @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 end
private 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:) def load_related_submissions(is_archived:)
shared_templates = Templates.shared(current_user) shared_templates = Templates.shared(current_user)
shared_templates = is_archived ? shared_templates.archived : shared_templates.active shared_templates = is_archived ? shared_templates.archived : shared_templates.active
@@ -63,7 +63,7 @@ class TemplatesUploadsController < ApplicationController
tempfile.write(DownloadUtils.call(params[:url], validate: true).body) tempfile.write(DownloadUtils.call(params[:url], validate: true).body)
tempfile.rewind 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])) filename ||= File.basename(URI.decode_www_form_component(params[:url]))
file = ActionDispatch::Http::UploadedFile.new( file = ActionDispatch::Http::UploadedFile.new(
@@ -6,6 +6,7 @@ class TestingAccountsController < ApplicationController
def create def create
authorize!(:manage, current_account) authorize!(:manage, current_account)
authorize!(:manage, current_user) authorize!(:manage, current_user)
authorize!(:manage, EncryptedConfig)
impersonate_user(Accounts.find_or_create_testing_user(true_user.account)) impersonate_user(Accounts.find_or_create_testing_user(true_user.account))
@@ -2,6 +2,8 @@
class TestingApiSettingsController < ApplicationController class TestingApiSettingsController < ApplicationController
def index def index
raise CanCan::AccessDenied unless current_account.testing?
authorize!(:manage, current_user.access_token) authorize!(:manage, current_user.access_token)
@webhook_url = current_account.webhook_urls.first_or_initialize @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? 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." if extension
raise Submitters::MaliciousFileExtension, "File type '.#{extension}' is not allowed."
end
blob = ActiveStorage::Blob.create_and_upload!(io: file.open, blob = ActiveStorage::Blob.create_and_upload!(io: file.open,
filename: file.original_filename, 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? 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." if extension
raise Submitters::MaliciousFileExtension, "File type '.#{extension}' is not allowed."
end
blob = ActiveStorage::Blob.create_and_upload!(io: file.open, blob = ActiveStorage::Blob.create_and_upload!(io: file.open,
filename: file.original_filename, filename: file.original_filename,
+1 -1
View File
@@ -20,7 +20,7 @@ class UsersController < ApplicationController
respond_to do |format| respond_to do |format|
format.html do format.html do
@pagy, @users = pagy(@users) @pagy, @users = pagy(@users, limit: turbo_native_app? ? 1_000 : Pagy::OPTIONS[:limit])
end end
if current_ability.can?(:manage, current_account) if current_ability.can?(:manage, current_account)
+9 -1
View File
@@ -3,11 +3,19 @@
class WebhookSecretController < ApplicationController class WebhookSecretController < ApplicationController
load_and_authorize_resource :webhook_url, parent: false load_and_authorize_resource :webhook_url, parent: false
HEADER_NAME_REGEXP = /\A[\w-]+\z/
def show; end def show; end
def update 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_url.update!(secret: {
webhook_secret_params[:key] => webhook_secret_params[:value] key => webhook_secret_params[:value]
}.compact_blank) }.compact_blank)
redirect_back(fallback_location: settings_webhook_path(@webhook_url), redirect_back(fallback_location: settings_webhook_path(@webhook_url),
+189 -38
View File
@@ -1,5 +1,5 @@
import '@hotwired/turbo' import '@hotwired/turbo-rails'
import { encodeMethodIntoRequestBody } from '@hotwired/turbo-rails/app/javascript/turbo/fetch_requests' import './native'
import { createApp, reactive } from 'vue' import { createApp, reactive } from 'vue'
import TemplateBuilder from './template_builder/builder' import TemplateBuilder from './template_builder/builder'
@@ -9,6 +9,15 @@ import ToggleVisible from './elements/toggle_visible'
import ToggleCookies from './elements/toggle_cookies' import ToggleCookies from './elements/toggle_cookies'
import DisableHidden from './elements/disable_hidden' import DisableHidden from './elements/disable_hidden'
import TurboModal from './elements/turbo_modal' 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 FileDropzone from './elements/file_dropzone'
import MenuActive from './elements/menu_active' import MenuActive from './elements/menu_active'
import ClipboardCopy from './elements/clipboard_copy' import ClipboardCopy from './elements/clipboard_copy'
@@ -59,10 +68,8 @@ import ConfirmUpload from './elements/confirm_upload'
import ScrollFade from './elements/scroll_fade' import ScrollFade from './elements/scroll_fade'
import OpenModalMobile from './elements/open_modal_mobile' import OpenModalMobile from './elements/open_modal_mobile'
import HistoryBack from './elements/history_back' import HistoryBack from './elements/history_back'
import DatePlaceholder from './elements/date_placeholder'
import * as TurboInstantClick from './lib/turbo_instant_click' import FlashMessage from './elements/flash_message'
TurboInstantClick.start()
document.addEventListener('turbo:before-cache', () => { document.addEventListener('turbo:before-cache', () => {
window.flash?.remove() window.flash?.remove()
@@ -74,10 +81,18 @@ document.addEventListener('keyup', (e) => {
} }
}) })
document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody) document.addEventListener('turbo:morph', () => {
document.addEventListener('turbo:before-fetch-request', (event) => { if (document.activeElement?.closest('.dropdown')) {
event.detail.fetchOptions.headers['X-Turbo'] = 'true' 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) => { document.addEventListener('turbo:submit-end', async (event) => {
const resp = event.detail?.formSubmission?.result?.fetchResponse?.response const resp = event.detail?.formSubmission?.result?.fetchResponse?.response
@@ -85,11 +100,23 @@ document.addEventListener('turbo:submit-end', async (event) => {
return 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 url = URL.createObjectURL(await resp.blob())
const link = document.createElement('a') const link = document.createElement('a')
link.href = url link.href = url
link.setAttribute('download', decodeURIComponent(resp.headers.get('content-disposition').split('"')[1])) link.setAttribute('download', filename)
document.body.appendChild(link) document.body.appendChild(link)
@@ -105,6 +132,15 @@ const safeRegisterElement = (name, element, options = {}) => !window.customEleme
safeRegisterElement('toggle-visible', ToggleVisible) safeRegisterElement('toggle-visible', ToggleVisible)
safeRegisterElement('disable-hidden', DisableHidden) safeRegisterElement('disable-hidden', DisableHidden)
safeRegisterElement('turbo-modal', TurboModal) 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('file-dropzone', FileDropzone)
safeRegisterElement('menu-active', MenuActive) safeRegisterElement('menu-active', MenuActive)
safeRegisterElement('clipboard-copy', ClipboardCopy) safeRegisterElement('clipboard-copy', ClipboardCopy)
@@ -156,16 +192,30 @@ safeRegisterElement('confirm-upload', ConfirmUpload)
safeRegisterElement('scroll-fade', ScrollFade) safeRegisterElement('scroll-fade', ScrollFade)
safeRegisterElement('open-modal-mobile', OpenModalMobile) safeRegisterElement('open-modal-mobile', OpenModalMobile)
safeRegisterElement('history-back', HistoryBack) safeRegisterElement('history-back', HistoryBack)
safeRegisterElement('date-placeholder', DatePlaceholder)
safeRegisterElement('flash-message', FlashMessage)
safeRegisterElement('template-builder', class extends HTMLElement { safeRegisterElement('template-builder', class extends HTMLElement {
connectedCallback () { connectedCallback () {
document.addEventListener('turbo:submit-end', this.onSubmit) 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) document.addEventListener('turbo:before-cache', this.onBeforeCache)
this.appElem = document.createElement('div') this.appElem = document.createElement('div')
this.appElem.classList.add('md:h-screen') 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)) const template = reactive(JSON.parse(this.dataset.template))
this.app = createApp(TemplateBuilder, { this.app = createApp(TemplateBuilder, {
@@ -203,7 +253,9 @@ safeRegisterElement('template-builder', class extends HTMLElement {
withDownload: true, withDownload: true,
currencies: (this.dataset.currencies || '').split(',').filter(Boolean), currencies: (this.dataset.currencies || '').split(',').filter(Boolean),
acceptFileTypes: this.dataset.acceptFileTypes, 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) this.component = this.app.mount(this.appElem)
@@ -211,39 +263,130 @@ safeRegisterElement('template-builder', class extends HTMLElement {
this.appendChild(this.appElem) this.appendChild(this.appElem)
} }
onSubmit = (e) => { onSubmit = async (e) => {
if (e.detail.success) { const form = e.detail.formSubmission?.formElement
if (e.detail?.formSubmission?.formElement?.id === 'submitters_form') {
e.detail.fetchResponse.response.json().then((data) => { if (!e.detail.success || !form) return
this.component.template.submitters = data.submitters
}) 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')) { if (field) {
e.detail.fetchResponse.response.text().then((data) => { this.component.drawField = field
const doc = new DOMParser().parseFromString(data, 'text/html') this.component.drawOption = option || null
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 (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 = () => { onBeforeCache = () => {
this.app?.unmount() this.app?.unmount()
this.appElem?.remove() this.appElem?.remove()
@@ -251,6 +394,14 @@ safeRegisterElement('template-builder', class extends HTMLElement {
disconnectedCallback () { disconnectedCallback () {
document.removeEventListener('turbo:submit-end', this.onSubmit) 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) document.removeEventListener('turbo:before-cache', this.onBeforeCache)
this.app?.unmount() this.app?.unmount()
+68
View File
@@ -166,3 +166,71 @@ button[disabled] .enabled, button.btn-disabled .enabled {
markdown-editor [contenteditable] p { markdown-editor [contenteditable] p {
margin-bottom: 18px; 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() const formData = new FormData()
formData.append('file', file) formData.append('file', file)
formData.append('submitter_slug', this.dataset.slug)
formData.append('name', 'attachments') formData.append('name', 'attachments')
formData.append('remember_signature', 'true') formData.append('remember_signature', 'true')
return fetch('/api/attachments', { return fetch(`/s/${this.dataset.slug}/upload`, {
method: 'POST', method: 'POST',
body: formData body: formData
}).then(resp => resp.json()) }).then(resp => resp.json())
@@ -1,5 +1,7 @@
export default class extends HTMLElement { export default class extends HTMLElement {
connectedCallback () { connectedCallback () {
this.id = `autoresize-textarea-${Math.random().toString(32).split('.')[1]}`
this.resize() this.resize()
this.textarea.addEventListener('input', () => this.resize()) this.textarea.addEventListener('input', () => this.resize())
+2
View File
@@ -1,5 +1,7 @@
export default class extends HTMLElement { export default class extends HTMLElement {
connectedCallback () { connectedCallback () {
this.id = `bar-chart-${Math.random().toString(32).split('.')[1]}`
this.chartLabels = JSON.parse(this.dataset.labels || '[]') this.chartLabels = JSON.parse(this.dataset.labels || '[]')
this.chartDatasets = JSON.parse(this.dataset.datasets || '[]') this.chartDatasets = JSON.parse(this.dataset.datasets || '[]')
+10 -1
View File
@@ -4,9 +4,18 @@ export default class extends HTMLElement {
this.addEventListener('click', (e) => { this.addEventListener('click', (e) => {
const text = this.dataset.text || this.innerText.trim() 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) { if (navigator.clipboard) {
navigator.clipboard.writeText(text) navigator.clipboard.writeText(text).then(() => {
window.webkit?.messageHandlers?.flash?.postMessage({ style: 'notice', message: this.dataset.copied })
})
} else { } else {
if (e.target.tagName !== 'INPUT') { if (e.target.tagName !== 'INPUT') {
alert(`Clipboard not available. Make sure you're using https://\nCopy text: ${text}`) 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 { export default class extends HTMLElement {
connectedCallback () { connectedCallback () {
const input = this.querySelector('input[type="file"]') this.addEventListener('change', async (e) => {
const form = input.form const input = e.target
const form = input.form
input.addEventListener('change', async () => {
await convertImagesInInput(input) await convertImagesInInput(input)
form.querySelector('[type="submit"]')?.setAttribute('disabled', true) 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) window.addEventListener('dragleave', this.onWindowDragleave)
this.fileDropzone?.addEventListener('drop', this.onDropFile) this.addEventListener('drop', this.onDrop)
this.folderDropzone?.addEventListener('drop', this.onDropNewFolder) 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))) dropTarget (e) {
this.templateCards.forEach((el) => el.addEventListener('drop', this.onDropTemplate)) return e.target.closest([
this.templateCards.forEach((el) => el.addEventListener('dragstart', this.onTemplateDragStart)) '[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) => { onDrop = (e) => {
el?.addEventListener('dragover', this.onDragover) const el = this.dropTarget(e)
el?.addEventListener('dragleave', this.onDragleave)
}) 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 () { disconnectedCallback () {
@@ -122,13 +146,13 @@ export default targetable(class extends HTMLElement {
} }
}).finally(() => { }).finally(() => {
window.Turbo.cache.clear() window.Turbo.cache.clear()
window.Turbo.visit(location.href) window.Turbo.visit(location.href, { action: 'replace' })
}) })
} }
} }
} }
onDropTemplate = (e) => { onDropTemplate = (e, el) => {
e.preventDefault() e.preventDefault()
if (e.dataTransfer.files.length) { 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.classList.add('bottom-5', 'left-0', 'flex', 'justify-center', 'w-full', 'absolute')
loading.innerHTML = loadingIconHtml loading.innerHTML = loadingIconHtml
e.target.appendChild(loading) el.appendChild(loading)
e.target.classList.add('opacity-50') 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`) this.uploadFiles(e.dataTransfer.files, `/templates/${id}/clone_and_replace`)
} }
@@ -173,16 +197,20 @@ export default targetable(class extends HTMLElement {
} }
} }
onDragover (e) { onDragover = (e) => {
if (e.dataTransfer?.types?.includes('Files') || this.dataset.targets !== 'dashboard-dropzone.templateCards') { const el = this.dropTarget(e)
this.style.backgroundColor = '#F7F3F0'
if (this.classList.contains('before:border-base-300')) { if (!el) return
this.classList.remove('before:border-base-300')
this.classList.add('before:border-base-content/30') if (e.dataTransfer?.types?.includes('Files') || el.dataset.targets !== 'dashboard-dropzone.templateCards') {
} else if (this.classList.contains('border-base-300')) { el.style.backgroundColor = '#F7F3F0'
this.classList.remove('border-base-300')
this.classList.add('border-base-content/30') 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() a.remove()
} }
onDragleave () { onDragleave = (e) => {
this.style.backgroundColor = null const el = this.dropTarget(e)
if (this.classList.contains('before:border-base-content/30')) { if (!el) return
this.classList.remove('before:border-base-content/30')
this.classList.add('before:border-base-300') el.style.backgroundColor = null
} else if (this.classList.contains('border-base-content/30')) {
this.classList.remove('border-base-content/30') if (el.classList.contains('before:border-base-content/30')) {
this.classList.add('border-base-300') 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'] static observedAttributes = ['class']
connectedCallback () { 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() this.trigger()
} }
@@ -21,6 +21,16 @@ export default targetable(class extends HTMLElement {
downloadFiles () { downloadFiles () {
if (!this.dataset.src) return 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() this.toggleState()
fetch(this.dataset.src).then(async (response) => { fetch(this.dataset.src).then(async (response) => {
+2
View File
@@ -39,6 +39,8 @@ export default targetable(class extends HTMLElement {
] ]
connectedCallback () { connectedCallback () {
this.id = `email-editor-${Math.random().toString(32).split('.')[1]}`
this.mount() this.mount()
if (this.input.value) { if (this.input.value) {
+1 -1
View File
@@ -35,7 +35,7 @@ export default class extends HTMLElement {
counter.innerText = `${count} / ${this.dataset.limit}` counter.innerText = `${count} / ${this.dataset.limit}`
} }
if (this.dataset.bulkEnabled !== 'true') { if (this.dataset.bulkEnabled !== 'true' && !/Hotwire Native iOS/.test(navigator.userAgent)) {
if (!bulkMessage) { if (!bulkMessage) {
bulkMessage = document.createElement('span') 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('dragover', (e) => e.preventDefault())
this.addEventListener('drop', this.onDrop) this.addEventListener('drop', this.onDrop)
document.addEventListener('turbo:submit-end', this.toggleLoading) 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('dragover', this.onDragover)
this.area?.addEventListener('dragleave', this.onDragleave) 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' })
}
}
}
+3 -1
View File
@@ -353,7 +353,7 @@ function buildExtensions ({ Node, Mark, Extension, Plugin, Decoration, Decoratio
const buildDecorations = (doc) => { const buildDecorations = (doc) => {
const decorations = [] const decorations = []
const regex = /\{\{?[a-zA-Z0-9_.-]+\}\}?/g const regex = /\{\{?[^{}\n]+\}\}?/g
doc.descendants((node, pos) => { doc.descendants((node, pos) => {
if (!node.isText) return if (!node.isText) return
@@ -458,6 +458,8 @@ export default actionable(targetable(class extends HTMLElement {
async connectedCallback () { async connectedCallback () {
if (!this.textarea || !this.editorElement) return if (!this.textarea || !this.editorElement) return
this.id = `html-editor-${Math.random().toString(32).split('.')[1]}`
this.textarea.style.display = 'none' this.textarea.style.display = 'none'
this.adjustShortcutsForPlatform() this.adjustShortcutsForPlatform()
@@ -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
})
}
}
+3 -1
View File
@@ -143,6 +143,8 @@ export default actionable(targetable(class extends HTMLElement {
async connectedCallback () { async connectedCallback () {
if (!this.textarea || !this.editorElement) return if (!this.textarea || !this.editorElement) return
this.id = `markdown-editor-${Math.random().toString(32).split('.')[1]}`
this.textarea.style.display = 'none' this.textarea.style.display = 'none'
this.adjustShortcutsForPlatform() this.adjustShortcutsForPlatform()
@@ -150,7 +152,7 @@ export default actionable(targetable(class extends HTMLElement {
const buildDecorations = (doc) => { const buildDecorations = (doc) => {
const decorations = [] const decorations = []
const regex = /\{\{?[a-zA-Z0-9_.-]+\}\}?/g const regex = /\{\{?[^{}\n]+\}\}?/g
doc.descendants((node, pos) => { doc.descendants((node, pos) => {
if (!node.isText) return if (!node.isText) return
+2 -3
View File
@@ -1,14 +1,13 @@
export default class extends HTMLElement { export default class extends HTMLElement {
connectedCallback () { connectedCallback () {
const maskedToken = this.input.value
this.input.addEventListener('focus', () => { this.input.addEventListener('focus', () => {
this.maskedToken = this.input.value
this.input.value = this.dataset.token this.input.value = this.dataset.token
this.input.select() this.input.select()
}) })
this.input.addEventListener('focusout', () => { this.input.addEventListener('focusout', () => {
this.input.value = maskedToken this.input.value = this.maskedToken
}) })
} }
+2
View File
@@ -1,5 +1,7 @@
export default class extends HTMLElement { export default class extends HTMLElement {
connectedCallback () { connectedCallback () {
this.id = `menu-active-${Math.random().toString(32).split('.')[1]}`
this.querySelectorAll('a').forEach((link) => { this.querySelectorAll('a').forEach((link) => {
if (document.location.pathname.startsWith(link.pathname) && !link.getAttribute('href').startsWith('http')) { if (document.location.pathname.startsWith(link.pathname) && !link.getAttribute('href').startsWith('http')) {
link.classList.add('bg-base-300') link.classList.add('bg-base-300')
+44
View File
@@ -0,0 +1,44 @@
import { nativeIcon } from '../native'
export default class extends HTMLElement {
connectedCallback () {
const bridge = window.webkit?.messageHandlers?.native
if (!bridge) return
const target = this.querySelector('a, button, [role="button"]') || this.firstElementChild
if (!target && !this.dataset.native) return
this.nativeId ||= `native-action-${Math.random().toString(32).split('.')[1]}`
this.id = this.nativeId
if (target) target.dataset.nativeId = this.nativeId
bridge.postMessage({
type: 'action',
op: 'add',
id: this.nativeId,
title: this.dataset.label || target?.textContent.trim(),
icon: nativeIcon(this.dataset.icon),
placement: this.dataset.placement || 'menu',
side: this.dataset.side,
view: this.dataset.view,
section: this.dataset.section,
menu: this.dataset.menu,
menuIcon: nativeIcon(this.dataset.menuIcon),
native: this.dataset.native,
url: this.dataset.url,
accept: this.dataset.accept,
destructive: this.dataset.destructive === 'true',
selected: this.dataset.selected === 'true',
haptic: this.dataset.haptic === 'true'
})
}
disconnectedCallback () {
if (this.nativeId) {
window.webkit?.messageHandlers?.native?.postMessage({ type: 'action', op: 'remove', id: this.nativeId })
}
}
}
+60
View File
@@ -0,0 +1,60 @@
export default class extends HTMLElement {
connectedCallback () {
if (this.dataset.on === 'submit') {
document.addEventListener('turbo:submit-end', this.onSubmitEnd)
this.addEventListener('turbo:before-fetch-response', this.onBeforeFetchResponse)
} else {
this.addEventListener('click', this.dispatch)
}
}
disconnectedCallback () {
document.removeEventListener('turbo:submit-end', this.onSubmitEnd)
this.removeEventListener('turbo:before-fetch-response', this.onBeforeFetchResponse)
this.removeEventListener('click', this.dispatch)
}
onBeforeFetchResponse = (e) => {
const response = e.detail.fetchResponse
if (window.webkit?.messageHandlers?.modal && response.succeeded && !response.redirected && !response.isHTML) {
e.preventDefault()
}
}
onSubmitEnd = async (e) => {
const form = e.detail.formSubmission?.formElement
if (!e.detail.success || !form || !this.contains(form)) return
this.post(JSON.stringify({ form: form.id, action: form.action, body: await e.detail.fetchResponse.responseText }))
}
dispatch = async () => {
if (this.dataset.loading) return
let detail = this.dataset.detail || 'null'
if (this.dataset.src) {
this.dataset.loading = 'true'
try {
const response = await fetch(this.dataset.src, { headers: { Accept: 'application/json' } })
if (!response.ok) return
detail = await response.text()
} catch {
return
} finally {
delete this.dataset.loading
}
}
this.post(detail)
}
post (detail) {
window.webkit?.messageHandlers?.modal?.postMessage({ action: 'dispatch', name: this.dataset.name, detail, dismiss: this.dataset.dismiss || 'true' })
}
}
+49
View File
@@ -0,0 +1,49 @@
import { nativeIcon } from '../native'
export default class extends HTMLElement {
connectedCallback () {
if (window.webkit?.messageHandlers?.native) {
this.addEventListener('click', this.onClick)
} else {
document.addEventListener('pointerdown', this.onPointerDown)
}
}
disconnectedCallback () {
this.removeEventListener('click', this.onClick)
document.removeEventListener('pointerdown', this.onPointerDown)
}
onPointerDown = (event) => {
if (this.contains(document.activeElement) && !this.contains(event.target)) {
document.activeElement.blur()
}
}
onClick = (event) => {
if (event.target.closest('native-menu-action')) return
const rect = this.firstElementChild.getBoundingClientRect()
const items = [...this.querySelectorAll('native-menu-action')].map((action) => {
const target = action.querySelector('a, button, [role="button"]') || action.firstElementChild
target.dataset.nativeId ||= `native-menu-${Math.random().toString(32).split('.')[1]}`
return {
id: target.dataset.nativeId,
title: action.dataset.label || target.textContent.trim(),
icon: nativeIcon(action.dataset.icon),
destructive: action.dataset.destructive === 'true',
selected: action.dataset.selected === 'true',
haptic: action.dataset.haptic === 'true'
}
})
window.webkit.messageHandlers.native.postMessage({
type: 'menu',
rect: { x: rect.left + window.scrollX, y: rect.top + window.scrollY, width: rect.width, height: rect.height },
items
})
}
}
+41
View File
@@ -0,0 +1,41 @@
export default class extends HTMLElement {
connectedCallback () {
document.addEventListener('turbo:submit-end', this.onSubmit)
this.addEventListener('click', this.onClick)
}
disconnectedCallback () {
document.removeEventListener('turbo:submit-end', this.onSubmit)
}
onSubmit = async (e) => {
if (!e.detail.success) return
if (e.detail.fetchResponse?.redirected && e.detail.fetchResponse.location.href !== window.location.href) return
if (e.detail.formSubmission?.formElement?.method === 'get') return
if (this.dataset.closeAfterSubmit === 'false') return
if (e.detail.formSubmission?.formElement?.dataset?.closeOnSubmit === 'false') return
if (e.detail.formSubmission?.formElement?.closest('native-event[data-on="submit"]')) return
const flash = await this.flashFrom(e.detail.fetchResponse)
window.webkit?.messageHandlers?.modal?.postMessage({ action: 'submitted', ...flash })
}
async flashFrom (fetchResponse) {
const html = await fetchResponse?.responseHTML
const flash = html && new DOMParser().parseFromString(html, 'text/html').getElementById('native_flash')
if (flash?.dataset.alert) return { alert: flash.dataset.alert }
if (flash?.dataset.notice) return { notice: flash.dataset.notice }
return {}
}
onClick = () => {
setTimeout(() => {
if (document.documentElement.scrollHeight > window.innerHeight) {
window.webkit?.messageHandlers?.modal?.postMessage({ action: 'expand' })
}
}, 300)
}
}
+59
View File
@@ -0,0 +1,59 @@
export default class extends HTMLElement {
connectedCallback () {
document.addEventListener('native:search', this.onSearch)
document.addEventListener('turbo:before-cache', this.onBeforeCache)
this.addEventListener('touchstart', this.onTouchStart, { passive: true })
this.addEventListener('scroll', this.onScroll, { capture: true, passive: true })
const scroller = this.querySelector('[data-scroll-top]')
if (scroller) scroller.scrollTop = scroller.dataset.scrollTop
}
disconnectedCallback () {
document.removeEventListener('native:search', this.onSearch)
document.removeEventListener('turbo:before-cache', this.onBeforeCache)
}
onBeforeCache = () => {
if (this.scroller) this.scroller.dataset.scrollTop = this.scroller.scrollTop
}
onTouchStart = () => {
this.isTouching = true
}
onScroll = (event) => {
this.scroller = event.target
if (!this.isTouching) return
this.isTouching = false
window.webkit?.messageHandlers?.native?.postMessage({ type: 'dismiss-keyboard' })
}
onSearch = (event) => {
const query = (event.detail?.q || '').trim()
if (query === (this.dataset.q || '')) return
this.dataset.q = query
const url = new URL(this.frame.src || this.dataset.src, window.location.href)
url.searchParams.delete('page')
if (query) {
url.searchParams.set('q', query)
} else {
url.searchParams.delete('q')
}
this.frame.src = url.toString()
}
get frame () {
return this.querySelector('turbo-frame')
}
}
+61
View File
@@ -0,0 +1,61 @@
import { convertImagesInInput } from './convert_upload'
export default class extends HTMLElement {
connectedCallback () {
this.files = []
document.addEventListener('turbo:submit-end', this.onSubmitEnd)
document.addEventListener('native:upload-start', this.onUploadStart)
document.addEventListener('native:upload-chunk', this.onUploadChunk)
document.addEventListener('native:upload-submit', this.onUploadSubmit)
}
disconnectedCallback () {
document.removeEventListener('turbo:submit-end', this.onSubmitEnd)
document.removeEventListener('native:upload-start', this.onUploadStart)
document.removeEventListener('native:upload-chunk', this.onUploadChunk)
document.removeEventListener('native:upload-submit', this.onUploadSubmit)
}
get form () {
return this.querySelector('form')
}
onUploadStart = (event) => {
this.files = []
event.detail.result = true
}
onUploadChunk = ({ detail: { index, name, type, chunk } }) => {
const file = this.files[index] ||= { name, type, chunks: [] }
file.chunks.push(Uint8Array.from(atob(chunk), (char) => char.charCodeAt(0)))
}
onUploadSubmit = (event) => {
event.detail.result = this.submit(event.detail.url)
}
async submit (url) {
const dataTransfer = new DataTransfer()
this.files.forEach(({ name, type, chunks }) => dataTransfer.items.add(new File(chunks, name, { type })))
this.files = []
const input = this.form.querySelector('[type="file"]')
input.files = dataTransfer.files
await convertImagesInInput(input)
this.form.action = url
this.form.requestSubmit()
}
onSubmitEnd = (e) => {
if (e.detail.formSubmission?.formElement !== this.form) return
window.webkit?.messageHandlers?.native?.postMessage({ type: 'upload', success: e.detail.success })
}
}
+8 -3
View File
@@ -1,12 +1,17 @@
export default class extends HTMLElement { export default class extends HTMLElement {
connectedCallback () { connectedCallback () {
this.addEventListener('pointerdown', this.onPointerdown)
}
onPointerdown = (event) => {
if (this.dataset.disabled === 'true') return if (this.dataset.disabled === 'true') return
if (/Hotwire Native/.test(navigator.userAgent)) return
if (!this.isMobile()) return if (!this.isMobile()) return
if (window.innerWidth >= 768) return if (window.innerWidth >= 768) return
this.querySelectorAll('a[data-turbo-frame]').forEach((link) => { const link = event.target.closest('a[data-turbo-frame]')
link.dataset.turboFrame = '_top'
}) if (link) link.dataset.turboFrame = '_top'
} }
isMobile () { isMobile () {
+2
View File
@@ -1,5 +1,7 @@
export default class extends HTMLElement { export default class extends HTMLElement {
connectedCallback () { connectedCallback () {
this.id = `scroll-fade-${Math.random().toString(32).split('.')[1]}`
this.addEventListener('scroll', this.updateFade, { passive: true }) this.addEventListener('scroll', this.updateFade, { passive: true })
window.addEventListener('resize', this.updateFade) window.addEventListener('resize', this.updateFade)
+16 -7
View File
@@ -1,20 +1,29 @@
export default class extends HTMLElement { export default class extends HTMLElement {
connectedCallback () { connectedCallback () {
this.button.addEventListener('click', (event) => { this.addEventListener('click', this.onClick)
if (!this.input.value && document.activeElement !== this.input) {
event.preventDefault()
this.input.focus()
}
})
document.addEventListener('turbo:before-cache', this.onBeforeCache) document.addEventListener('turbo:before-cache', this.onBeforeCache)
if (this.input?.value) {
this.input.focus()
this.input.setSelectionRange(this.input.value.length, this.input.value.length)
}
} }
disconnectedCallback () { disconnectedCallback () {
document.removeEventListener('turbo:before-cache', this.onBeforeCache) document.removeEventListener('turbo:before-cache', this.onBeforeCache)
} }
onClick = (event) => {
if (!event.target.closest('button')) return
if (!this.input.value && document.activeElement !== this.input) {
event.preventDefault()
this.input.focus()
}
}
onBeforeCache = () => { onBeforeCache = () => {
this.input.value = this.input.getAttribute('value') || '' this.input.value = this.input.getAttribute('value') || ''
} }
+2
View File
@@ -1,5 +1,7 @@
export default class extends HTMLElement { export default class extends HTMLElement {
connectedCallback () { connectedCallback () {
this.id = `set-timezone-${Math.random().toString(32).split('.')[1]}`
if (this.dataset.inputId) { if (this.dataset.inputId) {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
@@ -5,6 +5,8 @@ export default targetable(class extends HTMLElement {
static [target.static] = ['canvas', 'input', 'clear', 'button'] static [target.static] = ['canvas', 'input', 'clear', 'button']
async connectedCallback () { async connectedCallback () {
this.lockSwipe(true)
const { default: SignaturePad } = await import('signature_pad') const { default: SignaturePad } = await import('signature_pad')
this.setCanvasSize() this.setCanvasSize()
@@ -61,11 +63,17 @@ export default targetable(class extends HTMLElement {
} }
disconnectedCallback () { disconnectedCallback () {
this.lockSwipe(false)
if (this.resizeObserver) { if (this.resizeObserver) {
this.resizeObserver.disconnect() this.resizeObserver.disconnect()
} }
} }
lockSwipe (locked) {
window.webkit?.messageHandlers?.native?.postMessage({ type: 'swipe', op: locked ? 'lock' : 'unlock' })
}
setCanvasSize () { setCanvasSize () {
const scale = 3 const scale = 3
+14 -6
View File
@@ -1,5 +1,19 @@
export default class extends HTMLElement { export default class extends HTMLElement {
connectedCallback () { connectedCallback () {
if (this.lastElementChild) {
this.init()
} else {
queueMicrotask(() => this.init())
}
}
disconnectedCallback () {
if (this.interval) {
clearInterval(this.interval)
}
}
init () {
const form = this.querySelector('form') || (this.querySelector('input, button, select') || this.lastElementChild).form const form = this.querySelector('form') || (this.querySelector('input, button, select') || this.lastElementChild).form
if (this.dataset.interval) { if (this.dataset.interval) {
@@ -24,10 +38,4 @@ export default class extends HTMLElement {
form.requestSubmit() form.requestSubmit()
} }
} }
disconnectedCallback () {
if (this.interval) {
clearInterval(this.interval)
}
}
} }
+14 -10
View File
@@ -1,19 +1,23 @@
export default class extends HTMLElement { export default class extends HTMLElement {
connectedCallback () { connectedCallback () {
this.addEventListener('click', this.onClick)
}
onClick = (event) => {
const button = this.querySelector('a, button, label') const button = this.querySelector('a, button, label')
if (!button?.contains(event.target)) return
const target = this.dataset.targetId ? document.getElementById(this.dataset.targetId) : button const target = this.dataset.targetId ? document.getElementById(this.dataset.targetId) : button
button.addEventListener('click', () => { this.dataset.classes.split(' ').forEach((cls) => {
this.dataset.classes.split(' ').forEach((cls) => { if (this.dataset.action === 'remove') {
if (this.dataset.action === 'remove') { target.classList.remove(cls)
target.classList.remove(cls) } else if (this.dataset.action === 'add') {
} else if (this.dataset.action === 'add') { target.classList.add(cls)
target.classList.add(cls) } else {
} else { target.classList.toggle(cls)
target.classList.toggle(cls) }
}
})
}) })
} }
} }
+5 -11
View File
@@ -1,27 +1,21 @@
export default class extends HTMLElement { export default class extends HTMLElement {
connectedCallback () { connectedCallback () {
document.addEventListener('turbo:submit-end', this.onSubmitEnd)
this.form.addEventListener('submit', this.onSubmit) this.form.addEventListener('submit', this.onSubmit)
this.form.addEventListener('turbo:before-fetch-response', this.onFetchResponse)
} }
disconnectedCallback () { disconnectedCallback () {
document.removeEventListener('turbo:submit-end', this.onSubmitEnd)
this.form.removeEventListener('submit', this.onSubmit) this.form.removeEventListener('submit', this.onSubmit)
this.form.removeEventListener('turbo:before-fetch-response', this.onFetchResponse)
} }
onSubmit = () => { onSubmit = () => {
this.element.classList.add('invisible') this.element.classList.add('invisible')
} }
onSubmitEnd = (event) => { onFetchResponse = (event) => {
if (event.target === this.form) { if (event.detail.fetchResponse.succeeded) {
const resp = event.detail?.formSubmission?.result?.fetchResponse?.response this.element.classList.remove('invisible')
if (resp?.status / 100 === 2) {
this.element.classList.remove('invisible')
}
} }
} }
+9 -1
View File
@@ -1,3 +1,5 @@
import './native'
import { createApp, reactive } from 'vue' import { createApp, reactive } from 'vue'
import Form from './submission_form/form' import Form from './submission_form/form'
@@ -8,6 +10,7 @@ import ScrollButtons from './elements/scroll_buttons'
import PageContainer from './elements/page_container' import PageContainer from './elements/page_container'
import SubmitForm from './elements/submit_form' import SubmitForm from './elements/submit_form'
import ModalButton from './elements/modal_button' import ModalButton from './elements/modal_button'
import NativeAction from './elements/native_action'
const safeRegisterElement = (name, element, options = {}) => !window.customElements.get(name) && window.customElements.define(name, element, options) const safeRegisterElement = (name, element, options = {}) => !window.customElements.get(name) && window.customElements.define(name, element, options)
@@ -18,6 +21,7 @@ safeRegisterElement('scroll-buttons', ScrollButtons)
safeRegisterElement('page-container', PageContainer) safeRegisterElement('page-container', PageContainer)
safeRegisterElement('submit-form', SubmitForm) safeRegisterElement('submit-form', SubmitForm)
safeRegisterElement('modal-button', ModalButton) safeRegisterElement('modal-button', ModalButton)
safeRegisterElement('native-action', NativeAction)
safeRegisterElement('submission-form', class extends HTMLElement { safeRegisterElement('submission-form', class extends HTMLElement {
connectedCallback () { connectedCallback () {
this.appElem = document.createElement('div') this.appElem = document.createElement('div')
@@ -28,7 +32,9 @@ safeRegisterElement('submission-form', class extends HTMLElement {
optionalInviteSubmitters: JSON.parse(this.dataset.optionalInviteSubmitters), optionalInviteSubmitters: JSON.parse(this.dataset.optionalInviteSubmitters),
schema: JSON.parse(this.dataset.schema), schema: JSON.parse(this.dataset.schema),
canSendEmail: this.dataset.canSendEmail === 'true', canSendEmail: this.dataset.canSendEmail === 'true',
previousSignatureValue: this.dataset.previousSignatureValue, prefillSignature: this.dataset.prefillSignature ? JSON.parse(this.dataset.prefillSignature) : null,
prefillInitials: this.dataset.prefillInitials ? JSON.parse(this.dataset.prefillInitials) : null,
currentUser: this.dataset.currentUser ? JSON.parse(this.dataset.currentUser) : null,
goToLast: this.dataset.goToLast === 'true', goToLast: this.dataset.goToLast === 'true',
isDemo: this.dataset.isDemo === 'true', isDemo: this.dataset.isDemo === 'true',
attribution: this.dataset.attribution !== 'false', attribution: this.dataset.attribution !== 'false',
@@ -37,10 +43,12 @@ safeRegisterElement('submission-form', class extends HTMLElement {
language: this.dataset.language, language: this.dataset.language,
paymentProvider: this.dataset.paymentProvider, paymentProvider: this.dataset.paymentProvider,
dryRun: this.dataset.dryRun === 'true', dryRun: this.dataset.dryRun === 'true',
viewToken: this.dataset.viewToken,
expand: ['true', 'false'].includes(this.dataset.expand) ? this.dataset.expand === 'true' : null, expand: ['true', 'false'].includes(this.dataset.expand) ? this.dataset.expand === 'true' : null,
withSignatureId: this.dataset.withSignatureId === 'true', withSignatureId: this.dataset.withSignatureId === 'true',
requireSigningReason: this.dataset.requireSigningReason === 'true', requireSigningReason: this.dataset.requireSigningReason === 'true',
withConfetti: this.dataset.withConfetti === 'true', withConfetti: this.dataset.withConfetti === 'true',
nativePlatform: this.dataset.nativePlatform,
withFieldLabels: this.dataset.withFieldLabels !== 'false', withFieldLabels: this.dataset.withFieldLabels !== 'false',
withDisclosure: this.dataset.withDisclosure === 'true', withDisclosure: this.dataset.withDisclosure === 'true',
reuseSignature: this.dataset.reuseSignature !== 'false', reuseSignature: this.dataset.reuseSignature !== 'false',
-98
View File
@@ -1,98 +0,0 @@
const requestCache = new Map()
const cacheTtl = 10 * 1000
function isPreloadable (linkElement) {
const href = linkElement.getAttribute('href')
if (!href || href === '#' || linkElement.dataset.turbo === 'false' || linkElement.dataset.prefetch === 'false') {
return
}
if (linkElement.origin !== document.location.origin) {
return
}
if (!['http:', 'https:'].includes(linkElement.protocol)) {
return
}
if (linkElement.pathname + linkElement.search === document.location.pathname + document.location.search) {
return
}
if (linkElement.dataset.turboMethod && linkElement.dataset.turboMethod !== 'get') {
return
}
return true
}
function mouseoverListener (event) {
let linkElement
if (event.target.tagName === 'A') {
linkElement = event.target
} else {
linkElement = event.target.closest('a')
}
if (!linkElement) {
return
}
if (!isPreloadable(linkElement)) {
return
}
const url = linkElement.getAttribute('href')
const loc = new URL(url, location.protocol + '//' + location.host)
const absoluteUrl = loc.toString()
const cached = requestCache.get(absoluteUrl)
if (cached && cached.ttl > new Date()) {
return
}
const requestOptions = {
credentials: 'same-origin',
headers: { Accept: 'text/html, application/xhtml+xml', 'VND.PREFETCH': 'true', 'X-Turbo': 'true' },
method: 'GET',
redirect: 'follow'
}
if (linkElement.dataset.turboFrame && linkElement.dataset.turboFrame !== '_top') {
requestOptions.headers['Turbo-Frame'] = linkElement.dataset.turboFrame
} else if (linkElement.dataset.turboFrame !== '_top') {
const turboFrame = linkElement.closest('turbo-frame')
if (turboFrame) {
requestOptions.headers['Turbo-Frame'] = turboFrame.id
}
}
requestCache.set(absoluteUrl, { request: fetch(absoluteUrl, requestOptions), ttl: new Date(new Date().getTime() + cacheTtl) })
}
function turboBeforeFetchRequest (event) {
if (event.target.tagName !== 'FORM' && event.detail.fetchOptions.method === 'GET') {
const cached = requestCache.get(event.detail.url.toString())
if (cached && cached.ttl > new Date()) {
event.detail.response = cached.request
}
}
requestCache.clear()
}
function start () {
if (!window.turboInstantClickEnabled) {
document.addEventListener('turbo:before-fetch-request', turboBeforeFetchRequest)
document.addEventListener('mouseover', mouseoverListener, { capture: true, passive: true })
}
window.turboInstantClickEnabled = true
}
export { start }
+208
View File
@@ -0,0 +1,208 @@
const ICONS = {
ios: {
link: 'link',
archive: 'archivebox',
folder: 'folder',
trash: 'trash',
rotate: 'arrow.counterclockwise',
copy: 'doc.on.doc',
pencil: 'pencil',
logs: 'list.bullet.rectangle',
external_link: 'arrow.up.right.square',
download: 'arrow.down.circle',
mail_forward: 'paperplane',
send: 'paperplane',
writing: 'signature',
adjustments_horizontal: 'slider.horizontal.3',
file_text: 'doc.text',
files: 'doc.on.doc',
plus: 'plus',
eye: 'eye',
check: 'checkmark',
code: 'chevron.left.forwardslash.chevron.right',
history: 'clock.arrow.circlepath',
user: 'person',
users: 'person.2',
users_plus: 'person.badge.plus',
photo: 'photo',
settings: 'gearshape',
logout: 'rectangle.portrait.and.arrow.right',
upload: 'arrow.up.doc',
scan: 'doc.viewfinder',
pencil_plus: 'square.and.pencil',
arrow_sort: 'arrow.up.arrow.down',
sort_descending_numbers: 'calendar',
sort_descending_small_big: 'clock.arrow.circlepath',
sort_ascending_letters: 'textformat.abc',
calendar: 'calendar',
calendar_check: 'calendar.badge.checkmark',
info_circle: 'info.circle',
list: 'list.bullet',
clock: 'clock',
circle_check: 'checkmark.circle'
}
}
const platform = navigator.userAgent.includes('Hotwire Native Android') ? 'android' : 'ios'
export function nativeIcon (name) {
return ICONS[platform]?.[name] || name
}
const handlers = window.webkit?.messageHandlers
const isNativeLoaded = window.isNativeLoaded
if (handlers?.native && window === window.top && !isNativeLoaded) {
window.isNativeLoaded = true
handlers.native.postMessage({ type: 'reset' })
const reportPathConfiguration = () => {
const element = document.getElementById('native_path_configuration')
if (!element) return
const config = JSON.parse(element.textContent)
config.rules.forEach(({ properties }) => {
if (properties.native_actions) properties.native_actions = properties.native_actions.map(nativeIcon)
})
handlers.native.postMessage({ type: 'path-configuration', json: JSON.stringify(config) })
}
document.addEventListener('turbo:load', reportPathConfiguration)
document.addEventListener('turbo:before-cache', () => {
document.getElementById('native_path_configuration')?.remove()
})
reportPathConfiguration()
const reportFlash = (element) => {
if (!element) return
if (element.dataset.notice) handlers.flash.postMessage({ style: 'notice', message: element.dataset.notice })
if (element.dataset.alert) handlers.flash.postMessage({ style: 'alert', message: element.dataset.alert })
element.remove()
}
const reportPageFlash = () => reportFlash(document.getElementById('native_flash'))
new MutationObserver(reportPageFlash).observe(document.documentElement, { childList: true, subtree: true })
document.addEventListener('turbo:load', reportPageFlash)
document.addEventListener('native:flash', (event) => {
reportFlash(new DOMParser().parseFromString(event.detail.html, 'text/html').getElementById('native_flash'))
})
reportPageFlash()
document.addEventListener('click', (event) => {
const link = event.target.closest('a[href]')
if (!link) return
const action = link.closest('native-action, native-menu-action')
const title = link.dataset.nativeTitle || link.getAttribute('aria-label') || link.closest('[data-native-title]')?.dataset.nativeTitle || action?.dataset.label || action?.textContent.replace(/\s+/g, ' ').trim()
if (!title) return
const actions = link.closest('[data-native-actions]')?.dataset.nativeActions
handlers.native.postMessage({ type: 'title', url: link.href, title, actions: actions ? actions.split(',').map(nativeIcon) : null })
}, true)
document.addEventListener('click', (event) => {
if (document.querySelector('native-modal')) return
const link = event.target.closest('a[data-turbo-frame="modal"], a[data-turbo-frame="drawer"]')
if (!link?.href) return
event.preventDefault()
event.stopPropagation()
handlers.modal.postMessage({ action: 'open', url: link.href, modal: link.closest('[data-native-modal]')?.dataset.nativeModal || '' })
}, true)
let isTouchPrefetch = false
document.addEventListener('touchstart', (event) => {
isTouchPrefetch = true
event.target.closest('a[href]')?.dispatchEvent(new MouseEvent('mouseenter'))
isTouchPrefetch = false
}, { passive: true })
document.addEventListener('turbo:before-prefetch', (event) => {
if (!isTouchPrefetch || event.target.closest('a[data-turbo-frame="modal"], a[data-turbo-frame="drawer"]')) {
event.preventDefault()
}
})
document.addEventListener('native:action', (event) => {
document.querySelector(`[data-native-id="${event.detail.id}"]`)?.click()
})
document.addEventListener('native:title-bottom', (event) => {
const rect = document.querySelector('h1, h2')?.getBoundingClientRect()
event.detail.result = rect?.height ? rect.bottom - document.documentElement.getBoundingClientRect().top : null
})
}
if (handlers?.native && window.Turbo && !isNativeLoaded) {
const staleSnapshots = new Set()
let isRestoring = false
window.Turbo.session.view.clearSnapshotCache = function () {
this.snapshotCache.keys.forEach((key) => staleSnapshots.add(key))
}
window.Turbo.session.navigator.linkPrefetchingIsEnabledForLocation = () => !document.querySelector('native-modal')
document.addEventListener('turbo:submit-end', (e) => {
if (e.detail.success && !e.detail.formSubmission.isSafe) {
handlers.native.postMessage({ type: 'stale' })
}
})
document.addEventListener('native:pull-to-refresh', (event) => {
const { session } = window.Turbo
const url = new URL(document.baseURI)
url.searchParams.delete('limit')
session.history.replace(url, session.history.restorationIdentifier)
session.view.lastRenderedLocation = url
event.detail.result = new Promise((resolve) => {
document.addEventListener('turbo:render', () => resolve(true), { once: true })
document.addEventListener('turbo:fetch-request-error', () => resolve(true), { once: true })
session.refresh(document.baseURI)
})
})
document.addEventListener('native:refresh', () => {
window.Turbo.session.refresh(document.baseURI)
})
document.addEventListener('native:turbo-stream', (e) => {
if (!e.detail.body?.trimStart().startsWith('<turbo-stream')) return
window.Turbo.renderStreamMessage(e.detail.body)
})
document.addEventListener('turbo:visit', (e) => {
isRestoring = e.detail.action === 'restore'
})
document.addEventListener('turbo:load', () => {
if (staleSnapshots.delete(window.location.href.split('#')[0]) && isRestoring && window.location.pathname !== '/search') {
setTimeout(() => window.Turbo.session.refresh(document.baseURI))
}
})
}
@@ -125,6 +125,11 @@ export default {
required: false, required: false,
default: () => [] default: () => []
}, },
fieldsUuidIndex: {
type: Object,
required: false,
default: () => ({})
},
values: { values: {
type: Object, type: Object,
required: false, required: false,
@@ -195,7 +200,7 @@ export default {
}, },
isMobileContainer: FieldAreas.computed.isMobileContainer, isMobileContainer: FieldAreas.computed.isMobileContainer,
isInlineSize: FieldAreas.computed.isInlineSize, isInlineSize: FieldAreas.computed.isInlineSize,
fieldsUuidIndex () { formulaFieldsUuidIndex () {
return this.formulaFields.reduce((acc, field) => { return this.formulaFields.reduce((acc, field) => {
acc[field.uuid] = field acc[field.uuid] = field
@@ -289,6 +294,7 @@ export default {
methods: { methods: {
normalizeFormula: FormulaAreas.methods.normalizeFormula, normalizeFormula: FormulaAreas.methods.normalizeFormula,
calculateFormula: FormulaAreas.methods.calculateFormula, calculateFormula: FormulaAreas.methods.calculateFormula,
dateFormulaValue: FormulaAreas.methods.dateFormulaValue,
evalTextFormula: FormulaAreas.methods.evalTextFormula, evalTextFormula: FormulaAreas.methods.evalTextFormula,
scrollInContainer: FieldAreas.methods.scrollInContainer, scrollInContainer: FieldAreas.methods.scrollInContainer,
scrollIntoArea: FieldAreas.methods.scrollIntoArea, scrollIntoArea: FieldAreas.methods.scrollIntoArea,
@@ -633,6 +633,32 @@ defineFunction('contains', {
return String(v[0]).indexOf(String(v[1])) !== -1 return String(v[0]).indexOf(String(v[1])) !== -1
} }
}) })
defineFunction('today', {
min: 0,
max: 0,
call: () => {
const now = new Date()
return Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()) / 86400000
}
})
defineFunction('datedif', {
min: 3,
max: 3,
call: (v) => {
if (v[0] == null || v[1] == null) return null
const start = toNumber(v[0])
const end = toNumber(v[1])
const unit = String(v[2]).toLowerCase()
if (unit === 'd') return end - start
const from = new Date(Math.min(start, end) * 86400000)
const to = new Date(Math.max(start, end) * 86400000)
const months = (to.getUTCFullYear() - from.getUTCFullYear()) * 12 + to.getUTCMonth() - from.getUTCMonth() - (to.getUTCDate() < from.getUTCDate() ? 1 : 0)
const sign = end < start ? -1 : 1
if (unit === 'm') return sign * months
if (unit === 'y') return sign * Math.floor(months / 12)
return null
}
})
const OP_CLASSES = { const OP_CLASSES = {
add: Addition, add: Addition,
+16 -2
View File
@@ -56,7 +56,7 @@
</span> </span>
</button> </button>
<button <button
v-if="!isWebView && withDownloadButton" v-if="(!isWebView || isNativeApp) && withDownloadButton"
class="base-button flex items-center space-x-1 w-full completed-form-download-button" class="base-button flex items-center space-x-1 w-full completed-form-download-button"
:disabled="isDownloading" :disabled="isDownloading"
@click.prevent="download" @click.prevent="download"
@@ -186,6 +186,11 @@ export default {
type: Object, type: Object,
required: false, required: false,
default: () => ({}) default: () => ({})
},
nativePlatform: {
type: String,
required: false,
default: ''
} }
}, },
data () { data () {
@@ -195,6 +200,9 @@ export default {
} }
}, },
computed: { computed: {
isNativeApp () {
return !!this.nativePlatform && !!window.webkit?.messageHandlers?.download
},
isWebView () { isWebView () {
return /webview|wv|ip((?!.*Safari)|(?=.*like Safari))/i.test(window.navigator.userAgent) return /webview|wv|ip((?!.*Safari)|(?=.*like Safari))/i.test(window.navigator.userAgent)
} }
@@ -230,7 +238,13 @@ export default {
download () { download () {
this.isDownloading = true this.isDownloading = true
fetch(this.baseUrl + `/submitters/${this.submitterSlug}/download`, { if (this.isNativeApp) {
document.addEventListener('native:download-end', () => { this.isDownloading = false }, { once: true })
return window.webkit.messageHandlers.download.postMessage({ src: this.baseUrl + `/s/${this.submitterSlug}/documents` })
}
fetch(this.baseUrl + `/s/${this.submitterSlug}/documents`, {
method: 'GET', method: 'GET',
...this.fetchOptions ...this.fetchOptions
}).then(async (response) => { }).then(async (response) => {
+13 -2
View File
@@ -87,7 +87,7 @@ export default {
IconCloudUpload, IconCloudUpload,
IconInnerShadowTop IconInnerShadowTop
}, },
inject: ['baseUrl', 't'], inject: ['baseUrl', 'fetchOptions', 't'],
props: { props: {
message: { message: {
type: String, type: String,
@@ -197,9 +197,20 @@ export default {
formData.append('submitter_slug', this.submitterSlug) formData.append('submitter_slug', this.submitterSlug)
formData.append('name', 'attachments') formData.append('name', 'attachments')
return fetch(this.baseUrl + '/api/attachments', { return fetch(this.baseUrl + `/s/${this.submitterSlug}/upload`, {
method: 'POST', method: 'POST',
headers: { ...this.fetchOptions.headers },
body: formData body: formData
}).then((resp) => {
if (resp.status === 404) {
return fetch(this.baseUrl + '/api/attachments', {
method: 'POST',
headers: { ...this.fetchOptions.headers },
body: formData
})
} else {
return resp
}
}).then(async (resp) => { }).then(async (resp) => {
const data = await resp.json() const data = await resp.json()
+82 -30
View File
@@ -31,6 +31,7 @@
:readonly-conditional-fields="readonlyConditionalFields" :readonly-conditional-fields="readonlyConditionalFields"
:readonly-conditional-field-values="readonlyConditionalFieldValues" :readonly-conditional-field-values="readonlyConditionalFieldValues"
:formula-fields="formulaFields" :formula-fields="formulaFields"
:fields-uuid-index="fieldsUuidIndex"
:values="values" :values="values"
:readonly-values="readonlyFieldValues" :readonly-values="readonlyFieldValues"
:submitter="submitter" :submitter="submitter"
@@ -71,6 +72,7 @@
<FormulaFieldAreas <FormulaFieldAreas
v-if="!withAccessibilityAreas && !isAccessibilityMode && formulaFields.length" v-if="!withAccessibilityAreas && !isAccessibilityMode && formulaFields.length"
:fields="formulaFields" :fields="formulaFields"
:fields-uuid-index="fieldsUuidIndex"
:readonly-values="readonlyFieldValues" :readonly-values="readonlyFieldValues"
:values="values" :values="values"
/> />
@@ -489,8 +491,8 @@
:reason="values[currentField.preferences?.reason_field_uuid]" :reason="values[currentField.preferences?.reason_field_uuid]"
:field="currentField" :field="currentField"
:values="values" :values="values"
:previous-value="previousSignatureValueFor(currentField) || previousSignatureValue" :previous-value="previousSignatureValueFor(currentField)"
:touch-attachment-uuid="previousSignatureValue" :prefill-data="prefillSignature"
:with-typed-signature="withTypedSignature" :with-typed-signature="withTypedSignature"
:remember-signature="rememberSignature" :remember-signature="rememberSignature"
:attachments-index="attachmentsIndex" :attachments-index="attachmentsIndex"
@@ -503,8 +505,8 @@
:with-qr-button="withQrButton" :with-qr-button="withQrButton"
:submitter="submitter" :submitter="submitter"
:show-field-names="showFieldNames" :show-field-names="showFieldNames"
:native-platform="nativePlatform"
@update:reason="values[currentField.preferences?.reason_field_uuid] = $event" @update:reason="values[currentField.preferences?.reason_field_uuid] = $event"
@touch-attachment="attachmentsIndex[previousSignatureValue] ? attachmentsIndex[previousSignatureValue].created_at = new Date() : null"
@attached="attachments.push($event)" @attached="attachments.push($event)"
@start="scrollIntoField(currentField)" @start="scrollIntoField(currentField)"
@minimize="minimizeForm" @minimize="minimizeForm"
@@ -518,9 +520,11 @@
:dry-run="dryRun" :dry-run="dryRun"
:submitter="submitter" :submitter="submitter"
:previous-value="previousInitialsValue" :previous-value="previousInitialsValue"
:prefill-data="prefillInitials"
:attachments-index="attachmentsIndex" :attachments-index="attachmentsIndex"
:show-field-names="showFieldNames" :show-field-names="showFieldNames"
:submitter-slug="submitterSlug" :submitter-slug="submitterSlug"
:native-platform="nativePlatform"
@attached="attachments.push($event)" @attached="attachments.push($event)"
@start="scrollIntoField(currentField)" @start="scrollIntoField(currentField)"
@focus="scrollIntoField(currentField)" @focus="scrollIntoField(currentField)"
@@ -558,8 +562,10 @@
:field="currentField" :field="currentField"
:submitter-slug="submitterSlug" :submitter-slug="submitterSlug"
:fields="formulaFields" :fields="formulaFields"
:fields-uuid-index="fieldsUuidIndex"
:values="values" :values="values"
:readonly-values="readonlyFieldValues" :readonly-values="readonlyFieldValues"
:fetch-options="fetchOptions"
:provider="paymentProvider" :provider="paymentProvider"
@attached="attachments.push($event)" @attached="attachments.push($event)"
@focus="scrollIntoField(currentField)" @focus="scrollIntoField(currentField)"
@@ -573,6 +579,7 @@
:empty-value-required-step="emptyValueRequiredStep" :empty-value-required-step="emptyValueRequiredStep"
:field="currentField" :field="currentField"
:submitter-slug="submitterSlug" :submitter-slug="submitterSlug"
:fetch-options="fetchOptions"
:values="values" :values="values"
@submit="!isSubmitting && submitStep()" @submit="!isSubmitting && submitStep()"
/> />
@@ -585,6 +592,7 @@
:empty-value-required-step="emptyValueRequiredStep" :empty-value-required-step="emptyValueRequiredStep"
:field="currentField" :field="currentField"
:submitter-slug="submitterSlug" :submitter-slug="submitterSlug"
:fetch-options="fetchOptions"
:values="values" :values="values"
@focus="scrollIntoField(currentField)" @focus="scrollIntoField(currentField)"
@submit="!isSubmitting && submitStep()" @submit="!isSubmitting && submitStep()"
@@ -649,6 +657,7 @@
:with-confetti="withConfetti" :with-confetti="withConfetti"
:can-send-email="canSendEmail && !!submitter.email" :can-send-email="canSendEmail && !!submitter.email"
:submitter-slug="submitterSlug" :submitter-slug="submitterSlug"
:native-platform="nativePlatform"
/> />
<nav <nav
v-if="stepFields.length < 80" v-if="stepFields.length < 80"
@@ -769,6 +778,7 @@ export default {
provide () { provide () {
return { return {
baseUrl: this.baseUrl, baseUrl: this.baseUrl,
fetchOptions: this.fetchOptions,
scrollIntoArea: this.scrollIntoArea, scrollIntoArea: this.scrollIntoArea,
scrollIntoField: this.scrollIntoField, scrollIntoField: this.scrollIntoField,
t: this.t t: this.t
@@ -906,6 +916,11 @@ export default {
required: false, required: false,
default: false default: false
}, },
nativePlatform: {
type: String,
required: false,
default: ''
},
autoscrollFields: { autoscrollFields: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -966,10 +981,20 @@ export default {
required: false, required: false,
default: '' default: ''
}, },
previousSignatureValue: { prefillSignature: {
type: String, type: Object,
required: false, required: false,
default: '' default: null
},
prefillInitials: {
type: Object,
required: false,
default: null
},
currentUser: {
type: Object,
required: false,
default: null
}, },
allowToSkip: { allowToSkip: {
type: Boolean, type: Boolean,
@@ -991,6 +1016,11 @@ export default {
required: false, required: false,
default: false default: false
}, },
viewToken: {
type: String,
required: false,
default: ''
},
attribution: { attribution: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -1326,6 +1356,30 @@ export default {
screen?.orientation?.addEventListener('change', this.onOrientationChange) screen?.orientation?.addEventListener('change', this.onOrientationChange)
if (this.currentUser) {
this.fields.forEach((field) => {
if (field.readonly || field.type !== 'text') {
return
}
const fieldName = (field.name || '').toLowerCase()
let value
if (fieldName === 'full name' || fieldName === 'legal name') {
value = this.currentUser.full_name
} else if (fieldName === 'first name') {
value = this.currentUser.first_name
} else if (fieldName === 'last name') {
value = this.currentUser.last_name
}
if (value) {
this.values[field.uuid] ??= value
}
})
}
this.fields.forEach((field) => { this.fields.forEach((field) => {
if (field.default_value && !field.readonly) { if (field.default_value && !field.readonly) {
this.values[field.uuid] ||= field.default_value this.values[field.uuid] ||= field.default_value
@@ -1520,15 +1574,10 @@ export default {
const newUrl = [window.location.pathname, queryParams.toString()].filter(Boolean).join('?') const newUrl = [window.location.pathname, queryParams.toString()].filter(Boolean).join('?')
window.history.replaceState({}, document.title, newUrl) window.history.replaceState({}, document.title, newUrl)
return fetch(this.baseUrl + '/api/submitter_email_clicks', { return fetch(this.baseUrl + `/s/${this.submitterSlug}/click_email`, {
method: 'POST', method: 'POST',
headers: { headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers },
'Content-Type': 'application/json' body: JSON.stringify({ t })
},
body: JSON.stringify({
t,
submitter_slug: this.submitterSlug
})
}) })
} else { } else {
return Promise.resolve({}) return Promise.resolve({})
@@ -1544,29 +1593,28 @@ export default {
const newUrl = [window.location.pathname, queryParams.toString()].filter(Boolean).join('?') const newUrl = [window.location.pathname, queryParams.toString()].filter(Boolean).join('?')
window.history.replaceState({}, document.title, newUrl) window.history.replaceState({}, document.title, newUrl)
return fetch(this.baseUrl + '/api/submitter_sms_clicks', { return fetch(this.baseUrl + `/s/${this.submitterSlug}/click_sms`, {
method: 'POST', method: 'POST',
headers: { headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers },
'Content-Type': 'application/json' body: JSON.stringify({ c })
},
body: JSON.stringify({
c,
submitter_slug: this.submitterSlug
})
}) })
} else { } else {
return Promise.resolve({}) return Promise.resolve({})
} }
}, },
trackViewForm () { trackViewForm () {
fetch(this.baseUrl + '/api/submitter_form_views', { fetch(this.baseUrl + `/s/${this.submitterSlug}/view`, {
method: 'POST', method: 'POST',
headers: { headers: { 'Content-Type': 'application/json' },
'Content-Type': 'application/json' body: JSON.stringify({ v: this.viewToken })
}, }).then((resp) => {
body: JSON.stringify({ if (resp.status === 404) {
submitter_slug: this.submitterSlug return fetch(this.baseUrl + '/api/submitter_form_views', {
}) method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ submitter_slug: this.submitterSlug })
})
}
}) })
}, },
previousSignatureValueFor (field) { previousSignatureValueFor (field) {
@@ -1764,6 +1812,10 @@ export default {
this.isCompleted = true this.isCompleted = true
this.isFormVisible = true this.isFormVisible = true
if (this.nativePlatform) {
window.webkit?.messageHandlers?.native?.postMessage({ type: 'stale' })
}
if (resp?.text) { if (resp?.text) {
const respData = await resp.text() const respData = await resp.text()
@@ -1776,7 +1828,7 @@ export default {
window.location.href = sanitizeUrl(this.completedRedirectUrl) window.location.href = sanitizeUrl(this.completedRedirectUrl)
} else { } else {
this.$nextTick(() => { this.$nextTick(() => {
const root = this.$root.$el.parentNode.getRootNode() const root = this.$root.$el.parentNode?.getRootNode() || document
const completedEl = root.getElementById('form_completed') const completedEl = root.getElementById('form_completed')
if (completedEl) { if (completedEl) {
Loaded 100 of 323 files, more files were not shown because too many files have changed in this diff. Show more