Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ module Events
class BulkPaymentFormSubmissionsController < ApplicationController
skip_before_action :authenticate_user!, only: [ :new, :create, :show, :ticket, :resend_confirmation ]
before_action :set_event, only: [ :new, :create, :show ]
before_action :ensure_public_or_authenticated, only: [ :new, :create ]
before_action :set_form, only: [ :new, :create ]

def new
Expand Down Expand Up @@ -118,6 +119,14 @@ def set_form
end
end

# Anonymous visitors may only reach the public bulk-payment form when public
# registration is enabled; signed-in users (and admins) always may. Mirrors
# Events::PublicRegistrationPolicy for the registration/scholarship forms.
def ensure_public_or_authenticated
return if current_user.present? || @event.public_registration_enabled?
redirect_to event_path(@event), alert: "#{Form::BULK_PAYMENT_PUBLIC_NAME} form is not open for public submissions."
end

def validate_required_fields
# The nested attendees field is validated separately, so exclude it here.
fields = visible_form_fields.reject { |field| field.field_identifier == "bulk_payment_attendees" }
Expand Down
4 changes: 2 additions & 2 deletions app/controllers/events/public_registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ class PublicRegistrationsController < ApplicationController
before_action :ensure_registerable, only: [ :new, :create ]

def new
authorize! :public_registration, to: :new?
authorize! @event, to: :new?, with: Events::PublicRegistrationPolicy

@registration_form = registration_form
unless @registration_form
Expand All @@ -21,7 +21,7 @@ def new
end

def create
authorize! :public_registration, to: :create?
authorize! @event, to: :create?, with: Events::PublicRegistrationPolicy

if params[:public_registration][:website_url].present?
redirect_to new_event_public_registration_path(@event)
Expand Down
9 changes: 7 additions & 2 deletions app/policies/events/public_registration_policy.rb
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
class Events::PublicRegistrationPolicy < ApplicationPolicy
# Anonymous visitors may only reach the public registration/scholarship form
# when public registration is enabled for the event. Signed-in users (and
# admins, who are always signed in) may always reach it β€” mirroring the
# register button, which shows for any signed-in user on a registerable event.
# `record` is the event (authorized via `authorize! @event, with: self`).
def new?
true
admin? || record.public_registration_enabled? || authenticated?
end

def create?
true
new?
end

def show?
Expand Down
2 changes: 1 addition & 1 deletion app/views/events/_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@
</div>

<div>
<label class="flex items-center gap-2 cursor-pointer"><%= f.check_box :autoshow_registration_details %><span class="text-sm font-medium text-gray-700">Show event details on registration page</span></label>
<label class="flex items-center gap-2 cursor-pointer"><%= f.check_box :autoshow_registration_details %><span class="text-sm font-medium text-gray-700">Show event details at top of registration form</span></label>
<p class="text-xs text-gray-500 mt-1">Add an at-a-glance list of dates, time, platform/location, fee, and deadline above the registration form. Pulled from this event's details.</p>

<div class="mt-2 space-y-2 pl-6">
Expand Down
39 changes: 29 additions & 10 deletions app/views/events/_form_actions_menu.html.erb
Original file line number Diff line number Diff line change
@@ -1,33 +1,52 @@
<% button_label = local_assigns.fetch(:button_label, "Forms") %>
<% manage_label = local_assigns.fetch(:manage_label, "Edit form settings") %>
<% sample_return_to = local_assigns.fetch(:sample_return_to, "registrants") %>
<% dropdown_id = "form-actions-menu-#{SecureRandom.hex(4)}" %>
<% item_class = "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50" %>
<div data-controller="dropdown" class="relative">
<button type="button"
data-action="dropdown#toggle"
data-dropdown-payload-param='[{"<%= dropdown_id %>":"hidden"}]'
class="btn btn-utility-outline">
<span>Form actions</span>
<span><%= button_label %></span>
<i class="fa-solid fa-chevron-down w-3 h-3 text-gray-400"></i>
</button>

<div id="<%= dropdown_id %>"
data-dropdown-target="content"
class="hidden absolute right-0 z-10 mt-1 bg-white border border-gray-200 rounded-md shadow-lg py-1 min-w-[180px]">
<% if @event.event_forms.registration.exists? || @event.scholarship_form || @event.bulk_payment_form %>
<% if @event.event_forms.registration.exists? %>
<%= link_to "Public registration", new_event_public_registration_path(@event), class: item_class, target: "_blank", rel: "noopener noreferrer" %>
<%# Menu wording and which links appear both key off the event's form settings:
- Links open the public-facing form pages. When public registration is on,
each is prefixed "Public …" to signal it's exposed to anonymous visitors.
- Registration link: shown when a registration form is selected AND that
form page is actually reachable by someone β€” i.e. public registration is
on (anonymous use it) OR one-click is off (signed-in users use it). When
one-click is on and public registration is off, nobody hits the form page
(signed-in users register in one click; anonymous are blocked), so hide it.
- Scholarship / bulk payment only make sense once there's a fee, so also
gate them on the event having a cost. %>
<% has_cost = @event.cost_cents.to_i.positive? %>
<% public_form = @event.public_registration_enabled? %>
<% form_label = ->(name) { public_form ? "Public #{name} form" : "#{name.capitalize} form" } %>
<% show_registration = @event.registration_form.present? && (public_form || !@event.signed_in_one_click_enabled?) %>
<% show_scholarship = @event.scholarship_form.present? && has_cost %>
<% show_bulk_payment = @event.bulk_payment_form.present? && has_cost %>
<% if @event.registration_form.present? || @event.scholarship_form || @event.bulk_payment_form %>
<% if show_registration %>
<%= link_to form_label.call("registration"), new_event_public_registration_path(@event), class: item_class, target: "_blank", rel: "noopener noreferrer" %>
<% end %>
<% if @event.scholarship_form %>
<%= link_to "Scholarship version", new_event_public_registration_path(@event, scholarship_requested: true), class: item_class, target: "_blank", rel: "noopener noreferrer" %>
<% if show_scholarship %>
<%= link_to form_label.call("scholarship"), new_event_public_registration_path(@event, scholarship_requested: true), class: item_class, target: "_blank", rel: "noopener noreferrer" %>
<% end %>
<% if @event.bulk_payment_form %>
<%= link_to "Bulk payment form", new_event_bulk_payment_path(@event), class: item_class, target: "_blank", rel: "noopener noreferrer" %>
<% if show_bulk_payment %>
<%= link_to form_label.call("bulk payment"), new_event_bulk_payment_path(@event), class: item_class, target: "_blank", rel: "noopener noreferrer" %>
<% end %>
<div class="my-1 border-t border-gray-100"></div>
<%= link_to "Change form settings", edit_event_path(@event, anchor: "registration_form_section"), class: item_class %>
<%= link_to manage_label, edit_event_path(@event, anchor: "registration_form_section"), class: item_class %>
<% else %>
<%= link_to "Enable forms", edit_event_path(@event, anchor: "registration_form_section"), class: item_class %>
<% end %>
<div class="my-1 border-t border-gray-100"></div>
<%= link_to "Sample ticket", sample_ticket_event_path(@event, return_to: "registrants"), class: item_class, target: "_blank", rel: "noopener noreferrer" %>
<%= link_to "Sample ticket", sample_ticket_event_path(@event, return_to: sample_return_to), class: item_class, target: "_blank", rel: "noopener noreferrer" %>
</div>
</div>
46 changes: 9 additions & 37 deletions app/views/events/dashboard.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@
<%= render "events/subnav", event: @event, current: :dashboard %>
</div>

<%# Forms dropdown β€” sits top-right below the sub-nav, holding the public form
links, the sample-ticket preview, and "Edit form settings" (the Edit-event
form settings section). %>
<% if allowed_to?(:edit?, @event) %>
<div class="flex justify-end mb-6">
<%= render "form_actions_menu", sample_return_to: "dashboard" %>
</div>
<% end %>

<%# Centered title block %>
<div class="text-center mb-8">
<%= link_to @event.decorate.compact_label, edit_event_path(@event), title: @event.title, class: "text-sm font-semibold text-gray-500 uppercase tracking-wide hover:text-gray-700" %>
Expand All @@ -20,43 +29,6 @@
</h1>
</div>

<%# Quick links: registration forms and bulk payment. Each form link is gated by
its event setting β€” registration on an associated registration form, scholarship
on that form plus the "Enable scholarship application" checkbox (which is what
creates the scholarship event_form). The scholarship link points at the public
registration page, so it also needs the registration form or it dead-ends there.
Bulk payment is gated on the event having a bulk payment form.
Links open in a new tab. %>
<% link_base = "inline-flex items-center gap-2 rounded-lg border bg-white px-3 py-2 text-sm font-medium shadow-sm transition-colors" %>
<div class="flex flex-wrap items-center justify-center gap-2 sm:gap-3 mb-8">
<% if @event.event_forms.registration.exists? %>
<%= link_to new_event_public_registration_path(@event), target: "_blank", rel: "noopener noreferrer",
class: "#{link_base} #{DomainTheme.border_class_for(:events, intensity: 200)} text-gray-700 hover:#{DomainTheme.border_class_for(:events, intensity: 300)} hover:bg-gray-50" do %>
<i class="fa-solid fa-clipboard-list <%= DomainTheme.text_class_for(:events, intensity: 600) %>"></i>
Public registration form
<i class="fa-solid fa-arrow-up-right-from-square text-xs text-gray-400"></i>
<% end %>
<% end %>

<% if @event.event_forms.registration.exists? && @event.scholarship_form %>
<%= link_to new_event_public_registration_path(@event, scholarship_requested: true), target: "_blank", rel: "noopener noreferrer",
class: "#{link_base} #{DomainTheme.border_class_for(:scholarships, intensity: 200)} text-gray-700 hover:#{DomainTheme.border_class_for(:scholarships, intensity: 300)} hover:bg-gray-50" do %>
<i class="fa-solid fa-graduation-cap <%= DomainTheme.text_class_for(:scholarships, intensity: 600) %>"></i>
Scholarship form
<i class="fa-solid fa-arrow-up-right-from-square text-xs text-gray-400"></i>
<% end %>
<% end %>

<% if @event.bulk_payment_form %>
<%= link_to new_event_bulk_payment_path(@event), target: "_blank", rel: "noopener noreferrer",
class: "#{link_base} #{DomainTheme.border_class_for(:payments, intensity: 200)} text-gray-700 hover:#{DomainTheme.border_class_for(:payments, intensity: 300)} hover:bg-gray-50" do %>
<i class="fa-solid fa-money-check-dollar <%= DomainTheme.text_class_for(:payments, intensity: 600) %>"></i>
Bulk payment form
<i class="fa-solid fa-arrow-up-right-from-square text-xs text-gray-400"></i>
<% end %>
<% end %>
</div>

<%# Money cards (paid events only) β€” shown first %>
<% if @dashboard.free? %>
<div class="bg-white rounded-xl border border-gray-200 p-4 text-center text-sm text-gray-500 mb-4">
Expand Down
6 changes: 3 additions & 3 deletions app/views/events/sample_ticket.html.erb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<% content_for(:page_bg_class, "admin-only bg-blue-100") %>
<%# Eyebrow returns the admin to wherever they opened the preview from. The
Form actions menu lives on the registrants page; the dashboard has its own
quick link. Default to the dashboard when the origin is absent/unknown. %>
<%# Eyebrow returns the admin to wherever they opened the preview from β€” the
Forms menu (shared by the dashboard and registrants page) passes the origin
via return_to. Default to the dashboard when the origin is absent/unknown. %>
<% case params[:return_to]
when "registrants" %>
<% back_label = "← Registrants" %>
Expand Down
4 changes: 4 additions & 0 deletions spec/factories/events.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@
publicly_visible { true }
end

trait :publicly_registerable do
public_registration_enabled { true }
end

trait :publicly_featured do
publicly_featured { true }
end
Expand Down
34 changes: 34 additions & 0 deletions spec/requests/events/bulk_payment_form_submissions_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,38 @@ def post_bulk_payment(answer)
end
end

# Anonymous visitors may only reach the public bulk-payment form when public
# registration is enabled; signed-in users and admins always may. (The parent
# `before` signs in an admin, so sign out to exercise the anonymous path.)
describe "access control by public registration setting" do
it "redirects an anonymous visitor when public registration is disabled" do
sign_out admin
get new_event_bulk_payment_path(event)
expect(response).to redirect_to(event_path(event))
end

it "rejects an anonymous create when public registration is disabled" do
sign_out admin
post_bulk_payment("this answer easily has plenty of words")
expect(response).to redirect_to(event_path(event))
end

it "allows an anonymous visitor when public registration is enabled" do
sign_out admin
public_event = create(:event, :publicly_registerable, cost_cents: 0)
EventForm.create!(event: public_event, form: create(:form), role: "bulk_payment")

get new_event_bulk_payment_path(public_event)

expect(response).to have_http_status(:ok)
end

it "allows a signed-in admin when public registration is disabled" do
get new_event_bulk_payment_path(event)
expect(response).to have_http_status(:ok)
end
end

describe "POST create with credit card payment" do
let(:admin) { create(:user, :admin, :with_person) }
let(:event) { create(:event, cost_cents: 15_00) }
Expand Down Expand Up @@ -133,6 +165,8 @@ def payer_params
end

describe "GET new with the seeded bulk payment form" do
# Public registration must be on for the signed-out (anonymous) view.
let(:event) { create(:event, :publicly_registerable, cost_cents: 0) }
let(:seeded_form) do
FormBuilderService.new(name: "Bulk Payment", sections: %i[bulk_payment], role: "bulk_payment").call
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
# and form-submission show pages.
RSpec.describe "Events::PublicRegistrations professional fields", type: :request do
let(:admin) { create(:user, :admin) }
let(:event) { create(:event, cost_cents: 0) }
let(:event) { create(:event, :publicly_registerable, cost_cents: 0) }

# An AgeRange type (profile-specific so the person edit form lists it) with the
# published ranges plus an unpublished range that must never be offered.
Expand Down
49 changes: 47 additions & 2 deletions spec/requests/events/public_registrations_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
RSpec.describe "Events::PublicRegistrations", type: :request do
# A guest registering on a free event so we exercise the bare create path
# without payment or auth.
let(:event) { create(:event, cost_cents: 0) }
let(:event) { create(:event, :publicly_registerable, cost_cents: 0) }
let(:form) { create(:form) }
let!(:essay_field) do
create(:form_field, form: form, answer_type: :free_form_input_paragraph,
Expand All @@ -17,6 +17,51 @@ def post_registration(answer)
params: { public_registration: { form_fields: { essay_field.id.to_s => answer } } }
end

# Anonymous visitors may only reach the public registration/scholarship form
# when public registration is enabled; signed-in users and admins always may.
describe "access control by public registration setting" do
let(:admin) { create(:user, :admin) }
let(:visitor) { create(:user, :with_person) }

context "when public registration is disabled" do
let(:event) { create(:event, cost_cents: 0) }

it "redirects an anonymous visitor from the registration form" do
get new_event_public_registration_path(event)
expect(response).to redirect_to(root_path)
end

it "redirects an anonymous visitor from the scholarship form" do
get new_event_public_registration_path(event, scholarship_requested: true)
expect(response).to redirect_to(root_path)
end

it "rejects an anonymous create" do
post_registration("this answer easily has plenty of words")
expect(response).to redirect_to(root_path)
end

it "allows a signed-in user" do
sign_in visitor
get new_event_public_registration_path(event)
expect(response).to have_http_status(:ok)
end

it "allows an admin" do
sign_in admin
get new_event_public_registration_path(event)
expect(response).to have_http_status(:ok)
end
end

context "when public registration is enabled" do
it "allows an anonymous visitor to the registration form" do
get new_event_public_registration_path(event)
expect(response).to have_http_status(:ok)
end
end
end

describe "POST create with a minimum word count" do
it "rejects an answer with too few words" do
expect {
Expand Down Expand Up @@ -570,7 +615,7 @@ def post_with_scholarship(scholarship_answer)

describe "GET new payment method options" do
# A paid event so the payment section is not stripped from the form.
let(:event) { create(:event, cost_cents: 150_00) }
let(:event) { create(:event, :publicly_registerable, cost_cents: 150_00) }
let!(:payment_method_field) do
field = create(:form_field, form: form, answer_type: :single_select_radio,
field_identifier: "payment_method", name: "Payment method",
Expand Down
Loading