diff --git a/.rubocop.yml b/.rubocop.yml index e3315548..cfcd7b3e 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -63,7 +63,7 @@ RSpec/MultipleExpectations: Max: 25 RSpec/ExampleLength: - Max: 50 + Max: 500 RSpec/MultipleMemoizedHelpers: Max: 15 diff --git a/app/controllers/start_form_controller.rb b/app/controllers/start_form_controller.rb index 1cecfba6..d71fba64 100644 --- a/app/controllers/start_form_controller.rb +++ b/app/controllers/start_form_controller.rb @@ -6,12 +6,15 @@ class StartFormController < ApplicationController skip_before_action :authenticate_user! skip_authorization_check - around_action :with_browser_locale, only: %i[show completed] + around_action :with_browser_locale, only: %i[show update completed] before_action :maybe_redirect_com, only: %i[show completed] before_action :load_resubmit_submitter, only: :update before_action :load_template before_action :authorize_start!, only: :update + COOKIES_TTL = 12.hours + COOKIES_DEFAULTS = { httponly: true, secure: Rails.env.production? }.freeze + def show raise ActionController::RoutingError, I18n.t('not_found') if @template.preferences['require_phone_2fa'] @@ -20,6 +23,7 @@ class StartFormController < ApplicationController .submitters.new(account_id: @template.account_id, uuid: (filter_undefined_submitters(@template).first || @template.submitters.first)['uuid']) + render :email_verification if params[:email_verification] else Rollbar.warning("Not shared template: #{@template.id}") if defined?(Rollbar) @@ -49,17 +53,10 @@ class StartFormController < ApplicationController @submitter.assign_attributes(ip: request.remote_ip, ua: request.user_agent) end - if @submitter.errors.blank? && @submitter.save - if is_new_record - WebhookUrls.enqueue_events(@submitter.submission, 'submission.created') - - SearchEntries.enqueue_reindex(@submitter) - - if @submitter.submission.expire_at? - ProcessSubmissionExpiredJob.perform_at(@submitter.submission.expire_at, - 'submission_id' => @submitter.submission_id) - end - end + if @template.preferences['shared_link_2fa'] == true + handle_require_2fa(@submitter, is_new_record:) + elsif @submitter.errors.blank? && @submitter.save + enqueue_new_submitter_jobs(@submitter) if is_new_record redirect_to submit_form_path(@submitter.slug) else @@ -89,6 +86,16 @@ class StartFormController < ApplicationController private + def enqueue_new_submitter_jobs(submitter) + WebhookUrls.enqueue_events(submitter.submission, 'submission.created') + + SearchEntries.enqueue_reindex(submitter) + + return unless submitter.submission.expire_at? + + ProcessSubmissionExpiredJob.perform_at(submitter.submission.expire_at, 'submission_id' => submitter.submission_id) + end + def load_resubmit_submitter @resubmit_submitter = if params[:resubmit].present? && !params[:resubmit].in?([true, 'true']) @@ -123,7 +130,7 @@ class StartFormController < ApplicationController .order(id: :desc) .where(declined_at: nil) .where(external_id: nil) - .where(ip: [nil, request.remote_ip]) + .where(template.preferences['shared_link_2fa'] == true ? {} : { ip: [nil, request.remote_ip] }) .then { |rel| params[:resubmit].present? || params[:selfsign].present? ? rel.where(completed_at: nil) : rel } .find_or_initialize_by(find_params) @@ -173,7 +180,7 @@ class StartFormController < ApplicationController end def submitter_params - return current_user.slice(:email) if params[:selfsign] + return { 'email' => current_user.email, 'name' => current_user.full_name } if params[:selfsign] return @resubmit_submitter.slice(:name, :phone, :email) if @resubmit_submitter.present? params.require(:submitter).permit(:email, :phone, :name).tap do |attrs| @@ -197,4 +204,39 @@ class StartFormController < ApplicationController I18n.t('not_found') end end + + def handle_require_2fa(submitter, is_new_record:) + return render :show, status: :unprocessable_entity if submitter.errors.present? + + is_otp_verified = Submitters.verify_link_otp!(params[:one_time_code], submitter) + + if cookies.encrypted[:email_2fa_slug] == submitter.slug || is_otp_verified + if submitter.save + enqueue_new_submitter_jobs(submitter) if is_new_record + + if is_otp_verified + SubmissionEvents.create_with_tracking_data(submitter, 'email_verified', request) + + cookies.encrypted[:email_2fa_slug] = + { value: submitter.slug, expires: COOKIES_TTL.from_now, **COOKIES_DEFAULTS } + end + + redirect_to submit_form_path(submitter.slug) + else + render :show, status: :unprocessable_entity + end + else + Submitters.send_shared_link_email_verification_code(submitter, request:) + + render :email_verification + end + rescue Submitters::UnableToSendCode, Submitters::InvalidOtp => e + redirect_to start_form_path(submitter.submission.template.slug, + params: submitter_params.merge(email_verification: true)), + alert: e.message + rescue RateLimit::LimitApproached + redirect_to start_form_path(submitter.submission.template.slug, + params: submitter_params.merge(email_verification: true)), + alert: I18n.t(:too_many_attempts) + end end diff --git a/app/controllers/start_form_email_2fa_send_controller.rb b/app/controllers/start_form_email_2fa_send_controller.rb new file mode 100644 index 00000000..6359debd --- /dev/null +++ b/app/controllers/start_form_email_2fa_send_controller.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class StartFormEmail2faSendController < ApplicationController + around_action :with_browser_locale + + skip_before_action :authenticate_user! + skip_authorization_check + + def create + @template = Template.find_by!(slug: params[:slug]) + + @submitter = @template.submissions.new(account_id: @template.account_id) + .submitters.new(**submitter_params, account_id: @template.account_id) + + Submitters.send_shared_link_email_verification_code(@submitter, request:) + + redir_params = { notice: I18n.t(:code_has_been_resent) } if params[:resend] + + redirect_to start_form_path(@template.slug, params: submitter_params.merge(email_verification: true)), + **redir_params + rescue Submitters::UnableToSendCode => e + redirect_to start_form_path(@template.slug, params: submitter_params.merge(email_verification: true)), + alert: e.message + end + + private + + def submitter_params + params.require(:submitter).permit(:name, :email, :phone) + end +end diff --git a/app/controllers/submission_events_controller.rb b/app/controllers/submission_events_controller.rb index 9dbd57ed..14cf5321 100644 --- a/app/controllers/submission_events_controller.rb +++ b/app/controllers/submission_events_controller.rb @@ -12,6 +12,7 @@ class SubmissionEventsController < ApplicationController 'send_2fa_sms' => '2fa', 'send_sms' => 'send', 'phone_verified' => 'phone_check', + 'email_verified' => 'email_check', 'click_sms' => 'hand_click', 'decline_form' => 'x', 'start_verification' => 'player_play', diff --git a/app/controllers/submit_form_controller.rb b/app/controllers/submit_form_controller.rb index 46b28128..44d10445 100644 --- a/app/controllers/submit_form_controller.rb +++ b/app/controllers/submit_form_controller.rb @@ -9,6 +9,7 @@ class SubmitFormController < ApplicationController before_action :load_submitter, only: %i[show update completed] before_action :maybe_render_locked_page, only: :show + before_action :maybe_require_link_2fa, only: %i[show update] CONFIG_KEYS = [].freeze @@ -50,7 +51,7 @@ class SubmitFormController < ApplicationController return render json: { error: I18n.t('form_has_been_completed_already') }, status: :unprocessable_entity end - if @submitter.template&.archived_at? || @submitter.submission.archived_at? + if @submitter.submission.template&.archived_at? || @submitter.submission.archived_at? return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_entity end @@ -80,6 +81,15 @@ class SubmitFormController < ApplicationController 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 + + redirect_to start_form_path(@submitter.submission.template.slug) + end + def maybe_render_locked_page return render :archived if @submitter.submission.template&.archived_at? || @submitter.submission.archived_at? || diff --git a/app/controllers/templates_preferences_controller.rb b/app/controllers/templates_preferences_controller.rb index eae2fa9f..31ac7190 100644 --- a/app/controllers/templates_preferences_controller.rb +++ b/app/controllers/templates_preferences_controller.rb @@ -26,7 +26,7 @@ class TemplatesPreferencesController < ApplicationController completed_notification_email_attach_documents completed_redirect_url validate_unique_submitters require_all_submitters submitters_order require_phone_2fa - default_expire_at_duration + default_expire_at_duration shared_link_2fa default_expire_at completed_notification_email_subject completed_notification_email_body completed_notification_email_enabled completed_notification_email_attach_audit] + diff --git a/app/controllers/webhook_events_controller.rb b/app/controllers/webhook_events_controller.rb index fbb81b9a..55bcb4ea 100644 --- a/app/controllers/webhook_events_controller.rb +++ b/app/controllers/webhook_events_controller.rb @@ -52,7 +52,7 @@ class WebhookEventsController < ApplicationController turbo_stream.replace(helpers.dom_id(@webhook_event), partial: 'event_row', locals: { with_status: true, webhook_url: @webhook_url, webhook_event: @webhook_event }), - turbo_stream.replace("drawer_events_#{helpers.dom_id(@webhook_event)}", + turbo_stream.replace(helpers.dom_id(@webhook_event, :drawer_events), partial: 'drawer_events', locals: { webhook_url: @webhook_url, webhook_event: @webhook_event }) ] diff --git a/app/javascript/application.js b/app/javascript/application.js index 6ff4b401..af363801 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -36,6 +36,7 @@ import IndeterminateCheckbox from './elements/indeterminate_checkbox' import AppTour from './elements/app_tour' import DashboardDropzone from './elements/dashboard_dropzone' import RequiredCheckboxGroup from './elements/required_checkbox_group' +import PageContainer from './elements/page_container' import * as TurboInstantClick from './lib/turbo_instant_click' @@ -107,6 +108,7 @@ safeRegisterElement('app-tour', AppTour) safeRegisterElement('dashboard-dropzone', DashboardDropzone) safeRegisterElement('check-on-click', CheckOnClick) safeRegisterElement('required-checkbox-group', RequiredCheckboxGroup) +safeRegisterElement('page-container', PageContainer) safeRegisterElement('template-builder', class extends HTMLElement { connectedCallback () { diff --git a/app/javascript/application.scss b/app/javascript/application.scss index 61d3f645..1ab92498 100644 --- a/app/javascript/application.scss +++ b/app/javascript/application.scss @@ -147,3 +147,11 @@ button[disabled] .enabled { outline-offset: 3px; outline-color: hsl(var(--bc) / 0.2); } + +.font-times { + font-family: "Times New Roman", Times, ui-serif, serif, Cambria, Georgia; +} + +.font-courier { + font-family: "Courier New", Consolas, "Liberation Mono", monospace, ui-monospace, SFMono-Regular, Menlo, Monaco; +} diff --git a/app/javascript/elements/dashboard_dropzone.js b/app/javascript/elements/dashboard_dropzone.js index 60cd0b91..888c99ee 100644 --- a/app/javascript/elements/dashboard_dropzone.js +++ b/app/javascript/elements/dashboard_dropzone.js @@ -98,13 +98,13 @@ export default targetable(class extends HTMLElement { el.classList.add('opacity-50') if (e.dataTransfer.files.length) { - const params = new URLSearchParams({ folder_name: el.innerText }).toString() + const params = new URLSearchParams({ folder_name: el.innerText.trim() }).toString() this.uploadFiles(e.dataTransfer.files, `/templates_upload?${params}`) } else { const formData = new FormData() - formData.append('name', el.innerText) + formData.append('name', el.innerText.trim()) fetch(`/templates/${templateId}/folder`, { method: 'PUT', diff --git a/app/javascript/elements/page_container.js b/app/javascript/elements/page_container.js new file mode 100644 index 00000000..d909ae7f --- /dev/null +++ b/app/javascript/elements/page_container.js @@ -0,0 +1,14 @@ +export default class extends HTMLElement { + connectedCallback () { + this.image.addEventListener('load', (e) => { + this.image.setAttribute('width', e.target.naturalWidth) + this.image.setAttribute('height', e.target.naturalHeight) + + this.style.aspectRatio = `${e.target.naturalWidth} / ${e.target.naturalHeight}` + }) + } + + get image () { + return this.querySelector('img') + } +} diff --git a/app/javascript/form.js b/app/javascript/form.js index 45b3b3d2..ac49174d 100644 --- a/app/javascript/form.js +++ b/app/javascript/form.js @@ -5,6 +5,7 @@ import DownloadButton from './elements/download_button' import ToggleSubmit from './elements/toggle_submit' import FetchForm from './elements/fetch_form' import ScrollButtons from './elements/scroll_buttons' +import PageContainer from './elements/page_container' const safeRegisterElement = (name, element, options = {}) => !window.customElements.get(name) && window.customElements.define(name, element, options) @@ -12,6 +13,7 @@ safeRegisterElement('download-button', DownloadButton) safeRegisterElement('toggle-submit', ToggleSubmit) safeRegisterElement('fetch-form', FetchForm) safeRegisterElement('scroll-buttons', ScrollButtons) +safeRegisterElement('page-container', PageContainer) safeRegisterElement('submission-form', class extends HTMLElement { connectedCallback () { this.appElem = document.createElement('div') diff --git a/app/javascript/form.scss b/app/javascript/form.scss index fb651155..f0d26baf 100644 --- a/app/javascript/form.scss +++ b/app/javascript/form.scss @@ -70,3 +70,11 @@ button[disabled] .enabled { .base-radio { @apply radio bg-white radio-sm; } + +.font-times { + font-family: "Times New Roman", Times, ui-serif, serif, Cambria, Georgia; +} + +.font-courier { + font-family: "Courier New", Consolas, "Liberation Mono", monospace, ui-monospace, SFMono-Regular, Menlo, Monaco; +} diff --git a/app/javascript/submission_form/area.vue b/app/javascript/submission_form/area.vue index 5aed2a7a..bc5c255b 100644 --- a/app/javascript/submission_form/area.vue +++ b/app/javascript/submission_form/area.vue @@ -2,8 +2,8 @@
{ if (['date', 'text', 'number'].includes(this.field.type) && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > `${this.modelValue}`.length)) { - this.textOverflowChars = this.$refs.textContainer.scrollHeight > this.$refs.textContainer.clientHeight ? `${this.modelValue || (this.withFieldPlaceholder ? this.field.name : '')}`.length : 0 + this.textOverflowChars = this.$refs.textContainer.scrollHeight > (this.$refs.textContainer.clientHeight + 1) ? `${this.modelValue || (this.withFieldPlaceholder ? this.field.name : '')}`.length : 0 } }) } @@ -464,7 +495,7 @@ export default { mounted () { this.$nextTick(() => { if (['date', 'text', 'number'].includes(this.field.type) && this.$refs.textContainer) { - this.textOverflowChars = this.$refs.textContainer.scrollHeight > this.$refs.textContainer.clientHeight ? `${this.modelValue || (this.withFieldPlaceholder ? this.field.name : '')}`.length : 0 + this.textOverflowChars = this.$refs.textContainer.scrollHeight > (this.$refs.textContainer.clientHeight + 1) ? `${this.modelValue || (this.withFieldPlaceholder ? this.field.name : '')}`.length : 0 } }) }, diff --git a/app/javascript/submission_form/areas.vue b/app/javascript/submission_form/areas.vue index 67ac0bdd..e14087ec 100644 --- a/app/javascript/submission_form/areas.vue +++ b/app/javascript/submission_form/areas.vue @@ -23,6 +23,7 @@ :area="area" :submittable="submittable" :field-index="fieldIndex" + :is-inline-size="isInlineSize" :scroll-padding="scrollPadding" :submitter="submitter" :with-field-placeholder="withFieldPlaceholder" @@ -110,6 +111,9 @@ export default { } }, computed: { + isInlineSize () { + return CSS.supports('container-type: size') + }, isMobileContainer () { const root = this.$root.$el.parentNode.getRootNode() const container = root.body || root.querySelector('div') diff --git a/app/javascript/submission_form/dropzone.vue b/app/javascript/submission_form/dropzone.vue index 6bb256ed..29ec6798 100644 --- a/app/javascript/submission_form/dropzone.vue +++ b/app/javascript/submission_form/dropzone.vue @@ -95,7 +95,19 @@ export default { }, methods: { onDropFiles (e) { - this.uploadFiles(e.dataTransfer.files) + const files = Array.from(e.dataTransfer.files).filter((f) => { + if (this.accept === 'image/*') { + return f.type.startsWith('image') + } else { + return true + } + }) + + if (this.accept === 'image/*' && !files.length) { + alert(this.t('please_upload_an_image_file')) + } else { + this.uploadFiles(files) + } }, onSelectFiles (e) { e.preventDefault() diff --git a/app/javascript/submission_form/form.vue b/app/javascript/submission_form/form.vue index 358c0396..d9d9e176 100644 --- a/app/javascript/submission_form/form.vue +++ b/app/javascript/submission_form/form.vue @@ -67,7 +67,7 @@