diff --git a/Dockerfile b/Dockerfile index 8b5a27c..4b121ee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -72,6 +72,9 @@ COPY --chown=rails:rails --from=build /rails /rails # Entrypoint prepares the database. ENTRYPOINT ["/rails/bin/docker-entrypoint"] -# Start server via Thruster by default, this can be overwritten at runtime +# Start server via Thruster, which reads $PORT from the environment (Railway +# sets this automatically). JSON form satisfies the Dockerfile linter; the +# last two args (./bin/rails server) let bin/docker-entrypoint detect a +# server start and run db:prepare before booting. EXPOSE 80 -CMD bin/rails server -b 0.0.0.0 -p ${PORT} +CMD ["./bin/thrust", "./bin/rails", "server"] 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 5b02093..b08612d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -119,6 +119,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) @@ -199,6 +205,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 @@ -242,6 +250,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) @@ -381,6 +394,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) @@ -432,6 +448,7 @@ DEPENDENCIES letter_opener mission_control-jobs pg (~> 1.1) + postmark-rails propshaft puma (>= 5.0) rack-mini-profiler @@ -443,6 +460,7 @@ DEPENDENCIES solid_queue stimulus-rails thruster + tito_ruby turbo-rails tzinfo-data web-console diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 2c343a0..eedb17d 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -250,3 +250,317 @@ hr { .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/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..790f33a 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -1,4 +1,4 @@ class ApplicationMailer < ActionMailer::Base - default from: "from@example.com" + default from: "hello@blueridgeruby.com" 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/user.rb b/app/models/user.rb new file mode 100644 index 0000000..00d41f1 --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,57 @@ +class User < ApplicationRecord + 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 } + + # Class-level readers point at the *current* event's credentials (from ENV). + # Used for live Tito API calls (sync, login lookup). + def self.tito_api_token = ENV["TITO_API_TOKEN"] + def self.tito_account_slug = ENV["TITO_ACCOUNT_SLUG"] + def self.tito_event_slug = ENV["TITO_EVENT_SLUG"] + + # Instance readers prefer the slugs memorialized on the row (the event that + # issued *this* user's ticket) and fall back to the current ENV values when + # none are stored yet. + def tito_account_slug = attributes["tito_account_slug"].presence || self.class.tito_account_slug + def tito_event_slug = attributes["tito_event_slug"].presence || self.class.tito_event_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 = self.class.tito_client + + def admin_ticket_url + return nil if tito_ticket_slug.blank? + "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 + + # When a user gets linked to a Tito ticket, snapshot the current event's + # slugs onto the row. That way, next year's deploy (with new ENV slugs) + # still renders correct admin_ticket_url values for this year's users. + def memorialize_tito_config + return if tito_ticket_slug.blank? + self[:tito_account_slug] ||= self.class.tito_account_slug + self[:tito_event_slug] ||= self.class.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/users/_form.html.erb b/app/views/admin/users/_form.html.erb new file mode 100644 index 0000000..3db0313 --- /dev/null +++ b/app/views/admin/users/_form.html.erb @@ -0,0 +1,42 @@ +<% if user.errors.any? %> +
| Name | +Role | +Tito | ++ | |
|---|---|---|---|---|
| <%= 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-blue underline" %> + <% end %> + | ++ <%= 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: "btn-ghost ml-2", style: "display:inline;color:#C41C1C;padding:0;box-shadow:none" %> + | +
+ You're signed in to Ruby Embassy. +
+ ++ Enter the email you used to register for Blue Ridge Ruby. We'll send you a login link. +
+ + <% if flash[:notice] %> ++ Don't have a ticket yet? + + Register for Blue Ridge Ruby + +
+
+ We couldn't find a Blue Ridge Ruby ticket for
+
+ <%= @email %>.
+
+ You'll need a ticket before you can sign in. Register on the Blue Ridge Ruby site to get started. +
+ + + Register for Blue Ridge Ruby + + ++ Typed the wrong email? + <%= link_to "Try again", new_session_path, class: "text-blue underline" %> +
+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/bin/docker-entrypoint b/bin/docker-entrypoint index 56d936a..f254fcd 100755 --- a/bin/docker-entrypoint +++ b/bin/docker-entrypoint @@ -5,6 +5,10 @@ if [ "${@: -2:1}" == "./bin/rails" ] && [ "${@: -1:1}" == "server" ]; then echo "Running database setup for all databases..." ./bin/rails db:prepare echo "Database setup complete" + + # Railway assigns $PORT; Thruster reads $HTTP_PORT. Bridge them so the + # proxy binds where the platform routes traffic. + export HTTP_PORT="${HTTP_PORT:-${PORT:-80}}" fi exec "${@}" diff --git a/config/brakeman.ignore b/config/brakeman.ignore new file mode 100644 index 0000000..15186f3 --- /dev/null +++ b/config/brakeman.ignore @@ -0,0 +1,26 @@ +{ + "ignored_warnings": [ + { + "warning_type": "Mass Assignment", + "warning_code": 105, + "fingerprint": "8af1cf15ccd35c1812bcbae788b2ba7ae028c7a2b19b42532f42c09ab60bb33b", + "check_name": "PermitAttributes", + "message": "Potentially dangerous key allowed for mass assignment", + "file": "app/controllers/admin/users_controller.rb", + "line": 83, + "link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/", + "code": "params.require(:user).permit(:first_name, :last_name, :email, :role)", + "render_path": null, + "location": { + "type": "method", + "class": "Admin::UsersController", + "method": "user_params" + }, + "user_input": ":role", + "confidence": "Medium", + "cwe_id": [915], + "note": "Admin-only controller (require_admin! before_action guards every action). Admins are explicitly allowed to change user roles via the admin UI." + } + ], + "brakeman_version": "8.0.4" +} 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 f0cada4..ca89bc4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,15 +1,26 @@ 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 + + # Admin area + namespace :admin do + resources :users do + post :sync, on: :collection + end + end + + # Background jobs dashboard (admin-only mount inside /admin/) + mount MissionControl::Jobs::Engine, at: "/admin/jobs" - # Defines the root path route ("/") + # Public landing page root "pages#home" end 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/migrate/20260422215023_drop_configurations.rb b/db/migrate/20260422215023_drop_configurations.rb new file mode 100644 index 0000000..c2b948f --- /dev/null +++ b/db/migrate/20260422215023_drop_configurations.rb @@ -0,0 +1,11 @@ +class DropConfigurations < ActiveRecord::Migration[8.1] + def change + drop_table :configurations do |t| + t.string :name, null: false + t.string :value + + t.timestamps + t.index [ :name ], unique: true + end + end +end diff --git a/db/schema.rb b/db/schema.rb index f8be1d3..a910623 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,8 +10,21 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 0) do +ActiveRecord::Schema[8.1].define(version: 2026_04_22_215023) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" + 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