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? %>
+
+
+ <% configuration.errors.full_messages.each do |msg| %>
+ - <%= msg %>
+ <% end %>
+
+
+<% 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
+
+
+
+
+
+ | Name |
+ Value |
+ |
+
+
+
+ <% @configurations.each do |configuration| %>
+
+ <%= configuration.name %> |
+ <% if configuration.persisted? %>
+ <%= 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" %>
+ |
+ <% else %>
+ not set |
+
+ <%= link_to "Create", new_admin_configuration_path(name: configuration.name),
+ class: "text-indigo-600 hover:underline" %>
+ |
+ <% end %>
+
+ <% end %>
+
+
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? %>
+
+
+ <% user.errors.full_messages.each do |msg| %>
+ - <%= msg %>
+ <% end %>
+
+
+<% 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" %>
+
+
+
+
+
+
+ | Name |
+ Email |
+ Role |
+ Tito |
+ |
+
+
+
+ <% @users.each do |user| %>
+
+ | <%= 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" %>
+ |
+
+ <% end %>
+
+
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| %>
+
+
+ <%= 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? %>
-
-
+
+
<% configuration.errors.full_messages.each do |msg| %>
- <%= msg %>
<% end %>
@@ -8,28 +8,28 @@
<% 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" %>
-
+
+ <%= form_with model: configuration,
+ url: configuration.persisted? ? admin_configuration_path(configuration) : admin_configurations_path,
+ class: "space-y-4" do |f| %>
+
+ <%= f.label :name, class: "label" %>
+ <%= f.text_field :name, required: true, class: "input" %>
+
-
-
- <% 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.label :value, class: "label" %>
+ <% if configuration.persisted? && configuration.secret? %>
+ <%= f.text_field :value, required: true, value: "",
+ placeholder: "Enter new value", class: "input" %>
+ <% else %>
+ <%= f.text_field :value, required: true, class: "input" %>
+ <% 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 %>
+
+ <%= f.submit class: "btn btn-navy" %>
+ <%= link_to "Cancel", admin_configurations_path, class: "btn btn-muted" %>
+
+ <% end %>
+
diff --git a/app/views/admin/configurations/edit.html.erb b/app/views/admin/configurations/edit.html.erb
index 8e0c080..a3d8071 100644
--- a/app/views/admin/configurations/edit.html.erb
+++ b/app/views/admin/configurations/edit.html.erb
@@ -1,4 +1,4 @@
-
Edit Configuration
+ Edit Configuration
<%= render "form", configuration: @configuration %>
diff --git a/app/views/admin/configurations/index.html.erb b/app/views/admin/configurations/index.html.erb
index 7ecb757..f809b76 100644
--- a/app/views/admin/configurations/index.html.erb
+++ b/app/views/admin/configurations/index.html.erb
@@ -1,39 +1,36 @@
-
Configurations
- <%= link_to "Add configuration", new_admin_configuration_path,
- class: "bg-indigo-600 text-white py-2 px-4 rounded hover:bg-indigo-700" %>
+ Configurations
+ <%= link_to "Add configuration", new_admin_configuration_path, class: "btn btn-red" %>
-
+
Expected keys: tito_api_token, tito_account_slug, tito_event_slug, email_from
-
-
+
+
- | Name |
- Value |
- |
+ Name |
+ Value |
+ |
<% @configurations.each do |configuration| %>
-
- <%= configuration.name %> |
+
+ <%= configuration.name %> |
<% if configuration.persisted? %>
- <%= configuration.display_value %> |
-
- <%= link_to "Edit", edit_admin_configuration_path(configuration),
- class: "text-indigo-600 hover:underline" %>
+ | <%= configuration.display_value %> |
+
+ <%= link_to "Edit", edit_admin_configuration_path(configuration), class: "text-blue 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" %>
+ style: "display:inline;background:transparent;border:0;color:#C41C1C;padding:0;margin-left:0.5rem;box-shadow:none;text-decoration:underline;cursor:pointer" %>
|
<% else %>
- not set |
-
- <%= link_to "Create", new_admin_configuration_path(name: configuration.name),
- class: "text-indigo-600 hover:underline" %>
+ | not set |
+
+ <%= link_to "Create", new_admin_configuration_path(name: configuration.name), class: "text-blue underline" %>
|
<% end %>
diff --git a/app/views/admin/configurations/new.html.erb b/app/views/admin/configurations/new.html.erb
index d7d730d..fd83711 100644
--- a/app/views/admin/configurations/new.html.erb
+++ b/app/views/admin/configurations/new.html.erb
@@ -1,4 +1,4 @@
-
Add Configuration
+ Add Configuration
<%= render "form", configuration: @configuration %>
diff --git a/app/views/admin/users/_form.html.erb b/app/views/admin/users/_form.html.erb
index 1a829aa..3db0313 100644
--- a/app/views/admin/users/_form.html.erb
+++ b/app/views/admin/users/_form.html.erb
@@ -1,6 +1,6 @@
<% if user.errors.any? %>
-
-
+
+
<% user.errors.full_messages.each do |msg| %>
- <%= msg %>
<% end %>
@@ -8,33 +8,35 @@
<% 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" %>
-
+
+ <%= form_with model: user,
+ url: user.persisted? ? admin_user_path(user) : admin_users_path,
+ class: "space-y-4" do |f| %>
+
+ <%= f.label :first_name, class: "label" %>
+ <%= f.text_field :first_name, required: true, class: "input" %>
+
-
-
- <%= f.text_field :last_name, required: true, class: "w-full border rounded px-3 py-2" %>
-
+
+ <%= f.label :last_name, class: "label" %>
+ <%= f.text_field :last_name, required: true, class: "input" %>
+
-
-
- <%= f.email_field :email, required: true, class: "w-full border rounded px-3 py-2" %>
-
+
+ <%= f.label :email, class: "label" %>
+ <%= f.email_field :email, required: true, class: "input" %>
+
-
-
- <%= f.select :role, User.roles.keys.map { |r| [r.humanize, r] },
- {}, class: "w-full border rounded px-3 py-2" %>
-
+
+ <%= f.label :role, class: "label" %>
+ <%= f.select :role,
+ User.roles.keys.map { |r| [r.humanize, r] },
+ {}, class: "select" %>
+
-
- <%= 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 %>
+
+ <%= f.submit class: "btn btn-navy" %>
+ <%= link_to "Cancel", admin_users_path, class: "btn btn-muted" %>
+
+ <% end %>
+
diff --git a/app/views/admin/users/edit.html.erb b/app/views/admin/users/edit.html.erb
index 258f76b..577ff50 100644
--- a/app/views/admin/users/edit.html.erb
+++ b/app/views/admin/users/edit.html.erb
@@ -1,4 +1,4 @@
-
Edit User
+ Edit User
<%= render "form", user: @user %>
diff --git a/app/views/admin/users/index.html.erb b/app/views/admin/users/index.html.erb
index a141926..9900b96 100644
--- a/app/views/admin/users/index.html.erb
+++ b/app/views/admin/users/index.html.erb
@@ -1,48 +1,41 @@
-
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
+
+ <%= button_to "Sync from Tito", sync_admin_users_path, method: :post, class: "btn btn-navy" %>
+ <%= link_to "Add user", new_admin_user_path, class: "btn btn-red" %>
-
-
+
+
- | Name |
- Email |
- Role |
- Tito |
- |
+ Name |
+ Email |
+ Role |
+ Tito |
+ |
<% @users.each do |user| %>
-
- | <%= user.full_name %> |
- <%= user.email %> |
-
-
- <%= user.role.humanize %>
-
+ |
+ | <%= 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" %>
+ class: "text-blue underline" %>
<% end %>
|
-
- <%= link_to "Edit", edit_admin_user_path(user), class: "text-indigo-600 hover:underline" %>
+ |
+ <%= link_to "Edit", edit_admin_user_path(user), class: "text-blue 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" %>
+ class: "btn-ghost ml-2", style: "display:inline;color:#C41C1C;padding:0;box-shadow:none" %>
|
<% end %>
diff --git a/app/views/admin/users/new.html.erb b/app/views/admin/users/new.html.erb
index 58f78d1..24bdf37 100644
--- a/app/views/admin/users/new.html.erb
+++ b/app/views/admin/users/new.html.erb
@@ -1,4 +1,4 @@
-
Add User
+ Add User
<%= render "form", user: @user %>
diff --git a/app/views/dashboard/show.html.erb b/app/views/dashboard/show.html.erb
index b617920..26cd891 100644
--- a/app/views/dashboard/show.html.erb
+++ b/app/views/dashboard/show.html.erb
@@ -1,32 +1,40 @@
-
- <% if flash[:notice] %>
-
<%= flash[:notice] %>
- <% end %>
+<% content_for(:title, "Dashboard — Ruby Embassy") %>
-
Welcome to Ruby Embassy
-
You're signed in.
+
+
+ <% if flash[:notice] %>
+
<%= flash[:notice] %>
+ <% end %>
-
-
-
Name
-
<%= current_user.full_name %>
-
-
-
Email
-
<%= current_user.email %>
-
-
-
Role
-
<%= current_user.role.humanize %>
+
+ Welcome<% if current_user.first_name.present? %>, <%= current_user.first_name %><% end %>
+
+
+ You're signed in to Ruby Embassy.
+
+
+
+
+
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" %>
+
+ <% if current_user.admin? %>
+ <%= link_to "Admin", admin_users_path, class: "btn btn-navy" %>
+ <% end %>
+ <%= button_to "Sign out", session_path, method: :delete, class: "btn btn-muted" %>
+
-
+
diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb
index 1434529..6eb4f32 100644
--- a/app/views/layouts/admin.html.erb
+++ b/app/views/layouts/admin.html.erb
@@ -1,5 +1,5 @@
-
+
<%= content_for(:title) || "Admin — Ruby Embassy" %>
@@ -11,32 +11,32 @@
+
<%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
-