of fully qualified class names that are allowed
+ def resolve(name, allowed: [])
+ normalized = normalize_name(name)
+ return nil if normalized.nil?
+ return nil unless allowed&.include?(normalized)
+
+ # At this point the name is allow-listed; constantize safely
+ constantize_safely(normalized)
+ rescue NameError
+ nil
+ end
+
+ # Resolve a constant if allowed, otherwise raise.
+ #
+ # error_class: custom error class to raise when resolution is not permitted
+ def resolve!(name, allowed: [], error_class: NameError)
+ constant = resolve(name, allowed:)
+ return constant if constant
+
+ raise error_class, "Unsafe or unknown class resolution attempted: #{name.inspect}"
+ end
+
+ # Internal: normalize class name strings by removing leading :: and converting symbols.
+ def normalize_name(name)
+ return nil if name.nil?
+
+ # rubocop:todo Style/IdenticalConditionalBranches
+ str = name.is_a?(Symbol) ? name.to_s : name.to_s # rubocop:todo Lint/DuplicateBranch, Style/IdenticalConditionalBranches
+ # rubocop:enable Style/IdenticalConditionalBranches
+ str.delete_prefix('::')
+ end
+ private_class_method :normalize_name
+
+ # Internal: safely constantize a fully-qualified constant name without evaluating arbitrary code.
+ def constantize_safely(qualified_name)
+ names = qualified_name.split('::')
+ names.shift if names.first.blank?
+
+ constant = Object
+ names.each do |n|
+ constant = constant.const_get(n)
+ end
+ constant
+ end
+ private_class_method :constantize_safely
+ end
+end
diff --git a/app/views/better_together/content/blocks/_associated_pages.html.erb b/app/views/better_together/content/blocks/_associated_pages.html.erb
index 7f5fbba4e..792fa89f5 100644
--- a/app/views/better_together/content/blocks/_associated_pages.html.erb
+++ b/app/views/better_together/content/blocks/_associated_pages.html.erb
@@ -1,7 +1,7 @@
<% if block.pages.any? %>
-
Associated Pages
- <%= block.pages.map { |page| link_to page }.join(', ').html_safe %>
+ <%= t('better_together.content.blocks.associated_pages', default: 'Associated Pages') %>
+ <%= safe_join(block.pages.map { |page| link_to page }, ', ') %>
<% else %>
No associated pages for this block.
diff --git a/app/views/better_together/content/blocks/_block_row.html.erb b/app/views/better_together/content/blocks/_block_row.html.erb
index cb952bde4..fdaad5222 100644
--- a/app/views/better_together/content/blocks/_block_row.html.erb
+++ b/app/views/better_together/content/blocks/_block_row.html.erb
@@ -2,7 +2,7 @@
<%= block %>
<%= block.class.model_name.human %>
- <%= block.pages.map { |page| link_to page.title, render_page_path(page) }.join(', ').html_safe %>
+ <%= safe_join(block.pages.map { |page| link_to page.title, render_page_path(page) }, ', ') %>
<%= link_to content_block_path(block), class: 'btn btn-outline-info btn-sm', 'aria-label' => t('globals.show') do %>
diff --git a/app/views/better_together/content/blocks/_css.html.erb b/app/views/better_together/content/blocks/_css.html.erb
index a83c29c77..5658cd7f5 100644
--- a/app/views/better_together/content/blocks/_css.html.erb
+++ b/app/views/better_together/content/blocks/_css.html.erb
@@ -1,2 +1,4 @@
-<%= content_tag :style, css.content.html_safe, type: 'text/css', id: dom_id(css) if css.content.present? %>
+<% if css.content.present? %>
+ <%= content_tag :style, sanitize_block_css(css.content), type: 'text/css', id: dom_id(css) %>
+<% end %>
diff --git a/app/views/better_together/content/blocks/_html.html.erb b/app/views/better_together/content/blocks/_html.html.erb
index 4122b9635..67de1b803 100644
--- a/app/views/better_together/content/blocks/_html.html.erb
+++ b/app/views/better_together/content/blocks/_html.html.erb
@@ -1,7 +1,6 @@
<%= render layout: 'better_together/content/blocks/block', locals: { block: html } do %>
<%= cache html.cache_key_with_version do %>
- <%# TODO: This REALLY needs to be sanitized before rendering. For now it's only open to a limitied groupof people who can create and manage pages, but this is a HIGH PRIORITY item. %>
- <%= html.content&.html_safe %>
+ <%= sanitize_block_html(html.content) %>
<% end %>
<% end %>
diff --git a/app/views/better_together/event_invitations_mailer/invite.html.erb b/app/views/better_together/event_invitations_mailer/invite.html.erb
new file mode 100644
index 000000000..50bfc078e
--- /dev/null
+++ b/app/views/better_together/event_invitations_mailer/invite.html.erb
@@ -0,0 +1,6 @@
+<%= t('.greeting', default: 'Hello,') %>
+<%= t('.invited_html', default: 'You have been invited to the event %{event} .', event: @event&.name) %>
+
+ <%= t('.review_invitation', default: 'Review Invitation') %>
+
+
diff --git a/app/views/better_together/events/_attendance_item.html.erb b/app/views/better_together/events/_attendance_item.html.erb
new file mode 100644
index 000000000..d2bfd3628
--- /dev/null
+++ b/app/views/better_together/events/_attendance_item.html.erb
@@ -0,0 +1,6 @@
+
+
+ <%= attendance.person.name %>
+ <%= attendance.status %>
+
+
diff --git a/app/views/better_together/events/_form.html.erb b/app/views/better_together/events/_form.html.erb
index c3924f182..d5015885d 100644
--- a/app/views/better_together/events/_form.html.erb
+++ b/app/views/better_together/events/_form.html.erb
@@ -72,7 +72,13 @@
<%= required_label form, :categories %>
- <%= form.select :category_ids, options_from_collection_for_select(resource_class.category_class_name.constantize.positioned.all.includes(:string_translations), :id, :name, event.category_ids), { include_blank: true, multiple: true }, class: 'form-select', data: { controller: 'better_together--slim_select' } %>
+ <%= form.select :category_ids,
+ options_from_collection_for_select(
+ resource_class.category_klass.positioned.all.includes(:string_translations),
+ :id, :name, event.category_ids
+ ),
+ { include_blank: true, multiple: true },
+ class: 'form-select', data: { controller: 'better_together--slim_select' } %>
<%= t('hints.categories.select_multiple') %>
@@ -121,7 +127,7 @@
@@ -164,35 +175,91 @@
<%= t('better_together.events.hints.location_name') %>
-
+
+ <% if false %>
<%= location_form.label :location_id, t('better_together.events.labels.select_address'), class: 'form-label' %>
- <%= location_form.collection_select :location_id,
- BetterTogether::Geography::LocatableLocation.available_addresses_for(current_person),
- :id, :to_formatted_s,
- { prompt: t('better_together.events.prompts.select_address') },
- { class: 'form-select',
- data: { action: 'change->better_together--location-selector#updateAddressType' } } %>
+
+ <%= location_form.collection_select :location_id,
+ BetterTogether::Geography::LocatableLocation.available_addresses_for(current_person),
+ :id, :to_formatted_s,
+ { prompt: t('better_together.events.prompts.select_address') },
+ { class: 'form-select',
+ data: { action: 'change->better_together--location-selector#updateAddressType', better_together__location_selector_target: 'locationSelect' } } %>
+
+ <% if policy(BetterTogether::Address).create? %>
+
+ <%= link_to '#', class: 'btn btn-outline-secondary btn-sm mt-1',
+ data: { action: 'click->better_together--location-selector#showNewAddress' } do %>
+
+ <%= t('better_together.events.actions.create_new_address') %>
+ <%= t('better_together.events.actions.create_new_short', default: 'New') %>
+ <% end %>
+ <% else %>
+
+
+ <%= t('better_together.events.actions.create_new_short', default: 'New') %>
+
+ <% end %>
+
<%= location_form.hidden_field :location_type, value: 'BetterTogether::Address',
data: { better_together__location_selector_target: 'addressTypeField' } %>
+
<%= t('better_together.events.hints.select_address') %>
+
+
+
+ <%# Build nested attributes for a new Address under the locatable location %>
+ <%= location_form.fields_for :location, BetterTogether::Address.new do |new_addr_form| %>
+ <%= render 'better_together/addresses/address_fields', form: new_addr_form %>
+ <% end %>
+
+ <% end %>
-
+
+ <% if false %>
<%= location_form.label :location_id, t('better_together.events.labels.select_building'), class: 'form-label' %>
- <%= location_form.collection_select :location_id,
- BetterTogether::Geography::LocatableLocation.available_buildings_for(current_person),
- :id, :name,
- { prompt: t('better_together.events.prompts.select_building') },
- { class: 'form-select',
- data: { action: 'change->better_together--location-selector#updateBuildingType' } } %>
+
+ <%= location_form.collection_select :location_id,
+ BetterTogether::Geography::LocatableLocation.available_buildings_for(current_person),
+ :id, :name,
+ { prompt: t('better_together.events.prompts.select_building') },
+ { class: 'form-select',
+ data: { action: 'change->better_together--location-selector#updateBuildingType', better_together__location_selector_target: 'buildingSelect' } } %>
+
+ <% if policy(BetterTogether::Infrastructure::Building).create? %>
+
+ <%= link_to '#', class: 'btn btn-outline-secondary btn-sm mt-1',
+ data: { action: 'click->better_together--location-selector#showNewBuilding' } do %>
+
+ <%= t('better_together.events.actions.create_new_building') %>
+ <%= t('better_together.events.actions.create_new_short', default: 'New') %>
+ <% end %>
+ <% else %>
+
+
+ <%= t('better_together.events.actions.create_new_short', default: 'New') %>
+
+ <% end %>
+
+
<%= location_form.hidden_field :location_type, value: 'BetterTogether::Infrastructure::Building',
data: { better_together__location_selector_target: 'buildingTypeField' } %>
+
<%= t('better_together.events.hints.select_building') %>
+
+
+
+ <%= location_form.fields_for :location, BetterTogether::Infrastructure::Building.new do |new_bld_form| %>
+ <%= render 'better_together/infrastructure/buildings/fields', form: new_bld_form %>
+ <% end %>
+
+ <% end %>
<% end %>
<% if event.errors[:location].any? %>
@@ -236,4 +303,4 @@
<%= yield :resource_toolbar %>
-<% end %>
\ No newline at end of file
+<% end %>
diff --git a/app/views/better_together/events/_invitations_panel.html.erb b/app/views/better_together/events/_invitations_panel.html.erb
new file mode 100644
index 000000000..15956f32b
--- /dev/null
+++ b/app/views/better_together/events/_invitations_panel.html.erb
@@ -0,0 +1,61 @@
+<% invitation = BetterTogether::EventInvitation.new(invitable: @event, inviter: current_person) %>
+<%# Only show the invitation UI to users permitted to manage the platform. This prevents non-platform-manager users from inviting others while the feature is being finished. %>
+<% if policy(invitation).create? %>
+
+
+
+
+ <%= form_with url: better_together.event_invitations_path(event_id: @event.slug), method: :post, data: { turbo: true } do |f| %>
+
+
+ <%= f.label :invitee_email, t('better_together.invitations.invitee_email', default: 'Email'), class: 'form-label' %>
+ <%= f.text_field :invitee_email, name: 'invitation[invitee_email]', class: 'form-control', required: true %>
+
+
+ <%= f.label :locale, t('globals.locale', default: 'Locale'), class: 'form-label' %>
+ <%= f.select :locale, I18n.available_locales.map{ |l| [l, l] }, {}, name: 'invitation[locale]', class: 'form-select' %>
+
+
+ <%= f.submit t('better_together.invitations.send_invite', default: 'Send Invitation'), class: 'btn btn-primary w-100' %>
+
+
+ <% end %>
+
+ <% pending = BetterTogether::EventInvitation.where(invitable: @event, status: 'pending') %>
+ <% if pending.any? %>
+
+
<%= t('better_together.invitations.pending', default: 'Pending Invitations') %>
+
+
+
+
+ <%= t('globals.email', default: 'Email') %>
+ <%= t('globals.sent', default: 'Sent') %>
+
+
+
+
+ <% pending.each do |pi| %>
+
+ <%= pi[:invitee_email] %>
+ <%= l(pi.last_sent, format: :short) if pi.last_sent %>
+
+ <% if policy(pi).resend? %>
+ <%= button_to t('globals.resend', default: 'Resend'), better_together.resend_event_invitation_path(@event, pi), method: :put, class: 'btn btn-outline-secondary btn-sm me-2' %>
+ <% end %>
+ <% if policy(pi).destroy? %>
+ <%= button_to t('globals.remove', default: 'Remove'), better_together.event_invitation_path(@event, pi), method: :delete, class: 'btn btn-outline-danger btn-sm' %>
+ <% end %>
+
+
+ <% end %>
+
+
+
+ <% end %>
+
+
+<% end %>
+
diff --git a/app/views/better_together/events/_pending_invitation_rows.html.erb b/app/views/better_together/events/_pending_invitation_rows.html.erb
new file mode 100644
index 000000000..ec8c253b9
--- /dev/null
+++ b/app/views/better_together/events/_pending_invitation_rows.html.erb
@@ -0,0 +1,15 @@
+<% pending = BetterTogether::EventInvitation.where(invitable: event, status: 'pending') %>
+<% pending.each do |pi| %>
+
+ <%= pi[:invitee_email] %>
+ <%= l(pi.last_sent, format: :short) if pi.last_sent %>
+
+ <% if policy(pi).resend? %>
+ <%= button_to t('globals.resend', default: 'Resend'), better_together.resend_event_invitation_path(event, pi), method: :put, class: 'btn btn-outline-secondary btn-sm me-2' %>
+ <% end %>
+ <% if policy(pi).destroy? %>
+ <%= button_to t('globals.remove', default: 'Remove'), better_together.event_invitation_path(event, pi), method: :delete, class: 'btn btn-outline-danger btn-sm' %>
+ <% end %>
+
+
+<% end %>
diff --git a/app/views/better_together/events/show.html.erb b/app/views/better_together/events/show.html.erb
index a8544947d..c7a658e27 100644
--- a/app/views/better_together/events/show.html.erb
+++ b/app/views/better_together/events/show.html.erb
@@ -68,56 +68,90 @@
<% end %>
-
+
- <%# Accordion content with accessible attributes and flexbox layout %>
+ <%# Tabbed content using Bootstrap tab panes so nav links activate panes correctly %>
-
-
-
-
- <%= @event.privacy.humanize %>
-
- <% if @event.location&.name&.present? %>
-
- <%= @event.location %>
-
- <% end %>
- <% if @event.starts_at.present? %>
-
- <%= l(@event.starts_at, format: :event) %>
-
- <% end %>
- <% if @event.registration_url.present? %>
-
- <%= link_to t('better_together.events.register'), @event.registration_url, target: '_blank', class: 'text-decoration-none' %>
-
- <% end %>
+
+
+
+
+
+
+ <%= @event.privacy.humanize %>
+
+ <% if @event.location&.name&.present? %>
+
+ <%= @event.location %>
+
+ <% end %>
+ <% if @event.starts_at.present? %>
+
+ <%= l(@event.starts_at, format: :event) %>
+
+ <% end %>
+ <% if @event.registration_url.present? %>
+
+ <%= link_to t('better_together.events.register'), @event.registration_url, target: '_blank', class: 'text-decoration-none' %>
+
+ <% end %>
+
+ <% if @event.categories.any? %>
+
+ <%= categories_badge(@event) %>
+
+ <% end %>
+
+
+
+ <%= @resource.description.presence || 'No description available.' %>
+
- <% if @event.categories.any? %>
-
- <%= categories_badge(@event) %>
+ <%= render 'better_together/events/event_hosts', event: @event %>
- <% end %>
+
+
-
-
- <%= @resource.description.presence || 'No description available.' %>
-
+
+
+
+
+ <% invitation ||= BetterTogether::EventInvitation.new(invitable: @event, inviter: current_person) %>
+ <% if policy(invitation).create? %>
+ <%# Render the existing invitations panel inside this attendees pane %>
+ <%= render 'better_together/events/invitations_panel' %>
- <%= render 'better_together/events/event_hosts', event: @event %>
+ <% attendances = BetterTogether::EventAttendance.includes(:person).where(event: @event) %>
+ <% if attendances.any? %>
+
+
<%= t('better_together.events.attendees', default: 'Attendees') %>
+
+ <%= render partial: 'better_together/events/attendance_item', collection: attendances, as: :attendance %>
+
+ <% else %>
+
<%= t('better_together.events.no_attendees', default: 'No attendees yet.') %>
+ <% end %>
+ <% end %>
+
+
-
+
-
+
<%= share_buttons(shareable: @event) if @event.privacy_public? %>
diff --git a/app/views/better_together/invitations/show.html.erb b/app/views/better_together/invitations/show.html.erb
new file mode 100644
index 000000000..4f5ec919a
--- /dev/null
+++ b/app/views/better_together/invitations/show.html.erb
@@ -0,0 +1,19 @@
+
+
<%= t('better_together.invitations.review', default: 'Invitation') %>
+
+ <% if @event %>
+
<%= t('better_together.invitations.event_name', default: 'Event:') %> <%= @event.name %>
+ <% end %>
+
+
+ <%= button_to t('better_together.invitations.accept', default: 'Accept'),
+ better_together.accept_invitation_path(@invitation.token),
+ method: :post,
+ class: 'btn btn-success' %>
+ <%= button_to t('better_together.invitations.decline', default: 'Decline'),
+ better_together.decline_invitation_path(@invitation.token),
+ method: :post,
+ class: 'btn btn-outline-secondary' %>
+
+
+
diff --git a/app/views/better_together/people/_form.html.erb b/app/views/better_together/people/_form.html.erb
index 434ec9eef..13c45532a 100644
--- a/app/views/better_together/people/_form.html.erb
+++ b/app/views/better_together/people/_form.html.erb
@@ -18,7 +18,7 @@