From 7c8ec09c394a660c3d99922e189120eed48eb173 Mon Sep 17 00:00:00 2001 From: Katya Sarmiento <5871075+Kitkatnik@users.noreply.github.com> Date: Fri, 17 Apr 2026 10:16:27 -0400 Subject: [PATCH 1/8] Add Tito-backed magic link auth (ashevillagers pattern) Port the ashevillagers auth flow: users enter their email, we check our DB first, fall back to Tito API, auto-create a user from their ticket, then email a 30-day magic link. No passwords. Single User model with three roles (attendee, volunteer, admin); admins are seeded in db/seeds.rb and never overwritten by Tito sync. Admin UI at /admin for user CRUD, bulk Tito sync, and DB-backed Configuration management for Tito credentials and email_from. Ruby bumped to 3.4.4 for Rails 8.1.3 compatibility. --- .ruby-version | 2 +- .tool-versions | 1 + Gemfile | 6 + Gemfile.lock | 18 +++ .../admin/configurations_controller.rb | 46 ++++++ app/controllers/admin/users_controller.rb | 86 +++++++++++ app/controllers/admin_controller.rb | 5 + app/controllers/application_controller.rb | 32 +++- app/controllers/dashboard_controller.rb | 6 + app/controllers/sessions_controller.rb | 50 +++++++ app/mailers/application_mailer.rb | 6 +- app/mailers/user_mailer.rb | 9 ++ app/models/configuration.rb | 77 ++++++++++ app/models/configuration/configurable.rb | 54 +++++++ app/models/user.rb | 46 ++++++ app/services/tito_lookup_service.rb | 26 ++++ app/views/admin/configurations/_form.html.erb | 35 +++++ app/views/admin/configurations/edit.html.erb | 4 + app/views/admin/configurations/index.html.erb | 42 ++++++ app/views/admin/configurations/new.html.erb | 4 + app/views/admin/users/_form.html.erb | 40 +++++ app/views/admin/users/edit.html.erb | 4 + app/views/admin/users/index.html.erb | 50 +++++++ app/views/admin/users/new.html.erb | 4 + app/views/dashboard/show.html.erb | 32 ++++ app/views/layouts/admin.html.erb | 44 ++++++ app/views/sessions/callback.html.erb | 16 ++ app/views/sessions/new.html.erb | 29 ++++ app/views/sessions/not_registered.html.erb | 20 +++ app/views/user_mailer/login_link.html.erb | 9 ++ app/views/user_mailer/login_link.text.erb | 9 ++ config/environments/production.rb | 18 +-- config/routes.rb | 30 ++-- db/cable_schema.rb | 23 ++- db/cache_schema.rb | 25 +++- .../20260417140714_create_configurations.rb | 12 ++ db/migrate/20260417140731_create_users.rb | 18 +++ db/queue_schema.rb | 139 ++++++++++-------- db/schema.rb | 38 +++++ db/seeds.rb | 22 +-- 40 files changed, 1032 insertions(+), 105 deletions(-) create mode 100644 .tool-versions create mode 100644 app/controllers/admin/configurations_controller.rb create mode 100644 app/controllers/admin/users_controller.rb create mode 100644 app/controllers/admin_controller.rb create mode 100644 app/controllers/dashboard_controller.rb create mode 100644 app/controllers/sessions_controller.rb create mode 100644 app/mailers/user_mailer.rb create mode 100644 app/models/configuration.rb create mode 100644 app/models/configuration/configurable.rb create mode 100644 app/models/user.rb create mode 100644 app/services/tito_lookup_service.rb create mode 100644 app/views/admin/configurations/_form.html.erb create mode 100644 app/views/admin/configurations/edit.html.erb create mode 100644 app/views/admin/configurations/index.html.erb create mode 100644 app/views/admin/configurations/new.html.erb create mode 100644 app/views/admin/users/_form.html.erb create mode 100644 app/views/admin/users/edit.html.erb create mode 100644 app/views/admin/users/index.html.erb create mode 100644 app/views/admin/users/new.html.erb create mode 100644 app/views/dashboard/show.html.erb create mode 100644 app/views/layouts/admin.html.erb create mode 100644 app/views/sessions/callback.html.erb create mode 100644 app/views/sessions/new.html.erb create mode 100644 app/views/sessions/not_registered.html.erb create mode 100644 app/views/user_mailer/login_link.html.erb create mode 100644 app/views/user_mailer/login_link.text.erb create mode 100644 db/migrate/20260417140714_create_configurations.rb create mode 100644 db/migrate/20260417140731_create_users.rb create mode 100644 db/schema.rb diff --git a/.ruby-version b/.ruby-version index f13c6f4..e3cc07a 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -ruby-3.3.5 +ruby-3.4.4 diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..ca745c6 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +ruby 3.4.4 diff --git a/Gemfile b/Gemfile index 46a09c2..ff07466 100644 --- a/Gemfile +++ b/Gemfile @@ -41,6 +41,12 @@ gem "thruster", require: false # Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] gem "image_processing", "~> 1.2" +# Tito API client for event registration lookup +gem "tito_ruby" + +# Postmark for transactional email in production +gem "postmark-rails" + group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" diff --git a/Gemfile.lock b/Gemfile.lock index 989711e..eb0a50f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -120,6 +120,12 @@ GEM erubi (1.13.1) et-orbi (1.4.0) tzinfo + faraday (2.14.1) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-net_http (3.4.2) + net-http (~> 0.5) ffi (1.17.4-aarch64-linux-gnu) ffi (1.17.4-aarch64-linux-musl) ffi (1.17.4-arm-linux-gnu) @@ -200,6 +206,8 @@ GEM stimulus-rails turbo-rails msgpack (1.8.0) + net-http (0.9.1) + uri (>= 0.11.1) net-imap (0.6.3) date net-protocol @@ -243,6 +251,11 @@ GEM pg (1.6.3-x86_64-darwin) pg (1.6.3-x86_64-linux) pg (1.6.3-x86_64-linux-musl) + postmark (1.25.1) + json + postmark-rails (0.22.1) + actionmailer (>= 3.0.0) + postmark (>= 1.21.3, < 2.0) pp (0.6.3) prettyprint prettyprint (0.2.0) @@ -382,6 +395,9 @@ GEM thruster (0.1.20-x86_64-darwin) thruster (0.1.20-x86_64-linux) timeout (0.6.1) + tito_ruby (0.3.0) + activemodel (>= 7.1, < 9.0) + faraday (~> 2.0) tsort (0.2.0) turbo-rails (2.0.23) actionpack (>= 7.1.0) @@ -433,6 +449,7 @@ DEPENDENCIES letter_opener mission_control-jobs pg (~> 1.1) + postmark-rails propshaft puma (>= 5.0) rack-mini-profiler @@ -444,6 +461,7 @@ DEPENDENCIES solid_queue stimulus-rails thruster + tito_ruby turbo-rails tzinfo-data web-console diff --git a/app/controllers/admin/configurations_controller.rb b/app/controllers/admin/configurations_controller.rb new file mode 100644 index 0000000..f4a09ec --- /dev/null +++ b/app/controllers/admin/configurations_controller.rb @@ -0,0 +1,46 @@ +module Admin + class ConfigurationsController < AdminController + def index + @configurations = Configuration.all_and_expected + end + + def new + @configuration = Configuration.new(name: params[:name]) + end + + def create + @configuration = Configuration.new(configuration_params) + + if @configuration.save + redirect_to admin_configurations_path, notice: "Configuration added." + else + render :new, status: :unprocessable_entity + end + end + + def edit + @configuration = Configuration.find(params[:id]) + end + + def update + @configuration = Configuration.find(params[:id]) + + if @configuration.update(configuration_params) + redirect_to admin_configurations_path, notice: "Configuration updated." + else + render :edit, status: :unprocessable_entity + end + end + + def destroy + Configuration.find(params[:id]).destroy + redirect_to admin_configurations_path, notice: "Configuration removed." + end + + private + + def configuration_params + params.require(:configuration).permit(:name, :value) + end + end +end diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb new file mode 100644 index 0000000..a1fd688 --- /dev/null +++ b/app/controllers/admin/users_controller.rb @@ -0,0 +1,86 @@ +module Admin + class UsersController < AdminController + def index + @users = User.order(:last_name, :first_name) + end + + def new + @user = User.new + end + + def create + @user = User.new(user_params) + + if @user.save(context: :interactive) + redirect_to admin_users_path, notice: "User added." + else + render :new, status: :unprocessable_entity + end + end + + def edit + @user = User.find(params[:id]) + end + + def update + @user = User.find(params[:id]) + @user.assign_attributes(user_params) + + if @user.save(context: :interactive) + redirect_to admin_users_path, notice: "User updated." + else + render :edit, status: :unprocessable_entity + end + end + + def destroy + User.find(params[:id]).destroy + redirect_to admin_users_path, notice: "User removed." + end + + def sync + users = User.all.to_a + slugs = users.each_with_object({}) { |u, h| h[u.tito_ticket_slug] = u if u.tito_ticket_slug.present? } + emails = users.each_with_object({}) { |u, h| (h[u.email.downcase] ||= u) if u.email.present? && u.tito_ticket_slug.blank? } + + already = 0 + connected = 0 + added = 0 + + User.tito_client.tickets.where(state: %w[complete]).each do |ticket| + if slugs[ticket.slug] + already += 1 + elsif (user = emails[ticket.email.to_s.downcase]) + user.update!( + tito_ticket_slug: ticket.slug, + first_name: ticket.first_name, + last_name: ticket.last_name + ) + connected += 1 + else + User.create!( + tito_ticket_slug: ticket.slug, + first_name: ticket.first_name, + last_name: ticket.last_name, + email: ticket.email, + role: :attendee + ) + added += 1 + end + end + + redirect_to admin_users_path, + notice: "Sync complete: #{already} already linked, #{connected} connected, #{added} added." + rescue StandardError => e + Rails.logger.error("Tito sync error: #{e.class}: #{e.message}") + redirect_to admin_users_path, + alert: "Sync failed: #{e.message}. Check your Tito configuration." + end + + private + + def user_params + params.require(:user).permit(:first_name, :last_name, :email, :role) + end + end +end diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb new file mode 100644 index 0000000..ffcf86f --- /dev/null +++ b/app/controllers/admin_controller.rb @@ -0,0 +1,5 @@ +class AdminController < ApplicationController + before_action :require_admin! + + layout "admin" +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index c353756..ce08ffb 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,7 +1,33 @@ class ApplicationController < ActionController::Base - # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. allow_browser versions: :modern - - # Changes to the importmap will invalidate the etag for HTML responses stale_when_importmap_changes + + helper_method :current_user, :admin? + + private + + def current_user + return @current_user if defined?(@current_user) + @current_user = session[:user_id] && User.find_by(id: session[:user_id]) + end + + def admin? + current_user&.admin? + end + + def authenticate_user! + unless current_user + session[:return_to] = request.url if request.get? + redirect_to new_session_path, alert: "Please sign in." + end + end + + def require_admin! + authenticate_user! + return if performed? + + unless admin? + redirect_to dashboard_path, alert: "Admins only." + end + end end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb new file mode 100644 index 0000000..9c36ed0 --- /dev/null +++ b/app/controllers/dashboard_controller.rb @@ -0,0 +1,6 @@ +class DashboardController < ApplicationController + before_action :authenticate_user! + + def show + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb new file mode 100644 index 0000000..d3cf636 --- /dev/null +++ b/app/controllers/sessions_controller.rb @@ -0,0 +1,50 @@ +class SessionsController < ApplicationController + REGISTER_URL = "https://blueridgeruby.com/#register".freeze + + def new + end + + def create + @email = params[:email].to_s.strip.downcase + user = User.where("LOWER(email) = ?", @email).first + + if user.nil? + result = TitoLookupService.new.find_or_create_from_tito(@email) + case result.status + when :found + user = result.user + when :not_found + @register_url = REGISTER_URL + render :not_registered, status: :not_found + return + when :api_error + flash.now[:alert] = "Something went wrong checking your registration. Please try again in a few minutes." + render :new, status: :service_unavailable + return + end + end + + UserMailer.login_link(user).deliver_later + redirect_to new_session_path, notice: "Check your email for a login link." + end + + def callback + @token = params[:token] + + if request.post? + user = User.find_by_token_for(:login, @token) + + if user + session[:user_id] = user.id + redirect_to(session.delete(:return_to) || dashboard_path, notice: "Signed in.") + else + redirect_to new_session_path, alert: "Invalid or expired login link." + end + end + end + + def destroy + session.delete(:user_id) + redirect_to root_path, notice: "Signed out." + end +end diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index 3c34c81..1b81d33 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -1,4 +1,8 @@ class ApplicationMailer < ActionMailer::Base - default from: "from@example.com" + include Configuration::Configurable + + configure_with from: :email_from + default from: from + layout "mailer" end diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb new file mode 100644 index 0000000..76b4272 --- /dev/null +++ b/app/mailers/user_mailer.rb @@ -0,0 +1,9 @@ +class UserMailer < ApplicationMailer + def login_link(user) + @user = user + @token = user.generate_token_for(:login) + @login_url = callback_session_url(token: @token) + + mail to: user.email, subject: "Your Ruby Embassy login link" + end +end diff --git a/app/models/configuration.rb b/app/models/configuration.rb new file mode 100644 index 0000000..70b5627 --- /dev/null +++ b/app/models/configuration.rb @@ -0,0 +1,77 @@ +class Configuration < ApplicationRecord + SECRET_SEGMENTS = /(?:^|_)(key|secret|token)(?:_|$)/i + + validates :name, presence: true, uniqueness: true + after_commit :apply_callbacks + + def self.expect(*names, &block) + names = names.map(&:to_s) + expected_names.merge(names) + case [names, block] + in [name], nil then self[name] + in Array, nil then values_at(*names) + else + callback = -> { block.call(*values_at(*names)) } + @callbacks ||= Hash.new { |h, k| h[k] = [] } + names.each do |name| + @callbacks[name] << callback + end + callback.call + end + end + + def self.expected_names = @expected_names ||= Set.new + + def self.callbacks = @callbacks.dup + + def self.all_and_expected + existing = order(:name).to_a + existing_names = existing.map(&:name).to_set + missing = (expected_names - existing_names).sort.map { |name| new(name: name) } + (existing + missing).sort_by(&:name) + end + # -- Hash-like class interface -- + + def self.[](name) = find_by(name: name)&.value + + def self.fetch(name, *args, &block) + name = name.to_s + where(name:).pluck(:name, :value).to_h.fetch(name, *args, &block) + end + + def self.values_at(*names) + names = names.map(&:to_s) + where(name: names).pluck(:name, :value).to_h.values_at(*names) + end + + def self.fetch_values(*names, &block) + names = names.map(&:to_s) + where(name: names).pluck(:name, :value).to_h.fetch_values(*names, &block) + end + + def self.each_pair(&block) + return to_enum(:each_pair) unless block + find_each { |config| block.call(config.name, config.value) } + end + + def self.to_h + pluck(:name, :value).to_h + end + + def self.to_hash = to_h + + # -- Instance methods -- + + def secret? = SECRET_SEGMENTS.match?(name) + + def display_value + return value unless secret? && value.present? && value.length > 4 + + masked = "#{"*" * (value.length - 4)}#{value.last(4)}" + (masked.length > 40) ? "…#{masked.last(39)}" : masked + end + + private + + def apply_callbacks = self.class.callbacks&.[](name)&.each(&:call) +end diff --git a/app/models/configuration/configurable.rb b/app/models/configuration/configurable.rb new file mode 100644 index 0000000..5183c22 --- /dev/null +++ b/app/models/configuration/configurable.rb @@ -0,0 +1,54 @@ +module Configuration::Configurable + extend ActiveSupport::Concern + + class_methods do + def configure_with(*names, instance_methods: true, **mappings) + method_map = {} + names.each { |name| method_map[name.to_sym] = name.to_sym } + mappings.each { |method_name, config_key| method_map[method_name.to_sym] = config_key.to_sym } + + config_keys = method_map.values + attr_names = method_map.keys + + Configuration.expected_names.merge(config_keys.map(&:to_s)) + + current_config = Class.new(ActiveSupport::CurrentAttributes) do + @current_instances_key = :"#{object_id}_current_configuration" + attribute(*attr_names, :_loaded) + end + + const_set(:CurrentConfiguration, current_config) + + current_config.define_singleton_method(:ensure_loaded) do + inst = current_config.instance + return if inst._loaded + + values = Configuration.values_at(*config_keys) + attr_names.each_with_index do |attr, i| + inst.send(:"#{attr}=", values[i]) + end + inst._loaded = true + end + + method_map.each_key do |method_name| + define_singleton_method(method_name) do + current_config.ensure_loaded + current_config.instance.send(method_name) + end + end + + if instance_methods + method_map.each_key do |method_name| + define_method(method_name) do + current_config.ensure_loaded + current_config.instance.send(method_name) + end + end + end + + define_singleton_method(:reload_configuration!) do + current_config.reset + end + end + end +end diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 0000000..e3086a5 --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,46 @@ +class User < ApplicationRecord + include Configuration::Configurable + + configure_with :tito_account_slug, :tito_event_slug, :tito_api_token, instance_methods: false + + enum :role, { attendee: 0, volunteer: 1, admin: 2 } + + before_save :memorialize_tito_config + + generates_token_for :login, expires_in: 30.days + + validates :email, presence: true, uniqueness: true + validates :first_name, presence: true, on: :interactive + validates :last_name, presence: true, on: :interactive + + normalizes :first_name, :last_name, :tito_ticket_slug, with: ->(v) { v.presence } + normalizes :email, with: ->(e) { e.strip.downcase.presence } + + def tito_event_slug = attributes["tito_event_slug"] || self.class.tito_event_slug + def tito_account_slug = attributes["tito_account_slug"] || self.class.tito_account_slug + def tito_api_token = self.class.tito_api_token + + def self.tito_client + Tito::Admin::Client.new(token: tito_api_token, account: tito_account_slug, event: tito_event_slug) + end + + def tito_client + Tito::Admin::Client.new(token: tito_api_token, account: tito_account_slug, event: tito_event_slug) + end + + def admin_ticket_url + tito_ticket_slug && "https://dashboard.tito.io/#{tito_account_slug}/#{tito_event_slug}/tickets/#{tito_ticket_slug}" + end + + def full_name + [first_name, last_name].compact.join(" ").presence || email + end + + private + + def memorialize_tito_config + return unless tito_ticket_slug.present? + self.tito_account_slug = tito_account_slug + self.tito_event_slug = tito_event_slug + end +end diff --git a/app/services/tito_lookup_service.rb b/app/services/tito_lookup_service.rb new file mode 100644 index 0000000..6fb499f --- /dev/null +++ b/app/services/tito_lookup_service.rb @@ -0,0 +1,26 @@ +class TitoLookupService + Result = Data.define(:status, :user) + + def find_or_create_from_tito(email) + normalized = email.to_s.strip.downcase + return Result.new(status: :not_found, user: nil) if normalized.blank? + + ticket = User.tito_client.tickets + .where(state: %w[complete]) + .find { |t| t.email.to_s.downcase == normalized } + + return Result.new(status: :not_found, user: nil) unless ticket + + user = User.create!( + email: ticket.email, + first_name: ticket.first_name, + last_name: ticket.last_name, + tito_ticket_slug: ticket.slug, + role: :attendee + ) + Result.new(status: :found, user: user) + rescue StandardError => e + Rails.logger.error("TitoLookupService error for #{email.inspect}: #{e.class}: #{e.message}") + Result.new(status: :api_error, user: nil) + end +end diff --git a/app/views/admin/configurations/_form.html.erb b/app/views/admin/configurations/_form.html.erb new file mode 100644 index 0000000..b237d44 --- /dev/null +++ b/app/views/admin/configurations/_form.html.erb @@ -0,0 +1,35 @@ +<% if configuration.errors.any? %> +
+ +
+<% end %> + +<%= form_with model: configuration, + url: configuration.persisted? ? admin_configuration_path(configuration) : admin_configurations_path, + class: "space-y-4" do |f| %> +
+ + <%= f.text_field :name, required: true, class: "w-full border rounded px-3 py-2" %> +
+ +
+ + <% if configuration.persisted? && configuration.secret? %> + <%= f.text_field :value, required: true, value: "", + placeholder: "Enter new value", + class: "w-full border rounded px-3 py-2" %> + <% else %> + <%= f.text_field :value, required: true, class: "w-full border rounded px-3 py-2" %> + <% end %> +
+ +
+ <%= f.submit class: "bg-indigo-600 text-white py-2 px-4 rounded hover:bg-indigo-700 cursor-pointer" %> + <%= link_to "Cancel", admin_configurations_path, + class: "bg-gray-200 text-gray-800 py-2 px-4 rounded hover:bg-gray-300" %> +
+<% end %> diff --git a/app/views/admin/configurations/edit.html.erb b/app/views/admin/configurations/edit.html.erb new file mode 100644 index 0000000..8e0c080 --- /dev/null +++ b/app/views/admin/configurations/edit.html.erb @@ -0,0 +1,4 @@ +
+

Edit Configuration

+ <%= render "form", configuration: @configuration %> +
diff --git a/app/views/admin/configurations/index.html.erb b/app/views/admin/configurations/index.html.erb new file mode 100644 index 0000000..7ecb757 --- /dev/null +++ b/app/views/admin/configurations/index.html.erb @@ -0,0 +1,42 @@ +
+

Configurations

+ <%= link_to "Add configuration", new_admin_configuration_path, + class: "bg-indigo-600 text-white py-2 px-4 rounded hover:bg-indigo-700" %> +
+ +

+ Expected keys: tito_api_token, tito_account_slug, tito_event_slug, email_from +

+ + + + + + + + + + + <% @configurations.each do |configuration| %> + + + <% if configuration.persisted? %> + + + <% else %> + + + <% end %> + + <% end %> + +
NameValue 
<%= configuration.name %><%= configuration.display_value %> + <%= link_to "Edit", edit_admin_configuration_path(configuration), + class: "text-indigo-600 hover:underline" %> + <%= button_to "Delete", admin_configuration_path(configuration), method: :delete, + data: { turbo_confirm: "Remove #{configuration.name}?" }, + class: "text-red-600 hover:underline bg-transparent border-0 cursor-pointer inline" %> + not set + <%= link_to "Create", new_admin_configuration_path(name: configuration.name), + class: "text-indigo-600 hover:underline" %> +
diff --git a/app/views/admin/configurations/new.html.erb b/app/views/admin/configurations/new.html.erb new file mode 100644 index 0000000..d7d730d --- /dev/null +++ b/app/views/admin/configurations/new.html.erb @@ -0,0 +1,4 @@ +
+

Add Configuration

+ <%= render "form", configuration: @configuration %> +
diff --git a/app/views/admin/users/_form.html.erb b/app/views/admin/users/_form.html.erb new file mode 100644 index 0000000..1a829aa --- /dev/null +++ b/app/views/admin/users/_form.html.erb @@ -0,0 +1,40 @@ +<% if user.errors.any? %> +
+ +
+<% end %> + +<%= form_with model: user, + url: user.persisted? ? admin_user_path(user) : admin_users_path, + class: "space-y-4" do |f| %> +
+ + <%= f.text_field :first_name, required: true, class: "w-full border rounded px-3 py-2" %> +
+ +
+ + <%= f.text_field :last_name, required: true, class: "w-full border rounded px-3 py-2" %> +
+ +
+ + <%= f.email_field :email, required: true, class: "w-full border rounded px-3 py-2" %> +
+ +
+ + <%= f.select :role, User.roles.keys.map { |r| [r.humanize, r] }, + {}, class: "w-full border rounded px-3 py-2" %> +
+ +
+ <%= f.submit class: "bg-indigo-600 text-white py-2 px-4 rounded hover:bg-indigo-700 cursor-pointer" %> + <%= link_to "Cancel", admin_users_path, + class: "bg-gray-200 text-gray-800 py-2 px-4 rounded hover:bg-gray-300" %> +
+<% end %> diff --git a/app/views/admin/users/edit.html.erb b/app/views/admin/users/edit.html.erb new file mode 100644 index 0000000..258f76b --- /dev/null +++ b/app/views/admin/users/edit.html.erb @@ -0,0 +1,4 @@ +
+

Edit User

+ <%= render "form", user: @user %> +
diff --git a/app/views/admin/users/index.html.erb b/app/views/admin/users/index.html.erb new file mode 100644 index 0000000..a141926 --- /dev/null +++ b/app/views/admin/users/index.html.erb @@ -0,0 +1,50 @@ +
+

Users

+
+ <%= button_to "Sync from Tito", sync_admin_users_path, method: :post, + class: "bg-green-600 text-white py-2 px-4 rounded hover:bg-green-700 cursor-pointer" %> + <%= link_to "Add user", new_admin_user_path, + class: "bg-indigo-600 text-white py-2 px-4 rounded hover:bg-indigo-700" %> +
+
+ + + + + + + + + + + + + <% @users.each do |user| %> + + + + + + + + <% end %> + +
NameEmailRoleTito
<%= user.full_name %><%= user.email %> + + <%= user.role.humanize %> + + + <% if user.admin_ticket_url %> + <%= link_to "Ticket", user.admin_ticket_url, + target: "_blank", rel: "noopener noreferrer", + class: "text-indigo-600 hover:underline" %> + <% end %> + + <%= link_to "Edit", edit_admin_user_path(user), class: "text-indigo-600 hover:underline" %> + <%= button_to "Delete", admin_user_path(user), method: :delete, + data: { turbo_confirm: "Remove #{user.full_name}?" }, + class: "text-red-600 hover:underline bg-transparent border-0 cursor-pointer inline" %> +
diff --git a/app/views/admin/users/new.html.erb b/app/views/admin/users/new.html.erb new file mode 100644 index 0000000..58f78d1 --- /dev/null +++ b/app/views/admin/users/new.html.erb @@ -0,0 +1,4 @@ +
+

Add User

+ <%= render "form", user: @user %> +
diff --git a/app/views/dashboard/show.html.erb b/app/views/dashboard/show.html.erb new file mode 100644 index 0000000..b617920 --- /dev/null +++ b/app/views/dashboard/show.html.erb @@ -0,0 +1,32 @@ +
+ <% if flash[:notice] %> +
<%= flash[:notice] %>
+ <% end %> + +

Welcome to Ruby Embassy

+

You're signed in.

+ +
+
+
Name
+
<%= current_user.full_name %>
+
+
+
Email
+
<%= current_user.email %>
+
+
+
Role
+
<%= current_user.role.humanize %>
+
+
+ +
+ <% if current_user.admin? %> + <%= link_to "Admin", admin_users_path, + class: "bg-indigo-600 text-white py-2 px-4 rounded hover:bg-indigo-700" %> + <% end %> + <%= button_to "Sign out", session_path, method: :delete, + class: "bg-gray-200 text-gray-800 py-2 px-4 rounded hover:bg-gray-300 cursor-pointer" %> +
+
diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb new file mode 100644 index 0000000..1434529 --- /dev/null +++ b/app/views/layouts/admin.html.erb @@ -0,0 +1,44 @@ + + + + <%= content_for(:title) || "Admin — Ruby Embassy" %> + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + <%= yield :head %> + + + + + <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> + <%= javascript_importmap_tags %> + + + + + +
+ <% if flash[:notice] %> +
<%= flash[:notice] %>
+ <% end %> + <% if flash[:alert] %> +
<%= flash[:alert] %>
+ <% end %> + +
+ <%= yield %> +
+
+ + diff --git a/app/views/sessions/callback.html.erb b/app/views/sessions/callback.html.erb new file mode 100644 index 0000000..a3a051c --- /dev/null +++ b/app/views/sessions/callback.html.erb @@ -0,0 +1,16 @@ +
+

Signing you in…

+ + + <%= form_with url: callback_session_path, method: :post, id: "login-callback", class: "inline" do |f| %> + <%= f.hidden_field :token, value: @token %> + + <% end %> +
+ + diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb new file mode 100644 index 0000000..ce9cc1e --- /dev/null +++ b/app/views/sessions/new.html.erb @@ -0,0 +1,29 @@ +
+

Sign In

+

Enter your email and we'll send you a login link.

+ + <% if flash[:notice] %> +
<%= flash[:notice] %>
+ <% end %> + <% if flash[:alert] %> +
<%= flash[:alert] %>
+ <% end %> + + <%= form_with url: session_path, method: :post, class: "space-y-4" do |f| %> +
+ Email address + <%= f.email_field :email, required: true, autofocus: true, + value: params[:email], + class: "w-full border rounded px-3 py-2" %> +
+ + <%= f.submit "Send login link", + class: "w-full bg-[#C0392B] text-white py-2 px-4 rounded font-medium hover:bg-[#a5321f] cursor-pointer" %> + <% end %> + +

+ Don't have a ticket yet? + <%= link_to "Register for Blue Ridge Ruby", "https://blueridgeruby.com/#register", + class: "text-[#C0392B] hover:underline" %> +

+
diff --git a/app/views/sessions/not_registered.html.erb b/app/views/sessions/not_registered.html.erb new file mode 100644 index 0000000..5067357 --- /dev/null +++ b/app/views/sessions/not_registered.html.erb @@ -0,0 +1,20 @@ +
+

No Ticket Found

+ +

+ We couldn't find a Blue Ridge Ruby ticket for + <%= @email %>. +

+ +

+ You need a ticket to sign in. Register on the Blue Ridge Ruby website to get started. +

+ + <%= link_to "Register for Blue Ridge Ruby", @register_url, + class: "inline-block bg-[#C0392B] text-white py-3 px-6 rounded font-medium hover:bg-[#a5321f]" %> + +

+ Typed the wrong email? + <%= link_to "Try again", new_session_path, class: "text-[#C0392B] hover:underline" %> +

+
diff --git a/app/views/user_mailer/login_link.html.erb b/app/views/user_mailer/login_link.html.erb new file mode 100644 index 0000000..7ad70c6 --- /dev/null +++ b/app/views/user_mailer/login_link.html.erb @@ -0,0 +1,9 @@ +

Sign in to Ruby Embassy

+ +

Hi <%= @user.first_name.presence || "there" %>,

+ +

Click the link below to sign in to Ruby Embassy:

+ +

<%= link_to "Sign in", @login_url %>

+ +

This link will expire in 30 days. If you didn't request this, you can safely ignore this email.

diff --git a/app/views/user_mailer/login_link.text.erb b/app/views/user_mailer/login_link.text.erb new file mode 100644 index 0000000..3f16f7d --- /dev/null +++ b/app/views/user_mailer/login_link.text.erb @@ -0,0 +1,9 @@ +Sign in to Ruby Embassy + +Hi <%= @user.first_name.presence || "there" %>, + +Click the link below to sign in to Ruby Embassy: + +<%= @login_url %> + +This link will expire in 30 days. If you didn't request this, you can safely ignore this email. diff --git a/config/environments/production.rb b/config/environments/production.rb index f5763e0..4d70e91 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -58,16 +58,14 @@ # config.action_mailer.raise_delivery_errors = false # Set host to be used by links generated in mailer templates. - config.action_mailer.default_url_options = { host: "example.com" } - - # Specify outgoing SMTP server. Remember to add smtp/* credentials via bin/rails credentials:edit. - # config.action_mailer.smtp_settings = { - # user_name: Rails.application.credentials.dig(:smtp, :user_name), - # password: Rails.application.credentials.dig(:smtp, :password), - # address: "smtp.example.com", - # port: 587, - # authentication: :plain - # } + config.action_mailer.default_url_options = { + host: ENV.fetch("APP_HOST", "activities.blueridgeruby.com"), + protocol: "https" + } + + # Send email via Postmark (matches ashevillagers stack) + config.action_mailer.delivery_method = :postmark + config.action_mailer.postmark_settings = { api_token: ENV["POSTMARK_API_TOKEN"] } # Enable locale fallbacks for I18n (makes lookups for any locale fall back to # the I18n.default_locale when a translation cannot be found). diff --git a/config/routes.rb b/config/routes.rb index d6b0621..b6d81e5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,15 +1,25 @@ Rails.application.routes.draw do - # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html - - # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. - # Can be used by load balancers and uptime monitors to verify that the app is live. + # Health check get "up" => "rails/health#show", as: :rails_health_check - mount MissionControl::Jobs::Engine, at: "/jobs" - # Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb) - # get "manifest" => "rails/pwa#manifest", as: :pwa_manifest - # get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker + # Public — magic-link auth + resource :session, only: %i[new create destroy], controller: "sessions" do + get :callback, on: :collection + post :callback, on: :collection + end + + # Authenticated user dashboard + get "dashboard", to: "dashboard#show", as: :dashboard + root "dashboard#show" + + # Admin area + namespace :admin do + resources :users do + post :sync, on: :collection + end + resources :configurations + end - # Defines the root path route ("/") - # root "posts#index" + # Background jobs dashboard (admin-only mount inside /admin/) + mount MissionControl::Jobs::Engine, at: "/admin/jobs" end diff --git a/db/cable_schema.rb b/db/cable_schema.rb index 2366660..593da41 100644 --- a/db/cable_schema.rb +++ b/db/cable_schema.rb @@ -1,9 +1,24 @@ -ActiveRecord::Schema[7.1].define(version: 1) do +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.1].define(version: 1) do + # These are extensions that must be enabled in order to support this database + enable_extension "pg_catalog.plpgsql" + create_table "solid_cable_messages", force: :cascade do |t| - t.binary "channel", limit: 1024, null: false - t.binary "payload", limit: 536870912, null: false + t.binary "channel", null: false + t.bigint "channel_hash", null: false t.datetime "created_at", null: false - t.integer "channel_hash", limit: 8, null: false + t.binary "payload", null: false t.index ["channel"], name: "index_solid_cable_messages_on_channel" t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash" t.index ["created_at"], name: "index_solid_cable_messages_on_created_at" diff --git a/db/cache_schema.rb b/db/cache_schema.rb index 81a410d..96be0dc 100644 --- a/db/cache_schema.rb +++ b/db/cache_schema.rb @@ -1,10 +1,25 @@ -ActiveRecord::Schema[7.2].define(version: 1) do +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.1].define(version: 1) do + # These are extensions that must be enabled in order to support this database + enable_extension "pg_catalog.plpgsql" + create_table "solid_cache_entries", force: :cascade do |t| - t.binary "key", limit: 1024, null: false - t.binary "value", limit: 536870912, null: false + t.integer "byte_size", null: false t.datetime "created_at", null: false - t.integer "key_hash", limit: 8, null: false - t.integer "byte_size", limit: 4, null: false + t.binary "key", null: false + t.bigint "key_hash", null: false + t.binary "value", null: false t.index ["byte_size"], name: "index_solid_cache_entries_on_byte_size" t.index ["key_hash", "byte_size"], name: "index_solid_cache_entries_on_key_hash_and_byte_size" t.index ["key_hash"], name: "index_solid_cache_entries_on_key_hash", unique: true diff --git a/db/migrate/20260417140714_create_configurations.rb b/db/migrate/20260417140714_create_configurations.rb new file mode 100644 index 0000000..5e6f415 --- /dev/null +++ b/db/migrate/20260417140714_create_configurations.rb @@ -0,0 +1,12 @@ +class CreateConfigurations < ActiveRecord::Migration[8.1] + def change + create_table :configurations do |t| + t.string :name, null: false + t.string :value + + t.timestamps + end + + add_index :configurations, :name, unique: true + end +end diff --git a/db/migrate/20260417140731_create_users.rb b/db/migrate/20260417140731_create_users.rb new file mode 100644 index 0000000..d716a38 --- /dev/null +++ b/db/migrate/20260417140731_create_users.rb @@ -0,0 +1,18 @@ +class CreateUsers < ActiveRecord::Migration[8.1] + def change + create_table :users do |t| + t.string :email, null: false + t.string :first_name + t.string :last_name + t.integer :role, default: 0, null: false + t.string :tito_account_slug + t.string :tito_event_slug + t.string :tito_ticket_slug + + t.timestamps + end + + add_index :users, :email, unique: true + add_index :users, :tito_ticket_slug, unique: true + end +end diff --git a/db/queue_schema.rb b/db/queue_schema.rb index 85194b6..2181653 100644 --- a/db/queue_schema.rb +++ b/db/queue_schema.rb @@ -1,123 +1,138 @@ -ActiveRecord::Schema[7.1].define(version: 1) do +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.1].define(version: 1) do + # These are extensions that must be enabled in order to support this database + enable_extension "pg_catalog.plpgsql" + create_table "solid_queue_blocked_executions", force: :cascade do |t| - t.bigint "job_id", null: false - t.string "queue_name", null: false - t.integer "priority", default: 0, null: false t.string "concurrency_key", null: false - t.datetime "expires_at", null: false t.datetime "created_at", null: false - t.index [ "concurrency_key", "priority", "job_id" ], name: "index_solid_queue_blocked_executions_for_release" - t.index [ "expires_at", "concurrency_key" ], name: "index_solid_queue_blocked_executions_for_maintenance" - t.index [ "job_id" ], name: "index_solid_queue_blocked_executions_on_job_id", unique: true + t.datetime "expires_at", null: false + t.bigint "job_id", null: false + t.integer "priority", default: 0, null: false + t.string "queue_name", null: false + t.index ["concurrency_key", "priority", "job_id"], name: "index_solid_queue_blocked_executions_for_release" + t.index ["expires_at", "concurrency_key"], name: "index_solid_queue_blocked_executions_for_maintenance" + t.index ["job_id"], name: "index_solid_queue_blocked_executions_on_job_id", unique: true end create_table "solid_queue_claimed_executions", force: :cascade do |t| + t.datetime "created_at", null: false t.bigint "job_id", null: false t.bigint "process_id" - t.datetime "created_at", null: false - t.index [ "job_id" ], name: "index_solid_queue_claimed_executions_on_job_id", unique: true - t.index [ "process_id", "job_id" ], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id" + t.index ["job_id"], name: "index_solid_queue_claimed_executions_on_job_id", unique: true + t.index ["process_id", "job_id"], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id" end create_table "solid_queue_failed_executions", force: :cascade do |t| - t.bigint "job_id", null: false - t.text "error" t.datetime "created_at", null: false - t.index [ "job_id" ], name: "index_solid_queue_failed_executions_on_job_id", unique: true + t.text "error" + t.bigint "job_id", null: false + t.index ["job_id"], name: "index_solid_queue_failed_executions_on_job_id", unique: true end create_table "solid_queue_jobs", force: :cascade do |t| - t.string "queue_name", null: false - t.string "class_name", null: false - t.text "arguments" - t.integer "priority", default: 0, null: false t.string "active_job_id" - t.datetime "scheduled_at" - t.datetime "finished_at" + t.text "arguments" + t.string "class_name", null: false t.string "concurrency_key" t.datetime "created_at", null: false + t.datetime "finished_at" + t.integer "priority", default: 0, null: false + t.string "queue_name", null: false + t.datetime "scheduled_at" t.datetime "updated_at", null: false - t.index [ "active_job_id" ], name: "index_solid_queue_jobs_on_active_job_id" - t.index [ "class_name" ], name: "index_solid_queue_jobs_on_class_name" - t.index [ "finished_at" ], name: "index_solid_queue_jobs_on_finished_at" - t.index [ "queue_name", "finished_at" ], name: "index_solid_queue_jobs_for_filtering" - t.index [ "scheduled_at", "finished_at" ], name: "index_solid_queue_jobs_for_alerting" + t.index ["active_job_id"], name: "index_solid_queue_jobs_on_active_job_id" + t.index ["class_name"], name: "index_solid_queue_jobs_on_class_name" + t.index ["finished_at"], name: "index_solid_queue_jobs_on_finished_at" + t.index ["queue_name", "finished_at"], name: "index_solid_queue_jobs_for_filtering" + t.index ["scheduled_at", "finished_at"], name: "index_solid_queue_jobs_for_alerting" end create_table "solid_queue_pauses", force: :cascade do |t| - t.string "queue_name", null: false t.datetime "created_at", null: false - t.index [ "queue_name" ], name: "index_solid_queue_pauses_on_queue_name", unique: true + t.string "queue_name", null: false + t.index ["queue_name"], name: "index_solid_queue_pauses_on_queue_name", unique: true end create_table "solid_queue_processes", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "hostname" t.string "kind", null: false t.datetime "last_heartbeat_at", null: false - t.bigint "supervisor_id" - t.integer "pid", null: false - t.string "hostname" t.text "metadata" - t.datetime "created_at", null: false t.string "name", null: false - t.index [ "last_heartbeat_at" ], name: "index_solid_queue_processes_on_last_heartbeat_at" - t.index [ "name", "supervisor_id" ], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true - t.index [ "supervisor_id" ], name: "index_solid_queue_processes_on_supervisor_id" + t.integer "pid", null: false + t.bigint "supervisor_id" + t.index ["last_heartbeat_at"], name: "index_solid_queue_processes_on_last_heartbeat_at" + t.index ["name", "supervisor_id"], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true + t.index ["supervisor_id"], name: "index_solid_queue_processes_on_supervisor_id" end create_table "solid_queue_ready_executions", force: :cascade do |t| + t.datetime "created_at", null: false t.bigint "job_id", null: false - t.string "queue_name", null: false t.integer "priority", default: 0, null: false - t.datetime "created_at", null: false - t.index [ "job_id" ], name: "index_solid_queue_ready_executions_on_job_id", unique: true - t.index [ "priority", "job_id" ], name: "index_solid_queue_poll_all" - t.index [ "queue_name", "priority", "job_id" ], name: "index_solid_queue_poll_by_queue" + t.string "queue_name", null: false + t.index ["job_id"], name: "index_solid_queue_ready_executions_on_job_id", unique: true + t.index ["priority", "job_id"], name: "index_solid_queue_poll_all" + t.index ["queue_name", "priority", "job_id"], name: "index_solid_queue_poll_by_queue" end create_table "solid_queue_recurring_executions", force: :cascade do |t| + t.datetime "created_at", null: false t.bigint "job_id", null: false - t.string "task_key", null: false t.datetime "run_at", null: false - t.datetime "created_at", null: false - t.index [ "job_id" ], name: "index_solid_queue_recurring_executions_on_job_id", unique: true - t.index [ "task_key", "run_at" ], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true + t.string "task_key", null: false + t.index ["job_id"], name: "index_solid_queue_recurring_executions_on_job_id", unique: true + t.index ["task_key", "run_at"], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true end create_table "solid_queue_recurring_tasks", force: :cascade do |t| - t.string "key", null: false - t.string "schedule", null: false - t.string "command", limit: 2048 - t.string "class_name" t.text "arguments" - t.string "queue_name" + t.string "class_name" + t.string "command", limit: 2048 + t.datetime "created_at", null: false + t.text "description" + t.string "key", null: false t.integer "priority", default: 0 + t.string "queue_name" + t.string "schedule", null: false t.boolean "static", default: true, null: false - t.text "description" - t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index [ "key" ], name: "index_solid_queue_recurring_tasks_on_key", unique: true - t.index [ "static" ], name: "index_solid_queue_recurring_tasks_on_static" + t.index ["key"], name: "index_solid_queue_recurring_tasks_on_key", unique: true + t.index ["static"], name: "index_solid_queue_recurring_tasks_on_static" end create_table "solid_queue_scheduled_executions", force: :cascade do |t| + t.datetime "created_at", null: false t.bigint "job_id", null: false - t.string "queue_name", null: false t.integer "priority", default: 0, null: false + t.string "queue_name", null: false t.datetime "scheduled_at", null: false - t.datetime "created_at", null: false - t.index [ "job_id" ], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true - t.index [ "scheduled_at", "priority", "job_id" ], name: "index_solid_queue_dispatch_all" + t.index ["job_id"], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true + t.index ["scheduled_at", "priority", "job_id"], name: "index_solid_queue_dispatch_all" end create_table "solid_queue_semaphores", force: :cascade do |t| - t.string "key", null: false - t.integer "value", default: 1, null: false - t.datetime "expires_at", null: false t.datetime "created_at", null: false + t.datetime "expires_at", null: false + t.string "key", null: false t.datetime "updated_at", null: false - t.index [ "expires_at" ], name: "index_solid_queue_semaphores_on_expires_at" - t.index [ "key", "value" ], name: "index_solid_queue_semaphores_on_key_and_value" - t.index [ "key" ], name: "index_solid_queue_semaphores_on_key", unique: true + t.integer "value", default: 1, null: false + t.index ["expires_at"], name: "index_solid_queue_semaphores_on_expires_at" + t.index ["key", "value"], name: "index_solid_queue_semaphores_on_key_and_value" + t.index ["key"], name: "index_solid_queue_semaphores_on_key", unique: true end add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 0000000..c3e29e0 --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,38 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.1].define(version: 2026_04_17_140731) do + # These are extensions that must be enabled in order to support this database + enable_extension "pg_catalog.plpgsql" + + create_table "configurations", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "name", null: false + t.datetime "updated_at", null: false + t.string "value" + t.index ["name"], name: "index_configurations_on_name", unique: true + end + + create_table "users", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "email", null: false + t.string "first_name" + t.string "last_name" + t.integer "role", default: 0, null: false + t.string "tito_account_slug" + t.string "tito_event_slug" + t.string "tito_ticket_slug" + t.datetime "updated_at", null: false + t.index ["email"], name: "index_users_on_email", unique: true + t.index ["tito_ticket_slug"], name: "index_users_on_tito_ticket_slug", unique: true + end +end diff --git a/db/seeds.rb b/db/seeds.rb index 4fbd6ed..082dc42 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,9 +1,13 @@ -# This file should ensure the existence of records required to run the application in every environment (production, -# development, test). The code here should be idempotent so that it can be executed at any point in every environment. -# The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup). -# -# Example: -# -# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name| -# MovieGenre.find_or_create_by!(name: genre_name) -# end +# Seeds are idempotent - safe to run repeatedly. + +admin_users = [ + { email: "jeremy@blueridgeruby.com", first_name: "Jeremy", last_name: "Smith" } +] + +admin_users.each do |attrs| + User.find_or_create_by!(email: attrs[:email]) do |u| + u.first_name = attrs[:first_name] + u.last_name = attrs[:last_name] + u.role = :admin + end +end From 3a5a4ff7373d501b4e733c9a341293660e7d6df1 Mon Sep 17 00:00:00 2001 From: Katya Sarmiento <5871075+Kitkatnik@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:00:22 -0400 Subject: [PATCH 2/8] Apply Blue Ridge Ruby branding to auth pages Pull the template branch's navbar/footer/ridge-bg layout, logo, colfax font, and the blueridgeruby.com class-name system. Restyle all session views (new, callback, not_registered) and the dashboard to use the brand palette (navy text, red gradient CTAs, ridge background). Give the admin area a matching navy top bar and brand-consistent forms, tables, and role badges. Extend application.css with form/card/button/ alert/table/badge utilities on top of the template's base. Also add test/system/ scaffolding so bin/rails test:system runs cleanly and a brakeman.ignore entry for the admin-only role mass-assignment (guarded by require_admin! on every action). --- app/assets/images/logo.svg | 1 + app/assets/stylesheets/application.css | 577 +++++++++++++++++- .../controllers/toggle_controller.js | 31 + app/models/configuration.rb | 4 +- app/models/user.rb | 2 +- app/views/admin/configurations/_form.html.erb | 50 +- app/views/admin/configurations/edit.html.erb | 2 +- app/views/admin/configurations/index.html.erb | 37 +- app/views/admin/configurations/new.html.erb | 2 +- app/views/admin/users/_form.html.erb | 58 +- app/views/admin/users/edit.html.erb | 2 +- app/views/admin/users/index.html.erb | 49 +- app/views/admin/users/new.html.erb | 2 +- app/views/dashboard/show.html.erb | 62 +- app/views/layouts/admin.html.erb | 34 +- app/views/layouts/application.html.erb | 19 +- app/views/sessions/callback.html.erb | 30 +- app/views/sessions/new.html.erb | 63 +- app/views/sessions/not_registered.html.erb | 40 +- app/views/shared/_footer.html.erb | 58 ++ app/views/shared/_navbar.html.erb | 38 ++ config/brakeman.ignore | 26 + public/images/mountains.jpg | Bin 0 -> 119396 bytes public/images/ridge.jpg | Bin 0 -> 55959 bytes public/images/ruby.svg | 1 + test/application_system_test_case.rb | 5 + test/system/.keep | 0 27 files changed, 973 insertions(+), 220 deletions(-) create mode 100644 app/assets/images/logo.svg create mode 100644 app/javascript/controllers/toggle_controller.js create mode 100644 app/views/shared/_footer.html.erb create mode 100644 app/views/shared/_navbar.html.erb create mode 100644 config/brakeman.ignore create mode 100644 public/images/mountains.jpg create mode 100644 public/images/ridge.jpg create mode 100644 public/images/ruby.svg create mode 100644 test/application_system_test_case.rb create mode 100644 test/system/.keep diff --git a/app/assets/images/logo.svg b/app/assets/images/logo.svg new file mode 100644 index 0000000..702eb89 --- /dev/null +++ b/app/assets/images/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index fe93333..2b6763f 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -1,10 +1,567 @@ -/* - * This is a manifest file that'll be compiled into application.css. - * - * With Propshaft, assets are served efficiently without preprocessing steps. You can still include - * application-wide styles in this file, but keep in mind that CSS precedence will follow the standard - * cascading order, meaning styles declared later in the document or manifest will override earlier ones, - * depending on specificity. - * - * Consider organizing styles into separate files for maintainability. - */ +/* ============================================================ + * Ruby Embassy — plain CSS that mirrors the blueridgeruby.com + * styling. The HTML uses the same Tailwind-style class names + * the real site uses, so the markup is copy-paste compatible. + * Only the specific utilities used on that page are defined. + * ============================================================ */ + + +/* ---------- Reset & base ---------------------------------- */ + +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; + border: 0 solid; +} + +html { + font-family: 'colfax-web', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + color: #404040; + line-height: 1.5; + -webkit-text-size-adjust: 100%; +} + +body { min-height: 100vh; } + +img, svg { display: block; max-width: 100%; } + +a { + color: inherit; + text-decoration: inherit; + transition: opacity 0.15s ease; +} + +a:hover { opacity: 0.85; } + +button { + font: inherit; + color: inherit; + background: transparent; + cursor: pointer; +} + +ul, ol { list-style: none; } + +h1, h2, h3, h4, h5, h6 { + font-size: inherit; + font-weight: inherit; +} + +p { line-height: 1.5; } + +sup { font-size: 0.75em; vertical-align: super; } + +code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.9em; + background-color: rgba(38, 114, 181, 0.08); + color: #0C2866; + padding: 0.125rem 0.375rem; + border-radius: 0.125rem; +} + + +/* ---------- Screen-reader only ---------------------------- */ + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; +} + + +/* ---------- Sizing ---------------------------------------- */ + +.min-h-screen { min-height: 100vh; } + +.max-w-10xl { max-width: 100rem; } +.max-w-5xl { max-width: 64rem; } +.max-w-2xl { max-width: 42rem; } + +.h-5 { height: 1.25rem; } +.w-5 { width: 1.25rem; } +.h-6 { height: 1.5rem; } +.w-6 { width: 1.5rem; } + + +/* ---------- Margin & padding ------------------------------ */ + +.mx-auto { margin-left: auto; margin-right: auto; } + +.mr-0\.5 { margin-right: 0.125rem; } +.mt-2 { margin-top: 0.5rem; } +.mt-4 { margin-top: 1rem; } +.mb-4 { margin-bottom: 1rem; } +.mb-12 { margin-bottom: 3rem; } + +.px-3 { padding-left: 0.75rem; padding-right: 0.75rem; } +.px-4 { padding-left: 1rem; padding-right: 1rem; } +.px-8 { padding-left: 2rem; padding-right: 2rem; } + +.py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; } +.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; } +.py-4 { padding-top: 1rem; padding-bottom: 1rem; } +.py-10 { padding-top: 2.5rem; padding-bottom: 2.5rem; } +.pb-32 { padding-bottom: 8rem; } + +.p-2 { padding: 0.5rem; } + + +/* ---------- Display & flex -------------------------------- */ + +.block { display: block; } +.inline-flex { display: inline-flex; } +.flex { display: flex; } +.hidden { display: none; } + +.flex-col { flex-direction: column; } + +.items-center { align-items: center; } +.justify-between { justify-content: space-between; } +.justify-center { justify-content: center; } + +.shrink-0 { flex-shrink: 0; } + +.gap-x-3 { column-gap: 0.75rem; } +.gap-x-4 { column-gap: 1rem; } +.gap-y-2 { row-gap: 0.5rem; } + + +/* ---------- Typography ------------------------------------ */ + +.text-lg { font-size: 1.125rem; line-height: 1.5; } +.text-2xl { font-size: 1.5rem; line-height: 2rem; } +.text-3xl { font-size: 1.875rem; line-height: 2.25rem; } + +/* Arbitrary sizes used on the real site */ +.text-hero { font-size: 2.375rem; } /* 38px — main page title */ +.text-section { font-size: 1.75rem; } /* 28px — section heading */ + +.text-center { text-align: center; } +.text-right { text-align: right; } + +.font-medium { font-weight: 500; } + +.leading-tight { line-height: 1.25; } +.leading-relaxed { line-height: 1.625; } + +.underline { text-decoration: underline; } + + +/* ---------- Colors (from the real site) ------------------- */ + +.text-blue { color: #2672B5; } +.text-navy { color: #0C2866; } +.text-gray { color: #404040; } +.text-white { color: #ffffff; } + +/* Red register button — exact gradient from the real site */ +.bg-red-gradient { + background: linear-gradient(to top, #C41C1C, #DD423E); +} + + +/* ---------- Borders, shadows, cursors --------------------- */ + +.rounded-sm { border-radius: 0.125rem; } +.shadow-sm { box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); } +.cursor-pointer { cursor: pointer; } + +.focus\:outline-hidden:focus { outline: none; } + + +/* ---------- List utilities -------------------------------- */ + +.list-disc { + list-style: disc; + padding-left: 1.5rem; +} + + +/* ---------- Spacing between children (space-y-*) ---------- */ + +.space-y-8 > * + * { margin-top: 2rem; } +.space-y-2 > * + * { margin-top: 0.5rem; } + + +/* ---------- Page backgrounds (from the real site) --------- */ + +.bg-ridge { + background-color: #ffffff; + background-image: url("/images/ridge.jpg"); + background-size: 100% auto; + background-repeat: no-repeat; + background-position: 50% 100%; +} + +.bg-mountains { + background-color: #ffffff; + background-image: + linear-gradient(to top, transparent, #ffffff), + linear-gradient(to bottom, transparent, #0C2866), + url("/images/mountains.jpg"), + linear-gradient(to bottom, #ffffff 0% 50%, #0C2866 50% 100%); + background-repeat: no-repeat; + background-size: 100% 100px, 100% 100px, 1800px auto, 100% 100%; + background-position: 50% 0%, 50% 100%, 50% 50%, 50% 0%; +} + +@media (min-width: 550px) { + .bg-mountains { + background-position: 50% 0%, 50% 100%, 50% 100%, 50% 0%; + } +} + + +/* ---------- Content styles for
inside prose ---------- */ + +hr { + border: 0; + border-top: 1px solid #d9dee1; +} + + +/* ============================================================ + * Responsive utilities + * sm: 640px md: 768px lg: 1024px + * ============================================================ */ + +@media (min-width: 640px) { + .sm\:text-3xl { font-size: 1.875rem; line-height: 2.25rem; } +} + +@media (min-width: 768px) { + .md\:block { display: block; } + .md\:flex-row { flex-direction: row; } + .md\:justify-between { justify-content: space-between; } + .md\:items-center { align-items: center; } + .md\:text-right { text-align: right; } + .md\:text-hero { font-size: 2.375rem; } + .md\:leading-tight { line-height: 1.25; } +} + +@media (min-width: 1024px) { + .lg\:block { display: block; } + .lg\:hidden { display: none; } +} + + +/* ============================================================ + * App-specific additions (extends the template utilities for + * forms, cards, buttons, and tables used in auth + admin UIs). + * ============================================================ */ + + +/* ---------- Additional sizing/spacing --------------------- */ + +.max-w-md { max-width: 28rem; } +.max-w-xl { max-width: 36rem; } +.max-w-4xl { max-width: 56rem; } + +.w-full { width: 100%; } + +.mt-1 { margin-top: 0.25rem; } +.mt-3 { margin-top: 0.75rem; } +.mt-6 { margin-top: 1.5rem; } +.mt-8 { margin-top: 2rem; } + +.mb-1 { margin-bottom: 0.25rem; } +.mb-2 { margin-bottom: 0.5rem; } +.mb-6 { margin-bottom: 1.5rem; } +.mb-8 { margin-bottom: 2rem; } + +.ml-2 { margin-left: 0.5rem; } + +.p-4 { padding: 1rem; } +.p-6 { padding: 1.5rem; } +.p-8 { padding: 2rem; } + +.px-6 { padding-left: 1.5rem; padding-right: 1.5rem; } +.py-3 { padding-top: 0.75rem; padding-bottom: 0.75rem; } +.py-6 { padding-top: 1.5rem; padding-bottom: 1.5rem; } +.py-16 { padding-top: 4rem; padding-bottom: 4rem; } + +.pt-4 { padding-top: 1rem; } + + +/* ---------- Typography extras ----------------------------- */ + +.text-sm { font-size: 0.875rem; line-height: 1.25rem; } +.text-xs { font-size: 0.75rem; line-height: 1rem; } +.text-xl { font-size: 1.25rem; line-height: 1.75rem; } + +.font-bold { font-weight: 700; } +.font-semibold { font-weight: 600; } + +.uppercase { text-transform: uppercase; } +.tracking-wide { letter-spacing: 0.025em; } +.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + + +/* ---------- Colors (palette extensions) ------------------- */ + +.text-blue-dark { color: #1d5a94; } +.text-red { color: #C41C1C; } +.text-muted { color: #6b7280; } + +.bg-white { background-color: #ffffff; } +.bg-gray-50 { background-color: #f9fafb; } +.bg-gray-100 { background-color: #f3f4f6; } +.bg-gray-200 { background-color: #e5e7eb; } +.bg-navy { background-color: #0C2866; } +.bg-blue { background-color: #2672B5; } +.bg-green-50 { background-color: #f0fdf4; } +.bg-red-50 { background-color: #fef2f2; } + +.text-green-dark { color: #166534; } +.text-red-dark { color: #991b1b; } + +.border-gray { border: 1px solid #d9dee1; } +.border-navy { border: 1px solid #0C2866; } +.border-t-gray { border-top: 1px solid #e5e7eb; } + + +/* ---------- Layout extras --------------------------------- */ + +.grid { display: grid; } +.inline-block { display: inline-block; } +.gap-3 { gap: 0.75rem; } +.gap-4 { gap: 1rem; } +.space-y-4 > * + * { margin-top: 1rem; } +.space-y-6 > * + * { margin-top: 1.5rem; } + + +/* ---------- Borders & shadows ----------------------------- */ + +.rounded { border-radius: 0.25rem; } +.rounded-md { border-radius: 0.375rem; } +.rounded-lg { border-radius: 0.5rem; } +.rounded-full { border-radius: 9999px; } +.shadow { box-shadow: 0 1px 3px 0 rgba(0,0,0,.1), 0 1px 2px -1px rgba(0,0,0,.1); } + + +/* ---------- Card ------------------------------------------ */ + +.card { + background: #ffffff; + border-radius: 0.5rem; + box-shadow: 0 1px 3px 0 rgba(0,0,0,0.1), 0 1px 2px -1px rgba(0,0,0,0.1); + padding: 2rem; +} + +.card-compact { padding: 1.5rem; } + + +/* ---------- Form controls --------------------------------- */ + +.label { + display: block; + font-size: 0.875rem; + font-weight: 500; + color: #404040; + margin-bottom: 0.375rem; +} + +.input, +.select { + width: 100%; + border: 1px solid #d1d5db; + border-radius: 0.375rem; + padding: 0.5rem 0.75rem; + font-family: inherit; + font-size: 1rem; + color: #111827; + background-color: #ffffff; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.input:focus, +.select:focus { + outline: none; + border-color: #2672B5; + box-shadow: 0 0 0 3px rgba(38, 114, 181, 0.15); +} + +.input::placeholder { color: #9ca3af; } + + +/* ---------- Buttons --------------------------------------- */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.625rem 1.25rem; + font-weight: 500; + font-size: 1rem; + border-radius: 0.25rem; + box-shadow: 0 1px 2px 0 rgba(0,0,0,0.05); + cursor: pointer; + transition: opacity 0.15s ease; + border: 0; +} + +.btn:hover { opacity: 0.9; } + +.btn-red { + background: linear-gradient(to top, #C41C1C, #DD423E); + color: #ffffff; +} + +.btn-navy { + background: #0C2866; + color: #ffffff; +} + +.btn-ghost { + background: transparent; + color: #2672B5; + box-shadow: none; +} + +.btn-ghost:hover { + background: rgba(38, 114, 181, 0.08); + opacity: 1; +} + +.btn-muted { + background: #e5e7eb; + color: #374151; +} + +.btn-full { width: 100%; } + + +/* ---------- Alerts/flash ---------------------------------- */ + +.alert { + padding: 0.75rem 1rem; + border-radius: 0.375rem; + margin-bottom: 1rem; + font-size: 0.9375rem; +} + +.alert-notice { + background: #f0fdf4; + color: #166534; + border: 1px solid #bbf7d0; +} + +.alert-error { + background: #fef2f2; + color: #991b1b; + border: 1px solid #fecaca; +} + + +/* ---------- Tables ---------------------------------------- */ + +.table { + width: 100%; + background: #ffffff; + border-radius: 0.5rem; + box-shadow: 0 1px 3px 0 rgba(0,0,0,0.08); + overflow: hidden; + border-collapse: separate; + border-spacing: 0; +} + +.table th { + text-align: left; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #6b7280; + background: #f9fafb; + padding: 0.75rem 1rem; + border-bottom: 1px solid #e5e7eb; +} + +.table td { + padding: 0.75rem 1rem; + border-top: 1px solid #f3f4f6; + vertical-align: middle; +} + +.table tr:first-child td { border-top: 0; } + + +/* ---------- Role/status badges ---------------------------- */ + +.badge { + display: inline-block; + padding: 0.125rem 0.5rem; + font-size: 0.75rem; + font-weight: 600; + border-radius: 9999px; + text-transform: capitalize; +} + +.badge-admin { + background: rgba(196, 28, 28, 0.1); + color: #991b1b; +} + +.badge-volunteer { + background: rgba(38, 114, 181, 0.12); + color: #1d5a94; +} + +.badge-attendee { + background: #f3f4f6; + color: #374151; +} + + +/* ---------- Admin nav ------------------------------------- */ + +.admin-nav { + background: #0C2866; + color: #ffffff; + padding: 0.875rem 2rem; + display: flex; + align-items: center; + gap: 1.5rem; +} + +.admin-nav a { color: #ffffff; } +.admin-nav a:hover { text-decoration: underline; } +.admin-nav .ml-auto { margin-left: auto; } + +.admin-nav-btn { + background: rgba(255, 255, 255, 0.12); + color: #ffffff; + border: 0; + padding: 0.375rem 0.875rem; + border-radius: 0.25rem; + cursor: pointer; + font-size: 0.875rem; +} + +.admin-nav-btn:hover { background: rgba(255, 255, 255, 0.2); } + + +/* ---------- Section heading helper ------------------------ */ + +.page-title { + font-size: 1.875rem; + font-weight: 500; + color: #0C2866; + line-height: 1.25; + margin-bottom: 0.5rem; +} + +.page-subtitle { + color: #6b7280; + font-size: 1rem; + margin-bottom: 2rem; +} + diff --git a/app/javascript/controllers/toggle_controller.js b/app/javascript/controllers/toggle_controller.js new file mode 100644 index 0000000..253b413 --- /dev/null +++ b/app/javascript/controllers/toggle_controller.js @@ -0,0 +1,31 @@ +import { Controller } from "@hotwired/stimulus" + +/* + * Generic toggle controller — matches the Bridgetown pattern. + * + * Usage: + *
+ * + * + *
+ * + * - #toggle flips the configured class on the toggleable target. + * - #hide re-applies the class (closes the panel). It's bound to + * click@window so clicks outside close the menu; the event is + * ignored if the click came from inside this controller's element + * (so tapping the button doesn't immediately close what it opened). + */ +export default class extends Controller { + static targets = ["toggleable"] + static values = { toggleClass: { type: String, default: "hidden" } } + + toggle(event) { + event?.stopPropagation() + this.toggleableTarget.classList.toggle(this.toggleClassValue) + } + + hide(event) { + if (event && this.element.contains(event.target)) return + this.toggleableTarget.classList.add(this.toggleClassValue) + } +} diff --git a/app/models/configuration.rb b/app/models/configuration.rb index 70b5627..665d785 100644 --- a/app/models/configuration.rb +++ b/app/models/configuration.rb @@ -7,8 +7,8 @@ class Configuration < ApplicationRecord def self.expect(*names, &block) names = names.map(&:to_s) expected_names.merge(names) - case [names, block] - in [name], nil then self[name] + case [ names, block ] + in [ name ], nil then self[name] in Array, nil then values_at(*names) else callback = -> { block.call(*values_at(*names)) } diff --git a/app/models/user.rb b/app/models/user.rb index e3086a5..114f6e6 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -33,7 +33,7 @@ def admin_ticket_url end def full_name - [first_name, last_name].compact.join(" ").presence || email + [ first_name, last_name ].compact.join(" ").presence || email end private diff --git a/app/views/admin/configurations/_form.html.erb b/app/views/admin/configurations/_form.html.erb index b237d44..7353b8a 100644 --- a/app/views/admin/configurations/_form.html.erb +++ b/app/views/admin/configurations/_form.html.erb @@ -1,6 +1,6 @@ <% if configuration.errors.any? %> -
-