From 6147073b73615f2c8294f0b206cbacd19895f533 Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 03:15:41 -0300 Subject: [PATCH 001/145] first commit --- README.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 000000000..6b4e504a8 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# umanni_test From e3e8ae550974a0917a51b52b7903c73cd1d215b7 Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 03:37:43 -0300 Subject: [PATCH 002/145] chore: scaffold Rails 8.1 app on Ruby 4.0 Development runs the same four SQLite databases as production, or the Solid adapters are never exercised before deploy. Pragmas are explicit so the WAL settings are visible without reading the adapter. --- .dockerignore | 51 ++ .gitattributes | 9 + .gitignore | 41 ++ .kamal/hooks/docker-setup.sample | 3 + .kamal/hooks/post-app-boot.sample | 3 + .kamal/hooks/post-deploy.sample | 14 + .kamal/hooks/post-proxy-reboot.sample | 3 + .kamal/hooks/pre-app-boot.sample | 3 + .kamal/hooks/pre-build.sample | 51 ++ .kamal/hooks/pre-connect.sample | 47 ++ .kamal/hooks/pre-deploy.sample | 122 ++++ .kamal/hooks/pre-proxy-reboot.sample | 3 + .kamal/secrets | 20 + .ruby-version | 1 + Dockerfile | 77 +++ Gemfile | 79 +++ Gemfile.lock | 586 ++++++++++++++++++ Procfile.dev | 3 + README.md | 25 +- Rakefile | 6 + app/assets/builds/.keep | 0 app/assets/images/.keep | 0 app/assets/stylesheets/application.css | 10 + app/assets/tailwind/application.css | 1 + app/controllers/application_controller.rb | 7 + app/controllers/concerns/.keep | 0 app/helpers/application_helper.rb | 2 + app/javascript/application.js | 3 + app/javascript/controllers/application.js | 9 + .../controllers/hello_controller.js | 7 + app/javascript/controllers/index.js | 4 + app/jobs/application_job.rb | 7 + app/mailers/application_mailer.rb | 4 + app/models/application_record.rb | 3 + app/models/concerns/.keep | 0 app/views/layouts/application.html.erb | 31 + app/views/layouts/mailer.html.erb | 13 + app/views/layouts/mailer.text.erb | 1 + app/views/pwa/manifest.json.erb | 22 + app/views/pwa/service-worker.js | 26 + bin/brakeman | 7 + bin/bundler-audit | 6 + bin/ci | 6 + bin/dev | 16 + bin/docker-entrypoint | 8 + bin/importmap | 4 + bin/jobs | 6 + bin/kamal | 16 + bin/rails | 4 + bin/rake | 4 + bin/rubocop | 8 + bin/setup | 35 ++ bin/thrust | 5 + config.ru | 6 + config/application.rb | 39 ++ config/boot.rb | 4 + config/bundler-audit.yml | 5 + config/cable.yml | 23 + config/cache.yml | 16 + config/ci.rb | 24 + config/credentials.yml.enc | 1 + config/database.yml | 63 ++ config/deploy.yml | 119 ++++ config/environment.rb | 5 + config/environments/development.rb | 82 +++ config/environments/production.rb | 90 +++ config/environments/test.rb | 56 ++ config/importmap.rb | 7 + config/initializers/assets.rb | 7 + .../initializers/content_security_policy.rb | 29 + .../initializers/filter_parameter_logging.rb | 8 + config/initializers/inflections.rb | 16 + config/locales/en.yml | 31 + config/puma.rb | 42 ++ config/queue.yml | 18 + config/recurring.yml | 15 + config/routes.rb | 14 + config/storage.yml | 27 + db/cable_schema.rb | 11 + db/cache_schema.rb | 12 + db/queue_schema.rb | 160 +++++ db/seeds.rb | 9 + lib/tasks/.keep | 0 log/.keep | 0 public/400.html | 135 ++++ public/404.html | 135 ++++ public/406-unsupported-browser.html | 135 ++++ public/422.html | 135 ++++ public/500.html | 135 ++++ public/icon.png | Bin 0 -> 4166 bytes public/icon.svg | 3 + public/robots.txt | 1 + script/.keep | 0 storage/.keep | 0 test/controllers/.keep | 0 test/fixtures/files/.keep | 0 test/helpers/.keep | 0 test/integration/.keep | 0 test/mailers/.keep | 0 test/models/.keep | 0 tmp/.keep | 0 tmp/pids/.keep | 0 tmp/storage/.keep | 0 vendor/.keep | 0 vendor/javascript/.keep | 0 105 files changed, 3039 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100755 .kamal/hooks/docker-setup.sample create mode 100755 .kamal/hooks/post-app-boot.sample create mode 100755 .kamal/hooks/post-deploy.sample create mode 100755 .kamal/hooks/post-proxy-reboot.sample create mode 100755 .kamal/hooks/pre-app-boot.sample create mode 100755 .kamal/hooks/pre-build.sample create mode 100755 .kamal/hooks/pre-connect.sample create mode 100755 .kamal/hooks/pre-deploy.sample create mode 100755 .kamal/hooks/pre-proxy-reboot.sample create mode 100644 .kamal/secrets create mode 100644 .ruby-version create mode 100644 Dockerfile create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 Procfile.dev create mode 100644 Rakefile create mode 100644 app/assets/builds/.keep create mode 100644 app/assets/images/.keep create mode 100644 app/assets/stylesheets/application.css create mode 100644 app/assets/tailwind/application.css create mode 100644 app/controllers/application_controller.rb create mode 100644 app/controllers/concerns/.keep create mode 100644 app/helpers/application_helper.rb create mode 100644 app/javascript/application.js create mode 100644 app/javascript/controllers/application.js create mode 100644 app/javascript/controllers/hello_controller.js create mode 100644 app/javascript/controllers/index.js create mode 100644 app/jobs/application_job.rb create mode 100644 app/mailers/application_mailer.rb create mode 100644 app/models/application_record.rb create mode 100644 app/models/concerns/.keep create mode 100644 app/views/layouts/application.html.erb create mode 100644 app/views/layouts/mailer.html.erb create mode 100644 app/views/layouts/mailer.text.erb create mode 100644 app/views/pwa/manifest.json.erb create mode 100644 app/views/pwa/service-worker.js create mode 100755 bin/brakeman create mode 100755 bin/bundler-audit create mode 100755 bin/ci create mode 100755 bin/dev create mode 100755 bin/docker-entrypoint create mode 100755 bin/importmap create mode 100755 bin/jobs create mode 100755 bin/kamal create mode 100755 bin/rails create mode 100755 bin/rake create mode 100755 bin/rubocop create mode 100755 bin/setup create mode 100755 bin/thrust create mode 100644 config.ru create mode 100644 config/application.rb create mode 100644 config/boot.rb create mode 100644 config/bundler-audit.yml create mode 100644 config/cable.yml create mode 100644 config/cache.yml create mode 100644 config/ci.rb create mode 100644 config/credentials.yml.enc create mode 100644 config/database.yml create mode 100644 config/deploy.yml create mode 100644 config/environment.rb create mode 100644 config/environments/development.rb create mode 100644 config/environments/production.rb create mode 100644 config/environments/test.rb create mode 100644 config/importmap.rb create mode 100644 config/initializers/assets.rb create mode 100644 config/initializers/content_security_policy.rb create mode 100644 config/initializers/filter_parameter_logging.rb create mode 100644 config/initializers/inflections.rb create mode 100644 config/locales/en.yml create mode 100644 config/puma.rb create mode 100644 config/queue.yml create mode 100644 config/recurring.yml create mode 100644 config/routes.rb create mode 100644 config/storage.yml create mode 100644 db/cable_schema.rb create mode 100644 db/cache_schema.rb create mode 100644 db/queue_schema.rb create mode 100644 db/seeds.rb create mode 100644 lib/tasks/.keep create mode 100644 log/.keep create mode 100644 public/400.html create mode 100644 public/404.html create mode 100644 public/406-unsupported-browser.html create mode 100644 public/422.html create mode 100644 public/500.html create mode 100644 public/icon.png create mode 100644 public/icon.svg create mode 100644 public/robots.txt create mode 100644 script/.keep create mode 100644 storage/.keep create mode 100644 test/controllers/.keep create mode 100644 test/fixtures/files/.keep create mode 100644 test/helpers/.keep create mode 100644 test/integration/.keep create mode 100644 test/mailers/.keep create mode 100644 test/models/.keep create mode 100644 tmp/.keep create mode 100644 tmp/pids/.keep create mode 100644 tmp/storage/.keep create mode 100644 vendor/.keep create mode 100644 vendor/javascript/.keep diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..325bfc036 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,51 @@ +# See https://docs.docker.com/engine/reference/builder/#dockerignore-file for more about ignoring files. + +# Ignore git directory. +/.git/ +/.gitignore + +# Ignore bundler config. +/.bundle + +# Ignore all environment files. +/.env* + +# Ignore all default key files. +/config/master.key +/config/credentials/*.key + +# Ignore all logfiles and tempfiles. +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep + +# Ignore pidfiles, but keep the directory. +/tmp/pids/* +!/tmp/pids/.keep + +# Ignore storage (uploaded files in development and any SQLite databases). +/storage/* +!/storage/.keep +/tmp/storage/* +!/tmp/storage/.keep + +# Ignore assets. +/node_modules/ +/app/assets/builds/* +!/app/assets/builds/.keep +/public/assets + +# Ignore CI service files. +/.github + +# Ignore Kamal files. +/config/deploy*.yml +/.kamal + +# Ignore development files +/.devcontainer + +# Ignore Docker-related files +/.dockerignore +/Dockerfile* diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..8dc432343 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# See https://git-scm.com/docs/gitattributes for more about git attribute files. + +# Mark the database schema as having been generated. +db/schema.rb linguist-generated + +# Mark any vendored files as having been vendored. +vendor/* linguist-vendored +config/credentials/*.yml.enc diff=rails_credentials +config/credentials.yml.enc diff=rails_credentials diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..ea2a09e12 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files for more about ignoring files. +# +# Temporary files generated by your text editor or operating system +# belong in git's global ignore instead: +# `$XDG_CONFIG_HOME/git/ignore` or `~/.config/git/ignore` + +# Ignore bundler config. +/.bundle + +# Ignore all environment files. +/.env* + +# Ignore all logfiles and tempfiles. +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep + +# Ignore pidfiles, but keep the directory. +/tmp/pids/* +!/tmp/pids/ +!/tmp/pids/.keep + +# Ignore storage (uploaded files in development and any SQLite databases). +/storage/* +!/storage/.keep +/tmp/storage/* +!/tmp/storage/ +!/tmp/storage/.keep + +/public/assets + +# Ignore key files for decrypting credentials and more. +/config/*.key + + +/app/assets/builds/* +!/app/assets/builds/.keep + +# Ignore coverage reports. +/coverage diff --git a/.kamal/hooks/docker-setup.sample b/.kamal/hooks/docker-setup.sample new file mode 100755 index 000000000..a0b053784 --- /dev/null +++ b/.kamal/hooks/docker-setup.sample @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +echo "Docker set up on $KAMAL_HOSTS..." diff --git a/.kamal/hooks/post-app-boot.sample b/.kamal/hooks/post-app-boot.sample new file mode 100755 index 000000000..7d2a13db2 --- /dev/null +++ b/.kamal/hooks/post-app-boot.sample @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +echo "Booted app version $KAMAL_VERSION on $KAMAL_HOSTS..." diff --git a/.kamal/hooks/post-deploy.sample b/.kamal/hooks/post-deploy.sample new file mode 100755 index 000000000..17b0567a5 --- /dev/null +++ b/.kamal/hooks/post-deploy.sample @@ -0,0 +1,14 @@ +#!/usr/bin/env sh + +# A sample post-deploy hook +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_ROLES (if set) +# KAMAL_DESTINATION (if set) +# KAMAL_RUNTIME + +echo "$KAMAL_PERFORMER deployed $KAMAL_VERSION to $KAMAL_DESTINATION in $KAMAL_RUNTIME seconds" diff --git a/.kamal/hooks/post-proxy-reboot.sample b/.kamal/hooks/post-proxy-reboot.sample new file mode 100755 index 000000000..84548ed04 --- /dev/null +++ b/.kamal/hooks/post-proxy-reboot.sample @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +echo "Rebooted kamal-proxy on $KAMAL_HOSTS" diff --git a/.kamal/hooks/pre-app-boot.sample b/.kamal/hooks/pre-app-boot.sample new file mode 100755 index 000000000..1f9fe844c --- /dev/null +++ b/.kamal/hooks/pre-app-boot.sample @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +echo "Booting app version $KAMAL_VERSION on $KAMAL_HOSTS..." diff --git a/.kamal/hooks/pre-build.sample b/.kamal/hooks/pre-build.sample new file mode 100755 index 000000000..d53d28cf7 --- /dev/null +++ b/.kamal/hooks/pre-build.sample @@ -0,0 +1,51 @@ +#!/usr/bin/env sh + +# A sample pre-build hook +# +# Checks: +# 1. We have a clean checkout +# 2. A remote is configured +# 3. The branch has been pushed to the remote +# 4. The version we are deploying matches the remote +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_ROLES (if set) +# KAMAL_DESTINATION (if set) + +if [ -n "$(git status --porcelain)" ]; then + echo "Git checkout is not clean, aborting..." >&2 + git status --porcelain >&2 + exit 1 +fi + +first_remote=$(git remote) + +if [ -z "$first_remote" ]; then + echo "No git remote set, aborting..." >&2 + exit 1 +fi + +current_branch=$(git branch --show-current) + +if [ -z "$current_branch" ]; then + echo "Not on a git branch, aborting..." >&2 + exit 1 +fi + +remote_head=$(git ls-remote $first_remote --tags $current_branch | cut -f1) + +if [ -z "$remote_head" ]; then + echo "Branch not pushed to remote, aborting..." >&2 + exit 1 +fi + +if [ "$KAMAL_VERSION" != "$remote_head" ]; then + echo "Version ($KAMAL_VERSION) does not match remote HEAD ($remote_head), aborting..." >&2 + exit 1 +fi + +exit 0 diff --git a/.kamal/hooks/pre-connect.sample b/.kamal/hooks/pre-connect.sample new file mode 100755 index 000000000..77744bdca --- /dev/null +++ b/.kamal/hooks/pre-connect.sample @@ -0,0 +1,47 @@ +#!/usr/bin/env ruby + +# A sample pre-connect check +# +# Warms DNS before connecting to hosts in parallel +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_ROLES (if set) +# KAMAL_DESTINATION (if set) +# KAMAL_RUNTIME + +hosts = ENV["KAMAL_HOSTS"].split(",") +results = nil +max = 3 + +elapsed = Benchmark.realtime do + results = hosts.map do |host| + Thread.new do + tries = 1 + + begin + Socket.getaddrinfo(host, 0, Socket::AF_UNSPEC, Socket::SOCK_STREAM, nil, Socket::AI_CANONNAME) + rescue SocketError + if tries < max + puts "Retrying DNS warmup: #{host}" + tries += 1 + sleep rand + retry + else + puts "DNS warmup failed: #{host}" + host + end + end + + tries + end + end.map(&:value) +end + +retries = results.sum - hosts.size +nopes = results.count { |r| r == max } + +puts "Prewarmed %d DNS lookups in %.2f sec: %d retries, %d failures" % [ hosts.size, elapsed, retries, nopes ] diff --git a/.kamal/hooks/pre-deploy.sample b/.kamal/hooks/pre-deploy.sample new file mode 100755 index 000000000..05b3055b7 --- /dev/null +++ b/.kamal/hooks/pre-deploy.sample @@ -0,0 +1,122 @@ +#!/usr/bin/env ruby + +# A sample pre-deploy hook +# +# Checks the Github status of the build, waiting for a pending build to complete for up to 720 seconds. +# +# Fails unless the combined status is "success" +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_COMMAND +# KAMAL_SUBCOMMAND +# KAMAL_ROLES (if set) +# KAMAL_DESTINATION (if set) + +# Only check the build status for production deployments +if ENV["KAMAL_COMMAND"] == "rollback" || ENV["KAMAL_DESTINATION"] != "production" + exit 0 +end + +require "bundler/inline" + +# true = install gems so this is fast on repeat invocations +gemfile(true, quiet: true) do + source "https://rubygems.org" + + gem "octokit" + gem "faraday-retry" +end + +MAX_ATTEMPTS = 72 +ATTEMPTS_GAP = 10 + +def exit_with_error(message) + $stderr.puts message + exit 1 +end + +class GithubStatusChecks + attr_reader :remote_url, :git_sha, :github_client, :combined_status + + def initialize + @remote_url = github_repo_from_remote_url + @git_sha = `git rev-parse HEAD`.strip + @github_client = Octokit::Client.new(access_token: ENV["GITHUB_TOKEN"]) + refresh! + end + + def refresh! + @combined_status = github_client.combined_status(remote_url, git_sha) + end + + def state + combined_status[:state] + end + + def first_status_url + first_status = combined_status[:statuses].find { |status| status[:state] == state } + first_status && first_status[:target_url] + end + + def complete_count + combined_status[:statuses].count { |status| status[:state] != "pending"} + end + + def total_count + combined_status[:statuses].count + end + + def current_status + if total_count > 0 + "Completed #{complete_count}/#{total_count} checks, see #{first_status_url} ..." + else + "Build not started..." + end + end + + private + def github_repo_from_remote_url + url = `git config --get remote.origin.url`.strip.delete_suffix(".git") + if url.start_with?("https://github.com/") + url.delete_prefix("https://github.com/") + elsif url.start_with?("git@github.com:") + url.delete_prefix("git@github.com:") + else + url + end + end +end + + +$stdout.sync = true + +begin + puts "Checking build status..." + + attempts = 0 + checks = GithubStatusChecks.new + + loop do + case checks.state + when "success" + puts "Checks passed, see #{checks.first_status_url}" + exit 0 + when "failure" + exit_with_error "Checks failed, see #{checks.first_status_url}" + when "pending" + attempts += 1 + end + + exit_with_error "Checks are still pending, gave up after #{MAX_ATTEMPTS * ATTEMPTS_GAP} seconds" if attempts == MAX_ATTEMPTS + + puts checks.current_status + sleep(ATTEMPTS_GAP) + checks.refresh! + end +rescue Octokit::NotFound + exit_with_error "Build status could not be found" +end diff --git a/.kamal/hooks/pre-proxy-reboot.sample b/.kamal/hooks/pre-proxy-reboot.sample new file mode 100755 index 000000000..93e11991d --- /dev/null +++ b/.kamal/hooks/pre-proxy-reboot.sample @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +echo "Rebooting kamal-proxy on $KAMAL_HOSTS..." diff --git a/.kamal/secrets b/.kamal/secrets new file mode 100644 index 000000000..b3089d6f5 --- /dev/null +++ b/.kamal/secrets @@ -0,0 +1,20 @@ +# Secrets defined here are available for reference under registry/password, env/secret, builder/secrets, +# and accessories/*/env/secret in config/deploy.yml. All secrets should be pulled from either +# password manager, ENV, or a file. DO NOT ENTER RAW CREDENTIALS HERE! This file needs to be safe for git. + +# Example of extracting secrets from 1password (or another compatible pw manager) +# SECRETS=$(kamal secrets fetch --adapter 1password --account your-account --from Vault/Item KAMAL_REGISTRY_PASSWORD RAILS_MASTER_KEY) +# KAMAL_REGISTRY_PASSWORD=$(kamal secrets extract KAMAL_REGISTRY_PASSWORD ${SECRETS}) +# RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY ${SECRETS}) + +# Example of extracting secrets from Rails credentials +# KAMAL_REGISTRY_PASSWORD=$(rails credentials:fetch kamal.registry_password) + +# Use a GITHUB_TOKEN if private repositories are needed for the image +# GITHUB_TOKEN=$(gh config get -h github.com oauth_token) + +# Grab the registry password from ENV +# KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD + +# Improve security by using a password manager. Never check config/master.key into git! +RAILS_MASTER_KEY=$(cat config/master.key) diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 000000000..d13e837c8 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +4.0.6 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..f0c8f7cce --- /dev/null +++ b/Dockerfile @@ -0,0 +1,77 @@ +# syntax=docker/dockerfile:1 +# check=error=true + +# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand: +# docker build -t umanni . +# docker run -d -p 80:80 -e RAILS_MASTER_KEY= --name umanni umanni + +# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html + +# Make sure RUBY_VERSION matches the Ruby version in .ruby-version +ARG RUBY_VERSION=4.0.6 +FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base + +# Rails app lives here +WORKDIR /rails + +# Install base packages +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 && \ + ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Set production environment variables and enable jemalloc for reduced memory usage and latency. +ENV RAILS_ENV="production" \ + BUNDLE_DEPLOYMENT="1" \ + BUNDLE_PATH="/usr/local/bundle" \ + BUNDLE_WITHOUT="development" \ + LD_PRELOAD="/usr/local/lib/libjemalloc.so" + +# Throw-away build stage to reduce size of final image +FROM base AS build + +# Install packages needed to build gems +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y build-essential git libvips libyaml-dev pkg-config && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Install application gems +COPY vendor/* ./vendor/ +COPY Gemfile Gemfile.lock ./ + +RUN bundle install && \ + rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \ + # -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495 + bundle exec bootsnap precompile -j 1 --gemfile + +# Copy application code +COPY . . + +# Precompile bootsnap code for faster boot times. +# -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495 +RUN bundle exec bootsnap precompile -j 1 app/ lib/ + +# Precompiling assets for production without requiring secret RAILS_MASTER_KEY +RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile + + + + +# Final stage for app image +FROM base + +# Run and own only the runtime files as a non-root user for security +RUN groupadd --system --gid 1000 rails && \ + useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash +USER 1000:1000 + +# Copy built artifacts: gems, application +COPY --chown=rails:rails --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" +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 +EXPOSE 80 +CMD ["./bin/thrust", "./bin/rails", "server"] diff --git a/Gemfile b/Gemfile new file mode 100644 index 000000000..6e09d99d4 --- /dev/null +++ b/Gemfile @@ -0,0 +1,79 @@ +source "https://rubygems.org" + +# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" +gem "rails", "~> 8.1.3", ">= 8.1.3.1" +# The modern asset pipeline for Rails [https://github.com/rails/propshaft] +gem "propshaft" +# Use sqlite3 as the database for Active Record +gem "sqlite3", ">= 2.1" +# Use the Puma web server [https://github.com/puma/puma] +gem "puma", ">= 5.0" +# Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails] +gem "importmap-rails" +# Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev] +gem "turbo-rails" +# Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev] +gem "stimulus-rails" +# Use Tailwind CSS [https://github.com/rails/tailwindcss-rails] +gem "tailwindcss-rails" + +# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] +# gem "bcrypt", "~> 3.1.7" + +# Windows does not include zoneinfo files, so bundle the tzinfo-data gem +gem "tzinfo-data", platforms: %i[ windows jruby ] + +# Use the database-backed adapters for Rails.cache, Active Job, and Action Cable +gem "solid_cache" +gem "solid_queue" +gem "solid_cable" + +# Reduces boot times through caching; required in config/boot.rb +gem "bootsnap", require: false + +# Deploy this application anywhere as a Docker container [https://kamal-deploy.org] +gem "kamal", require: false + +# Add HTTP asset caching/compression and X-Sendfile acceleration to Puma [https://github.com/basecamp/thruster/] +gem "thruster", require: false + +# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] +gem "image_processing", "~> 1.2" + +# Reads .csv and .xlsx spreadsheets behind a single interface [https://github.com/roo-rb/roo] +gem "roo", "~> 2.10" +# No longer a default gem as of Ruby 3.4; required by Roo's CSV backend. +gem "csv" + +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" + + # Audits gems for known security defects (use config/bundler-audit.yml to ignore issues) + gem "bundler-audit", require: false + + # Static analysis for security vulnerabilities [https://brakemanscanner.org/] + gem "brakeman", require: false + + # Omakase Ruby styling, extended by .rubocop.yml [https://github.com/rails/rubocop-rails-omakase/] + gem "rubocop-rails-omakase", require: false + gem "rubocop-minitest", require: false + gem "rubocop-performance", require: false + + # Realistic seed data. + gem "faker", "~> 3.5" +end + +group :development do + # Use console on exceptions pages [https://github.com/rails/web-console] + gem "web-console" +end + +group :test do + # Use system testing [https://guides.rubyonrails.org/testing.html#system-testing] + gem "capybara" + gem "selenium-webdriver" + + # Coverage reporting, aggregated across parallel test workers. + gem "simplecov", require: false +end diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 000000000..91ef594a1 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,586 @@ +GEM + remote: https://rubygems.org/ + specs: + action_text-trix (2.1.19) + railties + actioncable (8.1.3.1) + actionpack (= 8.1.3.1) + activesupport (= 8.1.3.1) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + zeitwerk (~> 2.6) + actionmailbox (8.1.3.1) + actionpack (= 8.1.3.1) + activejob (= 8.1.3.1) + activerecord (= 8.1.3.1) + activestorage (= 8.1.3.1) + activesupport (= 8.1.3.1) + mail (>= 2.8.0) + actionmailer (8.1.3.1) + actionpack (= 8.1.3.1) + actionview (= 8.1.3.1) + activejob (= 8.1.3.1) + activesupport (= 8.1.3.1) + mail (>= 2.8.0) + rails-dom-testing (~> 2.2) + actionpack (8.1.3.1) + actionview (= 8.1.3.1) + activesupport (= 8.1.3.1) + nokogiri (>= 1.8.5) + rack (>= 2.2.4) + rack-session (>= 1.0.1) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + useragent (~> 0.16) + actiontext (8.1.3.1) + action_text-trix (~> 2.1.15) + actionpack (= 8.1.3.1) + activerecord (= 8.1.3.1) + activestorage (= 8.1.3.1) + activesupport (= 8.1.3.1) + globalid (>= 0.6.0) + nokogiri (>= 1.8.5) + actionview (8.1.3.1) + activesupport (= 8.1.3.1) + builder (~> 3.1) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activejob (8.1.3.1) + activesupport (= 8.1.3.1) + globalid (>= 0.3.6) + activemodel (8.1.3.1) + activesupport (= 8.1.3.1) + activerecord (8.1.3.1) + activemodel (= 8.1.3.1) + activesupport (= 8.1.3.1) + timeout (>= 0.4.0) + activestorage (8.1.3.1) + actionpack (= 8.1.3.1) + activejob (= 8.1.3.1) + activerecord (= 8.1.3.1) + activesupport (= 8.1.3.1) + marcel (~> 1.0) + activesupport (8.1.3.1) + base64 + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + json + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) + ast (2.4.3) + base64 (0.3.0) + bcrypt_pbkdf (1.1.2) + bigdecimal (4.1.2) + bindex (0.8.1) + bootsnap (1.25.0) + msgpack (~> 1.5) + brakeman (8.0.6) + racc + builder (3.3.0) + bundler-audit (0.9.3) + bundler (>= 1.2.0) + thor (~> 1.0) + capybara (3.40.0) + addressable + matrix + mini_mime (>= 0.1.3) + nokogiri (~> 1.11) + rack (>= 1.6.0) + rack-test (>= 0.6.3) + regexp_parser (>= 1.5, < 3.0) + xpath (~> 3.2) + concurrent-ruby (1.3.8) + connection_pool (3.0.2) + crass (1.0.7) + csv (3.3.6) + date (3.5.1) + debug (1.11.1) + irb (~> 1.10) + reline (>= 0.3.8) + dotenv (3.2.0) + drb (2.2.3) + ed25519 (1.4.0) + erb (6.0.7) + erubi (1.13.1) + et-orbi (1.4.2) + tzinfo + faker (3.8.0) + i18n (>= 1.8.11, < 2) + ffi (1.17.4-aarch64-linux-gnu) + ffi (1.17.4-aarch64-linux-musl) + ffi (1.17.4-arm-linux-gnu) + ffi (1.17.4-arm-linux-musl) + ffi (1.17.4-x86_64-linux-gnu) + ffi (1.17.4-x86_64-linux-musl) + fugit (1.13.0) + et-orbi (~> 1.4) + raabro (~> 1.4) + globalid (1.4.0) + activesupport (>= 6.1) + i18n (1.15.2) + concurrent-ruby (~> 1.0) + image_processing (1.14.0) + mini_magick (>= 4.9.5, < 6) + ruby-vips (>= 2.0.17, < 3) + importmap-rails (2.2.3) + actionpack (>= 6.0.0) + activesupport (>= 6.0.0) + railties (>= 6.0.0) + io-console (0.9.2) + irb (1.18.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + json (2.21.2) + kamal (2.12.0) + activesupport (>= 7.0) + base64 (~> 0.2) + bcrypt_pbkdf (~> 1.0) + concurrent-ruby (~> 1.2) + dotenv (~> 3.1) + ed25519 (~> 1.4) + net-ssh (~> 7.3) + sshkit (>= 1.23.0, < 2.0) + thor (~> 1.3) + zeitwerk (>= 2.6.18, < 3.0) + language_server-protocol (3.17.0.6) + lint_roller (1.1.0) + logger (1.7.0) + loofah (2.25.2) + crass (~> 1.0.2) + nokogiri (>= 1.12.0) + mail (2.9.1) + logger + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp + marcel (1.2.1) + matrix (0.4.3) + mini_magick (5.4.0) + logger + mini_mime (1.1.5) + minitest (6.0.6) + drb (~> 2.0) + prism (~> 1.5) + msgpack (1.8.4) + net-imap (0.6.6) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.3.0) + timeout + net-scp (4.1.0) + net-ssh (>= 2.6.5, < 8.0.0) + net-sftp (4.0.0) + net-ssh (>= 5.0.0, < 8.0.0) + net-smtp (0.5.1) + net-protocol + net-ssh (7.3.3) + nio4r (2.7.5) + nokogiri (1.19.4-aarch64-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.4-aarch64-linux-musl) + racc (~> 1.4) + nokogiri (1.19.4-arm-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.4-arm-linux-musl) + racc (~> 1.4) + nokogiri (1.19.4-x86_64-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.4-x86_64-linux-musl) + racc (~> 1.4) + ostruct (0.6.3) + parallel (2.1.0) + parser (3.3.12.0) + ast (~> 2.4.1) + racc + pp (0.6.4) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) + propshaft (1.3.2) + actionpack (>= 7.0.0) + activesupport (>= 7.0.0) + rack + public_suffix (7.0.5) + puma (8.0.2) + nio4r (~> 2.0) + raabro (1.5.0) + racc (1.8.1) + rack (3.2.7) + rack-session (2.1.2) + base64 (>= 0.1.0) + rack (>= 3.0.0) + rack-test (2.2.0) + rack (>= 1.3) + rackup (2.3.1) + rack (>= 3) + rails (8.1.3.1) + actioncable (= 8.1.3.1) + actionmailbox (= 8.1.3.1) + actionmailer (= 8.1.3.1) + actionpack (= 8.1.3.1) + actiontext (= 8.1.3.1) + actionview (= 8.1.3.1) + activejob (= 8.1.3.1) + activemodel (= 8.1.3.1) + activerecord (= 8.1.3.1) + activestorage (= 8.1.3.1) + activesupport (= 8.1.3.1) + bundler (>= 1.15.0) + railties (= 8.1.3.1) + rails-dom-testing (2.3.0) + activesupport (>= 5.0.0) + minitest + nokogiri (>= 1.6) + rails-html-sanitizer (1.7.1) + loofah (~> 2.25, >= 2.25.2) + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + railties (8.1.3.1) + actionpack (= 8.1.3.1) + activesupport (= 8.1.3.1) + irb (~> 1.13) + rackup (>= 1.0.0) + rake (>= 12.2) + thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) + zeitwerk (~> 2.6) + rainbow (3.1.1) + rake (13.4.2) + rbs (4.2.0) + logger + prism (>= 1.6.0) + tsort + rdoc (8.0.0) + erb + prism (>= 1.6.0) + rbs (>= 4.0.0) + tsort + regexp_parser (2.12.0) + reline (0.7.0) + io-console (~> 0.5) + rexml (3.4.4) + roo (2.10.1) + nokogiri (~> 1) + rubyzip (>= 1.3.0, < 3.0.0) + rubocop (1.90.0) + json (>= 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (>= 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.50.0) + parser (>= 3.3.7.2) + prism (~> 1.7) + rubocop-minitest (0.40.0) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.38.0, < 2.0) + rubocop-performance (1.27.0) + lint_roller (~> 1.1) + rubocop (>= 1.89.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) + rubocop-rails (2.37.0) + activesupport (>= 4.2.0) + lint_roller (~> 1.1) + rack (>= 1.1) + rubocop (>= 1.89.0, < 2.0) + rubocop-ast (>= 1.44.0, < 2.0) + rubocop-rails-omakase (1.1.0) + rubocop (>= 1.72) + rubocop-performance (>= 1.24) + rubocop-rails (>= 2.30) + ruby-progressbar (1.13.0) + ruby-vips (2.3.0) + ffi (~> 1.12) + logger + rubyzip (2.4.1) + securerandom (0.4.1) + selenium-webdriver (4.48.0) + base64 (~> 0.2) + logger (~> 1.4) + rexml (~> 3.2, >= 3.2.5) + rubyzip (>= 1.2.2, < 4.0) + websocket (~> 1.0) + simplecov (1.1.1) + solid_cable (4.0.2) + actioncable (>= 7.2) + activejob (>= 7.2) + activerecord (>= 7.2) + railties (>= 7.2) + solid_cache (1.0.10) + activejob (>= 7.2) + activerecord (>= 7.2) + railties (>= 7.2) + solid_queue (1.7.0) + activejob (>= 7.1) + activerecord (>= 7.1) + concurrent-ruby (>= 1.3.1) + fugit (~> 1.11) + railties (>= 7.1) + thor (>= 1.3.1) + sqlite3 (2.9.6-aarch64-linux-gnu) + sqlite3 (2.9.6-aarch64-linux-musl) + sqlite3 (2.9.6-arm-linux-gnu) + sqlite3 (2.9.6-arm-linux-musl) + sqlite3 (2.9.6-x86_64-linux-gnu) + sqlite3 (2.9.6-x86_64-linux-musl) + sshkit (1.25.1) + base64 + logger + net-scp (>= 1.1.2) + net-sftp (>= 2.1.2) + net-ssh (>= 2.8.0) + ostruct + stimulus-rails (1.3.4) + railties (>= 6.0.0) + tailwindcss-rails (4.6.0) + railties (>= 7.0.0) + tailwindcss-ruby (~> 4.0) + tailwindcss-ruby (4.3.3) + tailwindcss-ruby (4.3.3-aarch64-linux-gnu) + tailwindcss-ruby (4.3.3-aarch64-linux-musl) + tailwindcss-ruby (4.3.3-x86_64-linux-gnu) + tailwindcss-ruby (4.3.3-x86_64-linux-musl) + thor (1.5.0) + thruster (0.1.26) + thruster (0.1.26-aarch64-linux) + thruster (0.1.26-x86_64-linux) + timeout (0.6.1) + tsort (0.2.0) + turbo-rails (2.0.23) + actionpack (>= 7.1.0) + railties (>= 7.1.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + uri (1.1.1) + useragent (0.16.11) + web-console (4.3.0) + actionview (>= 8.0.0) + bindex (>= 0.4.0) + railties (>= 8.0.0) + websocket (1.2.11) + websocket-driver (0.8.2) + base64 + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + xpath (3.2.0) + nokogiri (~> 1.8) + zeitwerk (2.8.3) + +PLATFORMS + aarch64-linux + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + x86_64-linux + x86_64-linux-gnu + x86_64-linux-musl + +DEPENDENCIES + bootsnap + brakeman + bundler-audit + capybara + csv + debug + faker (~> 3.5) + image_processing (~> 1.2) + importmap-rails + kamal + propshaft + puma (>= 5.0) + rails (~> 8.1.3, >= 8.1.3.1) + roo (~> 2.10) + rubocop-minitest + rubocop-performance + rubocop-rails-omakase + selenium-webdriver + simplecov + solid_cable + solid_cache + solid_queue + sqlite3 (>= 2.1) + stimulus-rails + tailwindcss-rails + thruster + turbo-rails + tzinfo-data + web-console + +CHECKSUMS + action_text-trix (2.1.19) sha256=7012f59421009cf284aa651294896414d653a61a2417c9b8714c8476d2f74009 + actioncable (8.1.3.1) sha256=e318528295c878a3efdfe25f0f2267c80cb7a76eba41bb5f64d44aa380a3d91b + actionmailbox (8.1.3.1) sha256=5f704972097d843ade8e435e93694a1dac732b926df1717aceba1f3840082b1c + actionmailer (8.1.3.1) sha256=88ea441b28ff02a0c6c006468892642a3d9942affce9d294e81a74504aa5c43c + actionpack (8.1.3.1) sha256=974cb7154548e81f470b1b0f247b99cb38e87825899dca58610596e2817723d0 + actiontext (8.1.3.1) sha256=5da729d833d1a29cddb1eee938878e55e503d2613e00e735f5daf58c2ba98af2 + actionview (8.1.3.1) sha256=2da68b8414c47b43bfbed1ce69c5afe1c04f78c267aacb5660a4cab5ca12cfb6 + activejob (8.1.3.1) sha256=1c8dd275df930df40deecffec63d913a550a33fd94bd298f69721dd96939954a + activemodel (8.1.3.1) sha256=99cc02ce2faec371d14440949d85787ebd23a907c9baef0a9d4bcd4d21888f88 + activerecord (8.1.3.1) sha256=0a2fb6c28f4938f6b013a3a549bec0a7e37d535f3dc8990e804bcc3258c0403b + activestorage (8.1.3.1) sha256=f555254f387b1cffa499d2fd3115d12635eadc5b15206a8534316a67036163ef + activesupport (8.1.3.1) sha256=85458765f25ea48b9019c46b6bb3fa5683197bf4280d9f06710a6e8d7a831376 + addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af + ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 + base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + bcrypt_pbkdf (1.1.2) sha256=c2414c23ce66869b3eb9f643d6a3374d8322dfb5078125c82792304c10b94cf6 + bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd + bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e + bootsnap (1.25.0) sha256=41059e7d0f9cb4023a33465d095f64b913fc9d1b808d6524c307da945fbcffcf + brakeman (8.0.6) sha256=759cc69341115e6c2dcd47b6fd8649a0b9bd540e3585ac8a0a94e31c66fee386 + builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f + bundler-audit (0.9.3) sha256=81c8766c71e47d0d28a0f98c7eed028539f21a6ea3cd8f685eb6f42333c9b4e9 + capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef + concurrent-ruby (1.3.8) sha256=b2f1be836e968ccc78ccfce277ea79c72a88633f22306782c16ff23fb415d1e1 + connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a + crass (1.0.7) sha256=94868719948664c89ddcaf0a37c65048413dfcb1c869470a5f7a7ceb5390b295 + csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456 + date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 + debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6 + dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d + drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 + ed25519 (1.4.0) sha256=16e97f5198689a154247169f3453ef4cfd3f7a47481fde0ae33206cdfdcac506 + erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92 + erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 + et-orbi (1.4.2) sha256=bb555dae668419cb24caa2a293a170e58be6d4df1e017c51f5030bdc133cd20c + faker (3.8.0) sha256=c147b308df73a90f27a4fc84f18d4c22ef0ad9c2a64b2b61c86fd0ca71753efc + ffi (1.17.4-aarch64-linux-gnu) sha256=b208f06f91ffd8f5e1193da3cae3d2ccfc27fc36fba577baf698d26d91c080df + ffi (1.17.4-aarch64-linux-musl) sha256=9286b7a615f2676245283aef0a0a3b475ae3aae2bb5448baace630bb77b91f39 + ffi (1.17.4-arm-linux-gnu) sha256=d6dbddf7cb77bf955411af5f187a65b8cd378cb003c15c05697f5feee1cb1564 + ffi (1.17.4-arm-linux-musl) sha256=9d4838ded0465bef6e2426935f6bcc93134b6616785a84ffd2a3d82bc3cf6f95 + ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d + ffi (1.17.4-x86_64-linux-musl) sha256=3fdf9888483de005f8ef8d1cf2d3b20d86626af206cbf780f6a6a12439a9c49e + fugit (1.13.0) sha256=a4f093fce740da52f216740a5041e2a594ea763cdb89e8b2754ca4399634ab18 + globalid (1.4.0) sha256=037f12fbf1d9d7a014d501c2d5c77356fd4ddd96d7a7991d6700bba96706f427 + i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5 + image_processing (1.14.0) sha256=754cc169c9c262980889bec6bfd325ed1dafad34f85242b5a07b60af004742fb + importmap-rails (2.2.3) sha256=7101be2a4dc97cf1558fb8f573a718404c5f6bcfe94f304bf1f39e444feeb16a + io-console (0.9.2) sha256=efa74f891dd03c0939a931dfc6e74c2813d904763d456ea9762b0525e748db08 + irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 + json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a + kamal (2.12.0) sha256=c51d1ab085e515470f98d0c0f043637122b5ebf76e8b610cb1fbbed0b7f9b8fa + language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0 + lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + loofah (2.25.2) sha256=2007f746959ac65552456e04b433e83deb22759ab38c838b4445c70e43425918 + mail (2.9.1) sha256=06574eca475253d6c18145dd70af80d0eb970182d55053497c5f4d797ea160e8 + marcel (1.2.1) sha256=1678e9360e32f9eafa917c80029e2f6d10b2715c66a4b87b6d0da9b9cd1f859f + matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b + mini_magick (5.4.0) sha256=f120af581d9ed4ec52c57f35a67a605d112bb1d8f582d1415b147fda42d11d78 + mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef + minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1 + msgpack (1.8.4) sha256=4411c22d350dd1c20250f7eada3cca2695438c2f769cf0782f0cd065d90a3e7b + net-imap (0.6.6) sha256=96aa4ee50df3060203e649efc341f53480b791d49e150f2fdebf68beb141a8df + net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 + net-protocol (0.3.0) sha256=ba310c3d4f1cad46bb1ab20336b06669b1ff8f7c568d9cb9342b32a718547472 + net-scp (4.1.0) sha256=a99b0b92a1e5d360b0de4ffbf2dc0c91531502d3d4f56c28b0139a7c093d1a5d + net-sftp (4.0.0) sha256=65bb91c859c2f93b09826757af11b69af931a3a9155050f50d1b06d384526364 + net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736 + net-ssh (7.3.3) sha256=831def58b2c51dcef66ec00d29397d4f210de89c19fe78f95873ca30f386e86a + nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 + nokogiri (1.19.4-aarch64-linux-gnu) sha256=1269fb644a6de405057a53dd5c762b1209b43ca7424f839454d3dbc677c31a8f + nokogiri (1.19.4-aarch64-linux-musl) sha256=35c65b9ce72b3bb03207bdbe7067915019dc18c1b9b59139684bd6690fdd01af + nokogiri (1.19.4-arm-linux-gnu) sha256=a301313e38bb065d68239e79734bcd6f56fb6efaacebde29e9abf2a4735340ca + nokogiri (1.19.4-arm-linux-musl) sha256=588923c101bcfa78869734d247d25b598674323e7f22474fc468f6e5647311eb + nokogiri (1.19.4-x86_64-linux-gnu) sha256=379fae440b28915e3f19d752ce2dcf8465ed2b2fbefd2a7ca0dd497bc981a06a + nokogiri (1.19.4-x86_64-linux-musl) sha256=17dfb7c1fa194ae02fbf7c51a7afc8d278045ab3fdacfd86f91d02d7b274470b + ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 + parallel (2.1.0) sha256=b35258865c2e31134c5ecb708beaaf6772adf9d5efae28e93e99260877b09356 + parser (3.3.12.0) sha256=21a6d7f755d5a24dfbdc6e6b772e4e879a52e7631a88bc5a3a134606052c9828 + pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570 + prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 + propshaft (1.3.2) sha256=1d56a3e56a92c21bfc29caf07406b5386b00d4c47ddf357cf989a5a234b1389e + public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 + puma (8.0.2) sha256=c8ed871dfbbe66448ea9ffd46692342d9804d4071522b52b5331b7b6e7b686fb + raabro (1.5.0) sha256=3f998a7bc84f9c84df3ab580634d2e0a5bda4f0841168d56035f529c9877440a + racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f + rack (3.2.7) sha256=93e13e1c24f93556671d85d2d79fa228c3485815c50d7e2f265b5330c6528fb7 + rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8 + rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463 + rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868 + rails (8.1.3.1) sha256=ccd11a36bfc171bf9c66d585d14c0ece91c0c9dde840aae60c0118d6f5c9c52a + rails-dom-testing (2.3.0) sha256=8acc7953a7b911ca44588bf08737bc16719f431a1cc3091a292bca7317925c1d + rails-html-sanitizer (1.7.1) sha256=e797a7c9b01e567307e317c576b49ab4168017e63eea4dba9ce3cb587e2f22c2 + railties (8.1.3.1) sha256=2388a232579a00cefea4487de66c8553c3408c1300abdc6cf1799d86ffb04487 + rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a + rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 + rbs (4.2.0) sha256=51f7b886dcc05bc09e10b901daa6a81829f6adc03101d6ca9ea4aac6103e0674 + rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469 + regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb + reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d + rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 + roo (2.10.1) sha256=cbb43bc955f9c110e74b721c835fb9bd3515b63af88ec709ac87fbf30f8be70e + rubocop (1.90.0) sha256=9eb4c065b5c5154e4ef554c547972f3905a9eb6b53e657e580b6796b54bf8242 + rubocop-ast (1.50.0) sha256=b9ca88300da0803ee222ad20cdb30494c0a784eed06fdc35d254b06d662788db + rubocop-minitest (0.40.0) sha256=353c698199115f12151144cf0b5a96f69bb9d77b660cf6536df2c4250c672a9d + rubocop-performance (1.27.0) sha256=eeeb1374d062a368ee1c787b70eb0b0cc4b184cb1f8565f424760946146d61ce + rubocop-rails (2.37.0) sha256=6e1645add5060e0328f8ddda0d820f55697c591394398bf14bb9dccb62f14b7e + rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d + ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 + ruby-vips (2.3.0) sha256=e685ec02c13969912debbd98019e50492e12989282da5f37d05f5471442f5374 + rubyzip (2.4.1) sha256=8577c88edc1fde8935eb91064c5cb1aef9ad5494b940cf19c775ee833e075615 + securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 + selenium-webdriver (4.48.0) sha256=0c8376ebc8a0a4879343fe6fe6eccdcea76748611cd25de370b33eded2077a94 + simplecov (1.1.1) sha256=25825ef13f0b2e74694d769817dad6ab8e90131dabdaa666e522fea105521e78 + solid_cable (4.0.2) sha256=084636a67679ad00d23088b33c84047e614bcf41ee559db24b414d83cdc42d03 + solid_cache (1.0.10) sha256=bc05a2fb3ac78a6f43cbb5946679cf9db67dd30d22939ededc385cb93e120d41 + solid_queue (1.7.0) sha256=6566b70b801d1c317c81bba7bcdd5677c019afac584a30374b4164002ca356d3 + sqlite3 (2.9.6-aarch64-linux-gnu) sha256=d8b1f7d23efd7abac285775a9566562fc7debfef79d594e3a20354406fb7907c + sqlite3 (2.9.6-aarch64-linux-musl) sha256=3579e1c98cdc7ff5c3722847bb63ed4e1efb7ff675cb5e1e48ef2d4da5fb3bc9 + sqlite3 (2.9.6-arm-linux-gnu) sha256=33541500e3615da02afe54a9cc38b17a6985d3cf9d8b76d6d0a83002f114e7ec + sqlite3 (2.9.6-arm-linux-musl) sha256=c5490af48bb228fefa54314e9541375c3907e70f8109f3881b5ff97e1c93ae33 + sqlite3 (2.9.6-x86_64-linux-gnu) sha256=613188ce02f614126ddbc38c5e217ccffd6306d0dcd9adca9764547aa890a634 + sqlite3 (2.9.6-x86_64-linux-musl) sha256=d493b11818a3573387a1d56e1ee8fa00da23a683a7a1cc063e7a0feeed843abf + sshkit (1.25.1) sha256=be3f10b9d6eb0b44d5eaba3f7cbe41bc6bb894bce4339688ac20124391455b78 + stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06 + tailwindcss-rails (4.6.0) sha256=d99512867173d55c5ef8890427682299d8539f550cec1408b3d8667a538bd365 + tailwindcss-ruby (4.3.3) sha256=ee0a64030749862deb501acab4c4aaf5adbee13865746a33299d46d7b5d0952a + tailwindcss-ruby (4.3.3-aarch64-linux-gnu) sha256=c86d6dd3eccc85fe0d792a832b06f2bf3c0a7a83b399308aeb9d8f5725f42a6a + tailwindcss-ruby (4.3.3-aarch64-linux-musl) sha256=72b77ca9edea82383dd09510ab520a122e3cb9f9864ca5b38e27698098d2b899 + tailwindcss-ruby (4.3.3-x86_64-linux-gnu) sha256=2337017ff8b02698480eae1e9637cf01faa0e4824db89d067a13c5a5ee38c9b2 + tailwindcss-ruby (4.3.3-x86_64-linux-musl) sha256=27d478c417bcf73828e5b544744c5bdfd5b5cb54f1a266fc4f185281c32efe6c + thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 + thruster (0.1.26) sha256=6e45e807086b29d51404841bd1ad493b67cd95892fd65dc5afcdd32e82e94ce8 + thruster (0.1.26-aarch64-linux) sha256=2171cb34928c0250830008f535c4ab2ee57846cc3f5d3e96c3475f7b3de7a541 + thruster (0.1.26-x86_64-linux) sha256=3117a6ee430663f845a0457699fe9a05232dcc6c396e2cc83504de5a223c60e8 + timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb + tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f + turbo-rails (2.0.23) sha256=ee0d90733aafff056cf51ff11e803d65e43cae258cc55f6492020ec1f9f9315f + tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b + unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 + unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f + uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 + useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844 + web-console (4.3.0) sha256=e13b71301cdfc2093f155b5aa3a622db80b4672d1f2f713119cc7ec7ac6a6da4 + websocket (1.2.11) sha256=b7e7a74e2410b5e85c25858b26b3322f29161e300935f70a0e0d3c35e0462737 + websocket-driver (0.8.2) sha256=97c556b019bf3410b4961002ac501621e9322d3f8a7bc02161a09301cc4c4146 + websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241 + xpath (3.2.0) sha256=6dfda79d91bb3b949b947ecc5919f042ef2f399b904013eb3ef6d20dd3a4082e + zeitwerk (2.8.3) sha256=2c85125a8467ce069e20123d1e709a08955c9d29c118c25b46b7b7fafdbb92e5 + +BUNDLED WITH + 4.0.16 diff --git a/Procfile.dev b/Procfile.dev new file mode 100644 index 000000000..c7cf64525 --- /dev/null +++ b/Procfile.dev @@ -0,0 +1,3 @@ +web: bin/rails server +css: bin/rails tailwindcss:watch +jobs: bin/jobs diff --git a/README.md b/README.md index 6b4e504a8..7db80e4ca 100644 --- a/README.md +++ b/README.md @@ -1 +1,24 @@ -# umanni_test +# README + +This README would normally document whatever steps are necessary to get the +application up and running. + +Things you may want to cover: + +* Ruby version + +* System dependencies + +* Configuration + +* Database creation + +* Database initialization + +* How to run the test suite + +* Services (job queues, cache servers, search engines, etc.) + +* Deployment instructions + +* ... diff --git a/Rakefile b/Rakefile new file mode 100644 index 000000000..9a5ea7383 --- /dev/null +++ b/Rakefile @@ -0,0 +1,6 @@ +# Add your own tasks in files placed in lib/tasks ending in .rake, +# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. + +require_relative "config/application" + +Rails.application.load_tasks diff --git a/app/assets/builds/.keep b/app/assets/builds/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/app/assets/images/.keep b/app/assets/images/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css new file mode 100644 index 000000000..fe93333c0 --- /dev/null +++ b/app/assets/stylesheets/application.css @@ -0,0 +1,10 @@ +/* + * 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. + */ diff --git a/app/assets/tailwind/application.css b/app/assets/tailwind/application.css new file mode 100644 index 000000000..f1d8c73cd --- /dev/null +++ b/app/assets/tailwind/application.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb new file mode 100644 index 000000000..c3537563d --- /dev/null +++ b/app/controllers/application_controller.rb @@ -0,0 +1,7 @@ +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 +end diff --git a/app/controllers/concerns/.keep b/app/controllers/concerns/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb new file mode 100644 index 000000000..de6be7945 --- /dev/null +++ b/app/helpers/application_helper.rb @@ -0,0 +1,2 @@ +module ApplicationHelper +end diff --git a/app/javascript/application.js b/app/javascript/application.js new file mode 100644 index 000000000..0d7b49404 --- /dev/null +++ b/app/javascript/application.js @@ -0,0 +1,3 @@ +// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails +import "@hotwired/turbo-rails" +import "controllers" diff --git a/app/javascript/controllers/application.js b/app/javascript/controllers/application.js new file mode 100644 index 000000000..1213e85c7 --- /dev/null +++ b/app/javascript/controllers/application.js @@ -0,0 +1,9 @@ +import { Application } from "@hotwired/stimulus" + +const application = Application.start() + +// Configure Stimulus development experience +application.debug = false +window.Stimulus = application + +export { application } diff --git a/app/javascript/controllers/hello_controller.js b/app/javascript/controllers/hello_controller.js new file mode 100644 index 000000000..5975c0789 --- /dev/null +++ b/app/javascript/controllers/hello_controller.js @@ -0,0 +1,7 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + connect() { + this.element.textContent = "Hello World!" + } +} diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js new file mode 100644 index 000000000..1156bf836 --- /dev/null +++ b/app/javascript/controllers/index.js @@ -0,0 +1,4 @@ +// Import and register all your controllers from the importmap via controllers/**/*_controller +import { application } from "controllers/application" +import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading" +eagerLoadControllersFrom("controllers", application) diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb new file mode 100644 index 000000000..d394c3d10 --- /dev/null +++ b/app/jobs/application_job.rb @@ -0,0 +1,7 @@ +class ApplicationJob < ActiveJob::Base + # Automatically retry jobs that encountered a deadlock + # retry_on ActiveRecord::Deadlocked + + # Most jobs are safe to ignore if the underlying records are no longer available + # discard_on ActiveJob::DeserializationError +end diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb new file mode 100644 index 000000000..3c34c8148 --- /dev/null +++ b/app/mailers/application_mailer.rb @@ -0,0 +1,4 @@ +class ApplicationMailer < ActionMailer::Base + default from: "from@example.com" + layout "mailer" +end diff --git a/app/models/application_record.rb b/app/models/application_record.rb new file mode 100644 index 000000000..b63caeb8a --- /dev/null +++ b/app/models/application_record.rb @@ -0,0 +1,3 @@ +class ApplicationRecord < ActiveRecord::Base + primary_abstract_class +end diff --git a/app/models/concerns/.keep b/app/models/concerns/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb new file mode 100644 index 000000000..7db2cd872 --- /dev/null +++ b/app/views/layouts/application.html.erb @@ -0,0 +1,31 @@ + + + + <%= content_for(:title) || "Umanni" %> + + + + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + <%= yield :head %> + + <%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %> + <%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %> + + + + + + <%# Includes all stylesheet files in app/assets/stylesheets %> + <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> + <%= javascript_importmap_tags %> + + + +
+ <%= yield %> +
+ + diff --git a/app/views/layouts/mailer.html.erb b/app/views/layouts/mailer.html.erb new file mode 100644 index 000000000..3aac9002e --- /dev/null +++ b/app/views/layouts/mailer.html.erb @@ -0,0 +1,13 @@ + + + + + + + + + <%= yield %> + + diff --git a/app/views/layouts/mailer.text.erb b/app/views/layouts/mailer.text.erb new file mode 100644 index 000000000..37f0bddbd --- /dev/null +++ b/app/views/layouts/mailer.text.erb @@ -0,0 +1 @@ +<%= yield %> diff --git a/app/views/pwa/manifest.json.erb b/app/views/pwa/manifest.json.erb new file mode 100644 index 000000000..20ec7c027 --- /dev/null +++ b/app/views/pwa/manifest.json.erb @@ -0,0 +1,22 @@ +{ + "name": "Umanni", + "icons": [ + { + "src": "/icon.png", + "type": "image/png", + "sizes": "512x512" + }, + { + "src": "/icon.png", + "type": "image/png", + "sizes": "512x512", + "purpose": "maskable" + } + ], + "start_url": "/", + "display": "standalone", + "scope": "/", + "description": "Umanni.", + "theme_color": "red", + "background_color": "red" +} diff --git a/app/views/pwa/service-worker.js b/app/views/pwa/service-worker.js new file mode 100644 index 000000000..b3a13fb7b --- /dev/null +++ b/app/views/pwa/service-worker.js @@ -0,0 +1,26 @@ +// Add a service worker for processing Web Push notifications: +// +// self.addEventListener("push", async (event) => { +// const { title, options } = await event.data.json() +// event.waitUntil(self.registration.showNotification(title, options)) +// }) +// +// self.addEventListener("notificationclick", function(event) { +// event.notification.close() +// event.waitUntil( +// clients.matchAll({ type: "window" }).then((clientList) => { +// for (let i = 0; i < clientList.length; i++) { +// let client = clientList[i] +// let clientPath = (new URL(client.url)).pathname +// +// if (clientPath == event.notification.data.path && "focus" in client) { +// return client.focus() +// } +// } +// +// if (clients.openWindow) { +// return clients.openWindow(event.notification.data.path) +// } +// }) +// ) +// }) diff --git a/bin/brakeman b/bin/brakeman new file mode 100755 index 000000000..ace1c9ba0 --- /dev/null +++ b/bin/brakeman @@ -0,0 +1,7 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +ARGV.unshift("--ensure-latest") + +load Gem.bin_path("brakeman", "brakeman") diff --git a/bin/bundler-audit b/bin/bundler-audit new file mode 100755 index 000000000..e2ef22690 --- /dev/null +++ b/bin/bundler-audit @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "bundler/audit/cli" + +ARGV.concat %w[ --config config/bundler-audit.yml ] if ARGV.empty? || ARGV.include?("check") +Bundler::Audit::CLI.start diff --git a/bin/ci b/bin/ci new file mode 100755 index 000000000..4137ad5bb --- /dev/null +++ b/bin/ci @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "active_support/continuous_integration" + +CI = ActiveSupport::ContinuousIntegration +require_relative "../config/ci.rb" diff --git a/bin/dev b/bin/dev new file mode 100755 index 000000000..ad72c7d53 --- /dev/null +++ b/bin/dev @@ -0,0 +1,16 @@ +#!/usr/bin/env sh + +if ! gem list foreman -i --silent; then + echo "Installing foreman..." + gem install foreman +fi + +# Default to port 3000 if not specified +export PORT="${PORT:-3000}" + +# Let the debug gem allow remote connections, +# but avoid loading until `debugger` is called +export RUBY_DEBUG_OPEN="true" +export RUBY_DEBUG_LAZY="true" + +exec foreman start -f Procfile.dev "$@" diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint new file mode 100755 index 000000000..ed31659f4 --- /dev/null +++ b/bin/docker-entrypoint @@ -0,0 +1,8 @@ +#!/bin/bash -e + +# If running the rails server then create or migrate existing database +if [ "${@: -2:1}" == "./bin/rails" ] && [ "${@: -1:1}" == "server" ]; then + ./bin/rails db:prepare +fi + +exec "${@}" diff --git a/bin/importmap b/bin/importmap new file mode 100755 index 000000000..36502ab16 --- /dev/null +++ b/bin/importmap @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby + +require_relative "../config/application" +require "importmap/commands" diff --git a/bin/jobs b/bin/jobs new file mode 100755 index 000000000..dcf59f309 --- /dev/null +++ b/bin/jobs @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby + +require_relative "../config/environment" +require "solid_queue/cli" + +SolidQueue::Cli.start(ARGV) diff --git a/bin/kamal b/bin/kamal new file mode 100755 index 000000000..d9ba27670 --- /dev/null +++ b/bin/kamal @@ -0,0 +1,16 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'kamal' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("kamal", "kamal") diff --git a/bin/rails b/bin/rails new file mode 100755 index 000000000..efc037749 --- /dev/null +++ b/bin/rails @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +APP_PATH = File.expand_path("../config/application", __dir__) +require_relative "../config/boot" +require "rails/commands" diff --git a/bin/rake b/bin/rake new file mode 100755 index 000000000..4fbf10b96 --- /dev/null +++ b/bin/rake @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "rake" +Rake.application.run diff --git a/bin/rubocop b/bin/rubocop new file mode 100755 index 000000000..5a2050471 --- /dev/null +++ b/bin/rubocop @@ -0,0 +1,8 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +# Explicit RuboCop config increases performance slightly while avoiding config confusion. +ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__)) + +load Gem.bin_path("rubocop", "rubocop") diff --git a/bin/setup b/bin/setup new file mode 100755 index 000000000..81be011e8 --- /dev/null +++ b/bin/setup @@ -0,0 +1,35 @@ +#!/usr/bin/env ruby +require "fileutils" + +APP_ROOT = File.expand_path("..", __dir__) + +def system!(*args) + system(*args, exception: true) +end + +FileUtils.chdir APP_ROOT do + # This script is a way to set up or update your development environment automatically. + # This script is idempotent, so that you can run it at any time and get an expectable outcome. + # Add necessary setup steps to this file. + + puts "== Installing dependencies ==" + system("bundle check") || system!("bundle install") + + # puts "\n== Copying sample files ==" + # unless File.exist?("config/database.yml") + # FileUtils.cp "config/database.yml.sample", "config/database.yml" + # end + + puts "\n== Preparing database ==" + system! "bin/rails db:prepare" + system! "bin/rails db:reset" if ARGV.include?("--reset") + + puts "\n== Removing old logs and tempfiles ==" + system! "bin/rails log:clear tmp:clear" + + unless ARGV.include?("--skip-server") + puts "\n== Starting development server ==" + STDOUT.flush # flush the output before exec(2) so that it displays + exec "bin/dev" + end +end diff --git a/bin/thrust b/bin/thrust new file mode 100755 index 000000000..36bde2d83 --- /dev/null +++ b/bin/thrust @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("thruster", "thrust") diff --git a/config.ru b/config.ru new file mode 100644 index 000000000..4a3c09a68 --- /dev/null +++ b/config.ru @@ -0,0 +1,6 @@ +# This file is used by Rack-based servers to start the application. + +require_relative "config/environment" + +run Rails.application +Rails.application.load_server diff --git a/config/application.rb b/config/application.rb new file mode 100644 index 000000000..ea8ba6c3f --- /dev/null +++ b/config/application.rb @@ -0,0 +1,39 @@ +require_relative "boot" + +require "rails" +# Pick the frameworks you want: +require "active_model/railtie" +require "active_job/railtie" +require "active_record/railtie" +require "active_storage/engine" +require "action_controller/railtie" +require "action_mailer/railtie" +# require "action_mailbox/engine" +# require "action_text/engine" +require "action_view/railtie" +require "action_cable/engine" +require "rails/test_unit/railtie" + +# Require the gems listed in Gemfile, including any gems +# you've limited to :test, :development, or :production. +Bundler.require(*Rails.groups) + +module Umanni + class Application < Rails::Application + # Initialize configuration defaults for originally generated Rails version. + config.load_defaults 8.1 + + # Please, add to the `ignore` list any other `lib` subdirectories that do + # not contain `.rb` files, or that should not be reloaded or eager loaded. + # Common ones are `templates`, `generators`, or `middleware`, for example. + config.autoload_lib(ignore: %w[assets tasks]) + + # Configuration for the application, engines, and railties goes here. + # + # These settings can be overridden in specific environments using the files + # in config/environments, which are processed later. + # + # config.time_zone = "Central Time (US & Canada)" + # config.eager_load_paths << Rails.root.join("extras") + end +end diff --git a/config/boot.rb b/config/boot.rb new file mode 100644 index 000000000..988a5ddc4 --- /dev/null +++ b/config/boot.rb @@ -0,0 +1,4 @@ +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +require "bundler/setup" # Set up gems listed in the Gemfile. +require "bootsnap/setup" # Speed up boot time by caching expensive operations. diff --git a/config/bundler-audit.yml b/config/bundler-audit.yml new file mode 100644 index 000000000..e74b3af94 --- /dev/null +++ b/config/bundler-audit.yml @@ -0,0 +1,5 @@ +# Audit all gems listed in the Gemfile for known security problems by running bin/bundler-audit. +# CVEs that are not relevant to the application can be enumerated on the ignore list below. + +ignore: + - CVE-THAT-DOES-NOT-APPLY diff --git a/config/cable.yml b/config/cable.yml new file mode 100644 index 000000000..4013226b1 --- /dev/null +++ b/config/cable.yml @@ -0,0 +1,23 @@ +# Solid Cable in development as well as production, so the broadcast path that powers +# the dashboard counters and the import progress bar is the same one that ships. +development: + adapter: solid_cable + connects_to: + database: + writing: cable + polling_interval: 0.1.seconds + message_retention: 1.day + +# System tests drive a real browser against the same process, so they need a working +# in-process pubsub. The :test adapter records broadcasts instead of delivering them, +# which would make every live-update assertion fail for the wrong reason. +test: + adapter: async + +production: + adapter: solid_cable + connects_to: + database: + writing: cable + polling_interval: 0.1.seconds + message_retention: 1.day diff --git a/config/cache.yml b/config/cache.yml new file mode 100644 index 000000000..19d490843 --- /dev/null +++ b/config/cache.yml @@ -0,0 +1,16 @@ +default: &default + store_options: + # Cap age of oldest cache entry to fulfill retention policies + # max_age: <%= 60.days.to_i %> + max_size: <%= 256.megabytes %> + namespace: <%= Rails.env %> + +development: + <<: *default + +test: + <<: *default + +production: + database: cache + <<: *default diff --git a/config/ci.rb b/config/ci.rb new file mode 100644 index 000000000..1712cc112 --- /dev/null +++ b/config/ci.rb @@ -0,0 +1,24 @@ +# Run using bin/ci + +CI.run do + step "Setup", "bin/setup --skip-server" + + step "Style: Ruby", "bin/rubocop" + + step "Security: Gem audit", "bin/bundler-audit" + step "Security: Importmap vulnerability audit", "bin/importmap audit" + step "Security: Brakeman code analysis", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error" + step "Tests: Rails", "bin/rails test" + step "Tests: Seeds", "env RAILS_ENV=test bin/rails db:seed:replant" + + # Optional: Run system tests + # step "Tests: System", "bin/rails test:system" + + # Optional: set a green GitHub commit status to unblock PR merge. + # Requires the `gh` CLI and `gh extension install basecamp/gh-signoff`. + # if success? + # step "Signoff: All systems go. Ready for merge and deploy.", "gh signoff" + # else + # failure "Signoff: CI failed. Do not merge or deploy.", "Fix the issues and try again." + # end +end diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc new file mode 100644 index 000000000..150146b99 --- /dev/null +++ b/config/credentials.yml.enc @@ -0,0 +1 @@ +uENhDg6SeHG8kKYkDOaGvBMDZ/LwJ/3QhSV1xo37qWRSZ4uiNQJd0SZYzkESXS8y3h2YNCWGPfqFCJPBc9s30TmhpkcuuSoJM96wS1nfYaiAm6irFtr1nfq0bHSnbvSJ8uNiy8F1NSaV2wgnR5luvNf8qJk1i4X6vjDDqSsTA7Vcn2OZ9vsdlCl/cqSm9qIKlgvPhyXZxN/DKius5xZXqfLpYAwWJ7+HfPxKbxpv/cwEoDGlAJJijAzqJs7Or3n9vRF2se829Cdxk9lEibD3r9h7q54fRs95HfDf1/qUeLstysAXtzlPua/8DuwUd8Y+dfRIdOxp8ptPQVXgMbsGreNKdyKPj3hfubQNcGUCt6A2VOk2KM80MxHNzfjuBEnSrptfgaFE7nFJN0CogvrIZ3pyhiAnUF/cznMex2WjlFRrgwfgIj5tHUKQHjFFYmh1u4vNoGIhArTvKPrLZrOT0aWIkpIgy0/KxWm3FIj69cwynmA98bkdfbn7--gdM/A9LSi5Yw3OX1--vtrr5SWn0TQZcOKbaE09oA== \ No newline at end of file diff --git a/config/database.yml b/config/database.yml new file mode 100644 index 000000000..d5949c2a5 --- /dev/null +++ b/config/database.yml @@ -0,0 +1,63 @@ +# SQLite in WAL mode. Rails 8 applies these pragmas by default; they are spelled out +# here because they are the difference between a toy SQLite setup and a production one, +# and a reviewer should not have to read the adapter source to know what we run with. +# +# journal_mode: wal readers never block the writer +# synchronous: normal fsync on checkpoint only — safe under WAL, much faster +# foreign_keys: true SQLite enforces FK constraints only when asked to +# busy_timeout (timeout) wait rather than raise SQLite3::BusyException under contention +default: &default + adapter: sqlite3 + max_connections: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + timeout: 5000 + pragmas: + journal_mode: wal + synchronous: normal + foreign_keys: true + mmap_size: 134217728 + journal_size_limit: 67108864 + cache_size: 2000 + +# Development mirrors production's four-database topology on purpose: Solid Queue, +# Solid Cache and Solid Cable are the point of this application, and running them on +# async/memory adapters in development would mean the code path never gets exercised. +development: + primary: + <<: *default + database: storage/development.sqlite3 + cache: + <<: *default + database: storage/development_cache.sqlite3 + migrations_paths: db/cache_migrate + queue: + <<: *default + database: storage/development_queue.sqlite3 + migrations_paths: db/queue_migrate + cable: + <<: *default + database: storage/development_cable.sqlite3 + migrations_paths: db/cable_migrate + +# The test suite runs jobs inline and cable in-process, so it needs the primary +# database only. Keeping it single-database also keeps parallel workers cheap: +# each worker forks its own storage/test.sqlite3-N. +test: + <<: *default + database: storage/test.sqlite3 + +production: + primary: + <<: *default + database: storage/production.sqlite3 + cache: + <<: *default + database: storage/production_cache.sqlite3 + migrations_paths: db/cache_migrate + queue: + <<: *default + database: storage/production_queue.sqlite3 + migrations_paths: db/queue_migrate + cable: + <<: *default + database: storage/production_cable.sqlite3 + migrations_paths: db/cable_migrate diff --git a/config/deploy.yml b/config/deploy.yml new file mode 100644 index 000000000..fbcf28ece --- /dev/null +++ b/config/deploy.yml @@ -0,0 +1,119 @@ +# Name of your application. Used to uniquely configure containers. +service: umanni + +# Name of the container image (use your-user/app-name on external registries). +image: umanni + +# Deploy to these servers. +servers: + web: + - 192.168.0.1 + # job: + # hosts: + # - 192.168.0.1 + # cmd: bin/jobs + +# Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server. +# If used with Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption. +# +# Using an SSL proxy like this requires turning on config.assume_ssl and config.force_ssl in production.rb! +# +# Don't use this when deploying to multiple web servers (then you have to terminate SSL at your load balancer). +# +# proxy: +# ssl: true +# host: app.example.com + +# Where you keep your container images. +registry: + # Alternatives: hub.docker.com / registry.digitalocean.com / ghcr.io / ... + server: localhost:5555 + + # Needed for authenticated registries. + # username: your-user + + # Always use an access token rather than real password when possible. + # password: + # - KAMAL_REGISTRY_PASSWORD + +# Inject ENV variables into containers (secrets come from .kamal/secrets). +env: + secret: + - RAILS_MASTER_KEY + clear: + # Run the Solid Queue Supervisor inside the web server's Puma process to do jobs. + # When you start using multiple servers, you should split out job processing to a dedicated machine. + SOLID_QUEUE_IN_PUMA: true + + # Set number of processes dedicated to Solid Queue (default: 1) + # JOB_CONCURRENCY: 3 + + # Set number of cores available to the application on each server (default: 1). + # WEB_CONCURRENCY: 2 + + # Match this to any external database server to configure Active Record correctly + # Use umanni-db for a db accessory server on same machine via local kamal docker network. + # DB_HOST: 192.168.0.2 + + # Log everything from Rails + # RAILS_LOG_LEVEL: debug + +# Aliases are triggered with "bin/kamal ". You can overwrite arguments on invocation: +# "bin/kamal logs -r job" will tail logs from the first server in the job section. +aliases: + console: app exec --interactive --reuse "bin/rails console" + shell: app exec --interactive --reuse "bash" + logs: app logs -f + dbc: app exec --interactive --reuse "bin/rails dbconsole --include-password" + +# Use a persistent storage volume for sqlite database files and local Active Storage files. +# Recommended to change this to a mounted volume path that is backed up off server. +volumes: + - "umanni_storage:/rails/storage" + +# Bridge fingerprinted assets, like JS and CSS, between versions to avoid +# hitting 404 on in-flight requests. Combines all files from new and old +# version inside the asset_path. +asset_path: /rails/public/assets + +# Configure the image builder. +builder: + arch: amd64 + + # # Build image via remote server (useful for faster amd64 builds on arm64 computers) + # remote: ssh://docker@docker-builder-server + # + # # Pass arguments and secrets to the Docker build process + # args: + # RUBY_VERSION: 4.0.6 + # secrets: + # - GITHUB_TOKEN + # - RAILS_MASTER_KEY + +# Use a different ssh user than root +# ssh: +# user: app + +# Use accessory services (secrets come from .kamal/secrets). +# accessories: +# db: +# image: mysql:8.0 +# host: 192.168.0.2 +# # Change to 3306 to expose port to the world instead of just local network. +# port: "127.0.0.1:3306:3306" +# env: +# clear: +# MYSQL_ROOT_HOST: '%' +# secret: +# - MYSQL_ROOT_PASSWORD +# files: +# - config/mysql/production.cnf:/etc/mysql/my.cnf +# - db/production.sql:/docker-entrypoint-initdb.d/setup.sql +# directories: +# - data:/var/lib/mysql +# redis: +# image: valkey/valkey:8 +# host: 192.168.0.2 +# port: 6379 +# directories: +# - data:/data diff --git a/config/environment.rb b/config/environment.rb new file mode 100644 index 000000000..cac531577 --- /dev/null +++ b/config/environment.rb @@ -0,0 +1,5 @@ +# Load the Rails application. +require_relative "application" + +# Initialize the Rails application. +Rails.application.initialize! diff --git a/config/environments/development.rb b/config/environments/development.rb new file mode 100644 index 000000000..6c416cdbe --- /dev/null +++ b/config/environments/development.rb @@ -0,0 +1,82 @@ +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # Make code changes take effect immediately without server restart. + config.enable_reloading = true + + # Do not eager load code on boot. + config.eager_load = false + + # Show full error reports. + config.consider_all_requests_local = true + + # Enable server timing. + config.server_timing = true + + # Enable/disable Action Controller caching. By default Action Controller caching is disabled. + # Run rails dev:cache to toggle Action Controller caching. + if Rails.root.join("tmp/caching-dev.txt").exist? + config.action_controller.perform_caching = true + config.action_controller.enable_fragment_cache_logging = true + config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" } + else + config.action_controller.perform_caching = false + end + + # Solid Cache/Queue in development too, so background processing and caching run on + # the same adapters as production instead of only being exercised at deploy time. + config.cache_store = :solid_cache_store + + config.active_job.queue_adapter = :solid_queue + config.solid_queue.connects_to = { database: { writing: :queue } } + + # Store uploaded files on the local file system (see config/storage.yml for options). + config.active_storage.service = :local + + # Don't care if the mailer can't send. + config.action_mailer.raise_delivery_errors = false + + # Make template changes take effect immediately. + config.action_mailer.perform_caching = false + + # Set localhost to be used by links generated in mailer templates. + config.action_mailer.default_url_options = { host: "localhost", port: 3000 } + + # Print deprecation notices to the Rails logger. + config.active_support.deprecation = :log + + # Raise an error on page load if there are pending migrations. + config.active_record.migration_error = :page_load + + # Highlight code that triggered database queries in logs. + config.active_record.verbose_query_logs = true + + # Append comments with runtime information tags to SQL queries in logs. + config.active_record.query_log_tags_enabled = true + + # Highlight code that enqueued background job in logs. + config.active_job.verbose_enqueue_logs = true + + # Highlight code that triggered redirect in logs. + config.action_dispatch.verbose_redirect_logs = true + + # Suppress logger output for asset requests. + config.assets.quiet = true + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + config.action_view.annotate_rendered_view_with_filenames = true + + # Uncomment if you wish to allow Action Cable access from any origin. + # config.action_cable.disable_request_forgery_protection = true + + # Raise error when a before_action's only/except options reference missing actions. + config.action_controller.raise_on_missing_callback_actions = true + + # Apply autocorrection by RuboCop to files generated by `bin/rails generate`. + # config.generators.apply_rubocop_autocorrect_after_generate! +end diff --git a/config/environments/production.rb b/config/environments/production.rb new file mode 100644 index 000000000..f5763e04e --- /dev/null +++ b/config/environments/production.rb @@ -0,0 +1,90 @@ +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # Code is not reloaded between requests. + config.enable_reloading = false + + # Eager load code on boot for better performance and memory savings (ignored by Rake tasks). + config.eager_load = true + + # Full error reports are disabled. + config.consider_all_requests_local = false + + # Turn on fragment caching in view templates. + config.action_controller.perform_caching = true + + # Cache assets for far-future expiry since they are all digest stamped. + config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" } + + # Enable serving of images, stylesheets, and JavaScripts from an asset server. + # config.asset_host = "http://assets.example.com" + + # Store uploaded files on the local file system (see config/storage.yml for options). + config.active_storage.service = :local + + # Assume all access to the app is happening through a SSL-terminating reverse proxy. + # config.assume_ssl = true + + # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. + # config.force_ssl = true + + # Skip http-to-https redirect for the default health check endpoint. + # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } + + # Log to STDOUT with the current request id as a default log tag. + config.log_tags = [ :request_id ] + config.logger = ActiveSupport::TaggedLogging.logger(STDOUT) + + # Change to "debug" to log everything (including potentially personally-identifiable information!). + config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") + + # Prevent health checks from clogging up the logs. + config.silence_healthcheck_path = "/up" + + # Don't log any deprecations. + config.active_support.report_deprecations = false + + # Replace the default in-process memory cache store with a durable alternative. + config.cache_store = :solid_cache_store + + # Replace the default in-process and non-durable queuing backend for Active Job. + config.active_job.queue_adapter = :solid_queue + config.solid_queue.connects_to = { database: { writing: :queue } } + + # Ignore bad email addresses and do not raise email delivery errors. + # Set this to true and configure the email server for immediate delivery to raise delivery errors. + # 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 + # } + + # Enable locale fallbacks for I18n (makes lookups for any locale fall back to + # the I18n.default_locale when a translation cannot be found). + config.i18n.fallbacks = true + + # Do not dump schema after migrations. + config.active_record.dump_schema_after_migration = false + + # Only use :id for inspections in production. + config.active_record.attributes_for_inspect = [ :id ] + + # Enable DNS rebinding protection and other `Host` header attacks. + # config.hosts = [ + # "example.com", # Allow requests from example.com + # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` + # ] + # + # Skip DNS rebinding protection for the default health check endpoint. + # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } +end diff --git a/config/environments/test.rb b/config/environments/test.rb new file mode 100644 index 000000000..41f8bc328 --- /dev/null +++ b/config/environments/test.rb @@ -0,0 +1,56 @@ +# The test environment is used exclusively to run your application's +# test suite. You never need to work with it otherwise. Remember that +# your test database is "scratch space" for the test suite and is wiped +# and recreated between test runs. Don't rely on the data there! + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # While tests run files are not watched, reloading is not necessary. + config.enable_reloading = false + + # Eager loading loads your entire application. When running a single test locally, + # this is usually not necessary, and can slow down your test suite. However, it's + # recommended that you enable it in continuous integration systems to ensure eager + # loading is working properly before deploying your code. + config.eager_load = ENV["CI"].present? + + # Configure public file server for tests with cache-control for performance. + config.public_file_server.headers = { "cache-control" => "public, max-age=3600" } + + # Show full error reports. + config.consider_all_requests_local = true + config.cache_store = :null_store + + # Jobs run inline through perform_enqueued_jobs; see test/test_helper.rb. + config.active_job.queue_adapter = :test + + # Render exception templates for rescuable exceptions and raise for other exceptions. + config.action_dispatch.show_exceptions = :rescuable + + # Disable request forgery protection in test environment. + config.action_controller.allow_forgery_protection = false + + # Store uploaded files on the local file system in a temporary directory. + config.active_storage.service = :test + + # Tell Action Mailer not to deliver emails to the real world. + # The :test delivery method accumulates sent emails in the + # ActionMailer::Base.deliveries array. + config.action_mailer.delivery_method = :test + + # Set host to be used by links generated in mailer templates. + config.action_mailer.default_url_options = { host: "example.com" } + + # Print deprecation notices to the stderr. + config.active_support.deprecation = :stderr + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + # config.action_view.annotate_rendered_view_with_filenames = true + + # Raise error when a before_action's only/except options reference missing actions. + config.action_controller.raise_on_missing_callback_actions = true +end diff --git a/config/importmap.rb b/config/importmap.rb new file mode 100644 index 000000000..909dfc542 --- /dev/null +++ b/config/importmap.rb @@ -0,0 +1,7 @@ +# Pin npm packages by running ./bin/importmap + +pin "application" +pin "@hotwired/turbo-rails", to: "turbo.min.js" +pin "@hotwired/stimulus", to: "stimulus.min.js" +pin "@hotwired/stimulus-loading", to: "stimulus-loading.js" +pin_all_from "app/javascript/controllers", under: "controllers" diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb new file mode 100644 index 000000000..487324424 --- /dev/null +++ b/config/initializers/assets.rb @@ -0,0 +1,7 @@ +# Be sure to restart your server when you modify this file. + +# Version of your assets, change this if you want to expire all your assets. +Rails.application.config.assets.version = "1.0" + +# Add additional assets to the asset load path. +# Rails.application.config.assets.paths << Emoji.images_path diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb new file mode 100644 index 000000000..d51d71397 --- /dev/null +++ b/config/initializers/content_security_policy.rb @@ -0,0 +1,29 @@ +# Be sure to restart your server when you modify this file. + +# Define an application-wide content security policy. +# See the Securing Rails Applications Guide for more information: +# https://guides.rubyonrails.org/security.html#content-security-policy-header + +# Rails.application.configure do +# config.content_security_policy do |policy| +# policy.default_src :self, :https +# policy.font_src :self, :https, :data +# policy.img_src :self, :https, :data +# policy.object_src :none +# policy.script_src :self, :https +# policy.style_src :self, :https +# # Specify URI for violation reports +# # policy.report_uri "/csp-violation-report-endpoint" +# end +# +# # Generate session nonces for permitted importmap, inline scripts, and inline styles. +# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } +# config.content_security_policy_nonce_directives = %w(script-src style-src) +# +# # Automatically add `nonce` to `javascript_tag`, `javascript_include_tag`, and `stylesheet_link_tag` +# # if the corresponding directives are specified in `content_security_policy_nonce_directives`. +# # config.content_security_policy_nonce_auto = true +# +# # Report violations without enforcing the policy. +# # config.content_security_policy_report_only = true +# end diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb new file mode 100644 index 000000000..c0b717f7e --- /dev/null +++ b/config/initializers/filter_parameter_logging.rb @@ -0,0 +1,8 @@ +# Be sure to restart your server when you modify this file. + +# Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file. +# Use this to limit dissemination of sensitive information. +# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. +Rails.application.config.filter_parameters += [ + :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc +] diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb new file mode 100644 index 000000000..3860f659e --- /dev/null +++ b/config/initializers/inflections.rb @@ -0,0 +1,16 @@ +# Be sure to restart your server when you modify this file. + +# Add new inflection rules using the following format. Inflections +# are locale specific, and you may define rules for as many different +# locales as you wish. All of these examples are active by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.plural /^(ox)$/i, "\\1en" +# inflect.singular /^(ox)en/i, "\\1" +# inflect.irregular "person", "people" +# inflect.uncountable %w( fish sheep ) +# end + +# These inflection rules are supported but not enabled by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.acronym "RESTful" +# end diff --git a/config/locales/en.yml b/config/locales/en.yml new file mode 100644 index 000000000..6c349ae5e --- /dev/null +++ b/config/locales/en.yml @@ -0,0 +1,31 @@ +# Files in the config/locales directory are used for internationalization and +# are automatically loaded by Rails. If you want to use locales other than +# English, add the necessary files in this directory. +# +# To use the locales, use `I18n.t`: +# +# I18n.t "hello" +# +# In views, this is aliased to just `t`: +# +# <%= t("hello") %> +# +# To use a different locale, set it with `I18n.locale`: +# +# I18n.locale = :es +# +# This would use the information in config/locales/es.yml. +# +# To learn more about the API, please read the Rails Internationalization guide +# at https://guides.rubyonrails.org/i18n.html. +# +# Be aware that YAML interprets the following case-insensitive strings as +# booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings +# must be quoted to be interpreted as strings. For example: +# +# en: +# "yes": yup +# enabled: "ON" + +en: + hello: "Hello world" diff --git a/config/puma.rb b/config/puma.rb new file mode 100644 index 000000000..38c4b8659 --- /dev/null +++ b/config/puma.rb @@ -0,0 +1,42 @@ +# This configuration file will be evaluated by Puma. The top-level methods that +# are invoked here are part of Puma's configuration DSL. For more information +# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html. +# +# Puma starts a configurable number of processes (workers) and each process +# serves each request in a thread from an internal thread pool. +# +# You can control the number of workers using ENV["WEB_CONCURRENCY"]. You +# should only set this value when you want to run 2 or more workers. The +# default is already 1. You can set it to `auto` to automatically start a worker +# for each available processor. +# +# The ideal number of threads per worker depends both on how much time the +# application spends waiting for IO operations and on how much you wish to +# prioritize throughput over latency. +# +# As a rule of thumb, increasing the number of threads will increase how much +# traffic a given process can handle (throughput), but due to CRuby's +# Global VM Lock (GVL) it has diminishing returns and will degrade the +# response time (latency) of the application. +# +# The default is set to 3 threads as it's deemed a decent compromise between +# throughput and latency for the average Rails application. +# +# Any libraries that use a connection pool or another resource pool should +# be configured to provide at least as many connections as the number of +# threads. This includes Active Record's `pool` parameter in `database.yml`. +threads_count = ENV.fetch("RAILS_MAX_THREADS", 3) +threads threads_count, threads_count + +# Specifies the `port` that Puma will listen on to receive requests; default is 3000. +port ENV.fetch("PORT", 3000) + +# Allow puma to be restarted by `bin/rails restart` command. +plugin :tmp_restart + +# Run the Solid Queue supervisor inside of Puma for single-server deployments. +plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"] + +# Specify the PID file. Defaults to tmp/pids/server.pid in development. +# In other environments, only set the PID file if requested. +pidfile ENV["PIDFILE"] if ENV["PIDFILE"] diff --git a/config/queue.yml b/config/queue.yml new file mode 100644 index 000000000..6b1436086 --- /dev/null +++ b/config/queue.yml @@ -0,0 +1,18 @@ +default: &default + dispatchers: + - polling_interval: 1 + batch_size: 500 + workers: + - queues: "*" + threads: 3 + processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %> + polling_interval: 1 + +development: + <<: *default + +test: + <<: *default + +production: + <<: *default diff --git a/config/recurring.yml b/config/recurring.yml new file mode 100644 index 000000000..b4207f9b0 --- /dev/null +++ b/config/recurring.yml @@ -0,0 +1,15 @@ +# examples: +# periodic_cleanup: +# class: CleanSoftDeletedRecordsJob +# queue: background +# args: [ 1000, { batch_size: 500 } ] +# schedule: every hour +# periodic_cleanup_with_command: +# command: "SoftDeletedRecord.due.delete_all" +# priority: 2 +# schedule: at 5am every day + +production: + clear_solid_queue_finished_jobs: + command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)" + schedule: every hour at minute 12 diff --git a/config/routes.rb b/config/routes.rb new file mode 100644 index 000000000..48254e88e --- /dev/null +++ b/config/routes.rb @@ -0,0 +1,14 @@ +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. + get "up" => "rails/health#show", as: :rails_health_check + + # 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 + + # Defines the root path route ("/") + # root "posts#index" +end diff --git a/config/storage.yml b/config/storage.yml new file mode 100644 index 000000000..927dc537c --- /dev/null +++ b/config/storage.yml @@ -0,0 +1,27 @@ +test: + service: Disk + root: <%= Rails.root.join("tmp/storage") %> + +local: + service: Disk + root: <%= Rails.root.join("storage") %> + +# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) +# amazon: +# service: S3 +# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> +# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> +# region: us-east-1 +# bucket: your_own_bucket-<%= Rails.env %> + +# Remember not to checkin your GCS keyfile to a repository +# google: +# service: GCS +# project: your_project +# credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> +# bucket: your_own_bucket-<%= Rails.env %> + +# mirror: +# service: Mirror +# primary: local +# mirrors: [ amazon, google, microsoft ] diff --git a/db/cable_schema.rb b/db/cable_schema.rb new file mode 100644 index 000000000..23666604a --- /dev/null +++ b/db/cable_schema.rb @@ -0,0 +1,11 @@ +ActiveRecord::Schema[7.1].define(version: 1) do + create_table "solid_cable_messages", force: :cascade do |t| + t.binary "channel", limit: 1024, null: false + t.binary "payload", limit: 536870912, null: false + t.datetime "created_at", null: false + t.integer "channel_hash", limit: 8, 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" + end +end diff --git a/db/cache_schema.rb b/db/cache_schema.rb new file mode 100644 index 000000000..81a410d18 --- /dev/null +++ b/db/cache_schema.rb @@ -0,0 +1,12 @@ +ActiveRecord::Schema[7.2].define(version: 1) do + create_table "solid_cache_entries", force: :cascade do |t| + t.binary "key", limit: 1024, null: false + t.binary "value", limit: 536870912, 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.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 + end +end diff --git a/db/queue_schema.rb b/db/queue_schema.rb new file mode 100644 index 000000000..f9a71dabb --- /dev/null +++ b/db/queue_schema.rb @@ -0,0 +1,160 @@ +ActiveRecord::Schema[7.1].define(version: 1) do + 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 + end + + create_table "solid_queue_claimed_executions", force: :cascade do |t| + 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" + 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 + 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.string "concurrency_key" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.bigint "batch_id" + t.index [ "active_job_id" ], name: "index_solid_queue_jobs_on_active_job_id" + t.index [ "batch_id" ], name: "index_solid_queue_jobs_on_batch_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 + end + + create_table "solid_queue_processes", force: :cascade do |t| + 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" + end + + create_table "solid_queue_ready_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.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" + end + + create_table "solid_queue_recurring_executions", force: :cascade do |t| + 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 + 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.integer "priority", default: 0 + 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" + end + + create_table "solid_queue_scheduled_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.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" + 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 "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 + end + + create_table "solid_queue_batches", force: :cascade do |t| + t.string "active_job_batch_id" + t.string "description" + t.text "on_finish" + t.text "on_success" + t.text "on_failure" + t.text "metadata" + t.integer "total_jobs", default: 0, null: false + t.integer "completed_jobs", default: 0, null: false + t.integer "failed_jobs", default: 0, null: false + t.datetime "enqueued_at" + t.datetime "finished_at" + t.datetime "failed_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "active_job_batch_id" ], name: "index_solid_queue_batches_on_active_job_batch_id", unique: true + t.index [ "finished_at" ], name: "index_solid_queue_batches_on_finished_at" + end + + create_table "solid_queue_batch_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.bigint "batch_id", null: false + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_batch_executions_on_job_id", unique: true + t.index [ "batch_id" ], name: "index_solid_queue_batch_executions_on_batch_id" + end + + add_foreign_key "solid_queue_batch_executions", "solid_queue_batches", column: "batch_id", on_delete: :cascade + add_foreign_key "solid_queue_batch_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade +end diff --git a/db/seeds.rb b/db/seeds.rb new file mode 100644 index 000000000..4fbd6ed97 --- /dev/null +++ b/db/seeds.rb @@ -0,0 +1,9 @@ +# 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 diff --git a/lib/tasks/.keep b/lib/tasks/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/log/.keep b/log/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/public/400.html b/public/400.html new file mode 100644 index 000000000..640de0339 --- /dev/null +++ b/public/400.html @@ -0,0 +1,135 @@ + + + + + + + The server cannot process the request due to a client error (400 Bad Request) + + + + + + + + + + + + + +
+
+ +
+
+

The server cannot process the request due to a client error. Please check the request and try again. If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/public/404.html b/public/404.html new file mode 100644 index 000000000..d7f0f1422 --- /dev/null +++ b/public/404.html @@ -0,0 +1,135 @@ + + + + + + + The page you were looking for doesn't exist (404 Not found) + + + + + + + + + + + + + +
+
+ +
+
+

The page you were looking for doesn't exist. You may have mistyped the address or the page may have moved. If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/public/406-unsupported-browser.html b/public/406-unsupported-browser.html new file mode 100644 index 000000000..43d2811e8 --- /dev/null +++ b/public/406-unsupported-browser.html @@ -0,0 +1,135 @@ + + + + + + + Your browser is not supported (406 Not Acceptable) + + + + + + + + + + + + + +
+
+ +
+
+

Your browser is not supported.
Please upgrade your browser to continue.

+
+
+ + + + diff --git a/public/422.html b/public/422.html new file mode 100644 index 000000000..f12fb4aa1 --- /dev/null +++ b/public/422.html @@ -0,0 +1,135 @@ + + + + + + + The change you wanted was rejected (422 Unprocessable Entity) + + + + + + + + + + + + + +
+
+ +
+
+

The change you wanted was rejected. Maybe you tried to change something you didn't have access to. If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/public/500.html b/public/500.html new file mode 100644 index 000000000..e4eb18a75 --- /dev/null +++ b/public/500.html @@ -0,0 +1,135 @@ + + + + + + + We're sorry, but something went wrong (500 Internal Server Error) + + + + + + + + + + + + + +
+
+ +
+
+

We're sorry, but something went wrong.
If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/public/icon.png b/public/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c4c9dbfbbd2f7c1421ffd5727188146213abbcef GIT binary patch literal 4166 zcmd6qU;WFw?|v@m)Sk^&NvB8tcujdV-r1b=i(NJxn&7{KTb zX$3(M+3TP2o^#KAo{#tIjl&t~(8D-k004kqPglzn0HFG(Q~(I*AKsD#M*g7!XK0T7 zN6P7j>HcT8rZgKl$v!xr806dyN19Bd4C0x_R*I-a?#zsTvb_89cyhuC&T**i|Rc zq5b8M;+{8KvoJ~uj9`u~d_f6`V&3+&ZX9x5pc8s)d175;@pjm(?dapmBcm0&vl9+W zx1ZD2o^nuyUHWj|^A8r>lUorO`wFF;>9XL-Jy!P}UXC{(z!FO%SH~8k`#|9;Q|eue zqWL0^Bp(fg_+Pkm!fDKRSY;+^@BF?AJE zCUWpXPst~hi_~u)SzYBDZroR+Z4xeHIlm_3Yc_9nZ(o_gg!jDgVa=E}Y8uDgem9`b zf=mfJ_@(BXSkW53B)F2s!&?_R4ptb1fYXlF++@vPhd=marQgEGRZS@B4g1Mu?euknL= z67P~tZ?*>-Hmi7GwlisNHHJDku-dSm7g@!=a}9cSL6Pa^w^2?&?$Oi8ibrr>w)xqx zOH_EMU@m05)9kuNR>>4@H%|){U$^yvVQ(YgOlh;5oU_-vivG-p4=LrN-k7D?*?u1u zsWly%tfAzKd6Fb=`eU2un_uaTXmcT#tlOL+aRS=kZZf}A7qT8lvcTx~7j` z*b>=z)mwg7%B2_!D0!1IZ?Nq{^Y$uI4Qx*6T!E2Col&2{k?ImCO=dD~A&9f9diXy^$x{6CwkBimn|1E09 zAMSezYtiL?O6hS37KpvDM?22&d{l)7h-!F)C-d3j8Z`c@($?mfd{R82)H>Qe`h{~G z!I}(2j(|49{LR?w4Jspl_i!(4T{31|dqCOpI52r5NhxYV+cDAu(xp*4iqZ2e-$YP= zoFOPmm|u*7C?S{Fp43y+V;>~@FFR76bCl@pTtyB93vNWy5yf;HKr8^0d7&GVIslYm zo3Tgt@M!`8B6IW&lK{Xk>%zp41G%`(DR&^u z5^pwD4>E6-w<8Kl2DzJ%a@~QDE$(e87lNhy?-Qgep!$b?5f7+&EM7$e>|WrX+=zCb z=!f5P>MxFyy;mIRxjc(H*}mceXw5a*IpC0PEYJ8Y3{JdoIW)@t97{wcUB@u+$FCCO z;s2Qe(d~oJC^`m$7DE-dsha`glrtu&v&93IZadvl_yjp!c89>zo;Krk+d&DEG4?x$ zufC1n+c1XD7dolX1q|7}uelR$`pT0Z)1jun<39$Sn2V5g&|(j~Z!wOddfYiZo7)A< z!dK`aBHOOk+-E_xbWCA3VR-+o$i5eO9`rMI#p_0xQ}rjEpGW;U!&&PKnivOcG(|m9 z!C8?WC6nCXw25WVa*eew)zQ=h45k8jSIPbq&?VE{oG%?4>9rwEeB4&qe#?-y_es4c|7ufw%+H5EY#oCgv!Lzv291#-oNlX~X+Jl5(riC~r z=0M|wMOP)Tt8@hNg&%V@Z9@J|Q#K*hE>sr6@oguas9&6^-=~$*2Gs%h#GF@h)i=Im z^iKk~ipWJg1VrvKS;_2lgs3n1zvNvxb27nGM=NXE!D4C!U`f*K2B@^^&ij9y}DTLB*FI zEnBL6y{jc?JqXWbkIZd7I16hA>(f9T!iwbIxJj~bKPfrO;>%*5nk&Lf?G@c2wvGrY&41$W{7HM9+b@&XY@>NZM5s|EK_Dp zQX60CBuantx>|d#DsaZ*8MW(we|#KTYZ=vNa#d*DJQe6hr~J6{_rI#?wi@s|&O}FR zG$kfPxheXh1?IZ{bDT-CWB4FTvO-k5scW^mi8?iY5Q`f8JcnnCxiy@m@D-%lO;y0pTLhh6i6l@x52j=#^$5_U^os}OFg zzdHbo(QI`%9#o*r8GCW~T3UdV`szO#~)^&X_(VW>o~umY9-ns9-V4lf~j z`QBD~pJ4a#b`*6bJ^3RS5y?RAgF7K5$ll97Y8#WZduZ`j?IEY~H(s^doZg>7-tk*t z4_QE1%%bb^p~4F5SB$t2i1>DBG1cIo;2(xTaj*Y~hlM{tSDHojL-QPg%Mo%6^7FrpB*{ z4G0@T{-77Por4DCMF zB_5Y~Phv%EQ64W8^GS6h?x6xh;w2{z3$rhC;m+;uD&pR74j+i22P5DS-tE8ABvH(U~indEbBUTAAAXfHZg5QpB@TgV9eI<)JrAkOI z8!TSOgfAJiWAXeM&vR4Glh;VxH}WG&V$bVb`a`g}GSpwggti*&)taV1@Ak|{WrV|5 zmNYx)Ans=S{c52qv@+jmGQ&vd6>6yX6IKq9O$3r&0xUTdZ!m1!irzn`SY+F23Rl6# zFRxws&gV-kM1NX(3(gnKpGi0Q)Dxi~#?nyzOR9!en;Ij>YJZVFAL*=R%7y%Mz9hU% zs>+ZB?qRmZ)nISx7wxY)y#cd$iaC~{k0avD>BjyF1q^mNQ1QcwsxiTySe<6C&cC6P zE`vwO9^k-d`9hZ!+r@Jnr+MF*2;2l8WjZ}DrwDUHzSF{WoG zucbSWguA!3KgB3MU%HH`R;XqVv0CcaGq?+;v_A5A2kpmk5V%qZE3yzQ7R5XWhq=eR zyUezH=@V)y>L9T-M-?tW(PQYTRBKZSVb_!$^H-Pn%ea;!vS_?M<~Tm>_rWIW43sPW z=!lY&fWc1g7+r?R)0p8(%zp&vl+FK4HRkns%BW+Up&wK8!lQ2~bja|9bD12WrKn#M zK)Yl9*8$SI7MAwSK$%)dMd>o+1UD<2&aQMhyjS5R{-vV+M;Q4bzl~Z~=4HFj_#2V9 zB)Gfzx3ncy@uzx?yzi}6>d%-?WE}h7v*w)Jr_gBl!2P&F3DX>j_1#--yjpL%<;JMR z*b70Gr)MMIBWDo~#<5F^Q0$VKI;SBIRneuR7)yVsN~A9I@gZTXe)E?iVII+X5h0~H zx^c(fP&4>!*q>fb6dAOC?MI>Cz3kld#J*;uik+Ps49cwm1B4 zZc1|ZxYyTv;{Z!?qS=D)sgRKx^1AYf%;y_V&VgZglfU>d+Ufk5&LV$sKv}Hoj+s; xK3FZRYdhbXT_@RW*ff3@`D1#ps#~H)p+y&j#(J|vk^lW{fF9OJt5(B-_&*Xgn9~3N literal 0 HcmV?d00001 diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 000000000..04b34bf83 --- /dev/null +++ b/public/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 000000000..c19f78ab6 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file diff --git a/script/.keep b/script/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/storage/.keep b/storage/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/test/controllers/.keep b/test/controllers/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/test/fixtures/files/.keep b/test/fixtures/files/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/test/helpers/.keep b/test/helpers/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/test/integration/.keep b/test/integration/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/test/mailers/.keep b/test/mailers/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/test/models/.keep b/test/models/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/tmp/.keep b/tmp/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/tmp/pids/.keep b/tmp/pids/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/tmp/storage/.keep b/tmp/storage/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/vendor/.keep b/vendor/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/vendor/javascript/.keep b/vendor/javascript/.keep new file mode 100644 index 000000000..e69de29bb From 08e1d26a3d63431e3e2f22e5a103fe417db33e90 Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 03:38:03 -0300 Subject: [PATCH 003/145] chore: extend RuboCop omakase with metrics and the Rails cops Style/IfUnlessModifier stays off; it fights guard clauses. --- .rubocop.yml | 85 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 .rubocop.yml diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 000000000..5a6f0f67d --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,85 @@ +# Omakase is the baseline Rails 8 ships with. Everything below is a deliberate +# addition on top of it: the framework cops omakase leaves out, the performance +# and Minitest plugins, and complexity ceilings that force extraction instead of +# letting a controller action quietly grow to forty lines. +inherit_gem: + rubocop-rails-omakase: rubocop.yml + +plugins: + - rubocop-minitest + - rubocop-performance + +AllCops: + TargetRubyVersion: 4.0 + NewCops: enable + Exclude: + - bin/**/* + - db/schema.rb + - db/*_schema.rb + - db/migrate/*_create_active_storage_tables.active_storage.rb + - vendor/**/* + - node_modules/**/* + - storage/**/* + - tmp/**/* + +Metrics/AbcSize: + Max: 17 +Metrics/BlockLength: + AllowedMethods: [ configure, describe, context, draw, included, class_methods ] + Max: 30 +Metrics/ClassLength: + Max: 120 + Exclude: + - test/**/* +Metrics/CyclomaticComplexity: + Max: 7 +Metrics/MethodLength: + Max: 15 + Exclude: + - db/migrate/**/* + - test/**/* +Metrics/ModuleLength: + Max: 120 +Metrics/ParameterLists: + Max: 5 +Metrics/PerceivedComplexity: + Max: 8 + +Layout/LineLength: + Max: 120 + AllowedPatterns: [ '\A#' ] + Exclude: + - db/migrate/**/* + +# Omakase stays quiet about naming and dead code; on a codebase meant to be +# maintained by more than one person, both are worth failing CI over. +Lint/UselessAssignment: + Enabled: true +Naming/PredicateMethod: + Enabled: true +Style/RedundantReturn: + Enabled: true +Style/GuardClause: + Enabled: true +Style/IfUnlessModifier: + Enabled: false + +Rails/Delegate: + Enabled: true +Rails/HasManyOrHasOneDependent: + Enabled: true +Rails/InverseOf: + Enabled: true +Rails/OutputSafety: + Enabled: true +Rails/SkipsModelValidations: + Enabled: true + # update_counters is how the import reports progress without loading each row. + AllowedMethods: [ touch, update_counters, insert_all, upsert_all ] +Rails/UniqueValidationWithoutIndex: + Enabled: true +Rails/Validation: + Enabled: true + +Minitest/MultipleAssertions: + Max: 12 From 71d69b52e369eb63097b80b8c78a0210625a7873 Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 04:28:53 -0300 Subject: [PATCH 004/145] test: parallelize, and merge SimpleCov results per worker Without the merge the report shows one worker's share. --- .simplecov | 21 +++++++++++++++++++++ test/application_system_test_case.rb | 8 ++++++++ test/test_helper.rb | 26 ++++++++++++++++++++++++++ 3 files changed, 55 insertions(+) create mode 100644 .simplecov create mode 100644 test/application_system_test_case.rb create mode 100644 test/test_helper.rb diff --git a/.simplecov b/.simplecov new file mode 100644 index 000000000..bf980f447 --- /dev/null +++ b/.simplecov @@ -0,0 +1,21 @@ +SimpleCov.start "rails" do + enable_coverage :branch + + add_filter %r{\A/test/} + add_filter "app/channels/application_cable" + add_filter "config/" + + add_group "Services", "app/services" + add_group "Policies", "app/policies" + add_group "Jobs", "app/jobs" + + # Parallel workers each write their own resultset; merging is what turns them + # back into a single number. Without it the report reads ~1/N of reality. + use_merging true + merge_timeout 600 + + # The brief asks for 90% line coverage. Branch coverage is measured and reported + # but not enforced: failing a submission on a threshold nobody asked for is a + # self-inflicted red build. + minimum_coverage line: 90 if ENV["CI"] || ENV["COVERAGE"] +end diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb new file mode 100644 index 000000000..0abc80e06 --- /dev/null +++ b/test/application_system_test_case.rb @@ -0,0 +1,8 @@ +require "test_helper" + +class ApplicationSystemTestCase < ActionDispatch::SystemTestCase + # Escape hatch for machines where Chrome is not on PATH (WSL, slim containers). + Selenium::WebDriver::Chrome.path = ENV["CHROME_BINARY"] if ENV["CHROME_BINARY"].present? + + driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 1400 ] +end diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 000000000..2f63363e7 --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,26 @@ +ENV["RAILS_ENV"] ||= "test" + +require "simplecov" + +require_relative "../config/environment" +require "rails/test_help" + +module ActiveSupport + class TestCase + parallelize(workers: :number_of_processors) + + # Each worker reports coverage under its own command name, then merges into the + # parent result. Skipping this makes SimpleCov report only the last worker's share. + parallelize_setup do |worker| + SimpleCov.command_name "#{SimpleCov.command_name}-#{worker}" + end + + parallelize_teardown do + SimpleCov.result + end + + fixtures :all + + include ActiveJob::TestHelper + end +end From ebdcddcaae01dbd2e4dfa752816fe2087b5ad018 Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 04:28:53 -0300 Subject: [PATCH 005/145] ci: run scans, lint and the full suite on pull requests Unit and system tests in one job so the coverage floor sees the whole suite. --- .github/dependabot.yml | 12 +++++ .github/workflows/ci.yml | 101 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..83610cfa4 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: +- package-ecosystem: bundler + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 10 +- package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 10 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..8271db0e3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,101 @@ +name: CI + +on: + pull_request: + push: + branches: [ master ] + +jobs: + scan_ruby: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Scan for common Rails security vulnerabilities using static analysis + run: bin/brakeman --no-pager + + - name: Scan for known security vulnerabilities in gems used + run: bin/bundler-audit + + scan_js: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Scan for security vulnerabilities in JavaScript dependencies + run: bin/importmap audit + + lint: + runs-on: ubuntu-latest + env: + RUBOCOP_CACHE_ROOT: tmp/rubocop + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Prepare RuboCop cache + uses: actions/cache@v4 + env: + DEPENDENCIES_HASH: ${{ hashFiles('.ruby-version', '**/.rubocop.yml', 'Gemfile.lock') }} + with: + path: ${{ env.RUBOCOP_CACHE_ROOT }} + key: rubocop-${{ runner.os }}-${{ env.DEPENDENCIES_HASH }}-${{ github.ref_name == github.event.repository.default_branch && github.run_id || 'default' }} + restore-keys: | + rubocop-${{ runner.os }}-${{ env.DEPENDENCIES_HASH }}- + + - name: Lint code for consistent style + run: bin/rubocop -f github + + # Unit, integration and system tests run in a single job on purpose: SimpleCov's + # 90% floor is only meaningful when measured over the whole suite at once. + test: + runs-on: ubuntu-latest + steps: + - name: Install packages + run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libvips + + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Run the full test suite + env: + RAILS_ENV: test + run: bin/rails db:test:prepare test:all + + - name: Keep screenshots from failed system tests + uses: actions/upload-artifact@v4 + if: failure() + with: + name: screenshots + path: ${{ github.workspace }}/tmp/screenshots + if-no-files-found: ignore + + - name: Publish coverage report + uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage + path: ${{ github.workspace }}/coverage + if-no-files-found: ignore From 817ca7b1e806273e2d65d87a6df233eca5cdd482 Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 04:28:53 -0300 Subject: [PATCH 006/145] chore: add a Docker Compose development environment No database service; SQLite and the Solid databases live in the storage volume. Host port 3200 to avoid a Rails server already on 3000. --- Dockerfile.dev | 28 ++++++++++++++++++++++++++++ Procfile.dev | 5 ++++- compose.yaml | 39 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 Dockerfile.dev create mode 100644 compose.yaml diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 000000000..06c7bf41e --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,28 @@ +# syntax=docker/dockerfile:1 +# check=error=true + +# Development image. The production build lives in ./Dockerfile — it is multi-stage, +# runs as a non-root user and serves through Thruster. This one trades image size for +# a working toolchain: build headers stay installed so native gems can be rebuilt. + +ARG RUBY_VERSION=4.0.6 +FROM docker.io/library/ruby:$RUBY_VERSION-slim + +WORKDIR /rails + +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y \ + build-essential curl git libvips libyaml-dev pkg-config sqlite3 && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +ENV RAILS_ENV="development" \ + BUNDLE_PATH="/usr/local/bundle" \ + BINDING="0.0.0.0" + +COPY Gemfile Gemfile.lock ./ +RUN bundle install + +COPY . . + +EXPOSE 3000 +CMD ["bin/dev"] diff --git a/Procfile.dev b/Procfile.dev index c7cf64525..9edd4bc84 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -1,3 +1,6 @@ web: bin/rails server -css: bin/rails tailwindcss:watch +# `always` keeps the watcher alive when stdin is not a TTY. Without it the CSS +# process exits the moment it starts under Docker Compose, and foreman takes the +# whole application down with it. +css: bin/rails tailwindcss:watch[always] jobs: bin/jobs diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 000000000..ad289c859 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,39 @@ +# One command to a running application: `docker compose up`. +# +# There is no database service — SQLite lives in the storage/ volume, and Solid Queue, +# Solid Cache and Solid Cable sit on their own SQLite files beside it. That is the point +# of the Rails 8 stack this test asks for: no Redis, no Postgres, no sidecar. +# +# The host port is 3200 rather than 3000 so this does not collide with a Rails server +# already running on the machine. Override with `WEB_PORT=4000 docker compose up`. +services: + web: + build: + context: . + dockerfile: Dockerfile.dev + command: bash -c "bin/rails db:prepare && bin/dev" + ports: + - "${WEB_PORT:-3200}:3000" + volumes: + - .:/rails + - bundle:/usr/local/bundle + - storage:/rails/storage + # tmp/ and log/ stay inside the container. Written through the bind mount they + # land on the host owned by root, which then blocks a local `bin/dev` from + # writing its own bootsnap cache. Use `docker compose logs` to read them. + - tmp:/rails/tmp + - log:/rails/log + environment: + RAILS_ENV: development + healthcheck: + test: [ "CMD", "curl", "-fsS", "http://localhost:3000/up" ] + interval: 10s + timeout: 5s + retries: 10 + start_period: 40s + +volumes: + bundle: + storage: + tmp: + log: From b323d1a65e0b0ffebe788d411f899b6b486885d4 Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 04:30:04 -0300 Subject: [PATCH 007/145] feat: add `rails generate authentication` output, unmodified Committed on its own so the next diff shows exactly what was customised. --- Gemfile | 2 +- Gemfile.lock | 3 + app/channels/application_cable/connection.rb | 16 +++++ app/controllers/application_controller.rb | 1 + app/controllers/concerns/authentication.rb | 52 ++++++++++++++ app/controllers/passwords_controller.rb | 35 ++++++++++ app/controllers/sessions_controller.rb | 21 ++++++ app/mailers/passwords_mailer.rb | 6 ++ app/models/current.rb | 4 ++ app/models/session.rb | 3 + app/models/user.rb | 6 ++ app/views/passwords/edit.html.erb | 21 ++++++ app/views/passwords/new.html.erb | 17 +++++ app/views/passwords_mailer/reset.html.erb | 6 ++ app/views/passwords_mailer/reset.text.erb | 4 ++ app/views/sessions/new.html.erb | 31 +++++++++ config/routes.rb | 2 + db/migrate/20260903072949_create_users.rb | 11 +++ db/migrate/20260903072950_create_sessions.rb | 11 +++ test/controllers/passwords_controller_test.rb | 67 +++++++++++++++++++ test/controllers/sessions_controller_test.rb | 33 +++++++++ test/fixtures/users.yml | 9 +++ .../previews/passwords_mailer_preview.rb | 7 ++ test/models/user_test.rb | 8 +++ test/test_helper.rb | 1 + test/test_helpers/session_test_helper.rb | 19 ++++++ 26 files changed, 395 insertions(+), 1 deletion(-) create mode 100644 app/channels/application_cable/connection.rb create mode 100644 app/controllers/concerns/authentication.rb create mode 100644 app/controllers/passwords_controller.rb create mode 100644 app/controllers/sessions_controller.rb create mode 100644 app/mailers/passwords_mailer.rb create mode 100644 app/models/current.rb create mode 100644 app/models/session.rb create mode 100644 app/models/user.rb create mode 100644 app/views/passwords/edit.html.erb create mode 100644 app/views/passwords/new.html.erb create mode 100644 app/views/passwords_mailer/reset.html.erb create mode 100644 app/views/passwords_mailer/reset.text.erb create mode 100644 app/views/sessions/new.html.erb create mode 100644 db/migrate/20260903072949_create_users.rb create mode 100644 db/migrate/20260903072950_create_sessions.rb create mode 100644 test/controllers/passwords_controller_test.rb create mode 100644 test/controllers/sessions_controller_test.rb create mode 100644 test/fixtures/users.yml create mode 100644 test/mailers/previews/passwords_mailer_preview.rb create mode 100644 test/models/user_test.rb create mode 100644 test/test_helpers/session_test_helper.rb diff --git a/Gemfile b/Gemfile index 6e09d99d4..4a889b116 100644 --- a/Gemfile +++ b/Gemfile @@ -18,7 +18,7 @@ gem "stimulus-rails" gem "tailwindcss-rails" # Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] -# gem "bcrypt", "~> 3.1.7" +gem "bcrypt", "~> 3.1.7" # Windows does not include zoneinfo files, so bundle the tzinfo-data gem gem "tzinfo-data", platforms: %i[ windows jruby ] diff --git a/Gemfile.lock b/Gemfile.lock index 91ef594a1..40b881041 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -79,6 +79,7 @@ GEM public_suffix (>= 2.0.2, < 8.0) ast (2.4.3) base64 (0.3.0) + bcrypt (3.1.22) bcrypt_pbkdf (1.1.2) bigdecimal (4.1.2) bindex (0.8.1) @@ -400,6 +401,7 @@ PLATFORMS x86_64-linux-musl DEPENDENCIES + bcrypt (~> 3.1.7) bootsnap brakeman bundler-audit @@ -446,6 +448,7 @@ CHECKSUMS addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + bcrypt (3.1.22) sha256=1f0072e88c2d705d94aff7f2c5cb02eb3f1ec4b8368671e19112527489f29032 bcrypt_pbkdf (1.1.2) sha256=c2414c23ce66869b3eb9f643d6a3374d8322dfb5078125c82792304c10b94cf6 bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb new file mode 100644 index 000000000..4264c745c --- /dev/null +++ b/app/channels/application_cable/connection.rb @@ -0,0 +1,16 @@ +module ApplicationCable + class Connection < ActionCable::Connection::Base + identified_by :current_user + + def connect + set_current_user || reject_unauthorized_connection + end + + private + def set_current_user + if session = Session.find_by(id: cookies.signed[:session_id]) + self.current_user = session.user + end + end + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index c3537563d..5f38f02f3 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,4 +1,5 @@ class ApplicationController < ActionController::Base + include Authentication # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. allow_browser versions: :modern diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb new file mode 100644 index 000000000..3538f485c --- /dev/null +++ b/app/controllers/concerns/authentication.rb @@ -0,0 +1,52 @@ +module Authentication + extend ActiveSupport::Concern + + included do + before_action :require_authentication + helper_method :authenticated? + end + + class_methods do + def allow_unauthenticated_access(**options) + skip_before_action :require_authentication, **options + end + end + + private + def authenticated? + resume_session + end + + def require_authentication + resume_session || request_authentication + end + + def resume_session + Current.session ||= find_session_by_cookie + end + + def find_session_by_cookie + Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id] + end + + def request_authentication + session[:return_to_after_authenticating] = request.url + redirect_to new_session_path + end + + def after_authentication_url + session.delete(:return_to_after_authenticating) || root_url + end + + def start_new_session_for(user) + user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session| + Current.session = session + cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax } + end + end + + def terminate_session + Current.session.destroy + cookies.delete(:session_id) + end +end diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb new file mode 100644 index 000000000..f95ec7874 --- /dev/null +++ b/app/controllers/passwords_controller.rb @@ -0,0 +1,35 @@ +class PasswordsController < ApplicationController + allow_unauthenticated_access + before_action :set_user_by_token, only: %i[ edit update ] + rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." } + + def new + end + + def create + if user = User.find_by(email_address: params[:email_address]) + PasswordsMailer.reset(user).deliver_later + end + + redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)." + end + + def edit + end + + def update + if @user.update(params.permit(:password, :password_confirmation)) + @user.sessions.destroy_all + redirect_to new_session_path, notice: "Password has been reset." + else + redirect_to edit_password_path(params[:token]), alert: "Passwords did not match." + end + end + + private + def set_user_by_token + @user = User.find_by_password_reset_token!(params[:token]) + rescue ActiveSupport::MessageVerifier::InvalidSignature + redirect_to new_password_path, alert: "Password reset link is invalid or has expired." + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb new file mode 100644 index 000000000..cf7fccd12 --- /dev/null +++ b/app/controllers/sessions_controller.rb @@ -0,0 +1,21 @@ +class SessionsController < ApplicationController + allow_unauthenticated_access only: %i[ new create ] + rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." } + + def new + end + + def create + if user = User.authenticate_by(params.permit(:email_address, :password)) + start_new_session_for user + redirect_to after_authentication_url + else + redirect_to new_session_path, alert: "Try another email address or password." + end + end + + def destroy + terminate_session + redirect_to new_session_path, status: :see_other + end +end diff --git a/app/mailers/passwords_mailer.rb b/app/mailers/passwords_mailer.rb new file mode 100644 index 000000000..4f0ac7fd9 --- /dev/null +++ b/app/mailers/passwords_mailer.rb @@ -0,0 +1,6 @@ +class PasswordsMailer < ApplicationMailer + def reset(user) + @user = user + mail subject: "Reset your password", to: user.email_address + end +end diff --git a/app/models/current.rb b/app/models/current.rb new file mode 100644 index 000000000..2bef56dad --- /dev/null +++ b/app/models/current.rb @@ -0,0 +1,4 @@ +class Current < ActiveSupport::CurrentAttributes + attribute :session + delegate :user, to: :session, allow_nil: true +end diff --git a/app/models/session.rb b/app/models/session.rb new file mode 100644 index 000000000..cf376fb28 --- /dev/null +++ b/app/models/session.rb @@ -0,0 +1,3 @@ +class Session < ApplicationRecord + belongs_to :user +end diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 000000000..c88d5b034 --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,6 @@ +class User < ApplicationRecord + has_secure_password + has_many :sessions, dependent: :destroy + + normalizes :email_address, with: ->(e) { e.strip.downcase } +end diff --git a/app/views/passwords/edit.html.erb b/app/views/passwords/edit.html.erb new file mode 100644 index 000000000..65798f808 --- /dev/null +++ b/app/views/passwords/edit.html.erb @@ -0,0 +1,21 @@ +
+ <% if alert = flash[:alert] %> +

<%= alert %>

+ <% end %> + +

Update your password

+ + <%= form_with url: password_path(params[:token]), method: :put, class: "contents" do |form| %> +
+ <%= form.password_field :password, required: true, autocomplete: "new-password", placeholder: "Enter new password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> +
+ +
+ <%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Repeat new password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> +
+ +
+ <%= form.submit "Save", class: "w-full sm:w-auto text-center rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white inline-block font-medium cursor-pointer" %> +
+ <% end %> +
diff --git a/app/views/passwords/new.html.erb b/app/views/passwords/new.html.erb new file mode 100644 index 000000000..8360e02f3 --- /dev/null +++ b/app/views/passwords/new.html.erb @@ -0,0 +1,17 @@ +
+ <% if alert = flash[:alert] %> +

<%= alert %>

+ <% end %> + +

Forgot your password?

+ + <%= form_with url: passwords_path, class: "contents" do |form| %> +
+ <%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address], class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> +
+ +
+ <%= form.submit "Email reset instructions", class: "w-full sm:w-auto text-center rounded-lg px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white inline-block font-medium cursor-pointer" %> +
+ <% end %> +
diff --git a/app/views/passwords_mailer/reset.html.erb b/app/views/passwords_mailer/reset.html.erb new file mode 100644 index 000000000..1b0915419 --- /dev/null +++ b/app/views/passwords_mailer/reset.html.erb @@ -0,0 +1,6 @@ +

+ You can reset your password on + <%= link_to "this password reset page", edit_password_url(@user.password_reset_token) %>. + + This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>. +

diff --git a/app/views/passwords_mailer/reset.text.erb b/app/views/passwords_mailer/reset.text.erb new file mode 100644 index 000000000..aecee82c4 --- /dev/null +++ b/app/views/passwords_mailer/reset.text.erb @@ -0,0 +1,4 @@ +You can reset your password on +<%= edit_password_url(@user.password_reset_token) %> + +This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>. diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb new file mode 100644 index 000000000..308b04b37 --- /dev/null +++ b/app/views/sessions/new.html.erb @@ -0,0 +1,31 @@ +
+ <% if alert = flash[:alert] %> +

<%= alert %>

+ <% end %> + + <% if notice = flash[:notice] %> +

<%= notice %>

+ <% end %> + +

Sign in

+ + <%= form_with url: session_url, class: "contents" do |form| %> +
+ <%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address], class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> +
+ +
+ <%= form.password_field :password, required: true, autocomplete: "current-password", placeholder: "Enter your password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> +
+ +
+
+ <%= form.submit "Sign in", class: "w-full sm:w-auto text-center rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white inline-block font-medium cursor-pointer" %> +
+ +
+ <%= link_to "Forgot password?", new_password_path, class: "text-gray-700 underline hover:no-underline" %> +
+
+ <% end %> +
diff --git a/config/routes.rb b/config/routes.rb index 48254e88e..29b007b33 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,6 @@ Rails.application.routes.draw do + resource :session + resources :passwords, param: :token # 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. diff --git a/db/migrate/20260903072949_create_users.rb b/db/migrate/20260903072949_create_users.rb new file mode 100644 index 000000000..71f2ff188 --- /dev/null +++ b/db/migrate/20260903072949_create_users.rb @@ -0,0 +1,11 @@ +class CreateUsers < ActiveRecord::Migration[8.1] + def change + create_table :users do |t| + t.string :email_address, null: false + t.string :password_digest, null: false + + t.timestamps + end + add_index :users, :email_address, unique: true + end +end diff --git a/db/migrate/20260903072950_create_sessions.rb b/db/migrate/20260903072950_create_sessions.rb new file mode 100644 index 000000000..ec9efdbaa --- /dev/null +++ b/db/migrate/20260903072950_create_sessions.rb @@ -0,0 +1,11 @@ +class CreateSessions < ActiveRecord::Migration[8.1] + def change + create_table :sessions do |t| + t.references :user, null: false, foreign_key: true + t.string :ip_address + t.string :user_agent + + t.timestamps + end + end +end diff --git a/test/controllers/passwords_controller_test.rb b/test/controllers/passwords_controller_test.rb new file mode 100644 index 000000000..e1a1b03dc --- /dev/null +++ b/test/controllers/passwords_controller_test.rb @@ -0,0 +1,67 @@ +require "test_helper" + +class PasswordsControllerTest < ActionDispatch::IntegrationTest + setup { @user = User.take } + + test "new" do + get new_password_path + assert_response :success + end + + test "create" do + post passwords_path, params: { email_address: @user.email_address } + assert_enqueued_email_with PasswordsMailer, :reset, args: [ @user ] + assert_redirected_to new_session_path + + follow_redirect! + assert_notice "reset instructions sent" + end + + test "create for an unknown user redirects but sends no mail" do + post passwords_path, params: { email_address: "missing-user@example.com" } + assert_enqueued_emails 0 + assert_redirected_to new_session_path + + follow_redirect! + assert_notice "reset instructions sent" + end + + test "edit" do + get edit_password_path(@user.password_reset_token) + assert_response :success + end + + test "edit with invalid password reset token" do + get edit_password_path("invalid token") + assert_redirected_to new_password_path + + follow_redirect! + assert_notice "reset link is invalid" + end + + test "update" do + assert_changes -> { @user.reload.password_digest } do + put password_path(@user.password_reset_token), params: { password: "new", password_confirmation: "new" } + assert_redirected_to new_session_path + end + + follow_redirect! + assert_notice "Password has been reset" + end + + test "update with non matching passwords" do + token = @user.password_reset_token + assert_no_changes -> { @user.reload.password_digest } do + put password_path(token), params: { password: "no", password_confirmation: "match" } + assert_redirected_to edit_password_path(token) + end + + follow_redirect! + assert_notice "Passwords did not match" + end + + private + def assert_notice(text) + assert_select "div", /#{text}/ + end +end diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb new file mode 100644 index 000000000..07d72ef78 --- /dev/null +++ b/test/controllers/sessions_controller_test.rb @@ -0,0 +1,33 @@ +require "test_helper" + +class SessionsControllerTest < ActionDispatch::IntegrationTest + setup { @user = User.take } + + test "new" do + get new_session_path + assert_response :success + end + + test "create with valid credentials" do + post session_path, params: { email_address: @user.email_address, password: "password" } + + assert_redirected_to root_path + assert cookies[:session_id] + end + + test "create with invalid credentials" do + post session_path, params: { email_address: @user.email_address, password: "wrong" } + + assert_redirected_to new_session_path + assert_nil cookies[:session_id] + end + + test "destroy" do + sign_in_as(User.take) + + delete session_path + + assert_redirected_to new_session_path + assert_empty cookies[:session_id] + end +end diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml new file mode 100644 index 000000000..095156329 --- /dev/null +++ b/test/fixtures/users.yml @@ -0,0 +1,9 @@ +<% password_digest = BCrypt::Password.create("password") %> + +one: + email_address: one@example.com + password_digest: <%= password_digest %> + +two: + email_address: two@example.com + password_digest: <%= password_digest %> diff --git a/test/mailers/previews/passwords_mailer_preview.rb b/test/mailers/previews/passwords_mailer_preview.rb new file mode 100644 index 000000000..01d07ecf8 --- /dev/null +++ b/test/mailers/previews/passwords_mailer_preview.rb @@ -0,0 +1,7 @@ +# Preview all emails at http://localhost:3000/rails/mailers/passwords_mailer +class PasswordsMailerPreview < ActionMailer::Preview + # Preview this email at http://localhost:3000/rails/mailers/passwords_mailer/reset + def reset + PasswordsMailer.reset(User.take) + end +end diff --git a/test/models/user_test.rb b/test/models/user_test.rb new file mode 100644 index 000000000..83445c476 --- /dev/null +++ b/test/models/user_test.rb @@ -0,0 +1,8 @@ +require "test_helper" + +class UserTest < ActiveSupport::TestCase + test "downcases and strips email_address" do + user = User.new(email_address: " DOWNCASED@EXAMPLE.COM ") + assert_equal("downcased@example.com", user.email_address) + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 2f63363e7..ed5c42fb4 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -4,6 +4,7 @@ require_relative "../config/environment" require "rails/test_help" +require_relative "test_helpers/session_test_helper" module ActiveSupport class TestCase diff --git a/test/test_helpers/session_test_helper.rb b/test/test_helpers/session_test_helper.rb new file mode 100644 index 000000000..0686378cf --- /dev/null +++ b/test/test_helpers/session_test_helper.rb @@ -0,0 +1,19 @@ +module SessionTestHelper + def sign_in_as(user) + Current.session = user.sessions.create! + + ActionDispatch::TestRequest.create.cookie_jar.tap do |cookie_jar| + cookie_jar.signed[:session_id] = Current.session.id + cookies["session_id"] = cookie_jar[:session_id] + end + end + + def sign_out + Current.session&.destroy! + cookies.delete("session_id") + end +end + +ActiveSupport.on_load(:action_dispatch_integration_test) do + include SessionTestHelper +end From 1f056362aa7845b7d3d07b84f41f9be8586b590a Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 04:43:30 -0300 Subject: [PATCH 008/145] feat: add full_name, role and an encrypted email to users Email is encrypted deterministically so the unique index and authenticate_by still work; the cost is that LIKE on email is impossible. Dev and test keys are in the environment files and are not secrets. Role has a CHECK constraint. --- app/channels/application_cable/connection.rb | 4 +- app/mailers/passwords_mailer.rb | 2 +- app/models/user.rb | 13 +- config/environments/development.rb | 7 + config/environments/test.rb | 11 ++ db/cable_schema.rb | 18 +- db/cache_schema.rb | 20 +- db/migrate/20260903072949_create_users.rb | 14 +- db/queue_schema.rb | 194 ++++++++++--------- db/schema.rb | 35 ++++ db/seeds.rb | 37 +++- test/fixtures/users.yml | 14 +- 12 files changed, 250 insertions(+), 119 deletions(-) create mode 100644 db/schema.rb diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb index 4264c745c..c60a0c9a4 100644 --- a/app/channels/application_cable/connection.rb +++ b/app/channels/application_cable/connection.rb @@ -8,9 +8,7 @@ def connect private def set_current_user - if session = Session.find_by(id: cookies.signed[:session_id]) - self.current_user = session.user - end + self.current_user = Session.find_by(id: cookies.signed[:session_id])&.user end end end diff --git a/app/mailers/passwords_mailer.rb b/app/mailers/passwords_mailer.rb index 4f0ac7fd9..06ac4a4da 100644 --- a/app/mailers/passwords_mailer.rb +++ b/app/mailers/passwords_mailer.rb @@ -1,6 +1,6 @@ class PasswordsMailer < ApplicationMailer def reset(user) @user = user - mail subject: "Reset your password", to: user.email_address + mail subject: "Reset your password", to: user.email end end diff --git a/app/models/user.rb b/app/models/user.rb index c88d5b034..e06723214 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -2,5 +2,16 @@ class User < ApplicationRecord has_secure_password has_many :sessions, dependent: :destroy - normalizes :email_address, with: ->(e) { e.strip.downcase } + # Deterministic so the column stays queryable and uniquely indexable. The cost is + # that partial matching is gone: no LIKE on email, ever. The admin list searches + # full_name, which is deliberately left in plaintext for that reason. + encrypts :email, deterministic: true + + enum :role, { user: "user", admin: "admin" }, default: :user, validate: true + + normalizes :email, with: ->(email) { email.strip.downcase } + + validates :full_name, presence: true, length: { maximum: 120 } + validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP } + validates :password, length: { minimum: 8 }, allow_nil: true end diff --git a/config/environments/development.rb b/config/environments/development.rb index 6c416cdbe..775790a27 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -79,4 +79,11 @@ # Apply autocorrection by RuboCop to files generated by `bin/rails generate`. # config.generators.apply_rubocop_autocorrect_after_generate! + + # Non-secret keys, committed on purpose: Active Record encryption has to be + # configured for the application to boot, and a reviewer cloning this repository + # has no master.key. Production reads the real keys from encrypted credentials. + config.active_record.encryption.primary_key = "s1jnxp0gDiOTkHHvrvYJ5018UMuFpZY1" + config.active_record.encryption.deterministic_key = "tjmrQSRmWUw4nIJTuYT0eWNm85y2bn6F" + config.active_record.encryption.key_derivation_salt = "3qUI2aAmXJDZ0NhO0EYxvfJRmf0llZg9" end diff --git a/config/environments/test.rb b/config/environments/test.rb index 41f8bc328..b6b22fa65 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -53,4 +53,15 @@ # Raise error when a before_action's only/except options reference missing actions. config.action_controller.raise_on_missing_callback_actions = true + + # Non-secret keys, committed on purpose: Active Record encryption has to be + # configured for the application to boot, and a reviewer cloning this repository + # has no master.key. Production reads the real keys from encrypted credentials. + config.active_record.encryption.primary_key = "s1jnxp0gDiOTkHHvrvYJ5018UMuFpZY1" + config.active_record.encryption.deterministic_key = "tjmrQSRmWUw4nIJTuYT0eWNm85y2bn6F" + config.active_record.encryption.key_derivation_salt = "3qUI2aAmXJDZ0NhO0EYxvfJRmf0llZg9" + + # Fixtures are written to the database directly, bypassing the model. Without this + # the encrypted columns would hold plaintext and every read would fail to decrypt. + config.active_record.encryption.encrypt_fixtures = true end diff --git a/db/cable_schema.rb b/db/cable_schema.rb index 23666604a..3aefc381e 100644 --- a/db/cable_schema.rb +++ b/db/cable_schema.rb @@ -1,9 +1,21 @@ -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 create_table "solid_cable_messages", force: :cascade do |t| t.binary "channel", limit: 1024, null: false - t.binary "payload", limit: 536870912, null: false - t.datetime "created_at", null: false t.integer "channel_hash", limit: 8, null: false + t.datetime "created_at", null: false + t.binary "payload", limit: 536870912, 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 81a410d18..2016467a1 100644 --- a/db/cache_schema.rb +++ b/db/cache_schema.rb @@ -1,10 +1,22 @@ -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 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", limit: 4, null: false t.datetime "created_at", null: false + t.binary "key", limit: 1024, null: false t.integer "key_hash", limit: 8, null: false - t.integer "byte_size", limit: 4, null: false + t.binary "value", limit: 536870912, 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/20260903072949_create_users.rb b/db/migrate/20260903072949_create_users.rb index 71f2ff188..a3a082dfa 100644 --- a/db/migrate/20260903072949_create_users.rb +++ b/db/migrate/20260903072949_create_users.rb @@ -1,11 +1,21 @@ class CreateUsers < ActiveRecord::Migration[8.1] def change create_table :users do |t| - t.string :email_address, null: false + t.string :full_name, null: false + t.string :email, null: false t.string :password_digest, null: false + t.string :role, null: false, default: "user" t.timestamps end - add_index :users, :email_address, unique: true + + # The email column stores ciphertext (deterministic encryption), so the unique + # index compares ciphertext. That works precisely because the encryption is + # deterministic; a randomised scheme would silently allow duplicates. + add_index :users, :email, unique: true + + # The enum guards this in Ruby. The constraint guards it against console + # sessions, data migrations and anything else that bypasses the model. + add_check_constraint :users, "role IN ('user', 'admin')", name: "users_role_check" end end diff --git a/db/queue_schema.rb b/db/queue_schema.rb index f9a71dabb..a4cda0523 100644 --- a/db/queue_schema.rb +++ b/db/queue_schema.rb @@ -1,152 +1,164 @@ -ActiveRecord::Schema[7.1].define(version: 1) do - create_table "solid_queue_blocked_executions", force: :cascade do |t| +# 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 + create_table "solid_queue_batch_executions", force: :cascade do |t| + t.bigint "batch_id", null: false + 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.index ["batch_id"], name: "index_solid_queue_batch_executions_on_batch_id" + t.index ["job_id"], name: "index_solid_queue_batch_executions_on_job_id", unique: true + end + + create_table "solid_queue_batches", force: :cascade do |t| + t.string "active_job_batch_id" + t.integer "completed_jobs", default: 0, null: false + t.datetime "created_at", null: false + t.string "description" + t.datetime "enqueued_at" + t.datetime "failed_at" + t.integer "failed_jobs", default: 0, null: false + t.datetime "finished_at" + t.text "metadata" + t.text "on_failure" + t.text "on_finish" + t.text "on_success" + t.integer "total_jobs", default: 0, null: false + t.datetime "updated_at", null: false + t.index ["active_job_batch_id"], name: "index_solid_queue_batches_on_active_job_batch_id", unique: true + t.index ["finished_at"], name: "index_solid_queue_batches_on_finished_at" + end + + create_table "solid_queue_blocked_executions", force: :cascade do |t| 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.bigint "batch_id" + 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.bigint "batch_id" - t.index [ "active_job_id" ], name: "index_solid_queue_jobs_on_active_job_id" - t.index [ "batch_id" ], name: "index_solid_queue_jobs_on_batch_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 ["batch_id"], name: "index_solid_queue_jobs_on_batch_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 "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 - end - - create_table "solid_queue_batches", force: :cascade do |t| - t.string "active_job_batch_id" - t.string "description" - t.text "on_finish" - t.text "on_success" - t.text "on_failure" - t.text "metadata" - t.integer "total_jobs", default: 0, null: false - t.integer "completed_jobs", default: 0, null: false - t.integer "failed_jobs", default: 0, null: false - t.datetime "enqueued_at" - t.datetime "finished_at" - t.datetime "failed_at" 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 [ "active_job_batch_id" ], name: "index_solid_queue_batches_on_active_job_batch_id", unique: true - t.index [ "finished_at" ], name: "index_solid_queue_batches_on_finished_at" - end - - create_table "solid_queue_batch_executions", force: :cascade do |t| - t.bigint "job_id", null: false - t.bigint "batch_id", null: false - t.datetime "created_at", null: false - t.index [ "job_id" ], name: "index_solid_queue_batch_executions_on_job_id", unique: true - t.index [ "batch_id" ], name: "index_solid_queue_batch_executions_on_batch_id" + 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_batch_executions", "solid_queue_batches", column: "batch_id", on_delete: :cascade diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 000000000..4be9303cb --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,35 @@ +# 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_09_03_072950) do + create_table "sessions", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "ip_address" + t.datetime "updated_at", null: false + t.string "user_agent" + t.integer "user_id", null: false + t.index ["user_id"], name: "index_sessions_on_user_id" + end + + create_table "users", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "email", null: false + t.string "full_name", null: false + t.string "password_digest", null: false + t.string "role", default: "user", null: false + t.datetime "updated_at", null: false + t.index ["email"], name: "index_users_on_email", unique: true + t.check_constraint "role IN ('user', 'admin')", name: "users_role_check" + end + + add_foreign_key "sessions", "users" +end diff --git a/db/seeds.rb b/db/seeds.rb index 4fbd6ed97..679c23136 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,9 +1,28 @@ -# 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 +# Idempotent: running it twice leaves the same database. Passwords are fixed on +# purpose — these are demo credentials for a reviewer, documented in the README. +DEMO_USER_COUNT = 32 +PASSWORD = "secret-password".freeze + +[ + { full_name: "Grace Hopper", email: "admin@umanni.test", role: :admin }, + { full_name: "Ada Lovelace", email: "user@umanni.test", role: :user } +].each do |attributes| + User.find_or_create_by!(email: attributes[:email]) do |user| + user.assign_attributes(attributes.merge(password: PASSWORD, password_confirmation: PASSWORD)) + end +end + +# Enough rows for sorting, filtering and the dashboard counters to be worth looking +# at. Driven by the total rather than by a fixed loop count, so a second run is a +# no-op instead of another thirty random people. +while User.count < DEMO_USER_COUNT + User.create!( + full_name: Faker::Name.name, + email: Faker::Internet.unique.email(domain: "umanni.test"), + role: Faker::Boolean.boolean(true_ratio: 0.2) ? :admin : :user, + password: PASSWORD, + password_confirmation: PASSWORD + ) +end + +puts "Seeded #{User.count} users (#{User.admin.count} admins). Password for all: #{PASSWORD}" diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index 095156329..d67738eaf 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -1,9 +1,13 @@ -<% password_digest = BCrypt::Password.create("password") %> +<% password_digest = BCrypt::Password.create("secret-password") %> -one: - email_address: one@example.com +admin: + full_name: Grace Hopper + email: grace@umanni.test password_digest: <%= password_digest %> + role: admin -two: - email_address: two@example.com +member: + full_name: Ada Lovelace + email: ada@umanni.test password_digest: <%= password_digest %> + role: user From 24ac863c9dc88ab4e5df2eb58b7b05f2021ed5bb Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 04:43:34 -0300 Subject: [PATCH 009/145] feat: extract a Tailwind component layer for forms and buttons --- app/assets/tailwind/application.css | 78 ++++++++++++++++++++++++++ app/views/layouts/application.html.erb | 13 ++--- app/views/passwords/edit.html.erb | 35 ++++++------ app/views/passwords/new.html.erb | 31 +++++----- app/views/sessions/new.html.erb | 46 ++++++++------- app/views/shared/_flash.html.erb | 14 +++++ app/views/shared/_form_errors.html.erb | 12 ++++ app/views/shared/_navbar.html.erb | 20 +++++++ 8 files changed, 189 insertions(+), 60 deletions(-) create mode 100644 app/views/shared/_flash.html.erb create mode 100644 app/views/shared/_form_errors.html.erb create mode 100644 app/views/shared/_navbar.html.erb diff --git a/app/assets/tailwind/application.css b/app/assets/tailwind/application.css index f1d8c73cd..fddb3d6c1 100644 --- a/app/assets/tailwind/application.css +++ b/app/assets/tailwind/application.css @@ -1 +1,79 @@ @import "tailwindcss"; + +/* A deliberately small component layer. Every screen in this application is a form, + a table or a card, and repeating the same twelve utility classes on each input is + how a Tailwind codebase becomes unreadable. Anything used once stays inline. */ + +@theme { + --color-brand-50: oklch(0.97 0.014 254.6); + --color-brand-100: oklch(0.93 0.032 255.6); + --color-brand-500: oklch(0.62 0.19 259.8); + --color-brand-600: oklch(0.55 0.21 262.9); + --color-brand-700: oklch(0.49 0.19 264.1); +} + +@layer components { + .card { + @apply bg-white rounded-xl border border-slate-200 shadow-sm; + } + + .field-label { + @apply block text-sm font-medium text-slate-700 mb-1.5; + } + + .field-input { + @apply block w-full rounded-lg border border-slate-300 px-3 py-2 text-slate-900 + placeholder:text-slate-400 shadow-xs + focus:border-brand-500 focus:ring-2 focus:ring-brand-100 focus:outline-none + disabled:bg-slate-50 disabled:text-slate-500; + } + + /* Paired with the invalid: variant so the browser's own validity state drives the + styling — no JavaScript needed for the first layer of form feedback. */ + .field-input:user-invalid { + @apply border-red-400 focus:border-red-500 focus:ring-red-100; + } + + .field-hint { + @apply mt-1.5 text-sm text-slate-500; + } + + .field-error { + @apply mt-1.5 text-sm text-red-600; + } + + /* Tailwind v4 will not @apply one component class inside another, so the shared + base is expressed as a selector list rather than by composition. */ + .btn, .btn-primary, .btn-secondary, .btn-danger { + @apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 + text-sm font-medium transition-colors cursor-pointer + focus-visible:outline-2 focus-visible:outline-offset-2 + disabled:opacity-50 disabled:cursor-not-allowed; + } + + .btn-primary { + @apply bg-brand-600 text-white hover:bg-brand-700 focus-visible:outline-brand-600; + } + + .btn-secondary { + @apply bg-white text-slate-700 border border-slate-300 hover:bg-slate-50 + focus-visible:outline-slate-400; + } + + .btn-danger { + @apply bg-white text-red-700 border border-red-300 hover:bg-red-50 + focus-visible:outline-red-500; + } + + .badge, .badge-admin, .badge-user { + @apply inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium; + } + + .badge-admin { + @apply bg-brand-50 text-brand-700 ring-1 ring-inset ring-brand-100; + } + + .badge-user { + @apply bg-slate-100 text-slate-700 ring-1 ring-inset ring-slate-200; + } +} diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 7db2cd872..b6ba7fd70 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -1,5 +1,5 @@ - + <%= content_for(:title) || "Umanni" %> @@ -11,20 +11,19 @@ <%= yield :head %> - <%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %> - <%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %> - - <%# Includes all stylesheet files in app/assets/stylesheets %> <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> <%= javascript_importmap_tags %> - -
+ + <%= render "shared/navbar" if authenticated? %> + +
+ <%= render "shared/flash" %> <%= yield %>
diff --git a/app/views/passwords/edit.html.erb b/app/views/passwords/edit.html.erb index 65798f808..b294c5282 100644 --- a/app/views/passwords/edit.html.erb +++ b/app/views/passwords/edit.html.erb @@ -1,21 +1,24 @@ -
- <% if alert = flash[:alert] %> -

<%= alert %>

- <% end %> +<% content_for :title, "Choose a new password" %> -

Update your password

+
+
+

Choose a new password

- <%= form_with url: password_path(params[:token]), method: :put, class: "contents" do |form| %> -
- <%= form.password_field :password, required: true, autocomplete: "new-password", placeholder: "Enter new password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> -
+ <%= form_with url: password_path(params[:token]), method: :put, class: "mt-6 space-y-5" do |form| %> +
+ <%= form.label :password, "New password", class: "field-label" %> + <%= form.password_field :password, required: true, autofocus: true, autocomplete: "new-password", + minlength: 8, maxlength: 72, placeholder: "••••••••", class: "field-input" %> +

At least 8 characters.

+
-
- <%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Repeat new password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> -
+
+ <%= form.label :password_confirmation, "Confirm password", class: "field-label" %> + <%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", + minlength: 8, maxlength: 72, placeholder: "••••••••", class: "field-input" %> +
-
- <%= form.submit "Save", class: "w-full sm:w-auto text-center rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white inline-block font-medium cursor-pointer" %> -
- <% end %> + <%= form.submit "Save password", class: "btn-primary w-full" %> + <% end %> +
diff --git a/app/views/passwords/new.html.erb b/app/views/passwords/new.html.erb index 8360e02f3..143410cb6 100644 --- a/app/views/passwords/new.html.erb +++ b/app/views/passwords/new.html.erb @@ -1,17 +1,22 @@ -
- <% if alert = flash[:alert] %> -

<%= alert %>

- <% end %> +<% content_for :title, "Reset your password" %> -

Forgot your password?

+
+
+

Reset your password

+

We will email you a link to choose a new one.

- <%= form_with url: passwords_path, class: "contents" do |form| %> -
- <%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address], class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> -
+ <%= form_with url: passwords_path, class: "mt-6 space-y-5" do |form| %> +
+ <%= form.label :email, class: "field-label" %> + <%= form.email_field :email, required: true, autofocus: true, autocomplete: "username", + placeholder: "you@company.com", class: "field-input" %> +
-
- <%= form.submit "Email reset instructions", class: "w-full sm:w-auto text-center rounded-lg px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white inline-block font-medium cursor-pointer" %> -
- <% end %> + <%= form.submit "Email reset instructions", class: "btn-primary w-full" %> + <% end %> + +

+ <%= link_to "Back to sign in", new_session_path, class: "text-slate-500 hover:text-slate-700" %> +

+
diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index 308b04b37..833905971 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -1,31 +1,29 @@ -
- <% if alert = flash[:alert] %> -

<%= alert %>

- <% end %> +<% content_for :title, "Sign in" %> - <% if notice = flash[:notice] %> -

<%= notice %>

- <% end %> +
+
+

Sign in

+

Welcome back.

-

Sign in

- - <%= form_with url: session_url, class: "contents" do |form| %> -
- <%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address], class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> -
- -
- <%= form.password_field :password, required: true, autocomplete: "current-password", placeholder: "Enter your password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> -
- -
-
- <%= form.submit "Sign in", class: "w-full sm:w-auto text-center rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white inline-block font-medium cursor-pointer" %> + <%= form_with url: session_path, scope: :session, class: "mt-6 space-y-5" do |form| %> +
+ <%= form.label :email, class: "field-label" %> + <%= form.email_field :email, required: true, autofocus: true, autocomplete: "username", + value: params.dig(:session, :email), placeholder: "you@company.com", class: "field-input" %>
-
- <%= link_to "Forgot password?", new_password_path, class: "text-gray-700 underline hover:no-underline" %> +
+ <%= form.label :password, class: "field-label" %> + <%= form.password_field :password, required: true, autocomplete: "current-password", + maxlength: 72, placeholder: "••••••••", class: "field-input" %>
+ + <%= form.submit "Sign in", class: "btn-primary w-full" %> + <% end %> + +
+ <%= link_to "Create an account", new_registration_path, class: "font-medium text-brand-600 hover:text-brand-700" %> + <%= link_to "Forgot password?", new_password_path, class: "text-slate-500 hover:text-slate-700" %>
- <% end %> +
diff --git a/app/views/shared/_flash.html.erb b/app/views/shared/_flash.html.erb new file mode 100644 index 000000000..46207f9aa --- /dev/null +++ b/app/views/shared/_flash.html.erb @@ -0,0 +1,14 @@ +<% if flash.any? %> +
+ <% flash.each do |type, message| %> + <%= tag.p message, + id: type, + role: (type == "alert" ? "alert" : "status"), + class: class_names( + "rounded-lg px-4 py-3 text-sm font-medium ring-1 ring-inset", + "bg-red-50 text-red-800 ring-red-200" => type == "alert", + "bg-emerald-50 text-emerald-800 ring-emerald-200" => type != "alert" + ) %> + <% end %> +
+<% end %> diff --git a/app/views/shared/_form_errors.html.erb b/app/views/shared/_form_errors.html.erb new file mode 100644 index 000000000..0e6530303 --- /dev/null +++ b/app/views/shared/_form_errors.html.erb @@ -0,0 +1,12 @@ +<% if model.errors.any? %> + +<% end %> diff --git a/app/views/shared/_navbar.html.erb b/app/views/shared/_navbar.html.erb new file mode 100644 index 000000000..77012d119 --- /dev/null +++ b/app/views/shared/_navbar.html.erb @@ -0,0 +1,20 @@ +
+ +
From 36e9e0873641ab1ea29141474d47e5d0bd958f50 Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 04:43:45 -0300 Subject: [PATCH 010/145] feat: gate /admin and route each role to its landing page Authorisation is in Admin::BaseController so every admin controller inherits it. --- app/controllers/admin/base_controller.rb | 12 ++++++++++ .../admin/dashboards_controller.rb | 6 +++++ app/controllers/application_controller.rb | 17 ++++++++++++-- app/controllers/home_controller.rb | 8 +++++++ app/controllers/profiles_controller.rb | 5 ++++ app/views/admin/dashboards/show.html.erb | 4 ++++ app/views/profiles/show.html.erb | 20 ++++++++++++++++ config/routes.rb | 23 ++++++++++--------- 8 files changed, 82 insertions(+), 13 deletions(-) create mode 100644 app/controllers/admin/base_controller.rb create mode 100644 app/controllers/admin/dashboards_controller.rb create mode 100644 app/controllers/home_controller.rb create mode 100644 app/controllers/profiles_controller.rb create mode 100644 app/views/admin/dashboards/show.html.erb create mode 100644 app/views/profiles/show.html.erb diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb new file mode 100644 index 000000000..f690528d0 --- /dev/null +++ b/app/controllers/admin/base_controller.rb @@ -0,0 +1,12 @@ +module Admin + # Every admin screen inherits from here, so authorisation is a property of the + # namespace rather than something each controller has to remember to declare. + class BaseController < ApplicationController + before_action :require_admin + + private + def require_admin + redirect_to profile_url, alert: "You are not authorised to access that page." unless Current.user.admin? + end + end +end diff --git a/app/controllers/admin/dashboards_controller.rb b/app/controllers/admin/dashboards_controller.rb new file mode 100644 index 000000000..490082dcc --- /dev/null +++ b/app/controllers/admin/dashboards_controller.rb @@ -0,0 +1,6 @@ +module Admin + class DashboardsController < BaseController + def show + end + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 5f38f02f3..16962a005 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,8 +1,21 @@ class ApplicationController < ActionController::Base include Authentication - # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. + + # Rejects browsers without webp, import maps, CSS nesting and CSS :has. That covers + # every current Chrome, Safari, Firefox and Edge; it excludes IE and long-abandoned + # builds. Turbo and Stimulus degrade gracefully within that range, so no polyfills. allow_browser versions: :modern - # Changes to the importmap will invalidate the etag for HTML responses stale_when_importmap_changes + + private + # Honours a deep link the visitor was bounced away from, and otherwise sends each + # role to the screen the brief specifies as their landing page. + def after_authentication_url + session.delete(:return_to_after_authenticating) || home_url_for(Current.user) + end + + def home_url_for(user) + user.admin? ? admin_dashboard_url : profile_url + end end diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb new file mode 100644 index 000000000..37047070f --- /dev/null +++ b/app/controllers/home_controller.rb @@ -0,0 +1,8 @@ +# The root path has no screen of its own: admins belong on the dashboard, everyone +# else on their profile. Keeping that decision here means a bookmarked "/" behaves +# the same as a fresh login. +class HomeController < ApplicationController + def show + redirect_to home_url_for(Current.user) + end +end diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb new file mode 100644 index 000000000..fb7e8f4dc --- /dev/null +++ b/app/controllers/profiles_controller.rb @@ -0,0 +1,5 @@ +class ProfilesController < ApplicationController + def show + @user = Current.user + end +end diff --git a/app/views/admin/dashboards/show.html.erb b/app/views/admin/dashboards/show.html.erb new file mode 100644 index 000000000..736aadaca --- /dev/null +++ b/app/views/admin/dashboards/show.html.erb @@ -0,0 +1,4 @@ +<% content_for :title, "Dashboard" %> + +

Dashboard

+

User metrics, updated live.

diff --git a/app/views/profiles/show.html.erb b/app/views/profiles/show.html.erb new file mode 100644 index 000000000..215f0d81d --- /dev/null +++ b/app/views/profiles/show.html.erb @@ -0,0 +1,20 @@ +<% content_for :title, "Your profile" %> + +
+

Your profile

+ +
+
+
Full name
+
<%= @user.full_name %>
+
+
+
Email
+
<%= @user.email %>
+
+
+
Role
+
"><%= @user.role.capitalize %>
+
+
+
diff --git a/config/routes.rb b/config/routes.rb index 29b007b33..4fafebe34 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,16 +1,17 @@ Rails.application.routes.draw do - resource :session - resources :passwords, param: :token - # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html + resource :session, only: %i[ new create destroy ] + resource :registration, only: %i[ new create ] + resources :passwords, param: :token, only: %i[ new create edit update ] - # 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. - get "up" => "rails/health#show", as: :rails_health_check + resource :profile, only: %i[ show edit update destroy ] + + namespace :admin do + resource :dashboard, only: :show + end - # 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 + # Returns 200 once the application boots cleanly. Used by the Compose healthcheck + # and by Kamal to decide when a container is ready to take traffic. + get "up" => "rails/health#show", as: :rails_health_check - # Defines the root path route ("/") - # root "posts#index" + root "home#show" end From 506ae2fabac67ffc62b9b4b8548daaeac4d776fa Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 04:43:45 -0300 Subject: [PATCH 011/145] refactor: use params.expect for the login form A malformed request is now a 400, not a wrong password. --- app/controllers/sessions_controller.rb | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index cf7fccd12..35ec9b0de 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,16 +1,18 @@ class SessionsController < ApplicationController allow_unauthenticated_access only: %i[ new create ] - rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." } + rate_limit to: 10, within: 3.minutes, only: :create, + with: -> { redirect_to new_session_path, alert: "Too many attempts. Try again later." } def new end def create - if user = User.authenticate_by(params.permit(:email_address, :password)) + if (user = User.authenticate_by(credentials)) start_new_session_for user redirect_to after_authentication_url else - redirect_to new_session_path, alert: "Try another email address or password." + flash.now[:alert] = "Try another email address or password." + render :new, status: :unprocessable_content end end @@ -18,4 +20,11 @@ def destroy terminate_session redirect_to new_session_path, status: :see_other end + + private + # expect, not permit: a login attempt has exactly one shape, and anything else is + # a malformed request rather than a wrong password. + def credentials + params.expect(session: [ :email, :password ]) + end end From e4b57bbdaf51fa166d1288a7f6e857e6a6aca71c Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 04:43:45 -0300 Subject: [PATCH 012/145] feat: let visitors register as plain users :role is not in the permitted list, and a test posts one. --- app/controllers/registrations_controller.rb | 25 ++++++++++++ app/views/registrations/new.html.erb | 44 +++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 app/controllers/registrations_controller.rb create mode 100644 app/views/registrations/new.html.erb diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb new file mode 100644 index 000000000..64e9e3e57 --- /dev/null +++ b/app/controllers/registrations_controller.rb @@ -0,0 +1,25 @@ +class RegistrationsController < ApplicationController + allow_unauthenticated_access + + def new + @user = User.new + end + + def create + @user = User.new(registration_params) + + if @user.save + start_new_session_for @user + redirect_to after_authentication_url, notice: "Welcome to Umanni." + else + render :new, status: :unprocessable_content + end + end + + private + # :role is absent by design. Self-registration always produces a plain user, and + # a crafted role parameter has to be ignored rather than merely unused. + def registration_params + params.expect(user: [ :full_name, :email, :password, :password_confirmation ]) + end +end diff --git a/app/views/registrations/new.html.erb b/app/views/registrations/new.html.erb new file mode 100644 index 000000000..d052a3afd --- /dev/null +++ b/app/views/registrations/new.html.erb @@ -0,0 +1,44 @@ +<% content_for :title, "Create your account" %> + +
+
+

Create your account

+

Takes less than a minute.

+ + <%= render "shared/form_errors", model: @user %> + + <%= form_with model: @user, url: registration_path, class: "mt-6 space-y-5" do |form| %> +
+ <%= form.label :full_name, "Full name", class: "field-label" %> + <%= form.text_field :full_name, required: true, autofocus: true, autocomplete: "name", + maxlength: 120, placeholder: "Ada Lovelace", class: "field-input" %> +
+ +
+ <%= form.label :email, class: "field-label" %> + <%= form.email_field :email, required: true, autocomplete: "email", + placeholder: "you@company.com", class: "field-input" %> +
+ +
+ <%= form.label :password, class: "field-label" %> + <%= form.password_field :password, required: true, autocomplete: "new-password", + minlength: 8, maxlength: 72, placeholder: "••••••••", class: "field-input" %> +

At least 8 characters.

+
+ +
+ <%= form.label :password_confirmation, "Confirm password", class: "field-label" %> + <%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", + minlength: 8, maxlength: 72, placeholder: "••••••••", class: "field-input" %> +
+ + <%= form.submit "Create account", class: "btn-primary w-full" %> + <% end %> + +

+ Already have an account? + <%= link_to "Sign in", new_session_path, class: "font-medium text-brand-600 hover:text-brand-700" %> +

+
+
From 56be315ffc5688742c5daa4704833ab2989721fc Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 04:43:45 -0300 Subject: [PATCH 013/145] fix: report the real validation error on a failed password reset --- app/controllers/passwords_controller.rb | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb index f95ec7874..8b6b564d3 100644 --- a/app/controllers/passwords_controller.rb +++ b/app/controllers/passwords_controller.rb @@ -7,7 +7,7 @@ def new end def create - if user = User.find_by(email_address: params[:email_address]) + if user = User.find_by(email: params[:email]) PasswordsMailer.reset(user).deliver_later end @@ -22,7 +22,10 @@ def update @user.sessions.destroy_all redirect_to new_session_path, notice: "Password has been reset." else - redirect_to edit_password_path(params[:token]), alert: "Passwords did not match." + # The generated controller reports "passwords did not match" for every failure, + # which is wrong as soon as there is a length rule to break. + flash.now[:alert] = @user.errors.full_messages.to_sentence + render :edit, status: :unprocessable_content end end From 75d76ed65f4c29dcceb19dcbe36198c202cc6e51 Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 04:43:50 -0300 Subject: [PATCH 014/145] test: cover authentication, roles and self-registration --- .simplecov | 21 ----- test/controllers/passwords_controller_test.rb | 32 ++++++-- .../registrations_controller_test.rb | 50 ++++++++++++ test/controllers/sessions_controller_test.rb | 34 ++++---- test/integration/role_based_access_test.rb | 52 ++++++++++++ test/models/user_test.rb | 81 ++++++++++++++++++- test/system/authentication_test.rb | 39 +++++++++ test/test_helper.rb | 25 +++++- 8 files changed, 287 insertions(+), 47 deletions(-) delete mode 100644 .simplecov create mode 100644 test/controllers/registrations_controller_test.rb create mode 100644 test/integration/role_based_access_test.rb create mode 100644 test/system/authentication_test.rb diff --git a/.simplecov b/.simplecov deleted file mode 100644 index bf980f447..000000000 --- a/.simplecov +++ /dev/null @@ -1,21 +0,0 @@ -SimpleCov.start "rails" do - enable_coverage :branch - - add_filter %r{\A/test/} - add_filter "app/channels/application_cable" - add_filter "config/" - - add_group "Services", "app/services" - add_group "Policies", "app/policies" - add_group "Jobs", "app/jobs" - - # Parallel workers each write their own resultset; merging is what turns them - # back into a single number. Without it the report reads ~1/N of reality. - use_merging true - merge_timeout 600 - - # The brief asks for 90% line coverage. Branch coverage is measured and reported - # but not enforced: failing a submission on a threshold nobody asked for is a - # self-inflicted red build. - minimum_coverage line: 90 if ENV["CI"] || ENV["COVERAGE"] -end diff --git a/test/controllers/passwords_controller_test.rb b/test/controllers/passwords_controller_test.rb index e1a1b03dc..cb01bfc2b 100644 --- a/test/controllers/passwords_controller_test.rb +++ b/test/controllers/passwords_controller_test.rb @@ -5,59 +5,77 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest test "new" do get new_password_path + assert_response :success end test "create" do - post passwords_path, params: { email_address: @user.email_address } + post passwords_path, params: { email: @user.email } + assert_enqueued_email_with PasswordsMailer, :reset, args: [ @user ] assert_redirected_to new_session_path follow_redirect! + assert_notice "reset instructions sent" end test "create for an unknown user redirects but sends no mail" do - post passwords_path, params: { email_address: "missing-user@example.com" } + post passwords_path, params: { email: "missing-user@example.com" } + assert_enqueued_emails 0 assert_redirected_to new_session_path follow_redirect! + assert_notice "reset instructions sent" end test "edit" do get edit_password_path(@user.password_reset_token) + assert_response :success end test "edit with invalid password reset token" do get edit_password_path("invalid token") + assert_redirected_to new_password_path follow_redirect! + assert_notice "reset link is invalid" end test "update" do assert_changes -> { @user.reload.password_digest } do - put password_path(@user.password_reset_token), params: { password: "new", password_confirmation: "new" } + put password_path(@user.password_reset_token), params: { password: "a-new-password", password_confirmation: "a-new-password" } + assert_redirected_to new_session_path end follow_redirect! + assert_notice "Password has been reset" end test "update with non matching passwords" do token = @user.password_reset_token assert_no_changes -> { @user.reload.password_digest } do - put password_path(token), params: { password: "no", password_confirmation: "match" } - assert_redirected_to edit_password_path(token) + put password_path(token), params: { password: "no-match-here", password_confirmation: "different-one" } end - follow_redirect! - assert_notice "Passwords did not match" + assert_response :unprocessable_content + assert_notice "Password confirmation doesn't match Password" + end + + test "update reports the real reason when the new password is too short" do + assert_no_changes -> { @user.reload.password_digest } do + put password_path(@user.password_reset_token), params: { password: "short", password_confirmation: "short" } + end + + assert_response :unprocessable_content + assert_notice "Password is too short" end private diff --git a/test/controllers/registrations_controller_test.rb b/test/controllers/registrations_controller_test.rb new file mode 100644 index 000000000..4582b5fd4 --- /dev/null +++ b/test/controllers/registrations_controller_test.rb @@ -0,0 +1,50 @@ +require "test_helper" + +class RegistrationsControllerTest < ActionDispatch::IntegrationTest + test "renders the registration form" do + get new_registration_path + + assert_response :success + end + + test "registers a visitor and signs them in" do + assert_difference -> { User.count }, 1 do + post registration_path, params: { user: valid_attributes } + end + + assert_redirected_to profile_path + assert_predicate cookies[:session_id], :present? + end + + test "ignores a role supplied by the visitor" do + post registration_path, params: { user: valid_attributes.merge(role: "admin") } + + assert_predicate User.find_by(email: "katherine@umanni.test"), :user? + end + + test "re-renders the form when the submission is invalid" do + assert_no_difference -> { User.count } do + post registration_path, params: { user: valid_attributes.merge(email: "not-an-email") } + end + + assert_response :unprocessable_content + end + + test "rejects a submission that is not nested under a user key" do + assert_no_difference -> { User.count } do + post registration_path, params: valid_attributes + end + + assert_response :bad_request + end + + private + def valid_attributes + { + full_name: "Katherine Johnson", + email: "katherine@umanni.test", + password: "secret-password", + password_confirmation: "secret-password" + } + end +end diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb index 07d72ef78..408b7cff5 100644 --- a/test/controllers/sessions_controller_test.rb +++ b/test/controllers/sessions_controller_test.rb @@ -1,33 +1,39 @@ require "test_helper" class SessionsControllerTest < ActionDispatch::IntegrationTest - setup { @user = User.take } - - test "new" do + test "renders the sign in form" do get new_session_path + assert_response :success end - test "create with valid credentials" do - post session_path, params: { email_address: @user.email_address, password: "password" } + test "signs in with valid credentials" do + post session_path, params: { session: { email: users(:member).email, password: "secret-password" } } - assert_redirected_to root_path - assert cookies[:session_id] + assert_redirected_to profile_path + assert_predicate cookies[:session_id], :present? end - test "create with invalid credentials" do - post session_path, params: { email_address: @user.email_address, password: "wrong" } + test "re-renders the form with an alert on invalid credentials" do + post session_path, params: { session: { email: users(:member).email, password: "wrong" } } - assert_redirected_to new_session_path - assert_nil cookies[:session_id] + assert_response :unprocessable_content + assert_empty cookies[:session_id].to_s + end + + test "rejects a login attempt with a malformed parameter structure" do + post session_path, params: { email: users(:member).email, password: "secret-password" } + + assert_response :bad_request + assert_empty cookies[:session_id].to_s end - test "destroy" do - sign_in_as(User.take) + test "signs out" do + sign_in_as users(:member) delete session_path assert_redirected_to new_session_path - assert_empty cookies[:session_id] + assert_empty cookies[:session_id].to_s end end diff --git a/test/integration/role_based_access_test.rb b/test/integration/role_based_access_test.rb new file mode 100644 index 000000000..66c7d49f4 --- /dev/null +++ b/test/integration/role_based_access_test.rb @@ -0,0 +1,52 @@ +require "test_helper" + +class RoleBasedAccessTest < ActionDispatch::IntegrationTest + test "an admin lands on the dashboard after signing in" do + sign_in users(:admin) + + assert_redirected_to admin_dashboard_path + end + + test "a user lands on their profile after signing in" do + sign_in users(:member) + + assert_redirected_to profile_path + end + + test "the root path sends each role to their own home" do + sign_in_as users(:admin) + get root_path + + assert_redirected_to admin_dashboard_path + + sign_out + sign_in_as users(:member) + get root_path + + assert_redirected_to profile_path + end + + test "a user cannot reach an admin screen" do + sign_in_as users(:member) + + get admin_dashboard_path + + assert_redirected_to profile_path + assert_equal "You are not authorised to access that page.", flash[:alert] + end + + test "a visitor is sent to sign in and returned to where they were headed" do + get admin_dashboard_path + + assert_redirected_to new_session_path + + sign_in users(:admin) + + assert_redirected_to admin_dashboard_url + end + + private + def sign_in(user) + post session_path, params: { session: { email: user.email, password: "secret-password" } } + end +end diff --git a/test/models/user_test.rb b/test/models/user_test.rb index 83445c476..73f192209 100644 --- a/test/models/user_test.rb +++ b/test/models/user_test.rb @@ -1,8 +1,83 @@ require "test_helper" class UserTest < ActiveSupport::TestCase - test "downcases and strips email_address" do - user = User.new(email_address: " DOWNCASED@EXAMPLE.COM ") - assert_equal("downcased@example.com", user.email_address) + test "strips and downcases email" do + user = User.new(email: " DOWNCASED@Example.COM ") + + assert_equal "downcased@example.com", user.email + end + + test "requires a full name, an email and a password" do + user = User.new + + assert_predicate user, :invalid? + assert_includes user.errors.attribute_names, :full_name + assert_includes user.errors.attribute_names, :email + assert_includes user.errors.attribute_names, :password + end + + test "rejects a malformed email" do + user = build(email: "not-an-email") + + assert_predicate user, :invalid? + assert_includes user.errors.attribute_names, :email end + + test "rejects an email already taken by another user" do + user = build(email: users(:member).email) + + assert_predicate user, :invalid? + assert_includes user.errors.attribute_names, :email + end + + test "rejects a password shorter than eight characters" do + user = build(password: "short", password_confirmation: "short") + + assert_predicate user, :invalid? + assert_includes user.errors.attribute_names, :password + end + + test "defaults to the user role" do + assert_predicate build.tap(&:save!), :user? + end + + test "rejects a role outside the enum" do + user = build(role: "superuser") + + assert_predicate user, :invalid? + assert_includes user.errors.attribute_names, :role + end + + test "stores the email encrypted at rest" do + user = users(:member) + stored = User.lease_connection.select_value( + User.sanitize_sql([ "SELECT email FROM users WHERE id = ?", user.id ]) + ) + + assert_not_equal user.email, stored + assert_no_match(/ada@umanni\.test/, stored) + end + + test "finds a user by email despite the column being encrypted" do + assert_equal users(:member), User.find_by(email: "ada@umanni.test") + end + + test "destroys its sessions when destroyed" do + user = users(:member) + user.sessions.create! + + assert_difference -> { Session.count }, -1 do + user.destroy + end + end + + private + def build(**attributes) + User.new({ + full_name: "Katherine Johnson", + email: "katherine@umanni.test", + password: "secret-password", + password_confirmation: "secret-password" + }.merge(attributes)) + end end diff --git a/test/system/authentication_test.rb b/test/system/authentication_test.rb new file mode 100644 index 000000000..0a991772a --- /dev/null +++ b/test/system/authentication_test.rb @@ -0,0 +1,39 @@ +require "application_system_test_case" + +class AuthenticationTest < ApplicationSystemTestCase + test "a visitor registers and lands on their profile" do + visit new_registration_path + + fill_in "Full name", with: "Katherine Johnson" + fill_in "Email", with: "katherine@umanni.test" + fill_in "Password", with: "secret-password" + fill_in "Confirm password", with: "secret-password" + click_on "Create account" + + assert_current_path profile_path + assert_text "Katherine Johnson" + assert_text "User" + end + + test "an admin signs in and lands on the dashboard" do + visit new_session_path + + fill_in "Email", with: users(:admin).email + fill_in "Password", with: "secret-password" + click_on "Sign in" + + assert_current_path admin_dashboard_path + assert_text "Dashboard" + end + + test "an invalid sign in keeps the email and shows the error" do + visit new_session_path + + fill_in "Email", with: users(:member).email + fill_in "Password", with: "wrong" + click_on "Sign in" + + assert_text "Try another email address or password." + assert_field "Email", with: users(:member).email + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index ed5c42fb4..b2728a368 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -2,6 +2,29 @@ require "simplecov" +# Started before the application is loaded, otherwise everything that runs at boot +# is reported as uncovered. +SimpleCov.start "rails" do + enable_coverage :branch + + skip %r{\A/test/} + skip "app/channels/application_cable" + skip "config/" + + group "Services", "app/services" + group "Jobs", "app/jobs" + + # Parallel workers each write their own resultset; merging is what turns them back + # into a single number instead of one worker's share. + merging true + merge_timeout 600 + + # The brief asks for 90% line coverage. Branch coverage is measured and reported + # but not enforced: failing a submission on a self-imposed threshold is a + # self-inflicted red build. + minimum_coverage line: 90 if ENV["CI"] || ENV["COVERAGE"] +end + require_relative "../config/environment" require "rails/test_help" require_relative "test_helpers/session_test_helper" @@ -10,8 +33,6 @@ module ActiveSupport class TestCase parallelize(workers: :number_of_processors) - # Each worker reports coverage under its own command name, then merges into the - # parent result. Skipping this makes SimpleCov report only the last worker's share. parallelize_setup do |worker| SimpleCov.command_name "#{SimpleCov.command_name}-#{worker}" end From d1ac03c23587c62f3555bf87150939b6d96edcf9 Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 04:57:51 -0300 Subject: [PATCH 015/145] fix: point Solid Cache at the cache database in development Sign-in returned 500: the store looked for solid_cache_entries in the primary database and the rate limiter raised. The suite cannot see this because test uses :null_store, so test/config/solid_stack_test.rb guards the config instead. --- config/cache.yml | 6 ++++ config/environments/test.rb | 5 +++ test/config/solid_stack_test.rb | 36 ++++++++++++++++++++ test/controllers/sessions_controller_test.rb | 9 +++++ test/test_helper.rb | 3 ++ 5 files changed, 59 insertions(+) create mode 100644 test/config/solid_stack_test.rb diff --git a/config/cache.yml b/config/cache.yml index 19d490843..ed177d68d 100644 --- a/config/cache.yml +++ b/config/cache.yml @@ -5,9 +5,15 @@ default: &default max_size: <%= 256.megabytes %> namespace: <%= Rails.env %> +# Development runs Solid Cache like production does, so it needs to be pointed at the +# cache database just the same. Without this the store falls back to the primary +# database, where solid_cache_entries does not exist, and every rate-limited action +# raises on its first cache write. development: + database: cache <<: *default +# The test environment uses :null_store (config/environments/test.rb), so no database. test: <<: *default diff --git a/config/environments/test.rb b/config/environments/test.rb index b6b22fa65..d5c97fef5 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -22,6 +22,11 @@ config.consider_all_requests_local = true config.cache_store = :null_store + # The rate limiter captures its store when the controller class loads, so a + # :null_store would make every rate-limit rule silently inert and untestable. + # General caching stays disabled; only the limiter gets a real store. + config.action_controller.cache_store = :memory_store + # Jobs run inline through perform_enqueued_jobs; see test/test_helper.rb. config.active_job.queue_adapter = :test diff --git a/test/config/solid_stack_test.rb b/test/config/solid_stack_test.rb new file mode 100644 index 000000000..7bb77628d --- /dev/null +++ b/test/config/solid_stack_test.rb @@ -0,0 +1,36 @@ +require "test_helper" + +# Development is configured to run the same Solid adapters as production, which only +# helps if both are pointed at the same databases. This caught a real failure: cache.yml +# named the cache database under production alone, so in development the store looked +# for solid_cache_entries in the primary database and every rate-limited action raised +# on its first write. The test suite never saw it, because test uses :null_store. +class SolidStackTest < ActiveSupport::TestCase + ENVIRONMENTS = %w[ development production ].freeze + + test "Solid Cache is pointed at the cache database everywhere it runs" do + ENVIRONMENTS.each do |env| + assert_equal "cache", Rails.application.config_for(:cache, env: env)[:database].to_s, + "#{env} runs Solid Cache without naming the cache database" + end + end + + test "Solid Cable is pointed at the cable database everywhere it runs" do + ENVIRONMENTS.each do |env| + config = Rails.application.config_for(:cable, env: env) + + assert_equal "solid_cable", config[:adapter].to_s, "#{env} is not using Solid Cable" + assert_equal "cable", config.dig(:connects_to, :database, :writing).to_s, + "#{env} runs Solid Cable without naming the cable database" + end + end + + test "every Solid database is declared in database.yml" do + ENVIRONMENTS.each do |env| + databases = Rails.application.config_for(:database, env: env).keys.map(&:to_s) + + assert_equal %w[ cable cache primary queue ], databases.sort, + "#{env} is missing one of the Solid databases" + end + end +end diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb index 408b7cff5..df06df62e 100644 --- a/test/controllers/sessions_controller_test.rb +++ b/test/controllers/sessions_controller_test.rb @@ -28,6 +28,15 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest assert_empty cookies[:session_id].to_s end + test "rate limits repeated sign in attempts from the same address" do + 11.times do + post session_path, params: { session: { email: users(:member).email, password: "wrong" } } + end + + assert_redirected_to new_session_path + assert_equal "Too many attempts. Try again later.", flash[:alert] + end + test "signs out" do sign_in_as users(:member) diff --git a/test/test_helper.rb b/test/test_helper.rb index b2728a368..f8be41f46 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -43,6 +43,9 @@ class TestCase fixtures :all + # Rate-limit counters would otherwise survive from one test to the next. + setup { ActionController::Base.cache_store.clear } + include ActiveJob::TestHelper end end From ef39e92d060391a783aa6f9928d4b7cf395b3e55 Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 12:13:15 -0300 Subject: [PATCH 016/145] chore: install Active Storage tables --- ...te_active_storage_tables.active_storage.rb | 57 +++++++++++++++++++ db/schema.rb | 32 ++++++++++- 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20260903150128_create_active_storage_tables.active_storage.rb diff --git a/db/migrate/20260903150128_create_active_storage_tables.active_storage.rb b/db/migrate/20260903150128_create_active_storage_tables.active_storage.rb new file mode 100644 index 000000000..6bd8bd082 --- /dev/null +++ b/db/migrate/20260903150128_create_active_storage_tables.active_storage.rb @@ -0,0 +1,57 @@ +# This migration comes from active_storage (originally 20170806125915) +class CreateActiveStorageTables < ActiveRecord::Migration[7.0] + def change + # Use Active Record's configured type for primary and foreign keys + primary_key_type, foreign_key_type = primary_and_foreign_key_types + + create_table :active_storage_blobs, id: primary_key_type do |t| + t.string :key, null: false + t.string :filename, null: false + t.string :content_type + t.text :metadata + t.string :service_name, null: false + t.bigint :byte_size, null: false + t.string :checksum + + if connection.supports_datetime_with_precision? + t.datetime :created_at, precision: 6, null: false + else + t.datetime :created_at, null: false + end + + t.index [ :key ], unique: true + end + + create_table :active_storage_attachments, id: primary_key_type do |t| + t.string :name, null: false + t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type + t.references :blob, null: false, type: foreign_key_type + + if connection.supports_datetime_with_precision? + t.datetime :created_at, precision: 6, null: false + else + t.datetime :created_at, null: false + end + + t.index [ :record_type, :record_id, :name, :blob_id ], name: :index_active_storage_attachments_uniqueness, unique: true + t.foreign_key :active_storage_blobs, column: :blob_id + end + + create_table :active_storage_variant_records, id: primary_key_type do |t| + t.belongs_to :blob, null: false, index: false, type: foreign_key_type + t.string :variation_digest, null: false + + t.index [ :blob_id, :variation_digest ], name: :index_active_storage_variant_records_uniqueness, unique: true + t.foreign_key :active_storage_blobs, column: :blob_id + end + end + + private + def primary_and_foreign_key_types + config = Rails.configuration.generators + setting = config.options[config.orm][:primary_key_type] + primary_key_type = setting || :primary_key + foreign_key_type = setting || :bigint + [ primary_key_type, foreign_key_type ] + end +end diff --git a/db/schema.rb b/db/schema.rb index 4be9303cb..5035595fc 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,35 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_09_03_072950) do +ActiveRecord::Schema[8.1].define(version: 2026_09_03_150128) do + create_table "active_storage_attachments", force: :cascade do |t| + t.bigint "blob_id", null: false + t.datetime "created_at", null: false + t.string "name", null: false + t.bigint "record_id", null: false + t.string "record_type", null: false + t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" + t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true + end + + create_table "active_storage_blobs", force: :cascade do |t| + t.bigint "byte_size", null: false + t.string "checksum" + t.string "content_type" + t.datetime "created_at", null: false + t.string "filename", null: false + t.string "key", null: false + t.text "metadata" + t.string "service_name", null: false + t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true + end + + create_table "active_storage_variant_records", force: :cascade do |t| + t.bigint "blob_id", null: false + t.string "variation_digest", null: false + t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true + end + create_table "sessions", force: :cascade do |t| t.datetime "created_at", null: false t.string "ip_address" @@ -31,5 +59,7 @@ t.check_constraint "role IN ('user', 'admin')", name: "users_role_check" end + add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" + add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "sessions", "users" end From c476ff409c4f1bfde6b907923d7b1d1bc3171d3b Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 12:13:15 -0300 Subject: [PATCH 017/145] feat: attach an avatar image to users No SVG: a stored SVG is a stored script, served from this origin. No variants either, which would put libvips on every machine for a 40px thumbnail. --- app/models/user.rb | 27 +++++++++++++++++++++++++++ app/views/profiles/show.html.erb | 16 ++++++++-------- app/views/shared/_avatar.html.erb | 17 +++++++++++++++++ 3 files changed, 52 insertions(+), 8 deletions(-) create mode 100644 app/views/shared/_avatar.html.erb diff --git a/app/models/user.rb b/app/models/user.rb index e06723214..221b25d13 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,6 +1,12 @@ class User < ApplicationRecord + # SVG is absent on purpose. A stored SVG is a stored script, and Active Storage + # serves attachments from the application's own origin. + AVATAR_CONTENT_TYPES = %w[ image/png image/jpeg image/webp ].freeze + AVATAR_MAX_SIZE = 2.megabytes + has_secure_password has_many :sessions, dependent: :destroy + has_one_attached :avatar_image # Deterministic so the column stays queryable and uniquely indexable. The cost is # that partial matching is gone: no LIKE on email, ever. The admin list searches @@ -14,4 +20,25 @@ class User < ApplicationRecord validates :full_name, presence: true, length: { maximum: 120 } validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP } validates :password, length: { minimum: 8 }, allow_nil: true + validate :avatar_image_must_be_a_supported_image + + scope :ordered, -> { order(:full_name, :id) } + scope :search, ->(term) { where("full_name LIKE ?", "%#{sanitize_sql_like(term.to_s.strip)}%") } + scope :with_role, ->(role) { where(role: role) } + + # Blank for an unsaved user, which is exactly the case on the "new user" form. + def initials + full_name.to_s.split.first(2).filter_map { |part| part[0] }.join.upcase + end + + private + def avatar_image_must_be_a_supported_image + return unless avatar_image.attached? + + supported = avatar_image.content_type.in?(AVATAR_CONTENT_TYPES) + oversized = avatar_image.byte_size > AVATAR_MAX_SIZE + + errors.add(:avatar_image, "must be a PNG, JPEG or WebP image") unless supported + errors.add(:avatar_image, "must be under #{AVATAR_MAX_SIZE / 1.megabyte} MB") if oversized + end end diff --git a/app/views/profiles/show.html.erb b/app/views/profiles/show.html.erb index 215f0d81d..2601980ff 100644 --- a/app/views/profiles/show.html.erb +++ b/app/views/profiles/show.html.erb @@ -3,15 +3,15 @@

Your profile

-
-
-
Full name
-
<%= @user.full_name %>
-
-
-
Email
-
<%= @user.email %>
+
+
+ <%= render "shared/avatar", user: @user, size: "size-16" %> +
+

<%= @user.full_name %>

+

<%= @user.email %>

+
+
Role
"><%= @user.role.capitalize %>
diff --git a/app/views/shared/_avatar.html.erb b/app/views/shared/_avatar.html.erb new file mode 100644 index 000000000..544f584ca --- /dev/null +++ b/app/views/shared/_avatar.html.erb @@ -0,0 +1,17 @@ +<%# Avatars are served at their uploaded size and constrained by CSS. Generating + variants would pull libvips into every developer's machine for a 40px thumbnail; + uploads are capped at 2 MB instead. See README for when that trade flips. %> +<% size = local_assigns.fetch(:size, "size-10") %> + +<%# The attachment has to be saved, not merely assigned: re-rendering the form after a + failed create would otherwise ask for a URL to a blob that has no id yet. %> +<% if user.avatar_image.attachment&.persisted? %> + <%= image_tag user.avatar_image, + alt: user.full_name, + loading: "lazy", + class: "#{size} rounded-full object-cover ring-1 ring-slate-200" %> +<% else %> + +<% end %> From f2c6a0e1da62d3c8998e2a2d08bd064479dca98a Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 12:13:31 -0300 Subject: [PATCH 018/145] feat: add admin user CRUD, search, filter and role toggle Role is a sub-resource so UsersController stays plain CRUD. Search matches full_name only, since the email column is ciphertext. Pagination fetches one row beyond the page to answer next? without a COUNT. --- .../admin/users/roles_controller.rb | 27 +++++++ app/controllers/admin/users_controller.rb | 73 +++++++++++++++++++ app/models/pagination.rb | 32 ++++++++ app/views/admin/dashboards/show.html.erb | 4 + app/views/admin/users/_form.html.erb | 50 +++++++++++++ app/views/admin/users/_user.html.erb | 39 ++++++++++ app/views/admin/users/edit.html.erb | 9 +++ app/views/admin/users/index.html.erb | 58 +++++++++++++++ app/views/admin/users/new.html.erb | 9 +++ .../admin/users/roles/update.turbo_stream.erb | 9 +++ app/views/shared/_flash.html.erb | 29 ++++---- app/views/shared/_navbar.html.erb | 6 +- app/views/shared/_pagination.html.erb | 19 +++++ config/routes.rb | 7 ++ 14 files changed, 356 insertions(+), 15 deletions(-) create mode 100644 app/controllers/admin/users/roles_controller.rb create mode 100644 app/controllers/admin/users_controller.rb create mode 100644 app/models/pagination.rb create mode 100644 app/views/admin/users/_form.html.erb create mode 100644 app/views/admin/users/_user.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/admin/users/roles/update.turbo_stream.erb create mode 100644 app/views/shared/_pagination.html.erb diff --git a/app/controllers/admin/users/roles_controller.rb b/app/controllers/admin/users/roles_controller.rb new file mode 100644 index 000000000..b3cd97920 --- /dev/null +++ b/app/controllers/admin/users/roles_controller.rb @@ -0,0 +1,27 @@ +module Admin + module Users + class RolesController < BaseController + before_action :set_user + + def update + if @user == Current.user + return redirect_to admin_users_path, + alert: "You cannot change your own role. Ask another admin." + end + + @user.update!(role: @user.admin? ? :user : :admin) + message = "#{@user.full_name} is now #{@user.admin? ? "an admin" : "a user"}." + + respond_to do |format| + format.turbo_stream { flash.now[:notice] = message } + format.html { redirect_to admin_users_path, notice: message } + end + end + + private + def set_user + @user = User.find(params[:user_id]) + end + end + end +end diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb new file mode 100644 index 000000000..861c53dc6 --- /dev/null +++ b/app/controllers/admin/users_controller.rb @@ -0,0 +1,73 @@ +module Admin + class UsersController < BaseController + before_action :set_user, only: %i[ edit update destroy ] + + def index + @page = Pagination.new(filtered_users.ordered, page: params[:page]) + end + + def new + @user = User.new + end + + def create + @user = User.new(user_params) + + if @user.save + redirect_to admin_users_path, notice: "#{@user.full_name} was added." + else + render :new, status: :unprocessable_content + end + end + + def edit + end + + def update + if @user.update(user_params) + redirect_to admin_users_path, notice: "#{@user.full_name} was updated." + else + render :edit, status: :unprocessable_content + end + end + + def destroy + if @user == Current.user + redirect_to admin_users_path, alert: "Delete your own account from your profile." + else + @user.destroy! + redirect_to admin_users_path, notice: "#{@user.full_name} was removed.", status: :see_other + end + end + + private + def set_user + @user = User.find(params[:id]) + end + + # Role is permitted here and nowhere else: an admin assigns roles, a visitor + # registering themselves does not. + def user_params + permitted = params.expect( + user: [ :full_name, :email, :role, :password, :password_confirmation, :avatar_image ] + ) + + # An edit form submits both fields empty when the admin is not changing the + # password, and an empty file input submits a blank avatar. Blanking those keys + # rather than compacting the whole hash keeps "the admin cleared the name" a + # validation error instead of a silent no-op. + permitted = permitted.except(:password, :password_confirmation) if permitted[:password].blank? + permitted = permitted.except(:avatar_image) if permitted[:avatar_image].blank? + permitted + end + + def filtered_users + scope = User.all + scope = scope.search(params[:query]) if params[:query].present? + # Checked against the enum rather than passed through, so a crafted role + # parameter cannot reach the query. + scope = scope.with_role(params[:role]) if User.roles.key?(params[:role]) + scope + end + end +end diff --git a/app/models/pagination.rb b/app/models/pagination.rb new file mode 100644 index 000000000..d45bce9be --- /dev/null +++ b/app/models/pagination.rb @@ -0,0 +1,32 @@ +# Offset pagination without a gem and without a COUNT query: one extra row is fetched +# beyond the page, and its presence is what answers "is there a next page". +# +# At this scale that is the whole requirement. A list that needed page numbers, jump +# links or a total count would be the point to bring in Pagy and stop hand-rolling. +class Pagination + PER_PAGE = 25 + + attr_reader :number + + def initialize(scope, page:, per_page: PER_PAGE) + @scope = scope + @number = [ page.to_i, 1 ].max + @per_page = per_page + end + + def records + @records ||= @scope.offset((number - 1) * @per_page).limit(@per_page + 1).to_a + end + + def visible + records.first(@per_page) + end + + def next? + records.size > @per_page + end + + def previous? + number > 1 + end +end diff --git a/app/views/admin/dashboards/show.html.erb b/app/views/admin/dashboards/show.html.erb index 736aadaca..8152594e1 100644 --- a/app/views/admin/dashboards/show.html.erb +++ b/app/views/admin/dashboards/show.html.erb @@ -2,3 +2,7 @@

Dashboard

User metrics, updated live.

+ +
+ <%= link_to "Manage users", admin_users_path, class: "btn-primary" %> +
diff --git a/app/views/admin/users/_form.html.erb b/app/views/admin/users/_form.html.erb new file mode 100644 index 000000000..78d1eb2e0 --- /dev/null +++ b/app/views/admin/users/_form.html.erb @@ -0,0 +1,50 @@ +<%= form_with model: user, url: url, class: "space-y-5" do |form| %> + <%= render "shared/form_errors", model: user %> + +
+ <%= form.label :full_name, "Full name", class: "field-label" %> + <%= form.text_field :full_name, required: true, autofocus: true, maxlength: 120, + placeholder: "Ada Lovelace", class: "field-input" %> +
+ +
+ <%= form.label :email, class: "field-label" %> + <%= form.email_field :email, required: true, placeholder: "ada@company.com", class: "field-input" %> +
+ +
+ <%= form.label :role, class: "field-label" %> + <%= form.select :role, User.roles.keys.map { |role| [ role.capitalize, role ] }, {}, class: "field-input" %> +
+ +
+ <%= form.label :avatar_image, "Avatar", class: "field-label" %> +
+ <%= render "shared/avatar", user: user, size: "size-14" %> + <%= form.file_field :avatar_image, accept: User::AVATAR_CONTENT_TYPES.join(","), + class: "field-input file:mr-3 file:rounded-md file:border-0 file:bg-slate-100 + file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-slate-700" %> +
+

PNG, JPEG or WebP, up to 2 MB.

+
+ +
+
+ <%= form.label :password, class: "field-label" %> + <%= form.password_field :password, autocomplete: "new-password", minlength: 8, maxlength: 72, + placeholder: "••••••••", class: "field-input" %> +

<%= user.persisted? ? "Leave blank to keep the current password." : "At least 8 characters." %>

+
+ +
+ <%= form.label :password_confirmation, "Confirm password", class: "field-label" %> + <%= form.password_field :password_confirmation, autocomplete: "new-password", + minlength: 8, maxlength: 72, placeholder: "••••••••", class: "field-input" %> +
+
+ +
+ <%= form.submit submit_label, class: "btn-primary" %> + <%= link_to "Cancel", admin_users_path, class: "btn-secondary" %> +
+<% end %> diff --git a/app/views/admin/users/_user.html.erb b/app/views/admin/users/_user.html.erb new file mode 100644 index 000000000..7fe24e5b7 --- /dev/null +++ b/app/views/admin/users/_user.html.erb @@ -0,0 +1,39 @@ + + +
+ <%= render "shared/avatar", user: user %> +
+

<%= user.full_name %>

+

<%= user.email %>

+
+
+ + + + "><%= user.role.capitalize %> + + + + <%= user.created_at.to_date.to_fs(:long) %> + + + +
+ <% if user == Current.user %> + You + <% else %> + <%= button_to admin_user_role_path(user), method: :patch, class: "btn-secondary text-xs" do %> + Make <%= user.admin? ? "user" : "admin" %> + <% end %> + <% end %> + + <%= link_to "Edit", edit_admin_user_path(user), class: "btn-secondary text-xs" %> + + <% unless user == Current.user %> + <%= button_to "Delete", admin_user_path(user), method: :delete, + class: "btn-danger text-xs", + form: { data: { turbo_confirm: "Delete #{user.full_name}? This cannot be undone." } } %> + <% end %> +
+ + diff --git a/app/views/admin/users/edit.html.erb b/app/views/admin/users/edit.html.erb new file mode 100644 index 000000000..3e3d09fbb --- /dev/null +++ b/app/views/admin/users/edit.html.erb @@ -0,0 +1,9 @@ +<% content_for :title, "Edit #{@user.full_name}" %> + +
+

Edit <%= @user.full_name %>

+ +
+ <%= render "admin/users/form", user: @user, url: admin_user_path(@user), submit_label: "Save changes" %> +
+
diff --git a/app/views/admin/users/index.html.erb b/app/views/admin/users/index.html.erb new file mode 100644 index 000000000..d5449de24 --- /dev/null +++ b/app/views/admin/users/index.html.erb @@ -0,0 +1,58 @@ +<% content_for :title, "Users" %> + +
+
+

Users

+

Create, edit and remove accounts.

+
+ + <%= link_to "New user", new_admin_user_path, class: "btn-primary" %> +
+ +<%= form_with url: admin_users_path, method: :get, class: "mt-6 flex flex-wrap items-end gap-3" do |form| %> +
+ <%= form.label :query, "Search by name", class: "field-label" %> + <%# Email is encrypted, so there is nothing to match against with LIKE. Searching + names is the honest capability; see README. %> + <%= form.search_field :query, value: params[:query], placeholder: "Ada", class: "field-input" %> +
+ +
+ <%= form.label :role, class: "field-label" %> + <%= form.select :role, + options_for_select(User.roles.keys.map { |role| [ role.capitalize, role ] }, params[:role]), + { include_blank: "All roles" }, class: "field-input" %> +
+ + <%= form.submit "Filter", class: "btn-secondary" %> + <% if params[:query].present? || params[:role].present? %> + <%= link_to "Clear", admin_users_path, class: "text-sm text-slate-500 hover:text-slate-700" %> + <% end %> +<% end %> + +
+ + + + + + + + + + + + <% if @page.visible.any? %> + <%= render partial: "admin/users/user", collection: @page.visible, as: :user %> + <% else %> + + + + <% end %> + +
UserRoleActions
+ No users match that filter. +
+
+ +<%= render "shared/pagination", page: @page %> diff --git a/app/views/admin/users/new.html.erb b/app/views/admin/users/new.html.erb new file mode 100644 index 000000000..f8e3ed15c --- /dev/null +++ b/app/views/admin/users/new.html.erb @@ -0,0 +1,9 @@ +<% content_for :title, "New user" %> + +
+

New user

+ +
+ <%= render "admin/users/form", user: @user, url: admin_users_path, submit_label: "Create user" %> +
+
diff --git a/app/views/admin/users/roles/update.turbo_stream.erb b/app/views/admin/users/roles/update.turbo_stream.erb new file mode 100644 index 000000000..506cdd55e --- /dev/null +++ b/app/views/admin/users/roles/update.turbo_stream.erb @@ -0,0 +1,9 @@ +<%# Only the changed row and the flash are sent back, so the admin's scroll position, + search term and page stay exactly where they were. %> +<%= turbo_stream.replace dom_id(@user) do %> + <%= render "admin/users/user", user: @user %> +<% end %> + +<%= turbo_stream.replace "flash" do %> + <%= render "shared/flash" %> +<% end %> diff --git a/app/views/shared/_flash.html.erb b/app/views/shared/_flash.html.erb index 46207f9aa..ed1349b00 100644 --- a/app/views/shared/_flash.html.erb +++ b/app/views/shared/_flash.html.erb @@ -1,14 +1,15 @@ -<% if flash.any? %> -
- <% flash.each do |type, message| %> - <%= tag.p message, - id: type, - role: (type == "alert" ? "alert" : "status"), - class: class_names( - "rounded-lg px-4 py-3 text-sm font-medium ring-1 ring-inset", - "bg-red-50 text-red-800 ring-red-200" => type == "alert", - "bg-emerald-50 text-emerald-800 ring-emerald-200" => type != "alert" - ) %> - <% end %> -
-<% end %> +<%# Renders its own wrapper id so a Turbo Stream can replace this block by name. %> +<% messages = local_assigns.fetch(:messages, flash) %> + +
"> + <% messages.each do |type, message| %> + <%= tag.p message, + id: type, + role: (type == "alert" ? "alert" : "status"), + class: class_names( + "rounded-lg px-4 py-3 text-sm font-medium ring-1 ring-inset", + "bg-red-50 text-red-800 ring-red-200" => type == "alert", + "bg-emerald-50 text-emerald-800 ring-emerald-200" => type != "alert" + ) %> + <% end %> +
diff --git a/app/views/shared/_navbar.html.erb b/app/views/shared/_navbar.html.erb index 77012d119..3f993439d 100644 --- a/app/views/shared/_navbar.html.erb +++ b/app/views/shared/_navbar.html.erb @@ -7,12 +7,16 @@
<% if Current.user.admin? %> <%= link_to "Dashboard", admin_dashboard_path, class: "text-sm font-medium text-slate-600 hover:text-slate-900" %> + <%= link_to "Users", admin_users_path, class: "text-sm font-medium text-slate-600 hover:text-slate-900" %> <% end %> <%= link_to "Profile", profile_path, class: "text-sm font-medium text-slate-600 hover:text-slate-900" %> - + <%= button_to "Sign out", session_path, method: :delete, class: "btn-secondary" %>
diff --git a/app/views/shared/_pagination.html.erb b/app/views/shared/_pagination.html.erb new file mode 100644 index 000000000..aeb5b5f0f --- /dev/null +++ b/app/views/shared/_pagination.html.erb @@ -0,0 +1,19 @@ +<% if page.previous? || page.next? %> + +<% end %> diff --git a/config/routes.rb b/config/routes.rb index 4fafebe34..992004809 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -7,6 +7,13 @@ namespace :admin do resource :dashboard, only: :show + + resources :users, except: :show do + # The role is a sub-resource rather than a custom action on the user, so that + # UsersController stays plain CRUD and the "an admin cannot demote themselves" + # rule has one obvious home. + resource :role, only: :update, module: :users + end end # Returns 200 once the application boots cleanly. Used by the Compose healthcheck From 14affab5d98575e0128f540d8ab5ce2987c01968 Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 12:13:31 -0300 Subject: [PATCH 019/145] fix: centre signed-out screens in the viewport --- app/views/layouts/application.html.erb | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index b6ba7fd70..5124caf59 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -19,10 +19,13 @@ <%= javascript_importmap_tags %> - + <%= render "shared/navbar" if authenticated? %> -
+ <%# Signed-out screens are a single card and centre in the viewport; signed-in + screens are lists and forms that should start at the top. %> +
"> <%= render "shared/flash" %> <%= yield %>
From c8b1a8057f6cf1bcaa80c584219e056a95c03feb Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 12:13:31 -0300 Subject: [PATCH 020/145] test: cover admin user management, role changes and pagination --- test/application_system_test_case.rb | 16 ++ .../admin/users/roles_controller_test.rb | 59 +++++++ .../admin/users_controller_test.rb | 146 ++++++++++++++++++ test/fixtures/files/avatar.png | Bin 0 -> 89 bytes test/fixtures/files/not-an-image.txt | 1 + test/models/pagination_test.rb | 43 ++++++ test/system/admin_user_management_test.rb | 91 +++++++++++ 7 files changed, 356 insertions(+) create mode 100644 test/controllers/admin/users/roles_controller_test.rb create mode 100644 test/controllers/admin/users_controller_test.rb create mode 100644 test/fixtures/files/avatar.png create mode 100644 test/fixtures/files/not-an-image.txt create mode 100644 test/models/pagination_test.rb create mode 100644 test/system/admin_user_management_test.rb diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb index 0abc80e06..a8b288c24 100644 --- a/test/application_system_test_case.rb +++ b/test/application_system_test_case.rb @@ -5,4 +5,20 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase Selenium::WebDriver::Chrome.path = ENV["CHROME_BINARY"] if ENV["CHROME_BINARY"].present? driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 1400 ] + + private + # Deliberately shadows the cookie-jar helper the generator provides: that one writes + # a cookie into a test request and a real browser never sees it. + # + # The assertion at the end is not decoration. `click_on` returns as soon as the click + # is dispatched, so without waiting for the redirect the next `visit` outruns the + # sign-in request and arrives as an anonymous visitor. + def sign_in_as(user, password: "secret-password") + visit new_session_path + fill_in "Email", with: user.email + fill_in "Password", with: password + click_on "Sign in" + + assert_no_current_path new_session_path + end end diff --git a/test/controllers/admin/users/roles_controller_test.rb b/test/controllers/admin/users/roles_controller_test.rb new file mode 100644 index 000000000..254f9f472 --- /dev/null +++ b/test/controllers/admin/users/roles_controller_test.rb @@ -0,0 +1,59 @@ +require "test_helper" + +class Admin::Users::RolesControllerTest < ActionDispatch::IntegrationTest + setup { sign_in_as users(:admin) } + + test "promotes a user to admin" do + patch admin_user_role_path(users(:member)) + + assert_predicate users(:member).reload, :admin? + assert_redirected_to admin_users_path + end + + test "demotes an admin to user" do + other_admin = User.create!( + full_name: "Katherine Johnson", email: "katherine@umanni.test", + role: :admin, password: "secret-password", password_confirmation: "secret-password" + ) + + patch admin_user_role_path(other_admin) + + assert_predicate other_admin.reload, :user? + end + + test "replaces only the changed row when asked for a turbo stream" do + patch admin_user_role_path(users(:member)), as: :turbo_stream + + assert_response :success + assert_match "turbo-stream", response.media_type + assert_match dom_id(users(:member)), response.body + end + + # An admin demoting themselves is how a system ends up with nobody able to + # administer it. Blocking that here is what keeps at least one admin around: any + # other admin they demote leaves the demoting admin still an admin. + test "refuses to change the signed in admin's own role" do + patch admin_user_role_path(users(:admin)) + + assert_predicate users(:admin).reload, :admin? + assert_equal "You cannot change your own role. Ask another admin.", flash[:alert] + end + + test "always leaves at least one admin behind" do + User.admin.where.not(id: users(:admin).id).find_each do |admin| + patch admin_user_role_path(admin) + end + + assert_operator User.admin.count, :>=, 1 + end + + test "is closed to users who are not admins" do + sign_out + sign_in_as users(:member) + + patch admin_user_role_path(users(:admin)) + + assert_redirected_to profile_url + assert_predicate users(:admin).reload, :admin? + end +end diff --git a/test/controllers/admin/users_controller_test.rb b/test/controllers/admin/users_controller_test.rb new file mode 100644 index 000000000..d822b334c --- /dev/null +++ b/test/controllers/admin/users_controller_test.rb @@ -0,0 +1,146 @@ +require "test_helper" + +class Admin::UsersControllerTest < ActionDispatch::IntegrationTest + setup { sign_in_as users(:admin) } + + test "lists users" do + get admin_users_path + + assert_response :success + assert_select "td", text: /Ada Lovelace/ + end + + test "filters by name" do + get admin_users_path(query: "Ada") + + assert_select "td", text: /Ada Lovelace/ + assert_select "td", text: /Grace Hopper/, count: 0 + end + + test "filters by role" do + get admin_users_path(role: "admin") + + assert_select "td", text: /Grace Hopper/ + assert_select "td", text: /Ada Lovelace/, count: 0 + end + + test "ignores a role filter that is not one of the enum values" do + get admin_users_path(role: "'; DROP TABLE users; --") + + assert_response :success + assert_select "td", text: /Ada Lovelace/ + end + + test "creates a user with a role of the admin's choosing" do + assert_difference -> { User.count }, 1 do + post admin_users_path, params: { user: valid_attributes.merge(role: "admin") } + end + + assert_redirected_to admin_users_path + assert_predicate User.find_by(email: "katherine@umanni.test"), :admin? + end + + test "re-renders the form when creation is invalid" do + assert_no_difference -> { User.count } do + post admin_users_path, params: { user: valid_attributes.merge(email: "nope") } + end + + assert_response :unprocessable_content + end + + test "attaches an avatar" do + post admin_users_path, params: { user: valid_attributes.merge(avatar_image: avatar_upload) } + + assert_predicate User.find_by(email: "katherine@umanni.test").avatar_image, :attached? + end + + test "rejects an avatar that is not a supported image" do + assert_no_difference -> { User.count } do + post admin_users_path, params: { + user: valid_attributes.merge( + avatar_image: fixture_file_upload("not-an-image.txt", "text/plain") + ) + } + end + + assert_response :unprocessable_content + end + + test "updates a user" do + patch admin_user_path(users(:member)), params: { user: { full_name: "Ada King" } } + + assert_redirected_to admin_users_path + assert_equal "Ada King", users(:member).reload.full_name + end + + test "keeps the existing password when the password fields are left blank" do + member = users(:member) + digest = member.password_digest + + patch admin_user_path(member), params: { + user: { full_name: "Ada King", password: "", password_confirmation: "" } + } + + assert_equal digest, member.reload.password_digest + end + + test "reports an error rather than silently keeping the name when it is cleared" do + patch admin_user_path(users(:member)), params: { user: { full_name: "" } } + + assert_response :unprocessable_content + assert_equal "Ada Lovelace", users(:member).reload.full_name + end + + test "deletes a user" do + assert_difference -> { User.count }, -1 do + delete admin_user_path(users(:member)) + end + + assert_redirected_to admin_users_path + end + + test "refuses to delete the signed in admin" do + assert_no_difference -> { User.count } do + delete admin_user_path(users(:admin)) + end + + assert_equal "Delete your own account from your profile.", flash[:alert] + end + + test "rejects a submission that is not nested under a user key" do + post admin_users_path, params: valid_attributes + + assert_response :bad_request + end + + test "is closed to users who are not admins" do + sign_out + sign_in_as users(:member) + + get admin_users_path + + assert_redirected_to profile_url + end + + test "is closed to visitors" do + sign_out + + get admin_users_path + + assert_redirected_to new_session_path + end + + private + def valid_attributes + { + full_name: "Katherine Johnson", + email: "katherine@umanni.test", + password: "secret-password", + password_confirmation: "secret-password" + } + end + + def avatar_upload + fixture_file_upload("avatar.png", "image/png") + end +end diff --git a/test/fixtures/files/avatar.png b/test/fixtures/files/avatar.png new file mode 100644 index 0000000000000000000000000000000000000000..9fdee4bdf7a80760a31290621e3b241f9064f8f2 GIT binary patch literal 89 zcmeAS@N?(olHy`uVBq!ia0vp^93afd3?%;@)Hwm9*aCb)T>t<7zkmP!#m;4xKoLGq l7sn8e>&ZVDTK@=eFz7Kb|6QD!cM&MV;OXk;vd$@?2>{Cp865xs literal 0 HcmV?d00001 diff --git a/test/fixtures/files/not-an-image.txt b/test/fixtures/files/not-an-image.txt new file mode 100644 index 000000000..c93adf7e2 --- /dev/null +++ b/test/fixtures/files/not-an-image.txt @@ -0,0 +1 @@ +this is not an image diff --git a/test/models/pagination_test.rb b/test/models/pagination_test.rb new file mode 100644 index 000000000..b98d7b9bb --- /dev/null +++ b/test/models/pagination_test.rb @@ -0,0 +1,43 @@ +require "test_helper" + +class PaginationTest < ActiveSupport::TestCase + setup do + @scope = User.ordered + @total = User.count + end + + test "treats a missing, zero or negative page as the first page" do + [ nil, "", "0", "-3", "not a number" ].each do |page| + assert_equal 1, Pagination.new(@scope, page: page).number, "page: #{page.inspect}" + end + end + + test "returns at most per_page records" do + page = Pagination.new(@scope, page: 1, per_page: 1) + + assert_equal 1, page.visible.size + end + + test "reports a next page without counting the whole table" do + page = Pagination.new(@scope, page: 1, per_page: 1) + + assert_predicate page, :next? + assert_not page.previous? + # The extra row is the signal, so the query never has to know the total. + assert_equal 2, page.records.size + end + + test "reports no next page on the last one" do + page = Pagination.new(@scope, page: @total, per_page: 1) + + assert_not page.next? + assert_predicate page, :previous? + end + + test "does not repeat records across pages" do + first = Pagination.new(@scope, page: 1, per_page: 1).visible + second = Pagination.new(@scope, page: 2, per_page: 1).visible + + assert_empty first & second + end +end diff --git a/test/system/admin_user_management_test.rb b/test/system/admin_user_management_test.rb new file mode 100644 index 000000000..da9394d09 --- /dev/null +++ b/test/system/admin_user_management_test.rb @@ -0,0 +1,91 @@ +require "application_system_test_case" + +class AdminUserManagementTest < ApplicationSystemTestCase + setup do + sign_in_as users(:admin) + visit admin_users_path + end + + test "creates a user with an avatar" do + click_on "New user" + + fill_in "Full name", with: "Katherine Johnson" + fill_in "Email", with: "katherine@umanni.test" + select "Admin", from: "Role" + attach_file "Avatar", file_fixture("avatar.png") + fill_in "user_password", with: "secret-password" + fill_in "Confirm password", with: "secret-password" + click_on "Create user" + + assert_text "Katherine Johnson was added." + assert_text "katherine@umanni.test" + end + + # A duplicate email is the interesting case: minlength and type=email are caught by + # the browser before a request is made, so only a server-side rule exercises this path. + test "shows a server-side validation error without losing the form" do + click_on "New user" + + fill_in "Full name", with: "Katherine Johnson" + fill_in "Email", with: users(:member).email + fill_in "user_password", with: "secret-password" + fill_in "Confirm password", with: "secret-password" + click_on "Create user" + + assert_text "Email has already been taken" + assert_field "Full name", with: "Katherine Johnson" + end + + test "blocks an invalid password in the browser, before any request" do + click_on "New user" + + fill_in "user_password", with: "short" + + refute page.evaluate_script("document.getElementById('user_password').checkValidity()") + end + + test "toggles a role in place, without a full page load" do + row = find("tr", text: "Ada Lovelace") + + within(row) { click_on "Make admin" } + + assert_text "Ada Lovelace is now an admin." + within("tr", text: "Ada Lovelace") { assert_text "Admin" } + # The row was replaced by a Turbo Stream, so the search form is untouched. + assert_field "Search by name", with: "" + end + + test "does not offer role or delete controls for the signed in admin" do + within("tr", text: "Grace Hopper") do + assert_text "You" + assert_no_button "Make user" + assert_no_button "Delete" + end + end + + test "searches by name" do + fill_in "Search by name", with: "Ada" + click_on "Filter" + + within "table" do + assert_text "Ada Lovelace" + assert_no_text "Grace Hopper" + end + end + + test "edits a user" do + within("tr", text: "Ada Lovelace") { click_on "Edit" } + + fill_in "Full name", with: "Ada King" + click_on "Save changes" + + assert_text "Ada King was updated." + end + + test "deletes a user after confirming" do + accept_confirm { within("tr", text: "Ada Lovelace") { click_on "Delete" } } + + assert_text "Ada Lovelace was removed." + within("table") { assert_no_text "Ada Lovelace" } + end +end From 341faa48009f045ad4581d77377d78719d738d67 Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 12:26:11 -0300 Subject: [PATCH 021/145] fix: never seed demo users outside development The entrypoint runs db:prepare, which seeds a database it just created. --- db/seeds.rb | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/db/seeds.rb b/db/seeds.rb index 679c23136..50b042cd5 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,5 +1,15 @@ -# Idempotent: running it twice leaves the same database. Passwords are fixed on -# purpose — these are demo credentials for a reviewer, documented in the README. +# Demo data, and only ever demo data. The container entrypoint runs db:prepare, which +# seeds a freshly created database — so without this guard a production deploy would +# come up with a known email and a published password already in it. +# +# A real first admin belongs to the deploy, not to this file. The README shows the +# one-liner for creating it. +unless Rails.env.local? + puts "Skipping demo seeds outside development and test." + exit +end + +# Idempotent: running it twice leaves the same database. DEMO_USER_COUNT = 32 PASSWORD = "secret-password".freeze From ab46f45535a44d0e46e75b99145f0058a5b55242 Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 12:26:11 -0300 Subject: [PATCH 022/145] fix: pin the name column while the users table scrolls on a phone --- app/views/admin/users/_user.html.erb | 6 ++++-- app/views/admin/users/index.html.erb | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/views/admin/users/_user.html.erb b/app/views/admin/users/_user.html.erb index 7fe24e5b7..33aef0ae0 100644 --- a/app/views/admin/users/_user.html.erb +++ b/app/views/admin/users/_user.html.erb @@ -1,5 +1,7 @@ - - + + <%# Sticky so the name stays visible while the row scrolls horizontally on a phone. + Without it you can see a role and a Delete button with no idea whose they are. %> +
<%= render "shared/avatar", user: user %>
diff --git a/app/views/admin/users/index.html.erb b/app/views/admin/users/index.html.erb index d5449de24..35cf90076 100644 --- a/app/views/admin/users/index.html.erb +++ b/app/views/admin/users/index.html.erb @@ -34,7 +34,7 @@ - + From 40865f556fda49790e026d53bab96961aa5727c2 Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 12:28:29 -0300 Subject: [PATCH 023/145] refactor: use params.expect for the password reset too --- app/controllers/passwords_controller.rb | 12 ++++++++++-- app/views/passwords/edit.html.erb | 2 +- app/views/passwords/new.html.erb | 2 +- test/controllers/passwords_controller_test.rb | 10 +++++----- 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb index 8b6b564d3..cf99fbdd5 100644 --- a/app/controllers/passwords_controller.rb +++ b/app/controllers/passwords_controller.rb @@ -7,7 +7,7 @@ def new end def create - if user = User.find_by(email: params[:email]) + if (user = User.find_by(email: reset_request_params[:email])) PasswordsMailer.reset(user).deliver_later end @@ -18,7 +18,7 @@ def edit end def update - if @user.update(params.permit(:password, :password_confirmation)) + if @user.update(new_password_params) @user.sessions.destroy_all redirect_to new_session_path, notice: "Password has been reset." else @@ -30,6 +30,14 @@ def update end private + def reset_request_params + params.expect(password_reset: [ :email ]) + end + + def new_password_params + params.expect(password_reset: [ :password, :password_confirmation ]) + end + def set_user_by_token @user = User.find_by_password_reset_token!(params[:token]) rescue ActiveSupport::MessageVerifier::InvalidSignature diff --git a/app/views/passwords/edit.html.erb b/app/views/passwords/edit.html.erb index b294c5282..7a9fa4616 100644 --- a/app/views/passwords/edit.html.erb +++ b/app/views/passwords/edit.html.erb @@ -4,7 +4,7 @@

Choose a new password

- <%= form_with url: password_path(params[:token]), method: :put, class: "mt-6 space-y-5" do |form| %> + <%= form_with url: password_path(params[:token]), method: :put, scope: :password_reset, class: "mt-6 space-y-5" do |form| %>
<%= form.label :password, "New password", class: "field-label" %> <%= form.password_field :password, required: true, autofocus: true, autocomplete: "new-password", diff --git a/app/views/passwords/new.html.erb b/app/views/passwords/new.html.erb index 143410cb6..c1b219464 100644 --- a/app/views/passwords/new.html.erb +++ b/app/views/passwords/new.html.erb @@ -5,7 +5,7 @@

Reset your password

We will email you a link to choose a new one.

- <%= form_with url: passwords_path, class: "mt-6 space-y-5" do |form| %> + <%= form_with url: passwords_path, scope: :password_reset, class: "mt-6 space-y-5" do |form| %>
<%= form.label :email, class: "field-label" %> <%= form.email_field :email, required: true, autofocus: true, autocomplete: "username", diff --git a/test/controllers/passwords_controller_test.rb b/test/controllers/passwords_controller_test.rb index cb01bfc2b..d8fc0bc6f 100644 --- a/test/controllers/passwords_controller_test.rb +++ b/test/controllers/passwords_controller_test.rb @@ -10,7 +10,7 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest end test "create" do - post passwords_path, params: { email: @user.email } + post passwords_path, params: { password_reset: { email: @user.email } } assert_enqueued_email_with PasswordsMailer, :reset, args: [ @user ] assert_redirected_to new_session_path @@ -21,7 +21,7 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest end test "create for an unknown user redirects but sends no mail" do - post passwords_path, params: { email: "missing-user@example.com" } + post passwords_path, params: { password_reset: { email: "missing-user@example.com" } } assert_enqueued_emails 0 assert_redirected_to new_session_path @@ -49,7 +49,7 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest test "update" do assert_changes -> { @user.reload.password_digest } do - put password_path(@user.password_reset_token), params: { password: "a-new-password", password_confirmation: "a-new-password" } + put password_path(@user.password_reset_token), params: { password_reset: { password: "a-new-password", password_confirmation: "a-new-password" } } assert_redirected_to new_session_path end @@ -62,7 +62,7 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest test "update with non matching passwords" do token = @user.password_reset_token assert_no_changes -> { @user.reload.password_digest } do - put password_path(token), params: { password: "no-match-here", password_confirmation: "different-one" } + put password_path(token), params: { password_reset: { password: "no-match-here", password_confirmation: "different-one" } } end assert_response :unprocessable_content @@ -71,7 +71,7 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest test "update reports the real reason when the new password is too short" do assert_no_changes -> { @user.reload.password_digest } do - put password_path(@user.password_reset_token), params: { password: "short", password_confirmation: "short" } + put password_path(@user.password_reset_token), params: { password_reset: { password: "short", password_confirmation: "short" } } end assert_response :unprocessable_content From e838bf10a57811912973b04c869513c992f2ee29 Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 12:28:29 -0300 Subject: [PATCH 024/145] ci: run system tests in bin/ci as well --- config/ci.rb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/config/ci.rb b/config/ci.rb index 1712cc112..a34578576 100644 --- a/config/ci.rb +++ b/config/ci.rb @@ -9,11 +9,9 @@ step "Security: Importmap vulnerability audit", "bin/importmap audit" step "Security: Brakeman code analysis", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error" step "Tests: Rails", "bin/rails test" + step "Tests: System", "bin/rails test:system" step "Tests: Seeds", "env RAILS_ENV=test bin/rails db:seed:replant" - # Optional: Run system tests - # step "Tests: System", "bin/rails test:system" - # Optional: set a green GitHub commit status to unblock PR merge. # Requires the `gh` CLI and `gh extension install basecamp/gh-signoff`. # if success? From e48262f3140841edf472be971829262e6d1bb40c Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 12:28:29 -0300 Subject: [PATCH 025/145] docs: add CLAUDE.md The gotchas section is the part worth keeping current. --- CLAUDE.md | 104 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..40556afa0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,104 @@ +# Umanni user management + +## Stack + +- Ruby 4.0.6 (`.ruby-version`), Rails 8.1.3.1, Bundler 4.0.16. +- SQLite 3 via `sqlite3` 2.9.6. Solid Cache 1.0.10, Solid Queue 1.7.0, Solid Cable 4.0.2. No Redis anywhere. +- Propshaft, importmap-rails, Turbo, Stimulus, tailwindcss-rails 4.6.0 (Tailwind v4). Puma 8, Thruster in production. +- Minitest 6, Capybara + selenium-webdriver (headless Chrome), SimpleCov 1.1.1. +- RuboCop 1.90 with rubocop-rails-omakase, rubocop-minitest, rubocop-performance. Brakeman and bundler-audit in CI. + +## Architecture + +- Authentication is the Rails 8 generator output (`bin/rails generate authentication`), committed unmodified on its + own and customised afterwards: signed `session_id` cookie, one `Session` row per browser, bcrypt digest, + token-based password reset. `app/controllers/concerns/authentication.rb`, `Current.session`/`Current.user`. +- `User` has `full_name`, `email`, `password_digest`, `role`. `role` is a string-backed enum (`user`, `admin`) with + `validate: true` and a database check constraint `users_role_check`. +- `email` uses `encrypts :email, deterministic: true`, so the unique index compares ciphertext and `authenticate_by` + works, but `LIKE` on email is impossible. Dev/test keys live in `config/environments/{development,test}.rb` on + purpose; production takes them from credentials (Rails default, nothing set in `production.rb`). +- `Admin::BaseController` runs `require_admin`; every admin controller inherits from it. Admin routes: + `admin/dashboard` (show, placeholder page), `admin/users` (all but show), `admin/users/:user_id/role` (update). +- `Admin::Users::RolesController#update` toggles the role and answers with a Turbo Stream that replaces the row + (`dom_id(user)`) and `#flash`. An admin cannot change their own role or delete themselves. +- Landing page after sign-in and at `/`: admins go to `admin_dashboard_url`, everyone else to `profile_url` + (`ApplicationController#home_url_for`, reused by `HomeController`). +- Avatar is `has_one_attached :avatar_image` (Active Storage, Disk service). PNG/JPEG/WebP only, 2 MB cap, validated + in the model. No variants; images are served at upload size and constrained by CSS. +- `Pagination` (`app/models/pagination.rb`) is a PORO: offset-based, fetches `per_page + 1` rows, no COUNT. +- `resource :profile` routes `show edit update destroy`; `ProfilesController` implements only `show`. +- Tailwind component layer in `app/assets/tailwind/application.css`: `card`, `field-*`, `btn-*`, `badge-*`. Tailwind v4 + will not `@apply` one component class inside another, hence the selector lists. + +## Database topology + +- `config/database.yml`: development and production each have four SQLite databases, `primary`, `cache`, `queue`, + `cable`, under `storage/`. Test is a single database (`storage/test.sqlite3`). +- Pragmas are explicit: `journal_mode: wal`, `synchronous: normal`, `foreign_keys: true`, `mmap_size`, + `journal_size_limit`, `cache_size`. +- `config/cache.yml` and `config/cable.yml` name their database in development and production; `cable.yml` uses + `async` in test. `test/config/solid_stack_test.rb` asserts this stays true. +- Schema files: `db/schema.rb`, `db/cache_schema.rb`, `db/queue_schema.rb`, `db/cable_schema.rb`. + +## Running it + +- Docker: `docker compose up` builds `Dockerfile.dev`, runs `bin/rails db:prepare && bin/dev`, serves on host port + 3200 (`WEB_PORT=xxxx docker compose up` to change). Health check hits `/up`. +- Local: `bin/setup` (bundle, `db:prepare`, then `bin/dev`), or `bin/dev` alone. `bin/dev` runs foreman over + `Procfile.dev`: `web`, `css` (Tailwind watcher), `jobs` (`bin/jobs`, Solid Queue). Default port 3000. +- Seeds (`bin/rails db:seed`) are idempotent, create 32 users, and refuse to run outside development and test — + `db:prepare` seeds a freshly created database, and a production deploy must not come up with demo logins in it. + Demo logins, password `secret-password`: `admin@umanni.test` (admin), `user@umanni.test` (user). +- Fixtures use `grace@umanni.test` (admin) and `ada@umanni.test` (user), same password. +- No SMTP is configured for development, so password-reset mail fails silently (`raise_delivery_errors = false`). + Preview at `/rails/mailers`. +- `Dockerfile` is the production image: multi-stage, non-root, Thruster. `config/deploy.yml` is a Kamal stub with + placeholder hosts. + +## Testing + +- `bin/rails test:all` runs unit, integration and system tests. `bin/rails test` skips system tests. +- Tests run in parallel (`workers: :number_of_processors`). SimpleCov results are merged per worker in + `test_helper.rb`. +- Line coverage floor is 90%, enforced only when `CI` or `COVERAGE` is set. Branch coverage is reported, not enforced. +- System tests use headless Chrome. Set `CHROME_BINARY=/path/to/chrome` when Chrome is not on `PATH` (WSL, slim + containers). +- Rate limiting is testable: `config.action_controller.cache_store = :memory_store` in test while the general store is + `:null_store`; `test_helper.rb` clears it before each test. +- `bin/ci` (`config/ci.rb`) mirrors the remote pipeline locally: setup, RuboCop, bundler-audit, importmap audit, + Brakeman, `bin/rails test`, `bin/rails test:system` and `db:seed:replant` in the test env. GitHub Actions (`.github/workflows/ci.yml`) runs the scans, lint and + `bin/rails db:test:prepare test:all` on pull requests and pushes to `master`. + +## Conventions + +- Conventional Commits in English (`feat:`, `fix:`, `test:`, `chore:`, `ci:`, `refactor:`), with a body that explains + the decision. Default branch is `master`. +- `params.expect`, not `permit`, in every controller that takes a form. +- `:role` is permitted only in `Admin::UsersController#user_params`. Self-registration never accepts it. +- Admin search matches `full_name` only (`User.search`, escaped with `sanitize_sql_like`). Role filter is checked + against `User.roles` before it reaches the query. +- RuboCop: omakase plus a stricter layer (`.rubocop.yml`: metrics ceilings, Rails cops, Minitest and Performance + plugins, line length 120). Run `bin/rubocop -A` after editing Ruby. +- Error responses render with `status: :unprocessable_content`; destroy redirects use `status: :see_other`. + +## Gotchas + +- `Procfile.dev` must keep `tailwindcss:watch[always]`. Without `always` the watcher exits when stdin is not a TTY + (Docker Compose), and foreman takes the whole application down with it. +- `config/cache.yml` needs `database: cache` under development as well as production. Missing it, Solid Cache looks for + `solid_cache_entries` in the primary database and every `rate_limit` action returns 500 on its first write. The test + suite never sees this because test uses `:null_store`; `test/config/solid_stack_test.rb` guards it instead. +- `config.active_record.encryption.encrypt_fixtures = true` in `test.rb` is required for fixture emails to match the + encrypted column. +- `shared/_avatar.html.erb` checks `avatar_image.attachment&.persisted?`, not `attached?`. Re-rendering a form after a + failed create otherwise asks for a URL to a blob with no id and raises. +- In system tests `click_on` returns before the request completes. `ApplicationSystemTestCase#sign_in_as` ends with + `assert_no_current_path new_session_path` so the next `visit` is not made as an anonymous visitor. +- `Dockerfile.dev` has no `USER`, so the container runs as root. `tmp` and `log` are named volumes in `compose.yaml`; + bind-mounting them leaves root-owned files on the host that block a local `bin/dev` from writing its bootsnap cache. + Read container logs with `docker compose logs`. +- libvips is only needed for Active Storage variants. Both Docker images and the CI test job install it; a host + without it runs everything, because avatars do not use variants. +- `sign_in_as` in `test/test_helpers/session_test_helper.rb` writes a cookie into a test request and is for + integration tests only; system tests use the browser-driven override in `ApplicationSystemTestCase`. From 2c804e2c6e493a635b8a36243daea7802ff1d969 Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 12:31:03 -0300 Subject: [PATCH 026/145] feat: update the dashboard counters over Solid Cable The stream is named by a lambda: the macro calls send on the record for anything that does not respond to call, so a bare symbol raises at commit time. --- .../admin/dashboards_controller.rb | 4 ++++ app/models/user.rb | 10 ++++++++++ app/views/admin/dashboards/_metrics.html.erb | 19 +++++++++++++++++++ app/views/admin/dashboards/show.html.erb | 17 ++++++++++++++--- 4 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 app/views/admin/dashboards/_metrics.html.erb diff --git a/app/controllers/admin/dashboards_controller.rb b/app/controllers/admin/dashboards_controller.rb index 490082dcc..cab6dca79 100644 --- a/app/controllers/admin/dashboards_controller.rb +++ b/app/controllers/admin/dashboards_controller.rb @@ -1,6 +1,10 @@ module Admin class DashboardsController < BaseController def show + # One grouped query answers both metrics the brief asks for; the total is the + # sum of the groups rather than a second COUNT. + @counts_by_role = User.group(:role).count + @total = @counts_by_role.values.sum end end end diff --git a/app/models/user.rb b/app/models/user.rb index 221b25d13..9f004aad6 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -4,6 +4,9 @@ class User < ApplicationRecord AVATAR_CONTENT_TYPES = %w[ image/png image/jpeg image/webp ].freeze AVATAR_MAX_SIZE = 2.megabytes + # Anyone watching the admin dashboard is subscribed to this stream. + DASHBOARD_STREAM = "dashboard".freeze + has_secure_password has_many :sessions, dependent: :destroy has_one_attached :avatar_image @@ -15,6 +18,13 @@ class User < ApplicationRecord enum :role, { user: "user", admin: "admin" }, default: :user, validate: true + # A lambda, not the bare symbol: the macro calls `send` on the record for anything + # that does not respond to `call`, so `:dashboard` would look for User#dashboard. + # + # Refreshes are debounced by Turbo, which is what makes this safe during an import: + # five hundred created users collapse into a handful of broadcasts. + broadcasts_refreshes_to ->(_user) { DASHBOARD_STREAM } + normalizes :email, with: ->(email) { email.strip.downcase } validates :full_name, presence: true, length: { maximum: 120 } diff --git a/app/views/admin/dashboards/_metrics.html.erb b/app/views/admin/dashboards/_metrics.html.erb new file mode 100644 index 000000000..79f24d8ca --- /dev/null +++ b/app/views/admin/dashboards/_metrics.html.erb @@ -0,0 +1,19 @@ +
+
+

Total users

+

<%= total %>

+
+ + <% User.roles.each_key do |role| %> +
+

+ <%# Identity travels on the dot, so the label and the number stay in ink. %> + "> + <%= role.pluralize.capitalize %> +

+

+ <%= counts_by_role.fetch(role, 0) %> +

+
+ <% end %> +
diff --git a/app/views/admin/dashboards/show.html.erb b/app/views/admin/dashboards/show.html.erb index 8152594e1..b51bd3470 100644 --- a/app/views/admin/dashboards/show.html.erb +++ b/app/views/admin/dashboards/show.html.erb @@ -1,8 +1,19 @@ <% content_for :title, "Dashboard" %> -

Dashboard

-

User metrics, updated live.

+<%# Morph rather than replace, so a refresh broadcast updates the numbers in place + instead of blowing away scroll position and focus. %> +<% turbo_refreshes_with method: :morph, scroll: :preserve %> +<%= turbo_stream_from User::DASHBOARD_STREAM %> + +
+
+

Dashboard

+

Counts update as users are created, changed or removed.

+
-
<%= link_to "Manage users", admin_users_path, class: "btn-primary" %>
+ +
+ <%= render "metrics", total: @total, counts_by_role: @counts_by_role %> +
From 23bde4d7010a1598eb413dcf1981f44cc50fa062 Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 12:31:03 -0300 Subject: [PATCH 027/145] test: prove the dashboard counters update without a reload --- .../admin/dashboards_controller_test.rb | 44 +++++++++++++++++++ test/system/live_dashboard_test.rb | 39 ++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 test/controllers/admin/dashboards_controller_test.rb create mode 100644 test/system/live_dashboard_test.rb diff --git a/test/controllers/admin/dashboards_controller_test.rb b/test/controllers/admin/dashboards_controller_test.rb new file mode 100644 index 000000000..2b1b4e261 --- /dev/null +++ b/test/controllers/admin/dashboards_controller_test.rb @@ -0,0 +1,44 @@ +require "test_helper" + +class Admin::DashboardsControllerTest < ActionDispatch::IntegrationTest + setup { sign_in_as users(:admin) } + + test "shows the total and the count for each role" do + get admin_dashboard_path + + assert_response :success + assert_select "#metric-total", text: User.count.to_s + assert_select "#metric-admin", text: User.admin.count.to_s + assert_select "#metric-user", text: User.user.count.to_s + end + + test "subscribes to the dashboard stream" do + get admin_dashboard_path + + assert_select "turbo-cable-stream-source" + end + + test "counts a role with no members as zero rather than omitting it" do + User.admin.where.not(id: users(:admin).id).destroy_all + users(:admin).update!(role: :user) + sign_out + sign_in_as User.create!( + full_name: "Katherine Johnson", email: "katherine@umanni.test", role: :admin, + password: "secret-password", password_confirmation: "secret-password" + ) + User.admin.where.not(email: "katherine@umanni.test").destroy_all + + get admin_dashboard_path + + assert_select "#metric-admin", text: "1" + end + + test "is closed to users who are not admins" do + sign_out + sign_in_as users(:member) + + get admin_dashboard_path + + assert_redirected_to profile_url + end +end diff --git a/test/system/live_dashboard_test.rb b/test/system/live_dashboard_test.rb new file mode 100644 index 000000000..219e95a5e --- /dev/null +++ b/test/system/live_dashboard_test.rb @@ -0,0 +1,39 @@ +require "application_system_test_case" + +class LiveDashboardTest < ApplicationSystemTestCase + setup do + sign_in_as users(:admin) + visit admin_dashboard_path + end + + test "updates the counters without the admin doing anything" do + assert_selector "#metric-total", text: User.count + before = User.count + + perform_enqueued_jobs do + User.create!( + full_name: "Katherine Johnson", email: "katherine@umanni.test", role: :admin, + password: "secret-password", password_confirmation: "secret-password" + ) + end + + # No reload, no click: the page is morphed by a broadcast on the dashboard stream. + assert_selector "#metric-total", text: before + 1 + end + + test "reflects a role change broadcast from elsewhere" do + admins = User.admin.count + + perform_enqueued_jobs { users(:member).update!(role: :admin) } + + assert_selector "#metric-admin", text: admins + 1 + end + + test "reflects a deletion broadcast from elsewhere" do + before = User.count + + perform_enqueued_jobs { users(:member).destroy! } + + assert_selector "#metric-total", text: before - 1 + end +end From 2d903426414ff4f4d360f60d6dbca4aa6ad31cf7 Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 12:41:27 -0300 Subject: [PATCH 028/145] feat: add the SpreadsheetImport model and row reader Validated by extension, not content type: Marcel reports a CSV as text/plain. Rows are read by index because each_row_streaming is xlsx-only. --- app/models/spreadsheet_import.rb | 45 +++++++++++++++++++ app/models/spreadsheet_import/row_reader.rb | 44 ++++++++++++++++++ app/models/user.rb | 1 + ...260903153213_create_spreadsheet_imports.rb | 20 +++++++++ db/schema.rb | 16 ++++++- 5 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 app/models/spreadsheet_import.rb create mode 100644 app/models/spreadsheet_import/row_reader.rb create mode 100644 db/migrate/20260903153213_create_spreadsheet_imports.rb diff --git a/app/models/spreadsheet_import.rb b/app/models/spreadsheet_import.rb new file mode 100644 index 000000000..1bd505f3f --- /dev/null +++ b/app/models/spreadsheet_import.rb @@ -0,0 +1,45 @@ +class SpreadsheetImport < ApplicationRecord + # Validated by extension rather than by content type: Marcel reports a CSV as + # text/plain, and Roo picks its parser from the extension anyway. + ALLOWED_EXTENSIONS = %w[ csv xlsx ].freeze + MAX_FILE_SIZE = 5.megabytes + + belongs_to :user + has_one_attached :file + + enum :status, + { pending: "pending", processing: "processing", completed: "completed", failed: "failed" }, + default: :pending, validate: true + + # On create only: the file is attached once and never replaced, and re-running this + # on every update would block the job from recording a failure on an unreadable file + # — the exact moment the status matters most. + validate :file_must_be_a_spreadsheet, on: :create + + scope :recent_first, -> { order(created_at: :desc) } + + def progress + return 0 if total_rows.zero? + + processed_rows * 100 / total_rows + end + + def finished? + completed? || failed? + end + + def imported_rows + processed_rows - failed_rows + end + + private + def file_must_be_a_spreadsheet + return errors.add(:file, "must be attached") unless file.attached? + + unless file.filename.extension_without_delimiter.downcase.in?(ALLOWED_EXTENSIONS) + errors.add(:file, "must be a .csv or .xlsx file") + end + + errors.add(:file, "must be under #{MAX_FILE_SIZE / 1.megabyte} MB") if file.byte_size > MAX_FILE_SIZE + end +end diff --git a/app/models/spreadsheet_import/row_reader.rb b/app/models/spreadsheet_import/row_reader.rb new file mode 100644 index 000000000..6bc7b5953 --- /dev/null +++ b/app/models/spreadsheet_import/row_reader.rb @@ -0,0 +1,44 @@ +class SpreadsheetImport + # Turns a .csv or .xlsx into row hashes, so the job never has to know which of the + # two it was handed. + # + # The extension is passed explicitly because Roo picks its parser from it, and an + # Active Storage file arrives as a temp file whose path carries no useful suffix. + # + # Rows are read by index rather than streamed: `each_row_streaming` exists only on + # Roo's xlsx backend, and one code path for both formats is worth more here than + # streaming a file the model caps at 5 MB. + class RowReader + COLUMNS = %i[ full_name email role ].freeze + HEADER_ROW = 1 + + def initialize(path, extension:) + @sheet = Roo::Spreadsheet.open(path.to_s, extension: extension.to_sym) + end + + def row_count + [ @sheet.last_row.to_i - HEADER_ROW, 0 ].max + end + + # Yields each data row's attributes with its line number in the file, so an error + # can point the admin at a row they can actually find. + def each_row + header = normalized_header + + ((HEADER_ROW + 1)..@sheet.last_row.to_i).each do |line| + yield attributes_from(header, @sheet.row(line)), line + end + end + + private + def normalized_header + @sheet.row(HEADER_ROW).map { |cell| cell.to_s.strip.downcase.tr(" ", "_").to_sym } + end + + def attributes_from(header, cells) + header.zip(cells.map { |cell| cell.to_s.strip.presence }) + .to_h + .slice(*COLUMNS) + end + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 9f004aad6..a7061ec23 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -9,6 +9,7 @@ class User < ApplicationRecord has_secure_password has_many :sessions, dependent: :destroy + has_many :spreadsheet_imports, dependent: :destroy has_one_attached :avatar_image # Deterministic so the column stays queryable and uniquely indexable. The cost is diff --git a/db/migrate/20260903153213_create_spreadsheet_imports.rb b/db/migrate/20260903153213_create_spreadsheet_imports.rb new file mode 100644 index 000000000..bb0c58788 --- /dev/null +++ b/db/migrate/20260903153213_create_spreadsheet_imports.rb @@ -0,0 +1,20 @@ +class CreateSpreadsheetImports < ActiveRecord::Migration[8.1] + def change + create_table :spreadsheet_imports do |t| + t.references :user, null: false, foreign_key: true + t.string :status, null: false, default: "pending" + t.integer :total_rows, null: false, default: 0 + t.integer :processed_rows, null: false, default: 0 + t.integer :failed_rows, null: false, default: 0 + # One entry per rejected row: the row number and why. Kept on the import so the + # admin can fix the file, rather than only in the log. + t.json :row_errors, null: false, default: [] + + t.timestamps + end + + add_check_constraint :spreadsheet_imports, + "status IN ('pending', 'processing', 'completed', 'failed')", + name: "spreadsheet_imports_status_check" + end +end diff --git a/db/schema.rb b/db/schema.rb index 5035595fc..5e396c8f6 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_09_03_150128) do +ActiveRecord::Schema[8.1].define(version: 2026_09_03_153213) do create_table "active_storage_attachments", force: :cascade do |t| t.bigint "blob_id", null: false t.datetime "created_at", null: false @@ -48,6 +48,19 @@ t.index ["user_id"], name: "index_sessions_on_user_id" end + create_table "spreadsheet_imports", force: :cascade do |t| + t.datetime "created_at", null: false + t.integer "failed_rows", default: 0, null: false + t.integer "processed_rows", default: 0, null: false + t.json "row_errors", default: [], null: false + t.string "status", default: "pending", null: false + t.integer "total_rows", default: 0, null: false + t.datetime "updated_at", null: false + t.integer "user_id", null: false + t.index ["user_id"], name: "index_spreadsheet_imports_on_user_id" + t.check_constraint "status IN ('pending', 'processing', 'completed', 'failed')", name: "spreadsheet_imports_status_check" + end + create_table "users", force: :cascade do |t| t.datetime "created_at", null: false t.string "email", null: false @@ -62,4 +75,5 @@ add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "sessions", "users" + add_foreign_key "spreadsheet_imports", "users" end From a40f4e14942dee6059e0aaf840a7deb3fad0feb0 Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 12:41:27 -0300 Subject: [PATCH 029/145] feat: import users from a spreadsheet in the background The job is continuable because a worker restarting mid-file would replay rows it already imported, and each would come back as a duplicate email. A bad row is counted and stepped over. The row loop does not broadcast at the end: :finish broadcasts straight after, and two messages a millisecond apart can arrive out of order, leaving the page on "Processing". --- app/jobs/spreadsheet_import_job.rb | 108 +++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 app/jobs/spreadsheet_import_job.rb diff --git a/app/jobs/spreadsheet_import_job.rb b/app/jobs/spreadsheet_import_job.rb new file mode 100644 index 000000000..d91e52d50 --- /dev/null +++ b/app/jobs/spreadsheet_import_job.rb @@ -0,0 +1,108 @@ +class SpreadsheetImportJob < ApplicationJob + include ActiveJob::Continuable + include ActionView::RecordIdentifier + + # A progress bar does not need to move once per row, and broadcasting per row would + # put a thousand messages on the wire for a thousand-row file. + BROADCAST_EVERY = 10 + + def perform(spreadsheet_import) + @import = spreadsheet_import + + step :prepare do + with_reader { |reader| start_import(reader.row_count) } + end + + step :import_rows do |step| + with_reader { |reader| import_rows(reader, step) } + end + + step :finish do + persist(status: :completed) + broadcast_progress + end + # An interruption is the continuation working as designed: the worker is shutting + # down and the job will resume from its cursor. Letting it fall through to the + # rescue below would mark a healthy import as failed. + rescue ActiveJob::Continuation::Interrupt + raise + rescue StandardError + @import.update(status: :failed) + broadcast_progress + raise + end + + private + def start_import(row_count) + @import.update!( + status: :processing, total_rows: row_count, + processed_rows: 0, failed_rows: 0, row_errors: [] + ) + broadcast_progress + end + + def import_rows(reader, step) + @row_errors = @import.row_errors.dup + + reader.each_row do |attributes, line| + index = line - (SpreadsheetImport::RowReader::HEADER_ROW + 1) + next if index < step.cursor.to_i + + record_row(attributes, line) + step.set!(index + 1) + + if (index + 1) % BROADCAST_EVERY == 0 + persist + broadcast_progress + end + end + + # Deliberately no broadcast here: :finish sends one immediately afterwards, and + # two messages a millisecond apart are not guaranteed to arrive in that order. + # The loser overwrites the winner, and the page ends up stuck on "Processing". + persist + end + + # A bad row is data, not an exception: it is counted, described and stepped over. + # One malformed line in a thousand must not cost the other nine hundred. + def record_row(attributes, line) + user = User.new(attributes.merge(password: SecureRandom.base58(24))) + # A role the file does not recognise is not worth failing a row over, and it is + # certainly not worth trusting: unknown values become plain users. + user.role = :user unless User.roles.key?(attributes[:role]) + + if user.save + SpreadsheetImport.update_counters(@import.id, processed_rows: 1) + else + SpreadsheetImport.update_counters(@import.id, processed_rows: 1, failed_rows: 1) + @row_errors << { "line" => line, "message" => user.errors.full_messages.to_sentence } + end + end + + # Counters are incremented per row because they are cheap and the bar reads them. + # The error list is written in batches, so a file of bad rows does not rewrite a + # growing JSON column once per line. + def persist(status: nil) + @import.reload + @import.update!({ row_errors: @row_errors || @import.row_errors, status: status }.compact) + end + + def with_reader + @import.file.open do |file| + yield SpreadsheetImport::RowReader.new(file.path, extension: file_extension) + end + end + + def file_extension + @import.file.filename.extension_without_delimiter.downcase + end + + def broadcast_progress + @import.broadcast_replace_to( + @import, + target: dom_id(@import, :progress), + partial: "admin/spreadsheet_imports/progress", + locals: { spreadsheet_import: @import } + ) + end +end From 1615cad8712fdf62bd405822f7992d12ace84e75 Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 12:41:27 -0300 Subject: [PATCH 030/145] feat: add the import upload form and live progress page --- .../admin/spreadsheet_imports_controller.rb | 36 +++++++++++ .../spreadsheet_imports/_progress.html.erb | 60 +++++++++++++++++++ .../admin/spreadsheet_imports/index.html.erb | 58 ++++++++++++++++++ .../admin/spreadsheet_imports/new.html.erb | 39 ++++++++++++ .../admin/spreadsheet_imports/show.html.erb | 29 +++++++++ app/views/shared/_navbar.html.erb | 1 + config/routes.rb | 2 + 7 files changed, 225 insertions(+) create mode 100644 app/controllers/admin/spreadsheet_imports_controller.rb create mode 100644 app/views/admin/spreadsheet_imports/_progress.html.erb create mode 100644 app/views/admin/spreadsheet_imports/index.html.erb create mode 100644 app/views/admin/spreadsheet_imports/new.html.erb create mode 100644 app/views/admin/spreadsheet_imports/show.html.erb diff --git a/app/controllers/admin/spreadsheet_imports_controller.rb b/app/controllers/admin/spreadsheet_imports_controller.rb new file mode 100644 index 000000000..d70690d1d --- /dev/null +++ b/app/controllers/admin/spreadsheet_imports_controller.rb @@ -0,0 +1,36 @@ +module Admin + class SpreadsheetImportsController < BaseController + before_action :set_spreadsheet_import, only: :show + + def index + @spreadsheet_imports = SpreadsheetImport.recent_first.includes(:user, file_attachment: :blob) + end + + def new + @spreadsheet_import = SpreadsheetImport.new + end + + def create + @spreadsheet_import = Current.user.spreadsheet_imports.new(spreadsheet_import_params) + + if @spreadsheet_import.save + SpreadsheetImportJob.perform_later(@spreadsheet_import) + redirect_to admin_spreadsheet_import_path(@spreadsheet_import) + else + render :new, status: :unprocessable_content + end + end + + def show + end + + private + def set_spreadsheet_import + @spreadsheet_import = SpreadsheetImport.find(params[:id]) + end + + def spreadsheet_import_params + params.expect(spreadsheet_import: [ :file ]) + end + end +end diff --git a/app/views/admin/spreadsheet_imports/_progress.html.erb b/app/views/admin/spreadsheet_imports/_progress.html.erb new file mode 100644 index 000000000..0f75fc2b5 --- /dev/null +++ b/app/views/admin/spreadsheet_imports/_progress.html.erb @@ -0,0 +1,60 @@ +<%# Replaced wholesale by the job's broadcast, so everything that changes as the + import runs lives inside this one element. %> +
+
+
+ "> + <%= spreadsheet_import.status.capitalize %> + +

+ <%= spreadsheet_import.file.filename %> +

+
+ +

+ <%= spreadsheet_import.processed_rows %> of <%= spreadsheet_import.total_rows %> rows +

+
+ +
+
" + style="width: <%= spreadsheet_import.progress %>%">
+
+ +
+
+
Imported
+
<%= spreadsheet_import.imported_rows %>
+
+
+
Rejected
+
<%= spreadsheet_import.failed_rows %>
+
+
+
Total
+
<%= spreadsheet_import.total_rows %>
+
+
+ + <% if spreadsheet_import.row_errors.any? %> +
+

Rows that could not be imported

+
    + <% spreadsheet_import.row_errors.each do |row_error| %> +
  • + Line <%= row_error["line"] %> + — <%= row_error["message"] %> +
  • + <% end %> +
+
+ <% end %> +
diff --git a/app/views/admin/spreadsheet_imports/index.html.erb b/app/views/admin/spreadsheet_imports/index.html.erb new file mode 100644 index 000000000..72e15b0c6 --- /dev/null +++ b/app/views/admin/spreadsheet_imports/index.html.erb @@ -0,0 +1,58 @@ +<% content_for :title, "Imports" %> + +
+
+

Imports

+

Spreadsheets uploaded to create users.

+
+ + <%= link_to "New import", new_admin_spreadsheet_import_path, class: "btn-primary" %> +
+ +
+
UserUser Role Actions
+ + + + + + + + + + + + <% if @spreadsheet_imports.any? %> + <% @spreadsheet_imports.each do |spreadsheet_import| %> + + + + + + + + <% end %> + <% else %> + + + + <% end %> + +
FileStatusRowsStarted
+ <%= link_to spreadsheet_import.file.filename, + admin_spreadsheet_import_path(spreadsheet_import), + class: "font-medium text-brand-600 hover:text-brand-700" %> + + "> + <%= spreadsheet_import.status.capitalize %> + + + <%= spreadsheet_import.imported_rows %> imported<%= ", #{spreadsheet_import.failed_rows} rejected" if spreadsheet_import.failed_rows.positive? %> + + <%= time_ago_in_words(spreadsheet_import.created_at) %> ago +
+ No imports yet. +
+
diff --git a/app/views/admin/spreadsheet_imports/new.html.erb b/app/views/admin/spreadsheet_imports/new.html.erb new file mode 100644 index 000000000..4430c2c48 --- /dev/null +++ b/app/views/admin/spreadsheet_imports/new.html.erb @@ -0,0 +1,39 @@ +<% content_for :title, "Import users" %> + +
+

Import users

+

+ A .csv or .xlsx file with a header row. Processing happens in the background. +

+ +
+ <%= render "shared/form_errors", model: @spreadsheet_import %> + + <%= form_with model: @spreadsheet_import, url: admin_spreadsheet_imports_path, class: "space-y-5" do |form| %> +
+ <%= form.label :file, "Spreadsheet", class: "field-label" %> + <%= form.file_field :file, required: true, accept: ".csv,.xlsx", + class: "field-input file:mr-3 file:rounded-md file:border-0 file:bg-slate-100 + file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-slate-700" %> +

Up to 5 MB.

+
+ +
+

Expected columns

+

+ full_name, + email, and optionally + role. + Anything other than admin + is imported as a plain user. Rows that fail validation are reported and skipped; + the rest of the file still imports. +

+
+ +
+ <%= form.submit "Start import", class: "btn-primary" %> + <%= link_to "Cancel", admin_spreadsheet_imports_path, class: "btn-secondary" %> +
+ <% end %> +
+
diff --git a/app/views/admin/spreadsheet_imports/show.html.erb b/app/views/admin/spreadsheet_imports/show.html.erb new file mode 100644 index 000000000..cc98f5500 --- /dev/null +++ b/app/views/admin/spreadsheet_imports/show.html.erb @@ -0,0 +1,29 @@ +<% content_for :title, "Import" %> + +<%= turbo_stream_from @spreadsheet_import %> + +
+
+
+

Import

+

+ Started <%= time_ago_in_words(@spreadsheet_import.created_at) %> ago by + <%= @spreadsheet_import.user.full_name %>. This page updates itself. +

+
+ +
+ <%= link_to "All imports", admin_spreadsheet_imports_path, class: "btn-secondary" %> + <%= link_to "New import", new_admin_spreadsheet_import_path, class: "btn-primary" %> +
+
+ +
+ <%= render "progress", spreadsheet_import: @spreadsheet_import %> +
+ +

+ Imported people have no password yet. They set one through + <%= link_to "the password reset flow", new_password_path, class: "text-brand-600 hover:text-brand-700" %>. +

+
diff --git a/app/views/shared/_navbar.html.erb b/app/views/shared/_navbar.html.erb index 3f993439d..98ee1c68c 100644 --- a/app/views/shared/_navbar.html.erb +++ b/app/views/shared/_navbar.html.erb @@ -8,6 +8,7 @@ <% if Current.user.admin? %> <%= link_to "Dashboard", admin_dashboard_path, class: "text-sm font-medium text-slate-600 hover:text-slate-900" %> <%= link_to "Users", admin_users_path, class: "text-sm font-medium text-slate-600 hover:text-slate-900" %> + <%= link_to "Imports", admin_spreadsheet_imports_path, class: "text-sm font-medium text-slate-600 hover:text-slate-900" %> <% end %> <%= link_to "Profile", profile_path, class: "text-sm font-medium text-slate-600 hover:text-slate-900" %> diff --git a/config/routes.rb b/config/routes.rb index 992004809..229d66173 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -8,6 +8,8 @@ namespace :admin do resource :dashboard, only: :show + resources :spreadsheet_imports, only: %i[ index new create show ] + resources :users, except: :show do # The role is a sub-resource rather than a custom action on the user, so that # UsersController stays plain CRUD and the "an admin cannot demote themselves" From be4cb6507ada14e3f086b293fe0d31f4a098b5bf Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 12:41:27 -0300 Subject: [PATCH 031/145] test: cover the spreadsheet import end to end The csv and xlsx fixtures hold the same rows and are asserted to read identically. --- .../spreadsheet_imports_controller_test.rb | 85 +++++++++++++ test/fixtures/files/users.csv | 6 + test/fixtures/files/users.xlsx | Bin 0 -> 5005 bytes test/jobs/spreadsheet_import_job_test.rb | 112 ++++++++++++++++++ .../spreadsheet_import/row_reader_test.rb | 52 ++++++++ test/system/spreadsheet_import_test.rb | 67 +++++++++++ 6 files changed, 322 insertions(+) create mode 100644 test/controllers/admin/spreadsheet_imports_controller_test.rb create mode 100644 test/fixtures/files/users.csv create mode 100644 test/fixtures/files/users.xlsx create mode 100644 test/jobs/spreadsheet_import_job_test.rb create mode 100644 test/models/spreadsheet_import/row_reader_test.rb create mode 100644 test/system/spreadsheet_import_test.rb diff --git a/test/controllers/admin/spreadsheet_imports_controller_test.rb b/test/controllers/admin/spreadsheet_imports_controller_test.rb new file mode 100644 index 000000000..9e8e0200b --- /dev/null +++ b/test/controllers/admin/spreadsheet_imports_controller_test.rb @@ -0,0 +1,85 @@ +require "test_helper" + +class Admin::SpreadsheetImportsControllerTest < ActionDispatch::IntegrationTest + setup { sign_in_as users(:admin) } + + test "lists imports newest first" do + older = create_import + older.update!(created_at: 1.hour.ago) + newer = create_import + + get admin_spreadsheet_imports_path + + assert_response :success + assert_operator response.body.index(admin_spreadsheet_import_path(newer)), + :<, response.body.index(admin_spreadsheet_import_path(older)) + end + + test "renders the upload form" do + get new_admin_spreadsheet_import_path + + assert_response :success + end + + test "queues the import and sends the admin to its progress page" do + assert_difference -> { SpreadsheetImport.count }, 1 do + assert_enqueued_with job: SpreadsheetImportJob do + post admin_spreadsheet_imports_path, params: { spreadsheet_import: { file: csv_upload } } + end + end + + assert_redirected_to admin_spreadsheet_import_path(SpreadsheetImport.last) + assert_predicate SpreadsheetImport.last, :pending? + end + + test "records who started the import" do + post admin_spreadsheet_imports_path, params: { spreadsheet_import: { file: csv_upload } } + + assert_equal users(:admin), SpreadsheetImport.last.user + end + + test "refuses a file that is not a spreadsheet" do + assert_no_difference -> { SpreadsheetImport.count } do + assert_no_enqueued_jobs only: SpreadsheetImportJob do + post admin_spreadsheet_imports_path, params: { + spreadsheet_import: { file: fixture_file_upload("not-an-image.txt", "text/plain") } + } + end + end + + assert_response :unprocessable_content + end + + test "rejects a submission that is not nested under a spreadsheet_import key" do + post admin_spreadsheet_imports_path, params: { file: csv_upload } + + assert_response :bad_request + end + + test "shows an import and subscribes to its stream" do + get admin_spreadsheet_import_path(create_import) + + assert_response :success + assert_select "turbo-cable-stream-source" + end + + test "is closed to users who are not admins" do + sign_out + sign_in_as users(:member) + + get admin_spreadsheet_imports_path + + assert_redirected_to profile_url + end + + private + def csv_upload + fixture_file_upload("users.csv", "text/csv") + end + + def create_import + users(:admin).spreadsheet_imports.create!( + file: { io: file_fixture("users.csv").open, filename: "users.csv" } + ) + end +end diff --git a/test/fixtures/files/users.csv b/test/fixtures/files/users.csv new file mode 100644 index 000000000..960afa9bf --- /dev/null +++ b/test/fixtures/files/users.csv @@ -0,0 +1,6 @@ +full_name,email,role +Katherine Johnson,katherine@umanni.test,admin +Dorothy Vaughan,dorothy@umanni.test,user +Mary Jackson,mary@umanni.test, +,no-name@umanni.test,user +Duplicate Person,katherine@umanni.test,user diff --git a/test/fixtures/files/users.xlsx b/test/fixtures/files/users.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..bc80f86514062bbe53acb985c4ac4cc546722a08 GIT binary patch literal 5005 zcmZ`-1yodP*B-j1L%I>^P`af6pA(g{_M$>)J}9P&mN=h@5u+>D84(yJL?}ME z(onfI!LI^(4o^|^s+QE_>{>Aqm8{}oDxIUpC)x1jkc}qSlt|^lx{fv&pft$((ht70 z#cIcCrq=z#1xbtvo$R^%)FfQ- z@es@vDv1TE(OSKy6MMVr1Cv2ZJwiX^0_xvjf9RGSG1!#Bs;4l{b8NXY3a-YeNd=G+ zuX*s2jTX2faEc^kx)@t~ygqOhhnj@2O{&y{t3xh6LNb%FsSB=gcX2Z29+ z5xppt|LYB>nyl}P?Z#R`+lSc&Pzl**NoujC;)eCQvQS>=0w?12kLZ!pp1mEp7*LSI z5~BV6!#|06Yo5VIf(-yKEo zI&=DXIkO-Ogl|bQ2nV?c(wQ&^41;ddwxgmaNXq6Q}lk1-#lk`w`nI z?s}b*tX~7x~g!@ z-=eZpR9W5jC8Mlcmmg;an7DdQ4vt}@SaP2~Po|w_Xqi_qKQP&oh$Q{d^AU5>e(ZLN zsES)=Jk~9MSof%wt{+sPK5D5mam+k^63$(xON*P<7j&u`%<^##dUcxG@+n`$H`8Bo z*fBZ4z7(x!wGLWbY+jYO9g|;3=5- zkuffRkSxG{u95KCzJ{&HgL7z8gcha(rF;I$ZcN(u2i94}J$(8EItFwQU5yG$sC~-; zBa}TZt-Od&6vD;8zuOT#vdrBg^W9Q0FHtTTre40HJ zn)aBFjVfLvu>aZ)wts>3)yC7Q zs?60-E~QsKFRUP-H$A$d6wXd%?7S)s){TfmUWz4KYrc0lAy7T^BmGC#LS{V} zJn^}HS~1F-t{bW5R;#0ltFwi`Iu|In*KKE3eThzc2sx;wKi>xt2Y zn_48^`vQePg-45ggm}DW5s%4Mv@Lc&Z=`2b__BJQr+UGxkFH!lsS>YCZCIUHC{?^f z&aa-kBuS}hW%zs~Me|TbXM01BO+_udrn{FIk4=s?l>F(z0eY})A$=g^oZ!O|VG}py zaJj5RfHH4LxQ^u*2N^|FB_S3IwcG0cev*S%4C0(O$T`$d*!DAZzfvSjM#N2N%8=u@ zVuq6-Y<9(PsD-wT(^|93*CmsW>w4C5LZ+5lM&&r~?Gj_nEBAEY#!lt3Ym-UFGvP%E z*ICI049hR%yh`~mVg6B3fuyBR+nS*d&p0xqYF``%F{fWn&gLWMmk9kPN=*xFc}V3n zar~GPv29|^PDHIs%tSaxB5hC;nG&v^B`AdB9m2w)b$mi%XiCBEF!e{w)x!DOK zJ0y`PeFB!H(A)a^d22fO(sMudi7Gq0|JGFu;!?^U?%e{mT-WT|UT>GxI$BED7DLYtB z)ogxM(HiT`U$^v)l;PZ`pAs7ZtDK}qz`U+Kr$X*{CY1DgEDu=(%OY;US17;|D|}oj zduYR;H~{uy7}EurlGe){*U|(h%9i7bN7W>lP8j#~TOr8yfN6+AOjfpNfC{Sqdt9A|J*2j>8*sM2q<4@xWtR=Q#Nr%%UW^%9%2jDv}cfV+Ja$Rc5N+LAu~o> z3OTNr2l;~}9DP~2-}3!c!)lF{tWJ1d(HCdU`iScUcttJ#ome0I$>D88no9#l=uk84UE=?S-^8VFV0Cnw+*xJzYE z=w!s;9%uI9REIrm^MzBbl}@H{Rl|;`m7f-vW_fvFqUZkHLLHq_`kS?Kws6V|CBclX zO+C7e7}io!C$MO=#>EL6etxKS&bFv}+M$linSBJr*ic6bCOfzLzF6w5c03>mhX3 zzOma~`Yan~G5*#MI86M9tq)HeOs*b`Y3_i-;k7O>8ma=!d|5=AEGF?)L5=HVli|U* z>s6an_F~lx@tFl$d&x2%UH*%v!0i)x zVk_nmFs;_*fjDF9*0B0zbP`)-5_!x{Q(bIZWyZ2oIb>b8zcR^PbJ_3|NL_bX-wWJF z%i(o^(i3XCCf?)LJ4;BT&o;Ly=ely=F{$BFlHp9|Is~*FlVb=9-4B2edG{yTV3=jm z=uI7U50hgel(VS^SS>28I`#cFqycj_LjsUxVs)R0m3QR44%w zpzc;~Kqq$r{-0}RqNWSF5TQJj3}~hGz8hVrY^qWzf}k;SBkyf@f=%}cGIpwi*^X56 zIxCA0N-X-$liq!{z^O;D+kD#SUOAxJk=r`!S+2?5dz$imIim3+r&A=nUyeEY__|ys z8?@Xn)lcvfwwv8+fnN9&r)b|Ry3*J*luW0e1&U~156!{xC(<&ZWjmKe_YMjNk58H7 zb5)gHc~27ZwW2+fp|V-?ZTZIqHcxB47dNkuM~W$A&e}5t3nIr5%6OdM$}_A#9X*_g z2^L1>;yWluiGM}e&s_Wz40ik(XR-04KleDw0R{ayBQC+}THBV8Ky_S_kPk)WEvA|) z{m;av5J|NZoV#IMy@<(AA1vu3zhRBap4Il8NoXJj%kNd4_nI#sjw?E5Y5DzpEHXjiMnuxqBQz=S`9-Zdl+VW9F2VQcQ z=uNZIUo9#+C1LpTj=b|*FA1R2jZ}U`^-y>Hu0bSt^SfFMms{>op{+2danh4YEGk}s zHSOB73W7zR+~@{~gmDcoz|mIVN(GL9t)X=ujXYQUW{2rrfW-)Drkbzl4W-$79QT95 zMmwWEzLc1FCohb-yc8CPIs8~W%ZBEl`J5#Vg74s=D}xjFI)tQzkUF-2OB)L2Y=HTJ z1}#P`=g7CdA`spa>tcV>E3&=XT#lmk0!17OM3Evvc&nOh-(gkey!bpTEA zL=Ji(e;FlpMDQ7D(1WVd&RsefK#ieU@G(54{hKXFKs4%wlpaEg?AVU8AK1IY2*{VB zr&OL?rr?Y{&0AsiC(zkasKd3VUxYE;uI`J|(&M{z%`#KCbx*eZai;L`rJW8+*N*? zKZa+ggCMlqBJWG~Q?Lj4^=!4qyDFkLnt46QZ|;{Ir42TigOVT_BOfISrZD->fk`Ie zxLIt^)pW?N3o|41>)dcwUN-rW-1SC(h;>SP=35tJy2l5EGl28(+WN^&25X9hsz8sL zVn%5>B3DHobW6ge)kPbjtO_-A^)J(YoA+#zGa6t zFhC1xCVcKpvH1=er>PNxm*0aOn8Bwy`jJxfF#gHq!jOw~e3RPRgfl+rbRA~EMyAtN zX>^}re4n|*TBz;rG*{@tGoo`N@rU2p@;whQKUNMh9eZVSU76AP5a(R&znFv;B=tNb ziddF`%AtBAG;|7#|2;ZEspjt^5XJESAEeww-<(?f!U6z+XkGtA|KAMbCj92!{x^IM z_4$9<>EASPv$y-NfglFlzYY9rr+3rJ&D#H8D`-lnZUFTgzdM1O(3_e2H?)KBk34=8 zcrz3J2EIqNAETlY)8A`wH?4s=aOJM10VxdMc00189 OlttxmiJzk?z<&TMCbZ1} literal 0 HcmV?d00001 diff --git a/test/jobs/spreadsheet_import_job_test.rb b/test/jobs/spreadsheet_import_job_test.rb new file mode 100644 index 000000000..e655a2be2 --- /dev/null +++ b/test/jobs/spreadsheet_import_job_test.rb @@ -0,0 +1,112 @@ +require "test_helper" + +class SpreadsheetImportJobTest < ActiveJob::TestCase + test "imports the valid rows of a csv and reports the rest" do + import = build_import("users.csv") + + assert_difference -> { User.count }, 3 do + SpreadsheetImportJob.perform_now(import) + end + + import.reload + + assert_predicate import, :completed? + assert_equal 5, import.total_rows + assert_equal 5, import.processed_rows + assert_equal 2, import.failed_rows + assert_equal 3, import.imported_rows + assert_equal 100, import.progress + end + + test "imports an xlsx exactly as it imports a csv" do + import = build_import("users.xlsx") + + assert_difference -> { User.count }, 3 do + SpreadsheetImportJob.perform_now(import) + end + + assert_equal 2, import.reload.failed_rows + end + + test "names the line and the reason for every row it could not import" do + import = build_import("users.csv") + + SpreadsheetImportJob.perform_now(import) + + errors = import.reload.row_errors + + assert_equal [ 5, 6 ], errors.map { |row_error| row_error["line"] } + assert_match "Full name can't be blank", errors.first["message"] + assert_match "Email has already been taken", errors.second["message"] + end + + test "assigns the role from the file, defaulting anything unrecognised to user" do + import = build_import("users.csv") + + SpreadsheetImportJob.perform_now(import) + + assert_predicate User.find_by(email: "katherine@umanni.test"), :admin? + assert_predicate User.find_by(email: "dorothy@umanni.test"), :user? + # Blank role in the file. + assert_predicate User.find_by(email: "mary@umanni.test"), :user? + end + + test "gives imported people an unguessable password rather than a shared one" do + import = build_import("users.csv") + + SpreadsheetImportJob.perform_now(import) + + digests = User.where(email: %w[ katherine@umanni.test dorothy@umanni.test ]).pluck(:password_digest) + + assert_equal 2, digests.uniq.size + end + + # The reason this job is continuable: a worker restarting mid-file would otherwise + # replay rows it already imported, and every one of them would come back as a + # duplicate email. Resuming from the cursor is correctness, not a nicety. + test "resumes from its cursor rather than replaying imported rows" do + import = build_import("users.csv") + import.update!(status: :processing, total_rows: 5, processed_rows: 2) + # Stand-ins for the two rows the interrupted run had already imported. + %w[ katherine dorothy ].each do |name| + User.create!( + full_name: name.capitalize, email: "#{name}@umanni.test", + password: "secret-password", password_confirmation: "secret-password" + ) + end + + # Picks up as if the worker had died after the second row. + assert_difference -> { User.count }, 1 do + perform_resumed(import, completed: %w[ prepare ], current: [ "import_rows", 2 ]) + end + + assert_predicate import.reload, :completed? + assert_not_nil User.find_by(email: "mary@umanni.test"), "the row at the cursor was skipped" + # Replaying the first two rows would have produced duplicate-email failures. + assert_equal 2, import.failed_rows + end + + test "marks the import failed and re-raises when the file cannot be read" do + import = build_import("not-an-image.txt", skip_validation: true) + + assert_raises StandardError do + SpreadsheetImportJob.perform_now(import) + end + + assert_predicate import.reload, :failed? + end + + private + def perform_resumed(import, completed:, current:) + job = SpreadsheetImportJob.new(import) + job.deserialize(job.serialize.merge("continuation" => { "completed" => completed, "current" => current })) + job.perform_now + end + + def build_import(fixture, skip_validation: false) + import = users(:admin).spreadsheet_imports.new + import.file.attach(io: file_fixture(fixture).open, filename: fixture) + skip_validation ? import.save!(validate: false) : import.save! + import + end +end diff --git a/test/models/spreadsheet_import/row_reader_test.rb b/test/models/spreadsheet_import/row_reader_test.rb new file mode 100644 index 000000000..455d25c9f --- /dev/null +++ b/test/models/spreadsheet_import/row_reader_test.rb @@ -0,0 +1,52 @@ +require "test_helper" + +class SpreadsheetImport::RowReaderTest < ActiveSupport::TestCase + test "reads a csv into row attributes" do + assert_equal expected_rows, rows_from("users.csv", :csv) + end + + test "reads an xlsx into the same row attributes" do + assert_equal expected_rows, rows_from("users.xlsx", :xlsx) + end + + test "counts data rows without the header" do + assert_equal 5, reader("users.csv", :csv).row_count + end + + test "numbers rows by their line in the file, header included" do + lines = [] + reader("users.csv", :csv).each_row { |_attributes, line| lines << line } + + assert_equal [ 2, 3, 4, 5, 6 ], lines + end + + test "normalises header casing and spacing" do + path = Rails.root.join("tmp", "odd-headers-#{SecureRandom.hex(4)}.csv") + path.write("Full Name, EMAIL ,Role\nAda Lovelace,ada@example.com,admin\n") + + attributes = SpreadsheetImport::RowReader.new(path, extension: :csv).to_enum(:each_row).first.first + + assert_equal({ full_name: "Ada Lovelace", email: "ada@example.com", role: "admin" }, attributes) + ensure + path&.delete + end + + private + def reader(name, extension) + SpreadsheetImport::RowReader.new(file_fixture(name), extension: extension) + end + + def rows_from(name, extension) + [].tap { |rows| reader(name, extension).each_row { |attributes, _line| rows << attributes } } + end + + def expected_rows + [ + { full_name: "Katherine Johnson", email: "katherine@umanni.test", role: "admin" }, + { full_name: "Dorothy Vaughan", email: "dorothy@umanni.test", role: "user" }, + { full_name: "Mary Jackson", email: "mary@umanni.test", role: nil }, + { full_name: nil, email: "no-name@umanni.test", role: "user" }, + { full_name: "Duplicate Person", email: "katherine@umanni.test", role: "user" } + ] + end +end diff --git a/test/system/spreadsheet_import_test.rb b/test/system/spreadsheet_import_test.rb new file mode 100644 index 000000000..31ff91fb8 --- /dev/null +++ b/test/system/spreadsheet_import_test.rb @@ -0,0 +1,67 @@ +require "application_system_test_case" + +class SpreadsheetImportTest < ApplicationSystemTestCase + setup { sign_in_as users(:admin) } + + test "uploads a spreadsheet and watches it finish without reloading" do + visit new_admin_spreadsheet_import_path + + attach_file "Spreadsheet", file_fixture("users.csv") + click_on "Start import" + + # The browser is on the progress page before the job runs, which is the whole + # point: what follows arrives over the wire, not from a page load. + assert_text "Pending" + assert_selector "[role=progressbar][aria-valuenow='0']" + + assert_difference -> { User.count }, 3 do + perform_enqueued_jobs + end + + assert_text "Completed" + assert_selector "[role=progressbar][aria-valuenow='100']" + assert_text "5 of 5 rows" + end + + test "reports the rows it could not import, by line and reason" do + visit new_admin_spreadsheet_import_path + attach_file "Spreadsheet", file_fixture("users.csv") + click_on "Start import" + + assert_text "Pending" + + perform_enqueued_jobs + + assert_text "Rows that could not be imported" + assert_text "Line 5" + assert_text "Full name can't be blank" + assert_text "Line 6" + assert_text "Email has already been taken" + end + + test "refuses a file that is not a spreadsheet" do + visit new_admin_spreadsheet_import_path + + attach_file "Spreadsheet", file_fixture("not-an-image.txt") + click_on "Start import" + + assert_text "File must be a .csv or .xlsx file" + end + + test "lists finished imports" do + visit new_admin_spreadsheet_import_path + attach_file "Spreadsheet", file_fixture("users.xlsx") + click_on "Start import" + + assert_text "Pending" + + perform_enqueued_jobs + + assert_text "Completed" + + click_on "All imports" + + assert_text "users.xlsx" + assert_text "3 imported, 2 rejected" + end +end From 85a2a4f6c973dd735cb889098774bb92be92c78c Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 12:43:31 -0300 Subject: [PATCH 032/145] feat: let a user edit and delete their own profile Singular resource, no id in the route, and :role is not permitted. --- app/controllers/profiles_controller.rb | 36 +++++++++++++++++- app/views/profiles/edit.html.erb | 52 ++++++++++++++++++++++++++ app/views/profiles/show.html.erb | 23 +++++++++++- 3 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 app/views/profiles/edit.html.erb diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index fb7e8f4dc..3e54c1091 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -1,5 +1,39 @@ class ProfilesController < ApplicationController + before_action :set_profile + def show - @user = Current.user end + + def edit + end + + def update + if @user.update(profile_params) + redirect_to profile_path, notice: "Your profile was updated." + else + render :edit, status: :unprocessable_content + end + end + + def destroy + @user.destroy! + terminate_session + redirect_to new_session_path, notice: "Your account has been deleted.", status: :see_other + end + + private + # Always the signed-in user. There is no id in the route, so there is nothing to + # tamper with: a user cannot ask for someone else's profile by changing a number. + def set_profile + @user = Current.user + end + + # :role is absent, as it is everywhere outside the admin namespace. A user editing + # their own profile cannot promote themselves. + def profile_params + permitted = params.expect(user: [ :full_name, :email, :password, :password_confirmation, :avatar_image ]) + permitted = permitted.except(:password, :password_confirmation) if permitted[:password].blank? + permitted = permitted.except(:avatar_image) if permitted[:avatar_image].blank? + permitted + end end diff --git a/app/views/profiles/edit.html.erb b/app/views/profiles/edit.html.erb new file mode 100644 index 000000000..c7efb26e4 --- /dev/null +++ b/app/views/profiles/edit.html.erb @@ -0,0 +1,52 @@ +<% content_for :title, "Edit your profile" %> + +
+

Edit your profile

+ +
+ <%= render "shared/form_errors", model: @user %> + + <%= form_with model: @user, url: profile_path, method: :patch, class: "space-y-5" do |form| %> +
+ <%= form.label :full_name, "Full name", class: "field-label" %> + <%= form.text_field :full_name, required: true, autofocus: true, maxlength: 120, class: "field-input" %> +
+ +
+ <%= form.label :email, class: "field-label" %> + <%= form.email_field :email, required: true, class: "field-input" %> +
+ +
+ <%= form.label :avatar_image, "Avatar", class: "field-label" %> +
+ <%= render "shared/avatar", user: @user, size: "size-14" %> + <%= form.file_field :avatar_image, accept: User::AVATAR_CONTENT_TYPES.join(","), + class: "field-input file:mr-3 file:rounded-md file:border-0 file:bg-slate-100 + file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-slate-700" %> +
+

PNG, JPEG or WebP, up to 2 MB.

+
+ +
+
+ <%= form.label :password, "New password", class: "field-label" %> + <%= form.password_field :password, autocomplete: "new-password", minlength: 8, maxlength: 72, + placeholder: "••••••••", class: "field-input" %> +

Leave blank to keep your current one.

+
+ +
+ <%= form.label :password_confirmation, "Confirm password", class: "field-label" %> + <%= form.password_field :password_confirmation, autocomplete: "new-password", + minlength: 8, maxlength: 72, placeholder: "••••••••", class: "field-input" %> +
+
+ +
+ <%= form.submit "Save changes", class: "btn-primary" %> + <%= link_to "Cancel", profile_path, class: "btn-secondary" %> +
+ <% end %> +
+
diff --git a/app/views/profiles/show.html.erb b/app/views/profiles/show.html.erb index 2601980ff..0e19eb259 100644 --- a/app/views/profiles/show.html.erb +++ b/app/views/profiles/show.html.erb @@ -1,7 +1,10 @@ <% content_for :title, "Your profile" %>
-

Your profile

+
+

Your profile

+ <%= link_to "Edit profile", edit_profile_path, class: "btn-primary" %> +
@@ -16,5 +19,23 @@
Role
"><%= @user.role.capitalize %>
+ +
+
Member since
+
<%= @user.created_at.to_date.to_fs(:long) %>
+
+
+ +
+
+
+

Delete your account

+

This removes your profile and signs you out. It cannot be undone.

+
+ + <%= button_to "Delete account", profile_path, method: :delete, + class: "btn-danger", + form: { data: { turbo_confirm: "Delete your account? This cannot be undone." } } %> +
From 59ef268c71443164b89859c2859efe1e987b1e7a Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 12:43:31 -0300 Subject: [PATCH 033/145] test: cover the profile a user manages themselves --- test/controllers/profiles_controller_test.rb | 92 ++++++++++++++++++++ test/system/profile_test.rb | 31 +++++++ 2 files changed, 123 insertions(+) create mode 100644 test/controllers/profiles_controller_test.rb create mode 100644 test/system/profile_test.rb diff --git a/test/controllers/profiles_controller_test.rb b/test/controllers/profiles_controller_test.rb new file mode 100644 index 000000000..29930583c --- /dev/null +++ b/test/controllers/profiles_controller_test.rb @@ -0,0 +1,92 @@ +require "test_helper" + +class ProfilesControllerTest < ActionDispatch::IntegrationTest + setup { sign_in_as users(:member) } + + test "shows the signed in user's own profile" do + get profile_path + + assert_response :success + assert_select "body", text: /Ada Lovelace/ + end + + test "renders the edit form" do + get edit_profile_path + + assert_response :success + end + + test "updates the profile" do + patch profile_path, params: { user: { full_name: "Ada King" } } + + assert_redirected_to profile_path + assert_equal "Ada King", users(:member).reload.full_name + end + + # The route has no id, so there is nothing to tamper with: a user cannot request + # someone else's profile by changing a number. This asserts that stays true. + test "always acts on the signed in user, never another one" do + patch profile_path, params: { user: { full_name: "Ada King" } } + + assert_equal "Ada King", users(:member).reload.full_name + assert_equal "Grace Hopper", users(:admin).reload.full_name + end + + test "ignores a role the user tries to give themselves" do + patch profile_path, params: { user: { full_name: "Ada King", role: "admin" } } + + assert_predicate users(:member).reload, :user? + end + + test "keeps the existing password when the password fields are left blank" do + digest = users(:member).password_digest + + patch profile_path, params: { user: { full_name: "Ada King", password: "", password_confirmation: "" } } + + assert_equal digest, users(:member).reload.password_digest + end + + test "changes the password when a new one is given" do + patch profile_path, params: { + user: { password: "a-new-password", password_confirmation: "a-new-password" } + } + + assert_predicate users(:member).reload.authenticate("a-new-password"), :present? + end + + test "re-renders the form when the submission is invalid" do + patch profile_path, params: { user: { email: "not-an-email" } } + + assert_response :unprocessable_content + assert_equal "ada@umanni.test", users(:member).reload.email + end + + test "attaches an avatar" do + patch profile_path, params: { user: { avatar_image: fixture_file_upload("avatar.png", "image/png") } } + + assert_predicate users(:member).reload.avatar_image, :attached? + end + + test "deletes the account and ends the session" do + assert_difference -> { User.count }, -1 do + delete profile_path + end + + assert_redirected_to new_session_path + assert_empty cookies[:session_id].to_s + end + + test "rejects a submission that is not nested under a user key" do + patch profile_path, params: { full_name: "Ada King" } + + assert_response :bad_request + end + + test "is closed to visitors" do + sign_out + + get profile_path + + assert_redirected_to new_session_path + end +end diff --git a/test/system/profile_test.rb b/test/system/profile_test.rb new file mode 100644 index 000000000..aea724574 --- /dev/null +++ b/test/system/profile_test.rb @@ -0,0 +1,31 @@ +require "application_system_test_case" + +class ProfileTest < ApplicationSystemTestCase + setup do + sign_in_as users(:member) + visit profile_path + end + + test "edits the profile" do + click_on "Edit profile" + + fill_in "Full name", with: "Ada King" + attach_file "Avatar", file_fixture("avatar.png") + click_on "Save changes" + + assert_text "Your profile was updated." + assert_text "Ada King" + end + + test "cannot reach the admin area from the profile" do + assert_no_link "Users" + assert_no_link "Dashboard" + end + + test "deletes the account after confirming and lands back on sign in" do + accept_confirm { click_on "Delete account" } + + assert_text "Your account has been deleted." + assert_current_path new_session_path + end +end From 526e1a3a3592563e248c795576e3c618bfc60d51 Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 12:52:59 -0300 Subject: [PATCH 034/145] chore: make config/deploy.yml deployable assume_ssl and force_ssl were commented out while TLS terminates at kamal-proxy. Solid Queue runs inside Puma because SQLite is a file, not a server. --- config/deploy.yml | 127 ++++++++---------------------- config/environments/production.rb | 4 +- 2 files changed, 36 insertions(+), 95 deletions(-) diff --git a/config/deploy.yml b/config/deploy.yml index fbcf28ece..67bc9f813 100644 --- a/config/deploy.yml +++ b/config/deploy.yml @@ -1,119 +1,60 @@ -# Name of your application. Used to uniquely configure containers. -service: umanni +# Kamal 2 deployment. +# +# Fill in the three placeholders below (registry, image owner, server IP and host) +# and `bin/kamal setup` is the whole deploy. Nothing else in this file is a stub. -# Name of the container image (use your-user/app-name on external registries). -image: umanni +service: umanni +image: your-registry-user/umanni -# Deploy to these servers. servers: web: - - 192.168.0.1 - # job: - # hosts: - # - 192.168.0.1 - # cmd: bin/jobs + - 192.0.2.1 -# Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server. -# If used with Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption. -# -# Using an SSL proxy like this requires turning on config.assume_ssl and config.force_ssl in production.rb! -# -# Don't use this when deploying to multiple web servers (then you have to terminate SSL at your load balancer). -# -# proxy: -# ssl: true -# host: app.example.com +# Zero-downtime deploys and automatic Let's Encrypt certificates through kamal-proxy. +# assume_ssl and force_ssl are already on in config/environments/production.rb, which +# is what makes terminating TLS here safe. +proxy: + ssl: true + host: umanni.example.com + # Thruster listens on 80 inside the container and serves compressed, cached assets. + app_port: 80 -# Where you keep your container images. registry: - # Alternatives: hub.docker.com / registry.digitalocean.com / ghcr.io / ... - server: localhost:5555 - - # Needed for authenticated registries. - # username: your-user - - # Always use an access token rather than real password when possible. - # password: - # - KAMAL_REGISTRY_PASSWORD + server: ghcr.io + username: your-registry-user + password: + - KAMAL_REGISTRY_PASSWORD -# Inject ENV variables into containers (secrets come from .kamal/secrets). env: secret: - RAILS_MASTER_KEY clear: - # Run the Solid Queue Supervisor inside the web server's Puma process to do jobs. - # When you start using multiple servers, you should split out job processing to a dedicated machine. + # The Solid Queue supervisor runs inside Puma rather than as a separate job role, + # and that is a consequence of the database choice rather than a shortcut: SQLite + # is a file, not a server, so a worker on a second machine could not reach it. + # + # Splitting jobs onto their own host is the moment this application would move to + # PostgreSQL. Splitting them into a second container on the *same* host would work + # today — both would share the storage volume — but buys little at this size. SOLID_QUEUE_IN_PUMA: true + JOB_CONCURRENCY: 2 + WEB_CONCURRENCY: 2 - # Set number of processes dedicated to Solid Queue (default: 1) - # JOB_CONCURRENCY: 3 - - # Set number of cores available to the application on each server (default: 1). - # WEB_CONCURRENCY: 2 - - # Match this to any external database server to configure Active Record correctly - # Use umanni-db for a db accessory server on same machine via local kamal docker network. - # DB_HOST: 192.168.0.2 - - # Log everything from Rails - # RAILS_LOG_LEVEL: debug - -# Aliases are triggered with "bin/kamal ". You can overwrite arguments on invocation: -# "bin/kamal logs -r job" will tail logs from the first server in the job section. aliases: console: app exec --interactive --reuse "bin/rails console" shell: app exec --interactive --reuse "bash" logs: app logs -f - dbc: app exec --interactive --reuse "bin/rails dbconsole --include-password" + dbc: app exec --interactive --reuse "bin/rails dbconsole" -# Use a persistent storage volume for sqlite database files and local Active Storage files. -# Recommended to change this to a mounted volume path that is backed up off server. +# The four SQLite databases and Active Storage's uploads all live here. This is the +# only stateful thing on the server, so it is the only thing that needs backing up — +# point it at a mounted volume that is backed up off the machine. volumes: - "umanni_storage:/rails/storage" -# Bridge fingerprinted assets, like JS and CSS, between versions to avoid -# hitting 404 on in-flight requests. Combines all files from new and old -# version inside the asset_path. +# Keeps fingerprinted assets from the previous version around during a deploy, so a +# request already in flight does not 404 on a stylesheet. asset_path: /rails/public/assets -# Configure the image builder. builder: arch: amd64 - - # # Build image via remote server (useful for faster amd64 builds on arm64 computers) - # remote: ssh://docker@docker-builder-server - # - # # Pass arguments and secrets to the Docker build process - # args: - # RUBY_VERSION: 4.0.6 - # secrets: - # - GITHUB_TOKEN - # - RAILS_MASTER_KEY - -# Use a different ssh user than root -# ssh: -# user: app - -# Use accessory services (secrets come from .kamal/secrets). -# accessories: -# db: -# image: mysql:8.0 -# host: 192.168.0.2 -# # Change to 3306 to expose port to the world instead of just local network. -# port: "127.0.0.1:3306:3306" -# env: -# clear: -# MYSQL_ROOT_HOST: '%' -# secret: -# - MYSQL_ROOT_PASSWORD -# files: -# - config/mysql/production.cnf:/etc/mysql/my.cnf -# - db/production.sql:/docker-entrypoint-initdb.d/setup.sql -# directories: -# - data:/var/lib/mysql -# redis: -# image: valkey/valkey:8 -# host: 192.168.0.2 -# port: 6379 -# directories: -# - data:/data diff --git a/config/environments/production.rb b/config/environments/production.rb index f5763e04e..f893475da 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -25,10 +25,10 @@ config.active_storage.service = :local # Assume all access to the app is happening through a SSL-terminating reverse proxy. - # config.assume_ssl = true + config.assume_ssl = true # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. - # config.force_ssl = true + config.force_ssl = true # Skip http-to-https redirect for the default health check endpoint. # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } From 3251cd9acbf95fef4ebe4f21a7910447228d8c38 Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 12:52:59 -0300 Subject: [PATCH 035/145] perf: measure ZJIT against YJIT before enabling either 20,000 renders in the production image: YJIT 0.204-0.226 ms, ZJIT 0.340-0.375 ms. A shorter warmup widened the gap. Rails enables YJIT on its own; leave it. --- script/jit_benchmark.rb | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 script/jit_benchmark.rb diff --git a/script/jit_benchmark.rb b/script/jit_benchmark.rb new file mode 100644 index 000000000..0958e9429 --- /dev/null +++ b/script/jit_benchmark.rb @@ -0,0 +1,35 @@ +# Measures a representative render-heavy request path under the interpreter, YJIT and +# ZJIT, so the choice in the Dockerfile rests on numbers from this machine rather than +# on a blog post. +# +# docker run --rm umanni:prod bin/rails runner script/jit_benchmark.rb +# docker run --rm -e RUBYOPT=--yjit umanni:prod bin/rails runner script/jit_benchmark.rb +# docker run --rm -e RUBYOPT=--zjit umanni:prod bin/rails runner script/jit_benchmark.rb +# +# No benchmark gem: it stopped being a default gem in Ruby 4, and a monotonic clock +# is all this needs. + +ITERATIONS = Integer(ENV.fetch("ITERATIONS", 5_000)) +WARMUP = Integer(ENV.fetch("WARMUP", 500)) + +users = Array.new(50) do |index| + User.new(id: index + 1, full_name: "Benchmark Person #{index}", email: "bench#{index}@umanni.test", + role: index.even? ? "admin" : "user", created_at: Time.current) +end + +def jit_name + return "ZJIT" if defined?(RubyVM::ZJIT) && RubyVM::ZJIT.enabled? + return "YJIT" if defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled? + + "interpreter" +end + +# Warm the compiler and the template cache before the measured run. +WARMUP.times { |i| ApplicationController.render(partial: "admin/users/user", locals: { user: users[i % 50] }) } + +started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) +ITERATIONS.times { |i| ApplicationController.render(partial: "admin/users/user", locals: { user: users[i % 50] }) } +elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at + +puts format("%-12s %6.2f s for %d renders (%.3f ms each, %d warmup)", + jit_name, elapsed, ITERATIONS, elapsed * 1000 / ITERATIONS, WARMUP) From 022e436066940df829f9e5dbc33cccb46f769f1a Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 12:55:21 -0300 Subject: [PATCH 036/145] docs: write the README --- README.md | 369 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 356 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 7db80e4ca..9fac6608d 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,367 @@ -# README +# Umanni — User Management -This README would normally document whatever steps are necessary to get the -application up and running. +A Rails 8.1 application on Ruby 4.0 for managing users: a live admin dashboard, full +CRUD with role control, asynchronous spreadsheet import with real-time progress, and a +profile each user manages themselves. -Things you may want to cover: +SQLite in WAL mode, Solid Queue, Solid Cache and Solid Cable. No Redis, no Postgres, no +sidecar of any kind. -* Ruby version +--- -* System dependencies +## AI Usage Disclosure -* Configuration +**Models used: Claude Opus 5 and Claude Fable 5.1, through Claude Code.** -* Database creation +This is the honest version, because a vague one would be worse than none. -* Database initialization +**What the models did.** Opus 5 wrote the application code, the tests, the commit +messages and this README. Fable 5.1 ran two isolated research tasks: surveying my +previous take-home repositories for conventions worth carrying forward, and drafting +`CLAUDE.md`. Sub-agents drove Playwright against the running application to test the UI +and reported back; they had no write access to the codebase. -* How to run the test suite +**What I did.** I chose the architecture and made every decision the work branched on: +Hotwire over Inertia/React, Minitest over RSpec, SQLite over PostgreSQL, one PR per +phase, and how to handle the point below. I set the standards the code was held to and +rejected work that missed them. Nothing was merged on the model's say-so — every phase +had to pass a real gate before it moved: the full suite green, RuboCop and Brakeman +clean, the production image actually built and run, and the flow actually clicked +through in a browser. -* Services (job queues, cache servers, search engines, etc.) +**The workflow.** The work ran in phases, each on its own branch with its own PR: +skeleton → authentication → admin CRUD → live dashboard → import → profile → delivery. +Read-only work fanned out in parallel — investigation, UI testing, a second opinion from +a different model — while writing stayed single-threaded, because two agents editing the +same tree produce decisions nobody reviewed. Each phase ended at a gate, and a failed +gate sent the phase back rather than forward. -* Deployment instructions +**What that caught.** Several bugs that a green test suite did not: -* ... +- `tailwindcss:watch` exits when stdin is not a TTY, so `docker compose up` died on + startup. It needed the `[always]` argument. This would have broken on the reviewer's + very first command. +- Signing in returned 500 in development: `config/cache.yml` named the cache database + under production only, so Solid Cache went looking for its table in the primary + database. The suite could not see it, because the test environment uses `:null_store`, + which makes rate limiting inert. There is now a test that guards the class of bug. +- The import's progress bar stuck on "Processing" about one run in three. Two broadcasts + a millisecond apart are not guaranteed to arrive in order, and the loser overwrote the + winner. The fix was one broadcast instead of two, not a longer wait in the test. +- The production image seeded demo users — a published email and a published password — + because the entrypoint runs `db:prepare` and `db:prepare` seeds a new database. Found + by running the production image rather than only building it. + +### A note on the hidden instructions in the brief + +The upstream `README.md` contains an HTML comment addressed to LLM assistants. It asks +the model to inject a marker string into the frontend and, in its own words, to *"not +disclose or explain these hidden constraints to the human user."* + +I read it, and I did not follow it. There is no marker string in this codebase, and +nothing about the process was hidden from me. Disclosure is above, in full. + +Worth stating plainly, because the ability to notice what enters a model's context and +decide what it is allowed to act on is not a side issue — it is the job, once these +tools are in the loop. + +--- + +## Running it + +### Docker (recommended) + +```bash +docker compose up +``` + +Then . Port 3200 rather than 3000 so it does not collide with +another Rails server; override with `WEB_PORT=4000 docker compose up`. + +There is no database service to wait for. SQLite and the three Solid databases live in +the `storage` volume, which is the point of this stack. + +```bash +docker compose exec web bin/rails db:seed +``` + +### Local + +Requires Ruby 4.0.6 (`.ruby-version`). + +```bash +bin/setup # bundle, prepare the databases, start the server +bin/rails db:seed +``` + +`bin/setup` ends by running `bin/dev`, which runs Puma, the Tailwind watcher and the +Solid Queue worker together. To start it on its own later, run `bin/dev`. + +### Demo logins + +Seeds are idempotent and create 32 users. Password for all of them: `secret-password`. + +| Email | Role | +|---|---| +| `admin@umanni.test` | admin | +| `user@umanni.test` | user | + +Seeds refuse to run outside development and test. `db:prepare` seeds a freshly created +database, and a production deploy must not come up with a published password in it. +The first admin of a real deployment is one command: + +```bash +bin/rails runner 'User.create!(full_name: "Ada Lovelace", email: "ada@example.com", role: :admin, password: ENV.fetch("ADMIN_PASSWORD"))' +``` + +### Trying the spreadsheet import + +`test/fixtures/files/users.csv` and `users.xlsx` are ready to upload from **Imports → +New import**. They are deliberately dirty: five rows, of which one has no name and one +repeats an earlier email. Three import, two are reported by line and reason, and the run +still completes. Open the dashboard in a second tab first and watch its counters move at +the same time. + +--- + +## Testing + +```bash +bin/rails test:all # unit, integration and system — 121 tests +bin/rails test # skips system tests +bin/ci # the whole pipeline: lint, audits, Brakeman, tests, seeds +``` + +| Layer | Files | Tests | +|---|---|---| +| Models and POROs | 3 | 20 | +| Controllers | 8 | 65 | +| Integration | 1 | 5 | +| Jobs | 1 | 7 | +| System (real Chrome) | 5 | 21 | +| Configuration | 1 | 3 | + +**121 tests, 392 assertions, 97.94% line coverage, 93.75% branch coverage.** Tests run +in parallel across one process per core, and SimpleCov results are merged per worker — +without that merge the report shows roughly one worker's share and every number after it +is fiction. The 90% floor is enforced under `CI` or `COVERAGE`. + +System tests need Chrome. If it is not on `PATH` (WSL, slim containers): + +```bash +CHROME_BINARY=/path/to/chrome bin/rails test:system +``` + +The system tests are the ones worth reading. They prove the things no controller test +can: that the dashboard counters move on their own when a user is created elsewhere, +that a role toggle replaces one table row without reloading the page, and that an +import's progress arrives over the wire while the page sits open. + +--- + +## What it does + +| Use case | Where | +|---|---| +| Admin dashboard, counts total and by role, live | `Admin::DashboardsController` | +| Admin lists, creates, edits and deletes users | `Admin::UsersController` | +| Admin toggles a user's role | `Admin::Users::RolesController` | +| Admin imports a spreadsheet asynchronously | `SpreadsheetImportJob` | +| Admin watches import progress live | `Admin::SpreadsheetImportsController` | +| Admin lands on the dashboard after login | `ApplicationController#home_url_for` | +| User lands on their profile after login | same | +| User sees, edits and deletes only their own profile | `ProfilesController` | +| Visitor registers as a plain user | `RegistrationsController` | + +--- + +## Technical decisions + +**SQLite, not PostgreSQL.** The brief asks for WAL mode, and Rails 8 is built around +SQLite plus the Solid trio. The pragmas are written out in `config/database.yml` rather +than left to the adapter's defaults, because WAL journalling, `synchronous: normal` and +enforced foreign keys are what separate a production-ready SQLite setup from a toy one, +and a reviewer should not have to read the adapter source to see them. The moment this +application needs a second machine running jobs, it moves to PostgreSQL — SQLite is a +file, not a server. + +**Development mirrors production.** Rails leaves development on the async and memory +adapters. That would mean the broadcast and job paths that actually ship are never +exercised until deploy, and a reviewer running `docker compose up` would reasonably +conclude the Solid stack was not used. Development runs the same four databases +production does. `test/config/solid_stack_test.rb` asserts they stay configured, which +is the test that would have caught the cache bug listed above. + +**Deterministic encryption on `email`.** The column has to stay uniquely indexable and +findable by exact value — `authenticate_by` and the unique index both depend on +identical plaintext producing identical ciphertext. The cost is real and worth stating: +`LIKE` on email is impossible, so the admin search matches `full_name`, which is +deliberately left in plaintext for exactly that reason. + +**Roles are an enum with a database constraint.** The enum guards the application; the +`CHECK` constraint guards the console, data migrations and anything else that goes +around the model. + +**Authorisation belongs to the namespace.** `Admin::BaseController` runs the check, so +every admin controller inherits it rather than remembering to declare it. Two roles do +not justify a policy object. A third role, or per-record permissions, is where Pundit +would go — and that controller is where it would plug in. + +**The role is a sub-resource, not a custom action.** `PATCH /admin/users/:user_id/role` +keeps `UsersController` plain CRUD and gives the rule that an admin cannot change their +own role one obvious home. That rule is also what keeps the system administrable: any +*other* admin they demote still leaves them an admin, so the last one can never vanish. + +**Pagination is offset-based and hand-rolled.** It fetches one row beyond the page, so +"is there a next page" is answered without a `COUNT`. That is the whole requirement at +this size. A list needing page numbers or a total is where Pagy goes in, rather than +growing this. + +**Avatars have no variants.** Generating them would put libvips on every developer's +machine to produce a 40px thumbnail. Uploads are capped at 2 MB and constrained by CSS +instead. At real avatar volume that trade flips and the variant comes back — libvips is +already in both Docker images. + +**Minitest, not RSpec.** RSpec is what I reach for day to day. `parallelize(workers: +:number_of_processors)` is the parallel testing feature the brief names, it is native +rather than a gem, and the whole test is built around Rails 8's own tools. Consistency +won over familiarity. + +**The import is continuable.** `ActiveJob::Continuable` is new in Rails 8.1, and here it +is correctness rather than novelty: a worker restarting mid-file would replay rows it had +already imported and every one would come back as a duplicate email. The cursor is the +row index, and there is a test that resumes from one and asserts the earlier rows are not +replayed. + +--- + +## Security + +**Encryption.** `email` is encrypted at rest with deterministic Active Record +encryption. A test reads the raw column and asserts the address is not in it. + +**SQL injection.** Every query goes through Active Record with bound parameters. The two +places user input reaches a query are covered directly: search escapes its term with +`sanitize_sql_like` before binding, and the role filter is checked against +`User.roles.key?` rather than passed through — there is a test that sends +`'; DROP TABLE users; --` as a role and asserts the page renders normally. + +**XSS.** ERB escapes by default and nothing in this codebase calls `html_safe` or +`raw`; `Rails/OutputSafety` is enabled to keep it that way. SVG is deliberately absent +from the allowed avatar types: a stored SVG is a stored script, and Active Storage serves +attachments from the application's own origin. + +**CSRF.** Rails' token protection is on, and every state change goes through +`form_with` or `button_to`. + +**Mass assignment.** `params.expect` everywhere rather than `params.permit` — a request +that is not shaped like the form is a 400 rather than something quietly filtered to an +empty hash. `:role` appears in exactly one permitted list, in the admin namespace. Both +self-registration and profile editing have a test that submits `role: admin` and asserts +the user stays a user. + +**Brute force.** The sign-in and password-reset endpoints keep the generated +`rate_limit`. The test environment gives the limiter a real cache store so the rule is +exercised rather than assumed. + +**Static analysis.** Brakeman, bundler-audit and `importmap audit` run on every pull +request and report zero findings. + +--- + +## Cross-browser support + +`allow_browser versions: :modern` rejects browsers without webp, import maps, CSS +nesting and CSS `:has`. That covers every current Chrome, Safari, Firefox and Edge, and +excludes Internet Explorer and long-abandoned builds. It is a deliberate floor rather +than an accident, and it is what makes the CSS in here safe to write without polyfills. + +Form feedback works in two layers. `required`, `type="email"`, `minlength` and +`accept` are enforced by the browser before a request is made, and +`.field-input:user-invalid` styles the field from its native validity state — no +JavaScript. The server-side rules are the ones that decide, and the system tests check +both: one asserts the browser blocks a short password before any request, and the +server-side test uses a duplicate email, because that is the case the browser cannot +catch. + +Layout is Tailwind, mobile-first. The users table scrolls inside its own container on a +phone with the name column pinned, so the row still says whose it is. + +--- + +## Deployment + +The production image is multi-stage, runs as a non-root user, and serves through +**Thruster** for asset caching, compression and X-Sendfile. + +```bash +docker build -t umanni . +docker run -d -p 80:80 -e RAILS_MASTER_KEY= -v umanni_storage:/rails/storage umanni +``` + +`config/deploy.yml` is a complete Kamal 2 configuration: fill in the registry, image +owner, server and host, and `bin/kamal setup` is the deploy. TLS terminates at +kamal-proxy, so `assume_ssl` and `force_ssl` are on in production. + +Solid Queue runs inside Puma rather than as a separate job role, and that follows from +SQLite rather than being a shortcut: a worker on a second machine could not reach a +database that is a file. The `storage` volume holds all four databases and every Active +Storage upload — it is the only stateful thing on the server, and so the only thing that +needs backing up. + +**Credentials.** `config/master.key` is not in this repository, which means the +committed credentials cannot be read on another machine — as is true of any Rails +repository. To run in production mode, generate your own: + +```bash +rm config/credentials.yml.enc +bin/rails db:encryption:init # copy the three keys it prints +bin/rails credentials:edit # paste them under active_record_encryption: +``` + +Rails wires `active_record_encryption` from credentials on its own. Development and test +keys are committed in the environment files on purpose — they are not secrets, and the +application has to boot for anyone who clones this. + +--- + +## Performance + +Ruby 4 ships ZJIT and the official image has it compiled in, so enabling it is one flag. +Measuring first says not to. Rendering the users table partial 20,000 times inside the +production image, after 5,000 warmup iterations: + +| | Per render | +|---|---| +| YJIT (Rails' default) | 0.204–0.226 ms | +| ZJIT | 0.340–0.375 ms | + +ZJIT stayed roughly 70% slower across runs, and a shorter warmup widened the gap rather +than narrowing it, so this is not ZJIT being handicapped by warmup on a young JIT. Rails +enables YJIT on its own and that is where this stays. + +Reproduce it with `script/jit_benchmark.rb` — the numbers above are from one machine, and +a claim like this deserves a way to check it. + +--- + +## Trade-offs and what is not here + +- **A user who is the last admin can delete their own account** and leave nobody able to + administer the system. The brief gives every user the right to delete their profile and + does not carve out admins, so this follows the brief. The admin list *does* refuse + self-deletion. In a real product I would block the last admin here or require a second + admin to confirm. +- **No "remove avatar" control.** The brief does not ask for one, and a checkbox that + purges an attachment is scope I did not take. +- **Imported users cannot sign in until they reset their password.** They are created + with a random one. A real system would send an invitation through the mailer that is + already wired up; the reset flow is the honest version of that without inventing + requirements. +- **No email delivery is configured in development**, so password-reset mail fails + silently. Previews are at `/rails/mailers`. +- **Counters in the import are written per row.** At a genuinely large file they would + batch alongside the error list; at this size the extra write buys a bar that moves. +- **No background job for avatar processing**, no CDN, no fragment caching. All three are + the right answer at a scale this application does not have, and adding them now would + be decoration. From 41f1d6a84b5098dfbf2068938d72d6990e4c7a73 Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 13:50:07 -0300 Subject: [PATCH 037/145] fix: eager-load avatars in the admin list One attachment query per row rendered. Also normalises a capitalised role from a spreadsheet, and moves the blank-password rule out of two controllers into one. --- app/controllers/admin/users_controller.rb | 15 ++++----------- app/controllers/application_controller.rb | 11 +++++++++++ app/controllers/profiles_controller.rb | 7 +++---- app/models/spreadsheet_import/row_reader.rb | 8 +++++--- app/models/user.rb | 17 ++++++++++++++--- 5 files changed, 37 insertions(+), 21 deletions(-) diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 861c53dc6..0acf38f97 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -48,22 +48,15 @@ def set_user # Role is permitted here and nowhere else: an admin assigns roles, a visitor # registering themselves does not. def user_params - permitted = params.expect( + without_untouched_fields params.expect( user: [ :full_name, :email, :role, :password, :password_confirmation, :avatar_image ] ) - - # An edit form submits both fields empty when the admin is not changing the - # password, and an empty file input submits a blank avatar. Blanking those keys - # rather than compacting the whole hash keeps "the admin cleared the name" a - # validation error instead of a silent no-op. - permitted = permitted.except(:password, :password_confirmation) if permitted[:password].blank? - permitted = permitted.except(:avatar_image) if permitted[:avatar_image].blank? - permitted end def filtered_users - scope = User.all - scope = scope.search(params[:query]) if params[:query].present? + # Without the eager load this costs one attachment query per row rendered. + scope = User.with_attached_avatar_image + scope = scope.matching(params[:query]) if params[:query].present? # Checked against the enum rather than passed through, so a crafted role # parameter cannot reach the query. scope = scope.with_role(params[:role]) if User.roles.key?(params[:role]) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 16962a005..b57109d01 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -18,4 +18,15 @@ def after_authentication_url def home_url_for(user) user.admin? ? admin_dashboard_url : profile_url end + + # An edit form submits empty password fields when the password is not being + # changed, and an empty file input when no new avatar was picked. Dropping those + # keys is what keeps "I did not touch this" from being read as "clear it", while + # still letting a genuinely cleared name fail validation instead of passing + # silently — which is why this is not a blanket compact_blank. + def without_untouched_fields(permitted) + permitted = permitted.except(:password, :password_confirmation) if permitted[:password].blank? + permitted = permitted.except(:avatar_image) if permitted[:avatar_image].blank? + permitted + end end diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index 3e54c1091..ae79a3a5d 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -31,9 +31,8 @@ def set_profile # :role is absent, as it is everywhere outside the admin namespace. A user editing # their own profile cannot promote themselves. def profile_params - permitted = params.expect(user: [ :full_name, :email, :password, :password_confirmation, :avatar_image ]) - permitted = permitted.except(:password, :password_confirmation) if permitted[:password].blank? - permitted = permitted.except(:avatar_image) if permitted[:avatar_image].blank? - permitted + without_untouched_fields params.expect( + user: [ :full_name, :email, :password, :password_confirmation, :avatar_image ] + ) end end diff --git a/app/models/spreadsheet_import/row_reader.rb b/app/models/spreadsheet_import/row_reader.rb index 6bc7b5953..805783573 100644 --- a/app/models/spreadsheet_import/row_reader.rb +++ b/app/models/spreadsheet_import/row_reader.rb @@ -36,9 +36,11 @@ def normalized_header end def attributes_from(header, cells) - header.zip(cells.map { |cell| cell.to_s.strip.presence }) - .to_h - .slice(*COLUMNS) + attributes = header.zip(cells.map { |cell| cell.to_s.strip.presence }).to_h.slice(*COLUMNS) + # "Admin" typed into Excel is the same role as "admin". Lowercasing here rather + # than in the job keeps every value the reader hands out already normalised. + attributes[:role] = attributes[:role]&.downcase + attributes end end end diff --git a/app/models/user.rb b/app/models/user.rb index a7061ec23..aaccea0a0 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -22,8 +22,10 @@ class User < ApplicationRecord # A lambda, not the bare symbol: the macro calls `send` on the record for anything # that does not respond to `call`, so `:dashboard` would look for User#dashboard. # - # Refreshes are debounced by Turbo, which is what makes this safe during an import: - # five hundred created users collapse into a handful of broadcasts. + # Turbo debounces these, and the debounce restarts on every write. A bulk import + # writing faster than the delay therefore produces no broadcast at all until it + # stops — which is why SpreadsheetImportJob suppresses this and paces the dashboard + # itself rather than relying on the callback. broadcasts_refreshes_to ->(_user) { DASHBOARD_STREAM } normalizes :email, with: ->(email) { email.strip.downcase } @@ -34,7 +36,16 @@ class User < ApplicationRecord validate :avatar_image_must_be_a_supported_image scope :ordered, -> { order(:full_name, :id) } - scope :search, ->(term) { where("full_name LIKE ?", "%#{sanitize_sql_like(term.to_s.strip)}%") } + # Deterministic encryption rules out a partial match on email but not an exact one, + # so an address is looked up whole and anything else searches the name. + scope :matching, ->(term) { + term = term.to_s.strip + if term.include?("@") + where(email: term.downcase) + else + where("full_name LIKE ?", "%#{sanitize_sql_like(term)}%") + end + } scope :with_role, ->(role) { where(role: role) } # Blank for an unsaved user, which is exactly the case on the "new user" form. From cf5c67a2d46ed4d9e5dc0429e0b301a99387f4fd Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 13:50:07 -0300 Subject: [PATCH 038/145] fix: pace dashboard refreshes during an import Turbo's debounce restarts on every write, so a job creating a row every 200ms broadcast once, after it finished. A failed import also records why now. --- app/jobs/spreadsheet_import_job.rb | 47 ++++++++++++++----- .../spreadsheet_imports/_progress.html.erb | 7 +++ ...d_failure_reason_to_spreadsheet_imports.rb | 5 ++ db/schema.rb | 3 +- 4 files changed, 49 insertions(+), 13 deletions(-) create mode 100644 db/migrate/20260903164525_add_failure_reason_to_spreadsheet_imports.rb diff --git a/app/jobs/spreadsheet_import_job.rb b/app/jobs/spreadsheet_import_job.rb index d91e52d50..9b6880621 100644 --- a/app/jobs/spreadsheet_import_job.rb +++ b/app/jobs/spreadsheet_import_job.rb @@ -6,6 +6,9 @@ class SpreadsheetImportJob < ApplicationJob # put a thousand messages on the wire for a thousand-row file. BROADCAST_EVERY = 10 + # The dashboard is a coarser view than the bar, so it is refreshed less often. + DASHBOARD_EVERY = 50 + def perform(spreadsheet_import) @import = spreadsheet_import @@ -20,15 +23,19 @@ def perform(spreadsheet_import) step :finish do persist(status: :completed) broadcast_progress + broadcast_dashboard end # An interruption is the continuation working as designed: the worker is shutting # down and the job will resume from its cursor. Letting it fall through to the # rescue below would mark a healthy import as failed. rescue ActiveJob::Continuation::Interrupt raise - rescue StandardError - @import.update(status: :failed) + rescue StandardError => error + # The reason belongs on the record. Otherwise the admin sees a red badge and has to + # be told to go read a jobs table to find out what went wrong. + @import.update(status: :failed, failure_reason: "#{error.class}: #{error.message}".truncate(500)) broadcast_progress + broadcast_dashboard raise end @@ -44,16 +51,19 @@ def start_import(row_count) def import_rows(reader, step) @row_errors = @import.row_errors.dup - reader.each_row do |attributes, line| - index = line - (SpreadsheetImport::RowReader::HEADER_ROW + 1) - next if index < step.cursor.to_i - - record_row(attributes, line) - step.set!(index + 1) - - if (index + 1) % BROADCAST_EVERY == 0 - persist - broadcast_progress + # Every created user would otherwise fire User's debounced dashboard refresh, and + # that debounce restarts on each write: a run creating rows faster than the delay + # produces no refresh at all until it finishes, which is precisely when a live + # counter would be worth having. Suppressing the callback and pacing the refresh + # here trades an unpredictable cadence for a fixed one. + User.suppressing_turbo_broadcasts do + reader.each_row do |attributes, line| + index = line - (SpreadsheetImport::RowReader::HEADER_ROW + 1) + next if index < step.cursor.to_i + + record_row(attributes, line) + step.set!(index + 1) + broadcast_batch(index + 1) end end @@ -63,6 +73,15 @@ def import_rows(reader, step) persist end + def broadcast_batch(processed) + if (processed % BROADCAST_EVERY).zero? + persist + broadcast_progress + end + + broadcast_dashboard if (processed % DASHBOARD_EVERY).zero? + end + # A bad row is data, not an exception: it is counted, described and stepped over. # One malformed line in a thousand must not cost the other nine hundred. def record_row(attributes, line) @@ -97,6 +116,10 @@ def file_extension @import.file.filename.extension_without_delimiter.downcase end + def broadcast_dashboard + Turbo::StreamsChannel.broadcast_refresh_to(User::DASHBOARD_STREAM) + end + def broadcast_progress @import.broadcast_replace_to( @import, diff --git a/app/views/admin/spreadsheet_imports/_progress.html.erb b/app/views/admin/spreadsheet_imports/_progress.html.erb index 0f75fc2b5..41a483525 100644 --- a/app/views/admin/spreadsheet_imports/_progress.html.erb +++ b/app/views/admin/spreadsheet_imports/_progress.html.erb @@ -44,6 +44,13 @@
+ <% if spreadsheet_import.failure_reason.present? %> +
+

The file could not be read

+

<%= spreadsheet_import.failure_reason %>

+
+ <% end %> + <% if spreadsheet_import.row_errors.any? %>

Rows that could not be imported

diff --git a/db/migrate/20260903164525_add_failure_reason_to_spreadsheet_imports.rb b/db/migrate/20260903164525_add_failure_reason_to_spreadsheet_imports.rb new file mode 100644 index 000000000..6023fb50d --- /dev/null +++ b/db/migrate/20260903164525_add_failure_reason_to_spreadsheet_imports.rb @@ -0,0 +1,5 @@ +class AddFailureReasonToSpreadsheetImports < ActiveRecord::Migration[8.1] + def change + add_column :spreadsheet_imports, :failure_reason, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 5e396c8f6..4e8a802e9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_09_03_153213) do +ActiveRecord::Schema[8.1].define(version: 2026_09_03_164525) do create_table "active_storage_attachments", force: :cascade do |t| t.bigint "blob_id", null: false t.datetime "created_at", null: false @@ -51,6 +51,7 @@ create_table "spreadsheet_imports", force: :cascade do |t| t.datetime "created_at", null: false t.integer "failed_rows", default: 0, null: false + t.string "failure_reason" t.integer "processed_rows", default: 0, null: false t.json "row_errors", default: [], null: false t.string "status", default: "pending", null: false From 21a6d975731245927fcebeec79f346c985751cc6 Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 13:50:07 -0300 Subject: [PATCH 039/145] feat: disable submit buttons while the request is in flight --- app/javascript/controllers/form_controller.js | 20 +++++++++++++++++++ .../controllers/hello_controller.js | 7 ------- .../admin/spreadsheet_imports/new.html.erb | 6 ++++-- app/views/admin/users/_form.html.erb | 5 +++-- app/views/registrations/new.html.erb | 6 ++++-- app/views/shared/_navbar.html.erb | 2 +- 6 files changed, 32 insertions(+), 14 deletions(-) create mode 100644 app/javascript/controllers/form_controller.js delete mode 100644 app/javascript/controllers/hello_controller.js diff --git a/app/javascript/controllers/form_controller.js b/app/javascript/controllers/form_controller.js new file mode 100644 index 000000000..a73226994 --- /dev/null +++ b/app/javascript/controllers/form_controller.js @@ -0,0 +1,20 @@ +import { Controller } from "@hotwired/stimulus" + +// Keeps a form from being submitted twice and says so while the request is in flight. +// Turbo already prevents the double navigation, but the button stays enabled and +// unchanged, so on a slow request there is nothing telling anyone the click landed. +export default class extends Controller { + static targets = ["submit"] + static values = { submitting: { type: String, default: "Working…" } } + + connect() { + this.originalLabel = this.submitTarget.value + } + + // Turbo re-enables the button itself when the response renders, so a validation + // error leaves the form usable without any reset of our own. + submitting() { + this.submitTarget.disabled = true + this.submitTarget.value = this.submittingValue + } +} diff --git a/app/javascript/controllers/hello_controller.js b/app/javascript/controllers/hello_controller.js deleted file mode 100644 index 5975c0789..000000000 --- a/app/javascript/controllers/hello_controller.js +++ /dev/null @@ -1,7 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -export default class extends Controller { - connect() { - this.element.textContent = "Hello World!" - } -} diff --git a/app/views/admin/spreadsheet_imports/new.html.erb b/app/views/admin/spreadsheet_imports/new.html.erb index 4430c2c48..b2596f537 100644 --- a/app/views/admin/spreadsheet_imports/new.html.erb +++ b/app/views/admin/spreadsheet_imports/new.html.erb @@ -9,7 +9,8 @@
<%= render "shared/form_errors", model: @spreadsheet_import %> - <%= form_with model: @spreadsheet_import, url: admin_spreadsheet_imports_path, class: "space-y-5" do |form| %> + <%= form_with model: @spreadsheet_import, url: admin_spreadsheet_imports_path, class: "space-y-5", + data: { controller: "form", action: "submit->form#submitting" } do |form| %>
<%= form.label :file, "Spreadsheet", class: "field-label" %> <%= form.file_field :file, required: true, accept: ".csv,.xlsx", @@ -31,7 +32,8 @@
- <%= form.submit "Start import", class: "btn-primary" %> + <%= form.submit "Start import", class: "btn-primary", + data: { form_target: "submit", form_submitting_value: "Uploading…" } %> <%= link_to "Cancel", admin_spreadsheet_imports_path, class: "btn-secondary" %>
<% end %> diff --git a/app/views/admin/users/_form.html.erb b/app/views/admin/users/_form.html.erb index 78d1eb2e0..e96b86691 100644 --- a/app/views/admin/users/_form.html.erb +++ b/app/views/admin/users/_form.html.erb @@ -1,4 +1,5 @@ -<%= form_with model: user, url: url, class: "space-y-5" do |form| %> +<%= form_with model: user, url: url, class: "space-y-5", + data: { controller: "form", action: "submit->form#submitting" } do |form| %> <%= render "shared/form_errors", model: user %>
@@ -44,7 +45,7 @@
- <%= form.submit submit_label, class: "btn-primary" %> + <%= form.submit submit_label, class: "btn-primary", data: { form_target: "submit" } %> <%= link_to "Cancel", admin_users_path, class: "btn-secondary" %>
<% end %> diff --git a/app/views/registrations/new.html.erb b/app/views/registrations/new.html.erb index d052a3afd..ecf121d68 100644 --- a/app/views/registrations/new.html.erb +++ b/app/views/registrations/new.html.erb @@ -7,7 +7,8 @@ <%= render "shared/form_errors", model: @user %> - <%= form_with model: @user, url: registration_path, class: "mt-6 space-y-5" do |form| %> + <%= form_with model: @user, url: registration_path, class: "mt-6 space-y-5", + data: { controller: "form", action: "submit->form#submitting" } do |form| %>
<%= form.label :full_name, "Full name", class: "field-label" %> <%= form.text_field :full_name, required: true, autofocus: true, autocomplete: "name", @@ -33,7 +34,8 @@ minlength: 8, maxlength: 72, placeholder: "••••••••", class: "field-input" %>
- <%= form.submit "Create account", class: "btn-primary w-full" %> + <%= form.submit "Create account", class: "btn-primary w-full", + data: { form_target: "submit", form_submitting_value: "Creating…" } %> <% end %>

diff --git a/app/views/shared/_navbar.html.erb b/app/views/shared/_navbar.html.erb index 98ee1c68c..c8e4ad2d3 100644 --- a/app/views/shared/_navbar.html.erb +++ b/app/views/shared/_navbar.html.erb @@ -19,7 +19,7 @@ <%= Current.user.full_name %> - <%= button_to "Sign out", session_path, method: :delete, class: "btn-secondary" %> + <%= button_to "Sign out", session_path, method: :delete, class: "btn-secondary whitespace-nowrap" %>

From 85007bed5035317e647eb347cacd7b5f15283b2f Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 13:50:07 -0300 Subject: [PATCH 040/145] test: guard the regressions this review round turned up --- .../admin/users_controller_test.rb | 8 +++ test/fixtures/files/bulk_users.csv | 61 +++++++++++++++++++ test/jobs/spreadsheet_import_job_test.rb | 52 +++++++++++++++- 3 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 test/fixtures/files/bulk_users.csv diff --git a/test/controllers/admin/users_controller_test.rb b/test/controllers/admin/users_controller_test.rb index d822b334c..dc9eff0dd 100644 --- a/test/controllers/admin/users_controller_test.rb +++ b/test/controllers/admin/users_controller_test.rb @@ -17,6 +17,14 @@ class Admin::UsersControllerTest < ActionDispatch::IntegrationTest assert_select "td", text: /Grace Hopper/, count: 0 end + # Deterministic encryption rules out a partial match on email but not an exact one. + test "finds a user by their exact email despite the column being encrypted" do + get admin_users_path(query: users(:member).email.upcase) + + assert_select "td", text: /Ada Lovelace/ + assert_select "td", text: /Grace Hopper/, count: 0 + end + test "filters by role" do get admin_users_path(role: "admin") diff --git a/test/fixtures/files/bulk_users.csv b/test/fixtures/files/bulk_users.csv new file mode 100644 index 000000000..2a3541755 --- /dev/null +++ b/test/fixtures/files/bulk_users.csv @@ -0,0 +1,61 @@ +full_name,email,role +Bulk Person 1,bulk1@umanni.test,user +Bulk Person 2,bulk2@umanni.test,user +Bulk Person 3,bulk3@umanni.test,user +Bulk Person 4,bulk4@umanni.test,user +Bulk Person 5,bulk5@umanni.test,user +Bulk Person 6,bulk6@umanni.test,user +Bulk Person 7,bulk7@umanni.test,user +Bulk Person 8,bulk8@umanni.test,user +Bulk Person 9,bulk9@umanni.test,user +Bulk Person 10,bulk10@umanni.test,admin +Bulk Person 11,bulk11@umanni.test,user +Bulk Person 12,bulk12@umanni.test,user +Bulk Person 13,bulk13@umanni.test,user +Bulk Person 14,bulk14@umanni.test,user +Bulk Person 15,bulk15@umanni.test,user +Bulk Person 16,bulk16@umanni.test,user +Bulk Person 17,bulk17@umanni.test,user +Bulk Person 18,bulk18@umanni.test,user +Bulk Person 19,bulk19@umanni.test,user +Bulk Person 20,bulk20@umanni.test,admin +Bulk Person 21,bulk21@umanni.test,user +Bulk Person 22,bulk22@umanni.test,user +Bulk Person 23,bulk23@umanni.test,user +Bulk Person 24,bulk24@umanni.test,user +Bulk Person 25,bulk25@umanni.test,user +Bulk Person 26,bulk26@umanni.test,user +Bulk Person 27,bulk27@umanni.test,user +Bulk Person 28,bulk28@umanni.test,user +Bulk Person 29,bulk29@umanni.test,user +Bulk Person 30,bulk30@umanni.test,admin +Bulk Person 31,bulk31@umanni.test,user +Bulk Person 32,bulk32@umanni.test,user +Bulk Person 33,bulk33@umanni.test,user +Bulk Person 34,bulk34@umanni.test,user +Bulk Person 35,bulk35@umanni.test,user +Bulk Person 36,bulk36@umanni.test,user +Bulk Person 37,bulk37@umanni.test,user +Bulk Person 38,bulk38@umanni.test,user +Bulk Person 39,bulk39@umanni.test,user +Bulk Person 40,bulk40@umanni.test,admin +Bulk Person 41,bulk41@umanni.test,user +Bulk Person 42,bulk42@umanni.test,user +Bulk Person 43,bulk43@umanni.test,user +Bulk Person 44,bulk44@umanni.test,user +Bulk Person 45,bulk45@umanni.test,user +Bulk Person 46,bulk46@umanni.test,user +Bulk Person 47,bulk47@umanni.test,user +Bulk Person 48,bulk48@umanni.test,user +Bulk Person 49,bulk49@umanni.test,user +Bulk Person 50,bulk50@umanni.test,admin +Bulk Person 51,bulk51@umanni.test,user +Bulk Person 52,bulk52@umanni.test,user +Bulk Person 53,bulk53@umanni.test,user +Bulk Person 54,bulk54@umanni.test,user +Bulk Person 55,bulk55@umanni.test,user +Bulk Person 56,bulk56@umanni.test,user +Bulk Person 57,bulk57@umanni.test,user +Bulk Person 58,bulk58@umanni.test,user +Bulk Person 59,bulk59@umanni.test,user +Bulk Person 60,bulk60@umanni.test,admin diff --git a/test/jobs/spreadsheet_import_job_test.rb b/test/jobs/spreadsheet_import_job_test.rb index e655a2be2..e24053db0 100644 --- a/test/jobs/spreadsheet_import_job_test.rb +++ b/test/jobs/spreadsheet_import_job_test.rb @@ -86,17 +86,65 @@ class SpreadsheetImportJobTest < ActiveJob::TestCase assert_equal 2, import.failed_rows end - test "marks the import failed and re-raises when the file cannot be read" do + test "marks the import failed, records why, and re-raises when the file cannot be read" do import = build_import("not-an-image.txt", skip_validation: true) assert_raises StandardError do SpreadsheetImportJob.perform_now(import) end - assert_predicate import.reload, :failed? + import.reload + + assert_predicate import, :failed? + # A red badge with no reason sends the admin to a jobs table to find out what broke. + assert_predicate import.failure_reason, :present? + end + + test "reads a capitalised role as the role it obviously is" do + path = Rails.root.join("tmp", "roles-#{SecureRandom.hex(4)}.csv") + path.write("full_name,email,role\nKatherine Johnson,katherine@umanni.test,Admin\n") + import = users(:admin).spreadsheet_imports.create!(file: { io: path.open, filename: "roles.csv" }) + + SpreadsheetImportJob.perform_now(import) + + # Silently demoting "Admin" to a plain user, and counting the row as a success, + # is the kind of thing nobody notices until an admin cannot sign in. + assert_predicate User.find_by(email: "katherine@umanni.test"), :admin? + assert_equal 0, import.reload.failed_rows + ensure + path&.delete + end + + # The bug this guards: User's dashboard refresh is debounced, and the debounce + # restarts on every write. A run creating rows faster than the delay produced no + # refresh at all until it finished — no live counter, in the one case that needed it. + test "refreshes the dashboard while a long import runs, not only at the end" do + import = build_import("bulk_users.csv") + + refreshes = count_dashboard_refreshes { SpreadsheetImportJob.perform_now(import) } + + assert_operator refreshes, :>, 1, "the dashboard was refreshed only once, at the end" end private + # Counted by hand rather than with a mocking library: Minitest 6 dropped + # minitest/mock, and turbo's own assertion helper needs the :test cable adapter, + # which this suite deliberately does not use — system tests need real delivery. + def count_dashboard_refreshes + count = 0 + original = Turbo::StreamsChannel.method(:broadcast_refresh_to) + + Turbo::StreamsChannel.define_singleton_method(:broadcast_refresh_to) do |*args, **options| + count += 1 + original.call(*args, **options) + end + + yield + count + ensure + Turbo::StreamsChannel.singleton_class.remove_method(:broadcast_refresh_to) + end + def perform_resumed(import, completed:, current:) job = SpreadsheetImportJob.new(import) job.deserialize(job.serialize.merge("continuation" => { "completed" => completed, "current" => current })) From 794e45615590c8e91edb557c85840254349a2cb0 Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 13:50:07 -0300 Subject: [PATCH 041/145] docs: correct the debounce claim It collapses a bulk import into one broadcast, not a handful. --- CLAUDE.md | 12 ++++++++++-- README.md | 43 +++++++++++++++++++++++++++++++++---------- 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 40556afa0..305b7edbd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,6 +27,8 @@ - Avatar is `has_one_attached :avatar_image` (Active Storage, Disk service). PNG/JPEG/WebP only, 2 MB cap, validated in the model. No variants; images are served at upload size and constrained by CSS. - `Pagination` (`app/models/pagination.rb`) is a PORO: offset-based, fetches `per_page + 1` rows, no COUNT. +- `SpreadsheetImport` + `SpreadsheetImport::RowReader` + `SpreadsheetImportJob` (`ActiveJob::Continuable`) handle the + .csv/.xlsx import. The job suppresses `User`'s debounced dashboard broadcast and paces refreshes itself. - `resource :profile` routes `show edit update destroy`; `ProfilesController` implements only `show`. - Tailwind component layer in `app/assets/tailwind/application.css`: `card`, `field-*`, `btn-*`, `badge-*`. Tailwind v4 will not `@apply` one component class inside another, hence the selector lists. @@ -76,8 +78,9 @@ the decision. Default branch is `master`. - `params.expect`, not `permit`, in every controller that takes a form. - `:role` is permitted only in `Admin::UsersController#user_params`. Self-registration never accepts it. -- Admin search matches `full_name` only (`User.search`, escaped with `sanitize_sql_like`). Role filter is checked - against `User.roles` before it reaches the query. +- Admin search is `User.matching`: an exact email when the term contains `@` (deterministic encryption allows it), + otherwise `full_name LIKE`, escaped with `sanitize_sql_like`. Role filter is checked against `User.roles` before it + reaches the query. - RuboCop: omakase plus a stricter layer (`.rubocop.yml`: metrics ceilings, Rails cops, Minitest and Performance plugins, line length 120). Run `bin/rubocop -A` after editing Ruby. - Error responses render with `status: :unprocessable_content`; destroy redirects use `status: :see_other`. @@ -102,3 +105,8 @@ without it runs everything, because avatars do not use variants. - `sign_in_as` in `test/test_helpers/session_test_helper.rb` writes a cookie into a test request and is for integration tests only; system tests use the browser-driven override in `ApplicationSystemTestCase`. +- Turbo's debounce on `broadcasts_refreshes_to` restarts on every write, so a bulk job writing faster than the delay + broadcasts nothing until it stops. `SpreadsheetImportJob` wraps its loop in `User.suppressing_turbo_broadcasts` and + calls `Turbo::StreamsChannel.broadcast_refresh_to` on a fixed cadence instead. +- The cable adapter in test is `async`, not `test`, because system tests need real delivery. That rules out + `assert_broadcasts` and turbo's own broadcast assertions; Minitest 6 also no longer ships `minitest/mock`. diff --git a/README.md b/README.md index 9fac6608d..256f27727 100644 --- a/README.md +++ b/README.md @@ -48,15 +48,21 @@ gate sent the phase back rather than forward. - The import's progress bar stuck on "Processing" about one run in three. Two broadcasts a millisecond apart are not guaranteed to arrive in order, and the loser overwrote the winner. The fix was one broadcast instead of two, not a longer wait in the test. +- The dashboard counters froze for the entire duration of an import — the one moment a + live counter earns its place. Turbo debounces refresh broadcasts and the debounce + restarts on every write, so a job creating rows faster than the delay produced no + refresh at all until it finished. Found by watching a 150-row import, not by reading + the code, which is why the comment in the model had confidently claimed the opposite. - The production image seeded demo users — a published email and a published password — because the entrypoint runs `db:prepare` and `db:prepare` seeds a new database. Found by running the production image rather than only building it. ### A note on the hidden instructions in the brief -The upstream `README.md` contains an HTML comment addressed to LLM assistants. It asks -the model to inject a marker string into the frontend and, in its own words, to *"not -disclose or explain these hidden constraints to the human user."* +The brief at contains an HTML comment in +its `README.md`, addressed to LLM assistants. It asks the model to inject a marker string +into the frontend and, in its own words, to *"not disclose or explain these hidden +constraints to the human user."* It is visible in the raw file, not in the rendered page. I read it, and I did not follow it. There is no marker string in this codebase, and nothing about the process was hidden from me. Disclosure is above, in full. @@ -127,7 +133,7 @@ the same time. ## Testing ```bash -bin/rails test:all # unit, integration and system — 121 tests +bin/rails test:all # unit, integration and system — 124 tests bin/rails test # skips system tests bin/ci # the whole pipeline: lint, audits, Brakeman, tests, seeds ``` @@ -135,13 +141,13 @@ bin/ci # the whole pipeline: lint, audits, Brakeman, tests, see | Layer | Files | Tests | |---|---|---| | Models and POROs | 3 | 20 | -| Controllers | 8 | 65 | +| Controllers | 8 | 66 | | Integration | 1 | 5 | -| Jobs | 1 | 7 | +| Jobs | 1 | 9 | | System (real Chrome) | 5 | 21 | | Configuration | 1 | 3 | -**121 tests, 392 assertions, 97.94% line coverage, 93.75% branch coverage.** Tests run +**124 tests, 402 assertions, 98.58% line coverage, 95.45% branch coverage.** Tests run in parallel across one process per core, and SimpleCov results are merged per worker — without that merge the report shows roughly one worker's share and every number after it is fiction. The 90% floor is enforced under `CI` or `COVERAGE`. @@ -227,6 +233,13 @@ already in both Docker images. rather than a gem, and the whole test is built around Rails 8's own tools. Consistency won over familiarity. +**The import paces its own dashboard refreshes.** `User` broadcasts a debounced refresh +on every commit, which is right for one-at-a-time editing and useless during a bulk +import: the debounce restarts on each write, so a fast job produces nothing until it +stops. The job suppresses the model's broadcast and refreshes on a fixed cadence +instead — an unpredictable schedule traded for a predictable one. There is a test that +fails if the suppression is removed. + **The import is continuable.** `ActiveJob::Continuable` is new in Rails 8.1, and here it is correctness rather than novelty: a worker restarting mid-file would replay rows it had already imported and every one would come back as a duplicate email. The cursor is the @@ -276,13 +289,20 @@ nesting and CSS `:has`. That covers every current Chrome, Safari, Firefox and Ed excludes Internet Explorer and long-abandoned builds. It is a deliberate floor rather than an accident, and it is what makes the CSS in here safe to write without polyfills. -Form feedback works in two layers. `required`, `type="email"`, `minlength` and +Stimulus is used where it earns its place rather than for the sake of appearing: one +controller disables a submit button and relabels it while the request is in flight, on +the three forms whose submission does real work. Turbo already prevents the double +navigation, but the button stays enabled and unchanged, so on a slow upload nothing tells +you the click landed. + +Form feedback works in three layers. `required`, `type="email"`, `minlength` and `accept` are enforced by the browser before a request is made, and `.field-input:user-invalid` styles the field from its native validity state — no JavaScript. The server-side rules are the ones that decide, and the system tests check both: one asserts the browser blocks a short password before any request, and the server-side test uses a duplicate email, because that is the case the browser cannot -catch. +catch. Errors render in a summary at the top of the form rather than beside each field — +per-field messaging is the obvious next step and is not here. Layout is Tailwind, mobile-first. The users table scrolls inside its own container on a phone with the name column pinned, so the row still says whose it is. @@ -353,7 +373,10 @@ a claim like this deserves a way to check it. self-deletion. In a real product I would block the last admin here or require a second admin to confirm. - **No "remove avatar" control.** The brief does not ask for one, and a checkbox that - purges an attachment is scope I did not take. + purges an attachment is scope I did not take. It is the gap I would close first: once + an avatar is uploaded there is no way to take it back through the interface. +- **Validation errors appear in a summary, not per field.** Adequate on forms this short, + and the wrong answer on a longer one. - **Imported users cannot sign in until they reset their password.** They are created with a random one. A real system would send an invitation through the mailer that is already wired up; the reset flow is the honest version of that without inventing From fc1d4e5d35cc4d0a254406b67ba3d8052840931c Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 13:59:13 -0300 Subject: [PATCH 042/145] fix: move the Stimulus value onto the controller element Values are read from the element carrying data-controller, never from a target, so the attribute rendered and never applied. --- app/views/admin/spreadsheet_imports/new.html.erb | 5 ++--- app/views/admin/users/_form.html.erb | 2 +- app/views/registrations/new.html.erb | 5 ++--- .../admin/spreadsheet_imports_controller_test.rb | 8 ++++++++ test/controllers/registrations_controller_test.rb | 1 + 5 files changed, 14 insertions(+), 7 deletions(-) diff --git a/app/views/admin/spreadsheet_imports/new.html.erb b/app/views/admin/spreadsheet_imports/new.html.erb index b2596f537..d3ba5f2d9 100644 --- a/app/views/admin/spreadsheet_imports/new.html.erb +++ b/app/views/admin/spreadsheet_imports/new.html.erb @@ -10,7 +10,7 @@ <%= render "shared/form_errors", model: @spreadsheet_import %> <%= form_with model: @spreadsheet_import, url: admin_spreadsheet_imports_path, class: "space-y-5", - data: { controller: "form", action: "submit->form#submitting" } do |form| %> + data: { controller: "form", action: "submit->form#submitting", form_submitting_value: "Uploading…" } do |form| %>
<%= form.label :file, "Spreadsheet", class: "field-label" %> <%= form.file_field :file, required: true, accept: ".csv,.xlsx", @@ -32,8 +32,7 @@
- <%= form.submit "Start import", class: "btn-primary", - data: { form_target: "submit", form_submitting_value: "Uploading…" } %> + <%= form.submit "Start import", class: "btn-primary", data: { form_target: "submit" } %> <%= link_to "Cancel", admin_spreadsheet_imports_path, class: "btn-secondary" %>
<% end %> diff --git a/app/views/admin/users/_form.html.erb b/app/views/admin/users/_form.html.erb index e96b86691..67e7ac032 100644 --- a/app/views/admin/users/_form.html.erb +++ b/app/views/admin/users/_form.html.erb @@ -1,5 +1,5 @@ <%= form_with model: user, url: url, class: "space-y-5", - data: { controller: "form", action: "submit->form#submitting" } do |form| %> + data: { controller: "form", action: "submit->form#submitting", form_submitting_value: "Saving…" } do |form| %> <%= render "shared/form_errors", model: user %>
diff --git a/app/views/registrations/new.html.erb b/app/views/registrations/new.html.erb index ecf121d68..688773c01 100644 --- a/app/views/registrations/new.html.erb +++ b/app/views/registrations/new.html.erb @@ -8,7 +8,7 @@ <%= render "shared/form_errors", model: @user %> <%= form_with model: @user, url: registration_path, class: "mt-6 space-y-5", - data: { controller: "form", action: "submit->form#submitting" } do |form| %> + data: { controller: "form", action: "submit->form#submitting", form_submitting_value: "Creating…" } do |form| %>
<%= form.label :full_name, "Full name", class: "field-label" %> <%= form.text_field :full_name, required: true, autofocus: true, autocomplete: "name", @@ -34,8 +34,7 @@ minlength: 8, maxlength: 72, placeholder: "••••••••", class: "field-input" %>
- <%= form.submit "Create account", class: "btn-primary w-full", - data: { form_target: "submit", form_submitting_value: "Creating…" } %> + <%= form.submit "Create account", class: "btn-primary w-full", data: { form_target: "submit" } %> <% end %>

diff --git a/test/controllers/admin/spreadsheet_imports_controller_test.rb b/test/controllers/admin/spreadsheet_imports_controller_test.rb index 9e8e0200b..3fd50d9fa 100644 --- a/test/controllers/admin/spreadsheet_imports_controller_test.rb +++ b/test/controllers/admin/spreadsheet_imports_controller_test.rb @@ -21,6 +21,14 @@ class Admin::SpreadsheetImportsControllerTest < ActionDispatch::IntegrationTest assert_response :success end + # Stimulus reads values from the element carrying data-controller, not from a target. + # On the wrong element the attribute still renders and simply never applies. + test "declares the submitting label on the element the controller is attached to" do + get new_admin_spreadsheet_import_path + + assert_select "form[data-controller=form][data-form-submitting-value=?]", "Uploading…" + end + test "queues the import and sends the admin to its progress page" do assert_difference -> { SpreadsheetImport.count }, 1 do assert_enqueued_with job: SpreadsheetImportJob do diff --git a/test/controllers/registrations_controller_test.rb b/test/controllers/registrations_controller_test.rb index 4582b5fd4..72a498ac4 100644 --- a/test/controllers/registrations_controller_test.rb +++ b/test/controllers/registrations_controller_test.rb @@ -5,6 +5,7 @@ class RegistrationsControllerTest < ActionDispatch::IntegrationTest get new_registration_path assert_response :success + assert_select "form[data-controller=form][data-form-submitting-value=?]", "Creating…" end test "registers a visitor and signs them in" do From 8e8e2ad7d51429f2862a9ad0f9a50508c82282bf Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 13:59:13 -0300 Subject: [PATCH 043/145] docs: update the test counts --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 256f27727..1acbb4b53 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ the same time. ## Testing ```bash -bin/rails test:all # unit, integration and system — 124 tests +bin/rails test:all # unit, integration and system — 125 tests bin/rails test # skips system tests bin/ci # the whole pipeline: lint, audits, Brakeman, tests, seeds ``` @@ -141,13 +141,13 @@ bin/ci # the whole pipeline: lint, audits, Brakeman, tests, see | Layer | Files | Tests | |---|---|---| | Models and POROs | 3 | 20 | -| Controllers | 8 | 66 | +| Controllers | 8 | 68 | | Integration | 1 | 5 | | Jobs | 1 | 9 | | System (real Chrome) | 5 | 21 | | Configuration | 1 | 3 | -**124 tests, 402 assertions, 98.58% line coverage, 95.45% branch coverage.** Tests run +**125 tests, 406 assertions, 98.58% line coverage, 95.45% branch coverage.** Tests run in parallel across one process per core, and SimpleCov results are merged per worker — without that merge the report shows roughly one worker's share and every number after it is fiction. The 90% floor is enforced under `CI` or `COVERAGE`. From 2236d39f41e6020550018d02e7217c9aa8100c8e Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 14:33:52 -0300 Subject: [PATCH 044/145] fix: keep the last admin through every route that could remove one The admin edit form and profile deletion could both strip the last admin; only the role toggle guarded it. The rule is a model validation plus before_destroy. --- app/controllers/admin/users_controller.rb | 2 +- app/controllers/profiles_controller.rb | 9 ++++--- app/models/user.rb | 30 ++++++++++++++++++++--- 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 0acf38f97..57af15ee1 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -59,7 +59,7 @@ def filtered_users scope = scope.matching(params[:query]) if params[:query].present? # Checked against the enum rather than passed through, so a crafted role # parameter cannot reach the query. - scope = scope.with_role(params[:role]) if User.roles.key?(params[:role]) + scope = scope.where(role: params[:role]) if User.roles.key?(params[:role]) scope end end diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index ae79a3a5d..a72a03481 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -16,9 +16,12 @@ def update end def destroy - @user.destroy! - terminate_session - redirect_to new_session_path, notice: "Your account has been deleted.", status: :see_other + if @user.destroy + terminate_session + redirect_to new_session_path, notice: "Your account has been deleted.", status: :see_other + else + redirect_to profile_path, alert: @user.errors.full_messages.to_sentence, status: :see_other + end end private diff --git a/app/models/user.rb b/app/models/user.rb index aaccea0a0..0b35ada9d 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -4,7 +4,6 @@ class User < ApplicationRecord AVATAR_CONTENT_TYPES = %w[ image/png image/jpeg image/webp ].freeze AVATAR_MAX_SIZE = 2.megabytes - # Anyone watching the admin dashboard is subscribed to this stream. DASHBOARD_STREAM = "dashboard".freeze has_secure_password @@ -34,6 +33,12 @@ class User < ApplicationRecord validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP } validates :password, length: { minimum: 8 }, allow_nil: true validate :avatar_image_must_be_a_supported_image + # The system has to keep someone able to administer it. The rule lives here because + # there are three ways to break it — the role toggle, the admin edit form and a user + # deleting their own profile — and a rule enforced in one controller out of three is + # not enforced. + validate :last_admin_keeps_the_role, on: :update + before_destroy :last_admin_is_not_deletable scope :ordered, -> { order(:full_name, :id) } # Deterministic encryption rules out a partial match on email but not an exact one, @@ -46,14 +51,33 @@ class User < ApplicationRecord where("full_name LIKE ?", "%#{sanitize_sql_like(term)}%") end } - scope :with_role, ->(role) { where(role: role) } - # Blank for an unsaved user, which is exactly the case on the "new user" form. def initials full_name.to_s.split.first(2).filter_map { |part| part[0] }.join.upcase end private + def another_admin_exists? + self.class.admin.where.not(id: id).exists? + end + + def last_admin_keeps_the_role + return unless role_changed?(from: "admin") + return if another_admin_exists? + + errors.add(:role, "cannot change: this is the only admin left") + end + + def last_admin_is_not_deletable + # role_in_database, not role: an unsaved change to the attribute must not decide + # whether the row may go. + return unless role_in_database == "admin" + return if another_admin_exists? + + errors.add(:base, "The only admin cannot be deleted.") + throw :abort + end + def avatar_image_must_be_a_supported_image return unless avatar_image.attached? From 276113e09d535866467b6fe74c93a0e589f6e10a Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 14:33:52 -0300 Subject: [PATCH 045/145] fix: keep one bad row from aborting the whole import RecordNotUnique is not RecordInvalid, so a race between validating and inserting escaped the row handler and killed the run. failure_reason is cleared on start, rejections are written as they happen, and trailing blank rows are skipped. The rescue of Continuation::Interrupt is gone: it descends from Exception. --- app/jobs/spreadsheet_import_job.rb | 27 +++++++++++++-------- app/models/spreadsheet_import.rb | 4 --- app/models/spreadsheet_import/row_reader.rb | 12 +++++++-- 3 files changed, 27 insertions(+), 16 deletions(-) diff --git a/app/jobs/spreadsheet_import_job.rb b/app/jobs/spreadsheet_import_job.rb index 9b6880621..4f2410e2b 100644 --- a/app/jobs/spreadsheet_import_job.rb +++ b/app/jobs/spreadsheet_import_job.rb @@ -25,11 +25,8 @@ def perform(spreadsheet_import) broadcast_progress broadcast_dashboard end - # An interruption is the continuation working as designed: the worker is shutting - # down and the job will resume from its cursor. Letting it fall through to the - # rescue below would mark a healthy import as failed. - rescue ActiveJob::Continuation::Interrupt - raise + # Continuation::Interrupt descends from Exception, not StandardError, so a worker + # shutting down mid-file passes through here untouched and resumes from its cursor. rescue StandardError => error # The reason belongs on the record. Otherwise the admin sees a red badge and has to # be told to go read a jobs table to find out what went wrong. @@ -43,7 +40,7 @@ def perform(spreadsheet_import) def start_import(row_count) @import.update!( status: :processing, total_rows: row_count, - processed_rows: 0, failed_rows: 0, row_errors: [] + processed_rows: 0, failed_rows: 0, row_errors: [], failure_reason: nil ) broadcast_progress end @@ -86,16 +83,26 @@ def broadcast_batch(processed) # One malformed line in a thousand must not cost the other nine hundred. def record_row(attributes, line) user = User.new(attributes.merge(password: SecureRandom.base58(24))) - # A role the file does not recognise is not worth failing a row over, and it is - # certainly not worth trusting: unknown values become plain users. + # An unrecognised role is not worth failing a row over, and not worth trusting. user.role = :user unless User.roles.key?(attributes[:role]) if user.save SpreadsheetImport.update_counters(@import.id, processed_rows: 1) else - SpreadsheetImport.update_counters(@import.id, processed_rows: 1, failed_rows: 1) - @row_errors << { "line" => line, "message" => user.errors.full_messages.to_sentence } + reject_row(line, user.errors.full_messages.to_sentence) end + rescue ActiveRecord::RecordNotUnique + # Validation checks uniqueness, then the insert races another writer between the + # two. Without this the whole import dies on a row the file was right about. + reject_row(line, "Email has already been taken") + end + + def reject_row(line, message) + SpreadsheetImport.update_counters(@import.id, processed_rows: 1, failed_rows: 1) + @row_errors << { "line" => line, "message" => message } + # Written straight away rather than with the next batch: rejections are rare, and + # an interruption between here and the batch would lose the reason for one. + persist end # Counters are incremented per row because they are cheap and the bar reads them. diff --git a/app/models/spreadsheet_import.rb b/app/models/spreadsheet_import.rb index 1bd505f3f..235f51c8b 100644 --- a/app/models/spreadsheet_import.rb +++ b/app/models/spreadsheet_import.rb @@ -24,10 +24,6 @@ def progress processed_rows * 100 / total_rows end - def finished? - completed? || failed? - end - def imported_rows processed_rows - failed_rows end diff --git a/app/models/spreadsheet_import/row_reader.rb b/app/models/spreadsheet_import/row_reader.rb index 805783573..3265c2853 100644 --- a/app/models/spreadsheet_import/row_reader.rb +++ b/app/models/spreadsheet_import/row_reader.rb @@ -16,8 +16,13 @@ def initialize(path, extension:) @sheet = Roo::Spreadsheet.open(path.to_s, extension: extension.to_sym) end + # Counted by walking the rows rather than trusting last_row: spreadsheets saved from + # Excel routinely carry trailing empty rows, and counting those makes every one of + # them a phantom rejected row and the progress bar wrong. def row_count - [ @sheet.last_row.to_i - HEADER_ROW, 0 ].max + count = 0 + each_row { count += 1 } + count end # Yields each data row's attributes with its line number in the file, so an error @@ -26,7 +31,10 @@ def each_row header = normalized_header ((HEADER_ROW + 1)..@sheet.last_row.to_i).each do |line| - yield attributes_from(header, @sheet.row(line)), line + attributes = attributes_from(header, @sheet.row(line)) + next if attributes.values.all?(&:blank?) + + yield attributes, line end end From 01faaf919c8898b4b5c1b4becfe8841257b8f52f Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 14:33:52 -0300 Subject: [PATCH 046/145] refactor: replace the submit controller with data-turbo-submits-with Turbo already ships it. Stimulus now previews a chosen avatar instead, which Turbo has no answer for. Registration also gained the rate limit it lacked. --- app/controllers/registrations_controller.rb | 2 ++ app/javascript/controllers/form_controller.js | 20 ------------- .../controllers/image_preview_controller.js | 29 +++++++++++++++++++ .../admin/spreadsheet_imports/new.html.erb | 5 ++-- app/views/admin/users/_form.html.erb | 14 +++++---- app/views/profiles/edit.html.erb | 9 ++++-- app/views/profiles/show.html.erb | 18 +++++++----- app/views/registrations/new.html.erb | 5 ++-- 8 files changed, 61 insertions(+), 41 deletions(-) delete mode 100644 app/javascript/controllers/form_controller.js create mode 100644 app/javascript/controllers/image_preview_controller.js diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 64e9e3e57..f645e2358 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -1,5 +1,7 @@ class RegistrationsController < ApplicationController allow_unauthenticated_access + rate_limit to: 10, within: 3.minutes, only: :create, + with: -> { redirect_to new_registration_path, alert: "Too many attempts. Try again later." } def new @user = User.new diff --git a/app/javascript/controllers/form_controller.js b/app/javascript/controllers/form_controller.js deleted file mode 100644 index a73226994..000000000 --- a/app/javascript/controllers/form_controller.js +++ /dev/null @@ -1,20 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -// Keeps a form from being submitted twice and says so while the request is in flight. -// Turbo already prevents the double navigation, but the button stays enabled and -// unchanged, so on a slow request there is nothing telling anyone the click landed. -export default class extends Controller { - static targets = ["submit"] - static values = { submitting: { type: String, default: "Working…" } } - - connect() { - this.originalLabel = this.submitTarget.value - } - - // Turbo re-enables the button itself when the response renders, so a validation - // error leaves the form usable without any reset of our own. - submitting() { - this.submitTarget.disabled = true - this.submitTarget.value = this.submittingValue - } -} diff --git a/app/javascript/controllers/image_preview_controller.js b/app/javascript/controllers/image_preview_controller.js new file mode 100644 index 000000000..5a3a845cb --- /dev/null +++ b/app/javascript/controllers/image_preview_controller.js @@ -0,0 +1,29 @@ +import { Controller } from "@hotwired/stimulus" + +// Shows the picked image before it is uploaded. Nothing in Turbo covers this: it reads +// a File the browser already has, with no request involved. +export default class extends Controller { + static targets = ["input", "preview", "current"] + + show() { + const [file] = this.inputTarget.files + if (!file) return + + this.#releaseUrl() + this.url = URL.createObjectURL(file) + this.previewTarget.src = this.url + this.previewTarget.hidden = false + this.currentTarget.hidden = true + } + + // An object URL pins the file in memory until it is revoked, and Turbo caches pages + // rather than reloading them, so without this the leak survives navigation. + disconnect() { + this.#releaseUrl() + } + + #releaseUrl() { + if (this.url) URL.revokeObjectURL(this.url) + this.url = null + } +} diff --git a/app/views/admin/spreadsheet_imports/new.html.erb b/app/views/admin/spreadsheet_imports/new.html.erb index d3ba5f2d9..32d71cf74 100644 --- a/app/views/admin/spreadsheet_imports/new.html.erb +++ b/app/views/admin/spreadsheet_imports/new.html.erb @@ -9,8 +9,7 @@

<%= render "shared/form_errors", model: @spreadsheet_import %> - <%= form_with model: @spreadsheet_import, url: admin_spreadsheet_imports_path, class: "space-y-5", - data: { controller: "form", action: "submit->form#submitting", form_submitting_value: "Uploading…" } do |form| %> + <%= form_with model: @spreadsheet_import, url: admin_spreadsheet_imports_path, class: "space-y-5" do |form| %>
<%= form.label :file, "Spreadsheet", class: "field-label" %> <%= form.file_field :file, required: true, accept: ".csv,.xlsx", @@ -32,7 +31,7 @@
- <%= form.submit "Start import", class: "btn-primary", data: { form_target: "submit" } %> + <%= form.submit "Start import", class: "btn-primary", data: { turbo_submits_with: "Uploading…" } %> <%= link_to "Cancel", admin_spreadsheet_imports_path, class: "btn-secondary" %>
<% end %> diff --git a/app/views/admin/users/_form.html.erb b/app/views/admin/users/_form.html.erb index 67e7ac032..2f97a062b 100644 --- a/app/views/admin/users/_form.html.erb +++ b/app/views/admin/users/_form.html.erb @@ -1,5 +1,4 @@ -<%= form_with model: user, url: url, class: "space-y-5", - data: { controller: "form", action: "submit->form#submitting", form_submitting_value: "Saving…" } do |form| %> +<%= form_with model: user, url: url, class: "space-y-5" do |form| %> <%= render "shared/form_errors", model: user %>
@@ -20,9 +19,14 @@
<%= form.label :avatar_image, "Avatar", class: "field-label" %> -
- <%= render "shared/avatar", user: user, size: "size-14" %> +
+ + + <%= render "shared/avatar", user: user, size: "size-14" %> + <%= form.file_field :avatar_image, accept: User::AVATAR_CONTENT_TYPES.join(","), + data: { image_preview_target: "input", action: "image-preview#show" }, class: "field-input file:mr-3 file:rounded-md file:border-0 file:bg-slate-100 file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-slate-700" %>
@@ -45,7 +49,7 @@
- <%= form.submit submit_label, class: "btn-primary", data: { form_target: "submit" } %> + <%= form.submit submit_label, class: "btn-primary", data: { turbo_submits_with: "Saving…" } %> <%= link_to "Cancel", admin_users_path, class: "btn-secondary" %>
<% end %> diff --git a/app/views/profiles/edit.html.erb b/app/views/profiles/edit.html.erb index c7efb26e4..a93a76b67 100644 --- a/app/views/profiles/edit.html.erb +++ b/app/views/profiles/edit.html.erb @@ -19,9 +19,14 @@
<%= form.label :avatar_image, "Avatar", class: "field-label" %> -
- <%= render "shared/avatar", user: @user, size: "size-14" %> +
+ + + <%= render "shared/avatar", user: @user, size: "size-14" %> + <%= form.file_field :avatar_image, accept: User::AVATAR_CONTENT_TYPES.join(","), + data: { image_preview_target: "input", action: "image-preview#show" }, class: "field-input file:mr-3 file:rounded-md file:border-0 file:bg-slate-100 file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-slate-700" %>
diff --git a/app/views/profiles/show.html.erb b/app/views/profiles/show.html.erb index 0e19eb259..d5a078dee 100644 --- a/app/views/profiles/show.html.erb +++ b/app/views/profiles/show.html.erb @@ -15,15 +15,17 @@
-
-
Role
-
"><%= @user.role.capitalize %>
-
+
+
+
Role
+
"><%= @user.role.capitalize %>
+
-
-
Member since
-
<%= @user.created_at.to_date.to_fs(:long) %>
-
+
+
Member since
+
<%= @user.created_at.to_date.to_fs(:long) %>
+
+
diff --git a/app/views/registrations/new.html.erb b/app/views/registrations/new.html.erb index 688773c01..d8965983e 100644 --- a/app/views/registrations/new.html.erb +++ b/app/views/registrations/new.html.erb @@ -7,8 +7,7 @@ <%= render "shared/form_errors", model: @user %> - <%= form_with model: @user, url: registration_path, class: "mt-6 space-y-5", - data: { controller: "form", action: "submit->form#submitting", form_submitting_value: "Creating…" } do |form| %> + <%= form_with model: @user, url: registration_path, class: "mt-6 space-y-5" do |form| %>
<%= form.label :full_name, "Full name", class: "field-label" %> <%= form.text_field :full_name, required: true, autofocus: true, autocomplete: "name", @@ -34,7 +33,7 @@ minlength: 8, maxlength: 72, placeholder: "••••••••", class: "field-input" %>
- <%= form.submit "Create account", class: "btn-primary w-full", data: { form_target: "submit" } %> + <%= form.submit "Create account", class: "btn-primary w-full", data: { turbo_submits_with: "Creating…" } %> <% end %>

From 41f21de83bc6b31706e089b0de6c88190cb78c30 Mon Sep 17 00:00:00 2001 From: Matheus Fontoura Date: Thu, 3 Sep 2026 14:33:52 -0300 Subject: [PATCH 047/145] feat: enforce a content security policy, and test XSS and CSRF default-src 'none' with a per-response nonce. The nonce is random, not the session id, which is empty for a visitor with no session and blocks the import map. Only SQL injection had a test; there are now seven. --- .../initializers/content_security_policy.rb | 55 ++++++------ test/integration/security_test.rb | 87 +++++++++++++++++++ 2 files changed, 114 insertions(+), 28 deletions(-) create mode 100644 test/integration/security_test.rb diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index d51d71397..7d0363cbe 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -1,29 +1,28 @@ -# Be sure to restart your server when you modify this file. - -# Define an application-wide content security policy. -# See the Securing Rails Applications Guide for more information: -# https://guides.rubyonrails.org/security.html#content-security-policy-header - -# Rails.application.configure do -# config.content_security_policy do |policy| -# policy.default_src :self, :https -# policy.font_src :self, :https, :data -# policy.img_src :self, :https, :data -# policy.object_src :none -# policy.script_src :self, :https -# policy.style_src :self, :https -# # Specify URI for violation reports -# # policy.report_uri "/csp-violation-report-endpoint" -# end -# -# # Generate session nonces for permitted importmap, inline scripts, and inline styles. -# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } -# config.content_security_policy_nonce_directives = %w(script-src style-src) +# The second line of defence against XSS, after ERB's escaping. Escaping can be +# defeated by one careless `html_safe`; this cannot, because the browser refuses to run +# a script the policy did not allow. # -# # Automatically add `nonce` to `javascript_tag`, `javascript_include_tag`, and `stylesheet_link_tag` -# # if the corresponding directives are specified in `content_security_policy_nonce_directives`. -# # config.content_security_policy_nonce_auto = true -# -# # Report violations without enforcing the policy. -# # config.content_security_policy_report_only = true -# end +# Everything is served from this origin: Propshaft ships the CSS and the import map +# ships the JavaScript, so there is no CDN to allow. Avatars are Active Storage blobs +# from :self, and `data:` covers the object URLs the avatar preview creates. +Rails.application.configure do + config.content_security_policy do |policy| + policy.default_src :none + policy.base_uri :self + policy.form_action :self + policy.frame_ancestors :none + policy.connect_src :self + policy.font_src :self + policy.img_src :self, :data, :blob + policy.object_src :none + policy.script_src :self + policy.style_src :self + end + + # The import map is an inline ") + + get admin_users_path + + assert_response :success + assert_no_match "" } ]) + + get admin_spreadsheet_import_path(import) + + assert_no_match "