Compare commits

...

386 Commits

Author SHA1 Message Date
Alex Turchyn 911e55ccc3 Merge from docusealco/wip 2026-04-13 13:25:16 +03:00
Pete Matsyburka 41fedfcc40 show verification error 2026-04-12 13:30:13 +03:00
Pete Matsyburka 2f9fc95af3 remove unused module 2026-04-11 18:13:25 +03:00
Pete Matsyburka 11b0b16ca7 prefill signature client side 2026-04-11 18:13:25 +03:00
Pete Matsyburka f8cb7ffdab remove mathjs 2026-04-11 18:13:23 +03:00
Pete Matsyburka 8b59c0aaa0 adjust normalize values 2026-04-10 15:20:35 +03:00
Pete Matsyburka bb2fb7a0c2 refactor download 2026-04-10 12:54:48 +03:00
Pete Matsyburka 8f8b36617a fix first party download from preview 2026-04-10 11:15:16 +03:00
Pete Matsyburka ff53436fd4 fix dynamic editor layout 2026-04-08 17:18:38 +03:00
Pete Matsyburka cf09a4f733 update gem 2026-04-08 12:24:31 +03:00
Pete Matsyburka 2303c21cea download users csv 2026-04-08 12:24:22 +03:00
Pete Matsyburka 89e797b95f update message 2026-04-08 10:29:45 +03:00
Pete Matsyburka 20375c3a42 update gem 2026-04-08 08:40:41 +03:00
Pete Matsyburka 339ceda18d upload attachment if not completed 2026-04-07 18:20:16 +03:00
Pete Matsyburka 97ce32fd52 set attachment name 2026-04-07 16:34:15 +03:00
Alex Turchyn 8c4cbd86f7 Merge from docusealco/wip 2026-04-06 15:22:38 +03:00
Pete Matsyburka eaedaa96bb fix rubocop 2026-04-02 13:28:12 +03:00
Pete Matsyburka bd7ad5fce8 update gems 2026-04-02 13:28:12 +03:00
Pete Matsyburka 759dac1b70 update schema 2026-04-01 19:07:57 +03:00
Pete Matsyburka 38577c6235 fix dynamic doc fields assignment 2026-04-01 16:08:49 +03:00
Pete Matsyburka b24fff0f55 fix rtl stamp 2026-04-01 11:10:09 +03:00
Pete Matsyburka 9e0efeb092 fix unicode in stamp 2026-04-01 10:29:31 +03:00
Pete Matsyburka 41152f90fc fix erblint 2026-03-30 15:18:34 +03:00
Alex Turchyn 4486f8040e Merge from docusealco/wip 2026-03-30 14:35:18 +03:00
Pete Matsyburka a98caad968 use musl build 2026-03-29 11:18:41 +03:00
Pete Matsyburka ffb75238b4 disable confetti by default 2026-03-28 16:53:20 +02:00
Pete Matsyburka cba3e74f3a optimize build 2026-03-28 16:44:08 +02:00
Pete Matsyburka 8d3f0b5b98 silence not found trace 2026-03-28 14:50:38 +02:00
Pete Matsyburka a2598b30f7 optimize build 2026-03-28 13:48:39 +02:00
Pete Matsyburka 8d11f5ddae adjust email 2fa 2026-03-28 12:51:00 +02:00
Pete Matsyburka 6ffc88b4ed fix n+1 2026-03-28 09:27:50 +02:00
Pete Matsyburka 640b451727 adjust shared link 2026-03-28 09:07:52 +02:00
Pete Matsyburka db6e2eb7ca preserve order with verification 2026-03-27 17:16:06 +02:00
Pete Matsyburka 407816b576 redirect delegate completed 2026-03-27 14:51:14 +02:00
Pete Matsyburka 486cc493e6 add i18n 2026-03-27 14:21:47 +02:00
Pete Matsyburka 3eb0b8a89f allow to delegate 2026-03-27 14:21:47 +02:00
Pete Matsyburka c23eca0ade fix field style 2026-03-26 16:09:57 +02:00
Pete Matsyburka 67de153016 increase image limit 2026-03-26 11:40:42 +02:00
Pete Matsyburka ba2da69a67 fix load ico 2026-03-26 08:29:17 +02:00
Pete Matsyburka 111bef14e1 update rails 2026-03-24 23:56:40 +02:00
Alex Turchyn 22d4c1bd10 Merge from docusealco/wip 2026-03-23 14:59:53 +02:00
Pete Matsyburka e3dfb0ae83 add SMTP_ENABLE_TLS 2026-03-23 10:02:54 +02:00
Pete Matsyburka 8d7c3f0e26 fix generation 2026-03-23 09:48:39 +02:00
Pete Matsyburka b11775ebda remove premailer 2026-03-22 18:13:41 +02:00
Pete Matsyburka 608191183a update deps 2026-03-21 13:37:31 +02:00
Pete Matsyburka 2ba903e170 fix specs 2026-03-18 10:18:28 +02:00
Alex Turchyn 342b64d502 add translations 2026-03-18 09:52:58 +02:00
Pete Matsyburka 1a8f048aaa update devise 2026-03-18 09:52:21 +02:00
Pete Matsyburka 7309581b75 add template archived event 2026-03-18 09:52:21 +02:00
Pete Matsyburka 36d59c40a7 add template update event 2026-03-18 08:36:36 +02:00
Pete Matsyburka ed3c7c324b adjust save dynamic 2026-03-16 17:24:07 +02:00
Alex Turchyn fd868360a3 Merge from docusealco/wip 2026-03-16 17:07:07 +02:00
Pete Matsyburka 8dc2ebfbe0 fix builder save 2026-03-16 16:59:45 +02:00
Alex Turchyn 997711b34a Merge from docusealco/wip 2026-03-16 13:36:06 +02:00
Pete Matsyburka ac01b7129d add i18n 2026-03-16 12:42:58 +02:00
Pete Matsyburka f4b7528c0d fix send form 2026-03-16 08:52:42 +02:00
Pete Matsyburka d7b9ae5df5 add datenow size 2026-03-16 08:21:36 +02:00
Pete Matsyburka 4db29126e9 fix variables list 2026-03-16 08:04:02 +02:00
Pete Matsyburka 4cfee2fe45 fix schema documents 2026-03-15 15:02:46 +02:00
Pete Matsyburka db5dbd6ac2 adjust dynamic editor 2026-03-15 14:05:20 +02:00
Pete Matsyburka 44db775b89 fix variable style 2026-03-14 17:47:53 +02:00
Alex Turchyn 8e2e780a1a fix webhook events filtering 2026-03-13 20:04:09 +02:00
Pete Matsyburka 7aba2eb4da disable decline 2026-03-12 08:58:56 +02:00
Pete Matsyburka 3e250bfb28 error update if declined 2026-03-11 23:44:06 +02:00
Pete Matsyburka 9c02ed66f5 fix field scroll 2026-03-11 14:43:16 +02:00
Pete Matsyburka f5ae8c60d3 preload dynamic editor 2026-03-11 10:44:12 +02:00
Pete Matsyburka e90ac391b2 update readme 2026-03-10 19:09:00 +02:00
Pete Matsyburka 0e5f949bcf remove email from signing reason 2026-03-10 11:39:00 +02:00
Pete Matsyburka b7c270bef8 dir auto section 2026-03-10 10:31:45 +02:00
Pete Matsyburka 5f16b506f5 fix n+1 2026-03-10 08:12:32 +02:00
Pete Matsyburka 176148fc71 adjust default initial 2026-03-09 17:29:46 +02:00
Alex Turchyn 861daaedf4 Merge from docusealco/wip 2026-03-09 10:40:29 +02:00
Pete Matsyburka d8430f4e63 fix migration 2026-03-09 09:02:56 +02:00
Pete Matsyburka dd44864485 fix field position 2026-03-06 11:37:53 +02:00
Pete Matsyburka b3bb29f383 fix brakeman 2026-03-05 21:42:55 +02:00
Pete Matsyburka b4d137c0de remove flash 2026-03-05 12:30:26 +02:00
Alex Turchyn 62a969d8fe add MCP support 2026-03-05 12:17:14 +02:00
Pete Matsyburka 961f09e092 set dynamic fields 2026-03-05 10:08:29 +02:00
Pete Matsyburka b2dfa83f18 upload json 2026-03-04 12:27:15 +02:00
Pete Matsyburka 37196ff89f add dynamic documents 2026-03-04 10:13:30 +02:00
Pete Matsyburka b50d982497 smtp timeout env vars 2026-03-03 22:49:55 +02:00
Pete Matsyburka 4a484aca69 hide storage nav if configured with env 2026-03-03 22:01:44 +02:00
Pete Matsyburka 0bf37d571a update color 2026-03-03 18:46:06 +02:00
Alex Turchyn d4e94ac2bc Merge from docusealco/wip 2026-03-02 17:26:04 +02:00
Pete Matsyburka 75bc789bb1 add reply to 2026-03-02 14:47:37 +02:00
Pete Matsyburka bc231d5bf1 add reply to 2026-03-02 14:34:16 +02:00
Alex Turchyn ab9008b3be fix brakeman SQL injection warning 2026-03-01 16:14:15 +02:00
Pete Matsyburka 65491936c3 fix archived export 2026-02-26 17:20:05 +02:00
Pete Matsyburka e589998840 add field type class 2026-02-26 11:08:26 +02:00
Pete Matsyburka 0afba8d11e adjust with prefillable 2026-02-26 11:03:08 +02:00
Pete Matsyburka f622ed168f fix autocomplete 2026-02-25 13:01:03 +02:00
Pete Matsyburka 5a31df40d8 add field settings classes 2026-02-25 11:02:00 +02:00
Pete Matsyburka db3b80c96c fix clone 2026-02-25 11:02:00 +02:00
Pete Matsyburka bd28a761d0 adjust bmp loader 2026-02-25 11:02:00 +02:00
Pete Matsyburka 4f2830a34d increase image resolution 2026-02-25 11:02:00 +02:00
Pete Matsyburka a5716cd518 show html textarea 2026-02-25 11:02:00 +02:00
Pete Matsyburka d035525c7a additional terms 2026-02-25 11:02:00 +02:00
Alex Turchyn 13fa87c449 Merge from docusealco/wip 2026-02-23 13:56:04 +02:00
Pete Matsyburka 246ccfb049 fix highlight 2026-02-23 08:31:38 +02:00
Pete Matsyburka cbffb1e5b8 cloudflare r2 fix 2026-02-22 20:13:19 +02:00
Pete Matsyburka c4b007411d radio select in formula 2026-02-22 19:54:06 +02:00
Pete Matsyburka 04264fe937 fix reason field 2026-02-22 12:15:44 +02:00
Pete Matsyburka 0c37ad12b7 add variables key 2026-02-22 09:46:04 +02:00
Pete Matsyburka 8c5298ed2e refactor link tooltip 2026-02-22 09:28:30 +02:00
Pete Matsyburka 262118e047 update deps 2026-02-22 07:37:05 +02:00
Pete Matsyburka e5b63ea2b0 fix multi party email form 2026-02-21 17:31:04 +02:00
Pete Matsyburka 746757d402 rtl in editor 2026-02-21 16:38:25 +02:00
Pete Matsyburka e6e640328b use br for email content 2026-02-21 16:20:48 +02:00
Pete Matsyburka 845782a69c link input width 2026-02-21 15:48:59 +02:00
Pete Matsyburka fc6baa1b3b adjust autolink 2026-02-21 15:48:58 +02:00
Pete Matsyburka 47822ecc15 remove autolink 2026-02-21 14:55:11 +02:00
Pete Matsyburka 3d0c7f1118 markdown in preferences 2026-02-21 14:44:19 +02:00
Pete Matsyburka e9c8e4d325 adjust editor 2026-02-21 14:32:10 +02:00
Alex Turchyn 39cc82ce0c add markdown editor 2026-02-21 11:15:09 +02:00
Pete Matsyburka 451f83421a fix cell render 2026-02-21 10:49:05 +02:00
Pete Matsyburka 5073f2eefb adjsut sign yourself 2026-02-21 10:17:58 +02:00
Pete Matsyburka 231fff5508 canvas blocked message 2026-02-21 09:53:03 +02:00
Pete Matsyburka f1d146eca3 fix typos 2026-02-20 22:39:03 +02:00
Pete Matsyburka b6635fcc4f change user only when root 2026-02-20 22:25:12 +02:00
Pete Matsyburka fb5e13ee4c adjust recipients form 2026-02-20 18:26:10 +02:00
Pete Matsyburka ba84741a64 invite party via field 2026-02-20 12:41:45 +02:00
Pete Matsyburka 65c275ac17 rename 2026-02-20 10:16:06 +02:00
Pete Matsyburka 825322d489 adjust for custom domain 2026-02-20 09:14:36 +02:00
Pete Matsyburka 739e2abdf8 vips size limit 2026-02-19 14:24:05 +02:00
Pete Matsyburka 1d2394e31e size limit 2026-02-19 14:17:59 +02:00
Pete Matsyburka 1b41af798d update 2026-02-17 19:42:47 +02:00
Pete Matsyburka 118f4a231b detailed time format 2026-02-17 18:09:34 +02:00
Pete Matsyburka e48652f425 fix rubocop 2026-02-17 10:29:00 +02:00
Pete Matsyburka 848f01edf8 add spec 2026-02-17 10:19:29 +02:00
Pete Matsyburka 43fbc42770 fix reason field 2026-02-17 10:04:11 +02:00
Pete Matsyburka 2c736a0eed validate readonly field 2026-02-17 08:53:13 +02:00
Alex Turchyn 33ca930055 Merge from docusealco/wip
Wip
2026-02-16 15:08:07 +02:00
Pete Matsyburka 3b396f8421 fix redirect 2026-02-16 10:00:51 +02:00
Pete Matsyburka aa77e3a8d3 adjust port check 2026-02-16 08:08:41 +02:00
Pete Matsyburka 75316d8d87 fix draw custom field 2026-02-15 22:02:00 +02:00
Pete Matsyburka a999109a5c add port check 2026-02-15 12:18:53 +02:00
Pete Matsyburka bdd33c7d6b fix spec 2026-02-15 09:53:31 +02:00
Pete Matsyburka 61c5ee22a0 hide form credentials 2026-02-15 09:11:40 +02:00
Pete Matsyburka 12c5b909e0 use cancan 2026-02-14 19:13:05 +02:00
Pete Matsyburka 871ef6dda6 add fetch options 2026-02-14 18:50:48 +02:00
Pete Matsyburka 40052a2d7c use query_params 2026-02-14 17:42:08 +02:00
Pete Matsyburka 347be0137d refactor 2fa 2026-02-14 17:35:10 +02:00
Pete Matsyburka fe6baba8bf fix erb lint 2026-02-14 12:10:21 +02:00
Pete Matsyburka ed8c313bd4 timestamp controller multitenant 2026-02-14 12:02:37 +02:00
Pete Matsyburka ca0acb34d6 use url for open modal 2026-02-14 12:02:31 +02:00
Pete Matsyburka 34ea639c25 escape wildcard query 2026-02-14 12:02:25 +02:00
Pete Matsyburka 680ab9dbed raise invalid param 2026-02-14 10:18:58 +02:00
Pete Matsyburka 9377766e52 fix url download 2026-02-14 09:08:52 +02:00
Pete Matsyburka 7a2c37454e update favicon 2026-02-12 15:56:54 +02:00
Pete Matsyburka 8f6f418f54 fix font size 2026-02-12 14:27:15 +02:00
Pete Matsyburka 9b7745c565 hide resend on archived 2026-02-11 17:32:04 +02:00
Alex Turchyn a389e8cc38 Merge from docusealco/wip 2026-02-11 16:49:32 +02:00
Pete Matsyburka 2056301f70 remember send tab selection 2026-02-11 13:14:09 +02:00
Pete Matsyburka 93babe3660 update logo 2026-02-11 10:01:12 +02:00
Pete Matsyburka 4aef2d8eb0 update deps 2026-02-10 09:02:37 +02:00
Pete Matsyburka c5ec2ed230 fix document condition 2026-02-05 11:56:22 +02:00
Pete Matsyburka a156d5f6cc increase webhook timeout 2026-02-05 08:42:53 +02:00
Pete Matsyburka f84674cddd adjust field focus 2026-02-04 17:05:12 +02:00
Pete Matsyburka b7b62c197a fix date field 2026-02-04 16:56:35 +02:00
Pete Matsyburka f5350eac36 prefill typed signature and initials from name 2026-02-04 16:48:40 +02:00
Pete Matsyburka ee6228db15 only required field prop 2026-02-04 10:51:11 +02:00
Pete Matsyburka 3e434c0878 fix 2026-02-04 00:47:34 +02:00
Pete Matsyburka 7c5372a8e2 fix pdf sign 2026-02-02 22:04:43 +02:00
Alex Turchyn 6ab68cc36b Merge from docusealco/wip 2026-02-02 14:13:54 +02:00
Pete Matsyburka ee2400a5c5 touch signature attachment 2026-02-02 12:46:34 +02:00
Pete Matsyburka 80be5d2cf5 sandbox 2026-02-01 08:10:02 +02:00
Pete Matsyburka 76cf44a31b fix 2026-01-31 22:17:11 +02:00
Pete Matsyburka eb63113227 use trilogy mysql 2026-01-31 22:15:17 +02:00
Pete Matsyburka b7045fb3d9 fix azure proxy 2026-01-31 13:07:42 +02:00
Pete Matsyburka 11a5b5842d starttls label 2026-01-31 11:41:52 +02:00
Pete Matsyburka 362994afd5 adjust i18n 2026-01-31 10:26:09 +02:00
Pete Matsyburka d7682f4ebd add number conditions 2026-01-31 09:00:24 +02:00
Pete Matsyburka 56d4fe7fa9 add white color 2026-01-31 07:58:12 +02:00
Pete Matsyburka 93a852ac14 fix save modal 2026-01-30 21:45:55 +02:00
Pete Matsyburka 964db0ab08 fix menu 2026-01-30 21:03:56 +02:00
Pete Matsyburka 8547295da7 border color 2026-01-30 20:53:47 +02:00
Pete Matsyburka c75050fa46 adjust kba 2026-01-30 18:17:47 +02:00
Pete Matsyburka 65372c52aa fix order 2026-01-30 17:42:50 +02:00
Alex Turchyn 12c767b7d3 add more options to context menu 2026-01-30 16:36:50 +02:00
Pete Matsyburka 2fc5e9cfb3 format cells 2026-01-30 11:21:47 +02:00
Pete Matsyburka bccc78ee65 optimize builder 2026-01-30 10:23:51 +02:00
Pete Matsyburka c0793f3ce7 cell font settings 2026-01-29 21:43:01 +02:00
Pete Matsyburka a8dd8699a3 adjust kba 2026-01-29 15:55:15 +02:00
Pete Matsyburka 91bb25e8b6 fix reload 2026-01-29 15:33:24 +02:00
Pete Matsyburka c3f4480cdc add submitter index 2026-01-29 13:01:44 +02:00
Pete Matsyburka 33e226861e add SMTP_SSL_VERIFY env 2026-01-29 11:34:37 +02:00
Pete Matsyburka 4f5acbec9d readonly radio 2026-01-28 21:36:48 +02:00
Pete Matsyburka d2c9bbf6ab optimize conditions 2026-01-28 13:24:05 +02:00
Pete Matsyburka b5e57e1f2f add include fields 2026-01-28 11:49:35 +02:00
Pete Matsyburka c82c8b2272 fix race condition 2026-01-28 08:50:44 +02:00
Pete Matsyburka 43f1b598a7 adjust smtp noverify 2026-01-28 08:27:25 +02:00
Pete Matsyburka 5081e387d0 fix undefined 2026-01-27 20:13:26 +02:00
Pete Matsyburka 1e5f55c1d6 fix Point is invalid 2026-01-27 20:07:57 +02:00
Alex Turchyn aa38773be3 Merge from docusealco/wip 2026-01-27 18:39:04 +02:00
Pete Matsyburka 96530bd809 fix race condition 2026-01-27 17:06:30 +02:00
Pete Matsyburka 127f95a9d0 fix typo 2026-01-27 12:31:06 +02:00
Pete Matsyburka cf31e12348 adjust smtp configs 2026-01-27 10:57:04 +02:00
Pete Matsyburka 4adaa531b7 fix pagination 2026-01-27 10:34:18 +02:00
Pete Matsyburka c7659563b8 fix puma warnings 2026-01-26 20:23:11 +02:00
Alex Turchyn 7b5494468f update rails to 8.1.2 2026-01-26 18:22:47 +02:00
Pete Matsyburka d12c67fd54 with force open 2026-01-26 18:00:46 +02:00
Pete Matsyburka e20eacb5ee center on click 2026-01-26 12:35:53 +02:00
Pete Matsyburka 98055f0762 add can parse polyfill 2026-01-26 12:17:57 +02:00
Pete Matsyburka a46c42c3a0 fix resize warning 2026-01-26 12:06:41 +02:00
Pete Matsyburka c3d1503361 cleanup 2026-01-26 11:44:12 +02:00
Pete Matsyburka 65b47d27cd adjust form 2026-01-26 11:38:33 +02:00
Pete Matsyburka 968c07f8be include idcode 2026-01-26 11:13:01 +02:00
Pete Matsyburka 91814ca105 custom fields 2026-01-26 11:12:55 +02:00
Pete Matsyburka 9128f24270 fix synology acl 2026-01-24 18:01:49 +02:00
Pete Matsyburka 264897bbd5 audit with sender 2026-01-23 12:12:37 +02:00
Pete Matsyburka 67d59ff1ba fix invite by order 2026-01-22 21:20:34 +02:00
Pete Matsyburka 81eee56e1e i18n 2026-01-21 20:14:53 +02:00
Pete Matsyburka 92ea8af786 fix options drag 2026-01-21 16:02:20 +02:00
Pete Matsyburka df0616f77e paste options 2026-01-21 15:42:38 +02:00
Pete Matsyburka a337dff85c change model 2026-01-21 10:23:26 +02:00
Pete Matsyburka 48e349c1d2 fix signature pad when resize 2026-01-20 17:24:05 +02:00
Pete Matsyburka df9fa0fec7 fix scroll into field 2026-01-20 15:27:35 +02:00
Alex Turchyn b3e72f0726 Merge from docusealco/wip 2026-01-19 15:40:56 +02:00
Pete Matsyburka 700ebc4727 render reason 2026-01-19 13:56:17 +02:00
Pete Matsyburka 745a18efca fix select mode draw 2026-01-19 11:57:09 +02:00
Pete Matsyburka b4a8147ff0 fix radio group paste 2026-01-19 11:51:00 +02:00
Pete Matsyburka 0543c25054 detect fields in context menu 2026-01-19 09:23:04 +02:00
Pete Matsyburka b1a0afea66 fix menu 2026-01-18 10:29:15 +02:00
Pete Matsyburka 43c41e2557 add select fields mode 2026-01-18 09:52:43 +02:00
Pete Matsyburka bc8eb33f05 Revert "hide area name menu on desktop"
This reverts commit 5212773d0a.
2026-01-17 19:42:27 +02:00
Pete Matsyburka d1cebc92f7 fix eslint 2026-01-16 12:38:29 +02:00
Pete Matsyburka bf02cf4058 fix paste 2026-01-16 12:25:50 +02:00
Pete Matsyburka 5212773d0a hide area name menu on desktop 2026-01-16 12:18:17 +02:00
Alex Turchyn bf53204a41 add context menu to template builder 2026-01-16 12:07:59 +02:00
Pete Matsyburka 092a19966c adjust condition 2026-01-15 17:34:05 +02:00
Pete Matsyburka 6060a2b798 fix icon 2026-01-14 21:58:30 +02:00
Pete Matsyburka ce12af68b2 fix required 2026-01-14 14:32:00 +02:00
Pete Matsyburka c05f8d47aa require 2fa 2026-01-14 12:44:27 +02:00
Pete Matsyburka f8be19c20e pg 18 2026-01-13 20:44:52 +02:00
Pete Matsyburka 2fe32a2145 show version 2026-01-13 12:25:08 +02:00
Pete Matsyburka 794ea4d0e8 remove expired from pending filter 2026-01-13 12:07:39 +02:00
Pete Matsyburka 307b0f5afd fix build 2026-01-13 09:44:58 +02:00
Alex Turchyn 9011e3f4b4 Merge from docusealco/wip 2026-01-12 13:25:54 +02:00
Pete Matsyburka 0b45ce5c0c update model 2026-01-12 12:05:54 +02:00
Pete Matsyburka a8ab93a4cc adjust sort 2026-01-12 11:27:19 +02:00
Pete Matsyburka d1768be636 secure env 2026-01-12 11:27:17 +02:00
Pete Matsyburka b2cd7c82a7 insert new field at page position 2026-01-11 14:45:42 +02:00
Pete Matsyburka b88d99afe0 merge preview docs 2026-01-11 11:09:00 +02:00
Pete Matsyburka 4523d38551 direct links option 2026-01-09 22:05:37 +02:00
Pete Matsyburka c4ba892559 refactor clone 2026-01-08 09:34:45 +02:00
Pete Matsyburka 40f8093e00 adjust onnx 2026-01-07 14:50:27 +02:00
Pete Matsyburka 4375459856 add nmm 2026-01-07 12:19:05 +02:00
Pete Matsyburka b3eb8a70bf fix submitter uuid 2026-01-07 00:01:47 +02:00
Pete Matsyburka 6245a487b6 add kba countdown 2026-01-06 11:24:30 +02:00
Pete Matsyburka a89f4c1b10 adjust trim 2026-01-06 10:22:34 +02:00
Pete Matsyburka 7df729b8b5 containment threshold 2026-01-06 09:04:25 +02:00
Pete Matsyburka f1db7c7e82 adjust trim 2026-01-06 08:54:29 +02:00
Alex Turchyn 00af69cbf2 Merge from docusealco/wip 2026-01-05 21:46:19 +02:00
Pete Matsyburka c6819a19bd adjust inference 2026-01-05 18:36:50 +02:00
Pete Matsyburka f5cbcb3d24 adjust kba 2026-01-05 13:45:41 +02:00
Pete Matsyburka 6ca75c53ef fix condition 2026-01-05 12:46:43 +02:00
Pete Matsyburka e0ec443e10 fix mysql migration 2026-01-05 12:34:50 +02:00
Pete Matsyburka 9eed2fc2ef fix 2026-01-05 12:21:34 +02:00
Pete Matsyburka b9c6d3ff9f model v2 2026-01-05 09:18:38 +02:00
Pete Matsyburka 03c25a89b2 fix save 2026-01-04 13:16:57 +02:00
Pete Matsyburka f5b4a0c5ab inference v2 2026-01-03 16:55:05 +02:00
Pete Matsyburka b10ed46ccc add kba 2026-01-02 17:02:45 +02:00
Pete Matsyburka c55036a1b1 update gem 2025-12-31 23:27:41 +02:00
Pete Matsyburka 962a5dade5 add preview url 2025-12-31 22:32:27 +02:00
Pete Matsyburka 369a09a6df fix upload 2025-12-26 12:13:30 +02:00
Pete Matsyburka e9a39e2571 add s3 endpoint variable 2025-12-25 14:03:57 +02:00
Pete Matsyburka 1cf9cc18f1 do not retry 2025-12-23 10:17:19 +02:00
Pete Matsyburka 1984d4ec8d fix sync 2025-12-22 20:31:00 +02:00
Alex Turchyn fa99b4ebdc Merge from docusealco/wip 2025-12-22 02:27:47 +02:00
Pete Matsyburka 83c793a2a5 reorder field options 2025-12-21 16:12:57 +02:00
Pete Matsyburka d078727070 recipient fields condition 2025-12-21 13:19:16 +02:00
Pete Matsyburka 0d8e2c5ff0 adjust signature size 2025-12-20 17:12:15 +02:00
Pete Matsyburka ea062d7494 update gem 2025-12-19 12:26:01 +02:00
Pete Matsyburka d525e0597a recipient fields config 2025-12-19 12:26:00 +02:00
Pete Matsyburka 964b5d4e74 validate webhook 2025-12-18 12:45:33 +02:00
Pete Matsyburka abf17eb09f localhost list 2025-12-18 12:12:40 +02:00
Pete Matsyburka 178809dad7 fix field render 2025-12-16 21:54:36 +02:00
Pete Matsyburka 881d189715 fix phone 2025-12-16 12:34:18 +02:00
Pete Matsyburka 48a8973910 add with field labels 2025-12-16 11:38:37 +02:00
Alex Turchyn ae891932c5 Merge from docusealco/wip 2025-12-15 13:19:09 +02:00
Alex Turchyn a8b5e00814 show field boxes on the submissions page 2025-12-15 12:05:37 +02:00
Alex Turchyn e98432d7e4 update docs 2025-12-15 11:11:00 +02:00
Pete Matsyburka 29ff58ed98 fix drawing 2025-12-15 10:22:27 +02:00
Pete Matsyburka a621575ffc 11k 2025-12-14 22:24:07 +02:00
Alex Turchyn d7c7efc203 update brakeman ignore rules 2025-12-12 21:44:54 +02:00
Pete Matsyburka 4f013ca927 do not combine with verification 2025-12-12 20:39:18 +02:00
Pete Matsyburka d9823feef7 fix option 2025-12-12 09:22:42 +02:00
Pete Matsyburka 676d775544 fix generation 2025-12-12 09:14:23 +02:00
Pete Matsyburka 840ad17258 fix locale 2025-12-11 11:11:46 +02:00
Alex Turchyn ce0cd09b70 add translations 2025-12-11 10:28:35 +02:00
Alex Turchyn bb752fbc00 update translations 2025-12-11 10:28:07 +02:00
Pete Matsyburka c71fb04404 fix mysql migration 2025-12-10 13:43:34 +02:00
Pete Matsyburka e856cf8787 validate redirect url 2025-12-10 09:13:55 +02:00
Pete Matsyburka 71aae65bc6 add require email 2fa param 2025-12-09 11:46:57 +02:00
Pete Matsyburka 7a33444f9b add invite_by 2025-12-09 11:39:15 +02:00
Pete Matsyburka e12ec9d986 fix build 2025-12-08 16:26:45 +02:00
Alex Turchyn 94d1fb7dcb Merge from docusealco/wip 2025-12-08 15:31:19 +02:00
Alex Turchyn 6e43a42274 add email 2FA 2025-12-08 14:51:49 +02:00
Pete Matsyburka 0e0aa0259e add validate prop 2025-12-07 15:31:20 +02:00
Pete Matsyburka 75ab6d39ef adjust unarchive 2025-12-07 14:57:21 +02:00
Pete Matsyburka 6f84d84f96 use newer model 2025-12-05 15:17:50 +02:00
Pete Matsyburka cf46593e9b fix required checbox 2025-12-04 21:41:36 +02:00
Pete Matsyburka 1808346348 fix build 2025-12-04 15:38:55 +02:00
Pete Matsyburka ea59a68499 adjust active job log 2025-12-04 14:54:55 +02:00
Pete Matsyburka cba2d02990 skip webhook not found 2025-12-03 13:20:52 +02:00
Pete Matsyburka e92fc0d3e1 adjust mobile field types 2025-12-03 10:33:22 +02:00
Pete Matsyburka f1eaa77092 password reset i18n 2025-12-02 14:25:45 +02:00
Alex Turchyn 5934bdec55 use set_reset_password_token 2025-12-02 14:00:51 +02:00
Alex Turchyn 7a7d3c6ee3 add translations for reset password instructions email 2025-12-02 14:00:51 +02:00
Pete Matsyburka cb2f58c04c fix i18n 2025-12-02 13:31:35 +02:00
Pete Matsyburka a1c117f1ca add i18n 2025-12-02 12:50:58 +02:00
Alex Turchyn 1aacd98460 Merge from docusealco/wip 2025-12-01 17:09:47 +02:00
Pete Matsyburka 840162c9ca fix sort 2025-12-01 10:37:58 +02:00
Pete Matsyburka 3519b0e009 fix typo 2025-12-01 10:15:23 +02:00
Pete Matsyburka 53fae56c51 fix regexp field types 2025-12-01 10:09:27 +02:00
Pete Matsyburka f4552dbaa8 skip reconfirmation 2025-11-30 16:30:56 +02:00
Pete Matsyburka dfab4c331c send confirmation 2025-11-30 16:04:30 +02:00
Pete Matsyburka d2a0937038 fix pdf text emoji 2025-11-30 14:37:05 +02:00
Pete Matsyburka fa1ef44b22 add tabs 2025-11-30 12:41:16 +02:00
Pete Matsyburka c1a5c91299 adjust detection 2025-11-30 11:11:08 +02:00
Pete Matsyburka e6280e8f5a optimize text nodes 2025-11-29 18:28:10 +02:00
Pete Matsyburka fd2ba57325 adjust invitation 2025-11-29 10:34:13 +02:00
Pete Matsyburka dc178ef03f adjust router 2025-11-28 16:15:57 +02:00
Alex Turchyn 68936e10c5 add confirmation columns 2025-11-28 15:57:36 +02:00
Pete Matsyburka 41f16e7d39 adjust detection 2025-11-27 17:00:26 +02:00
Pete Matsyburka 453ebbf90a adjust start form 2025-11-27 12:23:45 +02:00
Pete Matsyburka 7ca290c080 adjust detection 2025-11-25 13:43:35 +02:00
Alex Turchyn dd5b6a6aa6 Merge from docusealco/wip 2025-11-24 17:43:59 +02:00
Pete Matsyburka bf2ebb995b add tz info 2025-11-24 15:46:50 +02:00
Alex Turchyn 3e929758fb add the reports page 2025-11-24 15:46:04 +02:00
Pete Matsyburka 4a53116586 adjust links 2025-11-24 12:28:13 +02:00
Pete Matsyburka 2d1981acfa adjust billing link 2025-11-24 11:13:00 +02:00
Pete Matsyburka c610df269f adjust populate 2025-11-22 13:02:58 +02:00
Pete Matsyburka 96dac635f5 add us timezones 2025-11-21 17:50:04 +02:00
Pete Matsyburka abd579103d add index 2025-11-21 13:41:51 +02:00
Pete Matsyburka 7ed27bb413 populate columns 2025-11-21 12:33:43 +02:00
Pete Matsyburka 180c542a9f add index 2025-11-21 11:45:19 +02:00
Pete Matsyburka 89613113de set is first completed submitter 2025-11-21 11:31:02 +02:00
Pete Matsyburka 3102900776 add is first to completed submitters 2025-11-21 11:27:28 +02:00
Pete Matsyburka 1a68053f4e set submission event account id 2025-11-21 11:18:28 +02:00
Pete Matsyburka 5d4a032763 add submission event account id 2025-11-21 11:17:20 +02:00
Pete Matsyburka 7481d27822 add tsa timeout 2025-11-18 22:17:05 +02:00
Alex Turchyn 22afd01fd2 Merge from docusealco/wip 2025-11-17 13:20:44 +02:00
Pete Matsyburka fd9eb09dd6 use can? 2025-11-17 10:21:38 +02:00
Alex Turchyn c53ed7f984 add custom invitation reminder emails 2025-11-17 10:05:52 +02:00
Pete Matsyburka 1d87e210be optimize detect fields 2025-11-16 19:05:54 +02:00
Pete Matsyburka 5fec952b8c adjust detections 2025-11-15 19:48:42 +02:00
Pete Matsyburka c4fbcc103a autocorrect pdf 2025-11-15 10:46:54 +02:00
Pete Matsyburka 9333a1ba45 fix fields error alert 2025-11-14 16:35:02 +02:00
Pete Matsyburka de5cd92dba adjust send email 2025-11-13 19:36:06 +02:00
Pete Matsyburka 83e32bec9f fix preferences form 2025-11-13 10:34:52 +02:00
Pete Matsyburka 4603efc04e refactor toggles form 2025-11-13 09:50:26 +02:00
Alex Turchyn 6c73c82a27 add ability to reset custom template emails to default 2025-11-13 08:56:09 +02:00
Pete Matsyburka 8e71d9f9e9 skip email 2025-11-13 08:29:27 +02:00
Pete Matsyburka 80f60a5d88 re-invite archived user 2025-11-12 20:33:52 +02:00
Pete Matsyburka 2c33ec382d fix device icon 2025-11-12 16:48:33 +02:00
Pete Matsyburka 884865cffc adjust size 2025-11-12 16:39:29 +02:00
Alex Turchyn 9537fe8c91 update constants 2025-11-12 16:33:51 +02:00
Alex Turchyn c8fe36a2b6 add desktop/mobile/tablet badges to events log 2025-11-12 16:33:51 +02:00
Pete Matsyburka 7ec3282b56 i18n 2025-11-11 17:34:50 +02:00
Pete Matsyburka ab8c41a520 add signature reasons param 2025-11-11 13:42:01 +02:00
Pete Matsyburka 4c1c70ff04 fix audit generation 2025-11-11 12:08:16 +02:00
Pete Matsyburka d7a6e80bb1 html escape font signature 2025-11-11 11:11:07 +02:00
Alex Turchyn f87ef670d1 Merge from docusealco/wip 2025-11-10 13:08:28 +02:00
Pete Matsyburka daba2505bc fix detection 2025-11-09 21:33:47 +02:00
Pete Matsyburka 71528eda45 map page nodes 2025-11-09 08:51:00 +02:00
Pete Matsyburka 269e2beeaa fix preview 2025-11-07 10:20:41 +02:00
Pete Matsyburka 72dd2c6bc5 add template download 2025-11-05 12:07:11 +02:00
Pete Matsyburka 9758f7a15f adjust detection 2025-11-04 16:17:13 +02:00
Alex Turchyn 2521eb5a4a Merge from docusealco/wip 2025-11-03 20:16:45 +02:00
Pete Matsyburka bf6afc5e61 adjust detection 2025-11-03 19:24:09 +02:00
Pete Matsyburka 291d869843 adjust detect 2025-11-03 09:49:25 +02:00
Pete Matsyburka cdfecb2a8f fix role validation 2025-11-03 09:17:50 +02:00
Pete Matsyburka c0df8e0ec3 close doc 2025-11-02 08:57:58 +02:00
Pete Matsyburka a21f7d1653 fix padding 2025-11-02 08:13:40 +02:00
Pete Matsyburka 79e2514416 fix model name 2025-11-02 08:11:31 +02:00
Pete Matsyburka 80a7385183 fix redirect 2025-11-02 08:09:54 +02:00
Pete Matsyburka 1c8a7b6a7c detect fields 2025-11-02 08:09:54 +02:00
Pete Matsyburka b46ced2b6f add log 2025-10-31 10:26:10 +02:00
Pete Matsyburka 5ae479644e focus on canvas click 2025-10-31 10:18:39 +02:00
Pete Matsyburka cb66eb1293 fix redirect 2025-10-31 10:10:19 +02:00
Pete Matsyburka b4d5a2cad4 dangerous extension error 2025-10-30 08:47:01 +02:00
Pete Matsyburka 32691b0c0d add background 2025-10-29 10:10:04 +02:00
Pete Matsyburka 9163f744a3 remove sign up button 2025-10-27 20:03:33 +02:00
Pete Matsyburka 23ecce51a3 hide archived completed page 2025-10-22 22:03:39 +03:00
Pete Matsyburka 747ffa8cf1 adjust signature ID size 2025-10-15 13:38:53 +03:00
417 changed files with 45720 additions and 29809 deletions
+6 -6
View File
@@ -12,7 +12,7 @@ jobs:
- name: Install Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.4.2
ruby-version: 4.0.1
- name: Cache gems
uses: actions/cache@v4
with:
@@ -37,7 +37,7 @@ jobs:
- name: Install Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.4.2
ruby-version: 4.0.1
- name: Cache gems
uses: actions/cache@v4
with:
@@ -62,7 +62,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v1
with:
node-version: 20.9.0
node-version: 20.19.0
- name: Cache directory path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
@@ -89,7 +89,7 @@ jobs:
- name: Install Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.4.2
ruby-version: 4.0.1
- name: Cache gems
uses: actions/cache@v4
with:
@@ -132,11 +132,11 @@ jobs:
- name: Install Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.4.2
ruby-version: 4.0.1
- name: Set up Node
uses: actions/setup-node@v1
with:
node-version: 20.9.0
node-version: 20.19.0
- name: Install Chrome
uses: browser-actions/setup-chrome@latest
with:
+7 -22
View File
@@ -1,27 +1,14 @@
name: Build Docker Images
on:
workflow_dispatch:
inputs:
version:
description: Version
type: string
required: true
image:
description: QEMU image
type: string
required: false
default: tonistiigi/binfmt:latest
os:
description: OS
type: string
required: false
default: ubuntu-24.04-arm
push:
tags:
- "*.*.*"
jobs:
build:
runs-on: ${{ inputs.os }}
timeout-minutes: 20
runs-on: ubuntu-24.04-arm
timeout-minutes: 30
steps:
- name: Checkout code
@@ -34,18 +21,16 @@ jobs:
uses: docker/metadata-action@v4
with:
images: docuseal/docuseal
tags: latest,${{ inputs.version }}
tags: type=semver,pattern={{version}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
image: ${{ inputs.image }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Create .version file
run: echo ${{ inputs.version }} > .version
run: echo ${{ github.ref_name }} > .version
- name: Login to Docker Hub
uses: docker/login-action@v3
+1
View File
@@ -37,3 +37,4 @@ yarn-debug.log*
/docuseal
/ee
dump.rdb
*.onnx
+31 -3
View File
@@ -1,4 +1,4 @@
require:
plugins:
- rubocop-performance
- rubocop-rails
- rubocop-rspec
@@ -10,7 +10,7 @@ AllCops:
- node_modules/**/*
- bin/*
- vendor/**/*
TargetRubyVersion: '3.3'
TargetRubyVersion: '4.0'
SuggestExtensions: false
Metrics/BlockLength:
@@ -45,6 +45,21 @@ Metrics/PerceivedComplexity:
Style/MultipleComparison:
Enabled: false
Style/NumericPredicate:
Enabled: false
Style/MinMaxComparison:
Enabled: false
Style/EmptyClassDefinition:
Enabled: false
Style/OneClassPerFile:
Enabled: false
Layout/MultilineMethodCallIndentation:
Enabled: false
Naming/PredicateMethod:
Enabled: false
@@ -78,7 +93,7 @@ RSpec/AnyInstance:
Enabled: false
Metrics/BlockNesting:
Max: 5
Max: 6
Rails/I18nLocaleTexts:
Enabled: false
@@ -94,3 +109,16 @@ Rails/ApplicationController:
Rails/Output:
Enabled: false
Rails/StrongParametersExpect:
Enabled: false
Rails/RedirectBackOrTo:
Enabled: false
Rails/UnknownEnv:
Environments:
- development
- test
- production
- local
+35 -31
View File
@@ -1,21 +1,19 @@
FROM ruby:3.4.2-alpine AS download
FROM ruby:4.0.1-alpine AS download
WORKDIR /fonts
RUN apk --no-cache add fontforge wget && \
RUN apk --no-cache add wget && \
wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Regular.ttf && \
wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Bold.ttf && \
wget https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && \
wget https://cdn.jsdelivr.net/gh/notofonts/notofonts.github.io/fonts/NotoSansSymbols2/hinted/ttf/NotoSansSymbols2-Regular.ttf && \
wget https://github.com/Maxattax97/gnu-freefont/raw/master/ttf/FreeSans.ttf && \
wget https://github.com/impallari/DancingScript/raw/master/OFL.txt && \
wget -O pdfium-linux.tgz "https://github.com/docusealco/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz" && \
wget https://raw.githubusercontent.com/impallari/DancingScript/master/OFL.txt && \
wget https://raw.githubusercontent.com/notofonts/noto-fonts/refs/heads/main/LICENSE && \
wget -O /model.onnx "https://github.com/docusealco/fields-detection/releases/download/2.0.0/model_704_int8.onnx" && \
wget -O pdfium-linux.tgz "https://github.com/bblanchon/pdfium-binaries/releases/latest/download/pdfium-linux-musl-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz" && \
mkdir -p /pdfium-linux && \
tar -xzf pdfium-linux.tgz -C /pdfium-linux
RUN fontforge -lang=py -c 'font1 = fontforge.open("FreeSans.ttf"); font2 = fontforge.open("NotoSansSymbols2-Regular.ttf"); font1.mergeFonts(font2); font1.generate("FreeSans.ttf")'
FROM ruby:3.4.2-alpine AS webpack
FROM ruby:4.0.1-alpine AS webpack
ENV RAILS_ENV=production
ENV NODE_ENV=production
@@ -35,22 +33,24 @@ COPY ./config/shakapacker.yml ./config/shakapacker.yml
COPY ./postcss.config.js ./postcss.config.js
COPY ./tailwind.config.js ./tailwind.config.js
COPY ./tailwind.form.config.js ./tailwind.form.config.js
COPY ./tailwind.dynamic.config.js ./tailwind.dynamic.config.js
COPY ./tailwind.application.config.js ./tailwind.application.config.js
COPY ./app/javascript ./app/javascript
COPY ./app/views ./app/views
RUN echo "gem 'shakapacker'" > Gemfile && ./bin/shakapacker
FROM ruby:3.4.2-alpine AS app
FROM ruby:4.0.1-alpine AS app
ENV RAILS_ENV=production
ENV BUNDLE_WITHOUT="development:test"
ENV LD_PRELOAD=/lib/libgcompat.so.0
ENV OPENSSL_CONF=/app/openssl_legacy.cnf
ENV OPENSSL_CONF=/etc/openssl_legacy.cnf
WORKDIR /app
RUN echo '@edge https://dl-cdn.alpinelinux.org/alpine/edge/community' >> /etc/apk/repositories && apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev@edge yaml-dev redis libheif@edge vips-heif@edge gcompat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
RUN apk add --no-cache libpq vips redis vips-heif fontconfig onnxruntime
RUN addgroup -g 2000 docuseal && adduser -u 2000 -G docuseal -s /bin/sh -D -h /home/docuseal docuseal
RUN echo $'.include = /etc/ssl/openssl.cnf\n\
\n\
@@ -62,34 +62,38 @@ legacy = legacy_sect\n\
activate = 1\n\
\n\
[legacy_sect]\n\
activate = 1' >> /app/openssl_legacy.cnf
activate = 1' >> /etc/openssl_legacy.cnf
COPY ./Gemfile ./Gemfile.lock ./
COPY --chown=docuseal:docuseal ./Gemfile ./Gemfile.lock ./
RUN apk add --no-cache build-base && bundle install && apk del --no-cache build-base && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf
RUN apk add --no-cache build-base git libpq-dev yaml-dev && bundle install && apk del --no-cache build-base git libpq-dev yaml-dev && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales,resources/unicode_data/properties}'] + Dir['/usr/local/bundle/gems/*/{test,tests,examples,sample,misc,doc,docs}'] + Dir['/usr/local/bundle/gems/*/ext/**/*.{c,h,o,S}']" | xargs rm -rf && ln -sf /usr/lib/libonnxruntime.so.1 $(ruby -e "print Dir[Gem::Specification.find_by_name('onnxruntime').gem_dir + '/vendor/*.so'].first")
COPY ./bin ./bin
COPY ./app ./app
COPY ./config ./config
COPY ./db/migrate ./db/migrate
COPY ./log ./log
COPY ./lib ./lib
COPY ./public ./public
COPY ./tmp ./tmp
COPY LICENSE README.md Rakefile config.ru .version ./
COPY .version ./public/version
COPY --chown=docuseal:docuseal ./bin ./bin
COPY --chown=docuseal:docuseal ./app ./app
COPY --chown=docuseal:docuseal ./config ./config
COPY --chown=docuseal:docuseal ./db/migrate ./db/migrate
COPY --chown=docuseal:docuseal ./log ./log
COPY --chown=docuseal:docuseal ./lib ./lib
COPY --chown=docuseal:docuseal ./public ./public
COPY --chown=docuseal:docuseal ./tmp ./tmp
COPY --chown=docuseal:docuseal LICENSE LICENSE_ADDITIONAL_TERMS README.md Rakefile config.ru .version ./
COPY --chown=docuseal:docuseal .version ./public/version
COPY --from=download /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts
COPY --from=download /fonts/FreeSans.ttf /usr/share/fonts/freefont
COPY --chown=docuseal:docuseal --from=download /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts/LICENSE /fonts/
COPY --from=download /pdfium-linux/lib/libpdfium.so /usr/lib/libpdfium.so
COPY --from=download /pdfium-linux/licenses/pdfium.txt /usr/lib/libpdfium-LICENSE.txt
COPY --from=webpack /app/public/packs ./public/packs
COPY --chown=docuseal:docuseal --from=download /model.onnx /app/tmp/model.onnx
COPY --chown=docuseal:docuseal --from=webpack /app/public/packs ./public/packs
RUN ln -s /fonts /app/public/fonts
RUN bundle exec bootsnap precompile --gemfile app/ lib/
RUN mkdir -p /app/public/fonts && ln -s /fonts/DancingScript-Regular.otf /app/public/fonts/ && \
mkdir -p /usr/share/fonts/noto && ln -s /fonts/GoNotoKurrent-Regular.ttf /usr/share/fonts/noto/ && ln -s /fonts/GoNotoKurrent-Bold.ttf /usr/share/fonts/noto/ && fc-cache -f && \
bundle exec bootsnap precompile -j 1 --gemfile app/ lib/ && \
chown -R docuseal:docuseal /app/tmp/cache
WORKDIR /data/docuseal
ENV HOME=/home/docuseal
ENV WORKDIR=/data/docuseal
ENV VIPS_MAX_COORD=17000
EXPOSE 3000
CMD ["/app/bin/bundle", "exec", "puma", "-C", "/app/config/puma.rb", "--dir", "/app"]
+12 -11
View File
@@ -2,16 +2,17 @@
source 'https://rubygems.org'
ruby '3.4.2'
ruby '4.0.1'
gem 'arabic-letter-connector', require: 'arabic-letter-connector/logic'
gem 'addressable'
gem 'arabic-letter-connector', require: false
gem 'aws-sdk-s3', require: false
gem 'aws-sdk-secretsmanager', require: false
gem 'azure-storage-blob', require: false
gem 'azure-blob', require: false
gem 'bootsnap', require: false
gem 'cancancan'
gem 'csv'
gem 'csv-safe'
gem 'csv', require: false
gem 'csv-safe', require: false
gem 'devise'
gem 'devise-two-factor'
gem 'dotenv', require: false
@@ -21,28 +22,28 @@ gem 'faraday-follow_redirects'
gem 'google-cloud-storage', require: false
gem 'hexapdf'
gem 'image_processing'
gem 'jwt'
gem 'jwt', require: false
gem 'lograge'
gem 'mysql2', require: false
gem 'numo-narray-alt', require: false
gem 'oj'
gem 'onnxruntime', require: false
gem 'pagy'
gem 'pg', require: false
gem 'premailer-rails'
gem 'pretender'
gem 'puma', require: false
gem 'rack'
gem 'rails'
gem 'rails_autolink'
gem 'rails-i18n'
gem 'rotp'
gem 'rouge', require: false
gem 'rqrcode'
gem 'rqrcode', require: false
gem 'ruby-vips'
gem 'rubyXL'
gem 'rubyXL', require: false
gem 'shakapacker'
gem 'sidekiq'
gem 'sqlite3', require: false
gem 'strip_attributes'
gem 'trilogy', require: false
gem 'turbo-rails'
gem 'twitter_cldr', require: false
gem 'tzinfo-data'
+308 -317
View File
@@ -1,29 +1,31 @@
GEM
remote: https://rubygems.org/
specs:
actioncable (8.0.2.1)
actionpack (= 8.0.2.1)
activesupport (= 8.0.2.1)
action_text-trix (2.1.18)
railties
actioncable (8.1.3)
actionpack (= 8.1.3)
activesupport (= 8.1.3)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (8.0.2.1)
actionpack (= 8.0.2.1)
activejob (= 8.0.2.1)
activerecord (= 8.0.2.1)
activestorage (= 8.0.2.1)
activesupport (= 8.0.2.1)
actionmailbox (8.1.3)
actionpack (= 8.1.3)
activejob (= 8.1.3)
activerecord (= 8.1.3)
activestorage (= 8.1.3)
activesupport (= 8.1.3)
mail (>= 2.8.0)
actionmailer (8.0.2.1)
actionpack (= 8.0.2.1)
actionview (= 8.0.2.1)
activejob (= 8.0.2.1)
activesupport (= 8.0.2.1)
actionmailer (8.1.3)
actionpack (= 8.1.3)
actionview (= 8.1.3)
activejob (= 8.1.3)
activesupport (= 8.1.3)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (8.0.2.1)
actionview (= 8.0.2.1)
activesupport (= 8.0.2.1)
actionpack (8.1.3)
actionview (= 8.1.3)
activesupport (= 8.1.3)
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
@@ -31,97 +33,97 @@ GEM
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (8.0.2.1)
actionpack (= 8.0.2.1)
activerecord (= 8.0.2.1)
activestorage (= 8.0.2.1)
activesupport (= 8.0.2.1)
actiontext (8.1.3)
action_text-trix (~> 2.1.15)
actionpack (= 8.1.3)
activerecord (= 8.1.3)
activestorage (= 8.1.3)
activesupport (= 8.1.3)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (8.0.2.1)
activesupport (= 8.0.2.1)
actionview (8.1.3)
activesupport (= 8.1.3)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (8.0.2.1)
activesupport (= 8.0.2.1)
activejob (8.1.3)
activesupport (= 8.1.3)
globalid (>= 0.3.6)
activemodel (8.0.2.1)
activesupport (= 8.0.2.1)
activerecord (8.0.2.1)
activemodel (= 8.0.2.1)
activesupport (= 8.0.2.1)
activemodel (8.1.3)
activesupport (= 8.1.3)
activerecord (8.1.3)
activemodel (= 8.1.3)
activesupport (= 8.1.3)
timeout (>= 0.4.0)
activestorage (8.0.2.1)
actionpack (= 8.0.2.1)
activejob (= 8.0.2.1)
activerecord (= 8.0.2.1)
activesupport (= 8.0.2.1)
activestorage (8.1.3)
actionpack (= 8.1.3)
activejob (= 8.1.3)
activerecord (= 8.1.3)
activesupport (= 8.1.3)
marcel (~> 1.0)
activesupport (8.0.2.1)
activesupport (8.1.3)
base64
benchmark (>= 0.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
json
logger (>= 1.4.2)
minitest (>= 5.1)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
annotaterb (4.14.0)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
annotaterb (4.22.0)
activerecord (>= 6.0.0)
activesupport (>= 6.0.0)
arabic-letter-connector (0.1.1)
ast (2.4.3)
aws-eventstream (1.3.0)
aws-partitions (1.1027.0)
aws-sdk-core (3.214.0)
aws-eventstream (1.4.0)
aws-partitions (1.1233.0)
aws-sdk-core (3.244.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
bigdecimal
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.96.0)
aws-sdk-core (~> 3, >= 3.210.0)
logger
aws-sdk-kms (1.123.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.176.1)
aws-sdk-core (~> 3, >= 3.210.0)
aws-sdk-s3 (1.218.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sdk-secretsmanager (1.110.0)
aws-sdk-core (~> 3, >= 3.210.0)
aws-sdk-secretsmanager (1.129.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.10.1)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
azure-storage-blob (2.0.3)
azure-storage-common (~> 2.0)
nokogiri (~> 1, >= 1.10.8)
azure-storage-common (2.0.4)
faraday (~> 1.0)
faraday_middleware (~> 1.0, >= 1.0.0.rc1)
net-http-persistent (~> 4.0)
nokogiri (~> 1, >= 1.10.8)
azure-blob (0.8.0)
cgi
rexml
base64 (0.3.0)
bcrypt (3.1.20)
benchmark (0.4.1)
better_html (2.1.1)
actionview (>= 6.0)
activesupport (>= 6.0)
bcrypt (3.1.22)
better_html (2.2.0)
actionview (>= 7.0)
activesupport (>= 7.0)
ast (~> 2.0)
erubi (~> 1.4)
parser (>= 2.4)
smart_properties
bigdecimal (3.2.2)
bigdecimal (4.1.0)
bindex (0.8.1)
bootsnap (1.18.4)
bootsnap (1.23.0)
msgpack (~> 1.2)
brakeman (7.0.0)
brakeman (8.0.4)
racc
builder (3.3.0)
bullet (8.0.0)
bullet (8.1.0)
activesupport (>= 3.0.0)
uniform_notifier (~> 1.11)
camertron-eprun (1.1.1)
@@ -135,51 +137,50 @@ GEM
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
cgi (0.5.1)
childprocess (5.1.0)
logger (~> 1.5)
chunky_png (1.4.0)
cldr-plurals-runtime-rb (1.1.0)
cmdparse (3.0.7)
coderay (1.1.3)
concurrent-ruby (1.3.5)
connection_pool (2.5.3)
crack (1.0.0)
concurrent-ruby (1.3.6)
connection_pool (3.0.2)
crack (1.0.1)
bigdecimal
rexml
crass (1.0.6)
css_parser (1.21.0)
addressable
csv (3.3.2)
csv (3.3.5)
csv-safe (3.3.1)
csv (~> 3.0)
cuprite (0.15.1)
cuprite (0.17)
capybara (~> 3.0)
ferrum (~> 0.15.0)
date (3.4.1)
debug (1.10.0)
ferrum (~> 0.17.0)
date (3.5.1)
debug (1.11.1)
irb (~> 1.10)
reline (>= 0.3.8)
declarative (0.0.20)
devise (4.9.4)
devise (5.0.3)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
railties (>= 4.1.0)
railties (>= 7.0)
responders
warden (~> 1.2.3)
devise-two-factor (6.1.0)
activesupport (>= 7.0, < 8.1)
devise (~> 4.0)
railties (>= 7.0, < 8.1)
devise-two-factor (6.4.0)
activesupport (>= 7.2, < 8.2)
devise (>= 4.0, < 6.0)
railties (>= 7.2, < 8.2)
rotp (~> 6.0)
diff-lcs (1.5.1)
digest-crc (0.6.5)
diff-lcs (1.6.2)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
docile (1.4.1)
dotenv (3.1.7)
dotenv (3.2.0)
drb (2.2.3)
email_typo (0.2.3)
erb (5.0.2)
erb_lint (0.7.0)
erb (6.0.2)
erb_lint (0.9.0)
activesupport
better_html (>= 2.0.1)
parser (>= 2.7.1.4)
@@ -187,117 +188,100 @@ GEM
rubocop (>= 1)
smart_properties
erubi (1.13.1)
factory_bot (6.5.0)
activesupport (>= 5.0.0)
factory_bot_rails (6.4.4)
factory_bot (6.5.6)
activesupport (>= 6.1.0)
factory_bot_rails (6.5.1)
factory_bot (~> 6.5)
railties (>= 5.0.0)
faker (3.5.1)
railties (>= 6.1.0)
faker (3.6.1)
i18n (>= 1.8.11, < 2)
faraday (1.10.4)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0)
faraday-multipart (~> 1.0)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.0)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.0)
faraday-excon (1.1.0)
faraday-follow_redirects (0.3.0)
faraday (2.14.1)
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-follow_redirects (0.5.0)
faraday (>= 1, < 3)
faraday-httpclient (1.0.1)
faraday-multipart (1.1.0)
multipart-post (~> 2.0)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
faraday_middleware (1.2.1)
faraday (~> 1.0)
ferrum (0.15)
faraday-net_http (3.4.2)
net-http (~> 0.5)
ferrum (0.17.2)
addressable (~> 2.5)
base64 (~> 0.2)
concurrent-ruby (~> 1.1)
webrick (~> 1.7)
websocket-driver (~> 0.7)
ffi (1.17.1)
ffi (1.17.1-aarch64-linux-gnu)
ffi (1.17.1-aarch64-linux-musl)
ffi (1.17.1-arm64-darwin)
ffi (1.17.1-x86_64-linux-gnu)
ffi (1.17.1-x86_64-linux-musl)
foreman (0.88.1)
ffi (1.17.4-aarch64-linux-gnu)
ffi (1.17.4-aarch64-linux-musl)
ffi (1.17.4-arm64-darwin)
ffi (1.17.4-x86_64-linux-gnu)
ffi (1.17.4-x86_64-linux-musl)
foreman (0.90.0)
thor (~> 1.4)
geom2d (0.4.1)
globalid (1.2.1)
globalid (1.3.0)
activesupport (>= 6.1)
google-apis-core (0.15.1)
addressable (~> 2.5, >= 2.5.1)
googleauth (~> 1.9)
httpclient (>= 2.8.3, < 3.a)
mini_mime (~> 1.0)
mutex_m
google-apis-core (1.0.2)
addressable (~> 2.8, >= 2.8.7)
faraday (~> 2.13)
faraday-follow_redirects (~> 0.3)
googleauth (~> 1.14)
mini_mime (~> 1.1)
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
google-apis-iamcredentials_v1 (0.22.0)
retriable (~> 3.1)
google-apis-iamcredentials_v1 (0.26.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.49.0)
google-apis-storage_v1 (0.61.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.7.1)
google-cloud-core (1.8.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (2.2.1)
google-cloud-env (2.3.1)
base64 (~> 0.2)
faraday (>= 1.0, < 3.a)
google-cloud-errors (1.4.0)
google-cloud-storage (1.54.0)
google-cloud-errors (1.6.0)
google-cloud-storage (1.59.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-core (~> 0.13)
google-apis-core (>= 0.18, < 2)
google-apis-iamcredentials_v1 (~> 0.18)
google-apis-storage_v1 (~> 0.38)
google-apis-storage_v1 (>= 0.42)
google-cloud-core (~> 1.6)
googleauth (~> 1.9)
mini_mime (~> 1.0)
google-logging-utils (0.1.0)
googleauth (1.12.2)
google-logging-utils (0.2.0)
googleauth (1.16.2)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.2)
google-logging-utils (~> 0.1)
jwt (>= 1.4, < 3.0)
jwt (>= 1.4, < 4.0)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
hashdiff (1.1.2)
hexapdf (1.4.0)
hashdiff (1.2.1)
hexapdf (1.6.0)
cmdparse (~> 3.0, >= 3.0.3)
geom2d (~> 0.4, >= 0.4.1)
openssl (>= 2.2.1)
strscan (>= 3.1.2)
htmlentities (4.3.4)
httpclient (2.8.3)
i18n (1.14.7)
i18n (1.14.8)
concurrent-ruby (~> 1.0)
image_processing (1.13.0)
mini_magick (>= 4.9.5, < 5)
image_processing (1.14.0)
mini_magick (>= 4.9.5, < 6)
ruby-vips (>= 2.0.17, < 3)
io-console (0.8.1)
irb (1.15.2)
io-console (0.8.2)
irb (1.17.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jmespath (1.6.2)
json (2.15.0)
jwt (2.9.3)
json (2.19.3)
jwt (3.1.2)
base64
language_server-protocol (3.17.0.5)
launchy (3.0.1)
launchy (3.1.1)
addressable (~> 2.8)
childprocess (~> 5.0)
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
letter_opener_web (3.0.0)
@@ -312,29 +296,29 @@ GEM
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.24.1)
loofah (2.25.1)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
mail (2.9.0)
logger
mini_mime (>= 0.1.1)
net-imap
net-pop
net-smtp
marcel (1.0.4)
matrix (0.4.2)
marcel (1.1.0)
matrix (0.4.3)
method_source (1.1.0)
mini_magick (4.13.2)
mini_magick (5.3.1)
logger
mini_mime (1.1.5)
mini_portile2 (2.8.9)
minitest (5.25.5)
msgpack (1.7.5)
multi_json (1.15.0)
multipart-post (2.4.1)
mutex_m (0.3.0)
mysql2 (0.5.6)
net-http-persistent (4.0.5)
connection_pool (~> 2.2)
net-imap (0.5.9)
minitest (6.0.3)
drb (~> 2.0)
prism (~> 1.5)
msgpack (1.8.0)
multi_json (1.19.1)
net-http (0.9.1)
uri (>= 0.11.1)
net-imap (0.6.3)
date
net-protocol
net-pop (0.1.2)
@@ -343,115 +327,117 @@ GEM
timeout
net-smtp (0.5.1)
net-protocol
nio4r (2.7.4)
nokogiri (1.18.9)
mini_portile2 (~> 2.8.2)
nio4r (2.7.5)
nokogiri (1.19.2-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.9-aarch64-linux-gnu)
nokogiri (1.19.2-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.18.9-aarch64-linux-musl)
nokogiri (1.19.2-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.9-arm64-darwin)
nokogiri (1.19.2-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.9-x86_64-linux-gnu)
nokogiri (1.19.2-x86_64-linux-musl)
racc (~> 1.4)
nokogiri (1.18.9-x86_64-linux-musl)
racc (~> 1.4)
oj (3.16.11)
numo-narray-alt (0.10.3)
oj (3.16.16)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
openssl (3.3.0)
onnxruntime (0.10.1-aarch64-linux)
ffi
onnxruntime (0.10.1-arm64-darwin)
ffi
onnxruntime (0.10.1-x86_64-linux)
ffi
openssl (4.0.1)
orm_adapter (0.5.0)
os (1.1.4)
ostruct (0.6.3)
package_json (0.1.0)
pagy (9.3.3)
parallel (1.27.0)
parser (3.3.9.0)
package_json (0.2.0)
pagy (43.4.4)
json
uri
yaml
parallel (1.28.0)
parser (3.3.11.1)
ast (~> 2.4.1)
racc
pg (1.5.9)
pp (0.6.2)
pg (1.6.3-aarch64-linux)
pg (1.6.3-aarch64-linux-musl)
pg (1.6.3-arm64-darwin)
pg (1.6.3-x86_64-linux)
pg (1.6.3-x86_64-linux-musl)
pp (0.6.3)
prettyprint
premailer (1.27.0)
addressable
css_parser (>= 1.19.0)
htmlentities (>= 4.0.0)
premailer-rails (1.12.0)
actionmailer (>= 3)
net-smtp
premailer (~> 1.7, >= 1.7.9)
pretender (0.5.0)
actionpack (>= 6.1)
pretender (1.0.0)
actionpack (>= 7.2)
prettyprint (0.2.0)
prism (1.5.1)
pry (0.15.0)
prism (1.9.0)
pry (0.16.0)
coderay (~> 1.1)
method_source (~> 1.0)
reline (>= 0.6.0)
pry-rails (0.3.11)
pry (>= 0.13.0)
psych (5.2.6)
psych (5.3.1)
date
stringio
public_suffix (6.0.1)
puma (6.5.0)
public_suffix (7.0.5)
puma (7.2.0)
nio4r (~> 2.0)
racc (1.8.1)
rack (3.2.3)
rack (3.2.6)
rack-proxy (0.7.7)
rack
rack-session (2.1.1)
rack-session (2.1.2)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
rackup (2.2.1)
rackup (2.3.1)
rack (>= 3)
rails (8.0.2.1)
actioncable (= 8.0.2.1)
actionmailbox (= 8.0.2.1)
actionmailer (= 8.0.2.1)
actionpack (= 8.0.2.1)
actiontext (= 8.0.2.1)
actionview (= 8.0.2.1)
activejob (= 8.0.2.1)
activemodel (= 8.0.2.1)
activerecord (= 8.0.2.1)
activestorage (= 8.0.2.1)
activesupport (= 8.0.2.1)
rails (8.1.3)
actioncable (= 8.1.3)
actionmailbox (= 8.1.3)
actionmailer (= 8.1.3)
actionpack (= 8.1.3)
actiontext (= 8.1.3)
actionview (= 8.1.3)
activejob (= 8.1.3)
activemodel (= 8.1.3)
activerecord (= 8.1.3)
activestorage (= 8.1.3)
activesupport (= 8.1.3)
bundler (>= 1.15.0)
railties (= 8.0.2.1)
railties (= 8.1.3)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.6.2)
loofah (~> 2.21)
rails-html-sanitizer (1.7.0)
loofah (~> 2.25)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails-i18n (8.0.1)
rails-i18n (8.1.0)
i18n (>= 0.7, < 2)
railties (>= 8.0.0, < 9)
rails_autolink (1.1.8)
actionview (> 3.1)
activesupport (> 3.1)
railties (> 3.1)
railties (8.0.2.1)
actionpack (= 8.0.2.1)
activesupport (= 8.0.2.1)
railties (8.1.3)
actionpack (= 8.1.3)
activesupport (= 8.1.3)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
tsort (>= 0.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.3.0)
rdoc (6.14.2)
rake (13.3.1)
rdoc (7.2.0)
erb
psych (>= 4.0.0)
redis-client (0.23.0)
tsort
redis-client (0.28.0)
connection_pool
regexp_parser (2.11.3)
reline (0.6.2)
reline (0.6.3)
io-console (~> 0.5)
representable (3.2.0)
declarative (< 0.1.0)
@@ -459,35 +445,35 @@ GEM
uber (< 0.2.0)
request_store (1.7.0)
rack (>= 1.4)
responders (3.1.1)
actionpack (>= 5.2)
railties (>= 5.2)
retriable (3.1.2)
responders (3.2.0)
actionpack (>= 7.0)
railties (>= 7.0)
retriable (3.4.1)
rexml (3.4.4)
rotp (6.3.0)
rouge (4.5.2)
rqrcode (2.2.0)
rouge (4.7.0)
rqrcode (3.2.0)
chunky_png (~> 1.0)
rqrcode_core (~> 1.0)
rqrcode_core (1.2.0)
rspec-core (3.13.2)
rqrcode_core (~> 2.0)
rqrcode_core (2.1.0)
rspec-core (3.13.6)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.3)
rspec-expectations (3.13.5)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-mocks (3.13.2)
rspec-mocks (3.13.8)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-rails (7.1.0)
actionpack (>= 7.0)
activesupport (>= 7.0)
railties (>= 7.0)
rspec-core (~> 3.13)
rspec-expectations (~> 3.13)
rspec-mocks (~> 3.13)
rspec-support (~> 3.13)
rspec-support (3.13.2)
rubocop (1.81.1)
rspec-rails (8.0.4)
actionpack (>= 7.2)
activesupport (>= 7.2)
railties (>= 7.2)
rspec-core (>= 3.13.0, < 5.0.0)
rspec-expectations (>= 3.13.0, < 5.0.0)
rspec-mocks (>= 3.13.0, < 5.0.0)
rspec-support (>= 3.13.0, < 5.0.0)
rspec-support (3.13.7)
rubocop (1.86.0)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@@ -495,122 +481,127 @@ GEM
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.47.1, < 2.0)
rubocop-ast (>= 1.49.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.47.1)
rubocop-ast (1.49.1)
parser (>= 3.3.7.2)
prism (~> 1.4)
rubocop-performance (1.23.0)
rubocop (>= 1.48.1, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rails (2.27.0)
prism (~> 1.7)
rubocop-performance (1.26.1)
lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.47.1, < 2.0)
rubocop-rails (2.34.3)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
rubocop (>= 1.52.0, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rspec (3.3.0)
rubocop (~> 1.61)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.44.0, < 2.0)
rubocop-rspec (3.9.0)
lint_roller (~> 1.1)
rubocop (~> 1.81)
ruby-progressbar (1.13.0)
ruby-vips (2.2.2)
ruby-vips (2.3.0)
ffi (~> 1.12)
logger
ruby2_keywords (0.0.5)
rubyXL (3.4.33)
rubyXL (3.4.35)
nokogiri (>= 1.10.8)
rubyzip (>= 1.3.0)
rubyzip (2.3.2)
rubyzip (>= 3.2.2)
rubyzip (3.2.2)
securerandom (0.4.1)
semantic_range (3.1.0)
shakapacker (8.0.2)
semantic_range (3.1.1)
shakapacker (9.7.0)
activesupport (>= 5.2)
package_json
rack-proxy (>= 0.6.1)
railties (>= 5.2)
semantic_range (>= 2.3.0)
sidekiq (7.3.7)
connection_pool (>= 2.3.0)
logger
rack (>= 2.2.4)
redis-client (>= 0.22.2)
signet (0.19.0)
sidekiq (8.1.2)
connection_pool (>= 3.0.0)
json (>= 2.16.0)
logger (>= 1.7.0)
rack (>= 3.2.0)
redis-client (>= 0.26.0)
signet (0.21.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0)
jwt (>= 1.5, < 4.0)
multi_json (~> 1.10)
simplecov (0.22.0)
docile (~> 1.1)
simplecov-html (~> 0.11)
simplecov_json_formatter (~> 0.1)
simplecov-html (0.13.1)
simplecov-html (0.13.2)
simplecov_json_formatter (0.1.4)
smart_properties (1.17.0)
sqlite3 (2.5.0)
mini_portile2 (~> 2.8.0)
sqlite3 (2.5.0-aarch64-linux-gnu)
sqlite3 (2.5.0-aarch64-linux-musl)
sqlite3 (2.5.0-arm64-darwin)
sqlite3 (2.5.0-x86_64-linux-gnu)
sqlite3 (2.5.0-x86_64-linux-musl)
stringio (3.1.7)
strip_attributes (1.14.1)
sqlite3 (2.9.2-aarch64-linux-gnu)
sqlite3 (2.9.2-aarch64-linux-musl)
sqlite3 (2.9.2-arm64-darwin)
sqlite3 (2.9.2-x86_64-linux-gnu)
sqlite3 (2.9.2-x86_64-linux-musl)
stringio (3.2.0)
strip_attributes (2.0.1)
activemodel (>= 3.0, < 9.0)
strscan (3.1.5)
thor (1.4.0)
timeout (0.4.3)
strscan (3.1.8)
thor (1.5.0)
timeout (0.6.1)
trailblazer-option (0.1.2)
turbo-rails (2.0.11)
actionpack (>= 6.0.0)
railties (>= 6.0.0)
twitter_cldr (6.12.1)
trilogy (2.12.2)
bigdecimal
tsort (0.2.0)
turbo-rails (2.0.23)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
twitter_cldr (6.14.0)
base64
camertron-eprun
cldr-plurals-runtime-rb (~> 1.1)
tzinfo
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
tzinfo-data (1.2024.2)
tzinfo-data (1.2026.1)
tzinfo (>= 1.0.0)
uber (0.1.0)
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.1.0)
uniform_notifier (1.16.0)
uri (1.0.3)
unicode-emoji (4.2.0)
uniform_notifier (1.18.0)
uri (1.1.1)
useragent (0.16.11)
warden (1.2.9)
rack (>= 2.0.9)
web-console (4.2.1)
actionview (>= 6.0.0)
activemodel (>= 6.0.0)
web-console (4.3.0)
actionview (>= 8.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
webmock (3.24.0)
railties (>= 8.0.0)
webmock (3.26.2)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
webrick (1.9.1)
webrick (1.9.2)
websocket-driver (0.8.0)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.7.3)
yaml (0.4.0)
zeitwerk (2.7.5)
PLATFORMS
aarch64-linux
aarch64-linux-musl
arm64-darwin
ruby
x86_64-linux
x86_64-linux-musl
DEPENDENCIES
addressable
annotaterb
arabic-letter-connector
aws-sdk-s3
aws-sdk-secretsmanager
azure-storage-blob
azure-blob
better_html
bootsnap
brakeman
@@ -637,18 +628,17 @@ DEPENDENCIES
jwt
letter_opener_web
lograge
mysql2
numo-narray-alt
oj
onnxruntime
pagy
pg
premailer-rails
pretender
pry-rails
puma
rack
rails
rails-i18n
rails_autolink
rotp
rouge
rqrcode
@@ -664,6 +654,7 @@ DEPENDENCIES
simplecov
sqlite3
strip_attributes
trilogy
turbo-rails
twitter_cldr
tzinfo-data
@@ -671,7 +662,7 @@ DEPENDENCIES
webmock
RUBY VERSION
ruby 3.4.2p28
ruby 4.0.1
BUNDLED WITH
2.5.3
4.0.3
+5
View File
@@ -0,0 +1,5 @@
Additional Terms
In accordance with Section 7(b) of the GNU Affero General Public License,
a covered work must retain the original DocuSeal attribution in interactive
user interfaces.
+4 -4
View File
@@ -1,7 +1,7 @@
<h1 align="center" style="border-bottom: none">
<div>
<a href="https://www.docuseal.com">
<img alt="DocuSeal" src="https://github.com/docusealco/docuseal/assets/5418788/c12cd051-81cd-4402-bc3a-92f2cfdc1b06" width="80" />
<img alt="DocuSeal" src="https://github.com/user-attachments/assets/38b45682-ffa4-4919-abde-d2d422325c44" width="80" />
<br>
</a>
DocuSeal
@@ -64,7 +64,7 @@ DocuSeal is an open source platform that provides secure and efficient digital d
|Heroku|Railway|
|:--:|:---:|
| [<img alt="Deploy on Heroku" src="https://www.herokucdn.com/deploy/button.svg" height="40">](https://heroku.com/deploy?template=https://github.com/docusealco/docuseal-heroku) | [<img alt="Deploy on Railway" src="https://railway.app/button.svg" height="40">](https://railway.app/template/IGoDnc?referralCode=ruU7JR)|
| [<img alt="Deploy on Heroku" src="https://www.herokucdn.com/deploy/button.svg" height="40">](https://heroku.com/deploy?template=https://github.com/docusealco/docuseal-heroku) | [<img alt="Deploy on Railway" src="https://railway.app/button.svg" height="40">](https://railway.com/deploy/IGoDnc?referralCode=ruU7JR)|
|**DigitalOcean**|**Render**|
| [<img alt="Deploy on DigitalOcean" src="https://www.deploytodo.com/do-btn-blue.svg" height="40">](https://cloud.digitalocean.com/apps/new?repo=https://github.com/docusealco/docuseal-digitalocean/tree/master&refcode=421d50f53990) | [<img alt="Deploy to Render" src="https://render.com/images/deploy-to-render-button.svg" height="40">](https://render.com/deploy?repo=https://github.com/docusealco/docuseal-render)
@@ -97,8 +97,8 @@ At DocuSeal we have expertise and technologies to make documents creation, filli
## License
Distributed under the AGPLv3 License. See [LICENSE](https://github.com/docusealco/docuseal/blob/master/LICENSE) for more information.
Unless otherwise noted, all files © 2023 DocuSeal LLC.
Distributed under the AGPLv3 License with Section 7(b) Additional Terms. See [LICENSE](https://github.com/docusealco/docuseal/blob/master/LICENSE) and [LICENSE_ADDITIONAL_TERMS](https://github.com/docusealco/docuseal/blob/master/LICENSE_ADDITIONAL_TERMS) for more information.
Unless otherwise noted, all files © 2023-2026 DocuSeal LLC.
## Tools
@@ -11,6 +11,7 @@ class AccountConfigsController < ApplicationController
AccountConfig::FORCE_MFA,
AccountConfig::ALLOW_TO_RESUBMIT,
AccountConfig::ALLOW_TO_DECLINE_KEY,
AccountConfig::ALLOW_TO_DELEGATE_KEY,
AccountConfig::FORM_PREFILL_SIGNATURE_KEY,
AccountConfig::ESIGNING_PREFERENCE_KEY,
AccountConfig::FORM_WITH_CONFETTI_KEY,
@@ -23,7 +24,8 @@ class AccountConfigsController < ApplicationController
AccountConfig::WITH_SIGNATURE_ID,
AccountConfig::COMBINE_PDF_RESULT_KEY,
AccountConfig::REQUIRE_SIGNING_REASON_KEY,
AccountConfig::DOCUMENT_FILENAME_FORMAT_KEY
AccountConfig::DOCUMENT_FILENAME_FORMAT_KEY,
AccountConfig::ENABLE_MCP_KEY
].freeze
InvalidKey = Class.new(StandardError)
@@ -35,7 +37,7 @@ class AccountConfigsController < ApplicationController
end
def destroy
raise InvalidKey unless ALLOWED_KEYS.include?(@account_config.key)
raise InvalidKey unless allowed_keys.include?(@account_config.key)
@account_config.destroy!
@@ -44,8 +46,12 @@ class AccountConfigsController < ApplicationController
private
def allowed_keys
ALLOWED_KEYS
end
def load_account_config
raise InvalidKey unless ALLOWED_KEYS.include?(account_config_params[:key])
raise InvalidKey unless allowed_keys.include?(account_config_params[:key])
@account_config =
AccountConfig.find_or_initialize_by(account: current_account, key: account_config_params[:key])
@@ -0,0 +1,33 @@
# frozen_string_literal: true
class AccountCustomFieldsController < ApplicationController
before_action :load_account_config, only: :create
def create
authorize!(:create, Template)
@account_config.update!(account_config_params)
render json: @account_config.value
end
private
def load_account_config
@account_config =
AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::TEMPLATE_CUSTOM_FIELDS_KEY)
end
def account_config_params
params.permit(
value: [[:uuid, :name, :type,
:required, :readonly, :default_value,
:title, :description,
{ preferences: {},
default_value: [],
options: [%i[value uuid]],
validation: %i[message pattern min max step],
areas: [%i[x y w h cell_w option_uuid]] }]]
)
end
end
+5 -1
View File
@@ -46,12 +46,16 @@ class AccountsController < ApplicationController
def destroy
authorize!(:manage, current_account)
true_user.skip_reconfirmation!
true_user.update!(locked_at: Time.current, email: true_user.email.sub('@', '+removed@'))
true_user.account.update!(archived_at: Time.current)
# rubocop:disable Layout/LineLength
render turbo_stream: turbo_stream.replace(
:account_delete_button,
html: helpers.tag.p(I18n.t('your_account_removal_request_will_be_processed_within_2_months_please_contact_us_if_you_want_to_keep_your_account'))
html: helpers.tag.p(I18n.t('your_account_will_be_permanently_deleted_within_2_months_please_contact_us_if_you_want_to_keep_your_account')) +
helpers.tag.br +
helpers.tag.p(I18n.t('your_email_address_has_been_released_immediately_you_can_now_be_added_to_your_company_team_without_waiting_for_the_deletion_period_to_end'))
)
# rubocop:enable Layout/LineLength
end
+1 -1
View File
@@ -3,7 +3,7 @@
module Api
class ApiBaseController < ActionController::API
include ActiveStorage::SetCurrent
include Pagy::Backend
include Pagy::Method
DEFAULT_LIMIT = 10
MAX_LIMIT = 100
@@ -10,6 +10,12 @@ module Api
def create
submitter = Submitter.find_by!(slug: params[:submitter_slug])
unless can_upload?(submitter)
Rollbar.error("Can't upload: #{submitter.id}") if defined?(Rollbar)
return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content
end
if params[:type].in?(%w[initials signature])
image = Vips::Image.new_from_file(params[:file].path)
@@ -34,6 +40,18 @@ module Api
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
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)
@@ -5,34 +5,17 @@ module Api
load_and_authorize_resource :submission
def index
is_merge = params[:merge] == 'true' &&
(@submission.schema_documents || @submission.template.schema_documents).size > 1
documents =
if @submission.submitters.all?(&:completed_at?)
last_submitter = @submission.submitters.max_by(&:completed_at)
if last_submitter.documents_attachments.blank?
last_submitter.documents_attachments = Submissions::EnsureResultGenerated.call(last_submitter)
end
last_submitter.documents_attachments
build_completed_documents(@submission, merge: is_merge)
else
values_hash = Submissions::GeneratePreviewAttachments.build_values_hash(@submission)
if @submission.preview_documents.present? &&
@submission.preview_documents.all? { |s| s.metadata['values_hash'] == values_hash }
@submission.preview_documents
else
ApplicationRecord.no_touching do
@submission.preview_documents.each(&:destroy)
end
Submissions::GeneratePreviewAttachments.call(@submission, values_hash:)
end
build_preview_documents(@submission, merge: is_merge)
end
ActiveRecord::Associations::Preloader.new(
records: documents,
associations: [:blob]
).call
ActiveRecord::Associations::Preloader.new(records: documents, associations: [:blob]).call
expires_at = Accounts.link_expires_at(current_account)
@@ -43,5 +26,50 @@ module Api
end
}
end
private
def build_completed_documents(submission, merge: false)
last_submitter = submission.submitters.max_by(&:completed_at)
if merge
if submission.merged_document_attachment.blank?
submission.merged_document_attachment =
Submissions::GenerateCombinedAttachment.call(last_submitter, with_audit: false)
end
[submission.merged_document_attachment]
else
if last_submitter.documents_attachments.blank?
last_submitter.documents_attachments = Submissions::EnsureResultGenerated.call(last_submitter)
end
last_submitter.documents_attachments
end
end
def build_preview_documents(submission, merge: false)
values_hash = Submissions::GeneratePreviewAttachments.build_values_hash(submission)
if merge
if submission.preview_merged_document_attachment.present? &&
submission.preview_merged_document_attachment.metadata['values_hash'] == values_hash
[submission.preview_merged_document_attachment]
else
ApplicationRecord.no_touching { submission.preview_merged_document_attachment&.destroy }
Submissions::GeneratePreviewAttachments.call(submission, values_hash:, merge: true)
end
elsif submission.preview_documents.present? &&
submission.preview_documents.all? { |s| s.metadata['values_hash'] == values_hash }
submission.preview_documents
else
ApplicationRecord.no_touching do
submission.preview_documents.each(&:destroy)
end
Submissions::GeneratePreviewAttachments.call(submission, values_hash:)
end
end
end
end
@@ -156,7 +156,7 @@ module Api
params:)
else
submissions_attrs, attachments =
Submissions::NormalizeParamUtils.normalize_submissions_params!(submissions_params, template)
Submissions::NormalizeParamUtils.normalize_submissions_params!(submissions_params, template, purpose: :api)
submissions = Submissions.create_from_submitters(
template:,
@@ -172,7 +172,10 @@ module Api
Submissions::NormalizeParamUtils.save_default_value_attachments!(attachments, submitters)
submitters.each do |submitter|
SubmissionEvents.create_with_tracking_data(submitter, 'api_complete_form', request) if submitter.completed_at?
if submitter.completed_at?
Submitters::SubmitValues.maybe_invite_via_field(submitter, request)
SubmissionEvents.create_with_tracking_data(submitter, 'api_complete_form', request)
end
end
submissions
@@ -182,13 +185,13 @@ module Api
def submissions_params
permitted_attrs = [
:send_email, :send_sms, :bcc_completed, :completed_redirect_url, :reply_to, :go_to_last,
:require_phone_2fa, :expire_at, :name,
:require_phone_2fa, :require_email_2fa, :expire_at, :name,
{
variables: {},
message: %i[subject body],
submitters: [[:send_email, :send_sms, :completed_redirect_url, :uuid, :name, :email, :role,
:completed, :phone, :application_key, :external_id, :reply_to, :go_to_last,
:require_phone_2fa, :order,
:require_phone_2fa, :require_email_2fa, :order, :index, :invite_by,
{ metadata: {}, values: {}, roles: [], readonly_fields: [], message: %i[subject body],
fields: [:name, :uuid, :default_value, :value, :title, :description,
:readonly, :required, :validation_pattern, :invalid_message,
+13 -3
View File
@@ -34,18 +34,24 @@ module Api
render json: Submitters::SerializeForApi.call(@submitter, with_template: true, with_events: true, params:)
end
# rubocop:disable Metrics/MethodLength
def update
if @submitter.completed_at?
return render json: { error: 'Submitter has already completed the submission.' }, status: :unprocessable_content
end
if @submitter.declined_at?
return render json: { error: 'Submitter has already declined the submission.' }, status: :unprocessable_content
end
submission = @submitter.submission
role = submission.template_submitters.find { |e| e['uuid'] == @submitter.uuid }['name']
normalized_params, new_attachments = Submissions::NormalizeParamUtils.normalize_submitter_params!(
submitter_params.merge(role:),
@submitter.template || Template.new(submitters: submission.template_submitters, account: @submitter.account),
for_submitter: @submitter
for_submitter: @submitter,
purpose: :api
)
Submissions::CreateFromSubmitters.maybe_set_template_fields(submission, [normalized_params],
@@ -60,7 +66,10 @@ module Api
@submitter.submission.save!
SubmissionEvents.create_with_tracking_data(@submitter, 'api_complete_form', request) if @submitter.completed_at?
if @submitter.completed_at?
Submitters::SubmitValues.maybe_invite_via_field(@submitter, request)
SubmissionEvents.create_with_tracking_data(@submitter, 'api_complete_form', request)
end
end
if @submitter.completed_at?
@@ -78,13 +87,14 @@ module Api
render json: { error: e.message }, status: :unprocessable_content
end
# rubocop:enable Metrics/MethodLength
def submitter_params
submitter_params = params.key?(:submitter) ? params.require(:submitter) : params
submitter_params.permit(
:send_email, :send_sms, :reply_to, :completed_redirect_url, :uuid, :name, :email, :role,
:completed, :phone, :application_key, :external_id, :go_to_last, :require_phone_2fa,
:completed, :phone, :application_key, :external_id, :go_to_last, :require_phone_2fa, :require_email_2fa,
{ metadata: {}, values: {}, readonly_fields: [], message: %i[subject body],
fields: [[:name, :uuid, :default_value, :value, :required,
:readonly, :validation_pattern, :invalid_message,
@@ -9,7 +9,7 @@ module Api
ActiveRecord::Associations::Preloader.new(
records: [@template],
associations: [schema_documents: :preview_images_attachments]
associations: [{ schema_documents: :preview_images_attachments }]
).call
cloned_template = Templates::Clone.call(
+6 -2
View File
@@ -69,6 +69,7 @@ module Api
SearchEntries.enqueue_reindex(@template)
WebhookUrls.enqueue_events(@template, 'template.updated')
WebhookUrls.enqueue_events(@template, 'template.archived') if archived == true
render json: @template.as_json(only: %i[id updated_at])
end
@@ -78,6 +79,8 @@ module Api
@template.destroy!
else
@template.update!(archived_at: Time.current)
WebhookUrls.enqueue_events(@template, 'template.archived')
end
render json: @template.as_json(only: %i[id archived_at])
@@ -107,7 +110,8 @@ module Api
:external_id,
:shared_link,
{
submitters: [%i[name uuid is_requester invite_by_uuid optional_invite_by_uuid linked_to_uuid email order]],
submitters: [%i[name uuid is_requester invite_by_uuid invite_via_field_uuid
optional_invite_by_uuid linked_to_uuid email order]],
fields: [[:uuid, :submitter_uuid, :name, :type,
:required, :readonly, :default_value,
:title, :description, :prefillable,
@@ -116,7 +120,7 @@ module Api
conditions: [%i[field_uuid value action operation]],
options: [%i[value uuid]],
validation: %i[message pattern min max step],
areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]]
areas: [%i[uuid x y w h cell_w attachment_uuid option_uuid page]] }]]
}
]
+8 -7
View File
@@ -4,7 +4,7 @@ class ApplicationController < ActionController::Base
BROWSER_LOCALE_REGEXP = /\A\w{2}(?:-\w{2})?/
include ActiveStorage::SetCurrent
include Pagy::Backend
include Pagy::Method
check_authorization unless: :devise_controller?
@@ -17,12 +17,13 @@ class ApplicationController < ActionController::Base
helper_method :button_title,
:current_account,
:true_ability,
:form_link_host,
:svg_icon
impersonates :user, with: ->(uuid) { User.find_by(uuid:) }
rescue_from Pagy::OverflowError do
rescue_from Pagy::RangeError do
redirect_to request.path
end
@@ -41,10 +42,6 @@ class ApplicationController < ActionController::Base
end
def default_url_options
if request.domain == 'docuseal.com'
return { host: 'docuseal.com', protocol: ENV['FORCE_SSL'].present? ? 'https' : 'http' }
end
Docuseal.default_url_options
end
@@ -59,7 +56,7 @@ class ApplicationController < ActionController::Base
def pagy_auto(collection, **keyword_args)
if current_ability.can?(:manage, :countless)
pagy_countless(collection, **keyword_args)
pagy(:countless, collection, **keyword_args)
else
pagy(collection, **keyword_args)
end
@@ -102,6 +99,10 @@ class ApplicationController < ActionController::Base
current_user&.account
end
def true_ability
@true_ability ||= Ability.new(true_user)
end
def maybe_redirect_to_setup
redirect_to setup_index_path unless User.exists?
end
@@ -17,8 +17,10 @@ class ConsoleRedirectController < ApplicationController
scope: :console,
exp: 1.minute.from_now.to_i)
path = Addressable::URI.parse(params[:redir]).path if params[:redir].to_s.starts_with?(Docuseal::CONSOLE_URL)
redir_uri = Addressable::URI.parse(params[:redir])
path = redir_uri.path if params[:redir].to_s.starts_with?(Docuseal::CONSOLE_URL)
redirect_to("#{Docuseal::CONSOLE_URL}#{path}?#{{ auth: }.to_query}", allow_other_host: true)
redirect_to "#{Docuseal::CONSOLE_URL}#{path}?#{{ **redir_uri&.query_values, 'auth' => auth }.to_query}",
allow_other_host: true
end
end
+2 -2
View File
@@ -40,7 +40,7 @@ class ErrorsController < ActionController::Base
render json: { status: error_status_code, error: }.compact, status: error_status_code
end
f.html { render error_status_code.to_s, status: error_status_code }
f.any { render error_status_code.to_s, status: error_status_code }
end
end
@@ -51,7 +51,7 @@ class ErrorsController < ActionController::Base
headers['Access-Control-Allow-Methods'] = 'POST, GET, PUT, PATCH, DELETE, OPTIONS'
headers['Access-Control-Allow-Headers'] = '*'
headers['Access-Control-Max-Age'] = '1728000'
headers['Access-Control-Allow-Credentials'] = true
headers['Access-Control-Allow-Credentials'] = 'true'
end
def error_status_code
+1 -1
View File
@@ -3,7 +3,7 @@
class EsignSettingsController < ApplicationController
DEFAULT_CERT_NAME = 'DocuSeal Self-Host Autogenerated'
CertFormRecord = Struct.new(:name, :file, :password, keyword_init: true) do
CertFormRecord = Struct.new(:name, :file, :password) do
include ActiveModel::Validations
def to_key
@@ -1,4 +1,11 @@
# frozen_string_literal: true
class InvitationsController < Devise::PasswordsController
def update
super do |resource|
resource.confirmed_at ||= Time.current if resource.errors.empty?
PasswordsController::Current.user = resource
end
end
end
+58
View File
@@ -0,0 +1,58 @@
# frozen_string_literal: true
class McpController < ActionController::API
before_action :authenticate_user!
before_action :verify_mcp_enabled!
before_action do
authorize!(:manage, :mcp)
end
def call
return head :ok if request.raw_post.blank?
body = JSON.parse(request.raw_post)
result = Mcp::HandleRequest.call(body, current_user, current_ability)
if result
render json: result
else
head :accepted
end
rescue CanCan::AccessDenied
render json: { jsonrpc: '2.0', id: nil, error: { code: -32_603, message: 'Forbidden' } }, status: :forbidden
rescue JSON::ParserError
render json: { jsonrpc: '2.0', id: nil, error: { code: -32_700, message: 'Parse error' } }, status: :bad_request
end
private
def authenticate_user!
render json: { error: 'Not authenticated' }, status: :unauthorized unless current_user
end
def verify_mcp_enabled!
return if Docuseal.multitenant?
return if AccountConfig.exists?(account_id: current_user.account_id,
key: AccountConfig::ENABLE_MCP_KEY,
value: true)
render json: { error: 'MCP is disabled' }, status: :forbidden
end
def current_user
@current_user ||= user_from_api_key
end
def user_from_api_key
token = request.headers['Authorization'].to_s[/\ABearer\s+(.+)\z/, 1]
return if token.blank?
sha256 = Digest::SHA256.hexdigest(token)
User.joins(:mcp_tokens).active.find_by(mcp_tokens: { sha256:, archived_at: nil })
end
end
@@ -0,0 +1,37 @@
# frozen_string_literal: true
class McpSettingsController < ApplicationController
load_and_authorize_resource :mcp_token, parent: false
before_action do
authorize!(:manage, :mcp)
end
def index
@mcp_tokens = @mcp_tokens.active.order(id: :desc)
end
def create
@mcp_token = current_user.mcp_tokens.new(mcp_token_params)
if @mcp_token.save
@mcp_tokens = [@mcp_token]
render :index, status: :created
else
render turbo_stream: turbo_stream.replace(:modal, template: 'mcp_settings/new'), status: :unprocessable_content
end
end
def destroy
@mcp_token.update!(archived_at: Time.current)
redirect_back fallback_location: settings_mcp_index_path, notice: I18n.t('mcp_token_has_been_removed')
end
private
def mcp_token_params
params.require(:mcp_token).permit(:name)
end
end
+2
View File
@@ -5,6 +5,8 @@ class PasswordsController < Devise::PasswordsController
skip_before_action :require_no_authentication, only: %i[edit update]
# rubocop:enable Rails/LexicallyScopedActionFilter
around_action :with_browser_locale
class Current < ActiveSupport::CurrentAttributes
attribute :user
end
@@ -4,6 +4,7 @@ class PersonalizationSettingsController < ApplicationController
ALLOWED_KEYS = [
AccountConfig::FORM_COMPLETED_BUTTON_KEY,
AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY,
AccountConfig::SUBMITTER_INVITATION_REMINDER_EMAIL_KEY,
AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY,
AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY,
AccountConfig::FORM_COMPLETED_MESSAGE_KEY,
@@ -6,9 +6,17 @@ class PreviewDocumentPageController < ActionController::API
FORMAT = Templates::ProcessDocument::FORMAT
def show
attachment_uuid = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid], purpose: :attachment)
result_data =
ApplicationRecord.signed_id_verifier.verified(params[:signed_key], purpose: :attachment)
attachment = ActiveStorage::Attachment.find_by(uuid: attachment_uuid) if attachment_uuid
attachment =
if result_data.is_a?(Array) && result_data.compact_blank.size == 2
attachment_id, attachment_uuid = result_data
ActiveStorage::Attachment.find_by(id: attachment_id, uuid: attachment_uuid)
elsif result_data
ActiveStorage::Attachment.find_by(uuid: result_data)
end
return head :not_found unless attachment
+8 -1
View File
@@ -9,7 +9,14 @@ class ProfileController < ApplicationController
def update_contact
if current_user.update(contact_params)
redirect_to settings_profile_index_path, notice: I18n.t('contact_information_has_been_update')
if current_user.try(:pending_reconfirmation?) && current_user.previous_changes.key?(:unconfirmed_email)
SendConfirmationInstructionsJob.perform_async('user_id' => current_user.id)
redirect_to settings_profile_index_path,
notice: I18n.t('a_confirmation_email_has_been_sent_to_the_new_email_address')
else
redirect_to settings_profile_index_path, notice: I18n.t('contact_information_has_been_update')
end
else
render :index, status: :unprocessable_content
end
@@ -29,7 +29,7 @@ class SendSubmissionEmailController < ApplicationController
RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes)
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! unless already_sent?(@submitter)
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! if can_send?(@submitter)
respond_to do |f|
f.html { render :success }
@@ -39,8 +39,11 @@ class SendSubmissionEmailController < ApplicationController
private
def already_sent?(submitter)
EmailEvent.exists?(tag: :submitter_documents_copy, email: submitter.email, emailable: submitter,
event_type: :send, created_at: SEND_DURATION.ago..Time.current)
def can_send?(submitter)
return false if submitter.account.archived_at?
return false if EmailEvent.exists?(tag: :submitter_documents_copy, email: submitter.email, emailable: submitter,
event_type: :send, created_at: SEND_DURATION.ago..Time.current)
true
end
end
+6 -2
View File
@@ -16,7 +16,9 @@ class StartFormController < ApplicationController
COOKIES_DEFAULTS = { httponly: true, secure: Rails.env.production? }.freeze
def show
raise ActionController::RoutingError, I18n.t('not_found') if @template.preferences['require_phone_2fa']
if @template.preferences['require_phone_2fa'] || @template.preferences['require_email_2fa']
raise ActionController::RoutingError, I18n.t('not_found')
end
if @template.shared_link?
@submitter = @template.submissions.new(account_id: @template.account_id)
@@ -81,7 +83,7 @@ class StartFormController < ApplicationController
@submitter = Submitter.where(submission: @template.submissions)
.where.not(completed_at: nil)
.find_by!(required_params)
.find_by!(required_params.except('name'))
end
private
@@ -170,6 +172,8 @@ class StartFormController < ApplicationController
submitters: [submitter],
source: :link)
Submissions::CreateFromSubmitters.maybe_set_dynamic_documents(submitter.submission)
submitter.account_id = submitter.submission.account_id
submitter
@@ -10,11 +10,13 @@ class SubmissionEventsController < ApplicationController
'api_complete_form' => 'check',
'send_reminder_email' => 'mail_forward',
'send_2fa_sms' => '2fa',
'send_2fa_email' => '2fa',
'send_sms' => 'send',
'phone_verified' => 'phone_check',
'email_verified' => 'email_check',
'click_sms' => 'hand_click',
'decline_form' => 'x',
'delegate_form' => 'user_share',
'start_verification' => 'player_play',
'complete_verification' => 'check',
'invite_party' => 'user_plus'
+25 -12
View File
@@ -12,13 +12,21 @@ class SubmissionsController < ApplicationController
authorize!(:create, Submission)
end
FIELD_ICONS = {
'text' => 'text_size', 'signature' => 'writing_sign', 'date' => 'calendar_event',
'number' => 'square_number_1', 'image' => 'photo', 'initials' => 'letter_case_upper',
'file' => 'paperclip', 'select' => 'select', 'checkbox' => 'checkbox', 'radio' => 'circle_dot',
'stamp' => 'rubber_stamp', 'cells' => 'columns_3', 'multiple' => 'checks', 'phone' => 'phone_check',
'payment' => 'credit_card', 'verification' => 'id', 'kba' => 'user_scan'
}.freeze
def show
@submission = Submissions.preload_with_pages(@submission)
unless @submission.submitters.all?(&:completed_at?)
ActiveRecord::Associations::Preloader.new(
records: [@submission],
associations: [submitters: :start_form_submission_events]
associations: [{ submitters: :start_form_submission_events }]
).call
end
@@ -43,17 +51,7 @@ class SubmissionsController < ApplicationController
emails: params[:emails],
params: params.merge('send_completed_email' => true))
else
submissions_attrs = submissions_params[:submission].to_h.values
submissions_attrs, =
Submissions::NormalizeParamUtils.normalize_submissions_params!(submissions_attrs, @template)
Submissions.create_from_submitters(template: @template,
user: current_user,
source: :invite,
submitters_order: params[:preserve_order] == '1' ? 'preserved' : 'random',
submissions_attrs:,
params: params.merge('send_completed_email' => true))
create_submissions(@template, submissions_params, params)
end
WebhookUrls.enqueue_events(submissions, 'submission.created')
@@ -88,6 +86,21 @@ class SubmissionsController < ApplicationController
private
def create_submissions(template, submissions_params, params)
submissions_attrs = submissions_params[:submission].to_h.values
submissions_attrs, _, new_fields =
Submissions::NormalizeParamUtils.normalize_submissions_params!(submissions_attrs, template, add_fields: true)
Submissions.create_from_submitters(template: template,
user: current_user,
source: :invite,
submitters_order: params[:preserve_order] == '1' ? 'preserved' : 'random',
submissions_attrs:,
new_fields:,
params: params.merge('send_completed_email' => true))
end
def save_template_message(template, params)
template.preferences['request_email_subject'] = params[:subject] if params[:subject].present?
template.preferences['request_email_body'] = params[:body] if params[:body].present?
@@ -9,7 +9,7 @@ class SubmissionsDebugController < ApplicationController
def index
@submitter = Submitter.preload({ attachments_attachments: :blob },
submission: { template: { documents_attachments: :blob } })
.find_by(slug: params[:submitter_slug])
.find_by(slug: params[:submit_form_slug])
respond_to do |f|
f.html do
@@ -1,40 +1,17 @@
# frozen_string_literal: true
class SubmissionsDownloadController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
TTL = 40.minutes
FILES_TTL = 5.minutes
load_and_authorize_resource :submission
def index
@submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present?
signature_valid =
if @submitter&.slug == params[:submitter_slug]
true
else
@submitter = nil
end
@submitter ||= Submitter.find_by!(slug: params[:submitter_slug])
Submissions::EnsureResultGenerated.call(@submitter)
last_submitter = @submitter.submission.submitters.where.not(completed_at: nil).order(:completed_at).last
last_submitter = @submission.submitters.where.not(completed_at: nil).order(:completed_at).last
return head :not_found unless last_submitter
Submissions::EnsureResultGenerated.call(last_submitter)
if last_submitter.completed_at < TTL.ago && !signature_valid && !current_user_submitter?(last_submitter)
Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar)
return head :not_found
end
if params[:combined] == 'true'
url = build_combined_url(@submitter)
url = Submitters.build_combined_url(last_submitter)
if url
render json: [url]
@@ -42,43 +19,7 @@ class SubmissionsDownloadController < ApplicationController
head :not_found
end
else
render json: build_urls(last_submitter)
render json: Submitters.build_document_urls(last_submitter)
end
end
private
def current_user_submitter?(submitter)
current_user && current_user.account.submitters.exists?(id: submitter.id)
end
def build_urls(submitter)
filename_format = AccountConfig.find_or_initialize_by(account_id: submitter.account_id,
key: AccountConfig::DOCUMENT_FILENAME_FORMAT_KEY)&.value
Submitters.select_attachments_for_download(submitter).map do |attachment|
ActiveStorage::Blob.proxy_url(
attachment.blob,
expires_at: FILES_TTL.from_now.to_i,
filename: Submitters.build_document_filename(submitter, attachment.blob, filename_format)
)
end
end
def build_combined_url(submitter)
return if submitter.submission.submitters.exists?(completed_at: nil)
return if submitter.submission.submitters.order(:completed_at).last != submitter
attachment = submitter.submission.combined_document_attachment
attachment ||= Submissions::EnsureCombinedGenerated.call(submitter)
filename_format = AccountConfig.find_or_initialize_by(account_id: submitter.account_id,
key: AccountConfig::DOCUMENT_FILENAME_FORMAT_KEY)&.value
ActiveStorage::Blob.proxy_url(
attachment.blob,
expires_at: FILES_TTL.from_now.to_i,
filename: Submitters.build_document_filename(submitter, attachment.blob, filename_format)
)
end
end
@@ -5,10 +5,11 @@ class SubmissionsExportController < ApplicationController
load_and_authorize_resource :submission, through: :template, parent: false, only: :index
def index
submissions = @submissions.active
.preload(submitters: { documents_attachments: :blob,
attachments_attachments: :blob })
.order(id: :asc)
submissions = params[:archived] == 'true' ? @submissions.archived : @submissions.active
submissions = submissions.preload(submitters: { documents_attachments: :blob,
attachments_attachments: :blob })
.order(id: :asc)
submissions = Submissions.search(current_user, submissions, params[:q], search_values: true)
submissions = Submissions::Filter.call(submissions, current_user, params)
@@ -10,13 +10,15 @@ class SubmissionsPreviewController < ApplicationController
TTL = 40.minutes
def show
submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present?
@sig_submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present?
signature_valid =
if submitter && submitter.submission.slug == params[:slug]
@submission = submitter.submission
if @sig_submitter && @sig_submitter.submission.slug == params[:slug]
@submission = @sig_submitter.submission
true
else
@sig_submitter = nil
end
@submission ||= Submission.find_by!(slug: params[:slug])
@@ -36,11 +38,14 @@ class SubmissionsPreviewController < ApplicationController
@submission = Submissions.preload_with_pages(@submission)
render 'submissions/show', layout: 'plain'
render 'submissions/show', layout: 'plain', locals: { is_preview: true }
end
def completed
@submission = Submission.find_by!(slug: params[:submissions_preview_slug])
raise ActionController::RoutingError, I18n.t('not_found') if @submission.account.archived_at?
@template = @submission.template
render :completed, layout: 'form'
@@ -50,8 +55,11 @@ class SubmissionsPreviewController < ApplicationController
def use_signature?(submission)
return false if current_user && can?(:read, submission)
return true if submission.submitters.any? { |e| e.preferences['require_phone_2fa'] }
return true if submission.submitters.any? do |e|
e.preferences['require_phone_2fa'] || e.preferences['require_email_2fa']
end
return true if submission.template&.preferences&.dig('require_phone_2fa')
return true if submission.template&.preferences&.dig('require_email_2fa')
!submission_valid_ttl?(submission)
end
@@ -0,0 +1,64 @@
# frozen_string_literal: true
class SubmissionsPreviewDownloadController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
TTL = 40.minutes
def index
@submission = Submission.find_by!(slug: params[:submission_slug] || params[:submissions_preview_slug])
last_submitter = @submission.submitters.where.not(completed_at: nil).order(:completed_at).last
return head :not_found unless last_submitter
Submissions::EnsureResultGenerated.call(last_submitter)
unless current_user_submission?(@submission)
if use_2fa?(@submission)
Rollbar.info("2FA download error: #{last_submitter.id}") if defined?(Rollbar)
return head :not_found
end
if last_submitter.completed_at < TTL.ago
Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar)
return head :not_found
end
end
if params[:combined] == 'true'
respond_with_combined(last_submitter)
else
render json: Submitters.build_document_urls(last_submitter)
end
end
private
def respond_with_combined(submitter)
url = Submitters.build_combined_url(submitter)
if url
render json: [url]
else
head :not_found
end
end
def current_user_submission?(submission)
current_user && current_ability.can?(:read, submission)
end
def use_2fa?(submission)
return true if submission.submitters.any? do |e|
e.preferences['require_phone_2fa'] || e.preferences['require_email_2fa']
end
return true if submission.template&.preferences&.dig('require_phone_2fa')
return true if submission.template&.preferences&.dig('require_email_2fa')
false
end
end
@@ -0,0 +1,70 @@
# frozen_string_literal: true
class SubmitFormCompletedDownloadController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
TTL = 40.minutes
FILES_TTL = 5.minutes
def index
@submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present?
signature_valid =
if @submitter&.slug == submitter_slug
true
else
@submitter = nil
end
@submitter ||= Submitter.find_by!(slug: submitter_slug)
Submissions::EnsureResultGenerated.call(@submitter)
last_submitter = @submitter.submission.submitters.where.not(completed_at: nil).order(:completed_at).last
return head :not_found unless last_submitter
Submissions::EnsureResultGenerated.call(last_submitter)
if !signature_valid && !current_user_submitter?(last_submitter)
unless Submitters::AuthorizedForForm.call(@submitter, current_user, request)
Rollbar.info("2FA download error: #{last_submitter.id}") if defined?(Rollbar)
return head :not_found
end
if last_submitter.completed_at < TTL.ago
Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar)
return head :not_found
end
end
if params[:combined] == 'true'
respond_with_combined(last_submitter)
else
render json: Submitters.build_document_urls(last_submitter)
end
end
private
def submitter_slug
params[:submit_form_slug] || params[:submitter_slug] || params[:submitter_id]
end
def respond_with_combined(submitter)
url = Submitters.build_combined_url(submitter)
if url
render json: [url]
else
head :not_found
end
end
def current_user_submitter?(submitter)
current_user && current_ability.can?(:read, submitter)
end
end
+34 -8
View File
@@ -3,19 +3,21 @@
class SubmitFormController < ApplicationController
layout 'form'
around_action :with_browser_locale, only: %i[show completed success]
around_action :with_browser_locale, only: %i[show completed success delegated]
skip_before_action :authenticate_user!
skip_authorization_check
before_action :load_submitter, only: %i[show update completed]
before_action :maybe_redirect_delegated, only: %i[show completed]
before_action :maybe_render_locked_page, only: :show
before_action :maybe_require_link_2fa, only: %i[show update]
before_action :maybe_require_link_2fa, only: %i[show]
CONFIG_KEYS = [].freeze
def show
submission = @submitter.submission
return render :email_2fa unless Submitters::AuthorizedForForm.pass_email_2fa?(@submitter, request)
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
@form_configs = Submitters::FormConfigs.call(@submitter, CONFIG_KEYS)
@@ -47,6 +49,11 @@ class SubmitFormController < ApplicationController
end
def update
unless Submitters::AuthorizedForForm.call(@submitter, current_user, request)
return render json: { error: I18n.t('verification_required_refresh_the_page_and_pass_2fa') },
status: :unprocessable_content
end
if @submitter.completed_at?
return render json: { error: I18n.t('form_has_been_completed_already') }, status: :unprocessable_content
end
@@ -75,17 +82,26 @@ class SubmitFormController < ApplicationController
render json: { error: e.message }, status: :unprocessable_content
end
def completed; end
def completed
raise ActionController::RoutingError, I18n.t('not_found') if @submitter.account.archived_at?
return if Submitters::AuthorizedForForm.call(@submitter, current_user, request)
redirect_to submit_form_path(params[:submit_form_slug])
end
def success; end
def delegated
submitter_version = SubmitterVersion.find_by!(slug: params[:slug] || params[:submit_form_slug])
@submitter = submitter_version.submitter
end
private
def maybe_require_link_2fa
return if @submitter.submission.source != 'link'
return unless @submitter.submission.template&.preferences&.dig('shared_link_2fa') == true
return if cookies.encrypted[:email_2fa_slug] == @submitter.slug
return if @submitter.email == current_user&.email && current_user&.account_id == @submitter.account_id
return if Submitters::AuthorizedForForm.pass_link_2fa?(@submitter, current_user, request)
redirect_to start_form_path(@submitter.submission.template.slug)
end
@@ -99,8 +115,18 @@ class SubmitFormController < ApplicationController
render :declined if @submitter.declined_at?
end
def maybe_redirect_delegated
return if @submitter
submitter_version = SubmitterVersion.find_by!(slug: params[:slug] || params[:submit_form_slug])
submitter_version.submitter.submission_events.find_by!(event_type: :delegate_form)
redirect_to submit_form_delegated_path(submitter_version.slug)
end
def load_submitter
@submitter = Submitter.find_by!(slug: params[:slug] || params[:submit_form_slug])
@submitter = Submitter.find_by(slug: params[:slug] || params[:submit_form_slug])
end
def build_attachments_index(submission)
@@ -4,29 +4,38 @@ class SubmitFormDeclineController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
def create
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
before_action :load_submitter
return redirect_to submit_form_path(submitter.slug) if submitter.declined_at? ||
submitter.completed_at? ||
submitter.submission.archived_at? ||
submitter.submission.expired? ||
submitter.submission.template&.archived_at?
def create
return redirect_to submit_form_path(@submitter.slug) if @submitter.declined_at? ||
@submitter.completed_at? ||
@submitter.submission.archived_at? ||
@submitter.submission.expired? ||
@submitter.submission.template&.archived_at? ||
!Submitters::AuthorizedForForm.call(@submitter,
current_user,
request)
ApplicationRecord.transaction do
submitter.update!(declined_at: Time.current)
@submitter.update!(declined_at: Time.current)
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
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
SubmitterMailer.declined_email(submitter, user).deliver_later!
SubmitterMailer.declined_email(@submitter, user).deliver_later!
end
WebhookUrls.enqueue_events(submitter, 'form.declined')
WebhookUrls.enqueue_events(@submitter, 'form.declined')
redirect_to submit_form_path(submitter.slug)
redirect_to submit_form_path(@submitter.slug)
end
private
def load_submitter
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
end
end
@@ -0,0 +1,47 @@
# frozen_string_literal: true
class SubmitFormDelegateController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
before_action :load_submitter
def create
return redirect_to submit_form_path(@submitter.slug) if @submitter.declined_at? ||
@submitter.completed_at? ||
@submitter.submission.archived_at? ||
@submitter.submission.expired? ||
@submitter.submission.template&.archived_at? ||
!Submitters::AuthorizedForForm.call(@submitter,
current_user,
request)
@submitter.account.account_configs.find_by!(key: AccountConfig::ALLOW_TO_DELEGATE_KEY, value: true)
email = Submissions.normalize_email(params[:email])
return redirect_to submit_form_path(@submitter.slug) if email.blank?
old_slug = @submitter.slug
ApplicationRecord.transaction do
@submitter.submitter_versions.create!(slug: old_slug, email: @submitter.email,
name: @submitter.name, phone: @submitter.phone)
SubmissionEvents.create_with_tracking_data(@submitter, 'delegate_form', request,
{ old_email: @submitter.email, email: })
@submitter.update!(email:, phone: nil, name: nil, slug: SecureRandom.base58(14))
end
SendSubmitterInvitationEmailJob.perform_async('submitter_id' => @submitter.id)
redirect_to submit_form_delegated_path(old_slug)
end
private
def load_submitter
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
end
end
@@ -9,7 +9,7 @@ class SubmitFormDownloadController < ApplicationController
def index
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
return redirect_to submitter_download_index_path(@submitter.slug) if @submitter.completed_at?
return redirect_to submit_form_documents_path(@submitter.slug) if @submitter.completed_at?
return head :unprocessable_content if @submitter.declined_at? ||
@submitter.submission.archived_at? ||
@@ -17,7 +17,8 @@ class SubmitFormDownloadController < ApplicationController
@submitter.submission.template&.archived_at? ||
AccountConfig.exists?(account_id: @submitter.account_id,
key: AccountConfig::ALLOW_TO_PARTIAL_DOWNLOAD_KEY,
value: false)
value: false) ||
!Submitters::AuthorizedForForm.call(@submitter, current_user, request)
last_completed_submitter = @submitter.submission.submitters
.where.not(id: @submitter.id)
@@ -32,7 +33,7 @@ class SubmitFormDownloadController < ApplicationController
end
urls = attachments.map do |attachment|
ActiveStorage::Blob.proxy_url(attachment.blob, expires_at: FILES_TTL.from_now.to_i)
ActiveStorage::Blob.proxy_path(attachment.blob, expires_at: FILES_TTL.from_now.to_i)
end
render json: urls
@@ -12,7 +12,8 @@ class SubmitFormDrawSignatureController < ApplicationController
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
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)
return redirect_to submit_form_path(@submitter.slug)
end
@@ -0,0 +1,52 @@
# frozen_string_literal: true
class SubmitFormEmail2fasController < ApplicationController
around_action :with_browser_locale
skip_before_action :authenticate_user!
skip_authorization_check
before_action :load_submitter
COOKIES_TTL = 12.hours
COOKIES_DEFAULTS = { httponly: true, secure: Rails.env.production? }.freeze
def create
RateLimit.call("verify-2fa-code-#{@submitter.id}", limit: 2, ttl: 45.seconds, enabled: true)
value = [@submitter.email.downcase.strip, @submitter.slug].join(':')
if EmailVerificationCodes.verify(params[:one_time_code].to_s.gsub(/\D/, ''), value)
SubmissionEvents.create_with_tracking_data(@submitter, 'email_verified', request, { email: @submitter.email })
cookies.encrypted[:email_2fa_slug] =
{ value: @submitter.slug, expires: COOKIES_TTL.from_now, **COOKIES_DEFAULTS }
redirect_to submit_form_path(@submitter.slug)
else
redirect_to submit_form_path(@submitter.slug, status: :error), alert: I18n.t(:invalid_code)
end
rescue RateLimit::LimitApproached
redirect_to submit_form_path(@submitter.slug, status: :error), alert: I18n.t(:too_many_attempts)
end
def update
if @submitter.submission_events.where(event_type: 'send_2fa_email').exists?(created_at: 15.seconds.ago..)
return redirect_to submit_form_path(@submitter.slug, status: :error), alert: I18n.t(:rate_limit_exceeded)
end
RateLimit.call("send-email-code-#{@submitter.id}", limit: 2, ttl: 45.seconds, enabled: true)
SendSubmitterVerificationEmailJob.perform_async('submitter_id' => @submitter.id, 'locale' => I18n.locale.to_s)
redir_params = params[:resend] ? { alert: I18n.t(:code_has_been_resent) } : {}
redirect_to submit_form_path(@submitter.slug, status: :sent), **redir_params
rescue RateLimit::LimitApproached
redirect_to submit_form_path(@submitter.slug, status: :error), alert: I18n.t(:too_many_attempts)
end
def load_submitter
@submitter = Submitter.find_by!(slug: params[:submitter_slug])
end
end
@@ -19,7 +19,9 @@ class SubmitFormInviteController < ApplicationController
next unless attrs
next if attrs[:email].blank?
submitter.submission.submitters.create!(**attrs, account_id: submitter.account_id)
email = Submissions.normalize_email(attrs[:email])
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 })
end
@@ -45,7 +47,8 @@ class SubmitFormInviteController < ApplicationController
!submitter.completed_at? &&
!submitter.submission.archived_at? &&
!submitter.submission.expired? &&
!submitter.submission.template&.archived_at?
!submitter.submission.template&.archived_at? &&
Submitters::AuthorizedForForm.call(submitter, current_user, request)
end
def filter_invite_submitters(submitter, key = 'invite_by_uuid')
@@ -7,10 +7,12 @@ class SubmitFormValuesController < ApplicationController
def index
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
return render json: {} if submitter.completed_at? || submitter.declined_at?
return render json: {} if submitter.submission.template&.archived_at? ||
return render json: {} if submitter.completed_at? ||
submitter.declined_at? ||
submitter.submission.template&.archived_at? ||
submitter.submission.archived_at? ||
submitter.submission.expired?
submitter.submission.expired? ||
!Submitters::AuthorizedForForm.call(submitter, current_user, request)
value = submitter.values[params['field_uuid']]
attachment = submitter.attachments.where(created_at: params[:after]..).find_by(uuid: value) if value.present?
@@ -7,25 +7,34 @@ class SubmittersAutocompleteController < ApplicationController
LIMIT = 100
def index
submitters = search_submitters(@submitters)
field = SELECT_COLUMNS.find { |c| c == params[:field] }
submitters = search_submitters(@submitters, field)
arel_columns = SELECT_COLUMNS.map { |col| Submitter.arel_table[col] }
values = submitters.limit(LIMIT).group(arel_columns).pluck(arel_columns)
values =
if field
max_ids = submitters.group(field).limit(LIMIT).select(Submitter.arel_table[:id].maximum)
submitters.where(id: max_ids).order(id: :desc).pluck(arel_columns)
else
submitters.limit(LIMIT).group(arel_columns).pluck(arel_columns)
end
attrs = values.map { |row| SELECT_COLUMNS.zip(row).to_h }
attrs = attrs.uniq { |e| e[params[:field]] } if params[:field].present?
render json: attrs
end
private
def search_submitters(submitters)
if SELECT_COLUMNS.include?(params[:field])
def search_submitters(submitters, field)
if field
if Docuseal.fulltext_search?
Submitters.fulltext_search_field(current_user, submitters, params[:q], params[:field])
Submitters.fulltext_search_field(current_user, submitters, params[:q], field)
else
column = Submitter.arel_table[params[:field].to_sym]
column = Submitter.arel_table[field.to_sym]
term = "#{params[:q].downcase}%"
@@ -0,0 +1,11 @@
# frozen_string_literal: true
class SubmittersDownloadController < ApplicationController
load_and_authorize_resource :submitter
def index
Submissions::EnsureResultGenerated.call(@submitter)
render json: Submitters.build_document_urls(@submitter)
end
end
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class SubmittersSendEmailController < ApplicationController
load_and_authorize_resource :submitter, id_param: :submitter_slug, find_by: :slug
load_and_authorize_resource :submitter
def create
if Docuseal.multitenant? && SubmissionEvent.exists?(submitter: @submitter,
@@ -3,6 +3,12 @@
class TemplateDocumentsController < ApplicationController
load_and_authorize_resource :template
FILES_TTL = 5.minutes
def index
render json: @template.schema_documents.map { |d| ActiveStorage::Blob.proxy_path(d.blob, expires_at: FILES_TTL.from_now.to_i) }
end
def create
if params[:blobs].blank? && params[:files].blank?
return render json: { error: I18n.t('file_is_missing') }, status: :unprocessable_content
@@ -10,7 +16,7 @@ class TemplateDocumentsController < ApplicationController
old_fields_hash = @template.fields.hash
documents = Templates::CreateAttachments.call(@template, params, extract_fields: true)
documents, = Templates::CreateAttachments.call(@template, params, extract_fields: true)
schema = documents.map do |doc|
{ attachment_uuid: doc.uuid, name: doc.filename.base }
@@ -21,7 +27,7 @@ class TemplateDocumentsController < ApplicationController
fields: old_fields_hash == @template.fields.hash ? nil : @template.fields,
submitters: old_fields_hash == @template.fields.hash ? nil : @template.submitters,
documents: documents.as_json(
methods: %i[metadata signed_uuid],
methods: %i[metadata signed_key],
include: {
preview_images: { methods: %i[url metadata filename] }
}
@@ -8,7 +8,7 @@ class TemplatesCloneAndReplaceController < ApplicationController
ActiveRecord::Associations::Preloader.new(
records: [@template],
associations: [schema_documents: :preview_images_attachments]
associations: [{ schema_documents: :preview_images_attachments }]
).call
cloned_template = Templates::Clone.call(@template, author: current_user)
@@ -0,0 +1,56 @@
# frozen_string_literal: true
class TemplatesCloneController < ApplicationController
load_and_authorize_resource :template, instance_name: :base_template
def new
authorize!(:create, Template)
@template = Template.new(name: "#{@base_template.name} (#{I18n.t('clone')})")
end
def create
ActiveRecord::Associations::Preloader.new(
records: [@base_template],
associations: [{ schema_documents: :preview_images_attachments }]
).call
@template = Templates::Clone.call(@base_template, author: current_user,
name: params.dig(:template, :name),
folder_name: params[:folder_name])
authorize!(:create, @template)
if params[:account_id].present? && true_ability.can?(:manage, Account.find(params[:account_id]))
@template.account_id = params[:account_id]
@template.author = true_user if true_user.account_id == @template.account_id
@template.folder = @template.account.default_template_folder if @template.account_id != current_account.id
else
@template.account = current_account
end
Templates.maybe_assign_access(@template)
if @template.save
Templates::CloneAttachments.call(template: @template, original_template: @base_template)
SearchEntries.enqueue_reindex(@template)
WebhookUrls.enqueue_events(@template, 'template.created')
maybe_redirect_to_template(@template)
else
render turbo_stream: turbo_stream.replace(:modal, partial: 'templates_clone/form'), status: :unprocessable_content
end
end
private
def maybe_redirect_to_template(template)
if template.account == current_account
redirect_to(edit_template_path(template))
else
redirect_back(fallback_location: root_path, notice: I18n.t('template_has_been_cloned'))
end
end
end
+14 -52
View File
@@ -3,8 +3,6 @@
class TemplatesController < ApplicationController
load_and_authorize_resource :template
before_action :load_base_template, only: %i[new create]
def show
submissions = @template.submissions.accessible_by(current_ability)
submissions = submissions.active if @template.archived_at.blank?
@@ -26,20 +24,18 @@ class TemplatesController < ApplicationController
redirect_to root_path
end
def new
@template.name = "#{@base_template.name} (#{I18n.t('clone')})" if @base_template
end
def new; end
def edit
ActiveRecord::Associations::Preloader.new(
records: [@template],
associations: [schema_documents: [:blob, { preview_images_attachments: :blob }]]
associations: [{ schema_documents: [:blob, { preview_images_attachments: :blob }] }]
).call
@template_data =
@template.as_json.merge(
documents: @template.schema_documents.as_json(
methods: %i[metadata signed_uuid],
methods: %i[metadata signed_key],
include: { preview_images: { methods: %i[url metadata filename] } }
)
).to_json
@@ -48,37 +44,18 @@ class TemplatesController < ApplicationController
end
def create
if @base_template
ActiveRecord::Associations::Preloader.new(
records: [@base_template],
associations: [schema_documents: :preview_images_attachments]
).call
@template = Templates::Clone.call(@base_template, author: current_user,
name: params.dig(:template, :name),
folder_name: params[:folder_name])
else
@template.author = current_user
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
end
if params[:account_id].present? && authorized_clone_account_id?(params[:account_id])
@template.account_id = params[:account_id]
@template.folder = @template.account.default_template_folder if @template.account_id != current_account.id
else
@template.account = current_account
end
@template.author = current_user
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
@template.account = current_account
Templates.maybe_assign_access(@template)
if @template.save
Templates::CloneAttachments.call(template: @template, original_template: @base_template) if @base_template
SearchEntries.enqueue_reindex(@template)
WebhookUrls.enqueue_events(@template, 'template.created')
maybe_redirect_to_template(@template)
redirect_to(edit_template_path(@template))
else
render turbo_stream: turbo_stream.replace(:modal, template: 'templates/new'), status: :unprocessable_content
end
@@ -107,6 +84,8 @@ class TemplatesController < ApplicationController
else
@template.update!(archived_at: Time.current)
WebhookUrls.enqueue_events(@template, 'template.archived')
I18n.t('template_has_been_archived')
end
@@ -118,9 +97,11 @@ class TemplatesController < ApplicationController
def template_params
params.require(:template).permit(
:name,
{ schema: [[:attachment_uuid, :google_drive_file_id, :name,
{ schema: [[:attachment_uuid, :google_drive_file_id, :name, :dynamic,
{ conditions: [%i[field_uuid value action operation]] }]],
submitters: [%i[name uuid is_requester linked_to_uuid invite_by_uuid optional_invite_by_uuid email order]],
submitters: [%i[name uuid is_requester linked_to_uuid invite_via_field_uuid
invite_by_uuid optional_invite_by_uuid email order]],
variables_schema: {},
fields: [[:uuid, :submitter_uuid, :name, :type,
:required, :readonly, :default_value,
:title, :description, :prefillable,
@@ -129,26 +110,7 @@ class TemplatesController < ApplicationController
conditions: [%i[field_uuid value action operation]],
options: [%i[value uuid]],
validation: %i[message pattern min max step],
areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]] }
areas: [%i[uuid x y w h cell_w attachment_uuid option_uuid page]] }]] }
)
end
def authorized_clone_account_id?(account_id)
true_user.account_id.to_s == account_id.to_s ||
true_user.account.linked_accounts.accessible_by(current_ability).exists?(id: account_id)
end
def maybe_redirect_to_template(template)
if template.account == current_account
redirect_to(edit_template_path(@template))
else
redirect_back(fallback_location: root_path, notice: I18n.t('template_has_been_cloned'))
end
end
def load_base_template
return if params[:base_template_id].blank?
@base_template = Template.accessible_by(current_ability).find_by(id: params[:base_template_id])
end
end
+26 -11
View File
@@ -6,29 +6,37 @@ class TemplatesDebugController < ApplicationController
DEBUG_FILE = ''
def show
attachment = @template.documents.first
schema_uuids = @template.schema.index_by { |e| e['attachment_uuid'] }
attachment = @template.documents.find { |a| schema_uuids[a.uuid] }
data = attachment.download
pdf = HexaPDF::Document.new(io: StringIO.new(data))
if attachment
data = attachment.download
fields = Templates::FindAcroFields.call(pdf, attachment, data)
unless attachment.image?
pdf = HexaPDF::Document.new(io: StringIO.new(data))
attachment.metadata['pdf'] ||= {}
attachment.metadata['pdf']['fields'] = fields
fields = Templates::FindAcroFields.call(pdf, attachment, data)
end
@template.update!(fields: Templates::ProcessDocument.normalize_attachment_fields(@template, [attachment]))
# fields, = Templates::DetectFields.call(StringIO.new(data), attachment:) if fields.blank?
attachment.metadata['pdf'] ||= {}
attachment.metadata['pdf']['fields'] = fields
@template.update!(fields: Templates::ProcessDocument.normalize_attachment_fields(@template, [attachment]))
end
debug_file if DEBUG_FILE.present?
ActiveRecord::Associations::Preloader.new(
records: [@template],
associations: [schema_documents: { preview_images_attachments: :blob }]
associations: [{ schema_documents: { preview_images_attachments: :blob } }]
).call
@template_data =
@template.as_json.merge(
documents: @template.schema_documents.as_json(
methods: %i[metadata signed_uuid],
methods: %i[metadata signed_key],
include: { preview_images: { methods: %i[url metadata filename] } }
)
).to_json
@@ -52,9 +60,16 @@ class TemplatesDebugController < ApplicationController
params = { files: [file] }
documents = Templates::CreateAttachments.call(@template, params)
documents, dynamic_documents = Templates::CreateAttachments.call(@template, params,
dynamic: DEBUG_FILE.ends_with?('.docx'))
schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } }
schema = documents.map do |doc|
{
attachment_uuid: doc.uuid,
name: doc.filename.base,
dynamic: dynamic_documents.find { |e| e.uuid == doc.uuid }.present?
}
end
@template.update!(schema:)
end
@@ -0,0 +1,30 @@
# frozen_string_literal: true
class TemplatesDetectFieldsController < ApplicationController
include ActionController::Live
load_and_authorize_resource :template
def create
response.headers['Content-Type'] = 'text/event-stream'
sse = SSE.new(response.stream)
documents = @template.schema_documents.preload(:blob)
documents = documents.where(uuid: params[:attachment_uuid]) if params[:attachment_uuid].present?
page_number = params[:page].presence&.to_i
documents.each do |document|
io = StringIO.new(document.download)
Templates::DetectFields.call(io, attachment: document, page_number:) do |(attachment_uuid, page, fields)|
sse.write({ attachment_uuid:, page:, fields: })
end
end
sse.write({ completed: true })
ensure
response.stream.close
end
end
@@ -3,6 +3,15 @@
class TemplatesPreferencesController < ApplicationController
load_and_authorize_resource :template
RESETTABLE_PREFERENCE_KEYS = {
AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY => %w[request_email_subject request_email_body submitters],
AccountConfig::SUBMITTER_INVITATION_REMINDER_EMAIL_KEY => %w[invitation_reminder_email_subject
invitation_reminder_email_body],
AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY => %w[documents_copy_email_subject documents_copy_email_body],
AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY => %w[completed_notification_email_subject
completed_notification_email_body]
}.freeze
def show; end
def create
@@ -15,23 +24,42 @@ class TemplatesPreferencesController < ApplicationController
head :ok
end
def destroy
authorize!(:update, @template)
config_key = params[:config_key]
preferences_to_delete = RESETTABLE_PREFERENCE_KEYS[config_key]
return head :ok if preferences_to_delete.blank?
preferences_to_delete.each do |key|
@template.preferences.delete(key)
end
@template.save!
render turbo_stream: turbo_stream.replace("#{config_key}_form",
partial: "templates_preferences/#{config_key}_form"),
status: :ok
end
private
def template_params
params.require(:template).permit(
preferences: %i[bcc_completed request_email_subject request_email_body
invitation_reminder_email_subject invitation_reminder_email_body
documents_copy_email_subject documents_copy_email_body
documents_copy_email_enabled documents_copy_email_attach_audit
documents_copy_email_attach_documents documents_copy_email_reply_to
completed_notification_email_attach_documents
completed_redirect_url validate_unique_submitters
require_all_submitters submitters_order require_phone_2fa
default_expire_at_duration shared_link_2fa
default_expire_at request_email_enabled
require_all_submitters submitters_order require_phone_2fa require_email_2fa
default_expire_at_duration shared_link_2fa default_expire_at request_email_enabled
completed_notification_email_subject completed_notification_email_body
completed_notification_email_enabled completed_notification_email_attach_audit] +
[completed_message: %i[title body],
submitters: [%i[uuid request_email_subject request_email_body]], link_form_fields: []]
[{ completed_message: %i[title body],
submitters: [%i[uuid request_email_subject request_email_body]], link_form_fields: [] }]
).tap do |attrs|
attrs[:preferences].delete(:submitters) if params[:request_email_per_submitter] != '1'
@@ -6,13 +6,13 @@ class TemplatesPreviewController < ApplicationController
def show
ActiveRecord::Associations::Preloader.new(
records: [@template],
associations: [schema_documents: { preview_images_attachments: :blob }]
associations: [{ schema_documents: { preview_images_attachments: :blob } }]
).call
@template_data =
@template.as_json.merge(
documents: @template.schema_documents.as_json(
methods: %i[metadata signed_uuid],
methods: %i[metadata signed_key],
include: { preview_images: { methods: %i[url metadata filename] } }
)
).to_json
@@ -22,7 +22,7 @@ class TemplatesRecipientsController < ApplicationController
def submitters_params
permit_params = { submitters: [%i[name uuid is_requester optional_invite_by_uuid
invite_by_uuid linked_to_uuid email option order]] }
invite_by_uuid invite_via_field_uuid linked_to_uuid email option order]] }
params.require(:template).permit(permit_params).fetch(:submitters, {}).values.filter_map do |s|
next if s[:uuid].blank?
@@ -36,6 +36,7 @@ class TemplatesRecipientsController < ApplicationController
s[:order] = s[:order].to_i if s[:order].present?
s.delete(:invite_by_uuid) if s[:invite_by_uuid].blank?
s.delete(:optional_invite_by_uuid) if s[:optional_invite_by_uuid].blank?
s.delete(:invite_via_field_uuid) if s[:invite_via_field_uuid].blank?
normalize_option_value(s)
end
@@ -53,6 +54,7 @@ class TemplatesRecipientsController < ApplicationController
attrs.delete(:email)
attrs.delete(:linked_to_uuid)
attrs.delete(:invite_by_uuid)
attrs.delete(:invite_via_field_uuid)
attrs.delete(:optional_invite_by_uuid)
when /\Alinked_to_(.*)\z/
attrs[:linked_to_uuid] = ::Regexp.last_match(-1)
@@ -6,6 +6,8 @@ class TemplatesRestoreController < ApplicationController
def create
@template.update!(archived_at: nil)
WebhookUrls.enqueue_events(@template, 'template.updated')
redirect_to template_path(@template), notice: I18n.t('template_has_been_unarchived')
end
end
@@ -12,7 +12,7 @@ class TemplatesUploadsController < ApplicationController
save_template!(@template, url_params)
documents = Templates::CreateAttachments.call(@template, url_params || params, extract_fields: true)
documents, = Templates::CreateAttachments.call(@template, url_params || params, extract_fields: true)
schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } }
if @template.fields.blank?
@@ -56,7 +56,7 @@ class TemplatesUploadsController < ApplicationController
def create_file_params_from_url
tempfile = Tempfile.new
tempfile.binmode
tempfile.write(DownloadUtils.call(params[:url]).body)
tempfile.write(DownloadUtils.call(params[:url], validate: true).body)
tempfile.rewind
filename = URI.decode_www_form_component(params[:filename]) if params[:filename].present?
@@ -32,7 +32,7 @@ class TimestampServerController < ApplicationController
uri = Addressable::URI.parse(url)
conn = Faraday.new(uri.origin) do |c|
c.basic_auth(uri.user, uri.password) if uri.password.present?
c.request :authorization, :basic, uri.user, uri.password if uri.password.present?
end
response = conn.post(uri.path, req.to_der,
+35 -6
View File
@@ -16,7 +16,19 @@ class UsersController < ApplicationController
@users.active.where.not(role: 'integration')
end
@pagy, @users = pagy(@users.preload(account: :account_accesses).where(account: current_account).order(id: :desc))
@users = @users.preload(account: :account_accesses).where(account: current_account).order(id: :desc)
respond_to do |format|
format.html do
@pagy, @users = pagy(@users)
end
if current_ability.can?(:manage, current_account)
format.csv do
send_data Users.generate_csv(@users), filename: "users-#{Time.current.iso8601}.csv", type: 'text/csv'
end
end
end
end
def new; end
@@ -24,10 +36,19 @@ class UsersController < ApplicationController
def edit; end
def create
if User.accessible_by(current_ability).exists?(email: @user.email)
@user.errors.add(:email, I18n.t('already_exists'))
existing_user = User.accessible_by(current_ability).find_by(email: @user.email)
return render turbo_stream: turbo_stream.replace(:modal, template: 'users/new'), status: :unprocessable_content
if existing_user
if existing_user.archived_at? &&
current_ability.can?(:manage, existing_user) && current_ability.can?(:manage, @user.account)
existing_user.assign_attributes(@user.slice(:first_name, :last_name, :role, :account_id))
existing_user.archived_at = nil
@user = existing_user
else
@user.errors.add(:email, I18n.t('already_exists'))
return render turbo_stream: turbo_stream.replace(:modal, template: 'users/new'), status: :unprocessable_content
end
end
@user.password = SecureRandom.hex if @user.password.blank?
@@ -45,7 +66,8 @@ class UsersController < ApplicationController
def update
return redirect_to settings_users_path, notice: I18n.t('unable_to_update_user') if Docuseal.demo?
attrs = user_params.compact_blank.merge(user_params.slice(:archived_at))
attrs = user_params.compact_blank
attrs = attrs.merge(user_params.slice(:archived_at)) if current_ability.can?(:create, @user)
if params.dig(:user, :account_id).present?
account = Account.accessible_by(current_ability).find(params.dig(:user, :account_id))
@@ -56,7 +78,14 @@ class UsersController < ApplicationController
end
if @user.update(attrs.except(*(current_user == @user ? %i[password otp_required_for_login role] : %i[password])))
redirect_back fallback_location: settings_users_path, notice: I18n.t('user_has_been_updated')
if @user.try(:pending_reconfirmation?) && @user.previous_changes.key?(:unconfirmed_email)
SendConfirmationInstructionsJob.perform_async('user_id' => @user.id)
redirect_back fallback_location: settings_users_path,
notice: I18n.t('a_confirmation_email_has_been_sent_to_the_new_email_address')
else
redirect_back fallback_location: settings_users_path, notice: I18n.t('user_has_been_updated')
end
else
render turbo_stream: turbo_stream.replace(:modal, template: 'users/edit'), status: :unprocessable_content
end
+1 -1
View File
@@ -15,7 +15,7 @@ class WebhookEventsController < ApplicationController
Submissions::SerializeForApi.call(@webhook_event.record)
when 'template.created', 'template.updated'
Templates::SerializeForApi.call(@webhook_event.record)
when 'submission.archived'
when 'submission.archived', 'template.archived'
@webhook_event.record.as_json(only: %i[id archived_at])
end
end
@@ -15,7 +15,7 @@ class WebhookSettingsController < ApplicationController
@webhook_events = @webhook_events.where(status: params[:status]) if %w[success error].include?(params[:status])
@pagy, @webhook_events = pagy_countless(@webhook_events.order(id: :desc))
@pagy, @webhook_events = pagy(:countless, @webhook_events.order(id: :desc))
render :show
end
@@ -26,7 +26,7 @@ class WebhookSettingsController < ApplicationController
@webhook_events = @webhook_events.where(status: params[:status]) if %w[success error].include?(params[:status])
@pagy, @webhook_events = pagy_countless(@webhook_events.order(id: :desc))
@pagy, @webhook_events = pagy(:countless, @webhook_events.order(id: :desc))
end
def new; end
+17 -1
View File
@@ -40,6 +40,7 @@ import DashboardDropzone from './elements/dashboard_dropzone'
import RequiredCheckboxGroup from './elements/required_checkbox_group'
import PageContainer from './elements/page_container'
import EmailEditor from './elements/email_editor'
import MarkdownEditor from './elements/markdown_editor'
import MountOnClick from './elements/mount_on_click'
import RemoveOnEvent from './elements/remove_on_event'
import ScrollTo from './elements/scroll_to'
@@ -51,6 +52,8 @@ import ToggleClasses from './elements/toggle_classes'
import AutosizeField from './elements/autosize_field'
import GoogleDriveFilePicker from './elements/google_drive_file_picker'
import OpenModal from './elements/open_modal'
import BarChart from './elements/bar_chart'
import FieldCondition from './elements/field_condition'
import * as TurboInstantClick from './lib/turbo_instant_click'
@@ -129,6 +132,7 @@ safeRegisterElement('check-on-click', CheckOnClick)
safeRegisterElement('required-checkbox-group', RequiredCheckboxGroup)
safeRegisterElement('page-container', PageContainer)
safeRegisterElement('email-editor', EmailEditor)
safeRegisterElement('markdown-editor', MarkdownEditor)
safeRegisterElement('mount-on-click', MountOnClick)
safeRegisterElement('remove-on-event', RemoveOnEvent)
safeRegisterElement('scroll-to', ScrollTo)
@@ -140,6 +144,8 @@ safeRegisterElement('toggle-classes', ToggleClasses)
safeRegisterElement('autosize-field', AutosizeField)
safeRegisterElement('google-drive-file-picker', GoogleDriveFilePicker)
safeRegisterElement('open-modal', OpenModal)
safeRegisterElement('bar-chart', BarChart)
safeRegisterElement('field-condition', FieldCondition)
safeRegisterElement('template-builder', class extends HTMLElement {
connectedCallback () {
@@ -149,23 +155,33 @@ safeRegisterElement('template-builder', class extends HTMLElement {
this.appElem.classList.add('md:h-screen')
const template = reactive(JSON.parse(this.dataset.template))
this.app = createApp(TemplateBuilder, {
template: reactive(JSON.parse(this.dataset.template)),
template,
customFields: reactive(JSON.parse(this.dataset.customFields || '[]')),
dynamicDocuments: reactive(JSON.parse(this.dataset.dynamicDocuments || '[]')),
backgroundColor: '#faf7f5',
locale: this.dataset.locale,
withPhone: this.dataset.withPhone === 'true',
withPrefillable: template.fields?.some((f) => f.prefillable),
withVerification: ['true', 'false'].includes(this.dataset.withVerification) ? this.dataset.withVerification === 'true' : null,
withKba: ['true', 'false'].includes(this.dataset.withKba) ? this.dataset.withKba === 'true' : null,
withLogo: this.dataset.withLogo !== 'false',
withFieldsDetection: this.dataset.withFieldsDetection === 'true',
editable: this.dataset.editable !== 'false',
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
withCustomFields: true,
withPayment: this.dataset.withPayment === 'true',
isPaymentConnected: this.dataset.isPaymentConnected === 'true',
withFormula: this.dataset.withFormula === 'true',
withSendButton: this.dataset.withSendButton !== 'false',
withSignYourselfButton: this.dataset.withSignYourselfButton !== 'false',
withConditions: this.dataset.withConditions === 'true',
withDynamicDocuments: this.dataset.withDynamicDocuments === 'true',
withGoogleDrive: this.dataset.withGoogleDrive === 'true',
withReplaceAndCloneUpload: true,
withDownload: true,
currencies: (this.dataset.currencies || '').split(',').filter(Boolean),
acceptFileTypes: this.dataset.acceptFileTypes,
showTourStartForm: this.dataset.showTourStartForm === 'true'
+4
View File
@@ -155,3 +155,7 @@ button[disabled] .enabled, button.btn-disabled .enabled {
.font-courier {
font-family: "Courier New", Consolas, "Liberation Mono", monospace, ui-monospace, SFMono-Regular, Menlo, Monaco;
}
markdown-editor [contenteditable] p {
margin-bottom: 18px;
}
+51 -6
View File
@@ -4,15 +4,26 @@ import { isValidSignatureCanvas } from './submission_form/validate_signature'
window.customElements.define('draw-signature', class extends HTMLElement {
connectedCallback () {
const scale = 3
this.canvas.width = this.canvas.parentNode.clientWidth * scale
this.canvas.height = this.canvas.parentNode.clientHeight * scale
this.canvas.getContext('2d').scale(scale, scale)
this.setCanvasSize()
this.pad = new SignaturePad(this.canvas)
this.resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => {
if (!this.canvas) return
const { width, height } = this.canvas
this.setCanvasSize()
if (this.canvas.width !== width || this.canvas.height !== height) {
this.redrawCanvas(width, height)
}
})
})
this.resizeObserver.observe(this.canvas.parentNode)
if (this.dataset.color) {
this.pad.penColor = this.dataset.color
}
@@ -57,6 +68,40 @@ window.customElements.define('draw-signature', class extends HTMLElement {
this.updateSubmitButtonVisibility()
}
disconnectedCallback () {
if (this.resizeObserver) {
this.resizeObserver.disconnect()
}
}
setCanvasSize () {
const scale = 3
const width = this.canvas.parentNode.clientWidth
const height = this.canvas.parentNode.clientHeight
if (this.canvas.width !== width * scale || this.canvas.height !== height * scale) {
this.canvas.width = width * scale
this.canvas.height = height * scale
this.canvas.getContext('2d').scale(scale, scale)
}
}
redrawCanvas (oldWidth, oldHeight) {
if (this.pad && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0) {
const sx = this.canvas.width / oldWidth
const sy = this.canvas.height / oldHeight
const scaledData = this.pad.toData().map((stroke) => ({
...stroke,
points: stroke.points.map((p) => ({ ...p, x: p.x * sx, y: p.y * sy }))
}))
this.pad.fromData(scaledData)
}
}
updateSubmitButtonVisibility () {
if (this.pad.isEmpty()) {
this.submitButton.style.display = 'none'
+50
View File
@@ -0,0 +1,50 @@
export default class extends HTMLElement {
connectedCallback () {
this.chartLabels = JSON.parse(this.dataset.labels || '[]')
this.chartDatasets = JSON.parse(this.dataset.datasets || '[]')
this.initChart()
}
disconnectedCallback () {
if (this.chartInstance) {
this.chartInstance.destroy()
this.chartInstance = null
}
}
async initChart () {
const { default: Chart } = await import(/* webpackChunkName: "chartjs" */ 'chart.js/auto')
const canvas = this.querySelector('canvas')
const ctx = canvas.getContext('2d')
this.chartInstance = new Chart(ctx, {
type: 'bar',
data: {
labels: this.chartLabels,
datasets: this.chartDatasets
},
options: {
responsive: true,
maintainAspectRatio: true,
animation: false,
scales: {
y: {
beginAtZero: true,
grace: '20%',
ticks: {
precision: 0
}
}
},
plugins: {
legend: {
display: false
}
}
}
})
}
}
+167
View File
@@ -0,0 +1,167 @@
export default class extends HTMLElement {
connectedCallback () {
this.targetId = this.dataset.targetId
this.fieldId = this.dataset.fieldId
this.action = (this.dataset.action || '').trim()
this.expectedValue = this.dataset.value
this.targetEl = document.getElementById(this.targetId)
this.sourceEl = document.getElementById(this.fieldId)
this.bindListeners()
this.evaluateAndApply()
}
disconnectedCallback () {
this.unbindListeners()
}
bindListeners () {
this.eventsFor(this.sourceEl).forEach((ev) => {
this.sourceEl.addEventListener(ev, this.evaluateAndApply)
})
}
unbindListeners () {
this.eventsFor(this.sourceEl).forEach((ev) => {
this.sourceEl.removeEventListener(ev, this.evaluateAndApply)
})
}
eventsFor (el) {
if (!el) return []
const tag = el.tagName.toLowerCase()
if (tag === 'textarea') return ['input']
if (tag === 'input') return ['input', 'change']
return ['change']
}
evaluateAndApply = () => {
const fieldConditions = document.querySelectorAll(`field-condition[data-target-id="${this.targetId}"]`)
const result = [...fieldConditions].reduce((acc, cond) => {
if (cond.dataset.operation === 'or') {
acc.push(acc.pop() || cond.checkCondition())
} else {
acc.push(cond.checkCondition())
}
return acc
}, [])
this.apply(!result.includes(false))
}
checkCondition () {
const action = this.action
const actual = this.getSourceValue()
const expected = this.expectedValue
if (action === 'empty' || action === 'unchecked') return this.isEmpty(actual)
if (action === 'not_empty' || action === 'checked') return !this.isEmpty(actual)
if (['equal', 'not_equal', 'greater_than', 'less_than'].includes(action) && this.sourceEl?.getAttribute('type') === 'number') {
if (this.isEmpty(actual) || this.isEmpty(expected)) return false
const actualNumber = parseFloat(actual)
const expectedNumber = parseFloat(expected)
if (Number.isNaN(actualNumber) || Number.isNaN(expectedNumber)) return false
if (action === 'equal') return Math.abs(actualNumber - expectedNumber) < Number.EPSILON
if (action === 'not_equal') return Math.abs(actualNumber - expectedNumber) > Number.EPSILON
if (action === 'greater_than') return actualNumber > expectedNumber
if (action === 'less_than') return actualNumber < expectedNumber
return false
}
if (action === 'equal') {
const list = Array.isArray(actual) ? actual : [actual]
return list.filter((v) => v !== null && v !== undefined).map(String).includes(String(expected))
}
if (action === 'contains') return this.contains(actual, expected)
if (action === 'not_equal') {
const list = Array.isArray(actual) ? actual : [actual]
return !list.filter((v) => v !== null && v !== undefined).map(String).includes(String(expected))
}
if (action === 'does_not_contain') return !this.contains(actual, expected)
return true
}
getSourceValue () {
const el = this.sourceEl
if (!el) return
const tag = el.tagName.toLowerCase()
const type = (el.getAttribute('type') || '').toLowerCase()
if (tag === 'select') return el.value
if (tag === 'textarea') return el.value
if (tag === 'input' && type === 'checkbox') return el.checked ? (el.value || '1') : null
if (tag === 'input') return el.value
return el.value ?? null
}
isEmpty (obj) {
if (obj == null) return true
if (Array.isArray(obj)) {
return obj.length === 0
}
if (typeof obj === 'string') {
return obj.trim().length === 0
}
if (typeof obj === 'object') {
return Object.keys(obj).length === 0
}
if (obj === false) {
return true
}
return false
}
contains (actual, expected) {
if (expected === null || expected === undefined) return false
const exp = String(expected)
if (Array.isArray(actual)) return actual.filter((v) => v !== null && v !== undefined).map(String).includes(exp)
if (typeof actual === 'string') return actual.includes(exp)
return actual !== null && actual !== undefined && String(actual) === exp
}
apply (passed) {
const controls = this.targetEl.matches('input, select, textarea, button')
? [this.targetEl]
: Array.from(this.targetEl.querySelectorAll('input, select, textarea, button'))
if (passed) {
this.targetEl.style.display = ''
this.targetEl.labels.forEach((label) => { label.style.display = '' })
controls.forEach((c) => (c.disabled = false))
} else {
this.targetEl.style.display = 'none'
this.targetEl.labels.forEach((label) => { label.style.display = 'none' })
controls.forEach((c) => (c.disabled = true))
}
}
}
+382
View File
@@ -0,0 +1,382 @@
import { target, targetable } from '@github/catalyst/lib/targetable'
import { actionable } from '@github/catalyst/lib/actionable'
function loadTiptap () {
return Promise.all([
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/core'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-bold'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-italic'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-paragraph'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-text'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-hard-break'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-document'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-link'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-underline'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extensions'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/markdown'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/pm/state'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/pm/view')
]).then(([core, bold, italic, paragraph, text, hardBreak, document, link, underline, extensions, markdown, pmState, pmView]) => ({
Editor: core.Editor,
Extension: core.Extension,
Bold: bold.default || bold,
Italic: italic.default || italic,
Paragraph: paragraph.default || paragraph,
Text: text.default || text,
HardBreak: hardBreak.default || hardBreak,
Document: document.default || document,
Link: link.default || link,
Underline: underline.default || underline,
UndoRedo: extensions.UndoRedo,
Markdown: markdown.Markdown,
Plugin: pmState.Plugin,
Decoration: pmView.Decoration,
DecorationSet: pmView.DecorationSet
}))
}
class LinkTooltip {
constructor (container, editor, templateEl) {
this.container = container
this.editor = editor
this.tooltip = templateEl.content.firstElementChild.cloneNode(true)
this.input = this.tooltip.querySelector('input')
this.saveButton = this.tooltip.querySelector('[data-role="link-save"]')
this.removeButton = this.tooltip.querySelector('[data-role="link-remove"]')
container.style.position = 'relative'
container.appendChild(this.tooltip)
}
isVisible () {
return !this.tooltip.classList.contains('hidden')
}
normalizeUrl (url) {
if (!url) return url
if (/^{/i.test(url)) return url
if (/^https?:\/\//i.test(url)) return url
if (/^mailto:/i.test(url)) return url
return `https://${url}`
}
show (url, pos, { focus = false } = {}) {
this.input.value = url || ''
this.removeButton.classList.toggle('hidden', !url)
this.tooltip.classList.remove('hidden')
const coords = this.editor.view.coordsAtPos(pos)
const containerRect = this.container.getBoundingClientRect()
this.tooltip.style.left = `${coords.left - containerRect.left}px`
this.tooltip.style.top = `${coords.bottom - containerRect.top + 4}px`
if (focus) this.input.focus()
this.saveHandler = () => {
const inputUrl = this.input.value.trim()
if (inputUrl) {
this.editor.chain().focus().extendMarkRange('link').setLink({ href: this.normalizeUrl(inputUrl) }).run()
}
this.hide()
}
this.removeHandler = () => {
this.editor.chain().focus().extendMarkRange('link').unsetLink().run()
this.hide()
}
this.keyHandler = (e) => {
if (e.key === 'Enter') {
e.preventDefault()
this.saveHandler()
} else if (e.key === 'Escape') {
e.preventDefault()
this.hide()
}
}
this.saveButton.addEventListener('click', this.saveHandler, { once: true })
this.removeButton.addEventListener('click', this.removeHandler, { once: true })
this.input.addEventListener('keydown', this.keyHandler)
}
hide () {
if (this.saveHandler) {
this.saveButton.removeEventListener('click', this.saveHandler)
this.saveHandler = null
}
if (this.removeHandler) {
this.removeButton.removeEventListener('click', this.removeHandler)
this.removeHandler = null
}
if (this.keyHandler) {
this.input.removeEventListener('keydown', this.keyHandler)
this.keyHandler = null
}
this.tooltip.classList.add('hidden')
this.currentMark = null
}
}
export default actionable(targetable(class extends HTMLElement {
static [target.static] = [
'textarea',
'editorElement',
'boldButton',
'italicButton',
'underlineButton',
'linkButton',
'linkTooltipTemplate'
]
async connectedCallback () {
if (!this.textarea || !this.editorElement) return
this.textarea.style.display = 'none'
this.adjustShortcutsForPlatform()
const { Editor, Extension, Bold, Italic, Paragraph, Text, HardBreak, UndoRedo, Document, Link, Underline, Markdown, Plugin, Decoration, DecorationSet } = await loadTiptap()
const buildDecorations = (doc) => {
const decorations = []
const regex = /\{\{?[a-zA-Z0-9_.-]+\}\}?/g
doc.descendants((node, pos) => {
if (!node.isText) return
let match
while ((match = regex.exec(node.text)) !== null) {
decorations.push(
Decoration.inline(pos + match.index, pos + match.index + match[0].length, {
class: 'bg-amber-100 py-0.5 px-1 rounded'
})
)
}
})
return DecorationSet.create(doc, decorations)
}
const VariableHighlight = Extension.create({
name: 'variableHighlight',
addProseMirrorPlugins () {
return [new Plugin({
state: {
init (_, { doc }) {
return buildDecorations(doc)
},
apply (tr, oldSet) {
return tr.docChanged ? buildDecorations(tr.doc) : oldSet
}
},
props: {
decorations (state) {
return this.getState(state)
}
}
})]
}
})
this.editor = new Editor({
element: this.editorElement,
extensions: [
Markdown,
Document,
Paragraph,
Text,
Bold,
Italic,
HardBreak.extend({
addKeyboardShortcuts () {
return {
Enter: () => this.editor.commands.setHardBreak()
}
}
}),
UndoRedo,
Link.extend({
inclusive: true,
addKeyboardShortcuts: () => ({
'Mod-k': () => {
this.toggleLink()
return true
}
})
}).configure({
openOnClick: false,
HTMLAttributes: {
class: 'link',
'data-turbo': 'false',
style: 'color: #2563eb; text-decoration: underline; cursor: text;'
}
}),
Underline,
VariableHighlight
],
content: (this.textarea.value || '').trim().replace(/ *\n/g, '<br>'),
contentType: 'markdown',
editorProps: {
attributes: {
style: 'min-height: 220px',
dir: 'auto',
class: 'p-3 outline-none focus:outline-none'
}
},
onUpdate: ({ editor }) => {
this.textarea.value = editor.getMarkdown()
this.textarea.dispatchEvent(new Event('input', { bubbles: true }))
},
onSelectionUpdate: ({ editor }) => {
this.updateToolbarState()
this.handleLinkTooltip(editor)
},
onBlur: () => {
setTimeout(() => {
if (!this.linkTooltip.tooltip.contains(document.activeElement)) {
this.linkTooltip.hide()
}
}, 0)
}
})
this.linkTooltip = new LinkTooltip(this, this.editor, this.linkTooltipTemplate)
}
adjustShortcutsForPlatform () {
if ((navigator.userAgentData?.platform || navigator.platform)?.toLowerCase()?.includes('mac')) {
this.querySelectorAll('.tooltip[data-tip]').forEach(tooltip => {
const tip = tooltip.getAttribute('data-tip')
if (tip && tip.includes('Ctrl')) {
tooltip.setAttribute('data-tip', tip.replace(/Ctrl/g, '⌘'))
}
})
}
}
bold (e) {
e.preventDefault()
this.editor.chain().focus().toggleBold().run()
this.updateToolbarState()
}
italic (e) {
e.preventDefault()
this.editor.chain().focus().toggleItalic().run()
this.updateToolbarState()
}
underline (e) {
e.preventDefault()
this.editor.chain().focus().toggleUnderline().run()
this.updateToolbarState()
}
linkSelection (e) {
e.preventDefault()
this.toggleLink()
this.updateToolbarState()
}
undo (e) {
e.preventDefault()
this.editor.chain().focus().undo().run()
this.updateToolbarState()
}
redo (e) {
e.preventDefault()
this.editor.chain().focus().redo().run()
this.updateToolbarState()
}
updateToolbarState () {
this.boldButton.classList.toggle('bg-base-200', this.editor.isActive('bold'))
this.italicButton.classList.toggle('bg-base-200', this.editor.isActive('italic'))
this.underlineButton.classList.toggle('bg-base-200', this.editor.isActive('underline'))
this.linkButton.classList.toggle('bg-base-200', this.editor.isActive('link'))
}
handleLinkTooltip (editor) {
const { from } = editor.state.selection
const mark = editor.state.doc.resolve(from).marks().find(m => m.type.name === 'link')
if (!mark) {
if (this.linkTooltip.isVisible()) this.linkTooltip.hide()
return
}
if (this.linkTooltip.isVisible() && this.linkTooltip.currentMark === mark) return
let linkStart = from
const start = editor.state.doc.resolve(from).start()
for (let i = from - 1; i >= start; i--) {
if (editor.state.doc.resolve(i).marks().some(m => m.eq(mark))) {
linkStart = i
} else {
break
}
}
this.linkTooltip.hide()
this.linkTooltip.show(mark.attrs.href, linkStart > start ? linkStart - 1 : linkStart)
this.linkTooltip.currentMark = mark
}
toggleLink () {
if (this.editor.isActive('link')) {
this.linkTooltip.hide()
this.editor.chain().focus().extendMarkRange('link').unsetLink().run()
this.updateToolbarState()
} else {
const { from } = this.editor.state.selection
this.linkTooltip.hide()
this.linkTooltip.show(this.editor.getAttributes('link').href, from, { focus: true })
}
}
insertVariable (e) {
const variable = e.target.closest('[data-variable]')?.dataset.variable
if (variable) {
const { from, to } = this.editor.state.selection
if (variable.includes('link') && from !== to) {
this.editor.chain().focus().setLink({ href: `{${variable}}` }).run()
} else {
this.editor.chain().focus().insertContent(`{${variable}}`).run()
}
}
}
disconnectedCallback () {
this.linkTooltip.hide()
if (this.editor) {
this.editor.destroy()
}
}
}))
+52 -7
View File
@@ -5,17 +5,28 @@ export default targetable(class extends HTMLElement {
static [target.static] = ['canvas', 'input', 'clear', 'button']
async connectedCallback () {
const scale = 3
this.canvas.width = this.canvas.parentNode.clientWidth * scale
this.canvas.height = this.canvas.parentNode.clientHeight * scale
this.canvas.getContext('2d').scale(scale, scale)
const { default: SignaturePad } = await import('signature_pad')
this.setCanvasSize()
this.pad = new SignaturePad(this.canvas)
this.resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => {
if (!this.canvas) return
const { width, height } = this.canvas
this.setCanvasSize()
if (this.canvas.width !== width || this.canvas.height !== height) {
this.redrawCanvas(width, height)
}
})
})
this.resizeObserver.observe(this.canvas.parentNode)
this.clear.addEventListener('click', (e) => {
e.preventDefault()
@@ -47,4 +58,38 @@ export default targetable(class extends HTMLElement {
this.closest('form').requestSubmit()
}
disconnectedCallback () {
if (this.resizeObserver) {
this.resizeObserver.disconnect()
}
}
setCanvasSize () {
const scale = 3
const width = this.canvas.parentNode.clientWidth
const height = this.canvas.parentNode.clientWidth / 2.5
if (this.canvas.width !== width * scale || this.canvas.height !== height * scale) {
this.canvas.width = width * scale
this.canvas.height = height * scale
this.canvas.getContext('2d').scale(scale, scale)
}
}
redrawCanvas (oldWidth, oldHeight) {
if (this.pad && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0) {
const sx = this.canvas.width / oldWidth
const sy = this.canvas.height / oldHeight
const scaledData = this.pad.toData().map((stroke) => ({
...stroke,
points: stroke.points.map((p) => ({ ...p, x: p.x * sx, y: p.y * sy }))
}))
this.pad.fromData(scaledData)
}
}
})
+8 -2
View File
@@ -1,12 +1,18 @@
export default class extends HTMLElement {
connectedCallback () {
this.input.addEventListener('change', (event) => {
if (!this.target) return
const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value
const dataValue = this.dataset.value === 'false' ? false : this.dataset.value || true
if (this.dataset.attribute) {
this.target[this.dataset.attribute] = event.target.checked
this.target[this.dataset.attribute] = value === dataValue
}
if (this.dataset.className) {
this.target.classList.toggle(this.dataset.className, event.target.value !== this.dataset.value)
this.target.classList.toggle(this.dataset.className, value !== dataValue)
if (this.dataset.className === 'hidden' && this.target.tagName === 'INPUT') {
this.target.disabled = event.target.value !== this.dataset.value
}
+10 -2
View File
@@ -1,10 +1,18 @@
export default class extends HTMLElement {
connectedCallback () {
const button = this.querySelector('a, button')
const button = this.querySelector('a, button, label')
const target = this.dataset.targetId ? document.getElementById(this.dataset.targetId) : button
button.addEventListener('click', () => {
this.dataset.classes.split(' ').forEach((cls) => {
button.classList.toggle(cls)
if (this.dataset.action === 'remove') {
target.classList.remove(cls)
} else if (this.dataset.action === 'add') {
target.classList.add(cls)
} else {
target.classList.toggle(cls)
}
})
})
}
+1 -1
View File
@@ -12,6 +12,6 @@ export default class extends HTMLElement {
}
get button () {
return this.querySelector('button')
return this.querySelector('button, label')
}
}
+3 -1
View File
@@ -31,12 +31,14 @@ safeRegisterElement('submission-form', class extends HTMLElement {
isDemo: this.dataset.isDemo === 'true',
attribution: this.dataset.attribution !== 'false',
scrollPadding: this.dataset.scrollPadding || '-80px',
signatureText: this.dataset.signatureText,
language: this.dataset.language,
dryRun: this.dataset.dryRun === 'true',
expand: ['true', 'false'].includes(this.dataset.expand) ? this.dataset.expand === 'true' : null,
withSignatureId: this.dataset.withSignatureId === 'true',
requireSigningReason: this.dataset.requireSigningReason === 'true',
withConfetti: this.dataset.withConfetti !== 'false',
withConfetti: this.dataset.withConfetti === 'true',
withFieldLabels: this.dataset.withFieldLabels !== 'false',
withDisclosure: this.dataset.withDisclosure === 'true',
reuseSignature: this.dataset.reuseSignature !== 'false',
withTypedSignature: this.dataset.withTypedSignature !== 'false',
+24 -6
View File
@@ -60,6 +60,11 @@
class="object-contain mx-auto"
:src="stamp.url"
>
<img
v-else-if="field.type === 'kba' && kba"
class="object-contain mx-auto"
:src="kba.url"
>
<div
v-else-if="field.type === 'signature' && signature"
class="flex justify-between h-full gap-1 overflow-hidden w-full"
@@ -177,8 +182,8 @@
</div>
<div
v-else-if="field.type === 'cells'"
class="w-full flex items-center"
:class="{ 'justify-end': field.preferences?.align === 'right', ...fontClasses }"
class="w-full flex"
:class="{ 'justify-end': field.preferences?.align === 'right', ...alignClasses, ...fontClasses }"
>
<div
v-for="(char, index) in modelValue"
@@ -272,7 +277,7 @@
<script>
import MarkdownContent from './markdown_content'
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard, IconRubberStamp, IconSquareNumber1, IconId } from '@tabler/icons-vue'
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard, IconRubberStamp, IconSquareNumber1, IconId, IconUserScan } from '@tabler/icons-vue'
export default {
name: 'FieldArea',
@@ -391,7 +396,8 @@ export default {
stamp: this.t('stamp'),
payment: this.t('payment'),
phone: this.t('phone'),
verification: this.t('verify_id')
verification: this.t('verify_id'),
kba: this.t('kba')
}
},
strikethroughWidth () {
@@ -454,7 +460,8 @@ export default {
multiple: IconChecks,
phone: IconPhoneCheck,
payment: IconCreditCard,
verification: IconId
verification: IconId,
kba: IconUserScan
}
},
image () {
@@ -471,6 +478,13 @@ export default {
return null
}
},
kba () {
if (this.field.type === 'kba') {
return this.attachmentsIndex[this.modelValue]
} else {
return null
}
},
signature () {
if (this.field.type === 'signature') {
return this.attachmentsIndex[this.modelValue]
@@ -553,10 +567,14 @@ export default {
style.color = this.field.preferences.color
}
if (this.field.preferences?.background) {
style.background = this.field.preferences.background
}
return style
},
isNarrow () {
return this.area.h > 0 && (this.area.w / this.area.h) > 6
return this.area.h > 0 && ((this.area.w * this.pageWidth) / (this.area.h * this.pageHeight)) > 4.5
}
},
watch: {
@@ -0,0 +1,874 @@
/*
* Portions of this file are derived from the Dentaku gem
* https://github.com/rubysolo/dentaku
*
* MIT License
*
* Copyright (c) 2012 Solomon White
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Modifications and JavaScript port
* Copyright (c) 2026 DocuSeal, LLC
*/
class CalculatorError extends Error { constructor (msg) { super(msg); this.name = 'CalculatorError' } }
class ParseError extends CalculatorError { constructor (msg) { super(msg); this.name = 'ParseError' } }
class TokenizerError extends CalculatorError { constructor (msg) { super(msg); this.name = 'TokenizerError' } }
class ArgumentError extends CalculatorError { constructor (msg) { super(msg); this.name = 'ArgumentError' } }
class ZeroDivisionError extends CalculatorError { constructor () { super('divided by 0'); this.name = 'ZeroDivisionError' } }
class UnboundVariableError extends CalculatorError {
constructor (vars) {
super('no value provided for variables: ' + vars.join(', '))
this.name = 'UnboundVariableError'
this.unbound = vars
}
}
class Token {
constructor (category, value, raw) {
this.category = category
this.value = value
this.raw = raw == null ? String(value) : String(raw)
}
is (cat) { return this.category === cat }
}
const OPERATOR_NAMES = {
'^': 'pow',
'+': 'add',
'-': 'subtract',
'*': 'multiply',
'/': 'divide',
'%': 'mod',
'|': 'bitor',
'&': 'bitand',
'<<': 'bitshiftleft',
'>>': 'bitshiftright'
}
const COMPARATOR_NAMES = {
'<=': 'le',
'>=': 'ge',
'!=': 'ne',
'<>': 'ne',
'<': 'lt',
'>': 'gt',
'==': 'eq',
'=': 'eq'
}
const GROUPING_NAMES = { '(': 'open', ')': 'close', ',': 'comma' }
function buildScanners (caseSensitive) {
return [
(s) => {
const m = /^\s+/.exec(s)
return m ? { tokens: [], length: m[0].length } : null
},
(s) => {
const m = /^null\b/i.exec(s)
return m ? { tokens: [new Token('null', null, m[0])], length: m[0].length } : null
},
(s) => {
const m = /^((?:\d+(?:\.\d+)?|\.\d+)(?:[eE][+-]?\d+)?)\b/.exec(s)
if (!m) return null
return { tokens: [new Token('numeric', Number(m[0]), m[0])], length: m[0].length }
},
(s) => {
const m = /^0x[0-9a-f]+\b/i.exec(s)
if (!m) return null
return { tokens: [new Token('numeric', parseInt(m[0].slice(2), 16), m[0])], length: m[0].length }
},
(s) => {
const m = /^"([^"]*)"/.exec(s)
if (!m) return null
return { tokens: [new Token('string', m[1], m[0])], length: m[0].length }
},
(s) => {
const m = /^'([^']*)'/.exec(s)
if (!m) return null
return { tokens: [new Token('string', m[1], m[0])], length: m[0].length }
},
(s, last) => {
if (s[0] !== '-') return null
const ok = last == null ||
last.is('operator') ||
last.is('comparator') ||
last.is('combinator') ||
last.value === 'open' ||
last.value === 'comma' ||
last.value === 'array_start'
if (!ok) return null
return { tokens: [new Token('operator', 'negate', '-')], length: 1 }
},
(s) => {
const m = /^(and|or|&&|\|\|)\s/i.exec(s)
if (!m) return null
const raw = m[1].toLowerCase()
const name = raw === '&&' ? 'and' : raw === '||' ? 'or' : raw
return { tokens: [new Token('combinator', name, m[0])], length: m[0].length }
},
(s) => {
const m = /^(<<|>>|\^|\+|-|\*|\/|%|\||&)/.exec(s)
if (!m) return null
return { tokens: [new Token('operator', OPERATOR_NAMES[m[0]], m[0])], length: m[0].length }
},
(s) => {
const m = /^(\(|\)|,)/.exec(s)
if (!m) return null
return { tokens: [new Token('grouping', GROUPING_NAMES[m[0]], m[0])], length: m[0].length }
},
(s) => {
if (s[0] === '{') return { tokens: [new Token('array', 'array_start', '{')], length: 1 }
if (s[0] === '}') return { tokens: [new Token('array', 'array_end', '}')], length: 1 }
return null
},
(s) => {
const m = /^(<=|>=|!=|<>|==|=|<|>)/.exec(s)
if (!m) return null
return { tokens: [new Token('comparator', COMPARATOR_NAMES[m[0]], m[0])], length: m[0].length }
},
(s) => {
const m = /^(true|false)\b/i.exec(s)
if (!m) return null
return { tokens: [new Token('logical', m[0].toLowerCase() === 'true', m[0])], length: m[0].length }
},
(s) => {
const m = /^(\w+!?)\s*\(/.exec(s)
if (!m) return null
const name = m[1].toLowerCase()
return {
tokens: [
new Token('function', name, m[1]),
new Token('grouping', 'open', '(')
],
length: m[0].length
}
},
(s) => {
const m = /^[A-Za-z_][\w.]*\b/.exec(s)
if (!m) return null
const value = caseSensitive ? m[0] : m[0].toLowerCase()
return { tokens: [new Token('identifier', value, m[0])], length: m[0].length }
},
(s) => {
const m = /^`([^`]*)`/.exec(s)
if (!m) return null
return { tokens: [new Token('identifier', m[1], m[0])], length: m[0].length }
}
]
}
function tokenize (input, options) {
options = options || {}
let remaining = String(input).replace(/\/\*[\s\S]*?\*\//g, '')
const scanners = buildScanners(options.caseSensitive === true)
const tokens = []
let nesting = 0
while (remaining.length > 0) {
let matched = false
for (let i = 0; i < scanners.length; i++) {
const result = scanners[i](remaining, tokens.length ? tokens[tokens.length - 1] : null)
if (result) {
for (const tok of result.tokens) {
if (tok.category === 'grouping' && tok.value === 'open') nesting++
if (tok.category === 'grouping' && tok.value === 'close') {
nesting--
if (nesting < 0) throw new TokenizerError('too many closing parentheses')
}
tokens.push(tok)
}
remaining = remaining.slice(result.length)
matched = true
break
}
}
if (!matched) throw new TokenizerError("parse error at: '" + remaining + "'")
}
if (nesting > 0) throw new TokenizerError('too many opening parentheses')
return tokens
}
function uniq (arr) {
const seen = new Set()
const out = []
for (const x of arr) { if (!seen.has(x)) { seen.add(x); out.push(x) } }
return out
}
function toNumber (val) {
if (typeof val === 'number') return val
if (typeof val === 'boolean') return val ? 1 : 0
if (val == null) throw new ArgumentError("'" + val + "' is not coercible to numeric")
if (typeof val === 'string') {
if (val.length === 0) throw new ArgumentError("'' is not coercible to numeric")
const n = Number(val)
if (isNaN(n)) throw new ArgumentError("'" + val + "' is not coercible to numeric")
return n
}
throw new ArgumentError("'" + val + "' is not coercible to numeric")
}
class Node {
static precedence = 0
static arity = null
static rightAssociative = false
static resolveClass () { return this }
static minParamCount () { return this.arity }
static maxParamCount () { return this.arity }
value () { return null }
dependencies () { return [] }
get type () { return null }
}
class NilNode extends Node { value () { return null } }
class Numeric extends Node {
constructor (token) { super(); this.v = token.value }
value () { return this.v }
get type () { return 'numeric' }
}
class Logical extends Node {
constructor (token) { super(); this.v = token.value }
value () { return this.v }
get type () { return 'logical' }
}
class StringNode extends Node {
constructor (token) { super(); this.v = token.value }
value () { return this.v }
get type () { return 'string' }
}
class Identifier extends Node {
constructor (token) { super(); this.ident = token.value }
value (ctx) {
if (ctx && (this.ident in ctx)) {
const v = ctx[this.ident]
if (v instanceof Node) return v.value(ctx)
if (typeof v === 'function') return v()
return v
}
throw new UnboundVariableError([this.ident])
}
dependencies (ctx) {
if (ctx && (this.ident in ctx)) {
const v = ctx[this.ident]
return v instanceof Node ? v.dependencies(ctx) : []
}
return [this.ident]
}
}
class Grouping extends Node {
constructor (node) { super(); this.node = node }
value (ctx) { return this.node.value(ctx) }
get type () { return this.node.type }
dependencies (ctx) { return this.node.dependencies(ctx) }
}
class Operation extends Node {
constructor (left, right) { super(); this.left = left; this.right = right }
dependencies (ctx) {
const l = this.left ? this.left.dependencies(ctx) : []
const r = this.right ? this.right.dependencies(ctx) : []
return uniq(l.concat(r))
}
}
class Arithmetic extends Operation { get type () { return 'numeric' } }
class Addition extends Arithmetic {
static precedence = 10
value (ctx) {
const l = this.left.value(ctx)
const r = this.right.value(ctx)
if (typeof l === 'string' || typeof r === 'string') return String(l) + String(r)
return toNumber(l) + toNumber(r)
}
}
class Subtraction extends Arithmetic {
static precedence = 10
value (ctx) { return toNumber(this.left.value(ctx)) - toNumber(this.right.value(ctx)) }
}
class Multiplication extends Arithmetic {
static precedence = 20
value (ctx) { return toNumber(this.left.value(ctx)) * toNumber(this.right.value(ctx)) }
}
class Division extends Arithmetic {
static precedence = 20
value (ctx) {
const r = toNumber(this.right.value(ctx))
if (r === 0) throw new ZeroDivisionError()
return toNumber(this.left.value(ctx)) / r
}
}
class Modulo extends Arithmetic {
static arity = 2
static precedence = 20
static resolveClass (nextTok) {
if (!nextTok) return Percentage
if (nextTok.category === 'operator') return Percentage
if (nextTok.category === 'comparator') return Percentage
if (nextTok.category === 'combinator') return Percentage
if (nextTok.category === 'grouping' && (nextTok.value === 'close' || nextTok.value === 'comma')) return Percentage
if (nextTok.category === 'array' && nextTok.value === 'array_end') return Percentage
return Modulo
}
value (ctx) {
const r = toNumber(this.right.value(ctx))
if (r === 0) throw new ZeroDivisionError()
return toNumber(this.left.value(ctx)) % r
}
}
class Percentage extends Arithmetic {
static arity = 1
static precedence = 30
constructor (child) { super(child, null) }
value (ctx) { return toNumber(this.left.value(ctx)) * 0.01 }
dependencies (ctx) { return this.left.dependencies(ctx) }
}
class Exponentiation extends Arithmetic {
static precedence = 30
static rightAssociative = true
value (ctx) { return Math.pow(toNumber(this.left.value(ctx)), toNumber(this.right.value(ctx))) }
}
class Negation extends Arithmetic {
static arity = 1
static precedence = 40
static rightAssociative = true
constructor (node) { super(node, null) }
value (ctx) { return -toNumber(this.left.value(ctx)) }
dependencies (ctx) { return this.left.dependencies(ctx) }
}
class BitwiseOr extends Operation {
static precedence = 10
value (ctx) { return toNumber(this.left.value(ctx)) | toNumber(this.right.value(ctx)) }
get type () { return 'numeric' }
}
class BitwiseAnd extends Operation {
static precedence = 10
value (ctx) { return toNumber(this.left.value(ctx)) & toNumber(this.right.value(ctx)) }
get type () { return 'numeric' }
}
class BitwiseShiftLeft extends Operation {
static precedence = 10
value (ctx) { return toNumber(this.left.value(ctx)) << toNumber(this.right.value(ctx)) }
get type () { return 'numeric' }
}
class BitwiseShiftRight extends Operation {
static precedence = 10
value (ctx) { return toNumber(this.left.value(ctx)) >> toNumber(this.right.value(ctx)) }
get type () { return 'numeric' }
}
function cmpCast (v) {
if (typeof v === 'string' && /^-?\d*\.?\d+$/.test(v)) return Number(v)
return v
}
class Comparator extends Operation {
static precedence = 5
get type () { return 'logical' }
}
class LessThan extends Comparator { value (ctx) { return cmpCast(this.left.value(ctx)) < cmpCast(this.right.value(ctx)) } }
class LessThanOrEqual extends Comparator { value (ctx) { return cmpCast(this.left.value(ctx)) <= cmpCast(this.right.value(ctx)) } }
class GreaterThan extends Comparator { value (ctx) { return cmpCast(this.left.value(ctx)) > cmpCast(this.right.value(ctx)) } }
class GreaterThanOrEqual extends Comparator { value (ctx) { return cmpCast(this.left.value(ctx)) >= cmpCast(this.right.value(ctx)) } }
class NotEqual extends Comparator {
value (ctx) {
const l = cmpCast(this.left.value(ctx))
const r = cmpCast(this.right.value(ctx))
return l !== r
}
}
class Equal extends Comparator {
value (ctx) {
const l = cmpCast(this.left.value(ctx))
const r = cmpCast(this.right.value(ctx))
return l === r
}
}
class Combinator extends Operation {
static precedence = 0
get type () { return 'logical' }
}
class And extends Combinator { value (ctx) { return Boolean(this.left.value(ctx)) && Boolean(this.right.value(ctx)) } }
class Or extends Combinator { value (ctx) { return Boolean(this.left.value(ctx)) || Boolean(this.right.value(ctx)) } }
class ArrayNode extends Node {
static arity = null
static minParamCount () { return 0 }
static maxParamCount () { return Infinity }
constructor (...items) {
super()
this.items = items
}
value (ctx) { return this.items.map((i) => i.value(ctx)) }
dependencies (ctx) {
return uniq([].concat.apply([], this.items.map((i) => i.dependencies(ctx))))
}
get type () { return 'array' }
}
class FunctionNode extends Node {
static arity = null
static minParamCount () { return 0 }
static maxParamCount () { return Infinity }
constructor (...args) {
super()
this.args = args
}
dependencies (ctx) {
return uniq([].concat.apply([], this.args.map((a) => a.dependencies(ctx))))
}
}
const FUNCTION_REGISTRY = Object.create(null)
function registerFunctionClass (name, cls) { FUNCTION_REGISTRY[name.toLowerCase()] = cls }
function getFunctionClass (name) { return FUNCTION_REGISTRY[name.toLowerCase()] }
function defineFunction (name, spec) {
const min = spec.min == null ? 0 : spec.min
const max = spec.max == null ? Infinity : spec.max
const call = spec.call
const type = spec.type || 'numeric'
class F extends FunctionNode {
static arity = (min === max) ? min : null
static minParamCount () { return min }
static maxParamCount () { return max }
value (ctx) {
const vals = this.args.map((a) => a.value(ctx))
return call(vals, ctx)
}
get type () { return type }
}
registerFunctionClass(name, F)
return F
}
function flattenNumeric (vals) {
const out = [];
(function walk (v) {
if (Array.isArray(v)) v.forEach(walk)
else out.push(toNumber(v))
})(vals)
return out
}
defineFunction('abs', { min: 1, max: 1, call: (v) => Math.abs(toNumber(v[0])) })
defineFunction('min', { min: 1, call: (v) => Math.min.apply(null, flattenNumeric(v)) })
defineFunction('max', { min: 1, call: (v) => Math.max.apply(null, flattenNumeric(v)) })
defineFunction('sum', { min: 1, call: (v) => flattenNumeric(v).reduce((a, b) => a + b, 0) })
defineFunction('avg', {
min: 1,
call: (v) => {
const nums = flattenNumeric(v)
if (nums.length === 0) return 0
return nums.reduce((a, b) => a + b, 0) / nums.length
}
})
defineFunction('count', {
min: 1,
max: 1,
call: (v) => Array.isArray(v[0]) ? v[0].length : (v[0] == null ? 0 : 1)
})
defineFunction('round', {
min: 1,
max: 2,
call: (v) => {
const n = toNumber(v[0])
const p = v.length > 1 ? toNumber(v[1]) : 0
const f = Math.pow(10, p)
return Math.round(n * f) / f
}
})
defineFunction('roundup', {
min: 1,
max: 2,
call: (v) => {
const n = toNumber(v[0])
const p = v.length > 1 ? toNumber(v[1]) : 0
const f = Math.pow(10, p)
return (n < 0 ? -Math.floor(-n * f) : Math.ceil(n * f)) / f
}
})
defineFunction('rounddown', {
min: 1,
max: 2,
call: (v) => {
const n = toNumber(v[0])
const p = v.length > 1 ? toNumber(v[1]) : 0
const f = Math.pow(10, p)
return (n < 0 ? -Math.ceil(-n * f) : Math.floor(n * f)) / f
}
})
const MATH_FNS = {
sqrt: Math.sqrt,
cbrt: Math.cbrt,
sin: Math.sin,
cos: Math.cos,
tan: Math.tan,
asin: Math.asin,
acos: Math.acos,
atan: Math.atan,
sinh: Math.sinh,
cosh: Math.cosh,
tanh: Math.tanh,
exp: Math.exp,
log: Math.log,
log2: Math.log2,
log10: Math.log10,
floor: Math.floor,
ceil: Math.ceil
}
Object.keys(MATH_FNS).forEach((name) => {
const fn = MATH_FNS[name]
defineFunction(name, { min: 1, max: 1, call: (v) => fn(toNumber(v[0])) })
})
defineFunction('atan2', { min: 2, max: 2, call: (v) => Math.atan2(toNumber(v[0]), toNumber(v[1])) })
defineFunction('hypot', { min: 1, call: (v) => Math.hypot.apply(null, flattenNumeric(v)) })
defineFunction('pow', { min: 2, max: 2, call: (v) => Math.pow(toNumber(v[0]), toNumber(v[1])) })
defineFunction('pi', { min: 0, max: 0, call: () => Math.PI })
defineFunction('e', { min: 0, max: 0, call: () => Math.E })
defineFunction('not', { min: 1, max: 1, type: 'logical', call: (v) => !v[0] })
defineFunction('and', { min: 1, type: 'logical', call: (v) => v.every((x) => Boolean(x)) })
defineFunction('or', { min: 1, type: 'logical', call: (v) => v.some((x) => Boolean(x)) })
defineFunction('xor', {
min: 2,
type: 'logical',
call: (v) => v.reduce((acc, x) => acc !== Boolean(x), false)
})
class IfFunction extends FunctionNode {
static arity = 3
static minParamCount () { return 3 }
static maxParamCount () { return 3 }
value (ctx) {
return this.args[0].value(ctx) ? this.args[1].value(ctx) : this.args[2].value(ctx)
}
get type () { return this.args[1].type }
dependencies (ctx) {
try {
return this.args[0].value(ctx) ? this.args[1].dependencies(ctx) : this.args[2].dependencies(ctx)
} catch (e) {
return uniq([].concat.apply([], this.args.map((a) => a.dependencies(ctx))))
}
}
}
registerFunctionClass('if', IfFunction)
defineFunction('length', { min: 1, max: 1, call: (v) => v[0] == null ? 0 : String(v[0]).length })
defineFunction('upcase', { min: 1, max: 1, type: 'string', call: (v) => String(v[0]).toUpperCase() })
defineFunction('downcase', { min: 1, max: 1, type: 'string', call: (v) => String(v[0]).toLowerCase() })
defineFunction('concat', { min: 1, type: 'string', call: (v) => v.map((x) => String(x)).join('') })
defineFunction('left', {
min: 2,
max: 2,
type: 'string',
call: (v) => String(v[0]).slice(0, toNumber(v[1]))
})
defineFunction('right', {
min: 2,
max: 2,
type: 'string',
call: (v) => {
const n = toNumber(v[1])
return n === 0 ? '' : String(v[0]).slice(-n)
}
})
defineFunction('mid', {
min: 3,
max: 3,
type: 'string',
call: (v) => {
const s = String(v[0])
const start = toNumber(v[1]) - 1
const len = toNumber(v[2])
return s.slice(start, start + len)
}
})
defineFunction('trim', { min: 1, max: 1, type: 'string', call: (v) => String(v[0]).trim() })
defineFunction('contains', {
min: 2,
max: 2,
type: 'logical',
call: (v) => {
if (Array.isArray(v[0])) return v[0].indexOf(v[1]) !== -1
return String(v[0]).indexOf(String(v[1])) !== -1
}
})
const OP_CLASSES = {
add: Addition,
subtract: Subtraction,
multiply: Multiplication,
divide: Division,
pow: Exponentiation,
negate: Negation,
mod: Modulo,
bitor: BitwiseOr,
bitand: BitwiseAnd,
bitshiftleft: BitwiseShiftLeft,
bitshiftright: BitwiseShiftRight,
lt: LessThan,
gt: GreaterThan,
le: LessThanOrEqual,
ge: GreaterThanOrEqual,
ne: NotEqual,
eq: Equal,
and: And,
or: Or
}
function isOperationClass (cls) {
return cls && (cls.prototype instanceof Operation)
}
function parse (tokens) {
if (!tokens || tokens.length === 0) return new NilNode()
const output = []
const operations = []
const arities = []
const skip = new Set()
function consume (count) {
if (count == null) count = 2
const op = operations.pop()
if (!op) throw new ParseError('invalid statement')
const outputSize = output.length
const opMin = (typeof op.minParamCount === 'function') ? op.minParamCount() : null
const opMax = (typeof op.maxParamCount === 'function') ? op.maxParamCount() : null
const actualArgs = (op.arity != null) ? op.arity : count
const minSize = (op.arity != null) ? op.arity : (opMin != null ? opMin : count)
const maxSize = (op.arity != null) ? op.arity : (opMax != null ? opMax : count)
if (outputSize < minSize || actualArgs < minSize) {
throw new ParseError((op.name || 'operator') + ' has too few operands (given ' + outputSize + ', expected ' + minSize + ')')
}
if ((outputSize > maxSize && operations.length === 0) || actualArgs > maxSize) {
throw new ParseError((op.name || 'operator') + ' has too many operands (given ' + outputSize + ', expected ' + maxSize + ')')
}
if (op === ArrayNode && output.length === 0) {
output.push(new ArrayNode())
return
}
if (outputSize < actualArgs) throw new ParseError('invalid statement')
const args = []
for (let i = 0; i < actualArgs; i++) args.unshift(output.pop())
// eslint-disable-next-line new-cap
output.push(new op(...args))
}
function handleOperator (token, lookahead) {
let cls = OP_CLASSES[token.value]
if (!cls) throw new ParseError('Unknown operator ' + token.value)
if (typeof cls.resolveClass === 'function') cls = cls.resolveClass(lookahead)
const prec = cls.precedence
if (cls.rightAssociative) {
while (operations.length > 0) {
const top = operations[operations.length - 1]
if (isOperationClass(top) && prec < top.precedence) consume()
else break
}
} else {
while (operations.length > 0) {
const top = operations[operations.length - 1]
if (isOperationClass(top) && prec <= top.precedence) consume()
else break
}
}
operations.push(cls)
}
function handleFunction (token) {
const fn = getFunctionClass(token.value)
if (!fn) throw new ParseError('Undefined function ' + token.value)
arities.push(0)
operations.push(fn)
}
function handleArray (token) {
if (token.value === 'array_start') {
operations.push(ArrayNode)
arities.push(0)
} else {
while (operations.length > 0 && operations[operations.length - 1] !== ArrayNode) consume()
if (operations[operations.length - 1] !== ArrayNode) throw new ParseError('Unbalanced bracket')
consume(arities.pop() + 1)
}
}
function handleGrouping (token, lookahead, index) {
if (token.value === 'open') {
if (lookahead && lookahead.value === 'close') {
skip.add(index + 1)
arities.pop()
consume(0)
} else {
operations.push(Grouping)
}
} else if (token.value === 'close') {
while (operations.length > 0 && operations[operations.length - 1] !== Grouping) consume()
const lparen = operations.pop()
if (lparen !== Grouping) throw new ParseError('Unbalanced parenthesis')
const top = operations[operations.length - 1]
if (top && (top.prototype instanceof FunctionNode)) consume(arities.pop() + 1)
} else if (token.value === 'comma') {
if (arities.length === 0) throw new ParseError('invalid statement')
arities[arities.length - 1] += 1
while (operations.length > 0 &&
operations[operations.length - 1] !== Grouping &&
operations[operations.length - 1] !== ArrayNode) consume()
} else {
throw new ParseError('Unknown grouping token ' + token.value)
}
}
for (let i = 0; i < tokens.length; i++) {
if (skip.has(i)) continue
const token = tokens[i]
const lookahead = tokens[i + 1]
switch (token.category) {
case 'numeric': output.push(new Numeric(token)); break
case 'logical': output.push(new Logical(token)); break
case 'string': output.push(new StringNode(token)); break
case 'identifier': output.push(new Identifier(token)); break
case 'null': output.push(new NilNode()); break
case 'operator':
case 'comparator':
case 'combinator':
handleOperator(token, lookahead); break
case 'function': handleFunction(token); break
case 'array': handleArray(token); break
case 'grouping': handleGrouping(token, lookahead, i); break
default:
throw new ParseError('Not implemented for tokens of category ' + token.category)
}
}
while (operations.length > 0) consume()
if (output.length !== 1) throw new ParseError('Invalid statement')
return output[0]
}
class Calculator {
constructor (options) {
options = options || {}
this.caseSensitive = options.caseSensitive === true
this.memory = Object.create(null)
this._astCache = Object.create(null)
}
key (k) { return this.caseSensitive ? k : String(k).toLowerCase() }
normalize (data) {
const out = Object.create(null)
if (!data) return out
for (const k of Object.keys(data)) out[this.key(k)] = data[k]
return out
}
store (data) { Object.assign(this.memory, this.normalize(data)); return this }
bind (data) { return this.store(data) }
clear () { this.memory = Object.create(null); return this }
ast (expression) {
const cached = this._astCache[expression]
if (cached) return cached
const node = parse(tokenize(expression, { caseSensitive: this.caseSensitive }))
this._astCache[expression] = node
return node
}
tokenize (expression) {
return tokenize(expression, { caseSensitive: this.caseSensitive })
}
dependencies (expression) {
return this.ast(expression).dependencies(this.memory)
}
evaluate (expression, data, onError) {
try {
return this.evaluateStrict(expression, data)
} catch (e) {
if (typeof data === 'function') return data(expression, e)
if (typeof onError === 'function') return onError(expression, e)
return undefined
}
}
evaluateStrict (expression, data) {
const ctx = Object.assign(Object.create(null), this.memory, this.normalize(data))
const node = this.ast(expression)
return node.value(ctx)
}
addFunction (name, spec) {
defineFunction(name, {
min: spec.minArgs == null ? (spec.min == null ? 0 : spec.min) : spec.minArgs,
max: spec.maxArgs == null ? (spec.max == null ? Infinity : spec.max) : spec.maxArgs,
type: spec.type || 'numeric',
call: spec.call
})
return this
}
}
export {
Calculator,
tokenize,
parse,
CalculatorError,
ParseError,
TokenizerError,
UnboundVariableError,
ZeroDivisionError,
ArgumentError
}
export function addFunction (name, spec) { defineFunction(name, spec) }
+9 -1
View File
@@ -161,6 +161,11 @@ export default {
required: false,
default: false
},
fetchOptions: {
type: Object,
required: false,
default: () => ({})
},
completedButton: {
type: Object,
required: false,
@@ -214,7 +219,10 @@ export default {
download () {
this.isDownloading = true
fetch(this.baseUrl + `/submitters/${this.submitterSlug}/download`).then(async (response) => {
fetch(this.baseUrl + `/submitters/${this.submitterSlug}/download`, {
method: 'GET',
...this.fetchOptions
}).then(async (response) => {
if (response.ok) {
const urls = await response.json()
const isMobileSafariIos = 'ontouchstart' in window && navigator.maxTouchPoints > 0 && /AppleWebKit/i.test(navigator.userAgent)
+1 -1
View File
@@ -42,7 +42,7 @@
<MarkdownContent :string="field.description" />
</div>
<AppearsOn :field="field" />
<div class="text-center">
<div class="text-center flex">
<input
:id="field.uuid"
ref="input"
+11 -3
View File
@@ -163,12 +163,20 @@ export default {
return fetch(this.baseUrl + '/api/attachments', {
method: 'POST',
body: formData
}).then(resp => resp.json()).then((data) => {
return data
}).then(async (resp) => {
const data = await resp.json()
if (resp.status === 422) {
alert(data.error)
} else {
return data
}
})
}
})).then((result) => {
this.$emit('upload', result)
if (result && result[0]) {
this.$emit('upload', result)
}
}).finally(() => {
this.isLoading = false
})
+178 -43
View File
@@ -8,7 +8,7 @@
:scroll-el="scrollEl"
:with-signature-id="withSignatureId"
:attachments-index="attachmentsIndex"
:with-label="!isAnonymousChecboxes && showFieldNames"
:with-label="withFieldLabels && !isAnonymousChecboxes && showFieldNames"
:current-step="currentStepFields"
:scroll-padding="scrollPadding"
@focus-step="[saveStep(), currentField.type !== 'checkbox' ? isFormVisible = true : '', goToStep($event, false, true)]"
@@ -69,7 +69,7 @@
id="expand_form_button"
class="btn btn-neutral flex text-white absolute bottom-0 w-full mb-3 expand-form-button text-base"
style="width: 96%; margin-left: 2%"
@click.prevent="[isFormVisible = true, scrollIntoField(currentField)]"
@click.prevent="[isFormVisible = true, $nextTick(() => scrollIntoField(currentField))]"
>
<template v-if="['initials', 'signature'].includes(currentField.type)">
<IconWritingSign stroke-width="1.5" />
@@ -133,6 +133,12 @@
name="_method"
type="hidden"
>
<input
v-if="validate === false"
value="false"
name="validate"
type="hidden"
>
<div class="md:mt-4">
<div v-if="['cells', 'text'].includes(currentField.type)">
<TextStep
@@ -351,8 +357,6 @@
class="base-checkbox !h-7 !w-7"
:required="field.required"
:checked="!!values[field.uuid]"
@invalid="$event.target.setCustomValidity(t('please_check_the_box_to_continue'))"
@change="$event.target.setCustomValidity($event.target.validity.valueMissing ? t('please_check_the_box_to_continue') : '')"
@click="[scrollIntoField(field), values[field.uuid] = !values[field.uuid]]"
>
<span
@@ -391,11 +395,15 @@
v-model="values[currentField.uuid]"
:reason="values[currentField.preferences?.reason_field_uuid]"
:field="currentField"
:values="values"
:previous-value="previousSignatureValueFor(currentField) || previousSignatureValue"
:touch-attachment-uuid="previousSignatureValue"
:with-typed-signature="withTypedSignature"
:remember-signature="rememberSignature"
:attachments-index="attachmentsIndex"
:require-signing-reason="requireSigningReason"
:signature-text="signatureText"
:signature-src="signatureSrc"
:button-text="submitButtonText"
:dry-run="dryRun"
:with-disclosure="withDisclosure"
@@ -403,6 +411,7 @@
:submitter="submitter"
:show-field-names="showFieldNames"
@update:reason="values[currentField.preferences?.reason_field_uuid] = $event"
@touch-attachment="attachmentsIndex[previousSignatureValue] ? attachmentsIndex[previousSignatureValue].created_at = new Date() : null"
@attached="attachments.push($event)"
@start="scrollIntoField(currentField)"
@minimize="minimizeForm"
@@ -414,6 +423,7 @@
v-model="values[currentField.uuid]"
:field="currentField"
:dry-run="dryRun"
:submitter="submitter"
:previous-value="previousInitialsValue"
:attachments-index="attachmentsIndex"
:show-field-names="showFieldNames"
@@ -461,6 +471,17 @@
@focus="scrollIntoField(currentField)"
@submit="!isSubmitting && submitStep()"
/>
<KbaStep
v-else-if="currentField.type === 'kba'"
ref="currentStep"
:key="currentField.uuid"
:submitter="submitter"
:empty-value-required-step="emptyValueRequiredStep"
:field="currentField"
:submitter-slug="submitterSlug"
:values="values"
@submit="!isSubmitting && submitStep()"
/>
<VerificationStep
v-else-if="currentField.type === 'verification'"
ref="currentStep"
@@ -476,7 +497,7 @@
/>
</div>
<div
v-if="(currentField.type !== 'payment' && currentField.type !== 'verification') || submittedValues[currentField.uuid]"
v-if="(currentField.type !== 'payment' && currentField.type !== 'verification' && currentField.type !== 'kba') || submittedValues[currentField.uuid]"
:class="currentField.type === 'signature' ? 'mt-2' : 'mt-4 md:mt-6'"
>
<button
@@ -511,6 +532,7 @@
v-else-if="isInvite"
:submitters="inviteSubmitters"
:optional-submitters="optionalInviteSubmitters"
:fetch-options="fetchOptions"
:submitter-slug="submitterSlug"
:authenticity-token="authenticityToken"
:url="baseUrl + submitPath + '/invite'"
@@ -524,6 +546,7 @@
:has-signature-fields="stepFields.some((fields) => fields.some((f) => ['signature', 'initials'].includes(f.type)))"
:has-multiple-documents="hasMultipleDocuments"
:completed-button="completedRedirectUrl ? {} : completedButton"
:fetch-options="fetchOptions"
:completed-message="completedRedirectUrl ? {} : completedMessage"
:with-send-copy-button="withSendCopyButton && !completedRedirectUrl"
:with-download-button="withDownloadButton && !completedRedirectUrl && !dryRun"
@@ -533,17 +556,21 @@
/>
<div
v-if="stepFields.length < 80"
class="flex justify-center mt-3 sm:mt-4 mb-0 sm:mb-1"
class="flex justify-center mt-3 sm:mt-4 mb-0 sm:mb-1 select-none"
>
<div class="flex items-center flex-wrap steps-progress">
<a
<template
v-for="(step, index) in stepFields"
:key="step[0].uuid"
href="#"
class="inline border border-base-300 h-3 w-3 rounded-full mx-1 mt-1"
:class="{ 'bg-base-300 steps-progress-current': index === currentStep, 'bg-base-content': (index < currentStep && stepFields[index].every((f) => !f.required || ![null, undefined, ''].includes(values[f.uuid]))) || isCompleted, 'bg-white': index > currentStep }"
@click.prevent="isCompleted ? '' : [saveStep(), goToStep(index, true)]"
/>
>
<a
v-if="!onlyRequiredFields || step.some((f) => f.required)"
href="#"
class="inline border border-base-300 h-3 w-3 rounded-full mx-1 mt-1"
:class="{ 'bg-base-300 steps-progress-current': index === currentStep, 'bg-base-content': (index < currentStep && stepFields[index].every((f) => !f.required || ![null, undefined, ''].includes(values[f.uuid]))) || isCompleted, 'bg-white': index > currentStep }"
@click.prevent="isCompleted ? '' : [saveStep(), goToStep(index, true)]"
/>
</template>
</div>
</div>
<div
@@ -565,6 +592,7 @@ import MultiSelectStep from './multi_select_step'
import PhoneStep from './phone_step'
import PaymentStep from './payment_step'
import VerificationStep from './verification_step'
import KbaStep from './kba_step'
import TextStep from './text_step'
import NumberStep from './number_step'
import DateStep from './date_step'
@@ -576,6 +604,18 @@ import AppearsOn from './appears_on'
import i18n from './i18n'
import { sanitizeUrl } from '@braintree/sanitize-url'
if (typeof URL.canParse !== 'function') {
URL.canParse = function (url, base) {
try {
const parsed = new URL(url, base)
return !!parsed
} catch {
return false
}
}
}
const isEmpty = (obj) => {
if (obj == null) return true
@@ -609,6 +649,7 @@ export default {
AttachmentStep,
InitialsStep,
VerificationStep,
KbaStep,
InviteForm,
MultiSelectStep,
IconInnerShadowTop,
@@ -641,6 +682,11 @@ export default {
required: false,
default: () => []
},
fetchOptions: {
type: Object,
required: false,
default: () => ({})
},
optionalInviteSubmitters: {
type: Array,
required: false,
@@ -661,6 +707,11 @@ export default {
required: false,
default: false
},
onlyRequiredFields: {
type: Boolean,
required: false,
default: false
},
requireSigningReason: {
type: Boolean,
required: false,
@@ -718,6 +769,11 @@ export default {
required: false,
default: false
},
validate: {
type: Boolean,
required: false,
default: true
},
withDisclosure: {
type: Boolean,
required: false,
@@ -728,6 +784,11 @@ export default {
required: false,
default: true
},
withFieldLabels: {
type: Boolean,
required: false,
default: true
},
withConfetti: {
type: Boolean,
required: false,
@@ -773,6 +834,16 @@ export default {
required: false,
default: ''
},
signatureText: {
type: String,
required: false,
default: ''
},
signatureSrc: {
type: String,
required: false,
default: ''
},
previousSignatureValue: {
type: String,
required: false,
@@ -887,10 +958,12 @@ export default {
}, {})
},
attachmentConditionsIndex () {
const cache = {}
return this.schema.reduce((acc, item) => {
if (item.conditions?.length) {
if (item.conditions.every((c) => this.fieldsUuidIndex[c.field_uuid])) {
acc[item.attachment_uuid] = this.checkFieldConditions(item)
acc[item.attachment_uuid] = this.checkFieldConditions(item, cache)
} else {
acc[item.attachment_uuid] = true
}
@@ -911,7 +984,13 @@ export default {
submitButtonText () {
if (this.alwaysMinimize) {
return this.t('submit')
} else if (this.stepFields.length === this.currentStep + 1) {
} else if (!this.onlyRequiredFields && this.stepFields.length === this.currentStep + 1) {
if (this.currentField.type === 'signature') {
return this.t('sign_and_complete')
} else {
return this.t('complete')
}
} else if (this.onlyRequiredFields && !this.findNextStep(this.currentStep)) {
if (this.currentField.type === 'signature') {
return this.t('sign_and_complete')
} else {
@@ -957,7 +1036,9 @@ export default {
},
previousInitialsValue () {
if (this.reuseSignature !== false) {
const initialsField = [...this.fields].reverse().find((field) => field.type === 'initials' && !!this.values[field.uuid])
const initialsField = this.fields.findLast
? this.fields.findLast((field) => field.type === 'initials' && !!this.values[field.uuid])
: [...this.fields].reverse().find((field) => field.type === 'initials' && !!this.values[field.uuid])
return this.values[initialsField?.uuid]
} else {
@@ -984,13 +1065,15 @@ export default {
return this.readonlyFields.filter((f) => f.conditions?.length)
},
readonlyFields () {
return this.fields.filter((f) => f.readonly && this.checkFieldConditions(f) && this.checkFieldDocumentsConditions(f))
const cache = {}
return this.fields.filter((f) => f.readonly && this.checkFieldConditions(f, cache) && this.checkFieldDocumentsConditions(f))
},
stepFields () {
const verificationFields = []
const sortedFields = this.fields.reduce((acc, f) => {
if (f.type === 'verification') {
if (f.type === 'verification' || f.type === 'kba') {
verificationFields.push(f)
} else if (!f.readonly) {
acc.push(f)
@@ -1039,10 +1122,12 @@ export default {
sortedFields.push(verificationFields.pop())
}
const cache = {}
return sortedFields.reduce((acc, f) => {
const prevStep = acc[acc.length - 1]
if (this.checkFieldConditions(f) && this.checkFieldDocumentsConditions(f)) {
if (this.checkFieldConditions(f, cache) && this.checkFieldDocumentsConditions(f)) {
if (f.type === 'checkbox' && Array.isArray(prevStep) && prevStep[0].type === 'checkbox' && !f.description) {
prevStep.push(f)
} else {
@@ -1054,7 +1139,9 @@ export default {
}, [])
},
formulaFields () {
return this.fields.filter((f) => f.preferences?.formula && f.type !== 'payment' && this.checkFieldConditions(f) && this.checkFieldDocumentsConditions(f))
const cache = {}
return this.fields.filter((f) => f.preferences?.formula && f.type !== 'payment' && this.checkFieldConditions(f, cache) && this.checkFieldDocumentsConditions(f))
},
attachmentsIndex () {
return this.attachments.reduce((acc, a) => {
@@ -1116,15 +1203,17 @@ export default {
this.currentStep = Math.max(stepIndex, 0)
} else if (this.goToLast) {
const requiredEmptyStepIndex = this.stepFields.indexOf(this.stepFields.find((fields) => fields.some((f) => f.required && !this.submittedValues[f.uuid])))
const lastFilledStepIndex = this.stepFields.indexOf([...this.stepFields].reverse().find((fields) => fields.some((f) => !!this.submittedValues[f.uuid]))) + 1
const lastFilledStepIndex = this.stepFields.indexOf(this.stepFields.findLast
? this.stepFields.findLast((fields) => fields.some((f) => !!this.submittedValues[f.uuid]))
: [...this.stepFields].reverse().find((fields) => fields.some((f) => !!this.submittedValues[f.uuid]))) + 1
const indexesList = [this.stepFields.length - 1]
if (requiredEmptyStepIndex !== -1) {
if (requiredEmptyStepIndex !== -1 && (!this.onlyRequiredFields || this.stepFields[requiredEmptyStepIndex].some((f) => f.required))) {
indexesList.push(requiredEmptyStepIndex)
}
if (lastFilledStepIndex !== -1) {
if (lastFilledStepIndex !== -1 && (!this.onlyRequiredFields || this.stepFields[lastFilledStepIndex].some((f) => f.required))) {
indexesList.push(lastFilledStepIndex)
}
@@ -1184,27 +1273,35 @@ export default {
return true
}
},
checkFieldConditions (field) {
checkFieldConditions (field, cache = {}) {
const cacheKey = field.uuid || field.attachment_uuid
if (cache[cacheKey] !== undefined) {
return cache[cacheKey]
}
if (field.conditions?.length) {
const result = field.conditions.reduce((acc, cond) => {
if (cond.operation === 'or') {
acc.push(acc.pop() || this.checkFieldCondition(cond))
acc.push(acc.pop() || this.checkFieldCondition(cond, cache))
} else {
acc.push(this.checkFieldCondition(cond))
acc.push(this.checkFieldCondition(cond, cache))
}
return acc
}, [])
return !result.includes(false)
cache[cacheKey] = !result.includes(false)
} else {
return true
cache[cacheKey] = true
}
return cache[cacheKey]
},
checkFieldCondition (condition) {
checkFieldCondition (condition, cache = {}) {
const field = this.fieldsUuidIndex[condition.field_uuid]
if (['not_empty', 'checked', 'equal', 'contains'].includes(condition.action) && field && !this.checkFieldConditions(field)) {
if (['not_empty', 'checked', 'equal', 'contains', 'greater_than', 'less_than'].includes(condition.action) && field && !this.checkFieldConditions(field, cache)) {
return false
}
@@ -1214,20 +1311,50 @@ export default {
return isEmpty(this.values[condition.field_uuid] ?? defaultValue)
} else if (['not_empty', 'checked'].includes(condition.action)) {
return !isEmpty(this.values[condition.field_uuid] ?? defaultValue)
} else if (field?.type === 'number' && ['equal', 'not_equal', 'greater_than', 'less_than'].includes(condition.action)) {
const value = this.values[condition.field_uuid] ?? defaultValue
if (isEmpty(value) || isEmpty(condition.value)) return false
const actual = parseFloat(value)
const expected = parseFloat(condition.value)
if (Number.isNaN(actual) || Number.isNaN(expected)) return false
if (condition.action === 'equal') return Math.abs(actual - expected) < Number.EPSILON
if (condition.action === 'not_equal') return Math.abs(actual - expected) > Number.EPSILON
if (condition.action === 'greater_than') return actual > expected
if (condition.action === 'less_than') return actual < expected
return false
} else if (['equal', 'contains'].includes(condition.action) && field) {
if (field.options) {
const option = field.options.find((o) => o.uuid === condition.value)
const values = [this.values[condition.field_uuid] ?? defaultValue].flat()
return values.includes(this.optionValue(option, field.options.indexOf(option)))
if (option) {
const values = [this.values[condition.field_uuid] ?? defaultValue].flat()
return values.includes(this.optionValue(option, field.options.indexOf(option)))
} else {
return false
}
} else {
return [this.values[condition.field_uuid] ?? defaultValue].flat().includes(condition.value)
}
} else if (['not_equal', 'does_not_contain'].includes(condition.action) && field) {
const option = field.options.find((o) => o.uuid === condition.value)
const values = [this.values[condition.field_uuid] ?? defaultValue].flat()
if (field.options) {
const option = field.options.find((o) => o.uuid === condition.value)
return !values.includes(this.optionValue(option, field.options.indexOf(option)))
if (option) {
const values = [this.values[condition.field_uuid] ?? defaultValue].flat()
return !values.includes(this.optionValue(option, field.options.indexOf(option)))
} else {
return false
}
} else {
return false
}
} else {
return true
}
@@ -1239,6 +1366,13 @@ export default {
return `${this.t('option')} ${index + 1}`
}
},
findNextStep (currentStepIndex) {
if (this.onlyRequiredFields) {
return this.stepFields.find((step, index) => index > currentStepIndex && step.some((f) => f.required))
} else {
return this.stepFields[currentStepIndex + 1]
}
},
maybeTrackEmailClick () {
const { queryParams } = this
@@ -1300,9 +1434,9 @@ export default {
},
previousSignatureValueFor (field) {
if (this.reuseSignature !== false) {
const signatureField = [...this.fields].reverse().find((f) =>
f.type === 'signature' && field.preferences?.format === f.preferences?.format && !!this.values[f.uuid]
)
const signatureField = this.fields.findLast
? this.fields.findLast((f) => f.type === 'signature' && field.preferences?.format === f.preferences?.format && !!this.values[f.uuid])
: [...this.fields].reverse().find((f) => f.type === 'signature' && field.preferences?.format === f.preferences?.format && !!this.values[f.uuid])
return this.values[signatureField?.uuid]
} else {
@@ -1352,7 +1486,8 @@ export default {
} else {
return fetch(this.baseUrl + this.submitPath, {
method: 'POST',
body: formData || new FormData(this.$refs.form)
body: formData || new FormData(this.$refs.form),
...this.fetchOptions
}).then((response) => {
if (response.status === 200) {
currentFieldUuids.forEach((fieldUuid) => {
@@ -1385,15 +1520,15 @@ export default {
this.isSubmittingComplete = true
}
const submitStep = this.currentStep
const submitStepIndex = this.currentStep
const stepPromise = ['signature', 'phone', 'initials', 'payment', 'verification'].includes(this.currentField.type)
const stepPromise = ['signature', 'phone', 'initials', 'payment', 'verification', 'kba'].includes(this.currentField.type)
? this.$refs.currentStep.submit
: () => Promise.resolve({})
stepPromise().then(async () => {
const emptyRequiredField = this.stepFields.find((fields, index) => {
if (forceComplete ? index === submitStep : index >= submitStep) {
if (forceComplete ? index === submitStepIndex : index >= submitStepIndex) {
return false
}
@@ -1403,7 +1538,7 @@ export default {
})
const formData = new FormData(this.$refs.form)
const isLastStep = (submitStep === this.stepFields.length - 1) || forceComplete
const isLastStep = (this.onlyRequiredFields ? !this.findNextStep(submitStepIndex) : (submitStepIndex === this.stepFields.length - 1)) || forceComplete
if (isLastStep && !emptyRequiredField && !this.inviteSubmitters.length && !this.optionalInviteSubmitters.length) {
formData.append('completed', 'true')
@@ -1447,7 +1582,7 @@ export default {
return Promise.reject(new Error(data.error))
}
const nextStep = (isLastStep && emptyRequiredField) || (forceComplete ? null : this.stepFields[submitStep + 1])
const nextStep = (isLastStep && emptyRequiredField) || (forceComplete ? null : this.findNextStep(submitStepIndex))
if (nextStep) {
if (this.alwaysMinimize) {
@@ -68,34 +68,9 @@ export default {
}
},
async mounted () {
const {
create,
evaluateDependencies,
addDependencies,
subtractDependencies,
divideDependencies,
multiplyDependencies,
powDependencies,
roundDependencies,
absDependencies,
sinDependencies,
tanDependencies,
cosDependencies
} = await import('mathjs')
const { Calculator } = await import('./calculator')
this.math = create({
evaluateDependencies,
addDependencies,
subtractDependencies,
divideDependencies,
multiplyDependencies,
powDependencies,
roundDependencies,
absDependencies,
sinDependencies,
tanDependencies,
cosDependencies
})
this.math = new Calculator()
this.isMathLoaded = true
},
+43 -15
View File
@@ -1,6 +1,7 @@
const en = {
kba: 'KBA',
please_upload_an_image_file: 'Please upload an image file',
must_be_characters_length: 'Must be {number} characters length',
must_be_characters_length: 'Must be {number} characters long',
complete_all_required_fields_to_proceed_with_identity_verification: 'Complete all required fields to proceed with identity verification.',
verify_id: 'Verify ID',
identity_verification: 'Identity verification',
@@ -13,7 +14,7 @@ const en = {
esignature_disclosure: 'eSignature Disclosure',
signature: 'Signature',
initials: 'Initials',
drawn_signature_on_a_touchscreen_device: 'Drawn signature on a touchscreen device',
sign_on_the_touchscreen: 'Sign on the touchscreen',
approved: 'Approved',
reviewed: 'Reviewed',
other: 'Other',
@@ -96,10 +97,12 @@ const en = {
upload: 'Upload',
files: 'Files',
signature_is_too_small_or_simple_please_redraw: 'Signature is too small or simple. Please redraw.',
browser_privacy_settings_block_canvas: 'Your browser privacy settings restrict use of the drawing canvas. Please use a different browser or device, or disable privacy settings that block canvas in order to sign.',
wait_countdown_seconds: 'Wait {countdown} seconds'
}
const es = {
kba: 'KBA',
please_upload_an_image_file: 'Por favor, sube un archivo de imagen',
must_be_characters_length: 'Debe tener {number} caracteres de longitud',
complete_all_required_fields_to_proceed_with_identity_verification: 'Complete todos los campos requeridos para continuar con la verificación de identidad.',
@@ -120,7 +123,7 @@ const es = {
select_a_reason: 'Selecciona una razón',
value_is_invalid: 'El valor no es válido',
verification_code_is_invalid: 'El código de verificación no es válido',
drawn_signature_on_a_touchscreen_device: 'Firma dibujada en un dispositivo con pantalla táctil',
sign_on_the_touchscreen: 'Firmar en pantalla táctil',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Escanea el código QR con la aplicación de la cámara para abrir el formulario en el móvil y dibujar tu firma',
by_clicking_you_agree_to_the: 'Al hacer clic en "{button}", usted acepta el',
electronic_signature_disclosure: 'Divulgación de Firma Electrónica',
@@ -197,10 +200,12 @@ const es = {
upload: 'Subir',
files: 'Archivos',
signature_is_too_small_or_simple_please_redraw: 'La firma es demasiado pequeña o simple. Por favor, vuelve a dibujarla.',
browser_privacy_settings_block_canvas: 'La configuración de privacidad de su navegador restringe el uso del lienzo de dibujo. Utilice un navegador o dispositivo diferente, o desactive la configuración de privacidad que bloquea el lienzo para firmar.',
wait_countdown_seconds: 'Espera {countdown} segundos'
}
const it = {
kba: 'KBA',
please_upload_an_image_file: 'Per favore carica un file immagine',
must_be_characters_length: 'Deve essere lungo {number} caratteri',
complete_all_required_fields_to_proceed_with_identity_verification: "Compila tutti i campi obbligatori per procedere con la verifica dell'identità.",
@@ -221,7 +226,7 @@ const it = {
select_a_reason: 'Seleziona una ragione',
value_is_invalid: 'Il valore non è valido',
verification_code_is_invalid: 'Il codice di verifica non è valido',
drawn_signature_on_a_touchscreen_device: 'Firma disegnata su un dispositivo con schermo tattile',
sign_on_the_touchscreen: 'Firma su schermo tattile',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: "Scansiona il codice QR con l'app della fotocamera per aprire il modulo sul cellulare e disegnare la tua firma",
by_clicking_you_agree_to_the: 'Cliccando su "{button}", accetti il',
electronic_signature_disclosure: 'Divulgazione della Firma Elettronica',
@@ -298,10 +303,12 @@ const it = {
upload: 'Carica',
files: 'File',
signature_is_too_small_or_simple_please_redraw: 'La firma è troppo piccola o semplice. Ridisegnala, per favore.',
browser_privacy_settings_block_canvas: 'Le impostazioni sulla privacy del browser limitano l\'uso dell\'area di disegno. Utilizza un browser o dispositivo diverso oppure disattiva le impostazioni sulla privacy che bloccano il canvas per firmare.',
wait_countdown_seconds: 'Attendi {countdown} secondi'
}
const de = {
kba: 'KBA',
please_upload_an_image_file: 'Bitte laden Sie eine Bilddatei hoch',
must_be_characters_length: 'Muss {number} Zeichen lang sein',
complete_all_required_fields_to_proceed_with_identity_verification: 'Füllen Sie alle Pflichtfelder aus, um mit der Identitätsprüfung fortzufahren.',
@@ -316,7 +323,7 @@ const de = {
esignature_disclosure: 'Hinweis zur eSignatur',
signature: 'Unterschrift',
initials: 'Initialen',
drawn_signature_on_a_touchscreen_device: 'Auf einem Touchscreen-Gerät gezeichnete Unterschrift',
sign_on_the_touchscreen: 'Auf Touchscreen signieren',
approved: 'Genehmigt',
reviewed: 'Geprüft',
other: 'Sonstiges',
@@ -399,10 +406,12 @@ const de = {
upload: 'Hochladen',
files: 'Dateien',
signature_is_too_small_or_simple_please_redraw: 'Die Unterschrift ist zu klein oder zu einfach. Bitte neu zeichnen.',
browser_privacy_settings_block_canvas: 'Die Datenschutzeinstellungen Ihres Browsers schränken die Nutzung der Zeichenfläche ein. Bitte verwenden Sie einen anderen Browser oder ein anderes Gerät oder deaktivieren Sie die Datenschutzeinstellungen, die Canvas blockieren, um zu unterschreiben.',
wait_countdown_seconds: 'Bitte {countdown} Sekunden warten'
}
const fr = {
kba: 'KBA',
please_upload_an_image_file: 'Veuillez téléverser un fichier image',
must_be_characters_length: 'Doit comporter {number} caractères',
complete_all_required_fields_to_proceed_with_identity_verification: "Veuillez remplir tous les champs obligatoires pour poursuivre la vérification d'identité.",
@@ -417,7 +426,7 @@ const fr = {
esignature_disclosure: 'Déclaration eSignature',
signature: 'Signature',
initials: 'Initiales',
drawn_signature_on_a_touchscreen_device: 'Signature dessinée sur un appareil à écran tactile',
sign_on_the_touchscreen: 'Signer sur écran tactile',
approved: 'Approuvé',
reviewed: 'Révisé',
other: 'Autre',
@@ -500,10 +509,12 @@ const fr = {
upload: 'Téléverser',
files: 'Fichiers',
signature_is_too_small_or_simple_please_redraw: 'La signature est trop petite ou trop simple. Veuillez la redessiner.',
browser_privacy_settings_block_canvas: 'Les paramètres de confidentialité de votre navigateur empêchent l\'utilisation du canevas de dessin. Veuillez utiliser un autre navigateur ou appareil, ou désactiver les paramètres de confidentialité qui bloquent le canevas pour signer.',
wait_countdown_seconds: 'Veuillez patienter {countdown} secondes'
}
const pl = {
kba: 'KBA',
please_upload_an_image_file: 'Proszę przesłać plik obrazu',
must_be_characters_length: 'Musi mieć długość {number} znaków',
complete_all_required_fields_to_proceed_with_identity_verification: 'Uzupełnij wszystkie wymagane pola, aby kontynuować weryfikację tożsamości.',
@@ -524,7 +535,7 @@ const pl = {
select_a_reason: 'Wybierz powód',
value_is_invalid: 'Wartość jest nieprawidłowa',
verification_code_is_invalid: 'Kod weryfikacyjny jest nieprawidłowy',
drawn_signature_on_a_touchscreen_device: 'Podpis odręczny na urządzeniu z ekranem dotykowym',
sign_on_the_touchscreen: 'Podpisz na ekranie dotykowym',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Zeskanuj kod QR za pomocą aplikacji aparatu, aby otworzyć formularz na telefonie i narysować swój podpis',
by_clicking_you_agree_to_the: 'Klikając na "{button}", zgadzasz się na',
electronic_signature_disclosure: 'Ujawnienie Elektronicznej Sygnatury',
@@ -601,10 +612,12 @@ const pl = {
upload: 'Przesyłanie',
files: 'Pliki',
signature_is_too_small_or_simple_please_redraw: 'Podpis jest zbyt mały lub zbyt prosty. Proszę narysować go ponownie.',
browser_privacy_settings_block_canvas: 'Ustawienia prywatności przeglądarki blokują użycie obszaru rysowania. Użyj innej przeglądarki lub urządzenia albo wyłącz ustawienia prywatności blokujące canvas, aby podpisać.',
wait_countdown_seconds: 'Poczekaj {countdown} sekund'
}
const uk = {
kba: 'KBA',
please_upload_an_image_file: 'Будь ласка, завантажте файл зображення',
must_be_characters_length: 'Має містити {number} символів',
complete_all_required_fields_to_proceed_with_identity_verification: "Заповніть всі обов'язкові поля, щоб продовжити перевірку особи.",
@@ -625,7 +638,7 @@ const uk = {
select_a_reason: 'Виберіть причину',
value_is_invalid: 'Значення є неправильним',
verification_code_is_invalid: 'Код підтвердження є неправильним',
drawn_signature_on_a_touchscreen_device: 'Підпис на сенсорному пристрої',
sign_on_the_touchscreen: 'Підписати на сенсорному екрані',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Скануйте QR-код за допомогою програми камери, щоб відкрити форму на мобільному пристрої та намалювати свій підпис',
by_clicking_you_agree_to_the: 'Натиснувши на "{button}", ви погоджуєтеся з',
electronic_signature_disclosure: 'Розголошення Електронного Підпису',
@@ -702,10 +715,12 @@ const uk = {
upload: 'Завантажити',
files: 'Файли',
signature_is_too_small_or_simple_please_redraw: 'Підпис занадто маленький або надто простий. Будь ласка, перемалюйте.',
browser_privacy_settings_block_canvas: 'Налаштування конфіденційності вашого браузера блокують використання полотна для малювання. Будь ласка, скористайтеся іншим браузером або пристроєм, або вимкніть налаштування конфіденційності, що блокують canvas, щоб підписати.',
wait_countdown_seconds: 'Зачекайте {countdown} секунд'
}
const cs = {
kba: 'KBA',
please_upload_an_image_file: 'Nahrajte prosím obrázkový soubor',
must_be_characters_length: 'Musí mít délku {number} znaků',
complete_all_required_fields_to_proceed_with_identity_verification: 'Vyplňte všechna povinná pole, abyste mohli pokračovat v ověření identity.',
@@ -726,7 +741,7 @@ const cs = {
select_a_reason: 'Vyberte důvod',
value_is_invalid: 'Hodnota je neplatná',
verification_code_is_invalid: 'Ověřovací kód je neplatný',
drawn_signature_on_a_touchscreen_device: 'Namalovaný podpis na dotykovém zařízení',
sign_on_the_touchscreen: 'Podepsat na dotykové obrazovce',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Naskenujte QR kód pomocí aplikace fotoaparátu, abyste otevřeli formulář na mobilním zařízení a nakreslili svůj podpis',
by_clicking_you_agree_to_the: 'Kliknutím na "{button}" souhlasíte s',
electronic_signature_disclosure: 'Zveřejněním Elektronického Podpisu',
@@ -803,10 +818,12 @@ const cs = {
upload: 'Nahrát',
files: 'Soubory',
signature_is_too_small_or_simple_please_redraw: 'Podpis je příliš malý nebo jednoduchý. Nakreslete jej prosím znovu.',
browser_privacy_settings_block_canvas: 'Nastavení soukromí vašeho prohlížeče omezuje použití kreslicího plátna. Použijte prosím jiný prohlížeč nebo zařízení, nebo vypněte nastavení soukromí blokující canvas pro podepsání.',
wait_countdown_seconds: 'Počkejte {countdown} sekund'
}
const pt = {
kba: 'KBA',
please_upload_an_image_file: 'Por favor, envie um arquivo de imagem',
must_be_characters_length: 'Deve ter {number} caracteres',
complete_all_required_fields_to_proceed_with_identity_verification: 'Preencha todos os campos obrigatórios para prosseguir com a verificação de identidade.',
@@ -827,7 +844,7 @@ const pt = {
select_a_reason: 'Selecione um motivo',
value_is_invalid: 'Valor é inválido',
verification_code_is_invalid: 'Código de verificação é inválido',
drawn_signature_on_a_touchscreen_device: 'Assinatura desenhada em um dispositivo com tela sensível ao toque',
sign_on_the_touchscreen: 'Assinar na tela sensível',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Escaneie o código QR com o aplicativo da câmera para abrir o formulário no celular e desenhar sua assinatura',
by_clicking_you_agree_to_the: 'Ao clicar em "{button}", você concorda com o',
electronic_signature_disclosure: 'Divulgação de Assinatura Eletrônica',
@@ -904,10 +921,12 @@ const pt = {
upload: 'Carregar',
files: 'Arquivos',
signature_is_too_small_or_simple_please_redraw: 'A assinatura é muito pequena ou simples. Por favor, redesenhe.',
browser_privacy_settings_block_canvas: 'As configurações de privacidade do seu navegador restringem o uso da área de desenho. Use um navegador ou dispositivo diferente, ou desative as configurações de privacidade que bloqueiam o canvas para assinar.',
wait_countdown_seconds: 'Aguarde {countdown} segundos'
}
const he = {
kba: 'KBA',
please_upload_an_image_file: 'אנא העלה קובץ תמונה',
must_be_characters_length: 'חייב להיות באורך של {number} תווים',
complete_all_required_fields_to_proceed_with_identity_verification: 'מלא את כל השדות הנדרשים כדי להמשיך עם אימות זהות.',
@@ -928,7 +947,7 @@ const he = {
select_a_reason: 'בחר סיבה',
value_is_invalid: 'ערך לא תקין',
verification_code_is_invalid: 'קוד האימות אינו תקין',
drawn_signature_on_a_touchscreen_device: 'חתימה שנוצרה במכשיר עם מסך מגע',
sign_on_the_touchscreen: 'חתום על מסך המגע',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'סרוק את קוד ה-QR באמצעות אפליקציית המצלמה כדי לפתוח את הטופס במובייל ולצייר את החתימה שלך',
by_clicking_you_agree_to_the: 'על ידי לחיצה על "{button}", אתה מסכים ל',
electronic_signature_disclosure: 'חשיפת חתימה אלקטרונית',
@@ -1005,10 +1024,12 @@ const he = {
upload: 'העלאה',
files: 'קבצים',
signature_is_too_small_or_simple_please_redraw: 'החתימה קטנה או פשוטה מדי. אנא חתום מחדש.',
browser_privacy_settings_block_canvas: 'הגדרות הפרטיות של הדפדפן שלך מגבילות את השימוש באזור הציור. אנא השתמש בדפדפן או מכשיר אחר, או בטל את הגדרות הפרטיות החוסמות canvas כדי לחתום.',
wait_countdown_seconds: 'המתן {countdown} שניות'
}
const nl = {
kba: 'KBA',
please_upload_an_image_file: 'Upload alstublieft een afbeeldingsbestand',
must_be_characters_length: 'Moet {number} tekens lang zijn',
complete_all_required_fields_to_proceed_with_identity_verification: 'Vul alle verplichte velden in om door te gaan met de identiteitsverificatie.',
@@ -1029,7 +1050,7 @@ const nl = {
select_a_reason: 'Selecteer een reden',
value_is_invalid: 'Waarde is ongeldig',
verification_code_is_invalid: 'Verificatiecode is ongeldig',
drawn_signature_on_a_touchscreen_device: 'Getekende handtekening op een apparaat met een touchscreen',
sign_on_the_touchscreen: 'Onderteken op touchscreen',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Scan de QR-code met de camera-app om het formulier op mobiel te openen en uw handtekening te tekenen',
by_clicking_you_agree_to_the: 'Door op "{button}" te klikken, gaat u akkoord met de',
electronic_signature_disclosure: 'Openbaarmaking van Elektronische Handtekening',
@@ -1106,10 +1127,12 @@ const nl = {
upload: 'Uploaden',
files: 'Bestanden',
signature_is_too_small_or_simple_please_redraw: 'De handtekening is te klein of te eenvoudig. Teken opnieuw.',
browser_privacy_settings_block_canvas: 'De privacyinstellingen van uw browser beperken het gebruik van het tekenveld. Gebruik een andere browser of ander apparaat, of schakel de privacyinstellingen uit die canvas blokkeren om te ondertekenen.',
wait_countdown_seconds: 'Wacht {countdown} seconden'
}
const ar = {
kba: 'KBA',
please_upload_an_image_file: 'يرجى تحميل ملف صورة',
must_be_characters_length: 'يجب أن يكون الطول {number} حرفًا',
complete_all_required_fields_to_proceed_with_identity_verification: 'أكمل جميع الحقول المطلوبة للمتابعة في التحقق من الهوية.',
@@ -1131,7 +1154,7 @@ const ar = {
value_is_invalid: 'القيمة غير صالحة',
verification_code_is_invalid: 'رمز التحقق غير صالح',
already_paid: 'تم الدفع بالفعل',
drawn_signature_on_a_touchscreen_device: 'توقيع مرسوم على جهاز بشاشة تعمل باللمس',
sign_on_the_touchscreen: 'وقع على شاشة اللمس',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'امسح رمز الاستجابة السريعة باستخدام تطبيق الكاميرا لفتح النموذج على الهاتف المحمول ورسم توقيعك',
by_clicking_you_agree_to_the: 'بالنقر فوق "{button}"، أنت توافق على',
electronic_signature_disclosure: 'كشف التوقيع الإلكتروني',
@@ -1207,10 +1230,12 @@ const ar = {
upload: 'تحميل',
files: 'الملفات',
signature_is_too_small_or_simple_please_redraw: 'التوقيع صغير جدًا أو بسيط جدًا. يرجى إعادة رسمه.',
browser_privacy_settings_block_canvas: 'إعدادات الخصوصية في متصفحك تمنع استخدام لوحة الرسم. يرجى استخدام متصفح أو جهاز مختلف، أو تعطيل إعدادات الخصوصية التي تحظر canvas للتوقيع.',
wait_countdown_seconds: 'انتظر {countdown} ثانية'
}
const ko = {
kba: 'KBA',
please_upload_an_image_file: '이미지 파일을 업로드해 주세요',
must_be_characters_length: '{number}자여야 합니다',
complete_all_required_fields_to_proceed_with_identity_verification: '신원 확인을 진행하려면 모든 필수 필드를 작성하십시오.',
@@ -1229,7 +1254,7 @@ const ko = {
reviewed_by: '검토자',
authored_by: '작성자',
select_a_reason: '이유 선택',
drawn_signature_on_a_touchscreen_device: '터치스크린 장치에서 그린 서명',
sign_on_the_touchscreen: '터치스크린에서 서명',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: '카메라 앱으로 QR 코드를 스캔하여 모바일에서 양식을 열고 서명을 그리세요',
by_clicking_you_agree_to_the: '"{button}"를 클릭함으로써, 다음에 동의하게 됩니다',
electronic_signature_disclosure: '전자 서명 공개',
@@ -1308,10 +1333,12 @@ const ko = {
upload: '업로드',
files: '파일',
signature_is_too_small_or_simple_please_redraw: '서명이 너무 작거나 단순합니다. 다시 그려주세요.',
browser_privacy_settings_block_canvas: '브라우저 개인정보 보호 설정으로 인해 그리기 캔버스를 사용할 수 없습니다. 다른 브라우저나 기기를 사용하거나, 서명을 위해 캔버스를 차단하는 개인정보 보호 설정을 비활성화해 주세요.',
wait_countdown_seconds: '{countdown}초 기다리세요'
}
const ja = {
kba: 'KBA',
please_upload_an_image_file: '画像ファイルをアップロードしてください',
must_be_characters_length: '{number}文字でなければなりません',
complete_all_required_fields_to_proceed_with_identity_verification: '本人確認を進めるには、すべての必須項目を入力してください。',
@@ -1326,7 +1353,7 @@ const ja = {
esignature_disclosure: '電子署名開示',
signature: '署名',
initials: 'イニシャル',
drawn_signature_on_a_touchscreen_device: 'タッチスクリーンデバイスで描かれた署名',
sign_on_the_touchscreen: 'タッチスクリーン署名',
approved: '承認済み',
reviewed: '確認済み',
other: 'その他',
@@ -1409,6 +1436,7 @@ const ja = {
upload: 'アップロード',
files: 'ファイル',
signature_is_too_small_or_simple_please_redraw: '署名が小さすぎるか単純すぎます。もう一度描いてください。',
browser_privacy_settings_block_canvas: 'ブラウザのプライバシー設定により、描画キャンバスの使用が制限されています。別のブラウザまたはデバイスを使用するか、署名するためにキャンバスをブロックするプライバシー設定を無効にしてください。',
wait_countdown_seconds: '{countdown} 秒お待ちください'
}
@@ -19,7 +19,7 @@
<div class="space-x-2 flex flex-none">
<span
v-if="isDrawInitials"
class="tooltip"
class="md:tooltip"
:data-tip="t('type_initial')"
>
<a
@@ -36,7 +36,7 @@
</span>
<span
v-else
class="tooltip ml-2"
class="md:tooltip ml-2"
:data-tip="t('draw_initials')"
>
<a
@@ -52,7 +52,7 @@
</a>
</span>
<span
class="tooltip"
class="md:tooltip"
:data-tip="t('click_to_upload')"
>
<label class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap upload-image-button">
@@ -123,6 +123,11 @@
v-if="!isDrawInitials"
class="absolute top-0 right-0 left-0 bottom-0"
/>
<label
v-if="!isDrawInitials && !modelValue && !computedPreviousValue"
for="initials_text_input"
class="absolute top-0 right-0 left-0 bottom-0"
/>
<canvas
v-show="!modelValue && !computedPreviousValue"
ref="canvas"
@@ -145,6 +150,7 @@
<script>
import { cropCanvasAndExportToPNG } from './crop_canvas'
import { isCanvasBlocked } from './validate_signature'
import { IconReload, IconTextSize, IconUpload, IconSignature, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
import SignaturePad from 'signature_pad'
import AppearsOn from './appears_on'
@@ -170,6 +176,10 @@ export default {
type: Object,
required: true
},
submitter: {
type: Object,
required: true
},
dryRun: {
type: Boolean,
required: false,
@@ -252,6 +262,14 @@ export default {
this.$refs.canvas.getContext('2d').scale(scale, scale)
if (!this.isDrawInitials) {
this.$nextTick(() => {
if (this.$refs.textInput) {
this.initTextInitial()
}
})
}
this.intersectionObserver?.disconnect()
}
})
@@ -327,12 +345,29 @@ export default {
if (!this.isDrawInitials) {
this.$nextTick(() => {
this.$refs.textInput.focus()
if (this.$refs.textInput) {
if (!this.submitter.name) {
this.$refs.textInput.focus()
}
this.$emit('start')
this.initTextInitial()
this.$emit('start')
}
})
}
},
initTextInitial () {
if (this.submitter.name) {
const parts = this.submitter.name.trim().split(/\s+/)
this.$refs.textInput.value = (parts.length > 1 ? [parts[0], parts[parts.length - 1]] : parts).map((part) => part[0]?.toUpperCase() || '').join('')
}
if (this.$refs.textInput.value) {
this.updateWrittenInitials({ target: this.$refs.textInput })
}
},
async submit () {
if (this.modelValue || this.computedPreviousValue) {
if (this.computedPreviousValue) {
@@ -387,7 +422,15 @@ export default {
}
}).catch((error) => {
if (this.field.required === true) {
alert(this.t('signature_is_too_small_or_simple_please_redraw'))
if (isCanvasBlocked()) {
alert(this.t('browser_privacy_settings_block_canvas'))
if (window.Rollbar) {
window.Rollbar.info('Canvas blocked')
}
} else {
alert(this.t('signature_is_too_small_or_simple_please_redraw'))
}
return reject(error)
} else {
@@ -78,6 +78,11 @@ export default {
type: Array,
required: true
},
fetchOptions: {
type: Object,
required: false,
default: () => ({})
},
optionalSubmitters: {
type: Array,
required: false,
@@ -108,7 +113,8 @@ export default {
return fetch(this.url, {
method: 'POST',
body: new FormData(this.$refs.form)
body: new FormData(this.$refs.form),
...this.fetchOptions
}).then((response) => {
if (response.status === 200) {
this.$emit('success')
+580
View File
@@ -0,0 +1,580 @@
<template>
<label
v-if="!error"
class="label text-xl sm:text-2xl py-0 mb-2 sm:mb-3.5 field-name-label"
>
<MarkdownContent
v-if="field.title"
:string="field.title"
/>
<template v-else>{{ field.name || 'Knowledge Based Authentication' }}</template>
<span
v-if="questions"
class="float-right font-normal text-neutral-500 mt-1 whitespace-nowrap text-right text-sm"
>
<span>Question {{ currentQuestionIndex + 1 }} / {{ questions.length }}</span>
<span
v-if="timeLeftSeconds !== null"
class="block"
>
Time left: {{ formattedTimeLeft }}
</span>
</span>
</label>
<div
v-if="field.description"
dir="auto"
class="mb-4 px-1 field-description-text"
>
<MarkdownContent :string="field.description" />
</div>
<div
v-if="isRequiredFieldEmpty"
class="px-1 field-description-text"
>
{{ t('complete_all_required_fields_to_proceed_with_identity_verification') }}
</div>
<div
v-if="error"
class="mb-4 text-center"
>
<div class="text-xl mb-4">
{{ error }}
</div>
<button
class="base-button w-full flex justify-center submit-form-button"
@click="restartKba"
>
{{ questions ? 'Restart KBA' : 'Retry' }}
</button>
</div>
<div
v-if="isLoading"
class="w-full flex space-x-2 justify-center mb-2"
>
<IconInnerShadowTop
width="40"
class="animate-spin h-10"
/>
</div>
<div v-else-if="questions && !error">
<form @submit.prevent="nextQuestion">
<div class="mb-6 px-1">
<p class="font-semibold mb-4 text-lg">
{{ currentQuestion.prompt }}
</p>
<div class="space-y-3.5 mx-auto">
<div
v-for="(answer, index) in currentQuestion.answers"
:key="`${currentQuestion.id}_${index}`"
>
<label
:for="`${currentQuestion.id}_${index}`"
class="flex items-center space-x-3 radio-label"
>
<input
:id="`${currentQuestion.id}_${index}`"
v-model="answers[currentQuestion.id]"
type="radio"
:name="currentQuestion.id"
:value="answer.text"
class="base-radio !h-7 !w-7"
required
>
<span class="text-xl">{{ answer.text }}</span>
</label>
</div>
</div>
</div>
<div class="mt-6">
<button
type="submit"
class="base-button w-full flex justify-center submit-form-button"
:disabled="isSubmitting || !answers[currentQuestion.id]"
>
<span class="flex">
<IconInnerShadowTop
v-if="isSubmitting"
class="mr-1 animate-spin"
/>
<span>
{{ isLastQuestion ? (isSubmitting ? t('submitting_') : t('complete')) : t('next') }}
</span><span
v-if="isSubmitting"
class="w-6 flex justify-start mr-1"
><span>...</span></span>
</span>
</button>
</div>
</form>
</div>
<div v-else-if="!error && !isRequiredFieldEmpty">
<form @submit.prevent="startKba">
<div class="grid grid-cols-6 gap-x-2 md:gap-x-4 md:gap-y-2 mb-4">
<div class="col-span-3">
<label
for="kba_fn"
class="label text-sm md:text-base"
>First Name</label>
<input
id="kba_fn"
v-model="form.fn"
type="text"
class="input input-bordered !h-10 w-full bg-white"
required
>
</div>
<div class="col-span-3">
<label
for="kba_ln"
class="label text-sm md:text-base"
>Last Name</label>
<input
id="kba_ln"
v-model="form.ln"
type="text"
class="input input-bordered !h-10 w-full bg-white"
required
>
</div>
<div class="col-span-6">
<label
for="kba_addr"
class="label text-sm md:text-base"
>Address</label>
<input
id="kba_addr"
v-model="form.addr"
type="text"
class="input input-bordered !h-10 w-full bg-white"
required
>
</div>
<div class="col-span-2">
<label
for="kba_city"
class="label text-sm md:text-base"
>City</label>
<input
id="kba_city"
v-model="form.city"
type="text"
class="input input-bordered !h-10 w-full bg-white"
required
>
</div>
<div class="col-span-2">
<label
for="kba_state"
class="label text-sm md:text-base"
>State</label>
<select
id="kba_state"
v-model="form.state"
class="select select-bordered !h-10 min-h-[2.5rem] w-full bg-white font-normal !text-base"
required
>
<option
value=""
disabled
>
Select State
</option>
<option
v-for="state in states"
:key="state.code"
:value="state.code"
>
{{ state.name }}
</option>
</select>
</div>
<div class="col-span-2">
<label
for="kba_zip"
class="label text-sm md:text-base"
>Zip</label>
<input
id="kba_zip"
v-model="form.zip"
type="text"
class="input input-bordered !h-10 w-full bg-white"
required
>
</div>
<div class="col-span-3">
<label
for="kba_phone"
class="label text-sm md:text-base"
>Phone</label>
<input
id="kba_phone"
v-model="form.phone"
type="text"
class="input input-bordered !h-10 w-full bg-white"
required
>
</div>
<div class="col-span-3">
<label
for="kba_email"
class="label text-sm md:text-base"
>Email</label>
<input
id="kba_email"
v-model="form.email"
type="email"
class="input input-bordered !h-10 w-full bg-white"
required
>
</div>
<div class="col-span-3">
<label
for="kba_dob"
class="label text-sm md:text-base"
>DOB</label>
<input
id="kba_dob"
v-model="form.dob"
type="date"
class="input input-bordered !h-10 md:w-full bg-white"
required
>
</div>
<div class="col-span-3">
<label
for="kba_ssn"
class="label text-sm md:text-base"
>SSN</label>
<input
id="kba_ssn"
v-model="form.ssn"
type="text"
class="input input-bordered !h-10 w-full bg-white"
required
>
</div>
</div>
<div class="mt-6">
<button
type="submit"
class="base-button w-full flex justify-center submit-form-button"
:disabled="isLoading"
>
<span class="flex">
<IconInnerShadowTop
v-if="isLoading"
class="mr-1 animate-spin"
/>
<span>
{{ isLoading ? 'Loading...' : 'Start Verification' }}
</span><span
v-if="isLoading"
class="w-6 flex justify-start mr-1"
><span>...</span></span>
</span>
</button>
</div>
</form>
</div>
</template>
<script>
import MarkdownContent from './markdown_content'
import { IconInnerShadowTop } from '@tabler/icons-vue'
export default {
name: 'KbaStep',
components: {
MarkdownContent,
IconInnerShadowTop
},
inject: ['baseUrl', 't'],
props: {
field: {
type: Object,
required: true
},
submitter: {
type: Object,
required: true
},
submitterSlug: {
type: String,
required: true
},
emptyValueRequiredStep: {
type: Object,
required: false,
default: null
},
values: {
type: Object,
required: true
}
},
emits: ['submit'],
data () {
return {
isLoading: false,
isSubmitting: false,
questions: null,
currentQuestionIndex: 0,
token: null,
reference: null,
answers: {},
error: null,
timeLeftSeconds: null,
countdownIntervalId: null,
form: {
fn: '',
ln: '',
addr: '',
city: '',
state: '',
zip: '',
dob: '',
ssn: '',
phone: '',
email: ''
}
}
},
computed: {
currentQuestion () {
return this.questions ? this.questions[this.currentQuestionIndex] : null
},
isRequiredFieldEmpty () {
return this.emptyValueRequiredStep && this.emptyValueRequiredStep[0] !== this.field
},
kbaTimeLimitSeconds () {
return 90
},
states () {
return [
{ code: 'AL', name: 'Alabama' },
{ code: 'AK', name: 'Alaska' },
{ code: 'AZ', name: 'Arizona' },
{ code: 'AR', name: 'Arkansas' },
{ code: 'CA', name: 'California' },
{ code: 'CO', name: 'Colorado' },
{ code: 'CT', name: 'Connecticut' },
{ code: 'DE', name: 'Delaware' },
{ code: 'DC', name: 'District of Columbia' },
{ code: 'FL', name: 'Florida' },
{ code: 'GA', name: 'Georgia' },
{ code: 'HI', name: 'Hawaii' },
{ code: 'ID', name: 'Idaho' },
{ code: 'IL', name: 'Illinois' },
{ code: 'IN', name: 'Indiana' },
{ code: 'IA', name: 'Iowa' },
{ code: 'KS', name: 'Kansas' },
{ code: 'KY', name: 'Kentucky' },
{ code: 'LA', name: 'Louisiana' },
{ code: 'ME', name: 'Maine' },
{ code: 'MD', name: 'Maryland' },
{ code: 'MA', name: 'Massachusetts' },
{ code: 'MI', name: 'Michigan' },
{ code: 'MN', name: 'Minnesota' },
{ code: 'MS', name: 'Mississippi' },
{ code: 'MO', name: 'Missouri' },
{ code: 'MT', name: 'Montana' },
{ code: 'NE', name: 'Nebraska' },
{ code: 'NV', name: 'Nevada' },
{ code: 'NH', name: 'New Hampshire' },
{ code: 'NJ', name: 'New Jersey' },
{ code: 'NM', name: 'New Mexico' },
{ code: 'NY', name: 'New York' },
{ code: 'NC', name: 'North Carolina' },
{ code: 'ND', name: 'North Dakota' },
{ code: 'OH', name: 'Ohio' },
{ code: 'OK', name: 'Oklahoma' },
{ code: 'OR', name: 'Oregon' },
{ code: 'PA', name: 'Pennsylvania' },
{ code: 'RI', name: 'Rhode Island' },
{ code: 'SC', name: 'South Carolina' },
{ code: 'SD', name: 'South Dakota' },
{ code: 'TN', name: 'Tennessee' },
{ code: 'TX', name: 'Texas' },
{ code: 'UT', name: 'Utah' },
{ code: 'VT', name: 'Vermont' },
{ code: 'VA', name: 'Virginia' },
{ code: 'WA', name: 'Washington' },
{ code: 'WV', name: 'West Virginia' },
{ code: 'WI', name: 'Wisconsin' },
{ code: 'WY', name: 'Wyoming' }
]
},
isLastQuestion () {
return this.questions && this.currentQuestionIndex === this.questions.length - 1
},
formattedTimeLeft () {
if (this.timeLeftSeconds === null) return ''
const minutes = Math.floor(this.timeLeftSeconds / 60)
const seconds = this.timeLeftSeconds % 60
return `${minutes}:${String(seconds).padStart(2, '0')}`
}
},
beforeUnmount () {
this.clearCountdown()
},
methods: {
clearCountdown () {
if (this.countdownIntervalId) {
clearInterval(this.countdownIntervalId)
this.countdownIntervalId = null
}
this.timeLeftSeconds = null
},
startCountdown () {
this.clearCountdown()
this.timeLeftSeconds = this.kbaTimeLimitSeconds
this.countdownIntervalId = setInterval(() => {
if (this.timeLeftSeconds === null) return
this.timeLeftSeconds -= 1
if (this.timeLeftSeconds <= 0) {
this.handleTimeout()
}
}, 1000)
},
handleTimeout () {
this.clearCountdown()
this.questions = null
this.token = null
this.reference = null
this.answers = {}
this.currentQuestionIndex = 0
this.error = `Knowledge Based Authentication timed out. You only have ${this.kbaTimeLimitSeconds} seconds to complete the verification. Please retry.`
},
nextQuestion () {
if (this.isLastQuestion) {
this.$emit('submit')
} else {
this.currentQuestionIndex++
}
},
restartKba () {
this.clearCountdown()
this.questions = null
this.token = null
this.reference = null
this.answers = {}
this.currentQuestionIndex = 0
this.error = null
},
async startKba () {
this.isLoading = true
this.error = null
this.clearCountdown()
try {
const payload = { ...this.form, submitter_slug: this.submitterSlug }
if (payload.dob) {
payload.dob = payload.dob.replace(/-/g, '')
}
if (payload.ssn) {
payload.ssn = payload.ssn.replace(/\D/g, '')
}
if (payload.phone) {
payload.phone = payload.phone.replace(/^\+1/, '')
}
const resp = await fetch(this.baseUrl + '/api/kba', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
const data = await resp.json()
if (!resp.ok) throw new Error(data.error || 'Failed to start KBA')
if (data.result && data.result.action === 'FAIL') {
if (data.result.detail === 'NO MATCH') {
throw new Error('Unfortunately, we were unable to start Knowledge Based Authentication with the details provided. Please review and confirm that all your personal details are correct.')
}
throw new Error(data.result.detail || 'KBA Start Failed')
}
if (data.output && data.output.questions && data.output.questions.questions) {
this.questions = data.output.questions.questions
this.token = data.continuations.questions.template.token
this.reference = data.meta.reference
this.questions.forEach(q => {
this.answers[q.id] = null
})
this.startCountdown()
} else {
throw new Error('Invalid KBA response')
}
} catch (e) {
this.error = e.message
} finally {
this.isLoading = false
}
},
async submit () {
this.clearCountdown()
this.isSubmitting = true
this.error = null
const formattedAnswers = Object.keys(this.answers).reduce((acc, key) => {
acc[key] = [this.answers[key]]
return acc
}, {})
try {
const resp = await fetch(this.baseUrl + `/api/kba/${this.field.uuid}`, {
method: 'PUT',
body: JSON.stringify({
token: this.token,
answers: formattedAnswers,
reference: this.reference,
submitter_slug: this.submitterSlug
}),
headers: { 'Content-Type': 'application/json' }
})
const data = await resp.json()
if (data.result?.action !== 'PASS') {
if (data.result?.issues?.length) {
this.error = `Knowledge Based Authentication Failed - make sure you provide correct details for the Knowledge Based authentication: ${data.result.issues.join(', ')}`
} else {
this.error = 'Knowledge Based Authentication Failed - make sure you provide correct answers for the Knowledge Based authentication.'
}
throw new Error('Knowledge Based Authentication Failed')
}
if (!resp.ok) {
this.error = 'Failed to submit answers'
throw new Error('Failed to submit answers')
}
return resp
} finally {
this.isSubmitting = false
}
}
}
}
</script>
@@ -165,34 +165,9 @@ export default {
}
if (this.field.preferences?.formula) {
const {
create,
evaluateDependencies,
addDependencies,
subtractDependencies,
divideDependencies,
multiplyDependencies,
powDependencies,
roundDependencies,
absDependencies,
sinDependencies,
tanDependencies,
cosDependencies
} = await import('mathjs')
const { Calculator } = await import('./calculator')
this.math = create({
evaluateDependencies,
addDependencies,
subtractDependencies,
divideDependencies,
multiplyDependencies,
powDependencies,
roundDependencies,
absDependencies,
sinDependencies,
tanDependencies,
cosDependencies
})
this.math = new Calculator()
this.isMathLoaded = true
}
@@ -100,7 +100,7 @@
<input
:id="field.uuid"
ref="phone"
:value="phoneValue"
:value="defaultValue && detectedPhoneValueDialCode ? phoneValue.split('+' + detectedPhoneValueDialCode).slice(-1).join('') : phoneValue"
:readonly="!!defaultValue"
class="base-input !text-2xl !rounded-l-none !border-l-0 !outline-none w-full"
autocomplete="tel"
+231 -55
View File
@@ -22,7 +22,7 @@
<div class="space-x-2 flex flex-none">
<span
v-if="isTextSignature && format !== 'typed_or_upload' && format !== 'typed' && format !== 'upload'"
class="tooltip"
class="md:tooltip"
:data-tip="t('draw_signature')"
>
<a
@@ -39,7 +39,7 @@
</span>
<span
v-else-if="withTypedSignature && format !== 'drawn_or_upload' && format !== 'typed_or_upload' && format !== 'typed' && format !== 'drawn' && format !== 'upload'"
class="tooltip ml-2"
class="md:tooltip ml-2"
:class="{ 'hidden sm:inline': modelValue || computedPreviousValue }"
:data-tip="t('type_text')"
>
@@ -57,7 +57,7 @@
</span>
<span
v-if="format !== 'typed' && format !== 'drawn' && format !== 'upload' && format !== 'drawn_or_typed'"
class="tooltip"
class="md:tooltip"
:class="{ 'hidden sm:inline': modelValue || computedPreviousValue }"
:data-tip="t('take_photo')"
>
@@ -86,8 +86,8 @@
</a>
<span
v-if="withQrButton && !modelValue && !computedPreviousValue && format !== 'typed_or_upload' && format !== 'typed' && format !== 'upload'"
class="tooltip before:translate-x-[-90%]"
:data-tip="t('drawn_signature_on_a_touchscreen_device')"
class="md:tooltip before:translate-x-[-90%]"
:data-tip="t('sign_on_the_touchscreen')"
>
<a
href="#"
@@ -127,6 +127,12 @@
type="hidden"
:name="`values[${field.uuid}]`"
>
<input
v-if="isTouchAttachment"
:value="touchAttachmentUuid"
type="hidden"
name="touch_attachment_uuid"
>
<img
v-if="modelValue || computedPreviousValue"
:src="attachmentsIndex[modelValue || computedPreviousValue].url"
@@ -142,7 +148,7 @@
/>
<div
v-else
class="relative"
class="relative select-none"
>
<div
v-if="!modelValue && !computedPreviousValue && !isShowQr && !isTextSignature && isSignatureStarted"
@@ -175,9 +181,7 @@
v-if="isShowQr"
class="top-0 bottom-0 right-0 left-0 absolute bg-base-content/10 rounded-2xl"
>
<div
class="absolute top-1.5 right-1.5 tooltip"
>
<div class="absolute top-1.5 right-1.5">
<a
href="#"
class="btn btn-sm btn-circle btn-normal btn-outline"
@@ -211,7 +215,7 @@
@input="updateWrittenSignature"
>
<select
v-if="requireSigningReason && !isOtherReason"
v-if="withSigningReason && !isOtherReason"
class="select base-input !text-2xl w-full mt-6 text-center"
:class="{ 'text-gray-300': !reason }"
required
@@ -226,24 +230,37 @@
>
{{ t('select_a_reason') }}
</option>
<option
v-for="(label, option) in defaultReasons"
:key="option"
:value="option"
:selected="reason === option"
class="text-base-content"
>
{{ label }}
</option>
<option
value="other"
class="text-base-content"
>
{{ t('other') }}
</option>
<template v-if="field.preferences?.reasons">
<option
v-for="option in field.preferences.reasons"
:key="option"
:value="option"
:selected="reason === option"
class="text-base-content"
>
{{ option }}
</option>
</template>
<template v-else>
<option
v-for="(label, option) in defaultReasons"
:key="option"
:value="option"
:selected="reason === option"
class="text-base-content"
>
{{ label }}
</option>
<option
value="other"
class="text-base-content"
>
{{ t('other') }}
</option>
</template>
</select>
<input
v-if="requireSigningReason && isOtherReason"
v-if="withSigningReason && isOtherReason"
class="base-input !text-2xl w-full mt-6"
required
:name="`values[${field.preferences.reason_field_uuid}]`"
@@ -253,7 +270,7 @@
@input="$emit('update:reason', $event.target.value)"
>
<input
v-if="requireSigningReason"
v-if="withSigningReason"
hidden
name="with_reason"
:value="field.preferences.reason_field_uuid"
@@ -268,7 +285,7 @@
<div
v-else-if="withDisclosure"
dir="auto"
class="text-base-content/60 text-xs text-center w-full mt-1"
class="text-base-content/60 text-xs text-center w-full mt-1 select-none"
>
{{ t('by_clicking_you_agree_to_the').replace('{button}', buttonText.charAt(0).toUpperCase() + buttonText.slice(1)) }} <a
href="https://www.docuseal.com/esign-disclosure"
@@ -292,14 +309,14 @@
<script>
import { IconReload, IconCamera, IconSignature, IconTextSize, IconArrowsDiagonalMinimize2, IconQrcode, IconX } from '@tabler/icons-vue'
import { cropCanvasAndExportToPNG } from './crop_canvas'
import { isValidSignatureCanvas } from './validate_signature'
import { isValidSignatureCanvas, isCanvasBlocked } from './validate_signature'
import SignaturePad from 'signature_pad'
import AppearsOn from './appears_on'
import FileDropzone from './dropzone'
import MarkdownContent from './markdown_content'
import { v4 } from 'uuid'
let isFontLoaded = false
let fontLoadPromise = null
const scale = 3
@@ -323,6 +340,10 @@ export default {
type: Object,
required: true
},
values: {
type: Object,
required: true
},
requireSigningReason: {
type: Boolean,
required: false,
@@ -377,25 +398,41 @@ export default {
required: false,
default: ''
},
touchAttachmentUuid: {
type: String,
required: false,
default: ''
},
reason: {
type: String,
required: false,
default: ''
},
signatureText: {
type: String,
required: false,
default: ''
},
signatureSrc: {
type: String,
required: false,
default: ''
},
modelValue: {
type: String,
required: false,
default: ''
}
},
emits: ['attached', 'update:model-value', 'start', 'minimize', 'update:reason'],
emits: ['attached', 'update:model-value', 'start', 'minimize', 'update:reason', 'touch-attachment'],
data () {
return {
isSignatureStarted: false,
isShowQr: false,
isOtherReason: false,
isUsePreviousValue: true,
isTextSignature: this.field.preferences?.format === 'typed' || this.field.preferences?.format === 'typed_or_upload',
isTouchAttachment: false,
isTextSignature: !this.signatureSrc && (!!this.signatureText || this.field.preferences?.format === 'typed' || this.field.preferences?.format === 'typed_or_upload'),
uploadImageInputKey: Math.random().toString()
}
},
@@ -406,6 +443,9 @@ export default {
format () {
return this.field.preferences?.format
},
withSigningReason () {
return this.requireSigningReason || this.field.preferences?.reasons?.length
},
defaultReasons () {
return {
[this.t('approved_by')]: this.t('approved'),
@@ -424,21 +464,15 @@ export default {
created () {
this.isSignatureStarted = !!this.computedPreviousValue
if (this.requireSigningReason) {
if (this.withSigningReason) {
this.field.preferences ||= {}
this.field.preferences.reason_field_uuid ||= v4()
this.isOtherReason = this.reason && !this.defaultReasons[this.reason]
this.isOtherReason = this.reason && !this.defaultReasons[this.reason] &&
(!this.field.preferences?.reasons?.length || !this.field.preferences.reasons.includes(this.reason))
}
},
async mounted () {
this.$nextTick(() => {
if (this.$refs.canvas) {
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth * scale
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth * scale / 3
this.$refs.canvas.getContext('2d').scale(scale, scale)
}
})
this.$nextTick(() => this.setCanvasSize())
if (this.$refs.canvas) {
this.pad = new SignaturePad(this.$refs.canvas)
@@ -453,13 +487,20 @@ export default {
this.$emit('start')
})
this.intersectionObserver = new IntersectionObserver((entries, observer) => {
this.intersectionObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth * scale
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth * scale / 3
this.setCanvasSize()
this.$refs.canvas.getContext('2d').scale(scale, scale)
if (this.signatureSrc) {
this.$nextTick(() => this.drawSignatureSrc())
} else if (this.isTextSignature) {
this.$nextTick(() => {
if (this.$refs.textInput) {
this.initTypedSignature()
}
})
}
this.intersectionObserver?.disconnect()
}
@@ -467,13 +508,66 @@ export default {
})
this.intersectionObserver.observe(this.$refs.canvas)
this.resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => {
if (!this.$refs.canvas) return
const { width, height } = this.$refs.canvas
this.setCanvasSize()
if (this.$refs.canvas.width !== width || this.$refs.canvas.height !== height) {
this.redrawCanvas(width, height)
}
})
})
this.resizeObserver.observe(this.$refs.canvas.parentNode)
if (this.isTextSignature) {
this.loadFont()
}
}
},
beforeUnmount () {
this.intersectionObserver?.disconnect()
this.resizeObserver?.disconnect()
this.stopCheckSignature()
},
methods: {
setCanvasSize () {
const canvas = this.$refs.canvas
if (canvas) {
const width = canvas.parentNode.clientWidth
const height = width / 3
if (canvas.width !== width * scale || canvas.height !== height * scale) {
canvas.width = width * scale
canvas.height = height * scale
canvas.getContext('2d').scale(scale, scale)
}
}
},
redrawCanvas (oldWidth, oldHeight) {
const canvas = this.$refs.canvas
if (this.pad && !this.isTextSignature && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0) {
const sx = canvas.width / oldWidth
const sy = canvas.height / oldHeight
const scaledData = this.pad.toData().map((stroke) => ({
...stroke,
points: stroke.points.map((p) => ({ ...p, x: p.x * sx, y: p.y * sy }))
}))
this.pad.fromData(scaledData)
} else if (this.isTextSignature && this.$refs.textInput) {
this.updateWrittenSignature({ target: this.$refs.textInput })
}
},
remove () {
this.$emit('update:model-value', '')
@@ -481,17 +575,17 @@ export default {
this.isSignatureStarted = false
},
loadFont () {
if (!isFontLoaded) {
if (!fontLoadPromise) {
const font = new FontFace('Dancing Script', `url(${this.baseUrl}/fonts/DancingScript-Regular.otf) format("opentype")`)
font.load().then((loadedFont) => {
fontLoadPromise = font.load().then((loadedFont) => {
document.fonts.add(loadedFont)
isFontLoaded = true
}).catch((error) => {
console.error('Font loading failed:', error)
})
}
return fontLoadPromise
},
showQr () {
this.isShowQr = true
@@ -591,14 +685,73 @@ export default {
if (this.isTextSignature) {
this.$nextTick(() => {
this.$refs.textInput.focus()
if (this.$refs.textInput) {
if (!this.submitter.name) {
this.$refs.textInput.focus()
}
this.loadFont()
this.initTypedSignature()
this.$emit('start')
this.$emit('start')
}
})
}
},
async initTypedSignature () {
if (this.signatureText) {
this.$refs.textInput.value = this.signatureText
} else if (this.submitter.name) {
this.$refs.textInput.value = this.submitter.name
}
await this.loadFont()
if (this.$refs.textInput.value) {
this.updateWrittenSignature({ target: this.$refs.textInput })
}
},
drawSignatureSrc () {
const canvas = this.$refs.canvas
if (!canvas) return
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => {
const context = canvas.getContext('2d')
const aspectRatio = img.width / img.height
const canvasWidth = canvas.width / scale
const canvasHeight = canvas.height / scale
let targetWidth = canvasWidth
let targetHeight = canvasHeight
if (canvasWidth / canvasHeight > aspectRatio) {
targetWidth = canvasHeight * aspectRatio
} else {
targetHeight = canvasWidth / aspectRatio
}
const x = (canvasWidth - targetWidth) / 2
const y = (canvasHeight - targetHeight) / 2
context.clearRect(0, 0, canvasWidth, canvasHeight)
context.drawImage(img, x, y, targetWidth, targetHeight)
this.isSignatureStarted = true
this.$emit('start')
}
img.onerror = () => {
console.error(`Failed to load signature image from ${this.signatureSrc}. The remote server must send an Access-Control-Allow-Origin header to allow CORS access.`)
}
img.src = this.signatureSrc
},
drawImage (event) {
this.remove()
this.clear()
@@ -677,6 +830,13 @@ export default {
},
async submit () {
if (this.modelValue || this.computedPreviousValue) {
if (this.touchAttachmentUuid && this.computedPreviousValue === this.touchAttachmentUuid && !Object.values(this.values).includes(this.touchAttachmentUuid)) {
this.isTouchAttachment = true
this.$emit('touch-attachment', this.touchAttachmentUuid)
} else {
this.isTouchAttachment = false
}
if (this.computedPreviousValue) {
this.$emit('update:model-value', this.computedPreviousValue)
}
@@ -686,7 +846,15 @@ export default {
if (this.isSignatureStarted && this.pad.toData().length > 0 && !isValidSignatureCanvas(this.pad.toData())) {
if (this.field.required === true || this.pad.toData().length > 0) {
alert(this.t('signature_is_too_small_or_simple_please_redraw'))
if (isCanvasBlocked()) {
alert(this.t('browser_privacy_settings_block_canvas'))
if (window.Rollbar) {
window.Rollbar.info('Canvas blocked')
}
} else {
alert(this.t('signature_is_too_small_or_simple_please_redraw'))
}
return Promise.reject(new Error('Image too small or simple'))
} else {
@@ -742,7 +910,15 @@ export default {
}
}).catch((error) => {
if (this.field.required === true) {
alert(this.t('signature_is_too_small_or_simple_please_redraw'))
if (isCanvasBlocked()) {
alert(this.t('browser_privacy_settings_block_canvas'))
if (window.Rollbar) {
window.Rollbar.info('Canvas blocked')
}
} else {
alert(this.t('signature_is_too_small_or_simple_please_redraw'))
}
return reject(error)
} else {
@@ -35,4 +35,24 @@ function isValidSignatureCanvas (data) {
return validStrokes.length > 0
}
export { isValidSignatureCanvas }
function isCanvasBlocked () {
try {
const testCanvas = document.createElement('canvas')
testCanvas.width = 2
testCanvas.height = 2
const ctx = testCanvas.getContext('2d')
ctx.fillStyle = 'rgb(255, 0, 0)'
ctx.fillRect(0, 0, 2, 2)
const pixel = ctx.getImageData(0, 0, 1, 1).data
return pixel[0] !== 255 || pixel[1] !== 0 || pixel[2] !== 0 || pixel[3] !== 255
} catch (e) {
return true
}
}
export { isValidSignatureCanvas, isCanvasBlocked }
@@ -20,8 +20,11 @@
{{ t('complete_all_required_fields_to_proceed_with_identity_verification') }}
</div>
<div v-else>
<div v-if="errorMessage">
{{ errorMessage }}
</div>
<div
v-if="isLoading"
v-else-if="isLoading"
class="w-full flex space-x-2 justify-center mb-2"
>
<IconInnerShadowTop
@@ -96,6 +99,7 @@ export default {
isMathLoaded: false,
redirectUrl: '',
isLoading: false,
errorMessage: '',
eidEasyData: {}
}
},
@@ -119,6 +123,7 @@ export default {
docId: this.eidEasyData.doc_id,
language: this.locale,
countryCode: this.countryCode,
sandbox: ['demo.docuseal.tech'].includes(location.host),
enabledMethods: {
signature: this.eidEasyData.available_methods
},
@@ -161,6 +166,12 @@ export default {
}).then(async (resp) => {
this.eidEasyData = await resp.json()
if (this.eidEasyData.error) {
this.errorMessage = this.eidEasyData.error
return
}
if (this.eidEasyData.check_completed) {
this.$emit('submit')
}
+86 -296
View File
@@ -8,7 +8,7 @@
@touchstart="startTouchDrag"
>
<div
v-if="isSelected || isDraw"
v-if="isSelected || isDraw || isInMultiSelection"
class="top-0 bottom-0 right-0 left-0 absolute border border-1.5 pointer-events-none"
:class="activeBorderClasses"
/>
@@ -24,142 +24,33 @@
:style="{ left: (cellW / area.w * 100) + '%' }"
>
<span
v-if="index === 0 && editable"
v-if="index === 0 && editable && !isInMultiSelection"
class="h-2.5 w-2.5 rounded-full -bottom-1 border-gray-400 bg-white shadow-md border absolute cursor-ew-resize z-10"
style="left: -4px"
@mousedown.stop="startResizeCell"
/>
</div>
</div>
<div
v-if="field?.type && (isSelected || isNameFocus)"
class="absolute bg-white rounded-t border overflow-visible whitespace-nowrap flex z-10 field-area-controls"
style="top: -25px; height: 25px"
@mousedown.stop
@pointerdown.stop
>
<FieldSubmitter
v-if="field.type != 'heading' && field.type != 'strikethrough'"
v-model="field.submitter_uuid"
class="border-r roles-dropdown"
:compact="true"
:editable="editable && (!defaultField || defaultField.role !== submitter?.name)"
:allow-add-new="!defaultSubmitters.length"
:menu-classes="'dropdown-content bg-white menu menu-xs p-2 shadow rounded-box w-52 rounded-t-none -left-[1px] mt-[1px]'"
:submitters="template.submitters"
@update:model-value="save"
@click="selectedAreaRef.value = area"
/>
<FieldType
v-model="field.type"
:button-width="27"
:editable="editable && !defaultField"
:button-classes="'px-1'"
:menu-classes="'bg-white rounded-t-none'"
@update:model-value="[maybeUpdateOptions(), save()]"
@click="selectedAreaRef.value = area"
/>
<span
v-if="field.type !== 'checkbox' || field.name"
ref="name"
:contenteditable="editable && !defaultField && field.type !== 'heading'"
dir="auto"
class="pr-1 cursor-text outline-none block"
style="min-width: 2px"
@paste.prevent="onPaste"
@keydown.enter.prevent="onNameEnter"
@focus="onNameFocus"
@blur="onNameBlur"
>{{ optionIndexText }} {{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || defaultName }}</span>
<div
v-if="isSettingsFocus || isSelectInput || (isValueInput && field.type !== 'heading') || (isNameFocus && !['checkbox', 'phone'].includes(field.type))"
class="flex items-center ml-1.5"
>
<input
v-if="!isValueInput && !isSelectInput"
:id="`required-checkbox-${field.uuid}`"
v-model="field.required"
type="checkbox"
class="checkbox checkbox-xs no-animation rounded"
@mousedown.prevent
>
<label
v-if="!isValueInput && !isSelectInput"
:for="`required-checkbox-${field.uuid}`"
class="label text-xs"
@click.prevent="field.required = !field.required"
@mousedown.prevent
>{{ t('required') }}</label>
<input
v-if="isValueInput || isSelectInput"
:id="`readonly-checkbox-${field.uuid}`"
type="checkbox"
class="checkbox checkbox-xs no-animation rounded"
:checked="!(field.readonly ?? true)"
@change="field.readonly = !(field.readonly ?? true)"
@mousedown.prevent
>
<label
v-if="isValueInput || isSelectInput"
:for="`readonly-checkbox-${field.uuid}`"
class="label text-xs"
@click.prevent="field.readonly = !(field.readonly ?? true)"
@mousedown.prevent
>{{ t('editable') }}</label>
<span
v-if="field.type !== 'payment' && !isValueInput"
class="dropdown dropdown-end field-area-settings-dropdown"
@mouseenter="renderDropdown = true"
@touchstart="renderDropdown = true"
>
<label
ref="settingsButton"
tabindex="0"
:title="t('settings')"
class="cursor-pointer flex items-center"
style="height: 25px"
@focus="isSettingsFocus = true"
@blur="maybeBlurSettings"
>
<IconDotsVertical class="w-5 h-5" />
</label>
<ul
v-if="renderDropdown"
ref="settingsDropdown"
tabindex="0"
class="dropdown-content menu menu-xs px-2 pb-2 pt-1 shadow rounded-box w-52 z-10 rounded-t-none"
:style="{ backgroundColor: 'white' }"
@dragstart.prevent.stop
@click="closeDropdown"
@focusout="maybeBlurSettings"
>
<FieldSettings
:field="field"
:default-field="defaultField"
:editable="editable"
:background-color="'white'"
:with-required="false"
:with-areas="false"
:with-signature-id="withSignatureId"
:with-prefillable="withPrefillable"
@click-formula="isShowFormulaModal = true"
@click-font="isShowFontModal = true"
@click-description="isShowDescriptionModal = true"
@click-condition="isShowConditionsModal = true"
@scroll-to="[selectedAreaRef.value = $event, $emit('scroll-to', $event)]"
/>
</ul>
</span>
</div>
<button
v-else-if="editable"
class="pr-1"
:title="t('remove')"
@click.prevent="$emit('remove')"
>
<IconX width="14" />
</button>
</div>
<AreaTitle
ref="title"
:area="area"
:field="field"
:template="template"
:selected-areas-ref="selectedAreasRef"
:get-field-type-index="getFieldTypeIndex"
:default-field="defaultField"
:with-signature-id="withSignatureId"
:with-prefillable="withPrefillable"
:default-submitters="defaultSubmitters"
:editable="editable"
:is-mobile="isMobile"
:is-value-input="isValueInput"
:is-select-input="isSelectInput"
@change="save"
@remove="$emit('remove')"
@scroll-to="$emit('scroll-to', $event)"
@add-custom-field="$emit('add-custom-field')"
/>
<div
ref="touchValueTarget"
class="flex h-full w-full field-area"
@@ -250,7 +141,8 @@
</span>
<div
v-else-if="field.type === 'cells' && field.default_value"
class="w-full flex items-center"
class="w-full flex"
:class="fontClasses"
>
<div
v-for="(char, index) in field.default_value"
@@ -266,7 +158,7 @@
ref="defaultValueSelect"
class="bg-transparent outline-none focus:outline-none w-full"
@change="[field.default_value = $event.target.value, field.readonly = !!field.default_value?.length, save()]"
@focus="selectedAreaRef.value = area"
@focus="selectedAreasRef.value = [area]"
@keydown.enter="onDefaultValueEnter"
>
<option
@@ -293,7 +185,7 @@
:class="{ 'cursor-text': isValueInput }"
:placeholder="withFieldPlaceholder && !isValueInput ? defaultField?.title || field.title || field.name || defaultName : (field.type === 'date' ? field.preferences?.format || t('type_value') : t('type_value'))"
@blur="onDefaultValueBlur"
@focus="selectedAreaRef.value = area"
@focus="selectedAreasRef.value = [area]"
@paste.prevent="onPaste"
@keydown.enter="onDefaultValueEnter"
>{{ field.default_value }}</span>
@@ -319,86 +211,26 @@
<span
v-if="field?.type && editable"
class="h-4 w-4 lg:h-2.5 lg:w-2.5 -right-1 rounded-full -bottom-1 border-gray-400 bg-white shadow-md border absolute cursor-nwse-resize"
:class="{ 'z-30': isInMultiSelection }"
@mousedown.stop="startResize"
@touchstart="startTouchResize"
/>
<Teleport
v-if="isShowFormulaModal"
:to="modalContainerEl"
>
<FormulaModal
:field="field"
:editable="editable && !defaultField"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@close="isShowFormulaModal = false"
/>
</Teleport>
<Teleport
v-if="isShowFontModal"
:to="modalContainerEl"
>
<FontModal
:field="field"
:editable="editable && !defaultField"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@close="isShowFontModal = false"
/>
</Teleport>
<Teleport
v-if="isShowConditionsModal"
:to="modalContainerEl"
>
<ConditionsModal
:item="field"
:build-default-name="buildDefaultName"
:default-field="defaultField"
@close="isShowConditionsModal = false"
/>
</Teleport>
<Teleport
v-if="isShowDescriptionModal"
:to="modalContainerEl"
>
<DescriptionModal
:field="field"
:editable="editable && !defaultField"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@close="isShowDescriptionModal = false"
/>
</Teleport>
</div>
</template>
<script>
import FieldSubmitter from './field_submitter'
import FieldType from './field_type'
import Field from './field'
import FieldSettings from './field_settings'
import FormulaModal from './formula_modal'
import FontModal from './font_modal'
import ConditionsModal from './conditions_modal'
import DescriptionModal from './description_modal'
import { IconX, IconCheck, IconDotsVertical } from '@tabler/icons-vue'
import { v4 } from 'uuid'
import AreaTitle from './area_title'
import { IconCheck } from '@tabler/icons-vue'
export default {
name: 'FieldArea',
components: {
FieldType,
IconCheck,
FieldSettings,
FormulaModal,
FontModal,
IconDotsVertical,
DescriptionModal,
ConditionsModal,
FieldSubmitter,
IconX
AreaTitle
},
inject: ['template', 'selectedAreaRef', 'save', 't', 'isInlineSize'],
inject: ['template', 'save', 't', 'isInlineSize', 'selectedAreasRef', 'isCmdKeyRef', 'getFieldTypeIndex'],
props: {
area: {
type: Object,
@@ -463,22 +295,25 @@ export default {
type: Object,
required: false,
default: null
},
isMobile: {
type: Boolean,
required: false,
default: false
},
isSelectMode: {
type: Boolean,
required: false,
default: false
}
},
emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove', 'scroll-to'],
emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove', 'scroll-to', 'add-custom-field'],
data () {
return {
isShowFormulaModal: false,
isShowFontModal: false,
isShowConditionsModal: false,
isContenteditable: false,
isSettingsFocus: false,
isShowDescriptionModal: false,
isResize: false,
isDragged: false,
isMoved: false,
renderDropdown: false,
isNameFocus: false,
isHeadingSelected: false,
textOverflowChars: 0,
dragFrom: { x: 0, y: 0 }
@@ -567,11 +402,8 @@ export default {
return (this.field.type === 'heading' && this.isHeadingSelected) || this.isContenteditable ||
(this.inputMode && (['text', 'number'].includes(this.field.type) || (this.field.type === 'date' && this.field.default_value !== '{{date}}')))
},
modalContainerEl () {
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
},
defaultName () {
return this.buildDefaultName(this.field, this.template.fields)
return this.buildDefaultName(this.field)
},
fontClasses () {
if (!this.field.preferences) {
@@ -591,13 +423,6 @@ export default {
italic: ['bold_italic', 'italic'].includes(this.field.preferences.font_type)
}
},
optionIndexText () {
if (this.area.option_uuid && this.field.options) {
return `${this.field.options.findIndex((o) => o.uuid === this.area.option_uuid) + 1}.`
} else {
return ''
}
},
cells () {
const cells = []
@@ -646,7 +471,10 @@ export default {
]
},
isSelected () {
return this.selectedAreaRef.value === this.area
return this.selectedAreasRef.value.includes(this.area)
},
isInMultiSelection () {
return this.selectedAreasRef.value.length >= 2 && this.isSelected
},
positionStyle () {
const { x, y, w, h } = this.area
@@ -677,16 +505,13 @@ export default {
},
methods: {
buildDefaultName: Field.methods.buildDefaultName,
closeDropdown () {
this.$el.getRootNode().activeElement.blur()
},
buildAreaOptionValue (area) {
const option = this.optionsUuidIndex[area.option_uuid]
return option.value || `${this.t('option')} ${this.field.options.indexOf(option) + 1}`
return option?.value || `${this.t('option')} ${this.field.options.indexOf(option) + 1}`
},
maybeToggleDefaultValue () {
if (!this.editable) {
if (!this.editable || this.isCmdKeyRef.value) {
return
}
@@ -764,23 +589,6 @@ export default {
return number
}
},
maybeBlurSettings (e) {
if (!e.relatedTarget || !this.$refs.settingsDropdown.contains(e.relatedTarget)) {
this.isSettingsFocus = false
}
},
onNameFocus (e) {
this.selectedAreaRef.value = this.area
this.isNameFocus = true
this.$refs.name.style.minWidth = this.$refs.name.clientWidth + 'px'
if (!this.field.name) {
setTimeout(() => {
this.$refs.name.innerText = ' '
}, 1)
}
},
startResizeCell (e) {
this.$el.getRootNode().addEventListener('mousemove', this.onResizeCell)
this.$el.getRootNode().addEventListener('mouseup', this.stopResizeCell)
@@ -815,53 +623,6 @@ export default {
}
}
},
maybeUpdateOptions () {
delete this.field.default_value
if (!['radio', 'multiple', 'select'].includes(this.field.type)) {
delete this.field.options
}
if (this.field.type === 'heading') {
this.field.readonly = true
}
if (this.field.type === 'strikethrough') {
this.field.readonly = true
this.field.default_value = true
}
if (['select', 'multiple', 'radio'].includes(this.field.type)) {
this.field.options ||= [{ value: '', uuid: v4() }]
}
(this.field.areas || []).forEach((area) => {
if (this.field.type === 'cells') {
area.cell_w = area.w * 2 / Math.floor(area.w / area.h)
} else {
delete area.cell_w
}
})
},
onNameBlur (e) {
if (e.relatedTarget === this.$refs.settingsButton) {
this.isSettingsFocus = true
}
const text = this.$refs.name.innerText.trim()
this.isNameFocus = false
this.$refs.name.style.minWidth = ''
if (text) {
this.field.name = text
} else {
this.field.name = ''
this.$refs.name.innerText = this.defaultName
}
this.save()
},
onDefaultValueBlur (e) {
const text = this.$refs.defaultValue.innerText.trim()
@@ -899,13 +660,19 @@ export default {
this.$refs.defaultValue.blur()
}
},
onNameEnter (e) {
this.$refs.name.blur()
},
resize (e) {
if (e.target.id === 'mask') {
this.area.w = e.offsetX / e.target.clientWidth - this.area.x
this.area.h = e.offsetY / e.target.clientHeight - this.area.y
if (this.isInMultiSelection) {
this.selectedAreasRef.value.forEach((area) => {
if (area !== this.area) {
area.w = this.area.w
area.h = this.area.h
}
})
}
}
},
drag (e) {
@@ -931,7 +698,7 @@ export default {
const rect = e.target.getBoundingClientRect()
this.selectedAreaRef.value = this.area
this.selectedAreasRef.value = [this.area]
this.dragFrom = { x: rect.left - e.touches[0].clientX, y: rect.top - e.touches[0].clientY }
@@ -980,13 +747,23 @@ export default {
e.preventDefault()
if (e.metaKey || e.ctrlKey) {
if (!this.selectedAreasRef.value.includes(this.area)) {
this.selectedAreasRef.value.push(this.area)
} else {
this.selectedAreasRef.value.splice(this.selectedAreasRef.value.indexOf(this.area), 1)
}
return
}
if (this.editable) {
this.isDragged = true
}
const rect = e.target.getBoundingClientRect()
this.selectedAreaRef.value = this.area
this.selectedAreasRef.value = [this.area]
this.dragFrom = { x: rect.left - e.clientX, y: rect.top - e.clientY }
@@ -1055,7 +832,9 @@ export default {
this.$emit('stop-drag')
},
startResize () {
this.selectedAreaRef.value = this.area
if (!this.selectedAreasRef.value.includes(this.area)) {
this.selectedAreasRef.value = [this.area]
}
this.$el.getRootNode().addEventListener('mousemove', this.resize)
this.$el.getRootNode().addEventListener('mouseup', this.stopResize)
@@ -1071,9 +850,11 @@ export default {
this.save()
},
startTouchResize (e) {
this.selectedAreaRef.value = this.area
if (!this.selectedAreasRef.value.includes(this.area)) {
this.selectedAreasRef.value = [this.area]
}
this.$refs?.name?.blur()
this.$refs?.title?.$refs?.name?.blur()
e.preventDefault()
@@ -1088,6 +869,15 @@ export default {
this.area.w = (e.touches[0].clientX - rect.left) / rect.width - this.area.x
this.area.h = (e.touches[0].clientY - rect.top) / rect.height - this.area.y
if (this.isInMultiSelection) {
this.selectedAreasRef.value.forEach((area) => {
if (area !== this.area) {
area.w = this.area.w
area.h = this.area.h
}
})
}
},
stopTouchResize () {
this.$el.getRootNode().removeEventListener('touchmove', this.touchResize)
@@ -0,0 +1,404 @@
<template>
<div
v-if="field?.type && (isSelected || isNameFocus) && !isInMultiSelection"
class="absolute bg-white rounded-t border overflow-visible whitespace-nowrap flex z-10 field-area-controls"
style="top: -25px; height: 25px"
@mousedown.stop
@pointerdown.stop
>
<FieldSubmitter
v-if="field.type != 'heading' && field.type != 'strikethrough'"
v-model="field.submitter_uuid"
class="border-r roles-dropdown"
:compact="true"
:editable="editable && (!defaultField || defaultField.role !== submitter?.name)"
:allow-add-new="!defaultSubmitters.length"
:menu-classes="'dropdown-content bg-white menu menu-xs p-2 shadow rounded-box w-52 rounded-t-none -left-[1px] mt-[1px]'"
:submitters="template.submitters"
@update:model-value="$emit('change')"
@click="selectedAreasRef.value = [area]"
/>
<FieldType
v-model="field.type"
:button-width="27"
:editable="editable && !defaultField"
:button-classes="'px-1'"
:menu-classes="'bg-white rounded-t-none'"
@update:model-value="[maybeUpdateOptions(), $emit('change')]"
@click="selectedAreasRef.value = [area]"
/>
<span
v-if="field.type !== 'checkbox' || field.name"
ref="name"
:contenteditable="editable && !defaultField && field.type !== 'heading'"
dir="auto"
class="pr-1 cursor-text outline-none block"
style="min-width: 2px"
@paste.prevent="onPaste"
@keydown.enter.prevent="onNameEnter"
@focus="onNameFocus"
@blur="onNameBlur"
>{{ optionIndexText }} {{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || defaultName }}</span>
<div
v-if="isSettingsFocus || isSelectInput || (isValueInput && field.type !== 'heading') || (isNameFocus && !['checkbox', 'phone'].includes(field.type))"
class="flex items-center ml-1.5"
>
<input
v-if="!isValueInput && !isSelectInput"
:id="`required-checkbox-${field.uuid}`"
v-model="field.required"
type="checkbox"
class="checkbox checkbox-xs no-animation rounded"
@mousedown.prevent
>
<label
v-if="!isValueInput && !isSelectInput"
:for="`required-checkbox-${field.uuid}`"
class="label text-xs"
@click.prevent="field.required = !field.required"
@mousedown.prevent
>{{ t('required') }}</label>
<input
v-if="isValueInput || isSelectInput"
:id="`readonly-checkbox-${field.uuid}`"
type="checkbox"
class="checkbox checkbox-xs no-animation rounded"
:checked="!(field.readonly ?? true)"
@change="field.readonly = !(field.readonly ?? true)"
@mousedown.prevent
>
<label
v-if="isValueInput || isSelectInput"
:for="`readonly-checkbox-${field.uuid}`"
class="label text-xs"
@click.prevent="field.readonly = !(field.readonly ?? true)"
@mousedown.prevent
>{{ t('editable') }}</label>
<span
v-if="field.type !== 'payment' && !isValueInput"
class="dropdown dropdown-end field-area-settings-dropdown"
@mouseenter="renderDropdown = true"
@touchstart="renderDropdown = true"
>
<label
ref="settingsButton"
tabindex="0"
:title="t('settings')"
class="cursor-pointer flex items-center"
style="height: 25px"
@focus="isSettingsFocus = true"
@blur="maybeBlurSettings"
>
<IconDotsVertical class="w-5 h-5" />
</label>
<ul
v-if="renderDropdown"
ref="settingsDropdown"
tabindex="0"
class="dropdown-content menu menu-xs px-2 pb-2 pt-1 shadow rounded-box w-52 z-10 rounded-t-none"
:style="{ backgroundColor: 'white' }"
@dragstart.prevent.stop
@click="closeDropdown"
@focusout="maybeBlurSettings"
>
<FieldSettings
v-if="isMobile"
:field="field"
:default-field="defaultField"
:editable="editable"
:background-color="'white'"
:with-required="false"
:with-areas="false"
:with-signature-id="withSignatureId"
:with-prefillable="withPrefillable"
@click-formula="isShowFormulaModal = true"
@click-font="isShowFontModal = true"
@click-description="isShowDescriptionModal = true"
@add-custom-field="$emit('add-custom-field')"
@click-condition="isShowConditionsModal = true"
@save="$emit('change')"
@scroll-to="[selectedAreasRef.value = [$event], $emit('scroll-to', $event)]"
/>
<div
v-else
class="whitespace-normal"
>
The dots menu is retired in favor of the field context menu. Right-click the field to access field settings. Double-click the field to set a default value.
</div>
</ul>
</span>
</div>
<button
v-else-if="editable"
class="pr-1"
:title="t('remove')"
@click.prevent="$emit('remove')"
>
<IconX width="14" />
</button>
<Teleport
v-if="isShowFormulaModal"
:to="modalContainerEl"
>
<FormulaModal
:field="field"
:editable="editable && !defaultField"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@save="$emit('change')"
@close="isShowFormulaModal = false"
/>
</Teleport>
<Teleport
v-if="isShowFontModal"
:to="modalContainerEl"
>
<FontModal
:field="field"
:editable="editable && !defaultField"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@save="$emit('change')"
@close="isShowFontModal = false"
/>
</Teleport>
<Teleport
v-if="isShowConditionsModal"
:to="modalContainerEl"
>
<ConditionsModal
:item="field"
:build-default-name="buildDefaultName"
:default-field="defaultField"
@save="$emit('change')"
@close="isShowConditionsModal = false"
/>
</Teleport>
<Teleport
v-if="isShowDescriptionModal"
:to="modalContainerEl"
>
<DescriptionModal
:field="field"
:editable="editable && !defaultField"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@save="$emit('change')"
@close="isShowDescriptionModal = false"
/>
</Teleport>
</div>
</template>
<script>
import FieldSubmitter from './field_submitter'
import FieldType from './field_type'
import Field from './field'
import FieldSettings from './field_settings'
import FormulaModal from './formula_modal'
import FontModal from './font_modal'
import ConditionsModal from './conditions_modal'
import DescriptionModal from './description_modal'
import { IconX, IconDotsVertical } from '@tabler/icons-vue'
import { v4 } from 'uuid'
export default {
name: 'AreaTitle',
components: {
FieldType,
FieldSettings,
FormulaModal,
FontModal,
IconDotsVertical,
DescriptionModal,
ConditionsModal,
FieldSubmitter,
IconX
},
inject: ['t'],
props: {
template: {
type: Object,
required: true
},
selectedAreasRef: {
type: Object,
required: true
},
getFieldTypeIndex: {
type: Function,
required: true
},
area: {
type: Object,
required: true
},
field: {
type: Object,
required: false,
default: null
},
defaultField: {
type: Object,
required: false,
default: null
},
withSignatureId: {
type: Boolean,
required: false,
default: null
},
withPrefillable: {
type: Boolean,
required: false,
default: false
},
defaultSubmitters: {
type: Array,
required: false,
default: () => []
},
editable: {
type: Boolean,
required: false,
default: true
},
isMobile: {
type: Boolean,
required: false,
default: false
},
isValueInput: {
type: Boolean,
required: false,
default: false
},
isSelectInput: {
type: Boolean,
required: false,
default: false
}
},
emits: ['remove', 'scroll-to', 'add-custom-field', 'change'],
data () {
return {
isShowFormulaModal: false,
isShowFontModal: false,
isShowConditionsModal: false,
isShowDescriptionModal: false,
isSettingsFocus: false,
renderDropdown: false,
isNameFocus: false
}
},
computed: {
fieldNames: FieldType.computed.fieldNames,
fieldLabels: FieldType.computed.fieldLabels,
submitter () {
return this.template.submitters.find((s) => s.uuid === this.field.submitter_uuid)
},
isSelected () {
return this.selectedAreasRef.value.includes(this.area)
},
isInMultiSelection () {
return this.selectedAreasRef.value.length >= 2 && this.isSelected
},
optionIndexText () {
if (this.area.option_uuid && this.field.options) {
return `${this.field.options.findIndex((o) => o.uuid === this.area.option_uuid) + 1}.`
} else {
return ''
}
},
defaultName () {
return this.buildDefaultName(this.field)
},
modalContainerEl () {
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
}
},
methods: {
buildDefaultName: Field.methods.buildDefaultName,
closeDropdown () {
this.$el.getRootNode().activeElement.blur()
},
maybeBlurSettings (e) {
if (!e.relatedTarget || !this.$refs.settingsDropdown.contains(e.relatedTarget)) {
this.isSettingsFocus = false
}
},
onNameFocus (e) {
this.selectedAreasRef.value = [this.area]
this.isNameFocus = true
this.$refs.name.style.minWidth = this.$refs.name.clientWidth + 'px'
if (!this.field.name) {
setTimeout(() => {
this.$refs.name.innerText = ' '
}, 1)
}
},
onNameBlur (e) {
if (e.relatedTarget === this.$refs.settingsButton) {
this.isSettingsFocus = true
}
const text = this.$refs.name.innerText.trim()
this.isNameFocus = false
this.$refs.name.style.minWidth = ''
if (text) {
this.field.name = text
} else {
this.field.name = ''
this.$refs.name.innerText = this.defaultName
}
this.$emit('change')
},
onNameEnter (e) {
this.$refs.name.blur()
},
onPaste (e) {
const text = (e.clipboardData || window.clipboardData).getData('text/plain')
const selection = this.$el.getRootNode().getSelection()
if (selection.rangeCount) {
selection.deleteFromDocument()
selection.getRangeAt(0).insertNode(document.createTextNode(text))
selection.collapseToEnd()
}
},
maybeUpdateOptions () {
delete this.field.default_value
if (!['radio', 'multiple', 'select'].includes(this.field.type)) {
delete this.field.options
}
if (this.field.type === 'heading') {
this.field.readonly = true
}
if (this.field.type === 'strikethrough') {
this.field.readonly = true
this.field.default_value = true
}
if (['select', 'multiple', 'radio'].includes(this.field.type)) {
this.field.options ||= [{ value: '', uuid: v4() }]
}
(this.field.areas || []).forEach((area) => {
if (this.field.type === 'cells') {
area.cell_w = area.w * 2 / Math.floor(area.w / area.h)
} else {
delete area.cell_w
}
})
}
}
}
</script>
File diff suppressed because it is too large Load Diff
@@ -9,7 +9,7 @@
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
<span class="modal-title">
{{ t('condition') }} - {{ (defaultField ? (defaultField.title || item.title || item.name) : item.name) || buildDefaultName(item, template.fields) }}
{{ t('condition') }} - {{ (defaultField ? (defaultField.title || item.title || item.name) : item.name) || buildDefaultName(item) }}
</span>
<a
href="#"
@@ -83,7 +83,7 @@
class="text-base-content"
:selected="condition.field_uuid === f.uuid"
>
{{ f.name || buildDefaultName(f, template.fields) }}
{{ f.name || buildDefaultName(f) }}
</option>
</select>
<select
@@ -124,6 +124,16 @@
{{ option.value || `${t('option')} ${index + 1}` }}
</option>
</select>
<input
v-else-if="conditionField(condition)?.type === 'number' && ['equal', 'not_equal', 'greater_than', 'less_than'].includes(condition.action)"
v-model="condition.value"
type="number"
step="any"
class="input input-bordered input-sm w-full bg-white h-11 pl-4 text-base font-normal"
:class="{ 'text-gray-300': !condition.value }"
:placeholder="t('type_value')"
required
>
</div>
</div>
<a
@@ -154,7 +164,7 @@
<script>
export default {
name: 'ConditionModal',
inject: ['t', 'save', 'template', 'withConditions'],
inject: ['t', 'template', 'withConditions'],
props: {
item: {
type: Object,
@@ -168,9 +178,14 @@ export default {
buildDefaultName: {
type: Function,
required: true
},
excludeFieldUuids: {
type: Array,
required: false,
default: () => []
}
},
emits: ['close'],
emits: ['close', 'save'],
data () {
return {
conditions: this.item.conditions?.[0] ? JSON.parse(JSON.stringify(this.item.conditions)) : [{}]
@@ -183,14 +198,14 @@ export default {
fields () {
if (this.item.submitter_uuid) {
return this.template.fields.reduce((acc, f) => {
if (f !== this.item && !this.excludeTypes.includes(f.type) && (!f.conditions?.length || !f.conditions.find((c) => c.field_uuid === this.item.uuid))) {
if (f !== this.item && !this.excludeTypes.includes(f.type) && !this.excludeFieldUuids.includes(f.uuid) && (!f.conditions?.length || !f.conditions.find((c) => c.field_uuid === this.item.uuid))) {
acc.push(f)
}
return acc
}, [])
} else {
return this.template.fields
return this.template.fields.filter((f) => !this.excludeFieldUuids.includes(f.uuid))
}
}
},
@@ -217,6 +232,8 @@ export default {
actions.push('equal', 'not_equal')
} else if (['multiple'].includes(field.type)) {
actions.push('contains', 'does_not_contain')
} else if (field.type === 'number') {
actions.push('not_empty', 'empty', 'equal', 'not_equal', 'greater_than', 'less_than')
} else {
actions.push('not_empty', 'empty')
}
@@ -234,7 +251,7 @@ export default {
delete this.item.conditions
}
this.save()
this.$emit('save')
this.$emit('close')
}
}
@@ -6,18 +6,24 @@
<span
ref="contenteditable"
dir="auto"
:contenteditable="editable"
style="min-width: 2px"
:class="[iconInline ? 'inline' : 'block', hideIcon ? 'focus:block' : '']"
class="peer outline-none"
:contenteditable="editable && (!editableOnButton || isEditable)"
:data-placeholder="placeholder"
:data-empty="isEmpty"
:style="{ minWidth }"
:class="[iconInline ? (isEmpty ? 'inline-block' : 'inline') : 'block', hideIcon ? 'focus:block' : '']"
class="peer relative inline-block outline-none before:pointer-events-none before:absolute before:left-0 before:top-0 before:select-none before:whitespace-pre before:text-neutral-400 before:content-[attr(data-placeholder)] before:opacity-0 data-[empty=true]:before:opacity-100"
@paste.prevent="onPaste"
@keydown.enter.prevent="blurContenteditable"
@input="updateInputValue"
@cut="updateInputValue"
@focus="$emit('focus', $event)"
@blur="onBlur"
@click="editable && (!editableOnButton || isEditable) ? '' : $emit('click-contenteditable')"
>
{{ value }}
</span>
<span
v-if="withButton"
class="relative inline"
:class="{ 'peer-focus:hidden': hideIcon, 'peer-focus:invisible': !hideIcon }"
>
@@ -28,7 +34,7 @@
:class="{ invisible: !editable, 'absolute top-1/2 -translate-y-1/2': !iconInline || floatIcon, 'inline align-bottom': iconInline, 'left-0': floatIcon }"
:width="iconWidth + 4"
:stroke-width="iconStrokeWidth"
@click="[focusContenteditable(), selectOnEditClick && selectContent()]"
@click="clickEdit"
/>
</span>
</div>
@@ -49,6 +55,16 @@ export default {
required: false,
default: ''
},
placeholder: {
type: String,
required: false,
default: ''
},
withButton: {
type: Boolean,
required: false,
default: true
},
iconInline: {
type: Boolean,
required: false,
@@ -74,6 +90,16 @@ export default {
required: false,
default: false
},
editableOnButton: {
type: Boolean,
required: false,
default: false
},
minWidth: {
type: String,
required: false,
default: '2px'
},
editable: {
type: Boolean,
required: false,
@@ -85,21 +111,34 @@ export default {
default: 2
}
},
emits: ['update:model-value', 'focus', 'blur'],
emits: ['update:model-value', 'focus', 'blur', 'click-contenteditable'],
data () {
return {
isEditable: false,
inputValue: '',
value: ''
}
},
computed: {
isEmpty () {
return !this.inputValue.replace(/\u200B/g, '').replace(/\u00A0/g, ' ').trim()
}
},
watch: {
modelValue: {
handler (value) {
this.value = value
this.value = value || ''
},
immediate: true
}
},
mounted () {
this.updateInputValue()
},
methods: {
updateInputValue () {
this.inputValue = this.$refs.contenteditable?.textContent || ''
},
onPaste (e) {
const text = (e.clipboardData || window.clipboardData).getData('text/plain')
@@ -110,6 +149,20 @@ export default {
selection.getRangeAt(0).insertNode(document.createTextNode(text))
selection.collapseToEnd()
}
this.updateInputValue()
},
clickEdit (e) {
this.focusContenteditable()
if (this.selectOnEditClick) {
this.selectContent()
}
},
setText (text) {
this.$refs.contenteditable.innerText = text
this.updateInputValue()
},
selectContent () {
const el = this.$refs.contenteditable
@@ -126,13 +179,24 @@ export default {
},
onBlur (e) {
setTimeout(() => {
this.value = this.$refs.contenteditable.innerText.trim() || this.modelValue
this.$emit('update:model-value', this.value)
if (this.$refs.contenteditable) {
this.value = this.$refs.contenteditable.innerText.trim() || this.modelValue
this.$emit('update:model-value', this.value)
}
this.$emit('blur', e)
this.isEditable = false
}, 1)
},
focusContenteditable () {
this.$refs.contenteditable.focus()
this.isEditable = true
this.$nextTick(() => {
this.$refs.contenteditable.focus()
this.updateInputValue()
})
},
blurContenteditable () {
this.$refs.contenteditable.blur()
+1 -1
View File
@@ -66,7 +66,7 @@ export default {
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf, application/zip'
default: 'image/*, application/pdf, application/zip, application/json'
},
withReplaceButton: {
type: Boolean,
@@ -0,0 +1,271 @@
<template>
<div
class="list-field group"
>
<div
class="border border-dashed border-base-300 hover:border-base-content/20 rounded relative group fields-list-item transition-colors"
:style="{ backgroundColor: backgroundColor }"
>
<div class="flex items-center justify-between relative group/contenteditable-container">
<div
v-if="!isNew"
class="absolute top-0 bottom-0 right-0 left-0 cursor-pointer"
@click="$emit('click', field)"
/>
<div
class="absolute top-0 bottom-0 left-0 flex items-center transition-all cursor-grab group-hover:bg-base-200/50"
@click="$emit('click', field)"
>
<IconDrag style="margin-left: 1px" />
</div>
<div class="flex items-center p-1 pl-6 space-x-1">
<FieldType
v-model="field.type"
:editable="false"
:button-width="20"
@click="$emit('click', field)"
/>
<Contenteditable
ref="name"
:model-value="field.name"
:placeholder="'Field Name'"
:icon-inline="true"
:icon-width="18"
:min-width="isNew ? '100px' : '2px'"
:icon-stroke-width="1.6"
:editable-on-button="!isNew"
:with-button="!isNew"
:class="{ 'cursor-pointer': !isNew }"
@click-contenteditable="$emit('click', field)"
@focus="onNameFocus"
@blur="onNameBlur"
/>
</div>
<div
class="flex items-center space-x-1"
>
<PaymentSettings
v-if="field.type === 'payment' && !isNew"
:field="field"
:with-condition="false"
:with-force-open="false"
@click-description="isShowDescriptionModal = true"
@click-formula="isShowFormulaModal = true"
/>
<span
v-else-if="!isNew"
class="dropdown dropdown-end field-settings-dropdown"
@mouseenter="renderDropdown = true"
@touchstart="renderDropdown = true"
>
<label
tabindex="0"
:title="t('settings')"
class="cursor-pointer text-transparent group-hover:text-base-content"
>
<IconSettings
:width="18"
:stroke-width="1.6"
/>
</label>
<ul
v-if="renderDropdown"
tabindex="0"
class="mt-1.5 dropdown-content menu menu-xs p-2 shadow rounded-box w-52 z-10"
:style="{ backgroundColor: dropdownBgColor }"
draggable="true"
@dragstart.prevent.stop
@click="closeDropdown"
>
<FieldSettings
:field="field"
:with-signature-id="withSignatureId"
:with-prefillable="withPrefillable"
:background-color="dropdownBgColor"
:with-areas="false"
:with-copy-to-all-pages="false"
:with-condition="false"
@click-formula="isShowFormulaModal = true"
@click-font="isShowFontModal = true"
@click-description="isShowDescriptionModal = true"
@save="$emit('save')"
/>
</ul>
</span>
<button
v-if="isNew && !$refs.name"
class="relative text-base-content pr-1 field-save-button"
:title="t('save')"
@click="field.name ? $emit('save', field) : focusName()"
>
<IconCheck
:width="18"
:stroke-width="2"
/>
</button>
<button
class="relative group-hover:text-base-content pr-1 field-remove-button"
:class="isNew ? 'text-base-content' : 'text-transparent group-hover:text-base-content'"
:title="t('remove')"
@click="onRemoveClick"
>
<IconTrashX
:width="18"
:stroke-width="1.6"
/>
</button>
</div>
</div>
</div>
<Teleport
v-if="isShowFormulaModal"
:to="modalContainerEl"
>
<FormulaModal
:field="field"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@save="$emit('save')"
@close="isShowFormulaModal = false"
/>
</Teleport>
<Teleport
v-if="isShowFontModal"
:to="modalContainerEl"
>
<FontModal
:field="field"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@save="$emit('save')"
@close="isShowFontModal = false"
/>
</Teleport>
<Teleport
v-if="isShowDescriptionModal"
:to="modalContainerEl"
>
<DescriptionModal
:field="field"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@save="$emit('save')"
@close="isShowDescriptionModal = false"
/>
</Teleport>
</div>
</template>
<script>
import Contenteditable from './contenteditable'
import FieldType from './field_type'
import PaymentSettings from './payment_settings'
import FieldSettings from './field_settings'
import FormulaModal from './formula_modal'
import FontModal from './font_modal'
import DescriptionModal from './description_modal'
import { IconTrashX, IconSettings, IconCheck } from '@tabler/icons-vue'
import IconDrag from './icon_drag'
export default {
name: 'CustomField',
components: {
Contenteditable,
IconSettings,
IconCheck,
FieldSettings,
PaymentSettings,
IconDrag,
FormulaModal,
FontModal,
DescriptionModal,
IconTrashX,
FieldType
},
inject: ['backgroundColor', 't'],
props: {
field: {
type: Object,
required: true
},
isNew: {
type: Boolean,
required: false,
default: false
},
withSignatureId: {
type: Boolean,
required: false,
default: null
},
withPrefillable: {
type: Boolean,
required: false,
default: false
}
},
emits: ['remove', 'save', 'click'],
data () {
return {
isShowFormulaModal: false,
isShowFontModal: false,
isShowDescriptionModal: false,
renderDropdown: false
}
},
computed: {
dropdownBgColor () {
return ['', null, 'transparent'].includes(this.backgroundColor) ? 'white' : this.backgroundColor
},
modalContainerEl () {
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
}
},
created () {
this.field.preferences ||= {}
},
mounted () {
if (this.isNew) {
this.focusName()
}
},
methods: {
buildDefaultName () {
return this.t('custom')
},
focusName () {
setTimeout(() => {
this.$refs.name.clickEdit()
}, 1)
},
onNameFocus (e) {
if (!this.field.name) {
setTimeout(() => {
this.$refs.name.$refs.contenteditable.innerText = ' '
}, 1)
}
},
closeDropdown () {
this.$el.getRootNode().activeElement.blur()
},
onRemoveClick () {
if (this.isNew || window.confirm(this.t('are_you_sure_'))) {
this.$emit('remove', this.field)
}
},
onNameBlur (e) {
const text = this.$refs.name.$refs.contenteditable.innerText.trim()
if (text) {
this.field.name = text
} else {
this.$refs.name.setText(this.field.name)
}
if (this.field.name) {
this.$emit('save')
}
}
}
}
</script>

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