From dd88ca24bd73118814aff5d77f439fef265acbcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B4nio=20Neto?= Date: Thu, 3 Sep 2026 14:12:07 -0300 Subject: [PATCH 01/33] chore: initialize Rails application and development environment Generate the Rails 8.1 application on Ruby 4.0 and wire up a Docker-first development environment. Infrastructure notes: - The Dockerfile keeps the Rails 8 production defaults (multi-stage build, non-root user, jemalloc, assets precompiled with SECRET_KEY_BASE_DUMMY, Thruster as the web entrypoint) and adds a healthcheck plus a development stage carrying the build tools and headless Chromium the system specs need. - Development and test mirror the production database topology: Solid Cache, Solid Queue and Solid Cable each get their own database in every environment. The generators only configure production, which leaves development running the queue in-process and Action Cable on the async adapter -- a setup that appears to work only because a single process is doing everything. Compose therefore runs the worker and the Tailwind watcher as separate services. - Test databases are suffixed with TEST_ENV_NUMBER so the suite can run across parallel workers. - The omakase RuboCop preset is replaced by an explicit, stricter rule set covering Rails, RSpec, Capybara, FactoryBot and performance cops. Every relaxation in .rubocop.yml carries its reason. Host-side entrypoints live in bin/ (setup, dev, test, lint, ci) and per-service operational wrappers in devops/, both driving Docker. config/ci.rb is the single definition of the verification pipeline. Verified: the stack boots, /up returns 200, Solid Queue registers its processes in the dedicated queue database, RuboCop is clean and Brakeman, Bundler Audit and the importmap audit report no findings. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BS3oGqeR1PXScuK6gLr8Mi --- .dockerignore | 63 ++ .env.example | 13 + .gitattributes | 9 + .github/dependabot.yml | 12 + .github/workflows/ci.yml | 67 ++ .gitignore | 43 ++ .rspec | 1 + .rubocop.yml | 81 +++ .ruby-version | 1 + Dockerfile | 104 +++ Gemfile | 75 ++ Gemfile.lock | 641 ++++++++++++++++++ Procfile.dev | 3 + Rakefile | 6 + app/assets/builds/.keep | 0 app/assets/images/.keep | 0 app/assets/stylesheets/application.css | 10 + app/assets/tailwind/application.css | 2 + app/assets/tailwind/tokens.css | 83 +++ 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 | 12 + bin/ci-run | 6 + bin/dev | 25 + bin/docker-entrypoint | 8 + bin/importmap | 4 + bin/jobs | 6 + bin/lint | 7 + bin/rails | 4 + bin/rake | 4 + bin/rubocop | 8 + bin/setup | 59 ++ bin/test | 17 + bin/thrust | 5 + config.ru | 6 + config/application.rb | 42 ++ config/boot.rb | 4 + config/bundler-audit.yml | 5 + config/cable.yml | 20 + config/cache.yml | 16 + config/ci.rb | 18 + config/credentials.yml.enc | 1 + config/database.yml | 67 ++ config/environment.rb | 5 + config/environments/development.rb | 85 +++ config/environments/production.rb | 90 +++ config/environments/test.rb | 53 ++ 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 + devops/common.sh | 67 ++ devops/postgres/dump.sh | 11 + devops/postgres/logs.sh | 5 + devops/postgres/psql.sh | 6 + devops/rails/console.sh | 5 + devops/rails/lint.sh | 5 + devops/rails/logs.sh | 5 + devops/rails/migrate.sh | 7 + devops/rails/security.sh | 15 + devops/rails/test-parallel.sh | 9 + devops/rails/test.sh | 14 + devops/tests/all.sh | 9 + devops/worker/logs.sh | 5 + devops/worker/status.sh | 10 + docker-compose.yml | 124 ++++ 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 spec/rails_helper.rb | 72 ++ spec/spec_helper.rb | 92 +++ storage/.keep | 0 tmp/.keep | 0 tmp/pids/.keep | 0 tmp/storage/.keep | 0 vendor/.keep | 0 vendor/javascript/.keep | 0 112 files changed, 3489 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .rspec create mode 100644 .rubocop.yml 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/assets/tailwind/tokens.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/ci-run 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/lint create mode 100755 bin/rails create mode 100755 bin/rake create mode 100755 bin/rubocop create mode 100755 bin/setup create mode 100755 bin/test 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/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 100755 devops/common.sh create mode 100755 devops/postgres/dump.sh create mode 100755 devops/postgres/logs.sh create mode 100755 devops/postgres/psql.sh create mode 100755 devops/rails/console.sh create mode 100755 devops/rails/lint.sh create mode 100755 devops/rails/logs.sh create mode 100755 devops/rails/migrate.sh create mode 100755 devops/rails/security.sh create mode 100755 devops/rails/test-parallel.sh create mode 100755 devops/rails/test.sh create mode 100755 devops/tests/all.sh create mode 100755 devops/worker/logs.sh create mode 100755 devops/worker/status.sh create mode 100644 docker-compose.yml 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 spec/rails_helper.rb create mode 100644 spec/spec_helper.rb create mode 100644 storage/.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..eee99fa8b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,63 @@ +# 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* + +# Ignore the test suite and coverage output: they are not needed in the +# production image and only make it larger. +/spec +/coverage +/.rspec_status + +# Ignore host-side operational scripts; they drive Docker from outside. +/devops + +# Ignore the internal planning document. +/PLANO_TESTE_FULLSTACK_UMANNI.md diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..04d5673f6 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# Copy to .env (bin/setup does it for you). These values are development-only; +# production configuration is supplied by Kamal secrets, never by this file. + +# ── PostgreSQL ─────────────────────────────────────────────────────────────── +POSTGRES_USER=user_management +POSTGRES_PASSWORD=development_only +POSTGRES_DB=user_management_development +TEST_POSTGRES_DB=user_management_test + +# ── Application ────────────────────────────────────────────────────────────── +# Host port for the web server. +WEB_PORT=3000 +RAILS_MAX_THREADS=5 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/.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..d58c2aa4c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,67 @@ +name: CI + +on: + pull_request: + push: + branches: [ main ] + +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', '**/.rubocop_todo.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 + diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..fe9d0d5bf --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# 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, but keep the documented template. +/.env* +!/.env.example + +# 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 + + +# Ignore coverage reports and RSpec run state. +/coverage +/.rspec_status + +/app/assets/builds/* +!/app/assets/builds/.keep diff --git a/.rspec b/.rspec new file mode 100644 index 000000000..c99d2e739 --- /dev/null +++ b/.rspec @@ -0,0 +1 @@ +--require spec_helper diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 000000000..2eb101b12 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,81 @@ +# Strict house style. The omakase preset was deliberately dropped in favour of +# an explicit rule set: every relaxation below is listed with its reason, so +# the configuration documents the trade-offs instead of hiding them. + +plugins: + - rubocop-rails + - rubocop-rspec + - rubocop-rspec_rails + - rubocop-capybara + - rubocop-factory_bot + - rubocop-performance + +AllCops: + NewCops: enable + DisplayCopNames: true + DisplayStyleGuide: true + Exclude: + - "db/schema.rb" + - "db/*_schema.rb" + - "db/migrate/*_solid_*.rb" + - "vendor/**/*" + - "tmp/**/*" + - "storage/**/*" + - "bin/bundle" + +# ── Layout ─────────────────────────────────────────────────────────────────── +Layout/LineLength: + Max: 120 + +# ── Style ──────────────────────────────────────────────────────────────────── +Style/Documentation: + # Class names and method names carry the intent here; mandatory top-of-class + # comments would add ceremony without adding information. + Enabled: false + +Style/StringLiterals: + EnforcedStyle: double_quotes + +Style/StringLiteralsInInterpolation: + EnforcedStyle: double_quotes + +Style/FrozenStringLiteralComment: + # Ruby 4 freezes string literals in files without the magic comment under + # the chilled-strings behaviour, so the annotation is noise. + Enabled: false + +# ── Metrics ────────────────────────────────────────────────────────────────── +# Kept enabled on purpose: these are the cops that keep methods small. +Metrics/BlockLength: + AllowedMethods: + - describe + - context + - shared_examples + - configure + - draw + +Metrics/MethodLength: + Max: 15 + +Metrics/AbcSize: + Max: 20 + +# ── Rails ──────────────────────────────────────────────────────────────────── +Rails/SkipsModelValidations: + # Import counters are advanced with atomic updates on purpose; validating a + # counter bump on every row would serialise the import for no benefit. + AllowedMethods: + - increment! + - update_columns + - touch + +# ── RSpec ──────────────────────────────────────────────────────────────────── +RSpec/ExampleLength: + Max: 12 + +RSpec/MultipleExpectations: + # A request spec legitimately asserts on status, redirect and side effect. + Max: 4 + +RSpec/NestedGroups: + Max: 4 diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 000000000..1cf76f52b --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +ruby-4.0.6 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..b70525c39 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,104 @@ +# syntax=docker/dockerfile:1 +# check=error=true + +# Multi-stage build for the user management application. +# +# base → shared runtime layer +# build → compiles gems and precompiles assets (thrown away) +# development → adds build tools, dev/test gems and headless Chrome +# final → lean production image, runs as non-root behind Thruster +# +# Production build: docker build -t user_management . +# Development build: docker build --target development -t user_management-dev . + +ARG RUBY_VERSION=4.0.6 +FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base + +WORKDIR /rails + +# Runtime packages only. libvips backs Active Storage avatar variants, +# postgresql-client is what bin/docker-entrypoint waits on. +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y curl libjemalloc2 libvips postgresql-client && \ + 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 + +ENV RAILS_ENV="production" \ + BUNDLE_DEPLOYMENT="1" \ + BUNDLE_PATH="/usr/local/bundle" \ + BUNDLE_WITHOUT="development:test" \ + LD_PRELOAD="/usr/local/lib/libjemalloc.so" + + +# ─── Build stage (discarded) ──────────────────────────────────────────────── +FROM base AS build + +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y build-essential git libpq-dev libyaml-dev pkg-config && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +COPY Gemfile Gemfile.lock ./ +RUN bundle install && \ + rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \ + bundle exec bootsnap precompile -j 1 --gemfile + +COPY . . + +RUN bundle exec bootsnap precompile -j 1 app/ lib/ + +# Assets are compiled at build time so the production image never needs the +# real credentials: the dummy key satisfies the initializers and is discarded. +RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile + + +# ─── Development stage ────────────────────────────────────────────────────── +# Used by docker-compose. Application code is bind-mounted, so only the gems +# and system dependencies are baked in. +FROM base AS development + +ENV RAILS_ENV="development" \ + BUNDLE_DEPLOYMENT="0" \ + BUNDLE_WITHOUT="" + +# chromium is what Cuprite drives over CDP for the Capybara system specs. +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y \ + build-essential git libpq-dev libyaml-dev pkg-config \ + chromium fonts-liberation && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +ENV BROWSER_PATH="/usr/bin/chromium" + +COPY Gemfile Gemfile.lock ./ +RUN bundle install && rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache + +# uid/gid 1000 matches the common host user, which keeps bind-mounted files +# writable from both sides without a chown dance. +RUN groupadd --system --gid 1000 rails && \ + useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \ + chown -R rails:rails /rails "${BUNDLE_PATH}" +USER 1000:1000 + +EXPOSE 3000 +CMD ["./bin/rails", "server", "-b", "0.0.0.0", "-p", "3000"] + + +# ─── Final production stage ───────────────────────────────────────────────── +FROM base AS final + +RUN groupadd --system --gid 1000 rails && \ + useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash +USER 1000:1000 + +COPY --chown=rails:rails --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" +COPY --chown=rails:rails --from=build /rails /rails + +ENTRYPOINT ["/rails/bin/docker-entrypoint"] + +EXPOSE 80 + +# Rails 8 serves /up from Rails::HealthController; Thruster fronts it on 80. +HEALTHCHECK --interval=15s --timeout=5s --start-period=40s --retries=5 \ + CMD curl -sf http://localhost/up || exit 1 + +CMD ["./bin/thrust", "./bin/rails", "server"] diff --git a/Gemfile b/Gemfile new file mode 100644 index 000000000..40966f864 --- /dev/null +++ b/Gemfile @@ -0,0 +1,75 @@ +source "https://rubygems.org" + +gem "rails", "~> 8.1.3", ">= 8.1.3.1" + +# Asset pipeline and front end +gem "importmap-rails" +gem "propshaft" +gem "stimulus-rails" +gem "tailwindcss-rails" +gem "turbo-rails" + +# Database and server +gem "pg", "~> 1.1" +gem "puma", ">= 5.0" + +# Database-backed adapters for Rails.cache, Active Job and Action Cable. +# Solid Cable is what carries the live dashboard and import progress updates. +gem "solid_cable" +gem "solid_cache" +gem "solid_queue" + +# Active Storage variants for avatars +gem "image_processing", "~> 1.2" + +# Spreadsheet parsing for user imports. `csv` left the default gems in Ruby 3.4, +# so it has to be declared explicitly on Ruby 4. +gem "csv" +gem "roo", "~> 3.0" + +# Pagination for the admin user list +gem "pagy", "~> 9.4" + +gem "bootsnap", require: false + +# Deployment +gem "kamal", require: false +gem "thruster", require: false + +gem "tzinfo-data", platforms: %i[windows jruby] + +group :development, :test do + gem "debug", platforms: %i[mri windows], require: "debug/prelude" + + gem "factory_bot_rails", "~> 6.5" + gem "faker", "~> 3.5" + gem "rspec-rails", "~> 8.0" + + # Security analysis + gem "brakeman", require: false + gem "bundler-audit", require: false + + # Style. The omakase preset is deliberately replaced by an explicit, stricter + # rule set covering Rails, RSpec, Capybara and performance cops. + gem "rubocop", require: false + gem "rubocop-capybara", require: false + gem "rubocop-factory_bot", require: false + gem "rubocop-performance", require: false + gem "rubocop-rails", require: false + gem "rubocop-rspec", require: false + gem "rubocop-rspec_rails", require: false +end + +group :test do + gem "capybara" + # Cuprite drives headless Chrome over CDP directly, which keeps system specs + # fast and removes the chromedriver version dance. + gem "cuprite" + gem "parallel_tests", require: false + gem "shoulda-matchers", "~> 6.4" + gem "simplecov", require: false +end + +group :development do + gem "web-console" +end diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 000000000..0b65425b9 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,641 @@ +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) + cuprite (0.18) + capybara (~> 3.0) + ferrum (~> 0.18.0) + date (3.5.1) + debug (1.11.1) + irb (~> 1.10) + reline (>= 0.3.8) + diff-lcs (1.6.2) + 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 + factory_bot (6.6.0) + activesupport (>= 6.1.0) + factory_bot_rails (6.5.1) + factory_bot (~> 6.5) + railties (>= 6.1.0) + faker (3.8.0) + i18n (>= 1.8.11, < 2) + ferrum (0.18.0) + addressable (~> 2.5) + base64 (~> 0.2) + concurrent-ruby (~> 1.1) + websocket-driver (~> 0.7) + 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) + pagy (9.4.0) + parallel (2.1.0) + parallel_tests (5.7.0) + parallel + parser (3.3.12.0) + ast (~> 2.4.1) + racc + pg (1.6.3) + pg (1.6.3-aarch64-linux) + pg (1.6.3-aarch64-linux-musl) + pg (1.6.3-x86_64-linux) + pg (1.6.3-x86_64-linux-musl) + 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) + roo (3.0.0) + base64 (~> 0.2) + csv (~> 3) + logger (~> 1) + nokogiri (~> 1) + rubyzip (>= 3.0.0, < 4.0.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.8) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-rails (8.0.4) + actionpack (>= 7.2) + activesupport (>= 7.2) + railties (>= 7.2) + rspec-core (>= 3.13.0, < 5.0.0) + rspec-expectations (>= 3.13.0, < 5.0.0) + rspec-mocks (>= 3.13.0, < 5.0.0) + rspec-support (>= 3.13.0, < 5.0.0) + rspec-support (3.13.7) + 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-capybara (3.0.0) + lint_roller (~> 1.1) + rubocop (~> 1.81) + rubocop-factory_bot (2.28.0) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) + 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-rspec (3.10.2) + lint_roller (~> 1.1) + regexp_parser (>= 2.0) + rubocop (~> 1.86, >= 1.86.2) + rubocop-rspec_rails (2.32.0) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) + rubocop-rspec (~> 3.5) + ruby-progressbar (1.13.0) + ruby-vips (2.3.0) + ffi (~> 1.12) + logger + rubyzip (3.6.0) + securerandom (0.4.1) + shoulda-matchers (6.5.0) + activesupport (>= 5.2.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) + 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-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-gnu + x86_64-linux-musl + +DEPENDENCIES + bootsnap + brakeman + bundler-audit + capybara + csv + cuprite + debug + factory_bot_rails (~> 6.5) + faker (~> 3.5) + image_processing (~> 1.2) + importmap-rails + kamal + pagy (~> 9.4) + parallel_tests + pg (~> 1.1) + propshaft + puma (>= 5.0) + rails (~> 8.1.3, >= 8.1.3.1) + roo (~> 3.0) + rspec-rails (~> 8.0) + rubocop + rubocop-capybara + rubocop-factory_bot + rubocop-performance + rubocop-rails + rubocop-rspec + rubocop-rspec_rails + shoulda-matchers (~> 6.4) + simplecov + solid_cable + solid_cache + solid_queue + 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 + cuprite (0.18) sha256=32c3203a492f25dbd5a3525716ae09610bcef8936ddd32ff39f34477a98062b2 + date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 + debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6 + diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 + 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 + factory_bot (6.6.0) sha256=1fc1b3b5620ec980a6a27aec1b6ec8c250ca82962e970e8a40f93e8d388d4b89 + factory_bot_rails (6.5.1) sha256=d3cc4851eae4dea8a665ec4a4516895045e710554d2b5ac9e68b94d351bc6d68 + faker (3.8.0) sha256=c147b308df73a90f27a4fc84f18d4c22ef0ad9c2a64b2b61c86fd0ca71753efc + ferrum (0.18.0) sha256=4cb8be16e352fc1d75f087e9214b34ec1b93ba932410c730a4724909ca89d7c6 + 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 + pagy (9.4.0) sha256=db3f2e043f684155f18f78be62a81e8d033e39b9f97b1e1a8d12ad38d7bce738 + parallel (2.1.0) sha256=b35258865c2e31134c5ecb708beaaf6772adf9d5efae28e93e99260877b09356 + parallel_tests (5.7.0) sha256=3f1762c46ca2c223b8af8ef877217f9d76974e191bfa934f2580b58bcf1d005c + parser (3.3.12.0) sha256=21a6d7f755d5a24dfbdc6e6b772e4e879a52e7631a88bc5a3a134606052c9828 + pg (1.6.3) sha256=1388d0563e13d2758c1089e35e973a3249e955c659592d10e5b77c468f628a99 + pg (1.6.3-aarch64-linux) sha256=0698ad563e02383c27510b76bf7d4cd2de19cd1d16a5013f375dd473e4be72ea + pg (1.6.3-aarch64-linux-musl) sha256=06a75f4ea04b05140146f2a10550b8e0d9f006a79cdaf8b5b130cde40e3ecc2c + pg (1.6.3-x86_64-linux) sha256=5d9e188c8f7a0295d162b7b88a768d8452a899977d44f3274d1946d67920ae8d + pg (1.6.3-x86_64-linux-musl) sha256=9c9c90d98c72f78eb04c0f55e9618fe55d1512128e411035fe229ff427864009 + 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 + roo (3.0.0) sha256=6fdd7a9158d657c69768b4168754ff2110cc21fdc01a1bec1010820cb05c91b1 + rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d + rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 + rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47 + rspec-rails (8.0.4) sha256=06235692fc0892683d3d34977e081db867434b3a24ae0dd0c6f3516bad4e22df + rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c + rubocop (1.90.0) sha256=9eb4c065b5c5154e4ef554c547972f3905a9eb6b53e657e580b6796b54bf8242 + rubocop-ast (1.50.0) sha256=b9ca88300da0803ee222ad20cdb30494c0a784eed06fdc35d254b06d662788db + rubocop-capybara (3.0.0) sha256=7a64655238acda7f8f3c87e37ac825a64c615a79c17c253f1a28270dc3768c4b + rubocop-factory_bot (2.28.0) sha256=4b17fc02124444173317e131759d195b0d762844a71a29fe8139c1105d92f0cb + rubocop-performance (1.27.0) sha256=eeeb1374d062a368ee1c787b70eb0b0cc4b184cb1f8565f424760946146d61ce + rubocop-rails (2.37.0) sha256=6e1645add5060e0328f8ddda0d820f55697c591394398bf14bb9dccb62f14b7e + rubocop-rspec (3.10.2) sha256=0b3e2ecc592cd10ecbf0095bb58d1e357905276e069643523cc19eb7495f65e2 + rubocop-rspec_rails (2.32.0) sha256=4a0d641c72f6ebb957534f539d9d0a62c47abd8ce0d0aeee1ef4701e892a9100 + ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 + ruby-vips (2.3.0) sha256=e685ec02c13969912debbd98019e50492e12989282da5f37d05f5471442f5374 + rubyzip (3.6.0) sha256=268994d44d62282d1cfd99bf10eae48d7267199158ad7ea3e1fee2da9458b695 + securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 + shoulda-matchers (6.5.0) sha256=ef6b572b2bed1ac4aba6ab2c5ff345a24b6d055a93a3d1c3bfc86d9d499e3f44 + 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 + 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-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/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..f840f7106 --- /dev/null +++ b/app/assets/tailwind/application.css @@ -0,0 +1,2 @@ +@import "tailwindcss"; +@import "./tokens.css"; diff --git a/app/assets/tailwind/tokens.css b/app/assets/tailwind/tokens.css new file mode 100644 index 000000000..0d49ed1e9 --- /dev/null +++ b/app/assets/tailwind/tokens.css @@ -0,0 +1,83 @@ +/* + * Design tokens. + * + * The visual language is taken from the Onix design system, which ships a + * triple theme system (data-accent / data-shell / data-sidebar). Two of its + * themes are adopted here rather than its default look: the "verde" accent and + * the "porcelana" / "marfim" light shells. The dark-and-gold default was left + * behind on purpose — it reads as a generic admin template, and the muted + * green on eggshell keeps the same restraint with far less visual noise. + * + * Contrast was checked against the page background (#F2EEE8, L = 0.859): + * --color-accent-strong #2D5C47 6.2:1 body text, links + * --color-accent #3D7A5F 4.4:1 fills, borders, icons — not text + * --color-ink #27231F 15.1:1 primary text + * --color-muted #6D655D 5.0:1 secondary text + * --color-dim #7B736A 4.0:1 large text (18.66px bold / 24px) + * and decoration only + * white on accent-strong 7.1:1 primary buttons + */ + +@theme static { + /* ── Surfaces ─────────────────────────────────────────────────────── */ + --color-canvas: #f2eee8; + --color-surface: #fbf8f4; + --color-surface-raised: #e7dfd2; + --color-surface-soft: rgb(55 50 44 / 0.04); + --color-surface-soft-hover: rgb(55 50 44 / 0.065); + + /* ── Text ─────────────────────────────────────────────────────────── */ + --color-ink: #27231f; + --color-muted: #6d655d; + --color-dim: #7b736a; + + /* ── Accent (Onix "verde") ────────────────────────────────────────── */ + --color-accent-soft: #4e9678; + --color-accent: #3d7a5f; + --color-accent-strong: #2d5c47; + --color-accent-wash: rgb(61 122 95 / 0.10); + + /* ── Borders ──────────────────────────────────────────────────────── */ + --color-line: rgb(154 133 96 / 0.16); + --color-line-strong: rgb(154 133 96 / 0.24); + + /* ── Sidebar (Onix "marfim") ──────────────────────────────────────── */ + --color-rail: #f6f1e8; + --color-rail-edge: #e8decf; + --color-rail-text: #746b63; + --color-rail-text-hover: #27221d; + --color-rail-active: rgb(61 122 95 / 0.14); + + /* ── Status ───────────────────────────────────────────────────────── */ + --color-positive: #2d6a4f; + --color-caution: #8a6d1f; + --color-critical: #9b3232; + + /* ── Typography ───────────────────────────────────────────────────── */ + --font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", + Roboto, "Helvetica Neue", Arial, sans-serif; + + --tracking-eyebrow: 0.24em; + --tracking-wide-label: 0.18em; + + --leading-tight: 1.02; + --leading-snug: 1.375; + --leading-relaxed: 1.625; + + /* ── Radii ────────────────────────────────────────────────────────── */ + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 20px; + --radius-card: 22px; + + /* ── Shadows ──────────────────────────────────────────────────────── */ + --shadow-card: 0 1px 4px rgb(44 37 26 / 0.06), 0 4px 16px rgb(44 37 26 / 0.05); + --shadow-panel: 0 20px 50px rgb(44 37 26 / 0.10); + --shadow-focus: 0 0 0 3px rgb(61 122 95 / 0.18); + + /* ── Layout ───────────────────────────────────────────────────────── */ + --spacing-rail: 240px; + --spacing-topbar: 60px; + --spacing-content-max: 1440px; +} 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..316426f98 --- /dev/null +++ b/app/views/layouts/application.html.erb @@ -0,0 +1,31 @@ + + + + <%= content_for(:title) || "User Management" %> + + + + + <%= 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..2b01344e6 --- /dev/null +++ b/app/views/pwa/manifest.json.erb @@ -0,0 +1,22 @@ +{ + "name": "UserManagement", + "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": "UserManagement.", + "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..38c6719ed --- /dev/null +++ b/bin/bundler-audit @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "bundler/audit/cli" + +ARGV.push("--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..dd588b19a --- /dev/null +++ b/bin/ci @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# +# Full verification, exactly as CI runs it: style, security and tests. +# The steps themselves live in config/ci.rb so there is a single definition; +# this script only makes sure they run inside the container. +# +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/devops/common.sh" + +require_docker +step "Running the CI pipeline in the web container" +rails_test_exec ./bin/ci-run diff --git a/bin/ci-run b/bin/ci-run new file mode 100755 index 000000000..e4742900f --- /dev/null +++ b/bin/ci-run @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "active_support/continuous_integration" + +CI = ActiveSupport::ContinuousIntegration +require_relative "../config/ci" diff --git a/bin/dev b/bin/dev new file mode 100755 index 000000000..001cc0e65 --- /dev/null +++ b/bin/dev @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# +# Starts the development stack (web, worker, css, postgres) with Docker. +# +# bin/dev # foreground, streaming logs +# bin/dev --detach # background +# bin/dev --down # stop everything +# +# Without Docker, run the processes directly instead: +# bundle exec foreman start -f Procfile.dev +# +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/devops/common.sh" + +require_docker +[[ -f .env ]] || fail "No .env found. Run bin/setup first." + +case "${1:-}" in + --down) step "Stopping"; compose down; ok "Stopped." ;; + --detach) step "Starting in the background"; compose up --detach --wait + ok "Running at http://localhost:${WEB_PORT:-3000}" ;; + --help|-h) sed -n '2,12p' "$0" ;; + "") step "Starting"; compose up ;; + *) fail "Unknown option: $1" ;; +esac 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/lint b/bin/lint new file mode 100755 index 000000000..37172391a --- /dev/null +++ b/bin/lint @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# +# Runs RuboCop inside the container. Pass -a or -A to autocorrect. +# +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +exec "${ROOT}/devops/rails/lint.sh" "$@" 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..82b54bb65 --- /dev/null +++ b/bin/setup @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# +# Prepares the development environment from a clean checkout. +# +# bin/setup # build images, create databases, seed +# bin/setup --reset # additionally drop the existing data volume +# bin/setup --no-seed # skip seeding +# +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/devops/common.sh" + +RESET_VOLUMES=false +SEED=true +for arg in "$@"; do + case "${arg}" in + --reset) RESET_VOLUMES=true ;; + --no-seed) SEED=false ;; + --help|-h) sed -n '2,9p' "$0"; exit 0 ;; + *) fail "Unknown option: ${arg}" ;; + esac +done + +require_docker + +step "Environment file" +if [[ -f .env ]]; then + log ".env already exists, leaving it untouched." +else + cp .env.example .env + ok "Created .env from .env.example." +fi + +if [[ "${RESET_VOLUMES}" == true ]]; then + step "Resetting containers and data volume" + compose down --volumes --remove-orphans + ok "Previous environment removed." +fi + +step "Building images" +compose build + +step "Starting PostgreSQL" +compose up --detach --wait postgres + +step "Preparing databases" +# db:prepare creates and migrates all four databases: primary, cache, queue +# and cable. +compose run --rm --no-deps web ./bin/rails db:prepare + +if [[ "${SEED}" == true ]]; then + step "Seeding" + compose run --rm --no-deps web ./bin/rails db:seed +fi + +step "Starting the full stack" +compose up --detach --wait + +ok "Ready. The application is at http://localhost:${WEB_PORT:-3000}" +log "Logs: devops/rails/logs.sh Tests: bin/test Console: devops/rails/console.sh" diff --git a/bin/test b/bin/test new file mode 100755 index 000000000..b8dff7e79 --- /dev/null +++ b/bin/test @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# +# Runs the test suite inside the container. +# +# bin/test # whole suite +# bin/test spec/models/user_spec.rb # a single file +# bin/test --parallel # across parallel workers +# +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +if [[ "${1:-}" == "--parallel" ]]; then + shift + exec "${ROOT}/devops/rails/test-parallel.sh" "$@" +fi + +exec "${ROOT}/devops/rails/test.sh" "$@" 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..f2e247e3a --- /dev/null +++ b/config/application.rb @@ -0,0 +1,42 @@ +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 UserManagement + 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") + + # Don't generate system test files. + config.generators.system_tests = nil + 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..36f0fd70a --- /dev/null +++ b/config/cable.yml @@ -0,0 +1,20 @@ +# Solid Cable in every environment except test, where the in-memory test +# adapter lets specs assert on broadcasts directly. +development: + adapter: solid_cable + connects_to: + database: + writing: cable + polling_interval: 0.1.seconds + message_retention: 1.day + +test: + adapter: test + +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..87836a53f --- /dev/null +++ b/config/ci.rb @@ -0,0 +1,18 @@ +# The single definition of the verification pipeline. +# +# Run it with bin/ci from the host (which executes it inside the container), +# or with bin/ci-run from inside a container that already has the gems. + +CI.run do + step "Database: prepare test schema", "bin/rails db:test:prepare" + + step "Style: Ruby", "bin/rubocop --parallel" + + step "Security: Gem audit", "bin/bundler-audit" + step "Security: Importmap audit", "bin/importmap audit" + step "Security: Brakeman code analysis", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error" + + # SimpleCov enforces the 90% minimum and fails the process when coverage + # drops below it, so the suite is also the coverage gate. + step "Tests: RSpec", "bundle exec rspec" +end diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc new file mode 100644 index 000000000..32cf77fcf --- /dev/null +++ b/config/credentials.yml.enc @@ -0,0 +1 @@ +015Lobts0X5SvG/7LskLoaMuLVbkEztc1r7V8TwRVVRa8w8XuEQBwizJZCIhp/XnXRfTgHbLyQ/2hRACZ9nfYX3tNMdNwgoJOFh++j3N+tRquTmfja4ikXzUr1Xl9X9OzA+I7xJH0qf/ivH0TF3orV6rKkNW1pfiuKDiX+/IfJ/7s7/nfyAtfJPuSaQYEUHZJyzO7VagyAspy85itSj3YQSFL0YB/kGKW7JGxqEnldHsdGhugifY10p9cn1dbwJ+H+Hl9y7vc09sGkqVTA/e9D9/NdZ11Xk4HxsIF4jNwXmdDj2Z6WcMlmX799C2t2dbzfprdfvDHKzf/KClh+yHgCAUCJqiocX5YblsdVpqyrqqwnn9AjQjMgYMAyF5WNwd8wBNfMa9GKkRplCwMBkZKkhQxrbNzuv6wRgS9R1vvPgHft63v+zevbX+wmMFBenuq7D2bppqIsyoKzgd5HgAA4BjTzC15nu42zNgDeXWT58HHEI4UN5AGTo9--iv5xKx8w/MpZDOT4--ZTv8GD3z+RYBCA+QvU2pyw== \ No newline at end of file diff --git a/config/database.yml b/config/database.yml new file mode 100644 index 000000000..aba213ae5 --- /dev/null +++ b/config/database.yml @@ -0,0 +1,67 @@ +# PostgreSQL configuration. +# +# Development and test mirror the production topology on purpose: Solid Queue +# and Solid Cable get their own databases in every environment, so the worker +# really is a separate process talking to a separate queue database rather than +# something that only works because it shares the web process. +default: &default + adapter: postgresql + encoding: unicode + max_connections: <%= ENV.fetch("RAILS_MAX_THREADS", 5) %> + host: <%= ENV.fetch("POSTGRES_HOST", "localhost") %> + port: <%= ENV.fetch("POSTGRES_PORT", 5432) %> + username: <%= ENV.fetch("POSTGRES_USER", nil) %> + password: <%= ENV.fetch("POSTGRES_PASSWORD", nil) %> + +development: + primary: + <<: *default + database: <%= ENV.fetch("POSTGRES_DB", "user_management_development") %> + cache: + <<: *default + database: <%= ENV.fetch("POSTGRES_DB", "user_management_development") %>_cache + migrations_paths: db/cache_migrate + queue: + <<: *default + database: <%= ENV.fetch("POSTGRES_DB", "user_management_development") %>_queue + migrations_paths: db/queue_migrate + cable: + <<: *default + database: <%= ENV.fetch("POSTGRES_DB", "user_management_development") %>_cable + migrations_paths: db/cable_migrate + +# Each parallel test worker gets its own suffixed database, so the suite can run +# with `parallel_tests` without workers stepping on each other. +test: + primary: + <<: *default + database: <%= ENV.fetch("TEST_POSTGRES_DB", "user_management_test") %><%= ENV["TEST_ENV_NUMBER"] %> + cache: + <<: *default + database: <%= ENV.fetch("TEST_POSTGRES_DB", "user_management_test") %><%= ENV["TEST_ENV_NUMBER"] %>_cache + migrations_paths: db/cache_migrate + queue: + <<: *default + database: <%= ENV.fetch("TEST_POSTGRES_DB", "user_management_test") %><%= ENV["TEST_ENV_NUMBER"] %>_queue + migrations_paths: db/queue_migrate + cable: + <<: *default + database: <%= ENV.fetch("TEST_POSTGRES_DB", "user_management_test") %><%= ENV["TEST_ENV_NUMBER"] %>_cable + migrations_paths: db/cable_migrate + +production: + primary: &primary_production + <<: *default + database: <%= ENV.fetch("POSTGRES_DB", "user_management_production") %> + cache: + <<: *primary_production + database: <%= ENV.fetch("POSTGRES_DB", "user_management_production") %>_cache + migrations_paths: db/cache_migrate + queue: + <<: *primary_production + database: <%= ENV.fetch("POSTGRES_DB", "user_management_production") %>_queue + migrations_paths: db/queue_migrate + cable: + <<: *primary_production + database: <%= ENV.fetch("POSTGRES_DB", "user_management_production") %>_cable + migrations_paths: db/cable_migrate 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..bd59c6408 --- /dev/null +++ b/config/environments/development.rb @@ -0,0 +1,85 @@ +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 + + # Change to :null_store to avoid any caching. + # Development mirrors the production topology on purpose: the same database + # backed adapters, pointed at the same dedicated databases. Running the queue + # in-process here would hide exactly the problems this stack has to prove it + # handles. + 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..737611a3a --- /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..c2095b117 --- /dev/null +++ b/config/environments/test.rb @@ -0,0 +1,53 @@ +# 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 + + # 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..f72dcdfaa --- /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 += %i[ + 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/devops/common.sh b/devops/common.sh new file mode 100755 index 000000000..23eb62570 --- /dev/null +++ b/devops/common.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# +# Shared helpers for the devops scripts. Source this, do not execute it. +# +# source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/common.sh" +# +set -euo pipefail + +DEVOPS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${DEVOPS_DIR}/.." && pwd)" +cd "${PROJECT_ROOT}" + +COMPOSE_FILE_PATH="${COMPOSE_FILE_PATH:-${PROJECT_ROOT}/docker-compose.yml}" +ENV_FILE_PATH="${ENV_FILE_PATH:-${PROJECT_ROOT}/.env}" + +# ── Output helpers ─────────────────────────────────────────────────────────── +if [[ -t 1 ]]; then + RED=$'\033[0;31m'; GREEN=$'\033[0;32m'; YELLOW=$'\033[1;33m' + CYAN=$'\033[0;36m'; BOLD=$'\033[1m'; RESET=$'\033[0m' +else + RED=''; GREEN=''; YELLOW=''; CYAN=''; BOLD=''; RESET='' +fi + +log() { echo -e "${CYAN}[app]${RESET} $*"; } +ok() { echo -e "${GREEN}[ok]${RESET} $*"; } +warn() { echo -e "${YELLOW}[!]${RESET} $*" >&2; } +fail() { echo -e "${RED}[x]${RESET} $*" >&2; exit 1; } +step() { echo -e "\n${BOLD}${CYAN}==> $*${RESET}"; } + +# ── Guards ─────────────────────────────────────────────────────────────────── +require_docker() { + command -v docker >/dev/null 2>&1 || fail "Docker is not installed or not on PATH." + docker compose version >/dev/null 2>&1 || fail "Docker Compose v2 is required." + docker info >/dev/null 2>&1 || fail "Docker daemon is not reachable. Is it running?" +} + +require_running() { + local service="$1" + if [[ -z "$(compose ps --quiet "${service}" 2>/dev/null)" ]]; then + fail "Service '${service}' is not running. Start it with bin/dev." + fi +} + +# ── Compose wrapper ────────────────────────────────────────────────────────── +# Resolves the compose file and the optional env file once, so every script +# talks to the same stack. +compose() { + local args=(compose --file "${COMPOSE_FILE_PATH}") + [[ -f "${ENV_FILE_PATH}" ]] && args+=(--env-file "${ENV_FILE_PATH}") + docker "${args[@]}" "$@" +} + +# Runs a command inside the web container with the test environment wired up. +# Keeping RAILS_ENV=test here is what stops specs from writing into the +# development database through Faker and FactoryBot. +rails_test_exec() { + require_running web + compose exec -T \ + -e RAILS_ENV=test \ + -e DISABLE_SPRING=1 \ + web "$@" +} + +rails_exec() { + require_running web + compose exec web "$@" +} diff --git a/devops/postgres/dump.sh b/devops/postgres/dump.sh new file mode 100755 index 000000000..4ccd04674 --- /dev/null +++ b/devops/postgres/dump.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Dumps the development database to tmp/backups/. +# devops/postgres/dump.sh [output-file] +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/common.sh" +require_running postgres +mkdir -p tmp/backups +target="${1:-tmp/backups/development-$(date +%Y%m%d-%H%M%S).dump}" +step "Dumping to ${target}" +compose exec -T postgres pg_dump -U "${POSTGRES_USER:-user_management}" -Fc "${POSTGRES_DB:-user_management_development}" > "${target}" +ok "Wrote ${target} ($(du -h "${target}" | cut -f1))." diff --git a/devops/postgres/logs.sh b/devops/postgres/logs.sh new file mode 100755 index 000000000..d8816a945 --- /dev/null +++ b/devops/postgres/logs.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Follows the PostgreSQL logs. +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/common.sh" +compose logs --follow --tail="${TAIL:-200}" postgres "$@" diff --git a/devops/postgres/psql.sh b/devops/postgres/psql.sh new file mode 100755 index 000000000..bf3be6b0a --- /dev/null +++ b/devops/postgres/psql.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Interactive psql against the development database. +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/common.sh" +require_running postgres +compose exec postgres psql -U "${POSTGRES_USER:-user_management}" -d "${POSTGRES_DB:-user_management_development}" "$@" diff --git a/devops/rails/console.sh b/devops/rails/console.sh new file mode 100755 index 000000000..b8d830165 --- /dev/null +++ b/devops/rails/console.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Opens a Rails console in the running web container. +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/common.sh" +rails_exec ./bin/rails console "$@" diff --git a/devops/rails/lint.sh b/devops/rails/lint.sh new file mode 100755 index 000000000..fbb6c2e69 --- /dev/null +++ b/devops/rails/lint.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# RuboCop. Pass -a or -A to autocorrect. +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/common.sh" +rails_exec ./bin/rubocop "$@" diff --git a/devops/rails/logs.sh b/devops/rails/logs.sh new file mode 100755 index 000000000..59788b156 --- /dev/null +++ b/devops/rails/logs.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Follows the web server logs. Pass extra docker compose logs flags if needed. +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/common.sh" +compose logs --follow --tail="${TAIL:-200}" web "$@" diff --git a/devops/rails/migrate.sh b/devops/rails/migrate.sh new file mode 100755 index 000000000..e00edcd5b --- /dev/null +++ b/devops/rails/migrate.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Runs pending migrations across every configured database. +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/common.sh" +step "Migrating databases" +rails_exec ./bin/rails db:migrate "$@" +ok "Migrations applied." diff --git a/devops/rails/security.sh b/devops/rails/security.sh new file mode 100755 index 000000000..a76fbaf72 --- /dev/null +++ b/devops/rails/security.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Static security analysis: Brakeman plus a dependency audit. +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/common.sh" +status=0 +step "Brakeman" +rails_exec ./bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error || status=1 +step "Bundler Audit" +rails_exec ./bin/bundler-audit || status=1 +step "Importmap audit" +rails_exec ./bin/importmap audit || status=1 +if [[ "${status}" -ne 0 ]]; then + fail "Security checks reported findings." +fi +ok "No security findings." diff --git a/devops/rails/test-parallel.sh b/devops/rails/test-parallel.sh new file mode 100755 index 000000000..90388af05 --- /dev/null +++ b/devops/rails/test-parallel.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Runs the suite across parallel workers, each with its own database. +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/common.sh" +WORKERS="${WORKERS:-$(nproc 2>/dev/null || echo 4)}" +step "Creating ${WORKERS} parallel test databases" +rails_test_exec bundle exec rake parallel:setup["${WORKERS}"] +step "Running RSpec on ${WORKERS} workers" +rails_test_exec bundle exec parallel_rspec -n "${WORKERS}" "$@" diff --git a/devops/rails/test.sh b/devops/rails/test.sh new file mode 100755 index 000000000..59bdf99ba --- /dev/null +++ b/devops/rails/test.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Runs RSpec inside the web container against the test databases. +# devops/rails/test.sh # whole suite +# devops/rails/test.sh spec/models/user_spec.rb +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/common.sh" +step "Preparing test databases" +rails_test_exec ./bin/rails db:test:prepare +step "Running RSpec" +if [[ "$#" -gt 0 ]]; then + rails_test_exec bundle exec rspec "$@" +else + rails_test_exec bundle exec rspec +fi diff --git a/devops/tests/all.sh b/devops/tests/all.sh new file mode 100755 index 000000000..0f6225d0c --- /dev/null +++ b/devops/tests/all.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Full verification: lint, security analysis and the test suite. +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/common.sh" +DEVOPS="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +"${DEVOPS}/rails/lint.sh" +"${DEVOPS}/rails/security.sh" +"${DEVOPS}/rails/test.sh" "$@" +ok "All checks passed." diff --git a/devops/worker/logs.sh b/devops/worker/logs.sh new file mode 100755 index 000000000..b3cf874ba --- /dev/null +++ b/devops/worker/logs.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Follows the Solid Queue worker logs. +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/common.sh" +compose logs --follow --tail="${TAIL:-200}" worker "$@" diff --git a/devops/worker/status.sh b/devops/worker/status.sh new file mode 100755 index 000000000..3610c8479 --- /dev/null +++ b/devops/worker/status.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Shows Solid Queue processes, queue depth and failed jobs. +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/common.sh" +rails_exec ./bin/rails runner ' + puts "processes: #{SolidQueue::Process.count}" + puts "ready: #{SolidQueue::ReadyExecution.count}" + puts "claimed: #{SolidQueue::ClaimedExecution.count}" + puts "failed: #{SolidQueue::FailedExecution.count}" +' diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..b614033e0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,124 @@ +# Development environment. +# +# Four processes, deliberately separate: the web server, the Solid Queue +# worker, the Tailwind watcher and PostgreSQL. The worker is its own container +# so asynchronous imports are genuinely asynchronous, not something that only +# works because a single process happens to run everything. + +name: user-management + +services: + # ─────────────────────────────────────────────── + # PostgreSQL + # ─────────────────────────────────────────────── + postgres: + image: postgres:17-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-user_management} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-development_only} + POSTGRES_DB: ${POSTGRES_DB:-user_management_development} + volumes: + - postgres_data:/var/lib/postgresql/data + # Not published to the host: reach it with devops/postgres/psql.sh instead. + expose: + - "5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-user_management}"] + interval: 10s + timeout: 5s + retries: 5 + networks: [app_network] + deploy: + resources: + limits: + memory: 512M + + # ─────────────────────────────────────────────── + # Web (Puma) + # ─────────────────────────────────────────────── + web: + build: + context: . + target: development + restart: unless-stopped + command: ./bin/rails server -b 0.0.0.0 -p 3000 + environment: &app_env + RAILS_ENV: development + POSTGRES_HOST: postgres + POSTGRES_PORT: 5432 + POSTGRES_USER: ${POSTGRES_USER:-user_management} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-development_only} + POSTGRES_DB: ${POSTGRES_DB:-user_management_development} + TEST_POSTGRES_DB: ${TEST_POSTGRES_DB:-user_management_test} + RAILS_MAX_THREADS: ${RAILS_MAX_THREADS:-5} + volumes: + - .:/rails + ports: + - "${WEB_PORT:-3000}:3000" + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:3000/up || exit 1"] + interval: 15s + timeout: 10s + retries: 8 + start_period: 45s + depends_on: + postgres: + condition: service_healthy + networks: [app_network] + deploy: + resources: + limits: + memory: 1G + + # ─────────────────────────────────────────────── + # Solid Queue worker + # ─────────────────────────────────────────────── + worker: + build: + context: . + target: development + restart: unless-stopped + command: ./bin/jobs + environment: *app_env + volumes: + - .:/rails + healthcheck: + test: ["CMD-SHELL", "pgrep -f solid-queue || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 45s + depends_on: + postgres: + condition: service_healthy + networks: [app_network] + deploy: + resources: + limits: + memory: 512M + + # ─────────────────────────────────────────────── + # Tailwind watcher + # ─────────────────────────────────────────────── + css: + build: + context: . + target: development + restart: unless-stopped + command: ./bin/rails tailwindcss:watch[always] + environment: *app_env + volumes: + - .:/rails + networks: [app_network] + deploy: + resources: + limits: + memory: 256M + +volumes: + postgres_data: + +networks: + app_network: + driver: bridge 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/spec/rails_helper.rb b/spec/rails_helper.rb new file mode 100644 index 000000000..b3c17f09e --- /dev/null +++ b/spec/rails_helper.rb @@ -0,0 +1,72 @@ +# This file is copied to spec/ when you run 'rails generate rspec:install' +require "spec_helper" +ENV["RAILS_ENV"] ||= "test" +require_relative "../config/environment" +# Prevent database truncation if the environment is production +abort("The Rails environment is running in production mode!") if Rails.env.production? +# Uncomment the line below in case you have `--require rails_helper` in the `.rspec` file +# that will avoid rails generators crashing because migrations haven't been run yet +# return unless Rails.env.test? +require "rspec/rails" +# Add additional requires below this line. Rails is not loaded until this point! + +# Requires supporting ruby files with custom matchers and macros, etc, in +# spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are +# run as spec files by default. This means that files in spec/support that end +# in _spec.rb will both be required and run as specs, causing the specs to be +# run twice. It is recommended that you do not name files matching this glob to +# end with _spec.rb. You can configure this pattern with the --pattern +# option on the command line or in ~/.rspec, .rspec or `.rspec-local`. +# +# The following line is provided for convenience purposes. It has the downside +# of increasing the boot-up time by auto-requiring all files in the support +# directory. Alternatively, in the individual `*_spec.rb` files, manually +# require only the support files necessary. +# +# Rails.root.glob('spec/support/**/*.rb').sort_by(&:to_s).each { |f| require f } + +# Ensures that the test database schema matches the current schema file. +# If there are pending migrations it will invoke `db:test:prepare` to +# recreate the test database by loading the schema. +# If you are not using ActiveRecord, you can remove these lines. +begin + ActiveRecord::Migration.maintain_test_schema! +rescue ActiveRecord::PendingMigrationError => e + abort e.to_s.strip +end +RSpec.configure do |config| + # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures + config.fixture_paths = [ + Rails.root.join("spec/fixtures") + ] + + # If you're not using ActiveRecord, or you'd prefer not to run each of your + # examples within a transaction, remove the following line or assign false + # instead of true. + config.use_transactional_fixtures = true + + # You can uncomment this line to turn off ActiveRecord support entirely. + # config.use_active_record = false + + # RSpec Rails uses metadata to mix in different behaviours to your tests, + # for example enabling you to call `get` and `post` in request specs. e.g.: + # + # RSpec.describe UsersController, type: :request do + # # ... + # end + # + # The different available types are documented in the features, such as in + # https://rspec.info/features/8-0/rspec-rails + # + # You can also infer these behaviours automatically by location, e.g. + # /spec/models would pull in the same behaviour as `type: :model` but this + # behaviour is considered legacy and will be removed in a future version. + # + # To enable this behaviour uncomment the line below. + # config.infer_spec_type_from_file_location! + + # Filter lines from Rails gems in backtraces. + config.filter_rails_from_backtrace! + # arbitrary gems may also be filtered via: + # config.filter_gems_from_backtrace("gem name") +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 000000000..9c96a9b6d --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,92 @@ +# This file was generated by the `rails generate rspec:install` command. Conventionally, all +# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. +# The generated `.rspec` file contains `--require spec_helper` which will cause +# this file to always be loaded, without a need to explicitly require it in any +# files. +# +# Given that it is always loaded, you are encouraged to keep this file as +# light-weight as possible. Requiring heavyweight dependencies from this file +# will add to the boot time of your test suite on EVERY test run, even for an +# individual file that may not need all of that loaded. Instead, consider making +# a separate helper file that requires the additional dependencies and performs +# the additional setup, and require it from the spec files that actually need +# it. +# +# See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration +RSpec.configure do |config| + # rspec-expectations config goes here. You can use an alternate + # assertion/expectation library such as wrong or the stdlib/minitest + # assertions if you prefer. + config.expect_with :rspec do |expectations| + # This option will default to `true` in RSpec 4. It makes the `description` + # and `failure_message` of custom matchers include text for helper methods + # defined using `chain`, e.g.: + # be_bigger_than(2).and_smaller_than(4).description + # # => "be bigger than 2 and smaller than 4" + # ...rather than: + # # => "be bigger than 2" + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + # rspec-mocks config goes here. You can use an alternate test double + # library (such as bogus or mocha) by changing the `mock_with` option here. + config.mock_with :rspec do |mocks| + # Prevents you from mocking or stubbing a method that does not exist on + # a real object. This is generally recommended, and will default to + # `true` in RSpec 4. + mocks.verify_partial_doubles = true + end + + # This option will default to `:apply_to_host_groups` in RSpec 4 (and will + # have no way to turn it off -- the option exists only for backwards + # compatibility in RSpec 3). It causes shared context metadata to be + # inherited by the metadata hash of host groups and examples, rather than + # triggering implicit auto-inclusion in groups with matching metadata. + config.shared_context_metadata_behavior = :apply_to_host_groups + + # The settings below are suggested to provide a good initial experience + # with RSpec, but feel free to customize to your heart's content. + # # This allows you to limit a spec run to individual examples or groups + # # you care about by tagging them with `:focus` metadata. When nothing + # # is tagged with `:focus`, all examples get run. RSpec also provides + # # aliases for `it`, `describe`, and `context` that include `:focus` + # # metadata: `fit`, `fdescribe` and `fcontext`, respectively. + # config.filter_run_when_matching :focus + # + # # Allows RSpec to persist some state between runs in order to support + # # the `--only-failures` and `--next-failure` CLI options. We recommend + # # you configure your source control system to ignore this file. + # config.example_status_persistence_file_path = "spec/examples.txt" + # + # # Limits the available syntax to the non-monkey patched syntax that is + # # recommended. For more details, see: + # # https://rspec.info/features/3-12/rspec-core/configuration/zero-monkey-patching-mode/ + # config.disable_monkey_patching! + # + # # Many RSpec users commonly either run the entire suite or an individual + # # file, and it's useful to allow more verbose output when running an + # # individual spec file. + # if config.files_to_run.one? + # # Use the documentation formatter for detailed output, + # # unless a formatter has already been configured + # # (e.g. via a command-line flag). + # config.default_formatter = "doc" + # end + # + # # Print the 10 slowest examples and example groups at the + # # end of the spec run, to help surface which specs are running + # # particularly slow. + # config.profile_examples = 10 + # + # # Run specs in random order to surface order dependencies. If you find an + # # order dependency and want to debug it, you can fix the order by providing + # # the seed, which is printed after each run. + # # --seed 1234 + # config.order = :random + # + # # Seed global randomization in this process using the `--seed` CLI option. + # # Setting this allows you to use `--seed` to deterministically reproduce + # # test failures related to randomization by passing the same `--seed` value + # # as the one that triggered the failure. + # Kernel.srand config.seed +end diff --git a/storage/.keep b/storage/.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 2922cfaf6aecc8cc6534b1be46cd6ec3d7aa276c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B4nio=20Neto?= Date: Thu, 3 Sep 2026 14:16:29 -0300 Subject: [PATCH 02/33] feat: add user model with roles on top of native authentication Set up the RSpec harness and drive the User model out with it, then adopt the Rails 8 authentication generator underneath. - SimpleCov starts before the application loads and gates the suite at 90% line / 80% branch coverage, merging results across parallel workers so the gate measures the whole suite rather than one shard. - Cuprite drives the Chromium in the development image over CDP for system specs; plain requests stay on rack_test. - The users table carries full_name, role and avatar_url alongside the columns the generator needs. Case-insensitive email uniqueness is enforced by a unique index on lower(email_address), so the rule holds for writes that skip validation, and a check constraint keeps the role column inside the enum. - Flash and mailer texts produced by the generator moved into config/locales, which is where the strict RuboCop configuration expects them. Verified: 13 examples, 0 failures; RuboCop clean. The coverage gate currently reports below its minimum because only the model is covered so far -- that is the gate working, and it will be met as the suite grows. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BS3oGqeR1PXScuK6gLr8Mi --- Gemfile | 3 + Gemfile.lock | 3 + app/channels/application_cable/connection.rb | 17 ++ app/controllers/application_controller.rb | 2 + app/controllers/concerns/authentication.rb | 53 +++++ app/controllers/passwords_controller.rb | 36 ++++ app/controllers/sessions_controller.rb | 22 +++ app/mailers/passwords_mailer.rb | 6 + app/models/current.rb | 4 + app/models/session.rb | 3 + app/models/user.rb | 20 ++ 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/locales/en.yml | 16 +- config/routes.rb | 2 + db/cable_schema.rb | 23 ++- db/cache_schema.rb | 25 ++- db/migrate/20260903171333_create_users.rb | 24 +++ db/migrate/20260903171334_create_sessions.rb | 11 ++ db/queue_schema.rb | 197 ++++++++++--------- db/schema.rb | 40 ++++ spec/factories/users.rb | 12 ++ spec/models/user_spec.rb | 78 ++++++++ spec/rails_helper.rb | 65 +----- spec/spec_helper.rb | 119 ++++------- spec/support/active_job.rb | 9 + spec/support/capybara.rb | 32 +++ spec/support/factory_bot.rb | 3 + spec/support/shoulda_matchers.rb | 6 + 32 files changed, 672 insertions(+), 238 deletions(-) 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/20260903171333_create_users.rb create mode 100644 db/migrate/20260903171334_create_sessions.rb create mode 100644 db/schema.rb create mode 100644 spec/factories/users.rb create mode 100644 spec/models/user_spec.rb create mode 100644 spec/support/active_job.rb create mode 100644 spec/support/capybara.rb create mode 100644 spec/support/factory_bot.rb create mode 100644 spec/support/shoulda_matchers.rb diff --git a/Gemfile b/Gemfile index 40966f864..7bee6b79f 100644 --- a/Gemfile +++ b/Gemfile @@ -10,6 +10,9 @@ gem "tailwindcss-rails" gem "turbo-rails" # Database and server +# Password hashing for the built-in Rails authentication +gem "bcrypt", "~> 3.1" + gem "pg", "~> 1.1" gem "puma", ">= 5.0" diff --git a/Gemfile.lock b/Gemfile.lock index 0b65425b9..1d16ed1db 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) @@ -435,6 +436,7 @@ PLATFORMS x86_64-linux-musl DEPENDENCIES + bcrypt (~> 3.1) bootsnap brakeman bundler-audit @@ -490,6 +492,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..1ba5d8a2c --- /dev/null +++ b/app/channels/application_cable/connection.rb @@ -0,0 +1,17 @@ +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..8afd42d88 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,4 +1,6 @@ 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..935633ca6 --- /dev/null +++ b/app/controllers/concerns/authentication.rb @@ -0,0 +1,53 @@ +module Authentication + extend ActiveSupport::Concern + + included do + before_action :require_authentication + helper_method :authenticated? + end + + class_methods do + def allow_unauthenticated_access(**) + skip_before_action(:require_authentication, **) + 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..33977ba82 --- /dev/null +++ b/app/controllers/passwords_controller.rb @@ -0,0 +1,36 @@ +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: lambda { + redirect_to new_password_path, alert: t("passwords.throttled") + } + + def new; end + + def edit; 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: t("passwords.reset_instructions_sent") + end + + def update + if @user.update(params.permit(:password, :password_confirmation)) + @user.sessions.destroy_all + redirect_to new_session_path, notice: t("passwords.reset") + else + redirect_to edit_password_path(params[:token]), alert: t("passwords.mismatch") + end + end + + private + + def set_user_by_token + @user = User.find_by!(password_reset_token: params.expect(:token)) + rescue ActiveSupport::MessageVerifier::InvalidSignature + redirect_to new_password_path, alert: t("passwords.invalid_token") + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb new file mode 100644 index 000000000..717f98e4e --- /dev/null +++ b/app/controllers/sessions_controller.rb @@ -0,0 +1,22 @@ +class SessionsController < ApplicationController + allow_unauthenticated_access only: %i[new create] + rate_limit to: 10, within: 3.minutes, only: :create, with: lambda { + redirect_to new_session_path, alert: t("sessions.throttled") + } + + 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: t("sessions.invalid_credentials") + 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..06d1bc5dc --- /dev/null +++ b/app/mailers/passwords_mailer.rb @@ -0,0 +1,6 @@ +class PasswordsMailer < ApplicationMailer + def reset(user) + @user = user + mail subject: t("passwords_mailer.reset.subject"), 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..7827e077a --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,20 @@ +class User < ApplicationRecord + MAX_FULL_NAME_LENGTH = 120 + + has_secure_password + has_many :sessions, dependent: :destroy + + # Two roles, explicitly numbered so the values are stable in the database and + # match the check constraint. `validate: true` turns an unknown role into a + # validation error instead of an ArgumentError raised from the setter. + enum :role, { user: 0, admin: 1 }, default: :user, validate: true + + normalizes :email_address, with: ->(value) { value.strip.downcase } + normalizes :full_name, with: ->(value) { value.strip.gsub(/\s+/, " ") } + + validates :full_name, presence: true, length: { maximum: MAX_FULL_NAME_LENGTH } + validates :email_address, + presence: true, + format: { with: URI::MailTo::EMAIL_REGEXP }, + uniqueness: { case_sensitive: false } +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/locales/en.yml b/config/locales/en.yml index 6c349ae5e..b3d816929 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -28,4 +28,18 @@ # enabled: "ON" en: - hello: "Hello world" + + sessions: + throttled: "Too many attempts. Please try again later." + invalid_credentials: "Try another email address or password." + + passwords: + throttled: "Too many attempts. Please try again later." + reset_instructions_sent: "Password reset instructions sent (if an account with that email address exists)." + reset: "Your password has been reset." + mismatch: "Passwords did not match." + invalid_token: "That password reset link is invalid or has expired." + + passwords_mailer: + reset: + subject: "Reset your password" 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/cable_schema.rb b/db/cable_schema.rb index 23666604a..593da414c 100644 --- a/db/cable_schema.rb +++ b/db/cable_schema.rb @@ -1,9 +1,24 @@ -ActiveRecord::Schema[7.1].define(version: 1) do +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.1].define(version: 1) do + # These are extensions that must be enabled in order to support this database + enable_extension "pg_catalog.plpgsql" + create_table "solid_cable_messages", force: :cascade do |t| - t.binary "channel", limit: 1024, null: false - t.binary "payload", limit: 536870912, null: false + t.binary "channel", null: false + t.bigint "channel_hash", null: false t.datetime "created_at", null: false - t.integer "channel_hash", limit: 8, null: false + t.binary "payload", null: false t.index ["channel"], name: "index_solid_cable_messages_on_channel" t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash" t.index ["created_at"], name: "index_solid_cable_messages_on_created_at" diff --git a/db/cache_schema.rb b/db/cache_schema.rb index 81a410d18..96be0dcaf 100644 --- a/db/cache_schema.rb +++ b/db/cache_schema.rb @@ -1,10 +1,25 @@ -ActiveRecord::Schema[7.2].define(version: 1) do +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.1].define(version: 1) do + # These are extensions that must be enabled in order to support this database + enable_extension "pg_catalog.plpgsql" + create_table "solid_cache_entries", force: :cascade do |t| - t.binary "key", limit: 1024, null: false - t.binary "value", limit: 536870912, null: false + t.integer "byte_size", null: false t.datetime "created_at", null: false - t.integer "key_hash", limit: 8, null: false - t.integer "byte_size", limit: 4, null: false + t.binary "key", null: false + t.bigint "key_hash", null: false + t.binary "value", null: false t.index ["byte_size"], name: "index_solid_cache_entries_on_byte_size" t.index ["key_hash", "byte_size"], name: "index_solid_cache_entries_on_key_hash_and_byte_size" t.index ["key_hash"], name: "index_solid_cache_entries_on_key_hash", unique: true diff --git a/db/migrate/20260903171333_create_users.rb b/db/migrate/20260903171333_create_users.rb new file mode 100644 index 000000000..8872a6442 --- /dev/null +++ b/db/migrate/20260903171333_create_users.rb @@ -0,0 +1,24 @@ +class CreateUsers < ActiveRecord::Migration[8.1] + def change + create_table :users do |t| + t.string :full_name, null: false + t.string :email_address, null: false + t.string :password_digest, null: false + t.integer :role, null: false, default: 0 + t.string :avatar_url + + t.timestamps + end + + # Uniqueness is enforced on the lowercased value so the database upholds the + # same case-insensitive rule the model does, even for writes that skip + # validation. + add_index :users, "lower(email_address)", unique: true, + name: "index_users_on_lower_email_address" + add_index :users, :role + + # The enum only defines two roles; the constraint stops anything else from + # reaching the column through a raw write. + add_check_constraint :users, "role IN (0, 1)", name: "users_role_within_enum" + end +end diff --git a/db/migrate/20260903171334_create_sessions.rb b/db/migrate/20260903171334_create_sessions.rb new file mode 100644 index 000000000..ec9efdbaa --- /dev/null +++ b/db/migrate/20260903171334_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/db/queue_schema.rb b/db/queue_schema.rb index f9a71dabb..00929c207 100644 --- a/db/queue_schema.rb +++ b/db/queue_schema.rb @@ -1,152 +1,167 @@ -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 + # These are extensions that must be enabled in order to support this database + enable_extension "pg_catalog.plpgsql" + + 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..f9a4b8511 --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,40 @@ +# 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_171334) do + # These are extensions that must be enabled in order to support this database + enable_extension "pg_catalog.plpgsql" + + 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.bigint "user_id", null: false + t.index ["user_id"], name: "index_sessions_on_user_id" + end + + create_table "users", force: :cascade do |t| + t.string "avatar_url" + t.datetime "created_at", null: false + t.string "email_address", null: false + t.string "full_name", null: false + t.string "password_digest", null: false + t.integer "role", default: 0, null: false + t.datetime "updated_at", null: false + t.index "lower((email_address)::text)", name: "index_users_on_lower_email_address", unique: true + t.index ["role"], name: "index_users_on_role" + t.check_constraint "role = ANY (ARRAY[0, 1])", name: "users_role_within_enum" + end + + add_foreign_key "sessions", "users" +end diff --git a/spec/factories/users.rb b/spec/factories/users.rb new file mode 100644 index 000000000..7de1d7f19 --- /dev/null +++ b/spec/factories/users.rb @@ -0,0 +1,12 @@ +FactoryBot.define do + factory :user do + full_name { Faker::Name.name } + sequence(:email_address) { |n| "user#{n}@example.com" } + password { "a-sufficiently-long-password" } + role { :user } + + trait :admin do + role { :admin } + end + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb new file mode 100644 index 000000000..dbd8456ad --- /dev/null +++ b/spec/models/user_spec.rb @@ -0,0 +1,78 @@ +require "rails_helper" + +RSpec.describe User do + describe "validations" do + subject { build(:user) } + + it { is_expected.to validate_presence_of(:full_name) } + it { is_expected.to validate_length_of(:full_name).is_at_most(120) } + it { is_expected.to validate_presence_of(:email_address) } + + it "rejects an email address without a domain" do + user = build(:user, email_address: "maria@") + + expect(user).not_to be_valid + expect(user.errors[:email_address]).to be_present + end + + it "accepts a conventional email address" do + expect(build(:user, email_address: "maria.silva@example.com")).to be_valid + end + end + + describe "normalization" do + it "downcases and strips the email address" do + user = create(:user, email_address: " Maria.Silva@Example.COM ") + + expect(user.email_address).to eq("maria.silva@example.com") + end + + it "collapses whitespace in the full name" do + user = create(:user, full_name: " Maria Silva ") + + expect(user.full_name).to eq("Maria Silva") + end + end + + describe "email uniqueness" do + it "rejects a duplicate regardless of case" do + create(:user, email_address: "maria@example.com") + duplicate = build(:user, email_address: "MARIA@EXAMPLE.COM") + + expect(duplicate).not_to be_valid + expect(duplicate.errors[:email_address]).to be_present + end + + it "is enforced by the database, not only by the model" do + create(:user, email_address: "maria@example.com") + duplicate = build(:user, email_address: "MARIA@EXAMPLE.COM") + + expect { duplicate.save!(validate: false) } + .to raise_error(ActiveRecord::RecordNotUnique) + end + end + + describe "roles" do + it "defines the user and admin roles" do + expect(described_class.roles.keys).to contain_exactly("user", "admin") + end + + it "defaults to the user role" do + expect(described_class.new.role).to eq("user") + end + + it "exposes a predicate for each role" do + expect(build(:user, :admin)).to be_admin + expect(build(:user)).not_to be_admin + end + end + + describe "password" do + it "authenticates with the correct password" do + user = create(:user, password: "correct horse battery") + + expect(user.authenticate("correct horse battery")).to eq(user) + expect(user.authenticate("wrong")).to be(false) + end + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index b3c17f09e..716214fb4 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -1,72 +1,23 @@ -# This file is copied to spec/ when you run 'rails generate rspec:install' require "spec_helper" + ENV["RAILS_ENV"] ||= "test" require_relative "../config/environment" -# Prevent database truncation if the environment is production + abort("The Rails environment is running in production mode!") if Rails.env.production? -# Uncomment the line below in case you have `--require rails_helper` in the `.rspec` file -# that will avoid rails generators crashing because migrations haven't been run yet -# return unless Rails.env.test? -require "rspec/rails" -# Add additional requires below this line. Rails is not loaded until this point! -# Requires supporting ruby files with custom matchers and macros, etc, in -# spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are -# run as spec files by default. This means that files in spec/support that end -# in _spec.rb will both be required and run as specs, causing the specs to be -# run twice. It is recommended that you do not name files matching this glob to -# end with _spec.rb. You can configure this pattern with the --pattern -# option on the command line or in ~/.rspec, .rspec or `.rspec-local`. -# -# The following line is provided for convenience purposes. It has the downside -# of increasing the boot-up time by auto-requiring all files in the support -# directory. Alternatively, in the individual `*_spec.rb` files, manually -# require only the support files necessary. -# -# Rails.root.glob('spec/support/**/*.rb').sort_by(&:to_s).each { |f| require f } +require "rspec/rails" -# Ensures that the test database schema matches the current schema file. -# If there are pending migrations it will invoke `db:test:prepare` to -# recreate the test database by loading the schema. -# If you are not using ActiveRecord, you can remove these lines. begin ActiveRecord::Migration.maintain_test_schema! rescue ActiveRecord::PendingMigrationError => e abort e.to_s.strip end -RSpec.configure do |config| - # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures - config.fixture_paths = [ - Rails.root.join("spec/fixtures") - ] - # If you're not using ActiveRecord, or you'd prefer not to run each of your - # examples within a transaction, remove the following line or assign false - # instead of true. - config.use_transactional_fixtures = true - - # You can uncomment this line to turn off ActiveRecord support entirely. - # config.use_active_record = false - - # RSpec Rails uses metadata to mix in different behaviours to your tests, - # for example enabling you to call `get` and `post` in request specs. e.g.: - # - # RSpec.describe UsersController, type: :request do - # # ... - # end - # - # The different available types are documented in the features, such as in - # https://rspec.info/features/8-0/rspec-rails - # - # You can also infer these behaviours automatically by location, e.g. - # /spec/models would pull in the same behaviour as `type: :model` but this - # behaviour is considered legacy and will be removed in a future version. - # - # To enable this behaviour uncomment the line below. - # config.infer_spec_type_from_file_location! +Rails.root.glob("spec/support/**/*.rb").each { |file| require file } - # Filter lines from Rails gems in backtraces. +RSpec.configure do |config| + config.fixture_paths = [Rails.root.join("spec/fixtures")] + config.use_transactional_fixtures = true + config.infer_spec_type_from_file_location! config.filter_rails_from_backtrace! - # arbitrary gems may also be filtered via: - # config.filter_gems_from_backtrace("gem name") end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 9c96a9b6d..921fdde54 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,92 +1,51 @@ -# This file was generated by the `rails generate rspec:install` command. Conventionally, all -# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. -# The generated `.rspec` file contains `--require spec_helper` which will cause -# this file to always be loaded, without a need to explicitly require it in any -# files. -# -# Given that it is always loaded, you are encouraged to keep this file as -# light-weight as possible. Requiring heavyweight dependencies from this file -# will add to the boot time of your test suite on EVERY test run, even for an -# individual file that may not need all of that loaded. Instead, consider making -# a separate helper file that requires the additional dependencies and performs -# the additional setup, and require it from the spec files that actually need -# it. -# -# See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration +# Coverage has to start before any application code is loaded, which is why it +# lives at the very top of this file rather than in rails_helper. +require "simplecov" + +SimpleCov.start "rails" do + enable_coverage :branch + + # Each parallel worker writes its own result and SimpleCov merges them, so + # the 90% gate is measured against the whole suite rather than one shard. + command_name "rspec#{ENV.fetch("TEST_ENV_NUMBER", nil)}" + use_merging true + merge_timeout 600 + + minimum_coverage line: 90, branch: 80 + + # Excluded because they contain no branching logic of our own: generated + # schemas, framework configuration and the mailer/job base classes Rails + # writes for us. + add_filter "/spec/" + add_filter "/config/" + add_filter "/db/" + add_filter "app/channels/application_cable/" + + add_group "Models", "app/models" + add_group "Controllers", "app/controllers" + add_group "Jobs", "app/jobs" + add_group "Views", "app/views" + add_group "Helpers", "app/helpers" +end + RSpec.configure do |config| - # rspec-expectations config goes here. You can use an alternate - # assertion/expectation library such as wrong or the stdlib/minitest - # assertions if you prefer. config.expect_with :rspec do |expectations| - # This option will default to `true` in RSpec 4. It makes the `description` - # and `failure_message` of custom matchers include text for helper methods - # defined using `chain`, e.g.: - # be_bigger_than(2).and_smaller_than(4).description - # # => "be bigger than 2 and smaller than 4" - # ...rather than: - # # => "be bigger than 2" expectations.include_chain_clauses_in_custom_matcher_descriptions = true + expectations.syntax = :expect end - # rspec-mocks config goes here. You can use an alternate test double - # library (such as bogus or mocha) by changing the `mock_with` option here. config.mock_with :rspec do |mocks| - # Prevents you from mocking or stubbing a method that does not exist on - # a real object. This is generally recommended, and will default to - # `true` in RSpec 4. mocks.verify_partial_doubles = true end - # This option will default to `:apply_to_host_groups` in RSpec 4 (and will - # have no way to turn it off -- the option exists only for backwards - # compatibility in RSpec 3). It causes shared context metadata to be - # inherited by the metadata hash of host groups and examples, rather than - # triggering implicit auto-inclusion in groups with matching metadata. config.shared_context_metadata_behavior = :apply_to_host_groups + config.filter_run_when_matching :focus + config.example_status_persistence_file_path = ".rspec_status" + config.disable_monkey_patching! + config.warnings = false + + config.default_formatter = "doc" if config.files_to_run.one? - # The settings below are suggested to provide a good initial experience - # with RSpec, but feel free to customize to your heart's content. - # # This allows you to limit a spec run to individual examples or groups - # # you care about by tagging them with `:focus` metadata. When nothing - # # is tagged with `:focus`, all examples get run. RSpec also provides - # # aliases for `it`, `describe`, and `context` that include `:focus` - # # metadata: `fit`, `fdescribe` and `fcontext`, respectively. - # config.filter_run_when_matching :focus - # - # # Allows RSpec to persist some state between runs in order to support - # # the `--only-failures` and `--next-failure` CLI options. We recommend - # # you configure your source control system to ignore this file. - # config.example_status_persistence_file_path = "spec/examples.txt" - # - # # Limits the available syntax to the non-monkey patched syntax that is - # # recommended. For more details, see: - # # https://rspec.info/features/3-12/rspec-core/configuration/zero-monkey-patching-mode/ - # config.disable_monkey_patching! - # - # # Many RSpec users commonly either run the entire suite or an individual - # # file, and it's useful to allow more verbose output when running an - # # individual spec file. - # if config.files_to_run.one? - # # Use the documentation formatter for detailed output, - # # unless a formatter has already been configured - # # (e.g. via a command-line flag). - # config.default_formatter = "doc" - # end - # - # # Print the 10 slowest examples and example groups at the - # # end of the spec run, to help surface which specs are running - # # particularly slow. - # config.profile_examples = 10 - # - # # Run specs in random order to surface order dependencies. If you find an - # # order dependency and want to debug it, you can fix the order by providing - # # the seed, which is printed after each run. - # # --seed 1234 - # config.order = :random - # - # # Seed global randomization in this process using the `--seed` CLI option. - # # Setting this allows you to use `--seed` to deterministically reproduce - # # test failures related to randomization by passing the same `--seed` value - # # as the one that triggered the failure. - # Kernel.srand config.seed + config.order = :random + Kernel.srand config.seed end diff --git a/spec/support/active_job.rb b/spec/support/active_job.rb new file mode 100644 index 000000000..875310d3c --- /dev/null +++ b/spec/support/active_job.rb @@ -0,0 +1,9 @@ +# Jobs are enqueued rather than executed by default, so specs can assert on +# what was scheduled. Tag an example with `perform_enqueued: true` when the +# work itself is what is under test. +RSpec.configure do |config| + config.before do |example| + ActiveJob::Base.queue_adapter = + example.metadata[:perform_enqueued] ? :inline : :test + end +end diff --git a/spec/support/capybara.rb b/spec/support/capybara.rb new file mode 100644 index 000000000..eaa4c011a --- /dev/null +++ b/spec/support/capybara.rb @@ -0,0 +1,32 @@ +require "capybara/rspec" +require "capybara/cuprite" + +# Cuprite talks to the Chromium installed in the development image over CDP. +# Both run inside the same container, so the test server is reachable on +# localhost and no host networking is involved. +Capybara.register_driver(:cuprite) do |app| + Capybara::Cuprite::Driver.new( + app, + window_size: [1400, 1000], + browser_path: ENV.fetch("BROWSER_PATH", nil), + browser_options: { + "no-sandbox" => nil, + "disable-dev-shm-usage" => nil, + "disable-gpu" => nil + }, + process_timeout: 30, + timeout: 15, + headless: true + ) +end + +Capybara.default_driver = :rack_test +Capybara.javascript_driver = :cuprite +Capybara.default_max_wait_time = 5 +Capybara.server = :puma, { Silent: true } +Capybara.disable_animation = true + +RSpec.configure do |config| + config.before(:each, type: :system) { driven_by :rack_test } + config.before(:each, :js, type: :system) { driven_by :cuprite } +end diff --git a/spec/support/factory_bot.rb b/spec/support/factory_bot.rb new file mode 100644 index 000000000..c7890e49c --- /dev/null +++ b/spec/support/factory_bot.rb @@ -0,0 +1,3 @@ +RSpec.configure do |config| + config.include FactoryBot::Syntax::Methods +end diff --git a/spec/support/shoulda_matchers.rb b/spec/support/shoulda_matchers.rb new file mode 100644 index 000000000..7d045f359 --- /dev/null +++ b/spec/support/shoulda_matchers.rb @@ -0,0 +1,6 @@ +Shoulda::Matchers.configure do |config| + config.integrate do |with| + with.test_framework :rspec + with.library :rails + end +end From 745dfc11e805dea550ffc5d7001471956c5eee3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B4nio=20Neto?= Date: Thu, 3 Sep 2026 14:22:51 -0300 Subject: [PATCH 03/33] feat: add registration, profiles and role-based authorization Drive the visitor and user journeys out of request specs, then add the authorization layer the admin area needs. - Authorization is a small explicit concern rather than a gem: two roles and a handful of rules do not justify a policy framework. A signed-in non-admin reaching the admin area is told plainly and sent to their profile. - Registration never accepts a role. The permitted parameters simply do not include it, so the column default assigns the role and there is no path from the public form to an administrator. The same holds for profile updates: a user cannot promote themselves. - after_authentication_url now falls back to the role: administrators land on the dashboard, everyone else on their own profile. A remembered page still wins, because returning the user there is the point of storing it. - ProfilesController never reads an identifier from the request; it always acts on Current.user, so there is no id to tamper with. The visual language is ported from the Onix design system as a token layer plus semantic component classes, using its "verde" accent over the "porcelana" and "marfim" light shells instead of its dark-and-gold default. Contrast ratios are documented in tokens.css; the reduced-motion preference is respected. Verified: 33 examples, 0 failures; RuboCop clean across 61 files. Coverage is at 77.6% line / 61.5% branch and still under the gate, which stays enforced. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BS3oGqeR1PXScuK6gLr8Mi --- app/assets/tailwind/application.css | 1 + app/assets/tailwind/components.css | 241 ++++++++++++++++++ app/controllers/admin/base_controller.rb | 5 + app/controllers/admin/dashboard_controller.rb | 5 + app/controllers/application_controller.rb | 1 + app/controllers/concerns/authentication.rb | 8 +- app/controllers/concerns/authorization.rb | 25 ++ app/controllers/home_controller.rb | 9 + app/controllers/profiles_controller.rb | 36 +++ app/controllers/registrations_controller.rb | 27 ++ app/helpers/application_helper.rb | 13 + app/views/admin/dashboard/show.html.erb | 8 + app/views/layouts/application.html.erb | 48 +++- app/views/profiles/edit.html.erb | 26 ++ app/views/profiles/show.html.erb | 47 ++++ app/views/registrations/new.html.erb | 42 +++ app/views/sessions/new.html.erb | 42 ++- app/views/shared/_brand.html.erb | 4 + app/views/shared/_current_user_chip.html.erb | 12 + app/views/shared/_flash.html.erb | 15 ++ app/views/shared/_form_errors.html.erb | 13 + app/views/shared/_sidebar.html.erb | 22 ++ app/views/shared/_topbar.html.erb | 12 + config/locales/en.yml | 11 + config/routes.rb | 18 +- spec/requests/admin/dashboard_spec.rb | 28 ++ spec/requests/profiles_spec.rb | 60 +++++ spec/requests/registrations_spec.rb | 61 +++++ spec/requests/sessions_spec.rb | 46 ++++ spec/support/authentication_helpers.rb | 12 + 30 files changed, 853 insertions(+), 45 deletions(-) create mode 100644 app/assets/tailwind/components.css create mode 100644 app/controllers/admin/base_controller.rb create mode 100644 app/controllers/admin/dashboard_controller.rb create mode 100644 app/controllers/concerns/authorization.rb create mode 100644 app/controllers/home_controller.rb create mode 100644 app/controllers/profiles_controller.rb create mode 100644 app/controllers/registrations_controller.rb create mode 100644 app/views/admin/dashboard/show.html.erb create mode 100644 app/views/profiles/edit.html.erb create mode 100644 app/views/profiles/show.html.erb create mode 100644 app/views/registrations/new.html.erb create mode 100644 app/views/shared/_brand.html.erb create mode 100644 app/views/shared/_current_user_chip.html.erb 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/_sidebar.html.erb create mode 100644 app/views/shared/_topbar.html.erb create mode 100644 spec/requests/admin/dashboard_spec.rb create mode 100644 spec/requests/profiles_spec.rb create mode 100644 spec/requests/registrations_spec.rb create mode 100644 spec/requests/sessions_spec.rb create mode 100644 spec/support/authentication_helpers.rb diff --git a/app/assets/tailwind/application.css b/app/assets/tailwind/application.css index f840f7106..4f5a15ce0 100644 --- a/app/assets/tailwind/application.css +++ b/app/assets/tailwind/application.css @@ -1,2 +1,3 @@ @import "tailwindcss"; @import "./tokens.css"; +@import "./components.css"; diff --git a/app/assets/tailwind/components.css b/app/assets/tailwind/components.css new file mode 100644 index 000000000..135925f23 --- /dev/null +++ b/app/assets/tailwind/components.css @@ -0,0 +1,241 @@ +/* + * Component classes. + * + * These mirror the semantic classes the Onix design system defines + * (.metric-card, .panel-surface, .btn-primary, .badge-accent, the sidebar + * active bar) rather than scattering the same utility strings across every + * template. Markup stays readable and the visual language has one home. + */ + +@layer components { + /* ── Surfaces ───────────────────────────────────────────────────────── */ + .panel { + background-color: var(--color-surface); + border: 1px solid var(--color-line); + border-radius: var(--radius-card); + box-shadow: var(--shadow-card); + } + + .panel-quiet { + background-color: var(--color-surface-soft); + border: 1px solid var(--color-line); + border-radius: var(--radius-lg); + } + + .metric-card { + background-color: var(--color-surface); + border: 1px solid var(--color-line); + border-radius: var(--radius-card); + padding: 1.25rem; + transition: border-color 180ms ease, box-shadow 180ms ease; + } + + .metric-card:hover { + border-color: var(--color-line-strong); + box-shadow: var(--shadow-card); + } + + /* ── Typographic roles ──────────────────────────────────────────────── */ + .eyebrow { + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: var(--tracking-eyebrow); + text-transform: uppercase; + color: var(--color-muted); + } + + .metric-value { + font-size: 1.875rem; + font-weight: 600; + letter-spacing: -0.02em; + color: var(--color-ink); + } + + /* ── Buttons ────────────────────────────────────────────────────────── */ + .btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + border-radius: var(--radius-md); + font-size: 0.875rem; + font-weight: 600; + line-height: 1; + cursor: pointer; + border: 1px solid transparent; + transition: background-color 150ms ease, border-color 150ms ease, color 150ms ease; + } + + .btn:focus-visible { + outline: 2px solid transparent; + box-shadow: var(--shadow-focus); + } + + .btn-primary { + background-color: var(--color-accent-strong); + color: #fff; + } + + .btn-primary:hover { + background-color: var(--color-accent); + } + + .btn-ghost { + background-color: transparent; + border-color: var(--color-line-strong); + color: var(--color-accent-strong); + } + + .btn-ghost:hover { + background-color: var(--color-accent-wash); + } + + .btn-danger { + background-color: transparent; + border-color: color-mix(in srgb, var(--color-critical) 35%, transparent); + color: var(--color-critical); + } + + .btn-danger:hover { + background-color: color-mix(in srgb, var(--color-critical) 8%, transparent); + } + + /* ── Forms ──────────────────────────────────────────────────────────── */ + .field { + display: block; + margin-bottom: 1.25rem; + } + + .field-label { + display: block; + margin-bottom: 0.375rem; + font-size: 0.8125rem; + font-weight: 500; + color: var(--color-ink); + } + + .field-hint { + margin-top: 0.375rem; + font-size: 0.75rem; + color: var(--color-muted); + } + + .field-input { + display: block; + width: 100%; + padding: 0.625rem 0.75rem; + font-size: 0.875rem; + color: var(--color-ink); + background-color: var(--color-surface); + border: 1px solid var(--color-line-strong); + border-radius: var(--radius-md); + transition: border-color 150ms ease, box-shadow 150ms ease; + } + + .field-input::placeholder { + color: var(--color-dim); + } + + .field-input:focus { + outline: none; + border-color: var(--color-accent); + box-shadow: var(--shadow-focus); + } + + .field-input[aria-invalid="true"] { + border-color: var(--color-critical); + } + + .field-error { + margin-top: 0.375rem; + font-size: 0.75rem; + color: var(--color-critical); + } + + /* ── Badges ─────────────────────────────────────────────────────────── */ + .badge { + display: inline-flex; + align-items: center; + padding: 0.125rem 0.5rem; + border-radius: 9999px; + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + } + + .badge-admin { + background-color: var(--color-accent-wash); + border: 1px solid var(--color-line-strong); + color: var(--color-accent-strong); + } + + .badge-user { + background-color: var(--color-surface-soft); + border: 1px solid var(--color-line); + color: var(--color-muted); + } + + /* ── Sidebar ────────────────────────────────────────────────────────── */ + .rail-item { + position: relative; + display: flex; + align-items: center; + gap: 0.625rem; + padding: 0.5rem 0.875rem; + border-radius: var(--radius-sm); + font-size: 0.875rem; + font-weight: 500; + color: var(--color-rail-text); + transition: background-color 150ms ease, color 150ms ease; + } + + .rail-item:hover { + background-color: var(--color-surface-soft); + color: var(--color-rail-text-hover); + } + + .rail-item[aria-current="page"] { + background-color: var(--color-rail-active); + color: var(--color-rail-text-hover); + } + + /* The 3px accent bar is the Onix active-state signature. */ + .rail-item[aria-current="page"]::before { + content: ""; + position: absolute; + inset-block: 0; + left: 0; + width: 3px; + border-radius: 0 2px 2px 0; + background-color: var(--color-accent); + } + + /* ── Avatar fallback ────────────────────────────────────────────────── */ + .avatar { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + overflow: hidden; + border-radius: 9999px; + background-color: var(--color-accent-wash); + border: 1px solid var(--color-line-strong); + color: var(--color-accent-strong); + font-weight: 600; + text-transform: uppercase; + } +} + +/* Animation is decoration here; users who ask for less should get less. */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb new file mode 100644 index 000000000..0e099c706 --- /dev/null +++ b/app/controllers/admin/base_controller.rb @@ -0,0 +1,5 @@ +module Admin + class BaseController < ApplicationController + before_action :require_admin + end +end diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb new file mode 100644 index 000000000..bb972a001 --- /dev/null +++ b/app/controllers/admin/dashboard_controller.rb @@ -0,0 +1,5 @@ +module Admin + class DashboardController < BaseController + def show; end + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 8afd42d88..3b0c03ef1 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,5 +1,6 @@ class ApplicationController < ActionController::Base include Authentication + include Authorization # 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 index 935633ca6..b02c16c67 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -35,8 +35,14 @@ def request_authentication redirect_to new_session_path end + # A page the user was bounced from wins, because returning them there is the + # whole point of remembering it. With nothing stored, the role decides. def after_authentication_url - session.delete(:return_to_after_authenticating) || root_url + session.delete(:return_to_after_authenticating) || post_authentication_url_for(Current.user) + end + + def post_authentication_url_for(user) + user.admin? ? admin_dashboard_path : profile_path end def start_new_session_for(user) diff --git a/app/controllers/concerns/authorization.rb b/app/controllers/concerns/authorization.rb new file mode 100644 index 000000000..8ba10df20 --- /dev/null +++ b/app/controllers/concerns/authorization.rb @@ -0,0 +1,25 @@ +# Authorization is deliberately a small, explicit layer rather than a gem: the +# application has two roles and a handful of rules, and a policy framework +# would add indirection without removing any decisions. +module Authorization + extend ActiveSupport::Concern + + included do + helper_method :admin? + end + + private + + def admin? + Current.user&.admin? || false + end + + # Sends a signed-in non-admin back to the only area they own. The admin area + # is not a secret, so this says no plainly instead of pretending the route + # does not exist. + def require_admin + return if admin? + + redirect_to profile_path, alert: t("authorization.admin_only") + end +end diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb new file mode 100644 index 000000000..a5e65d1d7 --- /dev/null +++ b/app/controllers/home_controller.rb @@ -0,0 +1,9 @@ +class HomeController < ApplicationController + allow_unauthenticated_access + + # The root path is a signpost: visitors are sent to sign in, and everyone + # else lands wherever their role belongs. + def index + redirect_to authenticated? ? post_authentication_url_for(Current.user) : new_session_path + end +end diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb new file mode 100644 index 000000000..0154e2066 --- /dev/null +++ b/app/controllers/profiles_controller.rb @@ -0,0 +1,36 @@ +class ProfilesController < ApplicationController + before_action :set_user + + def show; end + + def edit; end + + def update + if @user.update(profile_params) + redirect_to profile_path, notice: t("profiles.updated") + else + render :edit, status: :unprocessable_content + end + end + + def destroy + user = @user + terminate_session + user.destroy! + + redirect_to new_session_path, status: :see_other, notice: t("profiles.deleted") + end + + private + + # Always the signed-in user. Nothing here reads an id from the request, so + # there is no identifier to tamper with. + def set_user + @user = Current.user + end + + # `role` is not permitted: a user cannot promote themselves. + def profile_params + params.expect(user: %i[full_name email_address avatar_url]) + end +end diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb new file mode 100644 index 000000000..ab0076910 --- /dev/null +++ b/app/controllers/registrations_controller.rb @@ -0,0 +1,27 @@ +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 profile_path, notice: t("registrations.created") + else + render :new, status: :unprocessable_content + end + end + + private + + # `role` is absent by design. A visitor can only ever create a regular user, + # and the column default is what assigns the role -- there is no code path + # from this form to an administrator. + def registration_params + params.expect(user: %i[full_name email_address password password_confirmation]) + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index de6be7945..6e000eaac 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,2 +1,15 @@ module ApplicationHelper + # Falls back to initials when there is no avatar to show. Two letters at most, + # taken from the first and last word of the name. + def user_initials(user) + parts = user.full_name.to_s.split.compact_blank + return "?" if parts.empty? + + [parts.first, (parts.last if parts.size > 1)].compact.pluck(0).join + end + + def role_badge(user) + tag.span user.role.humanize, + class: "badge #{user.admin? ? "badge-admin" : "badge-user"}" + end end diff --git a/app/views/admin/dashboard/show.html.erb b/app/views/admin/dashboard/show.html.erb new file mode 100644 index 000000000..286de509d --- /dev/null +++ b/app/views/admin/dashboard/show.html.erb @@ -0,0 +1,8 @@ +<% content_for :title, "Dashboard" %> +<% content_for :page_title, "Dashboard" %> +<% content_for :page_subtitle, "An overview of the people in the system" %> + +
+

Overview

+

Users

+
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 316426f98..b7cf8308a 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -1,31 +1,55 @@ - + - <%= content_for(:title) || "User Management" %> + <%= content_for(:title) || "Roster" %> - + <%= 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 %> -
+ +
+ Skip to content + + + <% if authenticated? %> +
+ <%= render "shared/sidebar" %> + +
+ <%= render "shared/topbar" %> + +
+
+ <%= render "shared/flash" %> + <%= yield %> +
+
+
+
+ <% else %> +
+
+ <%= render "shared/brand" %> + <%= render "shared/flash" %> + <%= yield %> +
+
+ <% end %> diff --git a/app/views/profiles/edit.html.erb b/app/views/profiles/edit.html.erb new file mode 100644 index 000000000..e0467b945 --- /dev/null +++ b/app/views/profiles/edit.html.erb @@ -0,0 +1,26 @@ +<% content_for :title, "Edit profile" %> +<% content_for :page_title, "Edit profile" %> +<% content_for :page_subtitle, "Update your account details" %> + +
+ <%= form_with model: @user, url: profile_path, method: :patch do |form| %> + <%= render "shared/form_errors", record: @user %> + +
+ <%= form.label :full_name, "Full name", class: "field-label" %> + <%= form.text_field :full_name, required: true, autocomplete: "name", + aria: { invalid: @user.errors[:full_name].any? }, class: "field-input" %> +
+ +
+ <%= form.label :email_address, "Email address", class: "field-label" %> + <%= form.email_field :email_address, required: true, autocomplete: "email", + aria: { invalid: @user.errors[:email_address].any? }, class: "field-input" %> +
+ +
+ <%= form.submit "Save changes", class: "btn btn-primary" %> + <%= link_to "Cancel", profile_path, class: "btn btn-ghost" %> +
+ <% end %> +
diff --git a/app/views/profiles/show.html.erb b/app/views/profiles/show.html.erb new file mode 100644 index 000000000..7dc47554f --- /dev/null +++ b/app/views/profiles/show.html.erb @@ -0,0 +1,47 @@ +<% content_for :title, "My profile" %> +<% content_for :page_title, "My profile" %> +<% content_for :page_subtitle, "Your account details" %> + +
+
+ + +
+

<%= @user.full_name %>

+

<%= @user.email_address %>

+
<%= role_badge(@user) %>
+
+ + <%= link_to "Edit profile", edit_profile_path, class: "btn btn-ghost" %> +
+ +
+
+
Full name
+
<%= @user.full_name %>
+
+
+
Email address
+
<%= @user.email_address %>
+
+
+
Role
+
<%= @user.role.humanize %>
+
+
+
Member since
+
<%= l(@user.created_at.to_date, format: :long) %>
+
+
+
+ +
+
+

Delete this account

+

Your profile and sessions are removed. This cannot be undone.

+
+ + <%= button_to "Delete my account", profile_path, method: :delete, + class: "btn btn-danger", + form: { data: { turbo_confirm: "Delete your account? This cannot be undone." } } %> +
diff --git a/app/views/registrations/new.html.erb b/app/views/registrations/new.html.erb new file mode 100644 index 000000000..800cc70c8 --- /dev/null +++ b/app/views/registrations/new.html.erb @@ -0,0 +1,42 @@ +<% content_for :title, "Create your account" %> + +
+

Create your account

+

You will be signed in as soon as it is ready.

+ + <%= form_with model: @user, url: registration_path, class: "mt-6" do |form| %> + <%= render "shared/form_errors", record: @user %> + +
+ <%= form.label :full_name, "Full name", class: "field-label" %> + <%= form.text_field :full_name, required: true, autofocus: true, autocomplete: "name", + aria: { invalid: @user.errors[:full_name].any? }, class: "field-input" %> +
+ +
+ <%= form.label :email_address, "Email address", class: "field-label" %> + <%= form.email_field :email_address, required: true, autocomplete: "email", + aria: { invalid: @user.errors[:email_address].any? }, class: "field-input" %> +
+ +
+ <%= form.label :password, "Password", class: "field-label" %> + <%= form.password_field :password, required: true, autocomplete: "new-password", + maxlength: 72, aria: { describedby: "password-hint" }, 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", + maxlength: 72, class: "field-input" %> +
+ + <%= form.submit "Create account", class: "btn btn-primary w-full" %> + <% end %> + +

+ Already have an account? + <%= link_to "Sign in", new_session_path, class: "text-accent-strong hover:underline" %> +

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

<%= alert %>

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

<%= notice %>

- <% end %> +<% content_for :title, "Sign in" %> -

Sign in

+
+

Sign in

+

Use the email address and password for your account.

- <%= 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_with url: session_path, class: "mt-6" do |form| %> +
+ <%= form.label :email_address, "Email address", class: "field-label" %> + <%= form.email_field :email_address, required: true, autofocus: true, + autocomplete: "username", value: params[:email_address], class: "field-input" %>
-
- <%= 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.label :password, "Password", class: "field-label" %> + <%= form.password_field :password, required: true, autocomplete: "current-password", + maxlength: 72, class: "field-input" %>
-
-
- <%= 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" %> -
-
+ <%= form.submit "Sign in", class: "btn btn-primary w-full" %> <% end %> + +
+ <%= link_to "Forgot password?", new_password_path, class: "text-accent-strong hover:underline" %> + <%= link_to "Create an account", new_registration_path, class: "text-accent-strong hover:underline" %> +
diff --git a/app/views/shared/_brand.html.erb b/app/views/shared/_brand.html.erb new file mode 100644 index 000000000..b98ed17ca --- /dev/null +++ b/app/views/shared/_brand.html.erb @@ -0,0 +1,4 @@ +
+

Roster

+

User management

+
diff --git a/app/views/shared/_current_user_chip.html.erb b/app/views/shared/_current_user_chip.html.erb new file mode 100644 index 000000000..a518bf13d --- /dev/null +++ b/app/views/shared/_current_user_chip.html.erb @@ -0,0 +1,12 @@ +
+ + +
+

<%= Current.user.full_name %>

+

<%= Current.user.role.humanize %>

+
+ + <%= button_to "Sign out", session_path, method: :delete, + class: "btn btn-ghost px-2 py-1 text-xs", + form: { data: { turbo_confirm: "Sign out of Roster?" } } %> +
diff --git a/app/views/shared/_flash.html.erb b/app/views/shared/_flash.html.erb new file mode 100644 index 000000000..438542f61 --- /dev/null +++ b/app/views/shared/_flash.html.erb @@ -0,0 +1,15 @@ +<%# Notices are announced politely; alerts interrupt, because they report a + failure the user has to act on. %> +<% if notice.present? %> +
+ <%= notice %> +
+<% end %> + +<% if alert.present? %> + +<% 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..2ad1b8cfd --- /dev/null +++ b/app/views/shared/_form_errors.html.erb @@ -0,0 +1,13 @@ +<% if record.errors.any? %> + +<% end %> diff --git a/app/views/shared/_sidebar.html.erb b/app/views/shared/_sidebar.html.erb new file mode 100644 index 000000000..7b740f34a --- /dev/null +++ b/app/views/shared/_sidebar.html.erb @@ -0,0 +1,22 @@ + diff --git a/app/views/shared/_topbar.html.erb b/app/views/shared/_topbar.html.erb new file mode 100644 index 000000000..cf889f691 --- /dev/null +++ b/app/views/shared/_topbar.html.erb @@ -0,0 +1,12 @@ +
+
+

<%= content_for(:page_title) || "Roster" %>

+ <% if content_for?(:page_subtitle) %> +

<%= content_for(:page_subtitle) %>

+ <% end %> +
+ +
+ <%= button_to "Sign out", session_path, method: :delete, class: "btn btn-ghost text-xs" %> +
+
diff --git a/config/locales/en.yml b/config/locales/en.yml index b3d816929..a3381e859 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -43,3 +43,14 @@ en: passwords_mailer: reset: subject: "Reset your password" + + authorization: + admin_only: "That area is only available to administrators." + + registrations: + created: "Welcome. Your account is ready." + + profiles: + updated: "Your profile has been updated." + deleted: "Your account has been deleted." + diff --git a/config/routes.rb b/config/routes.rb index 29b007b33..9b2d1eef0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,16 +1,16 @@ Rails.application.routes.draw do + root "home#index" + resource :session resources :passwords, param: :token - # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html + resource :registration, only: %i[new create], path: "sign_up", path_names: { new: "" } + resource :profile, only: %i[show edit update destroy] + + namespace :admin do + get "dashboard", to: "dashboard#show" + resources :users + end # 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/spec/requests/admin/dashboard_spec.rb b/spec/requests/admin/dashboard_spec.rb new file mode 100644 index 000000000..1108a04ab --- /dev/null +++ b/spec/requests/admin/dashboard_spec.rb @@ -0,0 +1,28 @@ +require "rails_helper" + +RSpec.describe "Admin dashboard" do + describe "GET /admin/dashboard" do + it "is shown to an administrator" do + sign_in create(:user, :admin) + + get admin_dashboard_path + + expect(response).to have_http_status(:ok) + end + + it "sends a visitor to the sign in page" do + get admin_dashboard_path + + expect(response).to redirect_to(new_session_path) + end + + it "refuses a regular user" do + sign_in create(:user) + + get admin_dashboard_path + + expect(response).to redirect_to(profile_path) + expect(flash[:alert]).to be_present + end + end +end diff --git a/spec/requests/profiles_spec.rb b/spec/requests/profiles_spec.rb new file mode 100644 index 000000000..6a857a729 --- /dev/null +++ b/spec/requests/profiles_spec.rb @@ -0,0 +1,60 @@ +require "rails_helper" + +RSpec.describe "Profiles" do + let(:user) { create(:user, full_name: "Maria Silva") } + + describe "GET /profile" do + it "shows the signed in user their own profile" do + sign_in user + + get profile_path + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Maria Silva") + end + + it "sends a visitor to the sign in page" do + get profile_path + + expect(response).to redirect_to(new_session_path) + end + end + + describe "PATCH /profile" do + it "updates the signed in user" do + sign_in user + + patch profile_path, params: { user: { full_name: "Maria Silva Santos" } } + + expect(response).to redirect_to(profile_path) + expect(user.reload.full_name).to eq("Maria Silva Santos") + end + + it "never lets a user promote themselves" do + sign_in user + + patch profile_path, params: { user: { full_name: "Maria", role: "admin" } } + + expect(user.reload).to be_user + end + + it "rejects an invalid change" do + sign_in user + + patch profile_path, params: { user: { email_address: "" } } + + expect(response).to have_http_status(:unprocessable_content) + expect(user.reload.email_address).to be_present + end + end + + describe "DELETE /profile" do + it "deletes the signed in user's own account" do + sign_in user + + expect { delete profile_path }.to change(User, :count).by(-1) + + expect(response).to redirect_to(new_session_path) + end + end +end diff --git a/spec/requests/registrations_spec.rb b/spec/requests/registrations_spec.rb new file mode 100644 index 000000000..399f8aa1c --- /dev/null +++ b/spec/requests/registrations_spec.rb @@ -0,0 +1,61 @@ +require "rails_helper" + +RSpec.describe "Registrations" do + let(:valid_params) do + { + user: { + full_name: "Maria Silva", + email_address: "maria@example.com", + password: "a-sufficiently-long-password", + password_confirmation: "a-sufficiently-long-password" + } + } + end + + describe "GET /sign_up" do + it "is reachable without being signed in" do + get new_registration_path + + expect(response).to have_http_status(:ok) + end + end + + describe "POST /registration" do + it "creates the account and signs the visitor in" do + expect { post registration_path, params: valid_params }.to change(User, :count).by(1) + + expect(response).to redirect_to(profile_path) + end + + it "always creates a regular user" do + post registration_path, params: valid_params + + expect(User.last).to be_user + end + + it "ignores a role smuggled through the public form" do + params = valid_params.deep_merge(user: { role: "admin" }) + + post registration_path, params: params + + expect(User.last).to be_user + expect(User.where(role: :admin)).to be_empty + end + + it "rejects an invalid submission without creating anything" do + params = valid_params.deep_merge(user: { email_address: "not-an-email" }) + + expect { post registration_path, params: params }.not_to change(User, :count) + + expect(response).to have_http_status(:unprocessable_content) + end + + it "rejects a mismatched password confirmation" do + params = valid_params.deep_merge(user: { password_confirmation: "something else" }) + + expect { post registration_path, params: params }.not_to change(User, :count) + + expect(response).to have_http_status(:unprocessable_content) + end + end +end diff --git a/spec/requests/sessions_spec.rb b/spec/requests/sessions_spec.rb new file mode 100644 index 000000000..ccfeb0034 --- /dev/null +++ b/spec/requests/sessions_spec.rb @@ -0,0 +1,46 @@ +require "rails_helper" + +RSpec.describe "Sessions" do + describe "POST /session" do + it "sends an administrator to the admin dashboard" do + sign_in create(:user, :admin) + + expect(response).to redirect_to(admin_dashboard_path) + end + + it "sends a regular user to their own profile" do + sign_in create(:user) + + expect(response).to redirect_to(profile_path) + end + + it "refuses a wrong password" do + user = create(:user) + + sign_in(user, password: "wrong password") + + expect(response).to redirect_to(new_session_path) + expect(flash[:alert]).to be_present + end + + it "gives the same answer for an unknown email address" do + post session_path, params: { email_address: "nobody@example.com", password: "whatever" } + + expect(response).to redirect_to(new_session_path) + expect(flash[:alert]).to eq(I18n.t("sessions.invalid_credentials")) + end + end + + describe "DELETE /session" do + it "signs the user out" do + sign_in create(:user) + + delete session_path + + expect(response).to redirect_to(new_session_path) + + get profile_path + expect(response).to redirect_to(new_session_path) + end + end +end diff --git a/spec/support/authentication_helpers.rb b/spec/support/authentication_helpers.rb new file mode 100644 index 000000000..64dcc387e --- /dev/null +++ b/spec/support/authentication_helpers.rb @@ -0,0 +1,12 @@ +module AuthenticationHelpers + DEFAULT_PASSWORD = "a-sufficiently-long-password".freeze + + def sign_in(user, password: DEFAULT_PASSWORD) + post session_path, params: { email_address: user.email_address, password: password } + end +end + +RSpec.configure do |config| + config.include AuthenticationHelpers, type: :request + config.include AuthenticationHelpers, type: :system +end From f4fda27039ab1861e61050d4cfa601d6e98fe55d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B4nio=20Neto?= Date: Thu, 3 Sep 2026 14:28:31 -0300 Subject: [PATCH 04/33] feat: let each user pick their interface language Ship the interface in English, Brazilian Portuguese and Spanish, with the choice belonging to the person rather than the browser. - The locale lives on users.locale: one column, defaulted to "en", validated against User::SUPPORTED_LOCALES and held to the same list by a check constraint. A separate settings table would be ceremony for a single field. - Localization resolves the locale in a deliberate order: the signed-in user's saved choice, then a visitor's session choice, then Accept-Language, then the default. That means someone who picks a language before signing up keeps it through registration, and it follows them to any browser once saved. - The picker is a row of flags in the top bar for signed-in users and under the wordmark for visitors. The flags are inline SVG, so there are no image requests and they scale cleanly; each button carries the language name as its accessible name and aria-pressed marks the active one, because a flag alone says nothing to a screen reader. - rails-i18n supplies the date formats and Active Record error messages for the two added languages, which would otherwise have stayed English inside otherwise translated pages. All three locale files carry exactly the same 66 keys, and fallbacks are on, so a missing translation renders English rather than a raw key. Verified: 42 examples, 0 failures; RuboCop clean across 65 files. Accept-Language negotiation and the html lang attribute confirmed for all three locales. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BS3oGqeR1PXScuK6gLr8Mi --- Gemfile | 4 + Gemfile.lock | 5 + app/controllers/application_controller.rb | 1 + app/controllers/concerns/localization.rb | 48 +++++++ app/controllers/locales_controller.rb | 25 ++++ app/models/user.rb | 6 + app/views/admin/dashboard/show.html.erb | 12 +- app/views/layouts/application.html.erb | 4 +- app/views/profiles/edit.html.erb | 14 +- app/views/profiles/show.html.erb | 26 ++-- app/views/registrations/new.html.erb | 22 ++-- app/views/sessions/new.html.erb | 16 +-- app/views/shared/_brand.html.erb | 6 +- app/views/shared/_current_user_chip.html.erb | 6 +- app/views/shared/_flag.html.erb | 23 ++++ app/views/shared/_form_errors.html.erb | 2 +- app/views/shared/_language_picker.html.erb | 11 ++ app/views/shared/_sidebar.html.erb | 10 +- app/views/shared/_topbar.html.erb | 8 +- config/application.rb | 8 ++ config/locales/en.yml | 121 ++++++++++++------ config/locales/es.yml | 97 ++++++++++++++ config/locales/pt-BR.yml | 97 ++++++++++++++ config/routes.rb | 1 + .../20260903172348_add_locale_to_users.rb | 10 ++ db/schema.rb | 4 +- spec/models/user_spec.rb | 16 +++ spec/requests/locales_spec.rb | 57 +++++++++ 28 files changed, 560 insertions(+), 100 deletions(-) create mode 100644 app/controllers/concerns/localization.rb create mode 100644 app/controllers/locales_controller.rb create mode 100644 app/views/shared/_flag.html.erb create mode 100644 app/views/shared/_language_picker.html.erb create mode 100644 config/locales/es.yml create mode 100644 config/locales/pt-BR.yml create mode 100644 db/migrate/20260903172348_add_locale_to_users.rb create mode 100644 spec/requests/locales_spec.rb diff --git a/Gemfile b/Gemfile index 7bee6b79f..1fbdd0af1 100644 --- a/Gemfile +++ b/Gemfile @@ -13,6 +13,10 @@ gem "turbo-rails" # Password hashing for the built-in Rails authentication gem "bcrypt", "~> 3.1" +# Locale data for the shipped languages: date formats, number formats and the +# Active Record validation messages, which would otherwise stay English-only. +gem "rails-i18n", "~> 8.0" + gem "pg", "~> 1.1" gem "puma", ">= 5.0" diff --git a/Gemfile.lock b/Gemfile.lock index 1d16ed1db..353268fff 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -272,6 +272,9 @@ GEM 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) + rails-i18n (8.1.0) + i18n (>= 0.7, < 2) + railties (>= 8.0.0, < 9) railties (8.1.3.1) actionpack (= 8.1.3.1) activesupport (= 8.1.3.1) @@ -455,6 +458,7 @@ DEPENDENCIES propshaft puma (>= 5.0) rails (~> 8.1.3, >= 8.1.3.1) + rails-i18n (~> 8.0) roo (~> 3.0) rspec-rails (~> 8.0) rubocop @@ -584,6 +588,7 @@ CHECKSUMS rails (8.1.3.1) sha256=ccd11a36bfc171bf9c66d585d14c0ece91c0c9dde840aae60c0118d6f5c9c52a rails-dom-testing (2.3.0) sha256=8acc7953a7b911ca44588bf08737bc16719f431a1cc3091a292bca7317925c1d rails-html-sanitizer (1.7.1) sha256=e797a7c9b01e567307e317c576b49ab4168017e63eea4dba9ce3cb587e2f22c2 + rails-i18n (8.1.0) sha256=52d5fd6c0abef28d84223cc05647f6ae0fd552637a1ede92deee9545755b6cf3 railties (8.1.3.1) sha256=2388a232579a00cefea4487de66c8553c3408c1300abdc6cf1799d86ffb04487 rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 3b0c03ef1..9513e1c5b 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,6 +1,7 @@ class ApplicationController < ActionController::Base include Authentication include Authorization + include Localization # 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/localization.rb b/app/controllers/concerns/localization.rb new file mode 100644 index 000000000..0aa14c897 --- /dev/null +++ b/app/controllers/concerns/localization.rb @@ -0,0 +1,48 @@ +# Chooses the locale for every request, in a deliberate order of preference: +# the signed-in user's saved choice, then a visitor's session choice, then what +# the browser asks for, then the default. +module Localization + extend ActiveSupport::Concern + + included do + around_action :switch_locale + helper_method :current_locale, :supported_locales + end + + private + + def switch_locale(&) + I18n.with_locale(current_locale, &) + end + + def current_locale + @current_locale ||= Current.user&.locale.presence || + session[:locale].presence || + locale_from_request_headers || + I18n.default_locale.to_s + end + + def supported_locales + User::SUPPORTED_LOCALES + end + + # Reads Accept-Language and takes the first entry the application actually + # speaks, preferring an exact tag ("pt-BR") over a language match ("pt"). + # Quality weights are ignored: browsers already send the list in descending + # order, and parsing them would add work for no difference here. + def locale_from_request_headers + tags = accepted_language_tags + + tags.find { |tag| User::SUPPORTED_LOCALES.include?(tag) } || + tags.filter_map { |tag| supported_locale_for_language(tag) }.first + end + + def accepted_language_tags + request.headers["Accept-Language"].to_s.split(",").map { |tag| tag.split(";").first.to_s.strip } + end + + def supported_locale_for_language(tag) + language = tag.split("-").first + User::SUPPORTED_LOCALES.find { |locale| locale.split("-").first == language } + end +end diff --git a/app/controllers/locales_controller.rb b/app/controllers/locales_controller.rb new file mode 100644 index 000000000..73a493adb --- /dev/null +++ b/app/controllers/locales_controller.rb @@ -0,0 +1,25 @@ +class LocalesController < ApplicationController + allow_unauthenticated_access + + # Visitors may switch language too, so authentication is not required. The + # session still has to be resumed by hand: skipping require_authentication + # also skips what would otherwise populate Current.user, and without it a + # signed-in user's choice would never reach their account. + before_action :resume_session + + # Persists the choice on the account when there is one, and in the session + # otherwise, so a visitor who picks a language keeps it through sign up. + def update + locale = params[:locale].to_s + + unless User::SUPPORTED_LOCALES.include?(locale) + return redirect_back_or_to(root_path, status: :unprocessable_content, + alert: t("locales.unsupported")) + end + + session[:locale] = locale + Current.user&.update!(locale: locale) + + redirect_back_or_to(root_path, status: :see_other) + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 7827e077a..b674572fe 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,6 +1,11 @@ class User < ApplicationRecord MAX_FULL_NAME_LENGTH = 120 + # The locales the interface is actually translated into. Keeping the list on + # the model means the validation, the check constraint and the language + # picker all read from one place. + SUPPORTED_LOCALES = %w[en pt-BR es].freeze + has_secure_password has_many :sessions, dependent: :destroy @@ -17,4 +22,5 @@ class User < ApplicationRecord presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }, uniqueness: { case_sensitive: false } + validates :locale, inclusion: { in: SUPPORTED_LOCALES } end diff --git a/app/views/admin/dashboard/show.html.erb b/app/views/admin/dashboard/show.html.erb index 286de509d..baa31042d 100644 --- a/app/views/admin/dashboard/show.html.erb +++ b/app/views/admin/dashboard/show.html.erb @@ -1,8 +1,8 @@ -<% content_for :title, "Dashboard" %> -<% content_for :page_title, "Dashboard" %> -<% content_for :page_subtitle, "An overview of the people in the system" %> +<% content_for :title, t(".title") %> +<% content_for :page_title, t(".title") %> +<% content_for :page_subtitle, t(".subtitle") %> -
-

Overview

-

Users

+
+

<%= t(".overview") %>

+

<%= t(".users") %>

diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index b7cf8308a..3a015d4b2 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -1,5 +1,5 @@ - + <%= content_for(:title) || "Roster" %> @@ -24,7 +24,7 @@ - Skip to content + <%= t("shared.skip_to_content") %> <% if authenticated? %> diff --git a/app/views/profiles/edit.html.erb b/app/views/profiles/edit.html.erb index e0467b945..670b638ee 100644 --- a/app/views/profiles/edit.html.erb +++ b/app/views/profiles/edit.html.erb @@ -1,26 +1,26 @@ -<% content_for :title, "Edit profile" %> -<% content_for :page_title, "Edit profile" %> -<% content_for :page_subtitle, "Update your account details" %> +<% content_for :title, t(".title") %> +<% content_for :page_title, t(".title") %> +<% content_for :page_subtitle, t(".subtitle") %>
<%= form_with model: @user, url: profile_path, method: :patch do |form| %> <%= render "shared/form_errors", record: @user %>
- <%= form.label :full_name, "Full name", class: "field-label" %> + <%= form.label :full_name, t(".full_name"), class: "field-label" %> <%= form.text_field :full_name, required: true, autocomplete: "name", aria: { invalid: @user.errors[:full_name].any? }, class: "field-input" %>
- <%= form.label :email_address, "Email address", class: "field-label" %> + <%= form.label :email_address, t(".email_address"), class: "field-label" %> <%= form.email_field :email_address, required: true, autocomplete: "email", aria: { invalid: @user.errors[:email_address].any? }, class: "field-input" %>
- <%= form.submit "Save changes", class: "btn btn-primary" %> - <%= link_to "Cancel", profile_path, class: "btn btn-ghost" %> + <%= form.submit t(".submit"), class: "btn btn-primary" %> + <%= link_to t(".cancel"), profile_path, class: "btn btn-ghost" %>
<% end %>
diff --git a/app/views/profiles/show.html.erb b/app/views/profiles/show.html.erb index 7dc47554f..238125071 100644 --- a/app/views/profiles/show.html.erb +++ b/app/views/profiles/show.html.erb @@ -1,6 +1,6 @@ -<% content_for :title, "My profile" %> -<% content_for :page_title, "My profile" %> -<% content_for :page_subtitle, "Your account details" %> +<% content_for :title, t(".title") %> +<% content_for :page_title, t(".title") %> +<% content_for :page_subtitle, t(".subtitle") %>
@@ -12,24 +12,24 @@
<%= role_badge(@user) %>
- <%= link_to "Edit profile", edit_profile_path, class: "btn btn-ghost" %> + <%= link_to t(".edit"), edit_profile_path, class: "btn btn-ghost" %>
-
Full name
+
<%= t(".full_name") %>
<%= @user.full_name %>
-
Email address
+
<%= t(".email_address") %>
<%= @user.email_address %>
-
Role
-
<%= @user.role.humanize %>
+
<%= t(".role") %>
+
<%= t("roles.#{@user.role}") %>
-
Member since
+
<%= t(".member_since") %>
<%= l(@user.created_at.to_date, format: :long) %>
@@ -37,11 +37,11 @@
-

Delete this account

-

Your profile and sessions are removed. This cannot be undone.

+

<%= t(".delete_heading") %>

+

<%= t(".delete_copy") %>

- <%= button_to "Delete my account", profile_path, method: :delete, + <%= button_to t(".delete_button"), profile_path, method: :delete, class: "btn btn-danger", - form: { data: { turbo_confirm: "Delete your account? This cannot be undone." } } %> + form: { data: { turbo_confirm: t(".delete_confirm") } } %>
diff --git a/app/views/registrations/new.html.erb b/app/views/registrations/new.html.erb index 800cc70c8..671d03b92 100644 --- a/app/views/registrations/new.html.erb +++ b/app/views/registrations/new.html.erb @@ -1,42 +1,42 @@ -<% content_for :title, "Create your account" %> +<% content_for :title, t(".title") %>
-

Create your account

-

You will be signed in as soon as it is ready.

+

<%= t(".title") %>

+

<%= t(".subtitle") %>

<%= form_with model: @user, url: registration_path, class: "mt-6" do |form| %> <%= render "shared/form_errors", record: @user %>
- <%= form.label :full_name, "Full name", class: "field-label" %> + <%= form.label :full_name, t(".full_name"), class: "field-label" %> <%= form.text_field :full_name, required: true, autofocus: true, autocomplete: "name", aria: { invalid: @user.errors[:full_name].any? }, class: "field-input" %>
- <%= form.label :email_address, "Email address", class: "field-label" %> + <%= form.label :email_address, t(".email_address"), class: "field-label" %> <%= form.email_field :email_address, required: true, autocomplete: "email", aria: { invalid: @user.errors[:email_address].any? }, class: "field-input" %>
- <%= form.label :password, "Password", class: "field-label" %> + <%= form.label :password, t(".password"), class: "field-label" %> <%= form.password_field :password, required: true, autocomplete: "new-password", maxlength: 72, aria: { describedby: "password-hint" }, class: "field-input" %> -

At least 8 characters.

+

<%= t(".password_hint") %>

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

- Already have an account? - <%= link_to "Sign in", new_session_path, class: "text-accent-strong hover:underline" %> + <%= t(".have_account") %> + <%= link_to t(".sign_in"), new_session_path, class: "text-accent-strong hover:underline" %>

diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index e44e77bb1..b33fc5050 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -1,27 +1,27 @@ -<% content_for :title, "Sign in" %> +<% content_for :title, t(".title") %>
-

Sign in

-

Use the email address and password for your account.

+

<%= t(".title") %>

+

<%= t(".subtitle") %>

<%= form_with url: session_path, class: "mt-6" do |form| %>
- <%= form.label :email_address, "Email address", class: "field-label" %> + <%= form.label :email_address, t(".email_address"), class: "field-label" %> <%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", value: params[:email_address], class: "field-input" %>
- <%= form.label :password, "Password", class: "field-label" %> + <%= form.label :password, t(".password"), class: "field-label" %> <%= form.password_field :password, required: true, autocomplete: "current-password", maxlength: 72, class: "field-input" %>
- <%= form.submit "Sign in", class: "btn btn-primary w-full" %> + <%= form.submit t(".submit"), class: "btn btn-primary w-full" %> <% end %>
- <%= link_to "Forgot password?", new_password_path, class: "text-accent-strong hover:underline" %> - <%= link_to "Create an account", new_registration_path, class: "text-accent-strong hover:underline" %> + <%= link_to t(".forgot_password"), new_password_path, class: "text-accent-strong hover:underline" %> + <%= link_to t(".create_account"), new_registration_path, class: "text-accent-strong hover:underline" %>
diff --git a/app/views/shared/_brand.html.erb b/app/views/shared/_brand.html.erb index b98ed17ca..f80a7e9ce 100644 --- a/app/views/shared/_brand.html.erb +++ b/app/views/shared/_brand.html.erb @@ -1,4 +1,8 @@

Roster

-

User management

+

<%= t("shared.tagline") %>

+ +
+ <%= render "shared/language_picker" %> +
diff --git a/app/views/shared/_current_user_chip.html.erb b/app/views/shared/_current_user_chip.html.erb index a518bf13d..9be3f3187 100644 --- a/app/views/shared/_current_user_chip.html.erb +++ b/app/views/shared/_current_user_chip.html.erb @@ -3,10 +3,10 @@

<%= Current.user.full_name %>

-

<%= Current.user.role.humanize %>

+

<%= t("roles.#{Current.user.role}") %>

- <%= button_to "Sign out", session_path, method: :delete, + <%= button_to t("shared.nav.sign_out"), session_path, method: :delete, class: "btn btn-ghost px-2 py-1 text-xs", - form: { data: { turbo_confirm: "Sign out of Roster?" } } %> + form: { data: { turbo_confirm: t("shared.nav.sign_out_confirm") } } %>
diff --git a/app/views/shared/_flag.html.erb b/app/views/shared/_flag.html.erb new file mode 100644 index 000000000..8d7387468 --- /dev/null +++ b/app/views/shared/_flag.html.erb @@ -0,0 +1,23 @@ +<%# Simplified flags, drawn inline so the picker needs no image requests and + scales cleanly. Each is decorative: the button carries the accessible name. %> +<% case locale %> +<% when "pt-BR" %> + +<% when "es" %> + +<% else %> + +<% end %> diff --git a/app/views/shared/_form_errors.html.erb b/app/views/shared/_form_errors.html.erb index 2ad1b8cfd..62a84cde8 100644 --- a/app/views/shared/_form_errors.html.erb +++ b/app/views/shared/_form_errors.html.erb @@ -2,7 +2,7 @@ -
- <%= button_to "Sign out", session_path, method: :delete, class: "btn btn-ghost text-xs" %> +
+ <%= render "shared/language_picker" %> + +
+ <%= button_to t("shared.nav.sign_out"), session_path, method: :delete, class: "btn btn-ghost text-xs" %> +
diff --git a/config/application.rb b/config/application.rb index f2e247e3a..c20a4943b 100644 --- a/config/application.rb +++ b/config/application.rb @@ -23,6 +23,14 @@ class Application < Rails::Application # Initialize configuration defaults for originally generated Rails version. config.load_defaults 8.1 + # ── Internationalisation ──────────────────────────────────────────────── + # Three shipped locales. Fallbacks mean a key missing from a translation + # renders the English text rather than the raw key. + config.i18n.available_locales = %w[en pt-BR es] + config.i18n.default_locale = :en + config.i18n.fallbacks = [:en] + config.i18n.load_path += Rails.root.glob("config/locales/**/*.yml") + # 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. diff --git a/config/locales/en.yml b/config/locales/en.yml index a3381e859..a2abd0fcf 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1,37 +1,89 @@ -# 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: + language_name: "English" + + shared: + skip_to_content: "Skip to content" + tagline: "User management" + language: + label: "Language" + choose: "Choose a language" + nav: + main: "Main" + dashboard: "Dashboard" + users: "Users" + my_profile: "My profile" + sign_out: "Sign out" + sign_out_confirm: "Sign out of Roster?" + form_errors: + heading: + one: "1 problem stopped this from being saved:" + other: "%{count} problems stopped this from being saved:" + + roles: + user: "User" + admin: "Administrator" sessions: throttled: "Too many attempts. Please try again later." invalid_credentials: "Try another email address or password." + new: + title: "Sign in" + subtitle: "Use the email address and password for your account." + email_address: "Email address" + password: "Password" + submit: "Sign in" + forgot_password: "Forgot password?" + create_account: "Create an account" + + registrations: + created: "Welcome. Your account is ready." + new: + title: "Create your account" + subtitle: "You will be signed in as soon as it is ready." + full_name: "Full name" + email_address: "Email address" + password: "Password" + password_hint: "At least 8 characters." + password_confirmation: "Confirm password" + submit: "Create account" + have_account: "Already have an account?" + sign_in: "Sign in" + + profiles: + updated: "Your profile has been updated." + deleted: "Your account has been deleted." + show: + title: "My profile" + subtitle: "Your account details" + edit: "Edit profile" + full_name: "Full name" + email_address: "Email address" + role: "Role" + member_since: "Member since" + delete_heading: "Delete this account" + delete_copy: "Your profile and sessions are removed. This cannot be undone." + delete_button: "Delete my account" + delete_confirm: "Delete your account? This cannot be undone." + edit: + title: "Edit profile" + subtitle: "Update your account details" + full_name: "Full name" + email_address: "Email address" + submit: "Save changes" + cancel: "Cancel" + + admin: + dashboard: + title: "Dashboard" + subtitle: "An overview of the people in the system" + overview: "Overview" + users: "Users" + + locales: + unsupported: "That language is not available." + + authorization: + admin_only: "That area is only available to administrators." passwords: throttled: "Too many attempts. Please try again later." @@ -43,14 +95,3 @@ en: passwords_mailer: reset: subject: "Reset your password" - - authorization: - admin_only: "That area is only available to administrators." - - registrations: - created: "Welcome. Your account is ready." - - profiles: - updated: "Your profile has been updated." - deleted: "Your account has been deleted." - diff --git a/config/locales/es.yml b/config/locales/es.yml new file mode 100644 index 000000000..531937e95 --- /dev/null +++ b/config/locales/es.yml @@ -0,0 +1,97 @@ +es: + language_name: "Español" + + shared: + skip_to_content: "Saltar al contenido" + tagline: "Gestión de usuarios" + language: + label: "Idioma" + choose: "Elige un idioma" + nav: + main: "Principal" + dashboard: "Panel" + users: "Usuarios" + my_profile: "Mi perfil" + sign_out: "Cerrar sesión" + sign_out_confirm: "¿Cerrar sesión en Roster?" + form_errors: + heading: + one: "1 problema impidió guardar los cambios:" + other: "%{count} problemas impidieron guardar los cambios:" + + roles: + user: "Usuario" + admin: "Administrador" + + sessions: + throttled: "Demasiados intentos. Inténtalo de nuevo más tarde." + invalid_credentials: "Revisa el correo electrónico y la contraseña." + new: + title: "Iniciar sesión" + subtitle: "Usa el correo electrónico y la contraseña de tu cuenta." + email_address: "Correo electrónico" + password: "Contraseña" + submit: "Iniciar sesión" + forgot_password: "¿Olvidaste tu contraseña?" + create_account: "Crear una cuenta" + + registrations: + created: "Bienvenido. Tu cuenta está lista." + new: + title: "Crea tu cuenta" + subtitle: "Iniciarás sesión en cuanto esté lista." + full_name: "Nombre completo" + email_address: "Correo electrónico" + password: "Contraseña" + password_hint: "Al menos 8 caracteres." + password_confirmation: "Confirma la contraseña" + submit: "Crear cuenta" + have_account: "¿Ya tienes una cuenta?" + sign_in: "Iniciar sesión" + + profiles: + updated: "Tu perfil se ha actualizado." + deleted: "Tu cuenta se ha eliminado." + show: + title: "Mi perfil" + subtitle: "Los datos de tu cuenta" + edit: "Editar perfil" + full_name: "Nombre completo" + email_address: "Correo electrónico" + role: "Rol" + member_since: "Miembro desde" + delete_heading: "Eliminar esta cuenta" + delete_copy: "Se eliminan tu perfil y tus sesiones. No se puede deshacer." + delete_button: "Eliminar mi cuenta" + delete_confirm: "¿Eliminar tu cuenta? No se puede deshacer." + edit: + title: "Editar perfil" + subtitle: "Actualiza los datos de tu cuenta" + full_name: "Nombre completo" + email_address: "Correo electrónico" + submit: "Guardar cambios" + cancel: "Cancelar" + + admin: + dashboard: + title: "Panel" + subtitle: "Un resumen de las personas en el sistema" + overview: "Resumen" + users: "Usuarios" + + locales: + unsupported: "Ese idioma no está disponible." + + authorization: + admin_only: "Esa área es solo para administradores." + + passwords: + throttled: "Demasiados intentos. Inténtalo de nuevo más tarde." + reset_instructions_sent: "Hemos enviado las instrucciones (si existe una cuenta con ese correo electrónico)." + reset: "Tu contraseña se ha restablecido." + mismatch: "Las contraseñas no coinciden." + invalid_token: "Ese enlace de restablecimiento no es válido o ha caducado." + + passwords_mailer: + reset: + subject: "Restablece tu contraseña" diff --git a/config/locales/pt-BR.yml b/config/locales/pt-BR.yml new file mode 100644 index 000000000..e3b720f83 --- /dev/null +++ b/config/locales/pt-BR.yml @@ -0,0 +1,97 @@ +pt-BR: + language_name: "Português" + + shared: + skip_to_content: "Pular para o conteúdo" + tagline: "Gestão de usuários" + language: + label: "Idioma" + choose: "Escolha um idioma" + nav: + main: "Principal" + dashboard: "Painel" + users: "Usuários" + my_profile: "Meu perfil" + sign_out: "Sair" + sign_out_confirm: "Sair do Roster?" + form_errors: + heading: + one: "1 problema impediu o salvamento:" + other: "%{count} problemas impediram o salvamento:" + + roles: + user: "Usuário" + admin: "Administrador" + + sessions: + throttled: "Tentativas demais. Tente novamente em alguns instantes." + invalid_credentials: "Verifique o e-mail e a senha e tente de novo." + new: + title: "Entrar" + subtitle: "Use o e-mail e a senha da sua conta." + email_address: "E-mail" + password: "Senha" + submit: "Entrar" + forgot_password: "Esqueceu a senha?" + create_account: "Criar uma conta" + + registrations: + created: "Boas-vindas. Sua conta está pronta." + new: + title: "Crie sua conta" + subtitle: "Você entra automaticamente assim que ela estiver pronta." + full_name: "Nome completo" + email_address: "E-mail" + password: "Senha" + password_hint: "No mínimo 8 caracteres." + password_confirmation: "Confirme a senha" + submit: "Criar conta" + have_account: "Já tem uma conta?" + sign_in: "Entrar" + + profiles: + updated: "Seu perfil foi atualizado." + deleted: "Sua conta foi excluída." + show: + title: "Meu perfil" + subtitle: "Os dados da sua conta" + edit: "Editar perfil" + full_name: "Nome completo" + email_address: "E-mail" + role: "Função" + member_since: "Membro desde" + delete_heading: "Excluir esta conta" + delete_copy: "Seu perfil e suas sessões são removidos. Não há como desfazer." + delete_button: "Excluir minha conta" + delete_confirm: "Excluir sua conta? Não há como desfazer." + edit: + title: "Editar perfil" + subtitle: "Atualize os dados da sua conta" + full_name: "Nome completo" + email_address: "E-mail" + submit: "Salvar alterações" + cancel: "Cancelar" + + admin: + dashboard: + title: "Painel" + subtitle: "Visão geral das pessoas no sistema" + overview: "Visão geral" + users: "Usuários" + + locales: + unsupported: "Esse idioma não está disponível." + + authorization: + admin_only: "Essa área é exclusiva de administradores." + + passwords: + throttled: "Tentativas demais. Tente novamente em alguns instantes." + reset_instructions_sent: "Enviamos as instruções de redefinição (caso exista uma conta com esse e-mail)." + reset: "Sua senha foi redefinida." + mismatch: "As senhas não coincidem." + invalid_token: "Este link de redefinição é inválido ou expirou." + + passwords_mailer: + reset: + subject: "Redefina sua senha" diff --git a/config/routes.rb b/config/routes.rb index 9b2d1eef0..78942b2db 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,6 +1,7 @@ Rails.application.routes.draw do root "home#index" + resource :locale, only: :update resource :session resources :passwords, param: :token resource :registration, only: %i[new create], path: "sign_up", path_names: { new: "" } diff --git a/db/migrate/20260903172348_add_locale_to_users.rb b/db/migrate/20260903172348_add_locale_to_users.rb new file mode 100644 index 000000000..91361106b --- /dev/null +++ b/db/migrate/20260903172348_add_locale_to_users.rb @@ -0,0 +1,10 @@ +class AddLocaleToUsers < ActiveRecord::Migration[8.1] + def change + add_column :users, :locale, :string, null: false, default: "en" + + # The application ships three locales; anything else reaching the column + # through a raw write would render a half-translated page. + add_check_constraint :users, "locale IN ('en', 'pt-BR', 'es')", + name: "users_locale_supported" + end +end diff --git a/db/schema.rb b/db/schema.rb index f9a4b8511..097b1695c 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_171334) do +ActiveRecord::Schema[8.1].define(version: 2026_09_03_172348) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -28,11 +28,13 @@ t.datetime "created_at", null: false t.string "email_address", null: false t.string "full_name", null: false + t.string "locale", default: "en", null: false t.string "password_digest", null: false t.integer "role", default: 0, null: false t.datetime "updated_at", null: false t.index "lower((email_address)::text)", name: "index_users_on_lower_email_address", unique: true t.index ["role"], name: "index_users_on_role" + t.check_constraint "locale::text = ANY (ARRAY['en'::character varying, 'pt-BR'::character varying, 'es'::character varying]::text[])", name: "users_locale_supported" t.check_constraint "role = ANY (ARRAY[0, 1])", name: "users_role_within_enum" end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index dbd8456ad..05ffb4880 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -75,4 +75,20 @@ expect(user.authenticate("wrong")).to be(false) end end + + describe "locale" do + it "defaults to English" do + expect(described_class.new.locale).to eq("en") + end + + it "accepts every supported locale" do + User::SUPPORTED_LOCALES.each do |locale| + expect(build(:user, locale: locale)).to be_valid + end + end + + it "rejects a locale the application does not support" do + expect(build(:user, locale: "de")).not_to be_valid + end + end end diff --git a/spec/requests/locales_spec.rb b/spec/requests/locales_spec.rb new file mode 100644 index 000000000..b283551ff --- /dev/null +++ b/spec/requests/locales_spec.rb @@ -0,0 +1,57 @@ +require "rails_helper" + +RSpec.describe "Locale switching" do + describe "PATCH /locale" do + it "remembers the choice on the account when signed in" do + user = create(:user) + sign_in user + + patch locale_path, params: { locale: "pt-BR" } + + expect(response).to have_http_status(:see_other) + expect(user.reload.locale).to eq("pt-BR") + end + + it "remembers the choice in the session for a visitor" do + patch locale_path, params: { locale: "es" } + + get new_session_path + + expect(response.body).to include(I18n.t("sessions.new.title", locale: :es)) + end + + it "refuses a locale the application does not support" do + user = create(:user, locale: "en") + sign_in user + + patch locale_path, params: { locale: "de" } + + expect(response).to have_http_status(:unprocessable_content) + expect(user.reload.locale).to eq("en") + end + + it "returns the visitor to the page they came from" do + patch locale_path, params: { locale: "es" }, headers: { "HTTP_REFERER" => new_registration_url } + + expect(response).to redirect_to(new_registration_url) + end + end + + describe "the locale applied to a request" do + it "renders in the locale stored on the account" do + sign_in create(:user, locale: "pt-BR") + + get profile_path + + expect(response.body).to include(I18n.t("profiles.show.title", locale: :"pt-BR")) + end + + it "falls back to English for an account that never chose one" do + sign_in create(:user) + + get profile_path + + expect(response.body).to include(I18n.t("profiles.show.title", locale: :en)) + end + end +end From 112774d5df6f9bfe9309e8e1ba18a3fa330e36e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B4nio=20Neto?= Date: Thu, 3 Sep 2026 14:34:00 -0300 Subject: [PATCH 05/33] feat: add admin user management with search, filtering and pagination The administrative CRUD, driven out of request specs. - The last administrator cannot be deleted or demoted, and that rule lives on the model rather than in a controller so it holds for every path into the data: the admin screens, the console, a seed, the import still to come. The check selects the remaining administrator rows FOR UPDATE, so two concurrent demotions cannot each see the other as the one still standing. PostgreSQL refuses to lock an aggregate, hence selecting ids rather than counting. - Search and role filter are scopes on User rather than a query object: two scopes is not enough surface to justify the indirection. The search term is bound as a parameter and passed through sanitize_sql_like, so neither SQL nor LIKE wildcards can be smuggled in; a spec fires a DROP TABLE at it. - An unrecognised role filter is ignored rather than erroring, and per_page is clamped to 100 so a hand-edited URL cannot ask for the whole table. - Administrators may set roles, unlike the public form. An empty password field on the edit form means "leave it alone", not "set it to nothing". - Filter state lives in the query string, so a filtered list is a shareable URL and the back button behaves. The table scrolls inside its own container so the page never scrolls sideways. Seeds are idempotent and refuse to invent an administrator password in production, where SEED_ADMIN_PASSWORD has to be supplied. Verified: 60 examples, 0 failures; RuboCop clean across 68 files. Coverage is 84.8% line / 81.3% branch, so the branch gate is met and the line gate is not yet. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BS3oGqeR1PXScuK6gLr8Mi --- app/controllers/admin/users_controller.rb | 72 +++++++++ app/models/user.rb | 46 ++++++ app/views/admin/users/_form.html.erb | 44 ++++++ app/views/admin/users/edit.html.erb | 7 + app/views/admin/users/index.html.erb | 104 +++++++++++++ app/views/admin/users/new.html.erb | 7 + app/views/shared/_pagination.html.erb | 23 +++ config/initializers/pagy.rb | 6 + config/locales/en.yml | 60 ++++++++ config/locales/es.yml | 60 ++++++++ config/locales/pt-BR.yml | 60 ++++++++ db/seeds.rb | 50 +++++- spec/requests/admin/users_spec.rb | 179 ++++++++++++++++++++++ 13 files changed, 710 insertions(+), 8 deletions(-) create mode 100644 app/controllers/admin/users_controller.rb create mode 100644 app/views/admin/users/_form.html.erb create mode 100644 app/views/admin/users/edit.html.erb create mode 100644 app/views/admin/users/index.html.erb create mode 100644 app/views/admin/users/new.html.erb create mode 100644 app/views/shared/_pagination.html.erb create mode 100644 config/initializers/pagy.rb create mode 100644 spec/requests/admin/users_spec.rb diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb new file mode 100644 index 000000000..58e513197 --- /dev/null +++ b/app/controllers/admin/users_controller.rb @@ -0,0 +1,72 @@ +module Admin + class UsersController < BaseController + include Pagy::Backend + + before_action :set_user, only: %i[edit update destroy] + + def index + @role_counts = User.role_counts + @pagy, @users = pagy(filtered_users, limit: per_page) + end + + def new + @user = User.new + end + + def edit; end + + def create + @user = User.new(user_params) + + if @user.save + redirect_to admin_users_path, notice: t(".created", name: @user.full_name) + else + render :new, status: :unprocessable_content + end + end + + def update + if @user.update(user_params) + redirect_to admin_users_path, notice: t(".updated", name: @user.full_name) + else + render :edit, status: :unprocessable_content + end + end + + def destroy + if @user.destroy + redirect_to admin_users_path, notice: t(".deleted", name: @user.full_name), status: :see_other + else + redirect_to admin_users_path, alert: @user.errors.full_messages.to_sentence, status: :see_other + end + end + + private + + def set_user + @user = User.find(params.expect(:id)) + end + + # Unlike the public form, an administrator may set the role. The last + # administrator is still protected -- by the model, not by this list. + def user_params + permitted = params.expect(user: %i[full_name email_address avatar_url role password]) + # An empty password field on the edit form means "leave it alone", not + # "set the password to nothing". + permitted.delete(:password) if permitted[:password].blank? + permitted + end + + def filtered_users + User.search(params[:query]).with_role(params[:role]).ordered + end + + # Bounded so a hand-edited URL cannot ask for the whole table at once. + def per_page + requested = params[:per_page].to_i + return Pagy::DEFAULT[:limit] if requested <= 0 + + requested.clamp(1, 100) + end + end +end diff --git a/app/models/user.rb b/app/models/user.rb index b674572fe..abe0d297a 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -23,4 +23,50 @@ class User < ApplicationRecord format: { with: URI::MailTo::EMAIL_REGEXP }, uniqueness: { case_sensitive: false } validates :locale, inclusion: { in: SUPPORTED_LOCALES } + + # The last administrator may not be removed or demoted. This lives on the + # model rather than in a controller so it holds for every path into the + # data: the admin screens, the console, a seed, a future import. + before_update :ensure_another_administrator_remains, if: :leaving_administrator_role? + before_destroy :ensure_another_administrator_remains, if: :admin? + + scope :search, lambda { |term| + next all if term.blank? + + pattern = "%#{sanitize_sql_like(term.to_s.strip)}%" + where("full_name ILIKE :term OR email_address ILIKE :term", term: pattern) + } + + scope :with_role, lambda { |role| + next all unless roles.key?(role.to_s) + + where(role: role) + } + + scope :ordered, -> { order(:full_name, :id) } + + def self.role_counts + group(:role).count.transform_keys(&:to_s) + end + + private + + def leaving_administrator_role? + role_changed? && role_was == "admin" + end + + def ensure_another_administrator_remains + return if other_administrators_exist? + + errors.add(:base, :last_administrator) + throw :abort + end + + # `FOR UPDATE` holds the remaining administrator rows for the rest of the + # surrounding transaction. Without it two concurrent demotions could each + # see the other as the one still standing and leave the system with none. + # An aggregate cannot be locked in PostgreSQL, so this selects ids. + def other_administrators_exist? + self.class.where(role: :admin).where.not(id: id).lock.ids.any? + end end diff --git a/app/views/admin/users/_form.html.erb b/app/views/admin/users/_form.html.erb new file mode 100644 index 000000000..bf4148c82 --- /dev/null +++ b/app/views/admin/users/_form.html.erb @@ -0,0 +1,44 @@ +<%= form_with model: user, url: url, method: method do |form| %> + <%= render "shared/form_errors", record: user %> + +
+ <%= form.label :full_name, t("admin.users.form.full_name"), class: "field-label" %> + <%= form.text_field :full_name, required: true, autofocus: true, autocomplete: "name", + aria: { invalid: user.errors[:full_name].any? }, class: "field-input" %> +
+ +
+ <%= form.label :email_address, t("admin.users.form.email_address"), class: "field-label" %> + <%= form.email_field :email_address, required: true, autocomplete: "email", + aria: { invalid: user.errors[:email_address].any? }, class: "field-input" %> +
+ +
+ <%= form.label :role, t("admin.users.form.role"), class: "field-label" %> + <%= form.select :role, + User.roles.keys.map { |role| [ t("roles.#{role}"), role ] }, + {}, class: "field-input" %> +
+ +
+ <%= form.label :password, t("admin.users.form.password"), class: "field-label" %> + <%= form.password_field :password, required: user.new_record?, autocomplete: "new-password", + maxlength: 72, aria: { describedby: "password-hint" }, class: "field-input" %> +

+ <%= user.new_record? ? t("admin.users.form.password_hint_new") : t("admin.users.form.password_hint_edit") %> +

+
+ +
+ <%= form.label :avatar_url, t("admin.users.form.avatar_url"), class: "field-label" %> + <%= form.url_field :avatar_url, autocomplete: "off", + aria: { describedby: "avatar-hint", invalid: user.errors[:avatar_url].any? }, + class: "field-input" %> +

<%= t("admin.users.form.avatar_url_hint") %>

+
+ +
+ <%= form.submit submit_label, class: "btn btn-primary" %> + <%= link_to t("admin.users.index.clear"), admin_users_path, class: "btn btn-ghost" %> +
+<% 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..0dffd5a4b --- /dev/null +++ b/app/views/admin/users/edit.html.erb @@ -0,0 +1,7 @@ +<% content_for :title, t(".title") %> +<% content_for :page_title, t(".title") %> +<% content_for :page_subtitle, t(".subtitle") %> + +
+ <%= render "form", user: @user, url: admin_user_path(@user), method: :patch, submit_label: t(".submit") %> +
diff --git a/app/views/admin/users/index.html.erb b/app/views/admin/users/index.html.erb new file mode 100644 index 000000000..2eec5864a --- /dev/null +++ b/app/views/admin/users/index.html.erb @@ -0,0 +1,104 @@ +<% content_for :title, t(".title") %> +<% content_for :page_title, t(".title") %> +<% content_for :page_subtitle, t(".subtitle") %> + +
+
+

<%= t("admin.dashboard.overview") %>

+

<%= t(".title") %>

+
+ + <%= link_to t(".new_user"), new_admin_user_path, class: "btn btn-primary" %> +
+ +<%# Search and role filter. Both live in the query string, so a filtered list + is a shareable URL and the browser back button behaves. %> +
+ <%= form_with url: admin_users_path, method: :get, class: "flex flex-col gap-3 sm:flex-row" do |form| %> +
+ <%= form.label :query, t(".search_label"), class: "sr-only" %> + <%= form.search_field :query, value: params[:query], placeholder: t(".search_placeholder"), + class: "field-input" %> +
+ + <%= form.hidden_field :role, value: params[:role] %> + +
+ <%= form.submit t(".search_submit"), class: "btn btn-primary" %> + <% if params[:query].present? || params[:role].present? %> + <%= link_to t(".clear"), admin_users_path, class: "btn btn-ghost" %> + <% end %> +
+ <% end %> + +
+ <% [ [ nil, t(".filter_all"), @role_counts.values.sum ], + [ "admin", t(".filter_admin"), @role_counts.fetch("admin", 0) ], + [ "user", t(".filter_user"), @role_counts.fetch("user", 0) ] ].each do |value, label, count| %> + <% active = params[:role].presence == value %> + <%= link_to admin_users_path(role: value, query: params[:query]), + class: "btn #{active ? 'btn-ghost' : 'text-muted hover:text-ink'} text-xs", + aria: { current: active ? "true" : nil } do %> + <%= label %> (<%= count %>) + <% end %> + <% end %> +
+
+ +
+
+

<%= t(".title") %>

+

<%= t(".results", count: @pagy.count) %>

+
+ + <% if @users.any? %> + <%# The table scrolls inside its own container so the page never does. %> +
+ + + + + + + + + + + + <% @users.each do |user| %> + + + + + + + + + <% end %> + +
<%= t(".name") %><%= t(".role") %><%= t(".actions") %>
+
+ +
+

<%= user.full_name %>

+

<%= user.email_address %>

+
+
+
<%= role_badge(user) %> +
+ <%= link_to t(".edit"), edit_admin_user_path(user), class: "btn btn-ghost text-xs" %> + <%= button_to t(".delete"), admin_user_path(user), method: :delete, + class: "btn btn-danger text-xs", + form: { data: { turbo_confirm: t(".delete_confirm", name: user.full_name) } } %> +
+
+
+ <% else %> +
+

<%= t(".empty_title") %>

+

<%= t(".empty_copy") %>

+
+ <% end %> +
+ +<%= render "shared/pagination", pagy: @pagy %> diff --git a/app/views/admin/users/new.html.erb b/app/views/admin/users/new.html.erb new file mode 100644 index 000000000..f4c9805d8 --- /dev/null +++ b/app/views/admin/users/new.html.erb @@ -0,0 +1,7 @@ +<% content_for :title, t(".title") %> +<% content_for :page_title, t(".title") %> +<% content_for :page_subtitle, t(".subtitle") %> + +
+ <%= render "form", user: @user, url: admin_users_path, method: :post, submit_label: t(".submit") %> +
diff --git a/app/views/shared/_pagination.html.erb b/app/views/shared/_pagination.html.erb new file mode 100644 index 000000000..d032a0111 --- /dev/null +++ b/app/views/shared/_pagination.html.erb @@ -0,0 +1,23 @@ +<% if pagy.pages > 1 %> + +<% end %> diff --git a/config/initializers/pagy.rb b/config/initializers/pagy.rb new file mode 100644 index 000000000..1b3d347d1 --- /dev/null +++ b/config/initializers/pagy.rb @@ -0,0 +1,6 @@ +require "pagy" + +# 25 rows keeps the admin list to one screen on a laptop without hiding the +# shape of the data. +Pagy::DEFAULT[:limit] = 25 +Pagy::DEFAULT[:size] = 7 diff --git a/config/locales/en.yml b/config/locales/en.yml index a2abd0fcf..4a0168317 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -19,6 +19,11 @@ en: one: "1 problem stopped this from being saved:" other: "%{count} problems stopped this from being saved:" + pagination: + previous: "Previous" + next: "Next" + page_of: "Page %{page} of %{pages}" + roles: user: "User" admin: "Administrator" @@ -79,6 +84,53 @@ en: overview: "Overview" users: "Users" + users: + created: "%{name} has been added." + updated: "%{name} has been updated." + deleted: "%{name} has been deleted." + form: + full_name: "Full name" + email_address: "Email address" + role: "Role" + password: "Password" + password_hint_new: "At least 8 characters." + password_hint_edit: "Leave blank to keep the current password." + avatar_url: "Avatar URL" + avatar_url_hint: "An https link to an image. Optional." + index: + title: "Users" + subtitle: "Everyone with an account" + search_label: "Search" + search_placeholder: "Search by name or email address" + search_submit: "Search" + clear: "Clear" + filter_all: "All" + filter_admin: "Administrators" + filter_user: "Users" + new_user: "Add user" + results: + one: "1 result" + other: "%{count} results" + name: "Name" + email_address: "Email address" + role: "Role" + actions: "Actions" + edit: "Edit" + delete: "Delete" + delete_confirm: "Delete %{name}? This cannot be undone." + empty_title: "Nobody here yet" + empty_copy: "No account matches what you are looking for." + new: + title: "Add user" + subtitle: "Create an account on someone's behalf" + submit: "Create user" + cancel: "Cancel" + edit: + title: "Edit user" + subtitle: "Update this account" + submit: "Save changes" + cancel: "Cancel" + locales: unsupported: "That language is not available." @@ -95,3 +147,11 @@ en: passwords_mailer: reset: subject: "Reset your password" + + activerecord: + errors: + models: + user: + attributes: + base: + last_administrator: "This is the only administrator left, so the role cannot be removed." diff --git a/config/locales/es.yml b/config/locales/es.yml index 531937e95..c1d0e7f3c 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -19,6 +19,11 @@ es: one: "1 problema impidió guardar los cambios:" other: "%{count} problemas impidieron guardar los cambios:" + pagination: + previous: "Anterior" + next: "Siguiente" + page_of: "Página %{page} de %{pages}" + roles: user: "Usuario" admin: "Administrador" @@ -79,6 +84,53 @@ es: overview: "Resumen" users: "Usuarios" + users: + created: "%{name} se ha añadido." + updated: "%{name} se ha actualizado." + deleted: "%{name} se ha eliminado." + form: + full_name: "Nombre completo" + email_address: "Correo electrónico" + role: "Rol" + password: "Contraseña" + password_hint_new: "Al menos 8 caracteres." + password_hint_edit: "Déjalo en blanco para mantener la contraseña actual." + avatar_url: "URL del avatar" + avatar_url_hint: "Un enlace https a una imagen. Opcional." + index: + title: "Usuarios" + subtitle: "Todas las personas con cuenta" + search_label: "Buscar" + search_placeholder: "Buscar por nombre o correo electrónico" + search_submit: "Buscar" + clear: "Limpiar" + filter_all: "Todos" + filter_admin: "Administradores" + filter_user: "Usuarios" + new_user: "Añadir usuario" + results: + one: "1 resultado" + other: "%{count} resultados" + name: "Nombre" + email_address: "Correo electrónico" + role: "Rol" + actions: "Acciones" + edit: "Editar" + delete: "Eliminar" + delete_confirm: "¿Eliminar a %{name}? No se puede deshacer." + empty_title: "Todavía no hay nadie" + empty_copy: "Ninguna cuenta coincide con lo que buscas." + new: + title: "Añadir usuario" + subtitle: "Crea una cuenta en nombre de otra persona" + submit: "Crear usuario" + cancel: "Cancelar" + edit: + title: "Editar usuario" + subtitle: "Actualiza esta cuenta" + submit: "Guardar cambios" + cancel: "Cancelar" + locales: unsupported: "Ese idioma no está disponible." @@ -95,3 +147,11 @@ es: passwords_mailer: reset: subject: "Restablece tu contraseña" + + activerecord: + errors: + models: + user: + attributes: + base: + last_administrator: "Este es el único administrador que queda, así que no se puede quitar el rol." diff --git a/config/locales/pt-BR.yml b/config/locales/pt-BR.yml index e3b720f83..87163fef4 100644 --- a/config/locales/pt-BR.yml +++ b/config/locales/pt-BR.yml @@ -19,6 +19,11 @@ pt-BR: one: "1 problema impediu o salvamento:" other: "%{count} problemas impediram o salvamento:" + pagination: + previous: "Anterior" + next: "Próxima" + page_of: "Página %{page} de %{pages}" + roles: user: "Usuário" admin: "Administrador" @@ -79,6 +84,53 @@ pt-BR: overview: "Visão geral" users: "Usuários" + users: + created: "%{name} foi adicionado." + updated: "%{name} foi atualizado." + deleted: "%{name} foi excluído." + form: + full_name: "Nome completo" + email_address: "E-mail" + role: "Função" + password: "Senha" + password_hint_new: "No mínimo 8 caracteres." + password_hint_edit: "Deixe em branco para manter a senha atual." + avatar_url: "URL do avatar" + avatar_url_hint: "Um link https para uma imagem. Opcional." + index: + title: "Usuários" + subtitle: "Todo mundo com uma conta" + search_label: "Buscar" + search_placeholder: "Buscar por nome ou e-mail" + search_submit: "Buscar" + clear: "Limpar" + filter_all: "Todos" + filter_admin: "Administradores" + filter_user: "Usuários" + new_user: "Adicionar usuário" + results: + one: "1 resultado" + other: "%{count} resultados" + name: "Nome" + email_address: "E-mail" + role: "Função" + actions: "Ações" + edit: "Editar" + delete: "Excluir" + delete_confirm: "Excluir %{name}? Não há como desfazer." + empty_title: "Ainda não há ninguém aqui" + empty_copy: "Nenhuma conta corresponde ao que você procura." + new: + title: "Adicionar usuário" + subtitle: "Crie uma conta em nome de outra pessoa" + submit: "Criar usuário" + cancel: "Cancelar" + edit: + title: "Editar usuário" + subtitle: "Atualize esta conta" + submit: "Salvar alterações" + cancel: "Cancelar" + locales: unsupported: "Esse idioma não está disponível." @@ -95,3 +147,11 @@ pt-BR: passwords_mailer: reset: subject: "Redefina sua senha" + + activerecord: + errors: + models: + user: + attributes: + base: + last_administrator: "Este é o único administrador restante, então a função não pode ser removida." diff --git a/db/seeds.rb b/db/seeds.rb index 4fbd6ed97..94fdb3af4 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,9 +1,43 @@ -# 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). +# Idempotent seeds: running this repeatedly converges on the same data instead +# of piling up duplicates. # -# Example: -# -# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name| -# MovieGenre.find_or_create_by!(name: genre_name) -# end +# The demonstration password is only ever allowed outside production. In +# production the seed refuses to invent credentials and expects +# SEED_ADMIN_PASSWORD to be supplied. + +DEMO_PASSWORD = ENV.fetch("SEED_ADMIN_PASSWORD") do + if Rails.env.production? + abort "Set SEED_ADMIN_PASSWORD before seeding production." + else + "password-for-development" + end +end + +def upsert_user!(email_address:, full_name:, role:, locale: "en") + user = User.find_or_initialize_by(email_address: email_address) + user.assign_attributes(full_name: full_name, role: role, locale: locale) + user.password = DEMO_PASSWORD if user.new_record? + user.save! + user +end + +upsert_user!(email_address: "admin@example.com", full_name: "Ada Lovelace", role: :admin) +upsert_user!(email_address: "admin.two@example.com", full_name: "Grace Hopper", role: :admin) +upsert_user!(email_address: "user@example.com", full_name: "Maria Silva", role: :user, locale: "pt-BR") + +[ + [ "joao.souza@example.com", "João Souza", "pt-BR" ], + [ "carla.mendes@example.com", "Carla Mendes", "pt-BR" ], + [ "diego.ramirez@example.com", "Diego Ramírez", "es" ], + [ "lucia.fernandez@example.com", "Lucía Fernández", "es" ], + [ "olivia.clarke@example.com", "Olivia Clarke", "en" ], + [ "noah.bennett@example.com", "Noah Bennett", "en" ], + [ "priya.nair@example.com", "Priya Nair", "en" ], + [ "tomas.novak@example.com", "Tomáš Novák", "en" ], + [ "yuki.tanaka@example.com", "Yuki Tanaka", "en" ], + [ "amara.okafor@example.com", "Amara Okafor", "en" ] +].each do |email_address, full_name, locale| + upsert_user!(email_address: email_address, full_name: full_name, role: :user, locale: locale) +end + +Rails.logger.debug { "Seeded #{User.count} users (#{User.admin.count} administrators)." } diff --git a/spec/requests/admin/users_spec.rb b/spec/requests/admin/users_spec.rb new file mode 100644 index 000000000..fb68bd8e6 --- /dev/null +++ b/spec/requests/admin/users_spec.rb @@ -0,0 +1,179 @@ +require "rails_helper" + +RSpec.describe "Admin users" do + let(:administrator) { create(:user, :admin, full_name: "Ada Admin") } + + before { sign_in administrator } + + describe "GET /admin/users" do + it "lists the people in the system" do + create(:user, full_name: "Maria Silva") + + get admin_users_path + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Maria Silva") + end + + it "finds people by name" do + create(:user, full_name: "Maria Silva") + create(:user, full_name: "Joao Souza") + + get admin_users_path, params: { query: "maria" } + + expect(response.body).to include("Maria Silva") + expect(response.body).not_to include("Joao Souza") + end + + it "finds people by email address" do + create(:user, full_name: "Maria Silva", email_address: "maria@example.com") + create(:user, full_name: "Joao Souza", email_address: "joao@example.com") + + get admin_users_path, params: { query: "joao@" } + + expect(response.body).to include("Joao Souza") + expect(response.body).not_to include("Maria Silva") + end + + it "treats a search term as data, not as SQL" do + create(:user, full_name: "Maria Silva") + + get admin_users_path, params: { query: "'; DROP TABLE users; --" } + + expect(response).to have_http_status(:ok) + expect(User.count).to be_positive + end + + it "filters by role" do + create(:user, full_name: "Maria Silva") + + get admin_users_path, params: { role: "admin" } + + expect(response.body).to include("Ada Admin") + expect(response.body).not_to include("Maria Silva") + end + + it "ignores a role filter it does not recognise" do + create(:user, full_name: "Maria Silva") + + get admin_users_path, params: { role: "wizard" } + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Maria Silva") + end + + it "paginates" do + create_list(:user, 3) + + get admin_users_path, params: { per_page: 2 } + + expect(response).to have_http_status(:ok) + end + end + + describe "POST /admin/users" do + let(:params) do + { + user: { + full_name: "Nova Pessoa", + email_address: "nova@example.com", + password: "a-sufficiently-long-password", + role: "admin" + } + } + end + + it "creates a user" do + expect { post admin_users_path, params: params }.to change(User, :count).by(1) + + expect(response).to redirect_to(admin_users_path) + end + + it "may assign the administrator role, unlike the public form" do + post admin_users_path, params: params + + expect(User.find_by(email_address: "nova@example.com")).to be_admin + end + + it "rejects an invalid submission" do + invalid = params.deep_merge(user: { email_address: "" }) + + expect { post admin_users_path, params: invalid }.not_to change(User, :count) + + expect(response).to have_http_status(:unprocessable_content) + end + end + + describe "PATCH /admin/users/:id" do + it "updates another user" do + user = create(:user) + + patch admin_user_path(user), params: { user: { full_name: "Nome Novo" } } + + expect(user.reload.full_name).to eq("Nome Novo") + end + + it "promotes a regular user" do + user = create(:user) + + patch admin_user_path(user), params: { user: { role: "admin" } } + + expect(user.reload).to be_admin + end + end + + describe "DELETE /admin/users/:id" do + it "deletes another user" do + user = create(:user) + + expect { delete admin_user_path(user) }.to change(User, :count).by(-1) + + expect(response).to redirect_to(admin_users_path) + end + end + + describe "protecting the last administrator" do + it "refuses to delete the only administrator" do + create(:user) + + expect { delete admin_user_path(administrator) }.not_to change(User, :count) + + expect(flash[:alert]).to be_present + end + + it "refuses to demote the only administrator" do + patch admin_user_path(administrator), params: { user: { role: "user" } } + + expect(administrator.reload).to be_admin + end + + it "allows deleting an administrator while another one remains" do + other = create(:user, :admin) + + expect { delete admin_user_path(other) }.to change(User, :count).by(-1) + end + + it "allows demoting an administrator while another one remains" do + other = create(:user, :admin) + + patch admin_user_path(other), params: { user: { role: "user" } } + + expect(other.reload).to be_user + end + end + + describe "authorization" do + it "refuses every action to a regular user" do + sign_in create(:user) + target = create(:user) + + get admin_users_path + expect(response).to redirect_to(profile_path) + + patch admin_user_path(target), params: { user: { role: "admin" } } + expect(target.reload).to be_user + + expect { delete admin_user_path(target) }.not_to change(User, :count) + end + end +end From a41a5408a5c996b23ebe3fa863313443b1a8f6c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B4nio=20Neto?= Date: Thu, 3 Sep 2026 14:47:29 -0300 Subject: [PATCH 06/33] feat: stream live dashboard counters over Solid Cable The dashboard totals update without a reload when somebody is added, removed or re-roled. - UserCounters is the single place that knows how the numbers are computed and broadcast, driven by one after_commit hook on User rather than a broadcast call in every controller action that happens to change a role. A change that cannot move the numbers -- renaming somebody -- broadcasts nothing. - There is one stream per locale. The broadcast carries rendered HTML with translated labels, so a single shared stream would push one language to every administrator watching. - Subscribing is authorised in its own right. The generated connection already refuses anyone without a session; AdminCountersChannel additionally refuses anyone signed in who is not an administrator, so a leaked stream name is not enough by itself. Channel specs cover the administrator, the regular user and the anonymous case. - UserCounters.suspend_broadcasts exists for the spreadsheet import still to come, which would otherwise broadcast once per imported row. Also fixes a real defect found while testing by hand: config/cache.yml only pointed Solid Cache at the cache database in production, so signing in raised PG::UndefinedTable for solid_cache_entries in development. The generators wire only production for all three Solid adapters; queue and cable were already corrected, this completes the set. System specs now drive a real browser. Rails' driven_by re-registers the Cuprite driver and discards Capybara.register_driver, so the options travel through driven_by instead, and Chromium gets the flags that stop it spending its startup on background networking. Verified: 79 examples, 0 failures, including browser sign-in for both roles and the language picker; RuboCop clean across 74 files. Coverage 86.8% line / 83.3% branch. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BS3oGqeR1PXScuK6gLr8Mi --- app/channels/admin_counters_channel.rb | 11 ++++ app/controllers/admin/dashboard_controller.rb | 4 +- app/models/user.rb | 12 ++++ app/models/user_counters.rb | 62 +++++++++++++++++++ app/views/admin/dashboard/_counters.html.erb | 12 ++++ app/views/admin/dashboard/show.html.erb | 20 +++++- config/cache.yml | 5 +- config/locales/en.yml | 5 ++ config/locales/es.yml | 5 ++ config/locales/pt-BR.yml | 5 ++ db/seeds.rb | 20 +++--- spec/channels/admin_counters_channel_spec.rb | 31 ++++++++++ .../application_cable/connection_spec.rb | 23 +++++++ spec/models/user_counters_spec.rb | 60 ++++++++++++++++++ spec/requests/admin/dashboard_spec.rb | 20 ++++++ spec/support/capybara.rb | 53 +++++++++++----- spec/system/authentication_spec.rb | 50 +++++++++++++++ 17 files changed, 369 insertions(+), 29 deletions(-) create mode 100644 app/channels/admin_counters_channel.rb create mode 100644 app/models/user_counters.rb create mode 100644 app/views/admin/dashboard/_counters.html.erb create mode 100644 spec/channels/admin_counters_channel_spec.rb create mode 100644 spec/channels/application_cable/connection_spec.rb create mode 100644 spec/models/user_counters_spec.rb create mode 100644 spec/system/authentication_spec.rb diff --git a/app/channels/admin_counters_channel.rb b/app/channels/admin_counters_channel.rb new file mode 100644 index 000000000..f96718569 --- /dev/null +++ b/app/channels/admin_counters_channel.rb @@ -0,0 +1,11 @@ +# The dashboard stream carries administrative data, so subscribing to it is +# checked in its own right. The connection already refuses anyone without a +# session; this refuses anyone who is signed in but not an administrator, so a +# leaked stream name is not enough on its own. +class AdminCountersChannel < Turbo::StreamsChannel + def subscribed + return reject unless current_user&.admin? + + super + end +end diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb index bb972a001..6c2827049 100644 --- a/app/controllers/admin/dashboard_controller.rb +++ b/app/controllers/admin/dashboard_controller.rb @@ -1,5 +1,7 @@ module Admin class DashboardController < BaseController - def show; end + def show + @counters = UserCounters.current + end end end diff --git a/app/models/user.rb b/app/models/user.rb index abe0d297a..c23ad756d 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -30,6 +30,10 @@ class User < ApplicationRecord before_update :ensure_another_administrator_remains, if: :leaving_administrator_role? before_destroy :ensure_another_administrator_remains, if: :admin? + # A single hook, rather than a broadcast call in every controller action that + # happens to add, remove or re-role somebody. + after_commit :broadcast_user_counters, if: :counters_affected? + scope :search, lambda { |term| next all if term.blank? @@ -51,6 +55,14 @@ def self.role_counts private + def counters_affected? + destroyed? || previously_new_record? || saved_change_to_role? + end + + def broadcast_user_counters + UserCounters.broadcast + end + def leaving_administrator_role? role_changed? && role_was == "admin" end diff --git a/app/models/user_counters.rb b/app/models/user_counters.rb new file mode 100644 index 000000000..56ce2fbce --- /dev/null +++ b/app/models/user_counters.rb @@ -0,0 +1,62 @@ +# The single place that knows how the dashboard numbers are computed and +# broadcast. Keeping it here means one after_commit hook on User instead of +# callbacks scattered across the controllers that happen to change a role. +class UserCounters + STREAM = "admin_user_counters".freeze + TARGET = "user-counters".freeze + + attr_reader :admins, :users + + def initialize(counts_by_role) + @admins = counts_by_role.fetch("admin", 0) + @users = counts_by_role.fetch("user", 0) + end + + def total + admins + users + end + + class << self + def current + new(User.role_counts) + end + + # One stream per locale: the broadcast carries rendered HTML, and the + # labels inside it are translated, so a single stream would push one + # language to everyone watching. + def stream_for(locale) + "#{STREAM}:#{locale}" + end + + def broadcast + return if suspended? + + counters = current + + User::SUPPORTED_LOCALES.each do |locale| + I18n.with_locale(locale) do + Turbo::StreamsChannel.broadcast_replace_to( + stream_for(locale), + target: TARGET, + partial: "admin/dashboard/counters", + locals: { counters: counters } + ) + end + end + end + + # Importing a spreadsheet would otherwise broadcast once per row. The job + # wraps its work in this and broadcasts once when it finishes. + def suspend_broadcasts + previous = Thread.current[:user_counters_suspended] + Thread.current[:user_counters_suspended] = true + yield + ensure + Thread.current[:user_counters_suspended] = previous + end + + def suspended? + Thread.current[:user_counters_suspended].present? + end + end +end diff --git a/app/views/admin/dashboard/_counters.html.erb b/app/views/admin/dashboard/_counters.html.erb new file mode 100644 index 000000000..46b9afdfc --- /dev/null +++ b/app/views/admin/dashboard/_counters.html.erb @@ -0,0 +1,12 @@ +<%# Replaced wholesale by the broadcast, so the id has to stay on this element. %> +
+ <% [ [ :total, counters.total ], + [ :administrators, counters.admins ], + [ :regular_users, counters.users ] ].each do |key, value| %> +
+

<%= t("admin.dashboard.#{key}") %>

+ <%# aria-live so the number reaching the page over the stream is announced. %> +

<%= number_with_delimiter(value) %>

+
+ <% end %> +
diff --git a/app/views/admin/dashboard/show.html.erb b/app/views/admin/dashboard/show.html.erb index baa31042d..034638b32 100644 --- a/app/views/admin/dashboard/show.html.erb +++ b/app/views/admin/dashboard/show.html.erb @@ -2,7 +2,23 @@ <% content_for :page_title, t(".title") %> <% content_for :page_subtitle, t(".subtitle") %> -
+<%# Subscribed through AdminCountersChannel rather than the default one, so the + subscription itself is checked for the administrator role. %> +<%= turbo_stream_from UserCounters.stream_for(I18n.locale), channel: AdminCountersChannel %> + +

<%= t(".overview") %>

-

<%= t(".users") %>

+

+ <%= t(".users") %> +

+ +
+ <%= render "counters", counters: @counters %> +
+ +
+

<%= t(".manage") %>

+

<%= t(".manage_copy") %>

+ <%= link_to t("admin.users.index.title"), admin_users_path, class: "btn btn-primary mt-4" %> +
diff --git a/config/cache.yml b/config/cache.yml index 19d490843..33811ce43 100644 --- a/config/cache.yml +++ b/config/cache.yml @@ -1,4 +1,8 @@ +# Solid Cache uses the dedicated `cache` database in every environment. The +# installer only wires this up for production, which leaves development and +# test pointing at the primary database, where the table does not exist. default: &default + database: cache store_options: # Cap age of oldest cache entry to fulfill retention policies # max_age: <%= 60.days.to_i %> @@ -12,5 +16,4 @@ test: <<: *default production: - database: cache <<: *default diff --git a/config/locales/en.yml b/config/locales/en.yml index 4a0168317..50941e1cf 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -84,6 +84,11 @@ en: overview: "Overview" users: "Users" + total: "Total users" + administrators: "Administrators" + regular_users: "Users" + manage: "Manage" + manage_copy: "Add people, change roles and remove accounts." users: created: "%{name} has been added." updated: "%{name} has been updated." diff --git a/config/locales/es.yml b/config/locales/es.yml index c1d0e7f3c..78072d82f 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -84,6 +84,11 @@ es: overview: "Resumen" users: "Usuarios" + total: "Usuarios totales" + administrators: "Administradores" + regular_users: "Usuarios" + manage: "Gestionar" + manage_copy: "Añade personas, cambia roles y elimina cuentas." users: created: "%{name} se ha añadido." updated: "%{name} se ha actualizado." diff --git a/config/locales/pt-BR.yml b/config/locales/pt-BR.yml index 87163fef4..2358d6e5b 100644 --- a/config/locales/pt-BR.yml +++ b/config/locales/pt-BR.yml @@ -84,6 +84,11 @@ pt-BR: overview: "Visão geral" users: "Usuários" + total: "Total de usuários" + administrators: "Administradores" + regular_users: "Usuários" + manage: "Gerenciar" + manage_copy: "Adicione pessoas, altere funções e remova contas." users: created: "%{name} foi adicionado." updated: "%{name} foi atualizado." diff --git a/db/seeds.rb b/db/seeds.rb index 94fdb3af4..0c5e11409 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -26,16 +26,16 @@ def upsert_user!(email_address:, full_name:, role:, locale: "en") upsert_user!(email_address: "user@example.com", full_name: "Maria Silva", role: :user, locale: "pt-BR") [ - [ "joao.souza@example.com", "João Souza", "pt-BR" ], - [ "carla.mendes@example.com", "Carla Mendes", "pt-BR" ], - [ "diego.ramirez@example.com", "Diego Ramírez", "es" ], - [ "lucia.fernandez@example.com", "Lucía Fernández", "es" ], - [ "olivia.clarke@example.com", "Olivia Clarke", "en" ], - [ "noah.bennett@example.com", "Noah Bennett", "en" ], - [ "priya.nair@example.com", "Priya Nair", "en" ], - [ "tomas.novak@example.com", "Tomáš Novák", "en" ], - [ "yuki.tanaka@example.com", "Yuki Tanaka", "en" ], - [ "amara.okafor@example.com", "Amara Okafor", "en" ] + ["joao.souza@example.com", "João Souza", "pt-BR"], + ["carla.mendes@example.com", "Carla Mendes", "pt-BR"], + ["diego.ramirez@example.com", "Diego Ramírez", "es"], + ["lucia.fernandez@example.com", "Lucía Fernández", "es"], + ["olivia.clarke@example.com", "Olivia Clarke", "en"], + ["noah.bennett@example.com", "Noah Bennett", "en"], + ["priya.nair@example.com", "Priya Nair", "en"], + ["tomas.novak@example.com", "Tomáš Novák", "en"], + ["yuki.tanaka@example.com", "Yuki Tanaka", "en"], + ["amara.okafor@example.com", "Amara Okafor", "en"] ].each do |email_address, full_name, locale| upsert_user!(email_address: email_address, full_name: full_name, role: :user, locale: locale) end diff --git a/spec/channels/admin_counters_channel_spec.rb b/spec/channels/admin_counters_channel_spec.rb new file mode 100644 index 000000000..73c372107 --- /dev/null +++ b/spec/channels/admin_counters_channel_spec.rb @@ -0,0 +1,31 @@ +require "rails_helper" + +RSpec.describe AdminCountersChannel do + def subscribe_to_counters(locale = :en) + subscribe(signed_stream_name: Turbo::StreamsChannel.signed_stream_name(UserCounters.stream_for(locale))) + end + + it "accepts an administrator" do + stub_connection current_user: create(:user, :admin) + + subscribe_to_counters + + expect(subscription).to be_confirmed + end + + it "rejects a signed-in user who is not an administrator" do + stub_connection current_user: create(:user) + + subscribe_to_counters + + expect(subscription).to be_rejected + end + + it "rejects a connection with nobody behind it" do + stub_connection current_user: nil + + subscribe_to_counters + + expect(subscription).to be_rejected + end +end diff --git a/spec/channels/application_cable/connection_spec.rb b/spec/channels/application_cable/connection_spec.rb new file mode 100644 index 000000000..6f48e56c5 --- /dev/null +++ b/spec/channels/application_cable/connection_spec.rb @@ -0,0 +1,23 @@ +require "rails_helper" + +RSpec.describe ApplicationCable::Connection do + it "identifies the user behind a valid session cookie" do + user = create(:user) + session = user.sessions.create!(user_agent: "rspec", ip_address: "127.0.0.1") + cookies.signed[:session_id] = session.id + + connect + + expect(connection.current_user).to eq(user) + end + + it "refuses a connection with no session at all" do + expect { connect }.to have_rejected_connection + end + + it "refuses a connection whose session no longer exists" do + cookies.signed[:session_id] = "00000000-0000-0000-0000-000000000000" + + expect { connect }.to have_rejected_connection + end +end diff --git a/spec/models/user_counters_spec.rb b/spec/models/user_counters_spec.rb new file mode 100644 index 000000000..1a8729448 --- /dev/null +++ b/spec/models/user_counters_spec.rb @@ -0,0 +1,60 @@ +require "rails_helper" + +RSpec.describe UserCounters do + describe ".current" do + it "counts everybody and splits them by role" do + create_list(:user, 2) + create(:user, :admin) + + counters = described_class.current + + expect(counters.total).to eq(3) + expect(counters.admins).to eq(1) + expect(counters.users).to eq(2) + end + + it "reports zero for a role nobody holds" do + create(:user) + + expect(described_class.current.admins).to be_zero + end + end + + describe "broadcasting" do + def broadcasts_for(locale) + ActionCable.server.pubsub.broadcasts(described_class.stream_for(locale)) + end + + it "broadcasts to every supported locale when a user is created" do + expect { create(:user) } + .to change { broadcasts_for("en").size }.by(1) + .and change { broadcasts_for("pt-BR").size }.by(1) + .and change { broadcasts_for("es").size }.by(1) + end + + it "broadcasts when a user is deleted" do + user = create(:user) + + expect { user.destroy! }.to change { broadcasts_for("en").size }.by(1) + end + + it "broadcasts when a role changes" do + user = create(:user) + + expect { user.update!(role: :admin) }.to change { broadcasts_for("en").size }.by(1) + end + + it "stays quiet when a change cannot affect the counters" do + user = create(:user) + + expect { user.update!(full_name: "Another Name") } + .not_to(change { broadcasts_for("en").size }) + end + + it "can be suspended so a bulk operation broadcasts once instead of per row" do + expect do + described_class.suspend_broadcasts { create_list(:user, 3) } + end.not_to(change { broadcasts_for("en").size }) + end + end +end diff --git a/spec/requests/admin/dashboard_spec.rb b/spec/requests/admin/dashboard_spec.rb index 1108a04ab..5ead9ea66 100644 --- a/spec/requests/admin/dashboard_spec.rb +++ b/spec/requests/admin/dashboard_spec.rb @@ -25,4 +25,24 @@ expect(flash[:alert]).to be_present end end + + describe "the counters" do + it "shows the totals" do + create_list(:user, 2) + sign_in create(:user, :admin) + + get admin_dashboard_path + + expect(response.body).to include('id="user-counters"') + expect(response.body).to include(I18n.t("admin.dashboard.total")) + end + + it "subscribes to the stream for the reader's own locale" do + sign_in create(:user, :admin, locale: "es") + + get admin_dashboard_path + + expect(response.body).to include("turbo-cable-stream-source") + end + end end diff --git a/spec/support/capybara.rb b/spec/support/capybara.rb index eaa4c011a..65dab2898 100644 --- a/spec/support/capybara.rb +++ b/spec/support/capybara.rb @@ -1,32 +1,55 @@ require "capybara/rspec" require "capybara/cuprite" -# Cuprite talks to the Chromium installed in the development image over CDP. +# Cuprite drives the Chromium installed in the development image over CDP. # Both run inside the same container, so the test server is reachable on # localhost and no host networking is involved. -Capybara.register_driver(:cuprite) do |app| - Capybara::Cuprite::Driver.new( - app, - window_size: [1400, 1000], +# +# The flag list is mostly about startup time: left alone, Chromium spends +# several seconds on background networking (GCM registration, component +# updates) before it prints the DevTools websocket URL that Ferrum waits for, +# which is enough to trip the process timeout on a cold start. +CHROME_FLAGS = { + "no-sandbox" => nil, + "disable-dev-shm-usage" => nil, + "disable-gpu" => nil, + "disable-background-networking" => nil, + "disable-background-timer-throttling" => nil, + "disable-backgrounding-occluded-windows" => nil, + "disable-breakpad" => nil, + "disable-component-update" => nil, + "disable-default-apps" => nil, + "disable-extensions" => nil, + "disable-renderer-backgrounding" => nil, + "disable-sync" => nil, + "disable-features" => "Translate,BackForwardCache,MediaRouter,OptimizationHints,AcceptCHFrame", + "no-first-run" => nil, + "mute-audio" => nil +}.freeze + +# Rails' `driven_by :cuprite` registers the driver itself, discarding anything +# passed to Capybara.register_driver beforehand, and it mutates the options +# hash it is given. So the options are built fresh for each example rather than +# shared as a frozen constant. +def cuprite_options + { browser_path: ENV.fetch("BROWSER_PATH", nil), - browser_options: { - "no-sandbox" => nil, - "disable-dev-shm-usage" => nil, - "disable-gpu" => nil - }, - process_timeout: 30, - timeout: 15, + browser_options: CHROME_FLAGS.dup, + process_timeout: 60, + timeout: 30, headless: true - ) + } end Capybara.default_driver = :rack_test -Capybara.javascript_driver = :cuprite Capybara.default_max_wait_time = 5 Capybara.server = :puma, { Silent: true } Capybara.disable_animation = true RSpec.configure do |config| config.before(:each, type: :system) { driven_by :rack_test } - config.before(:each, :js, type: :system) { driven_by :cuprite } + + config.before(:each, :js, type: :system) do + driven_by :cuprite, screen_size: [1400, 1000], options: cuprite_options + end end diff --git a/spec/system/authentication_spec.rb b/spec/system/authentication_spec.rb new file mode 100644 index 000000000..347d05e83 --- /dev/null +++ b/spec/system/authentication_spec.rb @@ -0,0 +1,50 @@ +require "rails_helper" + +RSpec.describe "Signing in", :js do + it "takes an administrator to the dashboard" do + create(:user, :admin, full_name: "Ada Lovelace", email_address: "ada@example.com", + password: "a-sufficiently-long-password") + + visit new_session_path + fill_in "Email address", with: "ada@example.com" + fill_in "Password", with: "a-sufficiently-long-password" + click_on "Sign in" + + expect(page).to have_current_path(admin_dashboard_path) + expect(page).to have_text("Ada Lovelace") + end + + it "takes a regular user to their profile" do + create(:user, full_name: "Maria Silva", email_address: "maria@example.com", + password: "a-sufficiently-long-password") + + visit new_session_path + fill_in "Email address", with: "maria@example.com" + fill_in "Password", with: "a-sufficiently-long-password" + click_on "Sign in" + + expect(page).to have_current_path(profile_path) + expect(page).to have_text("Maria Silva") + end + + it "keeps the visitor on the form when the password is wrong" do + create(:user, email_address: "maria@example.com") + + visit new_session_path + fill_in "Email address", with: "maria@example.com" + fill_in "Password", with: "wrong password" + click_on "Sign in" + + expect(page).to have_current_path(new_session_path) + expect(page).to have_text(I18n.t("sessions.invalid_credentials")) + end + + it "switches the interface language from the flag picker" do + visit new_session_path + + click_on "Português" + + expect(page).to have_text("Entrar") + expect(page).to have_field("E-mail") + end +end From af2698e5feacbe48477032641749544f0ee9e01b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B4nio=20Neto?= Date: Thu, 3 Sep 2026 14:53:10 -0300 Subject: [PATCH 07/33] fix: make missing translations fail the suite, and fix the ones hiding Capturing reference screenshots showed the dashboard top bar reading "Title" and "Subtitle", and its panel reading "Manage Copy". Those were missing translation keys: t(".title") in app/views/admin/dashboard/show.html.erb resolves to admin.dashboard.show.title, and the keys sat at admin.dashboard.title. Rails humanises a missing key rather than raising, so in English the guess reads as correct copy and only the other two languages would have shown the hole. - config.i18n.raise_on_missing_translations is now on in test, which is the only reliable way to catch this. Turning it on immediately found a fourth missing key the screenshots had not reached. - Dashboard keys are nested under show: to match the lazy lookup, except the ones genuinely shared with the users list, which stay absolute. - The sidebar chip was truncating to "Ada Lov..." because the sign-out button shared its row; it now sits on its own. Also: the screenshot spec is not a test, so it is tagged and excluded from the default run -- SCREENSHOTS=1 bin/test produces the README images. And the SimpleCov configuration was using four deprecated APIs, printing deprecation warnings on every run; they are now the current names. Two of my own configuration mistakes fixed on the way: ENV["SCREENSHOTS"] set to an empty string is truthy in Ruby, so the filter never applied; and appending a second RSpec/ExampleLength block to .rubocop.yml silently replaced the earlier Max: 12 with the default of 5, because a duplicate YAML key overrides rather than merges. Verified: 79 examples, 0 failures with missing translations now raising; RuboCop clean across 75 files; no deprecation warnings. Coverage 87.2% line / 83.9% branch. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BS3oGqeR1PXScuK6gLr8Mi --- .rubocop.yml | 13 ++++++++ app/views/admin/dashboard/show.html.erb | 2 +- app/views/shared/_current_user_chip.html.erb | 15 ++++++---- config/environments/test.rb | 5 ++++ config/locales/en.yml | 12 ++++---- config/locales/es.yml | 12 ++++---- config/locales/pt-BR.yml | 12 ++++---- devops/rails/test.sh | 4 +-- spec/spec_helper.rb | 30 ++++++++++--------- spec/system/screenshots_spec.rb | 31 ++++++++++++++++++++ 10 files changed, 95 insertions(+), 41 deletions(-) create mode 100644 spec/system/screenshots_spec.rb diff --git a/.rubocop.yml b/.rubocop.yml index 2eb101b12..21c9bea07 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -72,6 +72,10 @@ Rails/SkipsModelValidations: # ── RSpec ──────────────────────────────────────────────────────────────────── RSpec/ExampleLength: Max: 12 + Exclude: + # System specs walk a whole journey; splitting one into five examples would + # mean re-driving the browser five times to assert the same thing once. + - "spec/system/**/*_spec.rb" RSpec/MultipleExpectations: # A request spec legitimately asserts on status, redirect and side effect. @@ -79,3 +83,12 @@ RSpec/MultipleExpectations: RSpec/NestedGroups: Max: 4 + +# ── Screenshot helper ──────────────────────────────────────────────────────── +# spec/system/screenshots_spec.rb produces the README images. save_screenshot is +# the entire point of that file; treating it as a stray debugging call is the +# right default everywhere else. +Lint/Debugger: + Exclude: + - "spec/system/screenshots_spec.rb" + diff --git a/app/views/admin/dashboard/show.html.erb b/app/views/admin/dashboard/show.html.erb index 034638b32..b09bdf6b2 100644 --- a/app/views/admin/dashboard/show.html.erb +++ b/app/views/admin/dashboard/show.html.erb @@ -7,7 +7,7 @@ <%= turbo_stream_from UserCounters.stream_for(I18n.locale), channel: AdminCountersChannel %>
-

<%= t(".overview") %>

+

<%= t("admin.dashboard.overview") %>

<%= t(".users") %>

diff --git a/app/views/shared/_current_user_chip.html.erb b/app/views/shared/_current_user_chip.html.erb index 9be3f3187..8ce30d5c6 100644 --- a/app/views/shared/_current_user_chip.html.erb +++ b/app/views/shared/_current_user_chip.html.erb @@ -1,12 +1,15 @@ -
- +
+
+ -
-

<%= Current.user.full_name %>

-

<%= t("roles.#{Current.user.role}") %>

+
+

<%= Current.user.full_name %>

+

<%= t("roles.#{Current.user.role}") %>

+
+ <%# On its own row: sharing the line with the name left neither enough space. %> <%= button_to t("shared.nav.sign_out"), session_path, method: :delete, - class: "btn btn-ghost px-2 py-1 text-xs", + class: "btn btn-ghost mt-3 w-full text-xs", form: { data: { turbo_confirm: t("shared.nav.sign_out_confirm") } } %>
diff --git a/config/environments/test.rb b/config/environments/test.rb index c2095b117..e2ba29565 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -36,6 +36,11 @@ # ActionMailer::Base.deliveries array. config.action_mailer.delivery_method = :test + # A missing key renders a humanised guess instead of raising, which reads as + # correct English and silently ships untranslated pages. Failing the suite is + # the only reliable way to catch it. + config.i18n.raise_on_missing_translations = true + # Set host to be used by links generated in mailer templates. config.action_mailer.default_url_options = { host: "example.com" } diff --git a/config/locales/en.yml b/config/locales/en.yml index 50941e1cf..c7416e731 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -79,16 +79,16 @@ en: admin: dashboard: - title: "Dashboard" - subtitle: "An overview of the people in the system" overview: "Overview" - users: "Users" - total: "Total users" administrators: "Administrators" regular_users: "Users" - manage: "Manage" - manage_copy: "Add people, change roles and remove accounts." + show: + title: "Dashboard" + subtitle: "An overview of the people in the system" + users: "Users" + manage: "Manage" + manage_copy: "Add people, change roles and remove accounts." users: created: "%{name} has been added." updated: "%{name} has been updated." diff --git a/config/locales/es.yml b/config/locales/es.yml index 78072d82f..d0e584885 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -79,16 +79,16 @@ es: admin: dashboard: - title: "Panel" - subtitle: "Un resumen de las personas en el sistema" overview: "Resumen" - users: "Usuarios" - total: "Usuarios totales" administrators: "Administradores" regular_users: "Usuarios" - manage: "Gestionar" - manage_copy: "Añade personas, cambia roles y elimina cuentas." + show: + title: "Panel" + subtitle: "Un resumen de las personas en el sistema" + users: "Usuarios" + manage: "Gestionar" + manage_copy: "Añade personas, cambia roles y elimina cuentas." users: created: "%{name} se ha añadido." updated: "%{name} se ha actualizado." diff --git a/config/locales/pt-BR.yml b/config/locales/pt-BR.yml index 2358d6e5b..850d01c3e 100644 --- a/config/locales/pt-BR.yml +++ b/config/locales/pt-BR.yml @@ -79,16 +79,16 @@ pt-BR: admin: dashboard: - title: "Painel" - subtitle: "Visão geral das pessoas no sistema" overview: "Visão geral" - users: "Usuários" - total: "Total de usuários" administrators: "Administradores" regular_users: "Usuários" - manage: "Gerenciar" - manage_copy: "Adicione pessoas, altere funções e remova contas." + show: + title: "Painel" + subtitle: "Visão geral das pessoas no sistema" + users: "Usuários" + manage: "Gerenciar" + manage_copy: "Adicione pessoas, altere funções e remova contas." users: created: "%{name} foi adicionado." updated: "%{name} foi atualizado." diff --git a/devops/rails/test.sh b/devops/rails/test.sh index 59bdf99ba..90600dca4 100755 --- a/devops/rails/test.sh +++ b/devops/rails/test.sh @@ -8,7 +8,7 @@ step "Preparing test databases" rails_test_exec ./bin/rails db:test:prepare step "Running RSpec" if [[ "$#" -gt 0 ]]; then - rails_test_exec bundle exec rspec "$@" + rails_test_exec env SCREENSHOTS="${SCREENSHOTS:-}" bundle exec rspec "$@" else - rails_test_exec bundle exec rspec + rails_test_exec env SCREENSHOTS="${SCREENSHOTS:-}" bundle exec rspec fi diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 921fdde54..670bf1dd1 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -8,24 +8,22 @@ # Each parallel worker writes its own result and SimpleCov merges them, so # the 90% gate is measured against the whole suite rather than one shard. command_name "rspec#{ENV.fetch("TEST_ENV_NUMBER", nil)}" - use_merging true + merging true merge_timeout 600 minimum_coverage line: 90, branch: 80 - # Excluded because they contain no branching logic of our own: generated - # schemas, framework configuration and the mailer/job base classes Rails - # writes for us. - add_filter "/spec/" - add_filter "/config/" - add_filter "/db/" - add_filter "app/channels/application_cable/" - - add_group "Models", "app/models" - add_group "Controllers", "app/controllers" - add_group "Jobs", "app/jobs" - add_group "Views", "app/views" - add_group "Helpers", "app/helpers" + # Excluded because they hold no logic of our own: the specs themselves, + # framework configuration and the generated schema files. + skip "/spec/" + skip "/config/" + skip "/db/" + + group "Models", "app/models" + group "Controllers", "app/controllers" + group "Jobs", "app/jobs" + group "Views", "app/views" + group "Helpers", "app/helpers" end RSpec.configure do |config| @@ -40,6 +38,10 @@ config.shared_context_metadata_behavior = :apply_to_host_groups config.filter_run_when_matching :focus + + # The screenshot spec exists to produce README images, not to verify + # behaviour, so it stays out of the default run. + config.filter_run_excluding :screenshots if ENV["SCREENSHOTS"].to_s.empty? config.example_status_persistence_file_path = ".rspec_status" config.disable_monkey_patching! config.warnings = false diff --git a/spec/system/screenshots_spec.rb b/spec/system/screenshots_spec.rb new file mode 100644 index 000000000..d89b7c1ae --- /dev/null +++ b/spec/system/screenshots_spec.rb @@ -0,0 +1,31 @@ +require "rails_helper" + +# Not a test: this captures the reference images used in the README. It asserts +# only enough to know the page rendered before the shutter fires. Excluded from +# the default run; generate the images with SCREENSHOTS=1 bin/test. +RSpec.describe "Screens", :js, :screenshots do + it "captures the admin journey" do + admin = create(:user, :admin, full_name: "Ada Lovelace", email_address: "ada@example.com", + password: "a-sufficiently-long-password") + create(:user, full_name: "Maria Silva", email_address: "maria@example.com") + create(:user, full_name: "João Souza", email_address: "joao@example.com") + create(:user, full_name: "Lucía Fernández", email_address: "lucia@example.com") + create(:user, :admin, full_name: "Grace Hopper", email_address: "grace@example.com") + + visit new_session_path + fill_in "Email address", with: admin.email_address + fill_in "Password", with: "a-sufficiently-long-password" + click_on "Sign in" + + expect(page).to have_text("Dashboard") + page.save_screenshot("tmp/shot-dashboard.png", full: true) + + visit admin_users_path + expect(page).to have_text("Maria Silva") + page.save_screenshot("tmp/shot-users.png", full: true) + + visit profile_path + expect(page).to have_text("Ada Lovelace") + page.save_screenshot("tmp/shot-profile.png", full: true) + end +end From fcec899974a9986034e8e372f718397efd9634c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B4nio=20Neto?= Date: Thu, 3 Sep 2026 15:03:54 -0300 Subject: [PATCH 08/33] feat: add avatars, and cover the password reset flow Avatars come from an upload or a remote URL, with initials as the fallback. - Uploads are checked against what the bytes actually are. The content type on an upload is supplied by the client and can claim anything, so the file is sniffed with Marcel instead: a shell script named avatar.png and announced as image/png is rejected, and there is a spec that does exactly that. - The remote URL is never fetched by the server. Validating a URL by requesting it is how an attacker gets the server to make requests on their behalf, so it is only ever checked as text and then handed to the browser as an img src, with the referrer withheld. - displayable_avatar_url is the single gate the URL passes, used by both the validation and the view. Checking only on save was not enough: a rejected value still sits on the record while the form is re-rendered, and the helper would have put a javascript: URL straight into an img src. Likewise avatar_source only reports :attachment for a persisted attachment, because a rejected upload is still attached in memory and asking Active Storage to build a thumbnail of it raises. Pushing coverage to the gate turned up a real defect rather than padding. RuboCop's Rails/DynamicFindBy autocorrect had rewritten User.find_by_password_reset_token!(token) into find_by!(password_reset_token: token), which looks for a column that does not exist. That method is generated by generates_token_for, not a dynamic column finder, so the entire password reset flow raised PG::UndefinedColumn and nothing caught it until it had a spec. The call is restored and the cop now has it in AllowedMethods so autocorrect cannot make the change again. The reset flow is now covered, including that a known and an unknown email address produce identical responses -- answering differently would turn the form into a way of asking who has an account here. Verified: bin/ci green end to end in 15.6s -- RuboCop, Bundler Audit, importmap audit and Brakeman clean, 116 examples 0 failures, coverage 97.9% line / 90.8% branch, both above the gate for the first time. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BS3oGqeR1PXScuK6gLr8Mi --- .rubocop.yml | 11 +++ app/controllers/admin/users_controller.rb | 2 +- app/controllers/passwords_controller.rb | 2 +- app/controllers/profiles_controller.rb | 8 +- app/helpers/application_helper.rb | 24 ++++- app/models/user.rb | 70 ++++++++++++++ app/views/admin/users/_form.html.erb | 8 ++ app/views/admin/users/index.html.erb | 2 +- app/views/profiles/edit.html.erb | 33 +++++++ app/views/profiles/show.html.erb | 2 +- app/views/shared/_current_user_chip.html.erb | 2 +- config/locales/en.yml | 13 +++ config/locales/es.yml | 13 +++ config/locales/pt-BR.yml | 13 +++ ...te_active_storage_tables.active_storage.rb | 59 ++++++++++++ db/schema.rb | 32 ++++++- spec/fixtures/files/avatar.png | Bin 0 -> 70 bytes spec/fixtures/files/not-really-an-image.png | 2 + spec/helpers/application_helper_spec.rb | 62 +++++++++++++ spec/models/user_avatar_spec.rb | 87 ++++++++++++++++++ spec/requests/home_spec.rb | 25 +++++ spec/requests/passwords_spec.rb | 73 +++++++++++++++ spec/requests/profiles_spec.rb | 54 +++++++++++ 23 files changed, 588 insertions(+), 9 deletions(-) create mode 100644 db/migrate/20260903175421_create_active_storage_tables.active_storage.rb create mode 100644 spec/fixtures/files/avatar.png create mode 100644 spec/fixtures/files/not-really-an-image.png create mode 100644 spec/helpers/application_helper_spec.rb create mode 100644 spec/models/user_avatar_spec.rb create mode 100644 spec/requests/home_spec.rb create mode 100644 spec/requests/passwords_spec.rb diff --git a/.rubocop.yml b/.rubocop.yml index 21c9bea07..2395a4a18 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -17,7 +17,9 @@ AllCops: Exclude: - "db/schema.rb" - "db/*_schema.rb" + # Migrations copied verbatim from the framework gems are not ours to style. - "db/migrate/*_solid_*.rb" + - "db/migrate/*.active_storage.rb" - "vendor/**/*" - "tmp/**/*" - "storage/**/*" @@ -61,6 +63,15 @@ Metrics/AbcSize: Max: 20 # ── Rails ──────────────────────────────────────────────────────────────────── +Rails/DynamicFindBy: + # find_by_password_reset_token! is not a dynamic column finder: it is + # generated by generates_token_for, and rewriting it into + # find_by!(password_reset_token: ...) looks for a column that does not exist. + # Autocorrect made exactly that change and broke the password reset flow. + AllowedMethods: + - find_by_password_reset_token! + - find_by_password_reset_token + Rails/SkipsModelValidations: # Import counters are advanced with atomic updates on purpose; validating a # counter bump on every row would serialise the import for no benefit. diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 58e513197..33a6680c3 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -50,7 +50,7 @@ def set_user # Unlike the public form, an administrator may set the role. The last # administrator is still protected -- by the model, not by this list. def user_params - permitted = params.expect(user: %i[full_name email_address avatar_url role password]) + permitted = params.expect(user: %i[full_name email_address avatar_url avatar role password]) # An empty password field on the edit form means "leave it alone", not # "set the password to nothing". permitted.delete(:password) if permitted[:password].blank? diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb index 33977ba82..65b1258f4 100644 --- a/app/controllers/passwords_controller.rb +++ b/app/controllers/passwords_controller.rb @@ -29,7 +29,7 @@ def update private def set_user_by_token - @user = User.find_by!(password_reset_token: params.expect(:token)) + @user = User.find_by_password_reset_token!(params.expect(:token)) rescue ActiveSupport::MessageVerifier::InvalidSignature redirect_to new_password_path, alert: t("passwords.invalid_token") end diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index 0154e2066..a98b15c0d 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -6,6 +6,8 @@ def show; end def edit; end def update + @user.avatar.purge if params.dig(:user, :remove_avatar) == "1" + if @user.update(profile_params) redirect_to profile_path, notice: t("profiles.updated") else @@ -29,8 +31,10 @@ def set_user @user = Current.user end - # `role` is not permitted: a user cannot promote themselves. + # `role` is not permitted: a user cannot promote themselves. `remove_avatar` + # is read directly in the action rather than assigned, since it is an + # instruction rather than an attribute. def profile_params - params.expect(user: %i[full_name email_address avatar_url]) + params.expect(user: %i[full_name email_address avatar_url avatar]) end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 6e000eaac..7c4ea6d15 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -8,8 +8,30 @@ def user_initials(user) [parts.first, (parts.last if parts.size > 1)].compact.pluck(0).join end + # One place decides how an avatar is rendered, so the precedence between an + # upload, a remote URL and initials is not re-decided in every template. + # + # The remote URL is never fetched by the server -- it is handed to the browser + # as an img src, with the referrer withheld so the other site learns nothing + # about who is looking. + def avatar_tag(user, size:, classes: nil) + dimensions = { class: "avatar #{classes}".strip, style: "width: #{size}px; height: #{size}px;" } + + case user.avatar_source + when :attachment + image_tag user.avatar.variant(resize_to_fill: [size * 2, size * 2]), + alt: "", loading: "lazy", **dimensions + when :remote + image_tag user.displayable_avatar_url, alt: "", loading: "lazy", + referrerpolicy: "no-referrer", **dimensions + else + style = "#{dimensions[:style]} font-size: #{[size / 3, 10].max}px;" + tag.span user_initials(user), aria: { hidden: true }, **dimensions.merge(style: style) + end + end + def role_badge(user) - tag.span user.role.humanize, + tag.span t("roles.#{user.role}"), class: "badge #{user.admin? ? "badge-admin" : "badge-user"}" end end diff --git a/app/models/user.rb b/app/models/user.rb index c23ad756d..c5d1ad42f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -6,8 +6,18 @@ class User < ApplicationRecord # picker all read from one place. SUPPORTED_LOCALES = %w[en pt-BR es].freeze + # Avatars are checked against what the bytes actually are, not what the + # upload claims they are. + AVATAR_CONTENT_TYPES = %w[image/png image/jpeg image/webp image/gif].freeze + MAX_AVATAR_BYTES = 2.megabytes + + # Only ordinary web URLs. javascript:, data: and file: are all valid URIs and + # none of them belong in an img src. + AVATAR_URL_SCHEMES = %w[http https].freeze + has_secure_password has_many :sessions, dependent: :destroy + has_one_attached :avatar # Two roles, explicitly numbered so the values are stable in the database and # match the check constraint. `validate: true` turns an unknown role into a @@ -24,6 +34,9 @@ class User < ApplicationRecord uniqueness: { case_sensitive: false } validates :locale, inclusion: { in: SUPPORTED_LOCALES } + validate :acceptable_avatar, if: -> { attachment_changes.key?("avatar") } + validate :avatar_url_is_an_ordinary_web_url, if: -> { avatar_url.present? } + # The last administrator may not be removed or demoted. This lives on the # model rather than in a controller so it holds for every path into the # data: the admin screens, the console, a seed, a future import. @@ -53,8 +66,65 @@ def self.role_counts group(:role).count.transform_keys(&:to_s) end + # Upload wins, then the remote URL, then initials. Callers render from this + # rather than each deciding the precedence for themselves. + def avatar_source + # Persisted, not merely assigned: a rejected upload is still attached in + # memory while the form is re-rendered, and asking Active Storage to build + # a thumbnail of it would raise. + return :attachment if avatar.attached? && avatar.attachment.persisted? + return :remote if displayable_avatar_url.present? + + :initials + end + + # The single gate the URL has to pass, used both by the validation and by the + # view. A rejected value is still sitting on the record while the form is + # re-rendered, so checking only on save would put an unvalidated string into + # an img src -- which is how a javascript: URL reaches the page. + def displayable_avatar_url + return if avatar_url.blank? + + uri = URI.parse(avatar_url) + avatar_url if AVATAR_URL_SCHEMES.include?(uri.scheme) && uri.host.present? + rescue URI::InvalidURIError + nil + end + private + # The content type on an upload is supplied by the client and can say + # anything, so the file is sniffed instead. A shell script named avatar.png + # and announced as image/png does not get through. + def acceptable_avatar + io = uploaded_avatar_io + return if io.nil? + + errors.add(:avatar, :too_large, limit: MAX_AVATAR_BYTES / 1.megabyte) if io.size > MAX_AVATAR_BYTES + + detected = Marcel::MimeType.for(io) + errors.add(:avatar, :invalid_type) unless AVATAR_CONTENT_TYPES.include?(detected) + ensure + io.rewind if io.respond_to?(:rewind) + end + + def uploaded_avatar_io + attachable = attachment_changes["avatar"]&.attachable + return if attachable.blank? + + return attachable.tempfile if attachable.respond_to?(:tempfile) + + attachable if attachable.respond_to?(:size) && attachable.respond_to?(:read) + end + + # Deliberately does not fetch anything. Validating a URL by requesting it is + # how an attacker gets the server to make requests on their behalf, so the + # URL is only ever checked as text and then handed to the browser as an + # img src. + def avatar_url_is_an_ordinary_web_url + errors.add(:avatar_url, :unsupported_scheme) if displayable_avatar_url.nil? + end + def counters_affected? destroyed? || previously_new_record? || saved_change_to_role? end diff --git a/app/views/admin/users/_form.html.erb b/app/views/admin/users/_form.html.erb index bf4148c82..e6fa2967b 100644 --- a/app/views/admin/users/_form.html.erb +++ b/app/views/admin/users/_form.html.erb @@ -29,6 +29,14 @@

+
+ <%= form.label :avatar, t("admin.users.form.avatar"), class: "field-label" %> + <%= form.file_field :avatar, accept: User::AVATAR_CONTENT_TYPES.join(","), + aria: { describedby: "avatar-file-hint", invalid: user.errors[:avatar].any? }, + class: "field-input" %> +

<%= t("admin.users.form.avatar_hint") %>

+
+
<%= form.label :avatar_url, t("admin.users.form.avatar_url"), class: "field-label" %> <%= form.url_field :avatar_url, autocomplete: "off", diff --git a/app/views/admin/users/index.html.erb b/app/views/admin/users/index.html.erb index 2eec5864a..0ac822d80 100644 --- a/app/views/admin/users/index.html.erb +++ b/app/views/admin/users/index.html.erb @@ -69,7 +69,7 @@
- + <%= avatar_tag(user, size: 32) %>

<%= user.full_name %>

<%= user.email_address %>

diff --git a/app/views/profiles/edit.html.erb b/app/views/profiles/edit.html.erb index 670b638ee..54b747080 100644 --- a/app/views/profiles/edit.html.erb +++ b/app/views/profiles/edit.html.erb @@ -18,6 +18,39 @@ aria: { invalid: @user.errors[:email_address].any? }, class: "field-input" %>
+
+ <%= t(".avatar") %> + +
+ <%= avatar_tag(@user, size: 56) %> + +
+ <%= form.label :avatar, t(".avatar_file"), class: "sr-only" %> + <%= form.file_field :avatar, accept: User::AVATAR_CONTENT_TYPES.join(","), + aria: { describedby: "avatar-file-hint", invalid: @user.errors[:avatar].any? }, + class: "field-input" %> +

<%= t(".avatar_hint") %>

+
+
+ + <% if @user.avatar_source == :attachment %> + <%# An instruction, not an attribute: the controller reads it directly + and it is deliberately absent from the permitted parameters. %> + + <% end %> +
+ +
+ <%= form.label :avatar_url, t(".avatar_url"), class: "field-label" %> + <%= form.url_field :avatar_url, autocomplete: "off", + aria: { describedby: "avatar-url-hint", invalid: @user.errors[:avatar_url].any? }, + class: "field-input" %> +

<%= t(".avatar_url_hint") %>

+
+
<%= form.submit t(".submit"), class: "btn btn-primary" %> <%= link_to t(".cancel"), profile_path, class: "btn btn-ghost" %> diff --git a/app/views/profiles/show.html.erb b/app/views/profiles/show.html.erb index 238125071..d8ac75b0b 100644 --- a/app/views/profiles/show.html.erb +++ b/app/views/profiles/show.html.erb @@ -4,7 +4,7 @@
- + <%= avatar_tag(@user, size: 64) %>

<%= @user.full_name %>

diff --git a/app/views/shared/_current_user_chip.html.erb b/app/views/shared/_current_user_chip.html.erb index 8ce30d5c6..a6b964773 100644 --- a/app/views/shared/_current_user_chip.html.erb +++ b/app/views/shared/_current_user_chip.html.erb @@ -1,6 +1,6 @@
- + <%= avatar_tag(Current.user, size: 36) %>

<%= Current.user.full_name %>

diff --git a/config/locales/en.yml b/config/locales/en.yml index c7416e731..b9a812f2c 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -74,6 +74,12 @@ en: subtitle: "Update your account details" full_name: "Full name" email_address: "Email address" + avatar: "Avatar" + avatar_file: "Avatar file" + avatar_hint: "PNG, JPEG, WebP or GIF, up to 2 MB." + remove_avatar: "Remove the uploaded image" + avatar_url: "Avatar URL" + avatar_url_hint: "Used when nothing is uploaded. An http or https link; the image is loaded by your browser, never fetched by the server." submit: "Save changes" cancel: "Cancel" @@ -100,6 +106,8 @@ en: password: "Password" password_hint_new: "At least 8 characters." password_hint_edit: "Leave blank to keep the current password." + avatar: "Avatar file" + avatar_hint: "PNG, JPEG, WebP or GIF, up to 2 MB." avatar_url: "Avatar URL" avatar_url_hint: "An https link to an image. Optional." index: @@ -158,5 +166,10 @@ en: models: user: attributes: + avatar: + too_large: "must be smaller than %{limit} MB." + invalid_type: "must be a PNG, JPEG, WebP or GIF image." + avatar_url: + unsupported_scheme: "must be an http or https link." base: last_administrator: "This is the only administrator left, so the role cannot be removed." diff --git a/config/locales/es.yml b/config/locales/es.yml index d0e584885..90137db37 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -74,6 +74,12 @@ es: subtitle: "Actualiza los datos de tu cuenta" full_name: "Nombre completo" email_address: "Correo electrónico" + avatar: "Avatar" + avatar_file: "Archivo del avatar" + avatar_hint: "PNG, JPEG, WebP o GIF, hasta 2 MB." + remove_avatar: "Quitar la imagen subida" + avatar_url: "URL del avatar" + avatar_url_hint: "Se usa cuando no hay archivo subido. Un enlace http o https; la imagen la carga tu navegador, el servidor nunca la descarga." submit: "Guardar cambios" cancel: "Cancelar" @@ -100,6 +106,8 @@ es: password: "Contraseña" password_hint_new: "Al menos 8 caracteres." password_hint_edit: "Déjalo en blanco para mantener la contraseña actual." + avatar: "Archivo del avatar" + avatar_hint: "PNG, JPEG, WebP o GIF, hasta 2 MB." avatar_url: "URL del avatar" avatar_url_hint: "Un enlace https a una imagen. Opcional." index: @@ -158,5 +166,10 @@ es: models: user: attributes: + avatar: + too_large: "debe pesar menos de %{limit} MB." + invalid_type: "debe ser una imagen PNG, JPEG, WebP o GIF." + avatar_url: + unsupported_scheme: "debe ser un enlace http o https." base: last_administrator: "Este es el único administrador que queda, así que no se puede quitar el rol." diff --git a/config/locales/pt-BR.yml b/config/locales/pt-BR.yml index 850d01c3e..dae8748b9 100644 --- a/config/locales/pt-BR.yml +++ b/config/locales/pt-BR.yml @@ -74,6 +74,12 @@ pt-BR: subtitle: "Atualize os dados da sua conta" full_name: "Nome completo" email_address: "E-mail" + avatar: "Avatar" + avatar_file: "Arquivo do avatar" + avatar_hint: "PNG, JPEG, WebP ou GIF, até 2 MB." + remove_avatar: "Remover a imagem enviada" + avatar_url: "URL do avatar" + avatar_url_hint: "Usada quando não há upload. Link http ou https; a imagem é carregada pelo seu navegador, nunca buscada pelo servidor." submit: "Salvar alterações" cancel: "Cancelar" @@ -100,6 +106,8 @@ pt-BR: password: "Senha" password_hint_new: "No mínimo 8 caracteres." password_hint_edit: "Deixe em branco para manter a senha atual." + avatar: "Arquivo do avatar" + avatar_hint: "PNG, JPEG, WebP ou GIF, até 2 MB." avatar_url: "URL do avatar" avatar_url_hint: "Um link https para uma imagem. Opcional." index: @@ -158,5 +166,10 @@ pt-BR: models: user: attributes: + avatar: + too_large: "deve ter menos de %{limit} MB." + invalid_type: "deve ser uma imagem PNG, JPEG, WebP ou GIF." + avatar_url: + unsupported_scheme: "deve ser um link http ou https." base: last_administrator: "Este é o único administrador restante, então a função não pode ser removida." diff --git a/db/migrate/20260903175421_create_active_storage_tables.active_storage.rb b/db/migrate/20260903175421_create_active_storage_tables.active_storage.rb new file mode 100644 index 000000000..c56fad134 --- /dev/null +++ b/db/migrate/20260903175421_create_active_storage_tables.active_storage.rb @@ -0,0 +1,59 @@ +# 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 %i[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 %i[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 097b1695c..88d93a3c8 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,10 +10,38 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_09_03_172348) do +ActiveRecord::Schema[8.1].define(version: 2026_09_03_175421) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" + 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" @@ -38,5 +66,7 @@ t.check_constraint "role = ANY (ARRAY[0, 1])", name: "users_role_within_enum" 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 diff --git a/spec/fixtures/files/avatar.png b/spec/fixtures/files/avatar.png new file mode 100644 index 0000000000000000000000000000000000000000..f37764b1f7606623616dcdc169cc858273ea2d94 GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9xZYBRYe;|OLfu)tPp=D){ QB2a?C)78&qol`;+0Lr!y6951J literal 0 HcmV?d00001 diff --git a/spec/fixtures/files/not-really-an-image.png b/spec/fixtures/files/not-really-an-image.png new file mode 100644 index 000000000..8d445c86a --- /dev/null +++ b/spec/fixtures/files/not-really-an-image.png @@ -0,0 +1,2 @@ +#!/bin/sh +echo 'this is not a png' diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb new file mode 100644 index 000000000..1af84d4c6 --- /dev/null +++ b/spec/helpers/application_helper_spec.rb @@ -0,0 +1,62 @@ +require "rails_helper" + +RSpec.describe ApplicationHelper do + describe "#user_initials" do + it "takes the first letter of the first and last words" do + expect(helper.user_initials(build(:user, full_name: "Maria da Silva Santos"))).to eq("MS") + end + + it "uses a single letter for a one-word name" do + expect(helper.user_initials(build(:user, full_name: "Prince"))).to eq("P") + end + + it "falls back to a placeholder when there is no name" do + expect(helper.user_initials(build(:user, full_name: ""))).to eq("?") + end + end + + describe "#avatar_tag" do + def upload + Rack::Test::UploadedFile.new(Rails.root.join("spec/fixtures/files/avatar.png"), "image/png") + end + + it "renders the uploaded image when there is one" do + user = create(:user) + user.avatar.attach(upload) + + expect(helper.avatar_tag(user, size: 40)).to include(" Date: Thu, 3 Sep 2026 15:18:39 -0300 Subject: [PATCH 09/33] feat: import users asynchronously from CSV and XLSX An administrator uploads a spreadsheet, Solid Queue does the work, and the progress reaches the page over Solid Cable. Reading: - Both formats stream. CSV goes through a streaming reader and XLSX through Roo's streaming API, so memory does not grow with the size of the file. - The extension chooses the parser and the sniffed bytes then have to agree with it. CSV has no magic number of its own, so its rule is "must not look like something else" rather than "must look like CSV" -- a renamed binary is still caught. - MAX_ROWS caps the work one upload can ask for. Without it a single file decides how long a worker is busy. Processing: - A bad row is recorded and the import keeps going. Each rejection stores the line number as it appears in the file and the reasons, so an operator can open the spreadsheet and fix that line. - Re-running is safe: counters and previous row errors are cleared first, so the record describes this run rather than the sum of every run, and rows whose account already exists come back as rejected duplicates instead of creating a second one. Duplicates are rejected rather than merged, which is the choice that cannot silently overwrite somebody's data. - Imported people never get a password from the file. They get an unguessable one and set their own through the reset flow. - The role column is honoured but strictly: user, admin, or blank meaning user. Anything else rejects the row. - Counter broadcasts are suspended for the duration and fired once at the end; progress itself is throttled to every tenth row, because a thousand-row file should not mean a thousand renders of a bar that moves a pixel. Two ideas taken from the Onix import feature: a downloadable template with the instructions written into it, and rows beginning with # being skipped, which is what makes such a template possible. Its enqueue-from-an-after_create callback was deliberately not copied -- creating a record in a console or a test should not quietly start a worker, so the job is enqueued from the controller. The rejected rows can be downloaded as CSV, and every cell carrying data from the uploaded file is neutralised first: a leading =, +, -, @ or control character makes a spreadsheet treat the cell as a formula. AdminCountersChannel is now AdminStreamChannel, since it authorises the import progress streams as well as the dashboard counters. Verified end to end against the running stack: the web container enqueued and the job ran in the worker container -- different hostnames in solid_queue_processes -- moving through pending, processing and completed while the user count went from 14 to 17. bin/ci green: 162 examples, 0 failures, coverage 97.5% line / 86.6% branch, RuboCop and all three security checks clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BS3oGqeR1PXScuK6gLr8Mi --- .rubocop.yml | 4 + app/assets/tailwind/components.css | 39 +++++ ...ers_channel.rb => admin_stream_channel.rb} | 5 +- .../admin/user_imports_controller.rb | 57 +++++++ app/jobs/process_user_import_job.rb | 119 ++++++++++++++ app/models/user_import.rb | 78 +++++++++ app/models/user_import_error.rb | 9 ++ app/models/user_import_parser.rb | 126 +++++++++++++++ app/models/user_import_template.rb | 66 ++++++++ app/views/admin/dashboard/show.html.erb | 4 +- .../admin/user_imports/_progress.html.erb | 38 +++++ app/views/admin/user_imports/index.html.erb | 75 +++++++++ app/views/admin/user_imports/new.html.erb | 3 + app/views/admin/user_imports/show.html.erb | 50 ++++++ app/views/shared/_sidebar.html.erb | 2 + config/locales/en.yml | 40 +++++ config/locales/es.yml | 40 +++++ config/locales/pt-BR.yml | 40 +++++ config/routes.rb | 5 + .../20260903180523_create_user_imports.rb | 33 ++++ ...0260903180524_create_user_import_errors.rb | 18 +++ db/schema.rb | 35 +++- ...l_spec.rb => admin_stream_channel_spec.rb} | 2 +- spec/factories/user_imports.rb | 15 ++ spec/fixtures/files/users-formula.csv | 2 + spec/fixtures/files/users-with-problems.csv | 7 + spec/fixtures/files/users-wrong-header.csv | 2 + spec/fixtures/files/users.csv | 4 + spec/fixtures/files/users.xlsx | Bin 0 -> 1729 bytes spec/jobs/process_user_import_job_spec.rb | 150 ++++++++++++++++++ spec/models/user_import_parser_spec.rb | 76 +++++++++ spec/models/user_import_spec.rb | 81 ++++++++++ spec/requests/admin/user_imports_spec.rb | 117 ++++++++++++++ spec/system/screenshots_spec.rb | 28 ++++ 34 files changed, 1364 insertions(+), 6 deletions(-) rename app/channels/{admin_counters_channel.rb => admin_stream_channel.rb} (59%) create mode 100644 app/controllers/admin/user_imports_controller.rb create mode 100644 app/jobs/process_user_import_job.rb create mode 100644 app/models/user_import.rb create mode 100644 app/models/user_import_error.rb create mode 100644 app/models/user_import_parser.rb create mode 100644 app/models/user_import_template.rb create mode 100644 app/views/admin/user_imports/_progress.html.erb create mode 100644 app/views/admin/user_imports/index.html.erb create mode 100644 app/views/admin/user_imports/new.html.erb create mode 100644 app/views/admin/user_imports/show.html.erb create mode 100644 db/migrate/20260903180523_create_user_imports.rb create mode 100644 db/migrate/20260903180524_create_user_import_errors.rb rename spec/channels/{admin_counters_channel_spec.rb => admin_stream_channel_spec.rb} (94%) create mode 100644 spec/factories/user_imports.rb create mode 100644 spec/fixtures/files/users-formula.csv create mode 100644 spec/fixtures/files/users-with-problems.csv create mode 100644 spec/fixtures/files/users-wrong-header.csv create mode 100644 spec/fixtures/files/users.csv create mode 100644 spec/fixtures/files/users.xlsx create mode 100644 spec/jobs/process_user_import_job_spec.rb create mode 100644 spec/models/user_import_parser_spec.rb create mode 100644 spec/models/user_import_spec.rb create mode 100644 spec/requests/admin/user_imports_spec.rb diff --git a/.rubocop.yml b/.rubocop.yml index 2395a4a18..273c55663 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -58,6 +58,10 @@ Metrics/BlockLength: Metrics/MethodLength: Max: 15 + Exclude: + # A create_table with a dozen columns is one declaration, not a long + # method. Splitting it to satisfy a line count would make it worse. + - "db/migrate/*.rb" Metrics/AbcSize: Max: 20 diff --git a/app/assets/tailwind/components.css b/app/assets/tailwind/components.css index 135925f23..15d327ef4 100644 --- a/app/assets/tailwind/components.css +++ b/app/assets/tailwind/components.css @@ -228,6 +228,45 @@ } } +/* Animation is decoration here; users who ask for less should get less. */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } + + /* ── Progress ───────────────────────────────────────────────────────── */ + /* A real element carries its own semantics, so it is worth + styling rather than replacing with divs and ARIA. Browsers each expose a + different pseudo-element for the filled part. */ + progress { + appearance: none; + border: none; + background-color: var(--color-surface-raised); + border-radius: 9999px; + } + + progress::-webkit-progress-bar { + background-color: var(--color-surface-raised); + border-radius: 9999px; + } + + progress::-webkit-progress-value { + background-color: var(--color-accent); + border-radius: 9999px; + transition: width 200ms ease; + } + + progress::-moz-progress-bar { + background-color: var(--color-accent); + border-radius: 9999px; + } +} + /* Animation is decoration here; users who ask for less should get less. */ @media (prefers-reduced-motion: reduce) { *, diff --git a/app/channels/admin_counters_channel.rb b/app/channels/admin_stream_channel.rb similarity index 59% rename from app/channels/admin_counters_channel.rb rename to app/channels/admin_stream_channel.rb index f96718569..dbc654aaf 100644 --- a/app/channels/admin_counters_channel.rb +++ b/app/channels/admin_stream_channel.rb @@ -1,8 +1,9 @@ -# The dashboard stream carries administrative data, so subscribing to it is +# Every administrative stream -- the dashboard counters and the progress of a +# running import -- carries administrative data, so subscribing is # checked in its own right. The connection already refuses anyone without a # session; this refuses anyone who is signed in but not an administrator, so a # leaked stream name is not enough on its own. -class AdminCountersChannel < Turbo::StreamsChannel +class AdminStreamChannel < Turbo::StreamsChannel def subscribed return reject unless current_user&.admin? diff --git a/app/controllers/admin/user_imports_controller.rb b/app/controllers/admin/user_imports_controller.rb new file mode 100644 index 000000000..7cfe8da33 --- /dev/null +++ b/app/controllers/admin/user_imports_controller.rb @@ -0,0 +1,57 @@ +module Admin + class UserImportsController < BaseController + def index + @user_imports = UserImport.recent_first.includes(file_attachment: :blob).limit(25) + @user_import = UserImport.new + end + + def show + @user_import = UserImport.find(params.expect(:id)) + @row_errors = @user_import.row_errors.order(:row_number) + end + + def new + @user_import = UserImport.new + end + + def create + @user_import = UserImport.new(user_import_params.merge(administrator: Current.user)) + + if @user_import.save + # Enqueued here rather than from a model callback: creating a record in + # a test or a console should not quietly start a worker. + ProcessUserImportJob.perform_later(@user_import.id) + redirect_to admin_user_import_path(@user_import), notice: t(".scheduled") + else + @user_imports = UserImport.recent_first.limit(25) + render :index, status: :unprocessable_content + end + end + + # The blank spreadsheet, with the expected header and instructions written + # into it as comment rows the importer skips. + def template + send_data UserImportTemplate.to_csv, + filename: "user-import-template.csv", + type: "text/csv; charset=utf-8", + disposition: "attachment" + end + + # The rejected rows as a spreadsheet, so an operator can fix them next to + # the original file. + def rejected_rows + import = UserImport.find(params.expect(:id)) + + send_data UserImportTemplate.rejected_rows_csv(import), + filename: "user-import-#{import.id}-rejected-rows.csv", + type: "text/csv; charset=utf-8", + disposition: "attachment" + end + + private + + def user_import_params + params.expect(user_import: [:file]) + end + end +end diff --git a/app/jobs/process_user_import_job.rb b/app/jobs/process_user_import_job.rb new file mode 100644 index 000000000..9d618e3d0 --- /dev/null +++ b/app/jobs/process_user_import_job.rb @@ -0,0 +1,119 @@ +class ProcessUserImportJob < ApplicationJob + queue_as :default + + # How often progress reaches the browser. Broadcasting every row would turn a + # thousand-row file into a thousand renders for a bar that moves a pixel. + PROGRESS_EVERY = 10 + + def perform(user_import_id) + @import = UserImport.find(user_import_id) + + start + process_rows + finish + rescue ActiveRecord::RecordNotFound + # The import was deleted before the worker reached it. Nothing to do. + rescue UserImportParser::UnreadableFile, UserImportParser::TooManyRows => e + fail_import(e.message) + end + + private + + attr_reader :import + + # Re-running is safe: counters and previous row errors are cleared first, so + # the record describes this run rather than the sum of every run. Rows whose + # user already exists come back as rejected duplicates instead of creating a + # second account. + def start + import.row_errors.delete_all + import.update!( + status: :processing, + started_at: Time.current, + finished_at: nil, + failure_reason: nil, + total_rows: 0, + processed_rows: 0, + created_users: 0, + rejected_rows: 0 + ) + end + + def process_rows + # Counter broadcasts are suspended for the duration: without this the + # dashboard would be re-rendered once per imported row. + UserCounters.suspend_broadcasts do + import.file.blob.open do |file| + parser = UserImportParser.new(path: file.path, format: import.format) + + import.update!(total_rows: parser.row_count) + broadcast_progress + + parser.each_row do |row_number, attributes| + import_row(row_number, attributes) + end + end + end + end + + def import_row(row_number, attributes) + user = build_user(attributes) + + if user.save + import.increment!(:created_users) + else + record_rejection(row_number, attributes, user.errors.full_messages) + end + + import.increment!(:processed_rows) + broadcast_progress if (import.processed_rows % PROGRESS_EVERY).zero? + rescue ArgumentError => e + # An unknown role reaches the enum setter before validation can speak. + record_rejection(row_number, attributes, [e.message]) + import.increment!(:processed_rows) + end + + def build_user(attributes) + User.new( + full_name: attributes[:full_name], + email_address: attributes[:email_address], + avatar_url: attributes[:avatar_url], + role: attributes[:role].presence || :user, + # Imported people never receive a password from the file. They get an + # unguessable one and set their own through the reset flow. + password: SecureRandom.base58(32) + ) + end + + def record_rejection(row_number, attributes, messages) + import.row_errors.create!( + row_number: row_number, + email_address: attributes[:email_address], + messages: messages + ) + import.increment!(:rejected_rows) + end + + def finish + import.update!( + status: import.rejected_rows.positive? ? :completed_with_errors : :completed, + finished_at: Time.current + ) + broadcast_progress + UserCounters.broadcast + end + + def fail_import(reason) + import.update!(status: :failed, failure_reason: reason, finished_at: Time.current) + broadcast_progress + end + + def broadcast_progress + Turbo::StreamsChannel.broadcast_replace_to( + import.stream_name, + target: import.dom_id, + partial: "admin/user_imports/progress", + locals: { user_import: import } + ) + end +end diff --git a/app/models/user_import.rb b/app/models/user_import.rb new file mode 100644 index 000000000..635d60a97 --- /dev/null +++ b/app/models/user_import.rb @@ -0,0 +1,78 @@ +class UserImport < ApplicationRecord + # The extension chooses the parser; the sniffed bytes then have to be + # consistent with it. CSV has no magic number of its own, so the rule for it + # is "must not look like something else" rather than "must look like CSV". + FORMATS = { + csv: %w[text/csv text/plain application/csv application/octet-stream].freeze, + xlsx: %w[application/vnd.openxmlformats-officedocument.spreadsheetml.sheet application/zip].freeze + }.freeze + + MAX_FILE_BYTES = 5.megabytes + + # A ceiling on work accepted in one go. Without it a single upload decides how + # long a worker is busy. + MAX_ROWS = 10_000 + + belongs_to :administrator, class_name: "User" + has_one_attached :file + has_many :row_errors, class_name: "UserImportError", dependent: :destroy + + enum :status, + { pending: 0, processing: 1, completed: 2, completed_with_errors: 3, failed: 4 }, + default: :pending, validate: true + + validates :file, presence: true + validate :acceptable_file, if: -> { attachment_changes.key?("file") } + + scope :recent_first, -> { order(created_at: :desc) } + + def format + extension = file.filename.extension_without_delimiter.to_s.downcase + extension.to_sym if FORMATS.key?(extension.to_sym) + end + + # The stream the progress updates travel on. One per import, so watching a + # running import does not mean receiving updates about every other one. + def stream_name + "user_import:#{id}" + end + + def dom_id + "user-import-#{id}" + end + + def finished? + completed? || completed_with_errors? || failed? + end + + def progress_percentage + return 0 if total_rows.zero? + + [(processed_rows * 100 / total_rows), 100].min + end + + private + + def acceptable_file + io = uploaded_io + return if io.nil? + + errors.add(:file, :too_large, limit: MAX_FILE_BYTES / 1.megabyte) if io.size > MAX_FILE_BYTES + + allowed = FORMATS[format] + return errors.add(:file, :unsupported_format) if allowed.nil? + + detected = Marcel::MimeType.for(io) + errors.add(:file, :content_mismatch) unless allowed.include?(detected) + ensure + io.rewind if io.respond_to?(:rewind) + end + + def uploaded_io + attachable = attachment_changes["file"]&.attachable + return if attachable.blank? + return attachable.tempfile if attachable.respond_to?(:tempfile) + + attachable if attachable.respond_to?(:size) && attachable.respond_to?(:read) + end +end diff --git a/app/models/user_import_error.rb b/app/models/user_import_error.rb new file mode 100644 index 000000000..7320caf53 --- /dev/null +++ b/app/models/user_import_error.rb @@ -0,0 +1,9 @@ +# One rejected row, kept so the operator can see exactly which line to fix and +# why. The email address is stored as it appeared in the file rather than +# normalised, because the point is to help somebody find it again. +class UserImportError < ApplicationRecord + belongs_to :user_import + + validates :row_number, numericality: { only_integer: true, greater_than: 0 } + validates :messages, presence: true +end diff --git a/app/models/user_import_parser.rb b/app/models/user_import_parser.rb new file mode 100644 index 000000000..5a8d69d99 --- /dev/null +++ b/app/models/user_import_parser.rb @@ -0,0 +1,126 @@ +require "csv" +require "roo" + +# Streams a spreadsheet row by row. Nothing here holds the whole file in +# memory: CSV is read with a streaming reader and XLSX through Roo's streaming +# API, so a large upload costs a constant amount of memory rather than its own +# size. +class UserImportParser + class UnreadableFile < StandardError; end + class TooManyRows < StandardError; end + + REQUIRED_HEADERS = %w[full_name email].freeze + OPTIONAL_HEADERS = %w[avatar_url role].freeze + HEADERS = (REQUIRED_HEADERS + OPTIONAL_HEADERS).freeze + + # Mirrors the ceiling on UserImport: the parser refuses to walk past it even + # if it is handed a file directly. + MAX_ROWS = UserImport::MAX_ROWS + + # Lines starting with this are instructions in the downloadable template, not + # data, and are skipped wherever they appear. + COMMENT_PREFIX = "#".freeze + + def initialize(path:, format:) + @path = path + @format = format + end + + def row_count + count = 0 + each_row { count += 1 } + count + end + + # Yields the line number as it appears in the file, so an error report points + # at the row the operator will actually see in their editor. + def each_row + header_map = nil + yielded = 0 + + each_raw_row do |number, values| + next if skippable?(values) + + if header_map.nil? + header_map = build_header_map(values) + next + end + + yielded += 1 + raise TooManyRows if yielded > MAX_ROWS + + yield number, attributes_from(values, header_map) + end + + raise UnreadableFile, "no header row found" if header_map.nil? + + yielded + end + + private + + attr_reader :path, :format + + def each_raw_row(&) + case format + when :csv then each_csv_row(&) + when :xlsx then each_xlsx_row(&) + else raise UnreadableFile, "unsupported format: #{format.inspect}" + end + end + + def each_csv_row + number = 0 + + CSV.foreach(path, encoding: "bom|utf-8") do |values| + number += 1 + yield number, values + end + rescue CSV::MalformedCSVError => e + raise UnreadableFile, e.message + end + + def each_xlsx_row + sheet = Roo::Spreadsheet.open(path, extension: :xlsx) + number = 0 + + sheet.each_row_streaming(pad_cells: true) do |row| + number += 1 + yield number, row.map { |cell| cell&.value } + end + rescue Roo::Error, Zip::Error => e + raise UnreadableFile, e.message + end + + def skippable?(values) + return true if values.nil? || values.compact.empty? + + values.first.to_s.strip.start_with?(COMMENT_PREFIX) + end + + def build_header_map(values) + headers = values.map { |value| value.to_s.strip.downcase } + missing = REQUIRED_HEADERS - headers + + raise UnreadableFile, "missing columns: #{missing.join(", ")}" if missing.any? + + HEADERS.index_with { |header| headers.index(header) }.compact + end + + def attributes_from(values, header_map) + { + full_name: cell(values, header_map["full_name"]), + email_address: cell(values, header_map["email"])&.downcase, + avatar_url: cell(values, header_map["avatar_url"]), + role: cell(values, header_map["role"])&.downcase + } + end + + def cell(values, index) + return if index.nil? + + value = values[index] + value = value.to_s.strip + value.presence + end +end diff --git a/app/models/user_import_template.rb b/app/models/user_import_template.rb new file mode 100644 index 000000000..321f53de1 --- /dev/null +++ b/app/models/user_import_template.rb @@ -0,0 +1,66 @@ +require "csv" + +# Builds the two CSV files this feature hands out: the blank template and the +# report of rejected rows. +module UserImportTemplate + # A leading =, +, -, @, tab or carriage return makes a spreadsheet treat the + # cell as a formula. Prefixing with an apostrophe keeps the text visible and + # inert. Every cell that carries data from a file somebody uploaded goes + # through here. + FORMULA_PREFIXES = ["=", "+", "-", "@", "\t", "\r"].freeze + + INSTRUCTIONS = [ + "Rows beginning with # are instructions and are ignored on import.", + "", + "REQUIRED: full_name, email", + "OPTIONAL: avatar_url, role", + "", + "email must be unique. A row whose address already exists is rejected", + " and reported, and no duplicate account is created.", + "role either 'user' or 'admin'. Left blank it becomes 'user'.", + " Anything else rejects the row.", + "avatar_url an http or https link. The server never downloads it; the", + " browser loads it when the profile is shown.", + "", + "Imported people get an unguessable password and set their own through", + "the 'forgot password' flow.", + "" + ].freeze + + EXAMPLE_ROWS = [ + ["Maria Silva", "maria.silva@example.com", "https://example.com/maria.png", "user"], + ["João Souza", "joao.souza@example.com", "", ""], + ["Ada Lovelace", "ada.lovelace@example.com", "", "admin"] + ].freeze + + class << self + def to_csv + CSV.generate do |csv| + INSTRUCTIONS.each { |line| csv << ["# #{line}".rstrip] } + csv << UserImportParser::HEADERS + EXAMPLE_ROWS.each { |row| csv << row } + end + end + + def rejected_rows_csv(user_import) + CSV.generate do |csv| + csv << %w[row_number email problems] + + user_import.row_errors.order(:row_number).each do |row_error| + csv << [ + row_error.row_number, + neutralise(row_error.email_address), + neutralise(row_error.messages.join("; ")) + ] + end + end + end + + def neutralise(value) + text = value.to_s + return text unless text.start_with?(*FORMULA_PREFIXES) + + "'#{text}" + end + end +end diff --git a/app/views/admin/dashboard/show.html.erb b/app/views/admin/dashboard/show.html.erb index b09bdf6b2..5618c094c 100644 --- a/app/views/admin/dashboard/show.html.erb +++ b/app/views/admin/dashboard/show.html.erb @@ -2,9 +2,9 @@ <% content_for :page_title, t(".title") %> <% content_for :page_subtitle, t(".subtitle") %> -<%# Subscribed through AdminCountersChannel rather than the default one, so the +<%# Subscribed through AdminStreamChannel rather than the default one, so the subscription itself is checked for the administrator role. %> -<%= turbo_stream_from UserCounters.stream_for(I18n.locale), channel: AdminCountersChannel %> +<%= turbo_stream_from UserCounters.stream_for(I18n.locale), channel: AdminStreamChannel %>

<%= t("admin.dashboard.overview") %>

diff --git a/app/views/admin/user_imports/_progress.html.erb b/app/views/admin/user_imports/_progress.html.erb new file mode 100644 index 000000000..355471d5a --- /dev/null +++ b/app/views/admin/user_imports/_progress.html.erb @@ -0,0 +1,38 @@ +<%# Replaced wholesale by each progress broadcast, so the id stays on this element. %> +
+
+
+

<%= t("admin.user_imports.statuses.#{user_import.status}") %>

+

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

+
+ +

+ <%= t("admin.user_imports.progress.counts", + created: user_import.created_users, + rejected: user_import.rejected_rows, + total: user_import.total_rows) %> +

+
+ + <%# A real progress element: it carries its own semantics, so assistive + technology announces the value without any ARIA of ours. %> +
+ + <%= user_import.progress_percentage %>% + + + <%= user_import.progress_percentage %>% + +
+ + <% if user_import.failure_reason.present? %> +

+ <%= user_import.failure_reason %> +

+ <% end %> +
diff --git a/app/views/admin/user_imports/index.html.erb b/app/views/admin/user_imports/index.html.erb new file mode 100644 index 000000000..981a082f5 --- /dev/null +++ b/app/views/admin/user_imports/index.html.erb @@ -0,0 +1,75 @@ +<% content_for :title, t(".title") %> +<% content_for :page_title, t(".title") %> +<% content_for :page_subtitle, t(".subtitle") %> + +
+
+

<%= t("admin.dashboard.overview") %>

+

<%= t(".title") %>

+
+ + <%= link_to t(".download_template"), template_admin_user_imports_path(format: :csv), + class: "btn btn-ghost" %> +
+ +
+

<%= t(".upload") %>

+

<%= t(".upload_copy") %>

+ + <%= form_with model: @user_import, url: admin_user_imports_path, class: "mt-4" do |form| %> + <%= render "shared/form_errors", record: @user_import %> + +
+
+ <%= form.label :file, t(".file"), class: "sr-only" %> + <%= form.file_field :file, accept: ".csv,.xlsx", + aria: { describedby: "import-file-hint", invalid: @user_import.errors[:file].any? }, + class: "field-input" %> +

<%= t(".file_hint") %>

+
+ + <%= form.submit t(".submit"), class: "btn btn-primary" %> +
+ <% end %> +
+ +
+
+

<%= t(".history") %>

+
+ + <% if @user_imports.any? %> +
    + <% @user_imports.each do |import| %> +
  • +
    + <%= link_to admin_user_import_path(import), + class: "truncate text-sm font-medium text-accent-strong hover:underline" do %> + <%= import.file.filename %> + <% end %> +

    + <%= l(import.created_at, format: :short) %> · + <%= import.administrator.full_name %> +

    +
    + +
    + + <%= t("admin.user_imports.progress.counts", + created: import.created_users, rejected: import.rejected_rows, + total: import.total_rows) %> + + + <%= t("admin.user_imports.statuses.#{import.status}") %> + +
    +
  • + <% end %> +
+ <% else %> +
+

<%= t(".empty_title") %>

+

<%= t(".empty_copy") %>

+
+ <% end %> +
diff --git a/app/views/admin/user_imports/new.html.erb b/app/views/admin/user_imports/new.html.erb new file mode 100644 index 000000000..f493edf45 --- /dev/null +++ b/app/views/admin/user_imports/new.html.erb @@ -0,0 +1,3 @@ +<%# The upload form lives on the index alongside the history; this route exists + so the resource is complete and a bookmarked /new still works. %> +<%= render template: "admin/user_imports/index" %> diff --git a/app/views/admin/user_imports/show.html.erb b/app/views/admin/user_imports/show.html.erb new file mode 100644 index 000000000..4cd2245cf --- /dev/null +++ b/app/views/admin/user_imports/show.html.erb @@ -0,0 +1,50 @@ +<% content_for :title, t(".title") %> +<% content_for :page_title, t(".title") %> +<% content_for :page_subtitle, @user_import.file.filename.to_s %> + +<%# Progress arrives on this import's own stream, subscribed through the + administrator-only channel. %> +<%= turbo_stream_from @user_import.stream_name, channel: AdminStreamChannel %> + +<%= render "progress", user_import: @user_import %> + +
+
+

<%= t(".rejected_rows") %>

+ + <% if @row_errors.any? %> + <%= link_to t(".download_rejected"), + rejected_rows_admin_user_import_path(@user_import, format: :csv), + class: "btn btn-ghost text-xs" %> + <% end %> +
+ + <% if @row_errors.any? %> +
+ + + + + + + + + + <% @row_errors.each do |row_error| %> + + + + + + <% end %> + +
<%= t(".row") %><%= t(".email_address") %><%= t(".problems") %>
<%= row_error.row_number %><%= row_error.email_address.presence || "—" %><%= row_error.messages.to_sentence %>
+
+ <% else %> +
+

<%= t(".no_rejected_rows") %>

+
+ <% end %> +
+ +<%= link_to t(".back"), admin_user_imports_path, class: "btn btn-ghost mt-6" %> diff --git a/app/views/shared/_sidebar.html.erb b/app/views/shared/_sidebar.html.erb index 3f486fc1e..70fb75214 100644 --- a/app/views/shared/_sidebar.html.erb +++ b/app/views/shared/_sidebar.html.erb @@ -10,6 +10,8 @@ aria: { current: current_page?(admin_dashboard_path) ? "page" : nil } %> <%= link_to t("shared.nav.users"), admin_users_path, class: "rail-item", aria: { current: controller_path == "admin/users" ? "page" : nil } %> + <%= link_to t("shared.nav.imports"), admin_user_imports_path, class: "rail-item", + aria: { current: controller_path == "admin/user_imports" ? "page" : nil } %> <% end %> <%= link_to t("shared.nav.my_profile"), profile_path, class: "rail-item", diff --git a/config/locales/en.yml b/config/locales/en.yml index b9a812f2c..6896f9ada 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -11,6 +11,7 @@ en: main: "Main" dashboard: "Dashboard" users: "Users" + imports: "Imports" my_profile: "My profile" sign_out: "Sign out" sign_out_confirm: "Sign out of Roster?" @@ -144,6 +145,39 @@ en: submit: "Save changes" cancel: "Cancel" + user_imports: + scheduled: "The file is queued. Progress appears below as it runs." + index: + title: "Imports" + subtitle: "Create accounts from a spreadsheet" + download_template: "Download template" + upload: "New import" + upload_copy: "A CSV or XLSX file with the columns full_name, email, avatar_url and role." + file: "Spreadsheet" + file_hint: "CSV or XLSX, up to 5 MB and 10,000 rows." + submit: "Start import" + history: "History" + empty_title: "No imports yet" + empty_copy: "Upload a spreadsheet to create accounts in bulk." + show: + title: "Import" + rejected_rows: "Rejected rows" + download_rejected: "Download as CSV" + row: "Row" + email_address: "Email address" + problems: "Problems" + no_rejected_rows: "Every row was imported." + back: "Back to imports" + statuses: + pending: "Waiting" + processing: "Importing" + completed: "Completed" + completed_with_errors: "Completed with errors" + failed: "Failed" + progress: + label: "Import progress" + counts: "%{created} created, %{rejected} rejected of %{total}" + locales: unsupported: "That language is not available." @@ -164,6 +198,12 @@ en: activerecord: errors: models: + user_import: + attributes: + file: + too_large: "must be smaller than %{limit} MB." + unsupported_format: "must be a .csv or .xlsx file." + content_mismatch: "does not contain what its extension promises." user: attributes: avatar: diff --git a/config/locales/es.yml b/config/locales/es.yml index 90137db37..0120aebac 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -11,6 +11,7 @@ es: main: "Principal" dashboard: "Panel" users: "Usuarios" + imports: "Importaciones" my_profile: "Mi perfil" sign_out: "Cerrar sesión" sign_out_confirm: "¿Cerrar sesión en Roster?" @@ -144,6 +145,39 @@ es: submit: "Guardar cambios" cancel: "Cancelar" + user_imports: + scheduled: "El archivo está en cola. El progreso aparece abajo mientras se ejecuta." + index: + title: "Importaciones" + subtitle: "Crea cuentas a partir de una hoja de cálculo" + download_template: "Descargar plantilla" + upload: "Nueva importación" + upload_copy: "Un archivo CSV o XLSX con las columnas full_name, email, avatar_url y role." + file: "Hoja de cálculo" + file_hint: "CSV o XLSX, hasta 5 MB y 10.000 filas." + submit: "Iniciar importación" + history: "Historial" + empty_title: "Todavía no hay importaciones" + empty_copy: "Sube una hoja de cálculo para crear cuentas en lote." + show: + title: "Importación" + rejected_rows: "Filas rechazadas" + download_rejected: "Descargar en CSV" + row: "Fila" + email_address: "Correo electrónico" + problems: "Problemas" + no_rejected_rows: "Todas las filas se importaron." + back: "Volver a importaciones" + statuses: + pending: "En espera" + processing: "Importando" + completed: "Completada" + completed_with_errors: "Completada con errores" + failed: "Falló" + progress: + label: "Progreso de la importación" + counts: "%{created} creados, %{rejected} rechazados de %{total}" + locales: unsupported: "Ese idioma no está disponible." @@ -164,6 +198,12 @@ es: activerecord: errors: models: + user_import: + attributes: + file: + too_large: "debe pesar menos de %{limit} MB." + unsupported_format: "debe ser un archivo .csv o .xlsx." + content_mismatch: "no contiene lo que promete su extensión." user: attributes: avatar: diff --git a/config/locales/pt-BR.yml b/config/locales/pt-BR.yml index dae8748b9..59ae87209 100644 --- a/config/locales/pt-BR.yml +++ b/config/locales/pt-BR.yml @@ -11,6 +11,7 @@ pt-BR: main: "Principal" dashboard: "Painel" users: "Usuários" + imports: "Importações" my_profile: "Meu perfil" sign_out: "Sair" sign_out_confirm: "Sair do Roster?" @@ -144,6 +145,39 @@ pt-BR: submit: "Salvar alterações" cancel: "Cancelar" + user_imports: + scheduled: "O arquivo entrou na fila. O progresso aparece abaixo conforme roda." + index: + title: "Importações" + subtitle: "Crie contas a partir de uma planilha" + download_template: "Baixar modelo" + upload: "Nova importação" + upload_copy: "Um arquivo CSV ou XLSX com as colunas full_name, email, avatar_url e role." + file: "Planilha" + file_hint: "CSV ou XLSX, até 5 MB e 10.000 linhas." + submit: "Iniciar importação" + history: "Histórico" + empty_title: "Nenhuma importação ainda" + empty_copy: "Envie uma planilha para criar contas em lote." + show: + title: "Importação" + rejected_rows: "Linhas rejeitadas" + download_rejected: "Baixar em CSV" + row: "Linha" + email_address: "E-mail" + problems: "Problemas" + no_rejected_rows: "Todas as linhas foram importadas." + back: "Voltar para importações" + statuses: + pending: "Na fila" + processing: "Importando" + completed: "Concluída" + completed_with_errors: "Concluída com erros" + failed: "Falhou" + progress: + label: "Progresso da importação" + counts: "%{created} criados, %{rejected} rejeitados de %{total}" + locales: unsupported: "Esse idioma não está disponível." @@ -164,6 +198,12 @@ pt-BR: activerecord: errors: models: + user_import: + attributes: + file: + too_large: "deve ter menos de %{limit} MB." + unsupported_format: "deve ser um arquivo .csv ou .xlsx." + content_mismatch: "não contém o que a extensão promete." user: attributes: avatar: diff --git a/config/routes.rb b/config/routes.rb index 78942b2db..dce97a2c6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -10,6 +10,11 @@ namespace :admin do get "dashboard", to: "dashboard#show" resources :users + + resources :user_imports, only: %i[index new create show] do + get :template, on: :collection + get :rejected_rows, on: :member + end end # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. diff --git a/db/migrate/20260903180523_create_user_imports.rb b/db/migrate/20260903180523_create_user_imports.rb new file mode 100644 index 000000000..e4ab97fbb --- /dev/null +++ b/db/migrate/20260903180523_create_user_imports.rb @@ -0,0 +1,33 @@ +class CreateUserImports < ActiveRecord::Migration[8.1] + def change + create_table :user_imports do |t| + t.references :administrator, null: false, foreign_key: { to_table: :users } + + t.integer :status, null: false, default: 0 + + # Counters rather than derived queries: the import reports progress while + # it is still running, when the rows it has not reached yet do not exist + # anywhere to be counted. + t.integer :total_rows, null: false, default: 0 + t.integer :processed_rows, null: false, default: 0 + t.integer :created_users, null: false, default: 0 + t.integer :rejected_rows, null: false, default: 0 + + # Set only when the whole import failed, as opposed to individual rows. + t.text :failure_reason + + t.datetime :started_at + t.datetime :finished_at + + t.timestamps + end + + add_index :user_imports, :status + add_index :user_imports, :created_at + + add_check_constraint :user_imports, "status BETWEEN 0 AND 4", name: "user_imports_status_within_enum" + add_check_constraint :user_imports, + "total_rows >= 0 AND processed_rows >= 0 AND created_users >= 0 AND rejected_rows >= 0", + name: "user_imports_counters_not_negative" + end +end diff --git a/db/migrate/20260903180524_create_user_import_errors.rb b/db/migrate/20260903180524_create_user_import_errors.rb new file mode 100644 index 000000000..d830859d3 --- /dev/null +++ b/db/migrate/20260903180524_create_user_import_errors.rb @@ -0,0 +1,18 @@ +class CreateUserImportErrors < ActiveRecord::Migration[8.1] + def change + create_table :user_import_errors do |t| + t.references :user_import, null: false, foreign_key: true + t.integer :row_number, null: false + + # Kept as given, not normalised: the report has to show the operator what + # was actually in the file so they can find and fix the row. + t.string :email_address + t.string :messages, array: true, null: false, default: [] + + t.timestamps + end + + add_index :user_import_errors, %i[user_import_id row_number] + add_check_constraint :user_import_errors, "row_number > 0", name: "user_import_errors_row_number_positive" + end +end diff --git a/db/schema.rb b/db/schema.rb index 88d93a3c8..73995559e 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_175421) do +ActiveRecord::Schema[8.1].define(version: 2026_09_03_180524) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -51,6 +51,37 @@ t.index ["user_id"], name: "index_sessions_on_user_id" end + create_table "user_import_errors", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "email_address" + t.string "messages", default: [], null: false, array: true + t.integer "row_number", null: false + t.datetime "updated_at", null: false + t.bigint "user_import_id", null: false + t.index ["user_import_id", "row_number"], name: "index_user_import_errors_on_user_import_id_and_row_number" + t.index ["user_import_id"], name: "index_user_import_errors_on_user_import_id" + t.check_constraint "row_number > 0", name: "user_import_errors_row_number_positive" + end + + create_table "user_imports", force: :cascade do |t| + t.bigint "administrator_id", null: false + t.datetime "created_at", null: false + t.integer "created_users", default: 0, null: false + t.text "failure_reason" + t.datetime "finished_at" + t.integer "processed_rows", default: 0, null: false + t.integer "rejected_rows", default: 0, null: false + t.datetime "started_at" + t.integer "status", default: 0, null: false + t.integer "total_rows", default: 0, null: false + t.datetime "updated_at", null: false + t.index ["administrator_id"], name: "index_user_imports_on_administrator_id" + t.index ["created_at"], name: "index_user_imports_on_created_at" + t.index ["status"], name: "index_user_imports_on_status" + t.check_constraint "status >= 0 AND status <= 4", name: "user_imports_status_within_enum" + t.check_constraint "total_rows >= 0 AND processed_rows >= 0 AND created_users >= 0 AND rejected_rows >= 0", name: "user_imports_counters_not_negative" + end + create_table "users", force: :cascade do |t| t.string "avatar_url" t.datetime "created_at", null: false @@ -69,4 +100,6 @@ 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 "user_import_errors", "user_imports" + add_foreign_key "user_imports", "users", column: "administrator_id" end diff --git a/spec/channels/admin_counters_channel_spec.rb b/spec/channels/admin_stream_channel_spec.rb similarity index 94% rename from spec/channels/admin_counters_channel_spec.rb rename to spec/channels/admin_stream_channel_spec.rb index 73c372107..87b794afc 100644 --- a/spec/channels/admin_counters_channel_spec.rb +++ b/spec/channels/admin_stream_channel_spec.rb @@ -1,6 +1,6 @@ require "rails_helper" -RSpec.describe AdminCountersChannel do +RSpec.describe AdminStreamChannel do def subscribe_to_counters(locale = :en) subscribe(signed_stream_name: Turbo::StreamsChannel.signed_stream_name(UserCounters.stream_for(locale))) end diff --git a/spec/factories/user_imports.rb b/spec/factories/user_imports.rb new file mode 100644 index 000000000..4f1b70025 --- /dev/null +++ b/spec/factories/user_imports.rb @@ -0,0 +1,15 @@ +FactoryBot.define do + factory :user_import do + administrator factory: %i[user admin] + + trait :with_csv do + after(:build) do |import| + import.file.attach( + io: Rails.root.join("spec/fixtures/files/users.csv").open, + filename: "users.csv", + content_type: "text/csv" + ) + end + end + end +end diff --git a/spec/fixtures/files/users-formula.csv b/spec/fixtures/files/users-formula.csv new file mode 100644 index 000000000..e5cac3165 --- /dev/null +++ b/spec/fixtures/files/users-formula.csv @@ -0,0 +1,2 @@ +full_name,email,avatar_url,role +=cmd|'/c calc'!A1,formula@example.com,,user diff --git a/spec/fixtures/files/users-with-problems.csv b/spec/fixtures/files/users-with-problems.csv new file mode 100644 index 000000000..dd6d8bdf6 --- /dev/null +++ b/spec/fixtures/files/users-with-problems.csv @@ -0,0 +1,7 @@ +full_name,email,avatar_url,role +Maria Silva,maria@example.com,,user +,no-name@example.com,,user +Bad Email,not-an-email,,user +Duplicate Maria,maria@example.com,,user +Wrong Role,role@example.com,,wizard +Valid Person,valid@example.com,,user diff --git a/spec/fixtures/files/users-wrong-header.csv b/spec/fixtures/files/users-wrong-header.csv new file mode 100644 index 000000000..2387e6d7c --- /dev/null +++ b/spec/fixtures/files/users-wrong-header.csv @@ -0,0 +1,2 @@ +nome,e_mail +Maria,maria@example.com diff --git a/spec/fixtures/files/users.csv b/spec/fixtures/files/users.csv new file mode 100644 index 000000000..1c4b384aa --- /dev/null +++ b/spec/fixtures/files/users.csv @@ -0,0 +1,4 @@ +full_name,email,avatar_url,role +Maria Silva,maria@example.com,https://example.com/maria.png,user +João Souza,joao@example.com,,user +Ada Admin,ada.admin@example.com,,admin diff --git a/spec/fixtures/files/users.xlsx b/spec/fixtures/files/users.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..9b448def5e922468eecbb43a07dc9b51967069b0 GIT binary patch literal 1729 zcmWIWW@Zs#U|`^2I9{O~o0qtQ_aBg_%f!GS45Xu-^Ycnl^Gf1FDhpDJWA!R>bJk8g z>vz~dpe6q3r>$LciySyq-!{%yF3{lLaAWq7uK!O;y?=eP$b8tq6=SjX&CHwmDdx9N zMwT6LQTs7%z3W-YD$y+;Yfs0Q_h02rpK>sJ!@=a!{T?;DGs}*59Pzpqe7b$kKWb05PFwi8$4U0ey{R1wW(b(@cHE5I==1QvkE{3p9Q7%Zd|j{iY3u*Z z?J@hRKQjiP1lNpPue;X)gGrr{fq@%H#}}pM6zl7O$lmh@`I-!P*dByOR!whYPv&Xv zHhh@%f$>=w8{@Uq*31AD zE}2{BoLJN#%6uiX@~G#r5}{uQS*P(xvwSh!CH&m+tHtRF6P;(9$^MKyW>R%{ZpdPj z6W=TA9{ju9u=b>s?g8^%4A<82{olCqHCtb5D9k&+v-OdS5!jfIaqOQ_A#-s_jXOWA^AZ zDZXln`1Hm+bn913m8R*=y8k~uDD+{LtJsmcZ zqtx;9tNPnG{a&T?ektVLm;ERD>HUPe!l>Si`rDMf5$HV)pf{x;-h@OTvS*=@d6En0 zV_;-T7H!Y8?eJJ0=kg--6*JG}l7(|yPwx2gR@SD2m4BYor^)(DZ0yeJyc4M?33HnG zRN+L{^}cP>3v$oRw&mzMwJob`nu^Ymf;FeO&uT3bm2*3X%=OF3ro;kmtRS_shzzOJ8m6vPiYUhpWCY z*q{3^?zGQB%QI?=pG541LbF9IrI}g_%UNqH>vrr~c%<@zsK?@s3*NtY(e#`>`TiZb zi<39R?{0dxcm4C8okjgzF=<&>64SERFv-`w?b~@vDdex3>dh3BU1?k`%WU3fYkxRU zoXvNn&CY4-8@AJis{0l=c`qz{o4bPh_~(B&cCKE?qszL^`L@#2+is_N*;*|>+oa$1 z3i~(d-6RjimI;EVYK2&D37pE4l6GD@Dd5_P%^^BFq*{F05+{qi5{_U@(M{ntO!d^c zx-st~$Es;Frn(*dqAIxC`styiR*9SbO<8ko_l%1wRl@uJOU&hSTdsM<{qxt%6Sqv{ z0~dR)31@!RbRksegVU3n0+rL8V#6G!O%<{dv*=_IO}Q{pZ>isse=pyvn^{*@-1xBQ zV}5`)Ba;X-?s66wDPYh5j3un4F1i8eB@aY9149F&KF|oHatK``dfrB8WCND@XgM8S vGkT6eXnqUPj3fV`n}ePp5$0TDhC2cyc?Nj1vVl~x0--C=mubM{%)kHuA+f@x literal 0 HcmV?d00001 diff --git a/spec/jobs/process_user_import_job_spec.rb b/spec/jobs/process_user_import_job_spec.rb new file mode 100644 index 000000000..74a9a0381 --- /dev/null +++ b/spec/jobs/process_user_import_job_spec.rb @@ -0,0 +1,150 @@ +require "rails_helper" + +RSpec.describe ProcessUserImportJob do + def import_for(file_name, administrator: create(:user, :admin)) + import = UserImport.new(administrator: administrator) + import.file.attach( + io: Rails.root.join("spec/fixtures/files/#{file_name}").open, + filename: file_name + ) + import.save! + import + end + + def run(import) + described_class.perform_now(import.id) + import.reload + end + + describe "a clean file" do + it "creates one user per row" do + import = import_for("users.csv") + + expect { run(import) }.to change(User, :count).by(3) + end + + it "takes the role from the file" do + run(import_for("users.csv")) + + expect(User.find_by(email_address: "ada.admin@example.com")).to be_admin + expect(User.find_by(email_address: "maria@example.com")).to be_user + end + + it "records what it did" do + import = run(import_for("users.csv")) + + expect(import).to have_attributes( + status: "completed", total_rows: 3, processed_rows: 3, created_users: 3, rejected_rows: 0 + ) + expect(import.started_at).to be_present + expect(import.finished_at).to be_present + end + + it "works the same way from an XLSX" do + import = import_for("users.xlsx") + + expect { run(import) }.to change(User, :count).by(3) + expect(import.status).to eq("completed") + end + + it "gives imported people an unguessable password they have to reset" do + run(import_for("users.csv")) + user = User.find_by(email_address: "maria@example.com") + + expect(user.authenticate("")).to be(false) + expect(user.password_digest).to be_present + end + end + + describe "a file with problems" do + subject(:import) { run(import_for("users-with-problems.csv")) } + + it "keeps going instead of stopping at the first bad row" do + expect(import.created_users).to eq(2) + end + + it "counts every row it looked at" do + expect(import).to have_attributes(total_rows: 6, processed_rows: 6, rejected_rows: 4) + end + + it "finishes in a status that says so" do + expect(import.status).to eq("completed_with_errors") + end + + it "records which line failed and why" do + rejected = import.row_errors.order(:row_number) + + expect(rejected.map(&:row_number)).to eq([3, 4, 5, 6]) + expect(rejected.map(&:email_address)).to include("not-an-email", "maria@example.com") + expect(rejected.flat_map(&:messages)).to all(be_present) + end + + it "rejects a second row carrying an email already used earlier in the file" do + expect(import.row_errors.map(&:email_address)).to include("maria@example.com") + expect(User.where(email_address: "maria@example.com").count).to eq(1) + end + + it "rejects a role it does not recognise" do + expect(User.find_by(email_address: "role@example.com")).to be_nil + end + end + + describe "a file it cannot read at all" do + subject(:import) { run(import_for("users-wrong-header.csv")) } + + it "fails as a whole" do + expect(import.status).to eq("failed") + end + + it "says why" do + expect(import.failure_reason).to be_present + end + + it "creates nobody" do + administrator = create(:user, :admin) + + expect { run(import_for("users-wrong-header.csv", administrator: administrator)) } + .not_to change(User, :count) + end + end + + describe "running the same import twice" do + it "does not create the same people again" do + import = import_for("users.csv") + run(import) + + expect { run(import) }.not_to change(User, :count) + end + + it "leaves counters describing the second run, not the sum of both" do + import = import_for("users.csv") + run(import) + run(import) + + expect(import.processed_rows).to eq(3) + expect(import.row_errors.count).to eq(3) + end + end + + describe "what it broadcasts" do + def import_broadcasts(import) + ActionCable.server.pubsub.broadcasts(import.stream_name) + end + + def counter_broadcasts + ActionCable.server.pubsub.broadcasts(UserCounters.stream_for("en")) + end + + it "reports progress while it works" do + import = import_for("users.csv") + + expect { run(import) }.to change { import_broadcasts(import).size }.by_at_least(1) + end + + it "refreshes the dashboard totals once, not once per row" do + import = import_for("users.csv") + + expect { run(import) }.to change(counter_broadcasts, :size).by(1) + end + end +end diff --git a/spec/models/user_import_parser_spec.rb b/spec/models/user_import_parser_spec.rb new file mode 100644 index 000000000..d3419b9e6 --- /dev/null +++ b/spec/models/user_import_parser_spec.rb @@ -0,0 +1,76 @@ +require "rails_helper" + +RSpec.describe UserImportParser do + def parser_for(name, format:) + described_class.new(path: Rails.root.join("spec/fixtures/files/#{name}").to_s, format: format) + end + + describe "reading a CSV" do + subject(:parser) { parser_for("users.csv", format: :csv) } + + it "counts the data rows, not the header" do + expect(parser.row_count).to eq(3) + end + + it "yields each row with its line number" do + numbers = [] + parser.each_row { |number, _attributes| numbers << number } + + expect(numbers).to eq([2, 3, 4]) + end + + it "normalises what it reads" do + first = nil + parser.each_row { |_number, attributes| first ||= attributes } + + expect(first).to eq( + full_name: "Maria Silva", + email_address: "maria@example.com", + avatar_url: "https://example.com/maria.png", + role: "user" + ) + end + + it "reads a blank cell as nothing rather than an empty string" do + rows = [] + parser.each_row { |_number, attributes| rows << attributes } + + expect(rows.second[:avatar_url]).to be_nil + end + end + + describe "reading an XLSX" do + subject(:parser) { parser_for("users.xlsx", format: :xlsx) } + + it "counts the data rows" do + expect(parser.row_count).to eq(3) + end + + it "reads the same shape as the CSV" do + rows = [] + parser.each_row { |_number, attributes| rows << attributes } + + expect(rows.first[:full_name]).to eq("Maria Silva") + expect(rows.last[:role]).to eq("admin") + expect(rows.pluck(:email_address)) + .to eq(%w[maria@example.com joao@example.com ada.admin@example.com]) + end + end + + describe "a file it cannot use" do + it "refuses a header without the columns it needs" do + parser = parser_for("users-wrong-header.csv", format: :csv) + + expect { parser.row_count }.to raise_error(described_class::UnreadableFile) + end + end + + describe "the row ceiling" do + it "refuses a file with more rows than the limit" do + stub_const("#{described_class}::MAX_ROWS", 2) + parser = parser_for("users.csv", format: :csv) + + expect { parser.row_count }.to raise_error(described_class::TooManyRows) + end + end +end diff --git a/spec/models/user_import_spec.rb b/spec/models/user_import_spec.rb new file mode 100644 index 000000000..bdda3fa53 --- /dev/null +++ b/spec/models/user_import_spec.rb @@ -0,0 +1,81 @@ +require "rails_helper" + +RSpec.describe UserImport do + def upload(name, content_type: "text/csv") + Rack::Test::UploadedFile.new(Rails.root.join("spec/fixtures/files/#{name}"), content_type) + end + + def build_import(file_name, **) + build(:user_import, **).tap { |import| import.file.attach(upload(file_name)) } + end + + describe "the attached file" do + it "accepts a CSV" do + expect(build_import("users.csv")).to be_valid + end + + it "accepts an XLSX" do + expect(build_import("users.xlsx")).to be_valid + end + + it "requires a file" do + expect(build(:user_import)).not_to be_valid + end + + it "refuses an extension it cannot parse" do + import = build(:user_import) + import.file.attach(upload("avatar.png", content_type: "image/png")) + + expect(import).not_to be_valid + end + + # Renaming a binary to .csv should not get it past the gate. + it "refuses content that does not match the extension" do + import = build(:user_import) + import.file.attach( + Rack::Test::UploadedFile.new(Rails.root.join("spec/fixtures/files/avatar.png"), "text/csv", + original_filename: "users.csv") + ) + + expect(import).not_to be_valid + end + end + + describe "#format" do + it "reads the format from the extension" do + expect(build_import("users.csv").format).to eq(:csv) + expect(build_import("users.xlsx").format).to eq(:xlsx) + end + end + + describe "progress" do + it "is zero before anything has been counted" do + expect(build(:user_import, total_rows: 0, processed_rows: 0).progress_percentage).to be_zero + end + + it "reports how far through the rows it is" do + import = build(:user_import, total_rows: 8, processed_rows: 2) + + expect(import.progress_percentage).to eq(25) + end + + it "never reports more than complete" do + import = build(:user_import, total_rows: 4, processed_rows: 9) + + expect(import.progress_percentage).to eq(100) + end + end + + describe "#finished?" do + it "is false while the work is still ahead or underway" do + expect(build(:user_import, status: :pending)).not_to be_finished + expect(build(:user_import, status: :processing)).not_to be_finished + end + + it "is true once it has come to rest, however it ended" do + expect(build(:user_import, status: :completed)).to be_finished + expect(build(:user_import, status: :completed_with_errors)).to be_finished + expect(build(:user_import, status: :failed)).to be_finished + end + end +end diff --git a/spec/requests/admin/user_imports_spec.rb b/spec/requests/admin/user_imports_spec.rb new file mode 100644 index 000000000..68ea1bd8f --- /dev/null +++ b/spec/requests/admin/user_imports_spec.rb @@ -0,0 +1,117 @@ +require "rails_helper" + +RSpec.describe "Admin user imports" do + let(:administrator) { create(:user, :admin) } + + def upload(name, content_type: "text/csv") + Rack::Test::UploadedFile.new(Rails.root.join("spec/fixtures/files/#{name}"), content_type) + end + + before { sign_in administrator } + + describe "GET /admin/user_imports" do + it "lists previous imports, newest first" do + older = create(:user_import, :with_csv, administrator: administrator, created_at: 2.days.ago) + newer = create(:user_import, :with_csv, administrator: administrator) + + get admin_user_imports_path + + expect(response).to have_http_status(:ok) + expect(response.body.index(admin_user_import_path(newer))) + .to be < response.body.index(admin_user_import_path(older)) + end + end + + describe "POST /admin/user_imports" do + it "accepts a CSV and schedules the work" do + expect { post admin_user_imports_path, params: { user_import: { file: upload("users.csv") } } } + .to change(UserImport, :count).by(1) + .and have_enqueued_job(ProcessUserImportJob) + + expect(response).to redirect_to(admin_user_import_path(UserImport.last)) + end + + it "records who asked for it" do + post admin_user_imports_path, params: { user_import: { file: upload("users.csv") } } + + expect(UserImport.last.administrator).to eq(administrator) + end + + it "refuses a file it cannot parse, without scheduling anything" do + expect do + post admin_user_imports_path, + params: { user_import: { file: upload("avatar.png", content_type: "image/png") } } + end.not_to have_enqueued_job(ProcessUserImportJob) + + expect(response).to have_http_status(:unprocessable_content) + end + end + + describe "GET /admin/user_imports/:id" do + it "shows the progress and the rejected rows" do + import = create(:user_import, :with_csv, administrator: administrator, + status: :completed_with_errors, total_rows: 2, + processed_rows: 2, created_users: 1, rejected_rows: 1) + import.row_errors.create!(row_number: 3, email_address: "bad@example.com", messages: ["Email is invalid"]) + + get admin_user_import_path(import) + + expect(response).to have_http_status(:ok) + expect(response.body).to include("bad@example.com") + expect(response.body).to include("Email is invalid") + end + end + + describe "GET /admin/user_imports/template.csv" do + it "offers a template carrying the expected header" do + get template_admin_user_imports_path(format: :csv) + + expect(response).to have_http_status(:ok) + expect(response.body).to include("full_name,email,avatar_url,role") + expect(response.headers["Content-Disposition"]).to include("attachment") + end + + it "is a file the importer itself can read back" do + get template_admin_user_imports_path(format: :csv) + path = Rails.root.join("tmp/template-roundtrip.csv") + path.write(response.body) + + parser = UserImportParser.new(path: path.to_s, format: :csv) + + expect(parser.row_count).to be_positive + ensure + path&.delete if path&.exist? + end + end + + describe "GET /admin/user_imports/:id/rejected_rows.csv" do + it "neutralises a cell that a spreadsheet would treat as a formula" do + import = create(:user_import, :with_csv, administrator: administrator) + import.row_errors.create!(row_number: 2, email_address: "=cmd|'/c calc'!A1", + messages: ["@SUM(1+1)"]) + + get rejected_rows_admin_user_import_path(import, format: :csv) + + expect(response.body).to include("'=cmd") + expect(response.body).to include("'@SUM") + expect(response.body).not_to match(/^=cmd/) + end + end + + describe "authorization" do + it "keeps a regular user out of every action" do + sign_in create(:user) + import = create(:user_import, :with_csv, administrator: administrator) + + get admin_user_imports_path + expect(response).to redirect_to(profile_path) + + get admin_user_import_path(import) + expect(response).to redirect_to(profile_path) + + expect do + post admin_user_imports_path, params: { user_import: { file: upload("users.csv") } } + end.not_to change(UserImport, :count) + end + end +end diff --git a/spec/system/screenshots_spec.rb b/spec/system/screenshots_spec.rb index d89b7c1ae..74152f862 100644 --- a/spec/system/screenshots_spec.rb +++ b/spec/system/screenshots_spec.rb @@ -28,4 +28,32 @@ expect(page).to have_text("Ada Lovelace") page.save_screenshot("tmp/shot-profile.png", full: true) end + + it "captures the import screens" do + administrator = create(:user, :admin, full_name: "Ada Lovelace", email_address: "ada@example.com", + password: "a-sufficiently-long-password") + + import = UserImport.new(administrator: administrator, status: :completed_with_errors, + total_rows: 6, processed_rows: 6, created_users: 4, rejected_rows: 2) + import.file.attach(io: Rails.root.join("spec/fixtures/files/users-with-problems.csv").open, + filename: "users-with-problems.csv") + import.save! + import.row_errors.create!(row_number: 3, email_address: nil, messages: ["Full name can't be blank"]) + import.row_errors.create!(row_number: 5, email_address: "maria@example.com", + messages: ["Email address has already been taken"]) + + visit new_session_path + fill_in "Email address", with: administrator.email_address + fill_in "Password", with: "a-sufficiently-long-password" + click_on "Sign in" + expect(page).to have_text("Dashboard") + + visit admin_user_imports_path + expect(page).to have_text("Imports") + page.save_screenshot("tmp/shot-imports.png", full: true) + + visit admin_user_import_path(import) + expect(page).to have_text("maria@example.com") + page.save_screenshot("tmp/shot-import-detail.png", full: true) + end end From 70fc7dd697211793a3093fd98c3b59d3981c5ddb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B4nio=20Neto?= Date: Fri, 4 Sep 2026 07:13:40 -0300 Subject: [PATCH 10/33] feat: sweep the application for security, vector by vector The vectors each feature owns were already covered next to that feature. What was left belonged to the application as a whole, and most of it was still sitting at generator defaults. Content Security Policy: the shipped initializer was commented out, so injected markup had nothing standing in its way beyond escaping. The policy now allows scripts only from this origin plus a per-response nonce -- which the importmap tags carry on their own -- names the two Google Fonts hosts explicitly, allows any https image because remote avatars are a feature, and adds the websocket origin Action Cable needs. frame-ancestors 'none' keeps the destructive admin forms out of a frame. Production: assume_ssl and force_ssl were commented out, which left the session cookie without its secure flag behind a TLS-terminating proxy. Both are on, the health check is excluded from the redirect, and the switch stays readable so the production image can still be smoke-tested over plain http. GET /admin/users/:id routed to an action that does not exist, so a path the application itself advertises answered 404. The route is gone. The specs cover the vectors by name: forged destructive requests, a hostile name typed into the form and the same name arriving through an import, the flags on the session cookie, the headers, credentials kept out of the log, and a spreadsheet too heavy or too long to accept. The production settings are read by booting a short-lived production process, since Rails boots one environment per process. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BX7yhokJ4UWtAHDnBmr46W --- config/environments/production.rb | 21 +- .../initializers/content_security_policy.rb | 65 ++-- config/initializers/session_store.rb | 11 + config/routes.rb | 5 +- spec/requests/security_spec.rb | 282 ++++++++++++++++++ 5 files changed, 349 insertions(+), 35 deletions(-) create mode 100644 config/initializers/session_store.rb create mode 100644 spec/requests/security_spec.rb diff --git a/config/environments/production.rb b/config/environments/production.rb index 737611a3a..c48aa9796 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -24,14 +24,19 @@ # 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" } } } + # Access arrives through a SSL-terminating proxy, so the app trusts the + # forwarded scheme rather than looking at its own socket. + config.assume_ssl = true + + # Redirects http to https, sends Strict-Transport-Security, and flags every + # cookie -- the signed session cookie included -- as secure. The switch + # exists so the production image can still be smoke-tested over plain http + # locally; leave it alone anywhere real. + config.force_ssl = ENV.fetch("FORCE_SSL", "true") == "true" + + # The container healthcheck speaks http inside the network and must not be + # answered with a redirect. + 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] diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index d51d71397..6f329c921 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -1,29 +1,42 @@ # 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 +# The policy is deliberately tight: everything the pages need is served from +# this origin, except the web font and the remote avatar URLs a user may point +# at. Inline scripts are allowed only with the per-request nonce, which the +# importmap tags carry automatically. +Rails.application.configure do + config.content_security_policy do |policy| + policy.default_src :self + policy.base_uri :self + policy.form_action :self + policy.object_src :none + # Nothing here is meant to be framed, which also covers clickjacking on the + # destructive admin forms. + policy.frame_ancestors :none -# 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 + policy.script_src :self + # Google Fonts serves the stylesheet from one host and the font files from + # another; both are needed for the typeface to load. + policy.style_src :self, "https://fonts.googleapis.com" + policy.font_src :self, :data, "https://fonts.gstatic.com" + # Remote avatars are an advertised feature, so any https image is allowed. + # The scheme itself is still validated on the model: javascript: and data: + # URLs never reach an img src. + policy.img_src :self, :https, :data + + # Action Cable connects back to this same origin over a websocket, which + # older CSP implementations do not read out of `self`. + # The block runs against the controller when there is one and against the + # request otherwise, so it asks for the request either way. + policy.connect_src :self, lambda { + http = respond_to?(:request) ? request : self + "#{http.ssl? ? "wss" : "ws"}://#{http.host_with_port}" + } + end + + # A fresh nonce per response. The alternative Rails suggests -- deriving it + # from the session id -- is friendlier to caching but hands every page of a + # session the same nonce, which is exactly what a nonce is meant not to be. + config.content_security_policy_nonce_generator = ->(_request) { SecureRandom.base64(16) } + config.content_security_policy_nonce_directives = %w[script-src] +end diff --git a/config/initializers/session_store.rb b/config/initializers/session_store.rb new file mode 100644 index 000000000..d07b1b2c8 --- /dev/null +++ b/config/initializers/session_store.rb @@ -0,0 +1,11 @@ +# Be sure to restart your server when you modify this file. + +# Rails already defaults to these, but the flags on the cookie that carries a +# signed-in session are worth stating out loud rather than inheriting: no +# script may read it, and it does not ride along on a cross-site request. +# `secure` is not set here -- ActionDispatch::SSL adds it in production, where +# `force_ssl` is on, so development over plain http keeps working. +Rails.application.config.session_store :cookie_store, + key: "_user_management_session", + httponly: true, + same_site: :lax diff --git a/config/routes.rb b/config/routes.rb index dce97a2c6..54af3d9e0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -9,7 +9,10 @@ namespace :admin do get "dashboard", to: "dashboard#show" - resources :users + # No `show`: the list carries everything there is to know about a person, + # and a route whose action does not exist answers 404 from a path the + # application itself advertises. + resources :users, except: :show resources :user_imports, only: %i[index new create show] do get :template, on: :collection diff --git a/spec/requests/security_spec.rb b/spec/requests/security_spec.rb new file mode 100644 index 000000000..d87a495ac --- /dev/null +++ b/spec/requests/security_spec.rb @@ -0,0 +1,282 @@ +require "rails_helper" +require "English" +require "shellwords" + +# A sweep by attack vector rather than by screen. The vectors each feature +# already owns -- SQL injection in the search, mass assignment of `role`, IDOR +# on the profile, a lying MIME type on an upload, an unsafe avatar URL, CSV +# injection in the rejected-rows report, the last-administrator race -- are +# covered next to those features. What is left here is what belongs to the +# application as a whole. +RSpec.describe "Security" do + let(:administrator) { create(:user, :admin, full_name: "Ada Admin") } + + # Forgery protection is off in the test environment so that ordinary request + # specs can post without a token. These examples need the real thing, so they + # turn it back on around themselves. + def with_forgery_protection + original = ActionController::Base.allow_forgery_protection + ActionController::Base.allow_forgery_protection = true + yield + ensure + ActionController::Base.allow_forgery_protection = original + end + + describe "cross-site request forgery" do + it "refuses a destructive admin request that carries no token" do + victim = create(:user) + sign_in administrator + + with_forgery_protection do + delete admin_user_path(victim) + end + + expect(response).to have_http_status(:unprocessable_content) + expect(User.exists?(victim.id)).to be(true) + end + + it "refuses to delete an account from a forged form" do + user = create(:user) + sign_in user + + with_forgery_protection do + delete profile_path + end + + expect(response).to have_http_status(:unprocessable_content) + expect(User.exists?(user.id)).to be(true) + end + + it "does not destroy anything over GET" do + victim = create(:user) + sign_in administrator + + get "/admin/users/#{victim.id}" + + expect(response).to have_http_status(:not_found) + expect(User.exists?(victim.id)).to be(true) + end + end + + describe "cross-site scripting" do + let(:payload) { "" } + + it "escapes a name typed into the admin form wherever it is echoed back" do + sign_in administrator + + post admin_users_path, params: { + user: { full_name: payload, email_address: "payload@example.com", password: "a-sufficiently-long-password" } + } + follow_redirect! + + expect(response.body).not_to include(payload) + expect(response.body).to include("<script>") + end + + it "escapes a name that arrived through an import" do + import_row("#{payload},imported@example.com") + + sign_in administrator + get admin_users_path, params: { query: "imported@example.com" } + + expect(User.find_by(email_address: "imported@example.com").full_name).to eq(payload) + expect(response.body).not_to include(payload) + expect(response.body).to include("<script>") + end + + it "escapes a name echoed inside a flash message" do + victim = create(:user, full_name: payload) + sign_in administrator + + delete admin_user_path(victim) + follow_redirect! + + expect(response.body).not_to include(payload) + expect(response.body).to include("<script>") + end + end + + describe "the session cookie" do + it "is signed, unreadable by scripts and not sent across sites" do + sign_in administrator + + cookie = cookie_header_for("session_id") + + expect(cookie.downcase).to include("httponly") + expect(cookie.downcase).to include("samesite=lax") + # The value is the signed payload, never the bare primary key. + expect(cookie).not_to include("session_id=#{Session.last.id};") + end + + it "is dropped on sign out, so a stolen cookie stops working" do + sign_in administrator + session_record = Session.last + + delete session_path + + expect(Session.exists?(session_record.id)).to be(false) + + get profile_path + expect(response).to redirect_to(new_session_path) + end + end + + describe "response headers" do + before { sign_in administrator } + + it "sends a content security policy that keeps injected markup inert" do + get admin_dashboard_path + + policy = response.headers["content-security-policy"] + + expect(policy).to include("default-src 'self'", "object-src 'none'") + expect(policy).to include("base-uri 'self'", "form-action 'self'") + # Nothing here is meant to be framed, which is also what protects the + # destructive admin forms from being clicked through an overlay. + expect(policy).to include("frame-ancestors 'none'") + end + + it "allows an inline script only with the nonce of that response" do + get admin_dashboard_path + first = response.headers["content-security-policy"][/'nonce-([^']+)'/, 1] + + get admin_dashboard_path + second = response.headers["content-security-policy"][/'nonce-([^']+)'/, 1] + + expect(first).to be_present + expect(second).not_to eq(first) + expect(response.body).to include(%(nonce="#{second}")) + end + + it "does not let a browser sniff a response into another content type" do + get admin_dashboard_path + + expect(response.headers["x-content-type-options"]).to eq("nosniff") + expect(response.headers["referrer-policy"]).to eq("strict-origin-when-cross-origin") + end + end + + describe "what reaches the log" do + it "keeps credentials and reset tokens out of it" do + user = create(:user, email_address: "logged@example.com") + + written = capturing_the_log do + post session_path, params: { email_address: user.email_address, password: "a-sufficiently-long-password" } + end + + expect(written).not_to include("a-sufficiently-long-password") + expect(written).not_to include("logged@example.com") + expect(written).to include("[FILTERED]") + end + end + + # The environment under test is `test`, and Rails only boots one environment + # per process, so the production settings are read by booting a short-lived + # production process rather than by asserting on a file's text. + describe "the production environment" do + it "forces SSL, which is what makes every cookie secure" do + settings = production_settings + + expect(settings["assume_ssl"]).to be(true) + expect(settings["force_ssl"]).to be(true) + # The container healthcheck speaks plain http from inside the network and + # would fail against a redirect. + expect(settings["health_check_redirected"]).to be(false) + expect(settings["other_paths_redirected"]).to be(true) + end + end + + describe "an oversized spreadsheet" do + it "is refused before the file is attached when it is too heavy" do + sign_in administrator + stub_const("UserImport::MAX_FILE_BYTES", 64) + + post admin_user_imports_path, params: { + user_import: { file: fixture_file_upload("users.csv", "text/csv") } + } + + expect(response).to have_http_status(:unprocessable_content) + expect(UserImport.count).to be_zero + expect(enqueued_jobs).to be_empty + end + + it "fails the import, rather than the worker, when it holds too many rows" do + import = create(:user_import, :with_csv, administrator: administrator) + stub_const("UserImportParser::MAX_ROWS", 1) + + ProcessUserImportJob.perform_now(import.id) + + expect(import.reload).to be_failed + expect(import.failure_reason).to be_present + # The ceiling is reached while the rows are being counted, before the + # first one is imported, so an oversized file creates nobody at all + # rather than half a directory. + expect(User.where(role: :user)).to be_empty + end + end + + private + + def production_settings + JSON.parse(booted_in_production(<<~RUBY).lines.last.to_s) + exclude = Rails.application.config.ssl_options.dig(:redirect, :exclude) + request = ->(path) { ActionDispatch::Request.new("PATH_INFO" => path) } + puts({ + assume_ssl: Rails.application.config.assume_ssl, + force_ssl: Rails.application.config.force_ssl, + health_check_redirected: !exclude.call(request.call("/up")), + other_paths_redirected: !exclude.call(request.call("/admin/users")) + }.to_json) + RUBY + end + + def booted_in_production(script) + # The original env, not an unbundled one: the child runs the same Gemfile + # from the same bundle path, only in another RAILS_ENV. + output = Bundler.with_original_env do + command = "RAILS_ENV=production SECRET_KEY_BASE_DUMMY=1 #{Rails.root.join("bin/rails")} runner" + `#{command} #{Shellwords.escape(script)} 2>&1` + end + raise "could not boot the production environment: #{output}" unless $CHILD_STATUS.success? + + output + end + + def cookie_header_for(name) + Array(response.headers["set-cookie"]) + .flat_map { |header| header.split("\n") } + .find { |header| header.start_with?("#{name}=") } + end + + # One row through the whole import path, so an example can ask what the + # importer does with a hostile cell without restating the plumbing. + def import_row(row) + import = build(:user_import, administrator: administrator) + import.file.attach( + io: StringIO.new("full_name,email\n#{row}\n"), + filename: "hostile.csv", + content_type: "text/csv" + ) + import.save! + ProcessUserImportJob.perform_now(import.id) + end + + # Swaps in a logger that writes where the example can read it. The broadcast + # logger Rails installs writes to more than one place, so both it and the + # controller's own logger are replaced. + def capturing_the_log + buffer = StringIO.new + logger = ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new(buffer)) + original_rails = Rails.logger + original_controller = ActionController::Base.logger + Rails.logger = logger + ActionController::Base.logger = logger + + yield + + buffer.string + ensure + Rails.logger = original_rails + ActionController::Base.logger = original_controller + end +end From dffa9e970fc6f7cf3a61ee73a3933208016ff555 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B4nio=20Neto?= Date: Fri, 4 Sep 2026 07:21:32 -0300 Subject: [PATCH 11/33] feat: make the application deployable, and prove the image boots config/deploy.yml describes the deployment Kamal 2 performs: the web role running the image's `final` stage behind kamal-proxy, a second role running bin/jobs so Solid Queue is a process of its own rather than a thread inside Puma, a PostgreSQL accessory, and a named volume for the uploaded avatars, which Active Storage keeps on disk. Hostnames and account names are placeholders; secrets are named in .kamal/secrets and read from the deploying machine's environment. Building and running the image found a real fault: db:prepare runs the seeds while the first container boots, and the seed file aborted in production when SEED_ADMIN_PASSWORD was missing, which crash-looped the deploy. Seeds no longer abort. In production they create one administrator from SEED_ADMIN_EMAIL and SEED_ADMIN_PASSWORD, or say they have nothing to do -- the demonstration roster, thirteen accounts sharing one password, has no business on a real installation. Verified against the running image rather than by reading it: /up answers 200, the assets are the digest-stamped ones compiled at build time, the session cookie carries secure, httponly and samesite=lax, the response sends HSTS and the content security policy, and bin/jobs starts its supervisor, worker, dispatcher and scheduler. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BX7yhokJ4UWtAHDnBmr46W --- .kamal/secrets | 17 +++++ config/deploy.yml | 103 ++++++++++++++++++++++++++++++ config/environments/production.rb | 8 ++- db/seeds.rb | 58 ++++++++++++----- 4 files changed, 167 insertions(+), 19 deletions(-) create mode 100644 .kamal/secrets create mode 100644 config/deploy.yml diff --git a/.kamal/secrets b/.kamal/secrets new file mode 100644 index 000000000..908cca6c1 --- /dev/null +++ b/.kamal/secrets @@ -0,0 +1,17 @@ +# Read by Kamal at deploy time and passed to the containers. Nothing here is a +# secret itself: each line names where the value comes from, and the values +# stay in the environment of whoever runs the deploy (or in a password +# manager, via `kamal secrets fetch`). +# +# export KAMAL_REGISTRY_PASSWORD=... +# export POSTGRES_PASSWORD=... +# +KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD + +# config/master.key is not in the repository; the deploying machine has it. +RAILS_MASTER_KEY=$(cat config/master.key) + +POSTGRES_PASSWORD=$POSTGRES_PASSWORD + +# Only needed on the first deploy, together with SEED_ADMIN_EMAIL: +# SEED_ADMIN_PASSWORD=$SEED_ADMIN_PASSWORD diff --git a/config/deploy.yml b/config/deploy.yml new file mode 100644 index 000000000..0316542a1 --- /dev/null +++ b/config/deploy.yml @@ -0,0 +1,103 @@ +# Kamal 2 deployment. +# +# Every value written as <...> has to be filled in before a real deploy; they +# are hostnames and account names, not secrets. Secrets are read from +# .kamal/secrets, which reads them from the environment or a password manager +# and never from this file. +# +# kamal setup first deploy, installs Docker and the proxy +# kamal deploy subsequent deploys +# kamal app logs -f follow the logs +# kamal app exec -i "bin/rails console" +service: user_management + +image: /user_management + +servers: + # Thruster listens on 80 inside the container: it terminates HTTP/2, gzips + # and serves the digest-stamped assets, and hands the rest to Puma. + web: + - + # Solid Queue runs as its own container, not inside Puma. A slow import must + # not compete with request threads for the same process. + job: + hosts: + - + cmd: bin/jobs + +# kamal-proxy terminates TLS with an automatic Let's Encrypt certificate and +# forwards plain HTTP with X-Forwarded-Proto, which is what `assume_ssl` in +# config/environments/production.rb reads. +proxy: + ssl: true + host: + app_port: 80 + healthcheck: + path: /up + interval: 5 + timeout: 5 + +registry: + username: + password: + - KAMAL_REGISTRY_PASSWORD + +builder: + arch: amd64 + # The image is the one in the Dockerfile's `final` stage: no build tools, no + # development or test gems, non-root. + target: final + +env: + clear: + # The accessory below, reachable by that name on the Kamal network. + POSTGRES_HOST: user_management-postgres + POSTGRES_USER: user_management + POSTGRES_DB: user_management_production + RAILS_MAX_THREADS: 5 + # Solid Queue processes for the job role. + JOB_CONCURRENCY: 1 + # Uncomment on the first deploy to have db:prepare create the first + # administrator; the password comes from the secret below. Without both, + # the seed does nothing and the application still boots. + # SEED_ADMIN_EMAIL: + # SEED_ADMIN_NAME: + secret: + - RAILS_MASTER_KEY + - POSTGRES_PASSWORD + # - SEED_ADMIN_PASSWORD + +# Uploaded avatars live on disk (config.active_storage.service = :local), so +# the directory has to outlive the container. Moving to S3 is a change of one +# line in config/storage.yml plus the credentials; the volume then goes away. +volumes: + - "user_management_storage:/rails/storage" + +# Kamal 2 serves the precompiled assets from here during a deploy, so a browser +# holding the previous page can still fetch the previous digests. +asset_path: /rails/public/assets + +accessories: + postgres: + image: postgres:18 + host: + port: "127.0.0.1:5432:5432" + env: + clear: + POSTGRES_USER: user_management + POSTGRES_DB: user_management_production + secret: + - POSTGRES_PASSWORD + directories: + - data:/var/lib/postgresql/data + +aliases: + console: app exec --interactive --reuse "bin/rails console" + shell: app exec --interactive --reuse "bash" + logs: app logs --follow + dbc: app exec --interactive --reuse "bin/rails dbconsole" + +# The four databases -- primary, cache, queue and cable -- are created and +# migrated by `db:prepare`, which bin/docker-entrypoint runs when the web +# container starts. With more than one web host, run the migration once from a +# pre-deploy hook instead, so two hosts do not race each other. diff --git a/config/environments/production.rb b/config/environments/production.rb index c48aa9796..6c4878aa9 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -34,8 +34,12 @@ # locally; leave it alone anywhere real. config.force_ssl = ENV.fetch("FORCE_SSL", "true") == "true" - # The container healthcheck speaks http inside the network and must not be - # answered with a redirect. + # With `assume_ssl` on, every request already looks like https, so this + # redirect never fires in practice -- the proxy is what sends a plain http + # visitor to https. The exclusion is here for the case where the app is put + # behind something that does not terminate TLS: the container healthcheck + # speaks http from inside the network and must not be answered with a + # redirect. config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } # Log to STDOUT with the current request id as a default log tag. diff --git a/db/seeds.rb b/db/seeds.rb index 0c5e11409..450843a44 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,29 +1,52 @@ # Idempotent seeds: running this repeatedly converges on the same data instead # of piling up duplicates. # -# The demonstration password is only ever allowed outside production. In -# production the seed refuses to invent credentials and expects -# SEED_ADMIN_PASSWORD to be supplied. +# `db:prepare` runs this file, and in production that happens while the first +# container is booting -- so nothing here may abort. A seed that has nothing to +# do says so and lets the application start. -DEMO_PASSWORD = ENV.fetch("SEED_ADMIN_PASSWORD") do - if Rails.env.production? - abort "Set SEED_ADMIN_PASSWORD before seeding production." - else - "password-for-development" - end -end - -def upsert_user!(email_address:, full_name:, role:, locale: "en") +def upsert_user!(email_address:, full_name:, role:, password:, locale: "en") user = User.find_or_initialize_by(email_address: email_address) user.assign_attributes(full_name: full_name, role: role, locale: locale) - user.password = DEMO_PASSWORD if user.new_record? + user.password = password if user.new_record? user.save! user end -upsert_user!(email_address: "admin@example.com", full_name: "Ada Lovelace", role: :admin) -upsert_user!(email_address: "admin.two@example.com", full_name: "Grace Hopper", role: :admin) -upsert_user!(email_address: "user@example.com", full_name: "Maria Silva", role: :user, locale: "pt-BR") +# ── Production ─────────────────────────────────────────────────────────────── +# One administrator, from credentials supplied at deploy time, and nothing +# else. The demonstration roster below shares a single known password, which +# has no business existing on a real installation. +if Rails.env.production? + email_address = ENV["SEED_ADMIN_EMAIL"].presence + password = ENV["SEED_ADMIN_PASSWORD"].presence + + if email_address && password + upsert_user!( + email_address: email_address, + full_name: ENV.fetch("SEED_ADMIN_NAME", "Administrator"), + role: :admin, + password: password + ) + Rails.logger.info { "Seeded the administrator account #{email_address}." } + else + Rails.logger.info do + "Skipping seeds: set SEED_ADMIN_EMAIL and SEED_ADMIN_PASSWORD to create the first administrator." + end + end + + return +end + +# ── Everywhere else ────────────────────────────────────────────────────────── +DEMO_PASSWORD = ENV.fetch("SEED_ADMIN_PASSWORD", "password-for-development") + +upsert_user!(email_address: "admin@example.com", full_name: "Ada Lovelace", + role: :admin, password: DEMO_PASSWORD) +upsert_user!(email_address: "admin.two@example.com", full_name: "Grace Hopper", + role: :admin, password: DEMO_PASSWORD) +upsert_user!(email_address: "user@example.com", full_name: "Maria Silva", + role: :user, password: DEMO_PASSWORD, locale: "pt-BR") [ ["joao.souza@example.com", "João Souza", "pt-BR"], @@ -37,7 +60,8 @@ def upsert_user!(email_address:, full_name:, role:, locale: "en") ["yuki.tanaka@example.com", "Yuki Tanaka", "en"], ["amara.okafor@example.com", "Amara Okafor", "en"] ].each do |email_address, full_name, locale| - upsert_user!(email_address: email_address, full_name: full_name, role: :user, locale: locale) + upsert_user!(email_address: email_address, full_name: full_name, + role: :user, password: DEMO_PASSWORD, locale: locale) end Rails.logger.debug { "Seeded #{User.count} users (#{User.admin.count} administrators)." } From cfcd264c5862cfd3effa29ae18a8513fe6c8bf2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B4nio=20Neto?= Date: Fri, 4 Sep 2026 07:38:35 -0300 Subject: [PATCH 12/33] fix: give the password reset screens the same design and languages The two password views and both mailer templates were still the generated scaffold: hardcoded English inside the markup, and the framework's default blue on a page that looks nothing like the rest of the application. The missing-translation guard could not catch it, because there were no translation calls to miss. They now use the same panel, field and button classes as the sign-in screen, and every string is a key in the three locales. The reset email gained a heading, a real action button, how long the link lasts and a line for the person who never asked for it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BX7yhokJ4UWtAHDnBmr46W --- app/views/passwords/edit.html.erb | 29 +++++++++++++---------- app/views/passwords/new.html.erb | 26 +++++++++++--------- app/views/passwords_mailer/reset.html.erb | 18 ++++++++++---- app/views/passwords_mailer/reset.text.erb | 8 +++++-- config/locales/en.yml | 19 +++++++++++++++ config/locales/es.yml | 19 +++++++++++++++ config/locales/pt-BR.yml | 19 +++++++++++++++ 7 files changed, 108 insertions(+), 30 deletions(-) diff --git a/app/views/passwords/edit.html.erb b/app/views/passwords/edit.html.erb index 65798f808..b1a22ef5b 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, t(".title") %> -

Update your password

+
+

<%= t(".title") %>

+

<%= t(".subtitle") %>

- <%= 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" do |form| %> +
+ <%= form.label :password, t(".password"), class: "field-label" %> + <%= form.password_field :password, required: true, autofocus: true, + autocomplete: "new-password", maxlength: 72, + aria: { describedby: "password-hint" }, class: "field-input" %> +

<%= t(".password_hint") %>

-
- <%= 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, t(".password_confirmation"), class: "field-label" %> + <%= form.password_field :password_confirmation, required: true, + autocomplete: "new-password", maxlength: 72, 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" %> -
+ <%= form.submit t(".submit"), class: "btn btn-primary w-full" %> <% end %>
diff --git a/app/views/passwords/new.html.erb b/app/views/passwords/new.html.erb index 8360e02f3..989174750 100644 --- a/app/views/passwords/new.html.erb +++ b/app/views/passwords/new.html.erb @@ -1,17 +1,21 @@ -
- <% if alert = flash[:alert] %> -

<%= alert %>

- <% end %> +<% content_for :title, t(".title") %> -

Forgot your password?

+
+

<%= t(".title") %>

+

<%= t(".subtitle") %>

- <%= 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" do |form| %> +
+ <%= form.label :email_address, t(".email_address"), class: "field-label" %> + <%= form.email_field :email_address, required: true, autofocus: true, + autocomplete: "username", value: params[:email_address], 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" %> -
+ <%= form.submit t(".submit"), class: "btn btn-primary w-full" %> <% end %> + +

+ <%= t(".remembered") %> + <%= link_to t(".sign_in"), new_session_path, class: "text-accent-strong hover:underline" %> +

diff --git a/app/views/passwords_mailer/reset.html.erb b/app/views/passwords_mailer/reset.html.erb index 1b0915419..a060b94e7 100644 --- a/app/views/passwords_mailer/reset.html.erb +++ b/app/views/passwords_mailer/reset.html.erb @@ -1,6 +1,16 @@ -

- You can reset your password on - <%= link_to "this password reset page", edit_password_url(@user.password_reset_token) %>. +

<%= t(".heading") %>

- This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>. +

+ <%= t(".body") %> +

+ +

+ <%= link_to t(".action"), edit_password_url(@user.password_reset_token), + style: "display: inline-block; padding: 10px 18px; border-radius: 8px; " \ + "background: #2f5d47; color: #ffffff; font-size: 15px; text-decoration: none;" %> +

+ +

+ <%= t(".expires", duration: distance_of_time_in_words(0, @user.password_reset_token_expires_in)) %> + <%= t(".ignore") %>

diff --git a/app/views/passwords_mailer/reset.text.erb b/app/views/passwords_mailer/reset.text.erb index aecee82c4..bccc57692 100644 --- a/app/views/passwords_mailer/reset.text.erb +++ b/app/views/passwords_mailer/reset.text.erb @@ -1,4 +1,8 @@ -You can reset your password on +<%= t(".heading") %> + +<%= t(".body") %> + <%= edit_password_url(@user.password_reset_token) %> -This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>. +<%= t(".expires", duration: distance_of_time_in_words(0, @user.password_reset_token_expires_in)) %> +<%= t(".ignore") %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 6896f9ada..bb2154ceb 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -190,10 +190,29 @@ en: reset: "Your password has been reset." mismatch: "Passwords did not match." invalid_token: "That password reset link is invalid or has expired." + new: + title: "Forgot your password?" + subtitle: "We will email you a link to choose a new one." + email_address: "Email address" + submit: "Send reset instructions" + remembered: "Remembered it?" + sign_in: "Sign in" + edit: + title: "Choose a new password" + subtitle: "Signing in again everywhere will need the new password." + password: "New password" + password_hint: "At least 8 characters." + password_confirmation: "Confirm new password" + submit: "Save password" passwords_mailer: reset: subject: "Reset your password" + heading: "Reset your password" + body: "Use the link below to choose a new password for your Roster account." + action: "Choose a new password" + expires: "The link expires in %{duration}." + ignore: "If you did not ask for this, nothing has changed and you can ignore this message." activerecord: errors: diff --git a/config/locales/es.yml b/config/locales/es.yml index 0120aebac..638f13ea1 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -190,10 +190,29 @@ es: reset: "Tu contraseña se ha restablecido." mismatch: "Las contraseñas no coinciden." invalid_token: "Ese enlace de restablecimiento no es válido o ha caducado." + new: + title: "¿Olvidaste tu contraseña?" + subtitle: "Te enviaremos por correo un enlace para elegir otra." + email_address: "Correo electrónico" + submit: "Enviar instrucciones" + remembered: "¿Ya la recuerdas?" + sign_in: "Iniciar sesión" + edit: + title: "Elige una contraseña nueva" + subtitle: "Necesitarás la contraseña nueva para volver a iniciar sesión en todos tus dispositivos." + password: "Contraseña nueva" + password_hint: "Al menos 8 caracteres." + password_confirmation: "Confirma la contraseña nueva" + submit: "Guardar contraseña" passwords_mailer: reset: subject: "Restablece tu contraseña" + heading: "Restablece tu contraseña" + body: "Usa el enlace de abajo para elegir una contraseña nueva para tu cuenta de Roster." + action: "Elegir contraseña nueva" + expires: "El enlace caduca en %{duration}." + ignore: "Si no lo has pedido, no ha cambiado nada y puedes ignorar este mensaje." activerecord: errors: diff --git a/config/locales/pt-BR.yml b/config/locales/pt-BR.yml index 59ae87209..810a9e2dd 100644 --- a/config/locales/pt-BR.yml +++ b/config/locales/pt-BR.yml @@ -190,10 +190,29 @@ pt-BR: reset: "Sua senha foi redefinida." mismatch: "As senhas não coincidem." invalid_token: "Este link de redefinição é inválido ou expirou." + new: + title: "Esqueceu sua senha?" + subtitle: "Enviaremos por e-mail um link para você escolher outra." + email_address: "E-mail" + submit: "Enviar instruções" + remembered: "Lembrou a senha?" + sign_in: "Entrar" + edit: + title: "Escolha uma nova senha" + subtitle: "Você precisará da nova senha para entrar de novo em todos os aparelhos." + password: "Nova senha" + password_hint: "Ao menos 8 caracteres." + password_confirmation: "Confirme a nova senha" + submit: "Salvar senha" passwords_mailer: reset: subject: "Redefina sua senha" + heading: "Redefina sua senha" + body: "Use o link abaixo para escolher uma nova senha da sua conta no Roster." + action: "Escolher nova senha" + expires: "O link expira em %{duration}." + ignore: "Se você não pediu isso, nada mudou e pode ignorar esta mensagem." activerecord: errors: From 2a17c342911f411e5bbe22440243fea8fa5ed8e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B4nio=20Neto?= Date: Fri, 4 Sep 2026 07:38:47 -0300 Subject: [PATCH 13/33] test: walk the three journeys in a browser, and prove the live updates System specs for the visitor, the regular user and the administrator, each following a whole path rather than one action: signing up from the root, correcting one's own details, keeping a chosen language across sign outs, being turned away from the admin area, creating and promoting and removing people, the last-administrator refusal, and an import reporting what it did with each row. Live delivery was the honest gap. The test cable adapter records broadcasts without delivering them, so "the counter updates by itself" was never actually observed. The test environment now takes its adapter from CABLE_ADAPTER, and a second pass -- bin/test --live, and a CI step -- runs the examples tagged :live with Solid Cable. They load a page, never reload it, and then change the data from the example: the dashboard counter moves and an import walks from waiting to finished in a real browser, over a real websocket. That also exercises the connect-src the content security policy allows. bin/test --parallel had never been run. It called parallel:setup, whose db:setup also runs the seeds, so every worker database started with the demonstration roster in it and specs that count administrators failed. It now calls parallel:prepare, the rake tasks are loaded in the Rakefile, and the worker count defaults to four rather than one per core. 193 examples across four workers, green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BX7yhokJ4UWtAHDnBmr46W --- Rakefile | 9 +++ bin/test | 8 ++ config/cable.yml | 11 ++- config/ci.rb | 5 ++ devops/rails/test-parallel.sh | 14 +++- devops/rails/test.sh | 4 +- spec/spec_helper.rb | 13 ++- spec/system/administrator_journey_spec.rb | 99 +++++++++++++++++++++++ spec/system/live_updates_spec.rb | 55 +++++++++++++ spec/system/regular_user_journey_spec.rb | 96 ++++++++++++++++++++++ spec/system/visitor_journey_spec.rb | 54 +++++++++++++ 11 files changed, 361 insertions(+), 7 deletions(-) create mode 100644 spec/system/administrator_journey_spec.rb create mode 100644 spec/system/live_updates_spec.rb create mode 100644 spec/system/regular_user_journey_spec.rb create mode 100644 spec/system/visitor_journey_spec.rb diff --git a/Rakefile b/Rakefile index 9a5ea7383..c08b5bafc 100644 --- a/Rakefile +++ b/Rakefile @@ -4,3 +4,12 @@ require_relative "config/application" Rails.application.load_tasks + +# parallel_tests ships the parallel:* tasks that create one database per +# worker. It is a test-only gem, so the require is guarded for environments +# where the group is not installed. +begin + require "parallel_tests/tasks" +rescue LoadError + nil +end diff --git a/bin/test b/bin/test index b8dff7e79..f20de41b6 100755 --- a/bin/test +++ b/bin/test @@ -5,6 +5,7 @@ # bin/test # whole suite # bin/test spec/models/user_spec.rb # a single file # bin/test --parallel # across parallel workers +# bin/test --live # the websocket delivery specs # set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -14,4 +15,11 @@ if [[ "${1:-}" == "--parallel" ]]; then exec "${ROOT}/devops/rails/test-parallel.sh" "$@" fi +# Solid Cable instead of the in-memory test adapter, so a broadcast really +# travels to the browser. +if [[ "${1:-}" == "--live" ]]; then + shift + exec env CABLE_ADAPTER=solid_cable "${ROOT}/devops/rails/test.sh" "$@" +fi + exec "${ROOT}/devops/rails/test.sh" "$@" diff --git a/config/cable.yml b/config/cable.yml index 36f0fd70a..9ed6b9c19 100644 --- a/config/cable.yml +++ b/config/cable.yml @@ -8,8 +8,17 @@ development: polling_interval: 0.1.seconds message_retention: 1.day +# The test adapter records broadcasts in memory so specs can assert on them +# without a database or a websocket. The system specs that need delivery in a +# real browser run in a second pass with CABLE_ADAPTER=solid_cable, which is +# what bin/test --live does. test: - adapter: test + adapter: <%= ENV.fetch("CABLE_ADAPTER", "test") %> + connects_to: + database: + writing: cable + polling_interval: 0.05.seconds + message_retention: 1.day production: adapter: solid_cable diff --git a/config/ci.rb b/config/ci.rb index 87836a53f..f10d73ad8 100644 --- a/config/ci.rb +++ b/config/ci.rb @@ -15,4 +15,9 @@ # SimpleCov enforces the 90% minimum and fails the process when coverage # drops below it, so the suite is also the coverage gate. step "Tests: RSpec", "bundle exec rspec" + + # A second, much smaller pass with Solid Cable in place of the in-memory + # test adapter, so the live updates are proven to reach a real browser over + # a real websocket rather than only to have been broadcast. + step "Tests: live updates", "CABLE_ADAPTER=solid_cable bundle exec rspec --tag live" end diff --git a/devops/rails/test-parallel.sh b/devops/rails/test-parallel.sh index 90388af05..9ecfa0e80 100755 --- a/devops/rails/test-parallel.sh +++ b/devops/rails/test-parallel.sh @@ -2,8 +2,16 @@ # Runs the suite across parallel workers, each with its own database. set -euo pipefail source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/common.sh" -WORKERS="${WORKERS:-$(nproc 2>/dev/null || echo 4)}" -step "Creating ${WORKERS} parallel test databases" -rails_test_exec bundle exec rake parallel:setup["${WORKERS}"] +# Four by default rather than one per core: each worker needs its own set of +# four databases, and a 16-core machine would spend longer creating them than +# running the suite. Override with WORKERS=n. +DEFAULT_WORKERS=4 +CORES="$(nproc 2>/dev/null || echo "${DEFAULT_WORKERS}")" +WORKERS="${WORKERS:-$(( CORES < DEFAULT_WORKERS ? CORES : DEFAULT_WORKERS ))}" +# `parallel:prepare` loads the schema into each worker database. +# `parallel:setup` would run db:setup instead, which also runs the seeds and +# leaves the demonstration roster sitting in every test database. +step "Preparing ${WORKERS} parallel test databases" +rails_test_exec bundle exec rake parallel:prepare["${WORKERS}"] step "Running RSpec on ${WORKERS} workers" rails_test_exec bundle exec parallel_rspec -n "${WORKERS}" "$@" diff --git a/devops/rails/test.sh b/devops/rails/test.sh index 90600dca4..a95d9b56a 100755 --- a/devops/rails/test.sh +++ b/devops/rails/test.sh @@ -8,7 +8,7 @@ step "Preparing test databases" rails_test_exec ./bin/rails db:test:prepare step "Running RSpec" if [[ "$#" -gt 0 ]]; then - rails_test_exec env SCREENSHOTS="${SCREENSHOTS:-}" bundle exec rspec "$@" + rails_test_exec env SCREENSHOTS="${SCREENSHOTS:-}" CABLE_ADAPTER="${CABLE_ADAPTER:-}" bundle exec rspec "$@" else - rails_test_exec env SCREENSHOTS="${SCREENSHOTS:-}" bundle exec rspec + rails_test_exec env SCREENSHOTS="${SCREENSHOTS:-}" CABLE_ADAPTER="${CABLE_ADAPTER:-}" bundle exec rspec fi diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 670bf1dd1..6534549e3 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,5 +1,11 @@ # Coverage has to start before any application code is loaded, which is why it # lives at the very top of this file rather than in rails_helper. +# +# The live-updates pass (bin/test --live) runs a handful of system specs and +# would report the rest of the application as uncovered, so it is measured by +# the full run instead. +LIVE_CABLE_PASS = !ENV["CABLE_ADAPTER"].to_s.empty? + require "simplecov" SimpleCov.start "rails" do @@ -11,7 +17,7 @@ merging true merge_timeout 600 - minimum_coverage line: 90, branch: 80 + minimum_coverage line: 90, branch: 80 unless LIVE_CABLE_PASS # Excluded because they hold no logic of our own: the specs themselves, # framework configuration and the generated schema files. @@ -42,6 +48,11 @@ # The screenshot spec exists to produce README images, not to verify # behaviour, so it stays out of the default run. config.filter_run_excluding :screenshots if ENV["SCREENSHOTS"].to_s.empty? + + # Delivery over a real websocket needs Solid Cable rather than the in-memory + # test adapter, so those examples run only in the live pass. + config.filter_run_excluding :live unless LIVE_CABLE_PASS + config.filter_run_including live: true if LIVE_CABLE_PASS config.example_status_persistence_file_path = ".rspec_status" config.disable_monkey_patching! config.warnings = false diff --git a/spec/system/administrator_journey_spec.rb b/spec/system/administrator_journey_spec.rb new file mode 100644 index 000000000..36f7e4085 --- /dev/null +++ b/spec/system/administrator_journey_spec.rb @@ -0,0 +1,99 @@ +require "rails_helper" + +# The administrative side from end to end: the dashboard, the list and its +# filters, the forms, and the two rules that protect the system from being left +# without anyone able to run it. +RSpec.describe "The administrator journey" do + let(:password) { "a-sufficiently-long-password" } + let!(:administrator) do + create(:user, :admin, full_name: "Ada Lovelace", email_address: "ada@example.com", password: password) + end + + before do + visit new_session_path + fill_in "Email address", with: administrator.email_address + fill_in "Password", with: password + click_on "Sign in" + end + + it "arrives at the dashboard and reads the counters" do + expect(page).to have_current_path(admin_dashboard_path) + expect(page).to have_text("Total users") + expect(page).to have_text("Administrators") + end + + it "creates an account on someone's behalf and finds it in the list" do + # "Users" names the sidebar link and the dashboard button both. + click_on "Users", match: :first + click_on "Add user" + + fill_in "Full name", with: "Maria Silva" + fill_in "Email address", with: "maria@example.com" + fill_in "Password", with: password + click_on "Create user" + + expect(page).to have_text("Maria Silva has been added.") + expect(page).to have_text("maria@example.com") + expect(User.find_by(email_address: "maria@example.com")).to be_user + end + + it "narrows a long list by name and by role" do + create(:user, full_name: "Maria Silva") + create(:user, full_name: "João Souza") + create(:user, :admin, full_name: "Grace Hopper") + + visit admin_users_path + fill_in "Search", with: "maria" + click_on "Search" + + expect(page).to have_text("Maria Silva") + expect(page).to have_no_text("João Souza") + + click_on "Clear" + click_on "Administrators" + + expect(page).to have_text("Grace Hopper") + expect(page).to have_no_text("Maria Silva") + end + + it "promotes somebody, then removes them" do + create(:user, full_name: "Maria Silva", email_address: "maria@example.com") + + visit admin_users_path + within("tr", text: "Maria Silva") { click_on "Edit" } + select "Administrator", from: "Role" + click_on "Save changes" + + expect(page).to have_text("Maria Silva has been updated.") + expect(User.find_by(email_address: "maria@example.com")).to be_admin + + within("tr", text: "Maria Silva") { click_on "Delete" } + + expect(page).to have_text("Maria Silva has been deleted.") + end + + it "refuses to remove the only administrator left" do + create(:user, full_name: "Maria Silva") + + visit admin_users_path + within("tr", text: "Ada Lovelace") { click_on "Delete" } + + expect(page).to have_text(I18n.t("activerecord.errors.models.user.attributes.base.last_administrator")) + expect(administrator.reload).to be_persisted + end + + it "imports a spreadsheet and reports what it did with each row", :perform_enqueued do + click_on "Imports", match: :first + attach_file "Spreadsheet", Rails.root.join("spec/fixtures/files/users-with-problems.csv") + + click_on "Start import" + + expect(page).to have_text(I18n.t("admin.user_imports.scheduled")) + + visit admin_user_import_path(UserImport.last) + + expect(page).to have_text("Rejected rows") + expect(UserImport.last).to be_completed_with_errors + expect(UserImport.last.created_users).to be_positive + end +end diff --git a/spec/system/live_updates_spec.rb b/spec/system/live_updates_spec.rb new file mode 100644 index 000000000..546d755d9 --- /dev/null +++ b/spec/system/live_updates_spec.rb @@ -0,0 +1,55 @@ +require "rails_helper" + +# Delivery in a real browser, over a real websocket: the page is loaded once +# and never reloaded, and the work that changes it happens afterwards, from +# the example itself. Excluded from the default run because it needs Solid +# Cable rather than the in-memory test adapter -- run it with bin/test --live. +RSpec.describe "Live updates", :js, :live do + let(:password) { "a-sufficiently-long-password" } + let(:administrator) do + create(:user, :admin, full_name: "Ada Lovelace", email_address: "ada@example.com", password: password) + end + + before do + visit new_session_path + fill_in "Email address", with: administrator.email_address + fill_in "Password", with: password + click_on "Sign in" + has_current_path?(admin_dashboard_path, wait: 5) + end + + # Solid Cable delivers what is published after a subscription exists, so a + # broadcast sent while the browser is still connecting is simply missed. + # Turbo marks its stream source element `connected` once the subscription is + # confirmed, which is the moment the page is really listening. + def wait_for_the_subscription + expect(page).to have_css("turbo-cable-stream-source[connected]", visible: :all) + end + + it "moves the dashboard counters when somebody else changes the roster" do + # The labels are uppercased by CSS, so they are matched without case. + expect(page).to have_text(/total users/i) + expect(page).to have_css("#user-counters", text: "1") + wait_for_the_subscription + + create(:user, full_name: "Maria Silva") + + # A generous wait: the broadcast travels through the cable database, which + # a spec shares with the polling thread, so delivery is measured in tenths + # of a second rather than milliseconds. + expect(page).to have_css("#user-counters", text: "2", wait: 15) + end + + it "carries an import from waiting to finished without a reload" do + import = create(:user_import, :with_csv, administrator: administrator) + + visit admin_user_import_path(import) + expect(page).to have_text(/#{I18n.t("admin.user_imports.statuses.pending")}/i) + wait_for_the_subscription + + ProcessUserImportJob.perform_now(import.id) + + expect(page).to have_text(/#{I18n.t("admin.user_imports.statuses.completed")}/i, wait: 15) + expect(page).to have_current_path(admin_user_import_path(import)) + end +end diff --git a/spec/system/regular_user_journey_spec.rb b/spec/system/regular_user_journey_spec.rb new file mode 100644 index 000000000..fadb1f882 --- /dev/null +++ b/spec/system/regular_user_journey_spec.rb @@ -0,0 +1,96 @@ +require "rails_helper" + +# What a person with an ordinary account can do from end to end, and where the +# application stops them. +RSpec.describe "The regular user journey" do + let(:password) { "a-sufficiently-long-password" } + let!(:user) do + create(:user, full_name: "Maria Silva", email_address: "maria@example.com", password: password) + end + + def sign_in_as(email_address) + visit new_session_path + fill_in "Email address", with: email_address + fill_in "Password", with: password + click_on "Sign in" + end + + it "signs in, corrects their own details and sees the change" do + sign_in_as(user.email_address) + + expect(page).to have_current_path(profile_path) + + click_on "Edit profile" + fill_in "Full name", with: "Maria Silva Santos" + fill_in "Avatar URL", with: "https://example.com/maria.png" + click_on "Save changes" + + expect(page).to have_text(I18n.t("profiles.updated")) + expect(page).to have_text("Maria Silva Santos") + expect(user.reload.full_name).to eq("Maria Silva Santos") + end + + it "is refused an avatar URL that is not an ordinary web link" do + sign_in_as(user.email_address) + + visit edit_profile_path + fill_in "Avatar URL", with: "javascript:alert('xss')" + click_on "Save changes" + + expect(page).to have_text("must be an http or https link") + expect(user.reload.avatar_url).to be_nil + end + + it "keeps the language they chose, because it belongs to the account" do + sign_in_as(user.email_address) + + click_on "Português" + + expect(page).to have_text("Meu perfil") + expect(user.reload.locale).to eq("pt-BR") + + # The sign out button appears in the sidebar and again in the mobile bar. + click_on "Sair", match: :first + # Back to English as a visitor, so what comes next cannot be the session + # remembering the choice -- it has to come from the account. + click_on "English" + fill_in "Email address", with: user.email_address + fill_in "Password", with: password + click_on "Sign in" + + expect(page).to have_text("Meu perfil") + end + + it "is turned away from the administration area, plainly" do + sign_in_as(user.email_address) + + visit admin_users_path + + expect(page).to have_current_path(profile_path) + expect(page).to have_text(I18n.t("authorization.admin_only")) + end + + it "cannot reach another person's profile, because there is no URL to try" do + other = create(:user, full_name: "Someone Else") + sign_in_as(user.email_address) + + visit profile_path + + expect(page).to have_text("Maria Silva") + expect(page).to have_no_text(other.full_name) + end + + it "deletes their own account and cannot get back in" do + sign_in_as(user.email_address) + + click_on "Delete my account" + + expect(page).to have_current_path(new_session_path) + expect(page).to have_text(I18n.t("profiles.deleted")) + + sign_in_as("maria@example.com") + + expect(page).to have_text(I18n.t("sessions.invalid_credentials")) + expect(User.find_by(email_address: "maria@example.com")).to be_nil + end +end diff --git a/spec/system/visitor_journey_spec.rb b/spec/system/visitor_journey_spec.rb new file mode 100644 index 000000000..e93e90775 --- /dev/null +++ b/spec/system/visitor_journey_spec.rb @@ -0,0 +1,54 @@ +require "rails_helper" + +# The whole path a visitor walks, in a browser, rather than one action at a +# time: the root is a signpost, the public form only ever creates a regular +# user, and nothing behind the sign-in page is reachable before signing in. +RSpec.describe "The visitor journey" do + it "signs up from the root and lands on their own profile" do + visit root_path + + expect(page).to have_current_path(new_session_path) + + click_on "Create an account" + fill_in "Full name", with: "Maria Silva" + fill_in "Email address", with: "maria@example.com" + fill_in "Password", with: "a-sufficiently-long-password" + fill_in "Confirm password", with: "a-sufficiently-long-password" + click_on "Create account" + + expect(page).to have_current_path(profile_path) + expect(page).to have_text("Maria Silva") + # The public form has no role field, and a hand-made request cannot add + # one: the created account is always a regular user. + expect(User.find_by(email_address: "maria@example.com")).to be_user + end + + it "is stopped at the door of every page that needs an account" do + [profile_path, edit_profile_path, admin_dashboard_path, admin_users_path, + admin_user_imports_path].each do |path| + visit path + + expect(page).to have_current_path(new_session_path) + end + end + + it "is returned to the page it asked for after signing in" do + create(:user, :admin, email_address: "ada@example.com", password: "a-sufficiently-long-password") + + visit admin_users_path + fill_in "Email address", with: "ada@example.com" + fill_in "Password", with: "a-sufficiently-long-password" + click_on "Sign in" + + expect(page).to have_current_path(admin_users_path) + end + + it "asks for a password reset without revealing whether the account exists" do + visit new_session_path + click_on "Forgot password?" + fill_in "Email address", with: "nobody@example.com" + click_on "Send reset instructions" + + expect(page).to have_text(I18n.t("passwords.reset_instructions_sent")) + end +end From 6d901135a901d6bf712dd0bd1d772325aad4a04b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B4nio=20Neto?= Date: Fri, 4 Sep 2026 07:44:28 -0300 Subject: [PATCH 14/33] feat: fold the language flags into a single menu Three flags sitting side by side made the choice loud and the current language ambiguous. The picker is now one button wearing the flag and the name of the language in use; the alternatives live inside it. It is a
element, so it opens with the keyboard and works with no JavaScript at all. The Stimulus controller adds only what
does not do on its own: closing when a click or the focus moves elsewhere, and on Escape. The generated hello_controller went with it, since it was the only other thing in that directory. Two things found on the way in: the styling had been written inside the prefers-reduced-motion block, so the import bar was only styled for people who ask for less animation; and the test environment read its cable adapter with ENV.fetch, which the wrapper's empty CABLE_ADAPTER satisfied with an empty string. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BX7yhokJ4UWtAHDnBmr46W --- app/assets/tailwind/components.css | 57 +++++++++++++++---- .../controllers/hello_controller.js | 7 --- .../controllers/language_menu_controller.js | 38 +++++++++++++ app/views/shared/_language_picker.html.erb | 42 ++++++++++---- config/cable.yml | 2 +- spec/support/capybara.rb | 11 ++++ spec/system/authentication_spec.rb | 2 +- spec/system/regular_user_journey_spec.rb | 4 +- 8 files changed, 130 insertions(+), 33 deletions(-) delete mode 100644 app/javascript/controllers/hello_controller.js create mode 100644 app/javascript/controllers/language_menu_controller.js diff --git a/app/assets/tailwind/components.css b/app/assets/tailwind/components.css index 15d327ef4..e6d7e0007 100644 --- a/app/assets/tailwind/components.css +++ b/app/assets/tailwind/components.css @@ -212,6 +212,51 @@ background-color: var(--color-accent); } + /* ── Menu ───────────────────────────────────────────────────────────── */ + /* The panel a
summary opens: the language picker today, anything + else that needs a short list of choices tomorrow. */ + .menu { + position: absolute; + right: 0; + z-index: 20; + margin-top: 0.375rem; + min-width: 11rem; + padding: 0.25rem; + background-color: var(--color-surface); + border: 1px solid var(--color-line); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); + } + + .menu-item { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.5rem 0.625rem; + border-radius: var(--radius-sm); + font-size: 0.8125rem; + font-weight: 500; + color: var(--color-ink); + cursor: pointer; + transition: background-color 150ms ease; + } + + .menu-item:hover { + background-color: var(--color-surface-soft); + } + + .menu-item:focus-visible { + outline: 2px solid transparent; + box-shadow: var(--shadow-focus); + } + + .menu-item.is-selected { + color: var(--color-accent-strong); + background-color: var(--color-accent-wash); + } + + /* ── Avatar fallback ────────────────────────────────────────────────── */ .avatar { display: inline-flex; @@ -226,18 +271,6 @@ font-weight: 600; text-transform: uppercase; } -} - -/* Animation is decoration here; users who ask for less should get less. */ -@media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - transition-duration: 0.01ms !important; - scroll-behavior: auto !important; - } /* ── Progress ───────────────────────────────────────────────────────── */ /* A real element carries its own semantics, so it is worth 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/javascript/controllers/language_menu_controller.js b/app/javascript/controllers/language_menu_controller.js new file mode 100644 index 000000000..7f609b3cd --- /dev/null +++ b/app/javascript/controllers/language_menu_controller.js @@ -0,0 +1,38 @@ +import { Controller } from "@hotwired/stimulus" + +//
opens and closes on its own. What it does not do is close when the +// person's attention moves on, which is what a menu is expected to do. +export default class extends Controller { + connect() { + this.closeOnOutsideEvent = this.closeOnOutsideEvent.bind(this) + } + + disconnect() { + this.stopWatching() + } + + // Watch only while open: a listener on every click of every page, to close a + // menu that is already closed, is a listener not worth installing. + toggled() { + this.element.open ? this.startWatching() : this.stopWatching() + } + + close() { + this.element.open = false + this.stopWatching() + } + + closeOnOutsideEvent(event) { + if (!this.element.contains(event.target)) this.close() + } + + startWatching() { + document.addEventListener("click", this.closeOnOutsideEvent) + document.addEventListener("focusin", this.closeOnOutsideEvent) + } + + stopWatching() { + document.removeEventListener("click", this.closeOnOutsideEvent) + document.removeEventListener("focusin", this.closeOnOutsideEvent) + } +} diff --git a/app/views/shared/_language_picker.html.erb b/app/views/shared/_language_picker.html.erb index 997647054..7351f692a 100644 --- a/app/views/shared/_language_picker.html.erb +++ b/app/views/shared/_language_picker.html.erb @@ -1,11 +1,33 @@ -
- <% supported_locales.each do |locale| %> - <% selected = locale == current_locale %> - <%= button_to locale_path(locale: locale), method: :patch, - class: "btn px-2 py-1.5 #{selected ? 'btn-ghost' : 'opacity-60 hover:opacity-100'}", - aria: { pressed: selected, label: t("language_name", locale: locale) }, - title: t("language_name", locale: locale) do %> - <%= render "shared/flag", locale: locale %> +<%# A single control rather than three: the button wears the flag of the + language in use, and the alternatives live inside it. + +
is what makes it work with the keyboard and without JavaScript; + the Stimulus controller only adds what the element does not do on its own, + which is closing when attention moves elsewhere. %> +
+ " + title="<%= t("shared.language.label") %>"> + <%= render "shared/flag", locale: current_locale %> + + + + + +
+
diff --git a/config/cable.yml b/config/cable.yml index 9ed6b9c19..412ba32ff 100644 --- a/config/cable.yml +++ b/config/cable.yml @@ -13,7 +13,7 @@ development: # real browser run in a second pass with CABLE_ADAPTER=solid_cable, which is # what bin/test --live does. test: - adapter: <%= ENV.fetch("CABLE_ADAPTER", "test") %> + adapter: <%= ENV["CABLE_ADAPTER"].presence || "test" %> connects_to: database: writing: cable diff --git a/spec/support/capybara.rb b/spec/support/capybara.rb index 65dab2898..faa61ffbe 100644 --- a/spec/support/capybara.rb +++ b/spec/support/capybara.rb @@ -41,12 +41,23 @@ def cuprite_options } end +# The language picker is a
menu: its options are inside the closed +# element until the summary is clicked. +module LanguagePickerHelpers + def choose_language(name) + first("summary[aria-haspopup='menu']").click + click_on name + end +end + Capybara.default_driver = :rack_test Capybara.default_max_wait_time = 5 Capybara.server = :puma, { Silent: true } Capybara.disable_animation = true RSpec.configure do |config| + config.include LanguagePickerHelpers, type: :system + config.before(:each, type: :system) { driven_by :rack_test } config.before(:each, :js, type: :system) do diff --git a/spec/system/authentication_spec.rb b/spec/system/authentication_spec.rb index 347d05e83..f742f1b3c 100644 --- a/spec/system/authentication_spec.rb +++ b/spec/system/authentication_spec.rb @@ -42,7 +42,7 @@ it "switches the interface language from the flag picker" do visit new_session_path - click_on "Português" + choose_language("Português") expect(page).to have_text("Entrar") expect(page).to have_field("E-mail") diff --git a/spec/system/regular_user_journey_spec.rb b/spec/system/regular_user_journey_spec.rb index fadb1f882..4fc0f59c3 100644 --- a/spec/system/regular_user_journey_spec.rb +++ b/spec/system/regular_user_journey_spec.rb @@ -44,7 +44,7 @@ def sign_in_as(email_address) it "keeps the language they chose, because it belongs to the account" do sign_in_as(user.email_address) - click_on "Português" + choose_language("Português") expect(page).to have_text("Meu perfil") expect(user.reload.locale).to eq("pt-BR") @@ -53,7 +53,7 @@ def sign_in_as(email_address) click_on "Sair", match: :first # Back to English as a visitor, so what comes next cannot be the session # remembering the choice -- it has to come from the account. - click_on "English" + choose_language("English") fill_in "Email address", with: user.email_address fill_in "Password", with: password click_on "Sign in" From c5dd87c3699a50f06c958d8e8d1dda54ce70957f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B4nio=20Neto?= Date: Fri, 4 Sep 2026 08:14:07 -0300 Subject: [PATCH 15/33] fix: two defects the tests were not looking for Deleting an administrator who had ever run an import returned a 500. The foreign key on user_imports.administrator_id raises rather than returning false, so `if @user.destroy` never saw it. The import history outlives the account that asked for it: the reference is now optional and nullified, and the address is copied onto the row when the import is created, so the list still says who requested it. The users list had an N+1. It renders an avatar per row, and without eager loading the attachment, blob and variant record were fetched once per person: eleven queries for ten people, and worse once the variants are rendered. Now four, whatever the page holds. The spec counts the queries for two people and then for eight and expects the same number, so this stays fixed rather than being fixed once. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BX7yhokJ4UWtAHDnBmr46W --- app/controllers/admin/users_controller.rb | 7 +++- app/models/user.rb | 3 ++ app/models/user_import.rb | 18 +++++++++- app/views/admin/user_imports/index.html.erb | 2 +- ...rts_when_their_administrator_is_removed.rb | 29 ++++++++++++++++ db/schema.rb | 5 +-- spec/models/user_import_spec.rb | 16 +++++++++ spec/requests/admin/users_spec.rb | 33 +++++++++++++++++++ spec/support/query_counting.rb | 22 +++++++++++++ 9 files changed, 130 insertions(+), 5 deletions(-) create mode 100644 db/migrate/20260904113000_keep_imports_when_their_administrator_is_removed.rb create mode 100644 spec/support/query_counting.rb diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 33a6680c3..b34f51c6c 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -57,8 +57,13 @@ def user_params permitted end + # The avatar of every row is rendered, so the attachment, its blob and the + # variant record are loaded with the page rather than one query per person. def filtered_users - User.search(params[:query]).with_role(params[:role]).ordered + User.search(params[:query]) + .with_role(params[:role]) + .ordered + .includes(avatar_attachment: { blob: :variant_records }) end # Bounded so a hand-edited URL cannot ask for the whole table at once. diff --git a/app/models/user.rb b/app/models/user.rb index c5d1ad42f..0d8130c6a 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -17,6 +17,9 @@ class User < ApplicationRecord has_secure_password has_many :sessions, dependent: :destroy + # Nullified rather than destroyed: removing an administrator must not erase + # the record of the imports they ran. Without this the foreign key raised. + has_many :user_imports, foreign_key: :administrator_id, inverse_of: :administrator, dependent: :nullify has_one_attached :avatar # Two roles, explicitly numbered so the values are stable in the database and diff --git a/app/models/user_import.rb b/app/models/user_import.rb index 635d60a97..a302d9340 100644 --- a/app/models/user_import.rb +++ b/app/models/user_import.rb @@ -13,7 +13,12 @@ class UserImport < ApplicationRecord # long a worker is busy. MAX_ROWS = 10_000 - belongs_to :administrator, class_name: "User" + # Optional, because an administrator may be removed after running an import + # and the history of what was imported outlives their account. The address is + # copied onto the row so the list can still say who asked for it. + belongs_to :administrator, class_name: "User", optional: true + + before_validation :remember_the_administrator, on: :create has_one_attached :file has_many :row_errors, class_name: "UserImportError", dependent: :destroy @@ -22,6 +27,7 @@ class UserImport < ApplicationRecord default: :pending, validate: true validates :file, presence: true + validates :administrator_email, presence: true validate :acceptable_file, if: -> { attachment_changes.key?("file") } scope :recent_first, -> { order(created_at: :desc) } @@ -51,8 +57,18 @@ def progress_percentage [(processed_rows * 100 / total_rows), 100].min end + # What the list shows in the "requested by" column: the name while the + # account exists, the address it was created with once it does not. + def requested_by + administrator&.full_name || administrator_email + end + private + def remember_the_administrator + self.administrator_email ||= administrator&.email_address + end + def acceptable_file io = uploaded_io return if io.nil? diff --git a/app/views/admin/user_imports/index.html.erb b/app/views/admin/user_imports/index.html.erb index 981a082f5..66c28667a 100644 --- a/app/views/admin/user_imports/index.html.erb +++ b/app/views/admin/user_imports/index.html.erb @@ -49,7 +49,7 @@ <% end %>

<%= l(import.created_at, format: :short) %> · - <%= import.administrator.full_name %> + <%= import.requested_by %>

diff --git a/db/migrate/20260904113000_keep_imports_when_their_administrator_is_removed.rb b/db/migrate/20260904113000_keep_imports_when_their_administrator_is_removed.rb new file mode 100644 index 000000000..b83edac7e --- /dev/null +++ b/db/migrate/20260904113000_keep_imports_when_their_administrator_is_removed.rb @@ -0,0 +1,29 @@ +# Deleting an administrator who had ever run an import violated the foreign key +# and raised out of the controller as a 500. The import history is worth +# keeping when the account that asked for it is gone, so the reference becomes +# optional and the address is copied onto the row, where it survives. +class KeepImportsWhenTheirAdministratorIsRemoved < ActiveRecord::Migration[8.1] + def up + change_table :user_imports, bulk: true do |t| + t.change_null :administrator_id, true + t.string :administrator_email + end + + execute <<~SQL.squish + UPDATE user_imports + SET administrator_email = users.email_address + FROM users + WHERE users.id = user_imports.administrator_id + AND user_imports.administrator_email IS NULL + SQL + + change_column_null :user_imports, :administrator_email, false + end + + def down + change_table :user_imports, bulk: true do |t| + t.remove :administrator_email + t.change_null :administrator_id, false + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 73995559e..8feafeaff 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_180524) do +ActiveRecord::Schema[8.1].define(version: 2026_09_04_113000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -64,7 +64,8 @@ end create_table "user_imports", force: :cascade do |t| - t.bigint "administrator_id", null: false + t.string "administrator_email", null: false + t.bigint "administrator_id" t.datetime "created_at", null: false t.integer "created_users", default: 0, null: false t.text "failure_reason" diff --git a/spec/models/user_import_spec.rb b/spec/models/user_import_spec.rb index bdda3fa53..df80d7505 100644 --- a/spec/models/user_import_spec.rb +++ b/spec/models/user_import_spec.rb @@ -41,6 +41,22 @@ def build_import(file_name, **) end end + describe "who asked for it" do + it "records the address at the time, so it outlives the account" do + administrator = create(:user, :admin, full_name: "Grace Hopper", + email_address: "grace@example.com") + import = create(:user_import, :with_csv, administrator: administrator) + create(:user, :admin) # the last-administrator rule is not what is under test + + expect(import.requested_by).to eq("Grace Hopper") + + administrator.destroy! + + expect(import.reload.administrator).to be_nil + expect(import.requested_by).to eq("grace@example.com") + end + end + describe "#format" do it "reads the format from the extension" do expect(build_import("users.csv").format).to eq(:csv) diff --git a/spec/requests/admin/users_spec.rb b/spec/requests/admin/users_spec.rb index fb68bd8e6..4cba91ca3 100644 --- a/spec/requests/admin/users_spec.rb +++ b/spec/requests/admin/users_spec.rb @@ -62,6 +62,19 @@ expect(response.body).to include("Maria Silva") end + # The page renders an avatar per row, which is where an N+1 hides: without + # eager loading the attachment, its blob and the variant record are fetched + # once per person. + it "costs the same number of queries however many people are listed" do + create_list(:user, 2).each { |user| attach_avatar(user) } + queries_for_two = count_queries { get admin_users_path }.size + + create_list(:user, 6).each { |user| attach_avatar(user) } + queries_for_eight = count_queries { get admin_users_path }.size + + expect(queries_for_eight).to eq(queries_for_two) + end + it "paginates" do create_list(:user, 3) @@ -130,6 +143,21 @@ expect(response).to redirect_to(admin_users_path) end + + # The import rows point at the administrator who asked for them, and a + # foreign key raises rather than returning false: without the association + # saying what to do, this was a 500. + it "deletes an administrator who has run an import, and keeps the import" do + colleague = create(:user, :admin, full_name: "Grace Hopper") + import = create(:user_import, :with_csv, administrator: colleague) + + delete admin_user_path(colleague) + + expect(response).to redirect_to(admin_users_path) + expect(User.exists?(colleague.id)).to be(false) + expect(import.reload.administrator).to be_nil + expect(import.requested_by).to eq(colleague.email_address) + end end describe "protecting the last administrator" do @@ -176,4 +204,9 @@ expect { delete admin_user_path(target) }.not_to change(User, :count) end end + + def attach_avatar(user) + user.avatar.attach(io: Rails.root.join("spec/fixtures/files/avatar.png").open, + filename: "avatar.png", content_type: "image/png") + end end diff --git a/spec/support/query_counting.rb b/spec/support/query_counting.rb new file mode 100644 index 000000000..dabdd7e17 --- /dev/null +++ b/spec/support/query_counting.rb @@ -0,0 +1,22 @@ +# Counting the queries a request makes turns "this page does not have an N+1" +# into something the suite can hold to, rather than a claim in a README. +module QueryCounting + IGNORED = /\ASCHEMA\z|\ATRANSACTION\z|\ACACHE\z/ + + def count_queries + queries = [] + subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload| + queries << payload[:sql] unless payload[:name].to_s.match?(IGNORED) + end + + yield + + queries + ensure + ActiveSupport::Notifications.unsubscribe(subscriber) + end +end + +RSpec.configure do |config| + config.include QueryCounting, type: :request +end From f142f99c9e6ac7b6b174f6c5b139792be140f4a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B4nio=20Neto?= Date: Fri, 4 Sep 2026 08:19:23 -0300 Subject: [PATCH 16/33] ci: run the real pipeline, and check accessibility instead of claiming it The workflow that came with the repository was the Rails default: it scanned for vulnerabilities and linted, and never ran a single test. It now runs bin/ci inside the same container the application is developed in -- style, three security scanners, the suite, the live-updates pass -- and keeps the coverage report as an artifact. A workflow that installed its own Ruby and its own PostgreSQL would be a second definition of the environment, free to drift from the one in the repository. Accessibility is now a check rather than a sentence in a README: axe runs against ten screens at WCAG 2.1 AA. The packaged matcher speaks Selenium and these specs drive Chrome over CDP, so the few lines that load the library and read the violations live in spec/support. It found three real faults on the first run: the counts beside the role filters used the 4:1 "dim" token, which the tokens file documents as being for large text and decoration; and the inline links on the sign-up and password-reset screens were distinguished by colour alone. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BX7yhokJ4UWtAHDnBmr46W --- .github/workflows/ci.yml | 74 +++++++------------ Gemfile | 5 ++ Gemfile.lock | 6 ++ app/views/admin/users/index.html.erb | 4 +- app/views/passwords/new.html.erb | 2 +- app/views/registrations/new.html.erb | 4 +- spec/support/accessibility.rb | 55 +++++++++++++++ spec/system/accessibility_spec.rb | 102 +++++++++++++++++++++++++++ spec/system/live_updates_spec.rb | 11 +-- 9 files changed, 208 insertions(+), 55 deletions(-) create mode 100644 spec/support/accessibility.rb create mode 100644 spec/system/accessibility_spec.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d58c2aa4c..abae19d35 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,67 +1,47 @@ +# One job, running the same pipeline a person runs locally: bin/ci inside the +# same container the application is developed in. A workflow that installs its +# own Ruby and its own PostgreSQL would be a second definition of the +# environment, free to drift from the one in the repository. name: CI on: pull_request: push: - branches: [ main ] + branches: [main, master] jobs: - scan_ruby: + ci: + name: Style, security and tests runs-on: ubuntu-latest + timeout-minutes: 25 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: Configure the environment + run: cp .env.example .env - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - bundler-cache: true + - name: Cache the Docker layers + uses: docker/setup-buildx-action@v3 - - name: Scan for security vulnerabilities in JavaScript dependencies - run: bin/importmap audit + - name: Build the development image + run: docker compose build web - lint: - runs-on: ubuntu-latest - env: - RUBOCOP_CACHE_ROOT: tmp/rubocop - steps: - - name: Checkout code - uses: actions/checkout@v6 + - name: Start PostgreSQL and the application + run: docker compose up --detach --wait postgres web - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - bundler-cache: true + - name: Run the pipeline + run: bin/ci - - name: Prepare RuboCop cache - uses: actions/cache@v4 - env: - DEPENDENCIES_HASH: ${{ hashFiles('.ruby-version', '**/.rubocop.yml', '**/.rubocop_todo.yml', 'Gemfile.lock') }} + - name: Keep the coverage report + if: always() + uses: actions/upload-artifact@v4 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 + name: coverage + path: coverage/ + retention-days: 7 + - name: Show the logs when something fails + if: failure() + run: docker compose logs --tail 200 diff --git a/Gemfile b/Gemfile index 1fbdd0af1..5bf604879 100644 --- a/Gemfile +++ b/Gemfile @@ -68,6 +68,11 @@ group :development, :test do end group :test do + # Accessibility as a check rather than a claim: axe runs against the rendered + # page in the system specs. The API gem carries the axe-core JavaScript; the + # matcher that drives it lives in spec/support, because the packaged one + # speaks Selenium and these specs drive Chrome over CDP. + gem "axe-core-api" gem "capybara" # Cuprite drives headless Chrome over CDP directly, which keeps system specs # fast and removes the chromedriver version dance. diff --git a/Gemfile.lock b/Gemfile.lock index 353268fff..b326b0147 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -78,6 +78,8 @@ GEM addressable (2.9.0) public_suffix (>= 2.0.2, < 8.0) ast (2.4.3) + axe-core-api (4.13.0) + dumb_delegator base64 (0.3.0) bcrypt (3.1.22) bcrypt_pbkdf (1.1.2) @@ -114,6 +116,7 @@ GEM diff-lcs (1.6.2) dotenv (3.2.0) drb (2.2.3) + dumb_delegator (1.1.0) ed25519 (1.4.0) erb (6.0.7) erubi (1.13.1) @@ -439,6 +442,7 @@ PLATFORMS x86_64-linux-musl DEPENDENCIES + axe-core-api bcrypt (~> 3.1) bootsnap brakeman @@ -495,6 +499,7 @@ CHECKSUMS activesupport (8.1.3.1) sha256=85458765f25ea48b9019c46b6bb3fa5683197bf4280d9f06710a6e8d7a831376 addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 + axe-core-api (4.13.0) sha256=6556c36d541090993b0efffd2659e13c289c109a0a6bf65d28271e363fc7ffa3 base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b bcrypt (3.1.22) sha256=1f0072e88c2d705d94aff7f2c5cb02eb3f1ec4b8368671e19112527489f29032 bcrypt_pbkdf (1.1.2) sha256=c2414c23ce66869b3eb9f643d6a3374d8322dfb5078125c82792304c10b94cf6 @@ -515,6 +520,7 @@ CHECKSUMS diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 + dumb_delegator (1.1.0) sha256=1ad255e5b095a2206a574c62b40c678f3d5c9151f1b3d0bae1b0463f7e40188e ed25519 (1.4.0) sha256=16e97f5198689a154247169f3453ef4cfd3f7a47481fde0ae33206cdfdcac506 erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92 erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 diff --git a/app/views/admin/users/index.html.erb b/app/views/admin/users/index.html.erb index 0ac822d80..3cdd8ed86 100644 --- a/app/views/admin/users/index.html.erb +++ b/app/views/admin/users/index.html.erb @@ -39,7 +39,9 @@ <%= link_to admin_users_path(role: value, query: params[:query]), class: "btn #{active ? 'btn-ghost' : 'text-muted hover:text-ink'} text-xs", aria: { current: active ? "true" : nil } do %> - <%= label %> (<%= count %>) + <%# Muted, not dim: dim is a 4:1 token, which is for large text and + decoration -- this is a small count that has to be readable. %> + <%= label %> (<%= count %>) <% end %> <% end %>
diff --git a/app/views/passwords/new.html.erb b/app/views/passwords/new.html.erb index 989174750..a61ba5c92 100644 --- a/app/views/passwords/new.html.erb +++ b/app/views/passwords/new.html.erb @@ -16,6 +16,6 @@

<%= t(".remembered") %> - <%= link_to t(".sign_in"), new_session_path, class: "text-accent-strong hover:underline" %> + <%= link_to t(".sign_in"), new_session_path, class: "text-accent-strong underline underline-offset-2" %>

diff --git a/app/views/registrations/new.html.erb b/app/views/registrations/new.html.erb index 671d03b92..3d484f634 100644 --- a/app/views/registrations/new.html.erb +++ b/app/views/registrations/new.html.erb @@ -37,6 +37,8 @@

<%= t(".have_account") %> - <%= link_to t(".sign_in"), new_session_path, class: "text-accent-strong hover:underline" %> + <%# Underlined, not merely coloured: a link inside a paragraph has to be + distinguishable without relying on colour. %> + <%= link_to t(".sign_in"), new_session_path, class: "text-accent-strong underline underline-offset-2" %>

diff --git a/spec/support/accessibility.rb b/spec/support/accessibility.rb new file mode 100644 index 000000000..036389fa8 --- /dev/null +++ b/spec/support/accessibility.rb @@ -0,0 +1,55 @@ +require "json" + +# Runs axe-core against the page the browser is showing. The axe-core-rspec +# matcher would do this too, but it reaches for Selenium's driver API, and +# these specs drive Chrome over CDP with Cuprite -- so the few lines it takes +# to load the library and read the violations live here instead. +module AccessibilityChecking + AXE_JS = Pathname.new(Gem.loaded_specs.fetch("axe-core-api").gem_dir) + .join("node_modules/axe-core/axe.min.js") + + # The WCAG 2.1 AA rule sets, which is the level the README claims. + RULE_SETS = %w[wcag2a wcag2aa wcag21a wcag21aa].freeze + + def accessibility_violations + load_axe + + page.evaluate_async_script(<<~JS, RULE_SETS) + const done = arguments[arguments.length - 1]; + axe.run(document, { runOnly: { type: "tag", values: arguments[0] } }) + .then((results) => done(JSON.parse(JSON.stringify(results.violations)))); + JS + end + + private + + def load_axe + return if page.evaluate_script("typeof window.axe === 'object'") + + page.execute_script(AXE_JS.read) + end +end + +# Reads as an expectation about the page and fails with the rule, the impact +# and the offending markup, which is what makes a failure actionable. +RSpec::Matchers.define :be_accessible do + match do |page_under_test| + @violations = page_under_test.accessibility_violations + @violations.empty? + end + + failure_message do + lines = @violations.map do |violation| + targets = violation["nodes"].map { |node| Array(node["target"]).join(" ") }.first(3) + " [#{violation["impact"]}] #{violation["id"]}: #{violation["help"]}\n " \ + "#{targets.join("\n ")}\n #{violation["helpUrl"]}" + end + + "expected the page to have no accessibility violations, but axe reported " \ + "#{@violations.size}:\n#{lines.join("\n")}" + end +end + +RSpec.configure do |config| + config.include AccessibilityChecking, type: :system +end diff --git a/spec/system/accessibility_spec.rb b/spec/system/accessibility_spec.rb new file mode 100644 index 000000000..f697be3df --- /dev/null +++ b/spec/system/accessibility_spec.rb @@ -0,0 +1,102 @@ +require "rails_helper" + +# Accessibility checked rather than asserted in prose. axe runs against each +# rendered screen and fails on anything it can detect automatically: contrast, +# names, roles, labels, landmarks, heading order. +# +# Automated rules cover a part of WCAG, not all of it -- the keyboard path and +# the screen-reader wording still need a person. What is checked here is what a +# machine can honestly check. +RSpec.describe "Accessibility", :js do + let(:password) { "a-sufficiently-long-password" } + + def sign_in_as(user) + visit new_session_path + fill_in "Email address", with: user.email_address + fill_in "Password", with: password + click_on "Sign in" + end + + def expect_the_page_to_be_accessible + expect(self).to be_accessible + end + + context "when signed out" do + it "the sign in screen" do + visit new_session_path + + expect_the_page_to_be_accessible + end + + it "the sign up screen" do + visit new_registration_path + + expect_the_page_to_be_accessible + end + + it "the password reset request" do + visit new_password_path + + expect_the_page_to_be_accessible + end + end + + context "when signed in as a regular user" do + let(:user) { create(:user, full_name: "Maria Silva", password: password) } + + before { sign_in_as(user) } + + it "the profile" do + expect_the_page_to_be_accessible + end + + it "the profile form" do + visit edit_profile_path + + expect_the_page_to_be_accessible + end + end + + context "when signed in as an administrator" do + let(:administrator) { create(:user, :admin, full_name: "Ada Lovelace", password: password) } + + before do + create_list(:user, 3) + sign_in_as(administrator) + end + + it "the dashboard" do + expect_the_page_to_be_accessible + end + + it "the list of users" do + visit admin_users_path + + expect_the_page_to_be_accessible + end + + it "the user form" do + visit new_admin_user_path + + expect_the_page_to_be_accessible + end + + it "the imports screen" do + visit admin_user_imports_path + + expect_the_page_to_be_accessible + end + + it "an import with rejected rows" do + import = create(:user_import, :with_csv, administrator: administrator, + status: :completed_with_errors, total_rows: 3, + processed_rows: 3, created_users: 2, rejected_rows: 1) + import.row_errors.create!(row_number: 3, email_address: "taken@example.com", + messages: ["Email address has already been taken"]) + + visit admin_user_import_path(import) + + expect_the_page_to_be_accessible + end + end +end diff --git a/spec/system/live_updates_spec.rb b/spec/system/live_updates_spec.rb index 546d755d9..9d60a59d0 100644 --- a/spec/system/live_updates_spec.rb +++ b/spec/system/live_updates_spec.rb @@ -6,6 +6,10 @@ # Cable rather than the in-memory test adapter -- run it with bin/test --live. RSpec.describe "Live updates", :js, :live do let(:password) { "a-sufficiently-long-password" } + # Delivery travels through a polling loop on the cable database, and a busy + # CI machine is slower than a laptop, so the waits here are generous. They + # cost nothing when the update arrives, which is the ordinary case. + let(:delivery_wait) { 25 } let(:administrator) do create(:user, :admin, full_name: "Ada Lovelace", email_address: "ada@example.com", password: password) end @@ -34,10 +38,7 @@ def wait_for_the_subscription create(:user, full_name: "Maria Silva") - # A generous wait: the broadcast travels through the cable database, which - # a spec shares with the polling thread, so delivery is measured in tenths - # of a second rather than milliseconds. - expect(page).to have_css("#user-counters", text: "2", wait: 15) + expect(page).to have_css("#user-counters", text: "2", wait: delivery_wait) end it "carries an import from waiting to finished without a reload" do @@ -49,7 +50,7 @@ def wait_for_the_subscription ProcessUserImportJob.perform_now(import.id) - expect(page).to have_text(/#{I18n.t("admin.user_imports.statuses.completed")}/i, wait: 15) + expect(page).to have_text(/#{I18n.t("admin.user_imports.statuses.completed")}/i, wait: delivery_wait) expect(page).to have_current_path(admin_user_import_path(import)) end end From c10d32d01eb2a9fc3a51b62bf12d708990c2111b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B4nio=20Neto?= Date: Fri, 4 Sep 2026 08:23:54 -0300 Subject: [PATCH 17/33] feat: invite the people an import creates An imported account had a random password nobody had ever seen and no message went anywhere: the person existed in the system and had no way of learning it, let alone of getting in. Every account an import creates now receives an invitation. The link carries a token generated for a new purpose rather than reusing the password reset, whose fifteen minutes are right for somebody who just asked and wrong for somebody who was imported at two in the morning; an invitation lasts a week. Like the reset token it is derived from the password salt, so it stops working the moment a password is set. Both arrive at the same screen, which reads as a welcome rather than a reset when the token is an invitation. Two things fixed on the way: mail is delivered by a worker, long after the request whose locale belonged to the reader, so every message went out in English whatever the person had chosen -- the mailers now switch to the recipient's language. And the from address was the generated from@example.com; it reads MAIL_FROM now. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BX7yhokJ4UWtAHDnBmr46W --- app/controllers/passwords_controller.rb | 15 +++++-- app/jobs/process_user_import_job.rb | 9 ++++- app/mailers/application_mailer.rb | 11 ++++- app/mailers/invitations_mailer.rb | 13 ++++++ app/mailers/passwords_mailer.rb | 5 ++- app/models/user.rb | 9 +++++ app/views/invitations_mailer/invite.html.erb | 16 ++++++++ app/views/invitations_mailer/invite.text.erb | 8 ++++ app/views/passwords/edit.html.erb | 16 ++++++-- config/locales/en.yml | 14 +++++++ config/locales/es.yml | 14 +++++++ config/locales/pt-BR.yml | 14 +++++++ spec/jobs/process_user_import_job_spec.rb | 9 +++++ spec/mailers/invitations_mailer_spec.rb | 32 +++++++++++++++ spec/rails_helper.rb | 3 ++ spec/requests/passwords_spec.rb | 42 ++++++++++++++++++++ 16 files changed, 219 insertions(+), 11 deletions(-) create mode 100644 app/mailers/invitations_mailer.rb create mode 100644 app/views/invitations_mailer/invite.html.erb create mode 100644 app/views/invitations_mailer/invite.text.erb create mode 100644 spec/mailers/invitations_mailer_spec.rb diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb index 65b1258f4..b7be9cd6c 100644 --- a/app/controllers/passwords_controller.rb +++ b/app/controllers/passwords_controller.rb @@ -20,7 +20,7 @@ def create def update if @user.update(params.permit(:password, :password_confirmation)) @user.sessions.destroy_all - redirect_to new_session_path, notice: t("passwords.reset") + redirect_to new_session_path, notice: t(@invited ? "passwords.chosen" : "passwords.reset") else redirect_to edit_password_path(params[:token]), alert: t("passwords.mismatch") end @@ -28,9 +28,16 @@ def update private + # The same screen serves two arrivals: somebody who asked to reset a password + # and somebody who was imported and has never had one. Both links carry a + # token derived from the password salt, so both stop working once a password + # is set; only the purpose and the wording differ. def set_user_by_token - @user = User.find_by_password_reset_token!(params.expect(:token)) - rescue ActiveSupport::MessageVerifier::InvalidSignature - redirect_to new_password_path, alert: t("passwords.invalid_token") + token = params.expect(:token) + @user = User.find_by_password_reset_token(token) + @invited = @user.nil? + @user ||= User.find_by_token_for(:invitation, token) + + redirect_to new_password_path, alert: t("passwords.invalid_token") if @user.nil? end end diff --git a/app/jobs/process_user_import_job.rb b/app/jobs/process_user_import_job.rb index 9d618e3d0..ff03fc768 100644 --- a/app/jobs/process_user_import_job.rb +++ b/app/jobs/process_user_import_job.rb @@ -60,7 +60,7 @@ def import_row(row_number, attributes) user = build_user(attributes) if user.save - import.increment!(:created_users) + record_creation(user) else record_rejection(row_number, attributes, user.errors.full_messages) end @@ -85,6 +85,13 @@ def build_user(attributes) ) end + # The account exists and its password is a random string nobody has seen, so + # the invitation is what makes the import mean anything to the person in it. + def record_creation(user) + InvitationsMailer.invite(user).deliver_later + import.increment!(:created_users) + end + def record_rejection(row_number, attributes, messages) import.row_errors.create!( row_number: row_number, diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index 3c34c8148..58fc0cb9d 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -1,4 +1,13 @@ class ApplicationMailer < ActionMailer::Base - default from: "from@example.com" + default from: ENV.fetch("MAIL_FROM", "roster@example.com") layout "mailer" + + private + + # Mail is delivered by a worker, long after the request whose locale was the + # reader's. Without this every message would go out in the default language, + # whatever the person chose in their account. + def in_the_language_of(user, &) + I18n.with_locale(user.locale.presence || I18n.default_locale, &) + end end diff --git a/app/mailers/invitations_mailer.rb b/app/mailers/invitations_mailer.rb new file mode 100644 index 000000000..d558a10b5 --- /dev/null +++ b/app/mailers/invitations_mailer.rb @@ -0,0 +1,13 @@ +# Sent to people who were created by an import rather than by signing up: the +# account exists, they have never seen a password, and this is how they learn +# both facts. +class InvitationsMailer < ApplicationMailer + def invite(user) + @user = user + @token = user.generate_token_for(:invitation) + + in_the_language_of(user) do + mail subject: t("invitations_mailer.invite.subject"), to: user.email_address + end + end +end diff --git a/app/mailers/passwords_mailer.rb b/app/mailers/passwords_mailer.rb index 06d1bc5dc..be05b63c6 100644 --- a/app/mailers/passwords_mailer.rb +++ b/app/mailers/passwords_mailer.rb @@ -1,6 +1,9 @@ class PasswordsMailer < ApplicationMailer def reset(user) @user = user - mail subject: t("passwords_mailer.reset.subject"), to: user.email_address + + in_the_language_of(user) do + mail subject: t("passwords_mailer.reset.subject"), to: user.email_address + end end end diff --git a/app/models/user.rb b/app/models/user.rb index 0d8130c6a..aa91268e1 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -15,7 +15,16 @@ class User < ApplicationRecord # none of them belong in an img src. AVATAR_URL_SCHEMES = %w[http https].freeze + # An invitation lasts a week, not the fifteen minutes has_secure_password + # gives a password reset: somebody imported at two in the morning should not + # find a dead link. Like the reset token, it is derived from the password + # salt, so it stops working the moment a password is set. + INVITATION_VALID_FOR = 7.days + has_secure_password + generates_token_for :invitation, expires_in: INVITATION_VALID_FOR do + password_salt&.last(10) + end has_many :sessions, dependent: :destroy # Nullified rather than destroyed: removing an administrator must not erase # the record of the imports they ran. Without this the foreign key raised. diff --git a/app/views/invitations_mailer/invite.html.erb b/app/views/invitations_mailer/invite.html.erb new file mode 100644 index 000000000..7f1c74cfc --- /dev/null +++ b/app/views/invitations_mailer/invite.html.erb @@ -0,0 +1,16 @@ +

<%= t(".heading") %>

+ +

+ <%= t(".body", name: @user.full_name) %> +

+ +

+ <%= link_to t(".action"), edit_password_url(@token), + style: "display: inline-block; padding: 10px 18px; border-radius: 8px; " \ + "background: #2f5d47; color: #ffffff; font-size: 15px; text-decoration: none;" %> +

+ +

+ <%= t(".expires", duration: distance_of_time_in_words(0, User::INVITATION_VALID_FOR)) %> + <%= t(".sign_in_with", email_address: @user.email_address) %> +

diff --git a/app/views/invitations_mailer/invite.text.erb b/app/views/invitations_mailer/invite.text.erb new file mode 100644 index 000000000..cfcc64893 --- /dev/null +++ b/app/views/invitations_mailer/invite.text.erb @@ -0,0 +1,8 @@ +<%= t(".heading") %> + +<%= t(".body", name: @user.full_name) %> + +<%= edit_password_url(@token) %> + +<%= t(".expires", duration: distance_of_time_in_words(0, User::INVITATION_VALID_FOR)) %> +<%= t(".sign_in_with", email_address: @user.email_address) %> diff --git a/app/views/passwords/edit.html.erb b/app/views/passwords/edit.html.erb index b1a22ef5b..ab71716fd 100644 --- a/app/views/passwords/edit.html.erb +++ b/app/views/passwords/edit.html.erb @@ -1,12 +1,18 @@ <% content_for :title, t(".title") %>
-

<%= t(".title") %>

-

<%= t(".subtitle") %>

+ <%# The same form greets an invited person and someone who forgot their + password; only the words change. %> +

+ <%= @invited ? t(".invited_title") : t(".title") %> +

+

+ <%= @invited ? t(".invited_subtitle") : t(".subtitle") %> +

<%= form_with url: password_path(params[:token]), method: :put, class: "mt-6" do |form| %>
- <%= form.label :password, t(".password"), class: "field-label" %> + <%= form.label :password, @invited ? t(".invited_password") : t(".password"), class: "field-label" %> <%= form.password_field :password, required: true, autofocus: true, autocomplete: "new-password", maxlength: 72, aria: { describedby: "password-hint" }, class: "field-input" %> @@ -14,7 +20,9 @@
- <%= form.label :password_confirmation, t(".password_confirmation"), class: "field-label" %> + <%= form.label :password_confirmation, + @invited ? t(".invited_password_confirmation") : t(".password_confirmation"), + class: "field-label" %> <%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", maxlength: 72, class: "field-input" %>
diff --git a/config/locales/en.yml b/config/locales/en.yml index bb2154ceb..623e03b3c 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -188,6 +188,7 @@ en: throttled: "Too many attempts. Please try again later." reset_instructions_sent: "Password reset instructions sent (if an account with that email address exists)." reset: "Your password has been reset." + chosen: "Your password is set. You can sign in now." mismatch: "Passwords did not match." invalid_token: "That password reset link is invalid or has expired." new: @@ -204,6 +205,10 @@ en: password_hint: "At least 8 characters." password_confirmation: "Confirm new password" submit: "Save password" + invited_title: "Welcome. Choose your password" + invited_subtitle: "Your account is ready; it only needs a password of your own." + invited_password: "Password" + invited_password_confirmation: "Confirm password" passwords_mailer: reset: @@ -214,6 +219,15 @@ en: expires: "The link expires in %{duration}." ignore: "If you did not ask for this, nothing has changed and you can ignore this message." + invitations_mailer: + invite: + subject: "Your Roster account is ready" + heading: "Your account is ready" + body: "Hello %{name}. An account was created for you on Roster. Choose a password and it is yours." + action: "Choose a password" + expires: "The link is valid for %{duration}." + sign_in_with: "You will sign in with %{email_address}." + activerecord: errors: models: diff --git a/config/locales/es.yml b/config/locales/es.yml index 638f13ea1..269379aa2 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -188,6 +188,7 @@ es: throttled: "Demasiados intentos. Inténtalo de nuevo más tarde." reset_instructions_sent: "Hemos enviado las instrucciones (si existe una cuenta con ese correo electrónico)." reset: "Tu contraseña se ha restablecido." + chosen: "Contraseña establecida. Ya puedes iniciar sesión." mismatch: "Las contraseñas no coinciden." invalid_token: "Ese enlace de restablecimiento no es válido o ha caducado." new: @@ -204,6 +205,10 @@ es: password_hint: "Al menos 8 caracteres." password_confirmation: "Confirma la contraseña nueva" submit: "Guardar contraseña" + invited_title: "Te damos la bienvenida. Elige tu contraseña" + invited_subtitle: "Tu cuenta ya existe; solo le falta una contraseña tuya." + invited_password: "Contraseña" + invited_password_confirmation: "Confirma la contraseña" passwords_mailer: reset: @@ -214,6 +219,15 @@ es: expires: "El enlace caduca en %{duration}." ignore: "Si no lo has pedido, no ha cambiado nada y puedes ignorar este mensaje." + invitations_mailer: + invite: + subject: "Tu cuenta de Roster está lista" + heading: "Tu cuenta está lista" + body: "Hola, %{name}. Hemos creado una cuenta para ti en Roster. Elige una contraseña y será tuya." + action: "Elegir una contraseña" + expires: "El enlace es válido durante %{duration}." + sign_in_with: "Iniciarás sesión con %{email_address}." + activerecord: errors: models: diff --git a/config/locales/pt-BR.yml b/config/locales/pt-BR.yml index 810a9e2dd..3b00f8a28 100644 --- a/config/locales/pt-BR.yml +++ b/config/locales/pt-BR.yml @@ -188,6 +188,7 @@ pt-BR: throttled: "Tentativas demais. Tente novamente em alguns instantes." reset_instructions_sent: "Enviamos as instruções de redefinição (caso exista uma conta com esse e-mail)." reset: "Sua senha foi redefinida." + chosen: "Senha definida. Agora é só entrar." mismatch: "As senhas não coincidem." invalid_token: "Este link de redefinição é inválido ou expirou." new: @@ -204,6 +205,10 @@ pt-BR: password_hint: "Ao menos 8 caracteres." password_confirmation: "Confirme a nova senha" submit: "Salvar senha" + invited_title: "Boas-vindas. Escolha sua senha" + invited_subtitle: "Sua conta já existe; falta só uma senha sua." + invited_password: "Senha" + invited_password_confirmation: "Confirme a senha" passwords_mailer: reset: @@ -214,6 +219,15 @@ pt-BR: expires: "O link expira em %{duration}." ignore: "Se você não pediu isso, nada mudou e pode ignorar esta mensagem." + invitations_mailer: + invite: + subject: "Sua conta no Roster está pronta" + heading: "Sua conta está pronta" + body: "Olá, %{name}. Criamos uma conta para você no Roster. Escolha uma senha e ela é sua." + action: "Escolher uma senha" + expires: "O link vale por %{duration}." + sign_in_with: "Você vai entrar com %{email_address}." + activerecord: errors: models: diff --git a/spec/jobs/process_user_import_job_spec.rb b/spec/jobs/process_user_import_job_spec.rb index 74a9a0381..025ba7d4b 100644 --- a/spec/jobs/process_user_import_job_spec.rb +++ b/spec/jobs/process_user_import_job_spec.rb @@ -30,6 +30,15 @@ def run(import) expect(User.find_by(email_address: "maria@example.com")).to be_user end + # An imported account has a random password nobody has seen, so without an + # invitation the person has no way in and no way of knowing they exist. + it "invites everybody it creates" do + import = import_for("users.csv") + + expect { run(import) } + .to have_enqueued_mail(InvitationsMailer, :invite).exactly(3).times + end + it "records what it did" do import = run(import_for("users.csv")) diff --git a/spec/mailers/invitations_mailer_spec.rb b/spec/mailers/invitations_mailer_spec.rb new file mode 100644 index 000000000..6b9556018 --- /dev/null +++ b/spec/mailers/invitations_mailer_spec.rb @@ -0,0 +1,32 @@ +require "rails_helper" + +RSpec.describe InvitationsMailer do + it "tells the person their account exists and how to claim it" do + user = create(:user, full_name: "Maria Silva", email_address: "maria@example.com") + + mail = described_class.invite(user) + + expect(mail.to).to eq(["maria@example.com"]) + expect(mail.subject).to eq(I18n.t("invitations_mailer.invite.subject")) + expect(mail.body.encoded).to include("Maria Silva", "maria@example.com") + end + + it "carries a link that opens the password screen" do + user = create(:user) + + mail = described_class.invite(user) + token = mail.body.encoded[%r{/passwords/([^/\s"]+)/edit}, 1] + + expect(token).to be_present + expect(User.find_by_token_for(:invitation, CGI.unescape(token))).to eq(user) + end + + it "is written in the language the person chose" do + user = create(:user, locale: "pt-BR") + + mail = described_class.invite(user) + + expect(mail.subject).to eq(I18n.t("invitations_mailer.invite.subject", locale: "pt-BR")) + expect(I18n.locale).to eq(:en) + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 716214fb4..2c1ab841f 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -20,4 +20,7 @@ config.use_transactional_fixtures = true config.infer_spec_type_from_file_location! config.filter_rails_from_backtrace! + + # travel / travel_to / freeze_time, for the specs about tokens that expire. + config.include ActiveSupport::Testing::TimeHelpers end diff --git a/spec/requests/passwords_spec.rb b/spec/requests/passwords_spec.rb index 419aaeeaa..f1339399b 100644 --- a/spec/requests/passwords_spec.rb +++ b/spec/requests/passwords_spec.rb @@ -70,4 +70,46 @@ expect(user.reload.authenticate("a-brand-new-password")).to be(false) end end + + # An invited person arrives at the same screen through a different token. + describe "the invitation link" do + let(:invitation) { user.generate_token_for(:invitation) } + + it "opens the password screen, worded as a welcome" do + get edit_password_path(invitation) + + expect(response).to have_http_status(:ok) + expect(response.body).to include(I18n.t("passwords.edit.invited_title")) + end + + it "sets the first password" do + patch password_path(invitation), params: { + password: "a-brand-new-password", password_confirmation: "a-brand-new-password" + } + + expect(response).to redirect_to(new_session_path) + expect(user.reload.authenticate("a-brand-new-password")).to eq(user) + end + + it "stops working once a password has been set, since the salt has changed" do + used = invitation + patch password_path(used), params: { + password: "a-brand-new-password", password_confirmation: "a-brand-new-password" + } + + get edit_password_path(used) + + expect(response).to redirect_to(new_password_path) + end + + it "expires" do + token = invitation + + travel(User::INVITATION_VALID_FOR + 1.day) do + get edit_password_path(token) + + expect(response).to redirect_to(new_password_path) + end + end + end end From fdc8a819de36b0d4c9ff0ada63c7c7ce99dbbeda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B4nio=20Neto?= Date: Fri, 4 Sep 2026 08:26:46 -0300 Subject: [PATCH 18/33] perf: make the search use an index instead of reading every row `ILIKE '%term%'` has a leading wildcard, so a B-tree has nothing to seek on and PostgreSQL read the whole table for every search. Measured on 50,000 rows: 23.9 ms, growing with the roster. A trigram index per column does not fix it -- the planner compares two GIN scans against one sequential scan and takes the sequential scan. So the two columns become one: a stored generated column that PostgreSQL keeps in step with the name and the address, and a single GIN trigram index over it. Same query, same results, 0.095 ms. The index is built concurrently, outside a transaction, so a deploy against a table with volume in it does not lock writes while it runs. Terms shorter than three characters cannot use a trigram index and still scan; script/benchmarks/search.rb reproduces both numbers, and the README says so rather than claiming a speed-up that does not apply. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BX7yhokJ4UWtAHDnBmr46W --- app/models/user.rb | 8 +++-- ...04140000_index_the_search_with_trigrams.rb | 25 ++++++++++++++++ db/schema.rb | 5 +++- script/benchmarks/search.rb | 29 +++++++++++++++++++ 4 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 db/migrate/20260904140000_index_the_search_with_trigrams.rb create mode 100644 script/benchmarks/search.rb diff --git a/app/models/user.rb b/app/models/user.rb index aa91268e1..80e731344 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -59,11 +59,15 @@ class User < ApplicationRecord # happens to add, remove or re-role somebody. after_commit :broadcast_user_counters, if: :counters_affected? + # `searchable_text` is a stored generated column -- the name and the address + # concatenated by PostgreSQL -- carrying a trigram index. Searching one + # indexed expression rather than two columns joined by OR is what lets the + # planner use the index at all: with an index per column it compares two GIN + # scans against one sequential scan and chooses the sequential scan. scope :search, lambda { |term| next all if term.blank? - pattern = "%#{sanitize_sql_like(term.to_s.strip)}%" - where("full_name ILIKE :term OR email_address ILIKE :term", term: pattern) + where("searchable_text ILIKE :term", term: "%#{sanitize_sql_like(term.to_s.strip)}%") } scope :with_role, lambda { |role| diff --git a/db/migrate/20260904140000_index_the_search_with_trigrams.rb b/db/migrate/20260904140000_index_the_search_with_trigrams.rb new file mode 100644 index 000000000..4bfd1b68d --- /dev/null +++ b/db/migrate/20260904140000_index_the_search_with_trigrams.rb @@ -0,0 +1,25 @@ +# The search is `ILIKE '%term%'` over two columns, which no ordinary B-tree +# index can serve: a leading wildcard has nothing to seek on, so PostgreSQL +# reads every row. Trigram indexes do serve it, but one per column is worse +# than none here -- the planner compares two GIN scans against one sequential +# scan and picks the sequential scan. +# +# So the two columns become one: a stored generated column that PostgreSQL +# keeps in step with them, and a single trigram index over it. +# +# Built concurrently, and therefore outside a transaction, so a deploy against +# a table with real volume in it does not lock writes while the index is built. +class IndexTheSearchWithTrigrams < ActiveRecord::Migration[8.1] + disable_ddl_transaction! + + def change + enable_extension "pg_trgm" + + add_column :users, :searchable_text, :virtual, type: :string, + as: "full_name || ' ' || email_address", stored: true + + add_index :users, :searchable_text, using: :gin, opclass: :gin_trgm_ops, + name: "index_users_on_searchable_text_trigrams", + algorithm: :concurrently + end +end diff --git a/db/schema.rb b/db/schema.rb index 8feafeaff..55f0f9c9d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,9 +10,10 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_09_04_113000) do +ActiveRecord::Schema[8.1].define(version: 2026_09_04_140000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" + enable_extension "pg_trgm" create_table "active_storage_attachments", force: :cascade do |t| t.bigint "blob_id", null: false @@ -91,9 +92,11 @@ t.string "locale", default: "en", null: false t.string "password_digest", null: false t.integer "role", default: 0, null: false + t.virtual "searchable_text", type: :string, as: "(((full_name)::text || ' '::text) || (email_address)::text)", stored: true t.datetime "updated_at", null: false t.index "lower((email_address)::text)", name: "index_users_on_lower_email_address", unique: true t.index ["role"], name: "index_users_on_role" + t.index ["searchable_text"], name: "index_users_on_searchable_text_trigrams", opclass: :gin_trgm_ops, using: :gin t.check_constraint "locale::text = ANY (ARRAY['en'::character varying, 'pt-BR'::character varying, 'es'::character varying]::text[])", name: "users_locale_supported" t.check_constraint "role = ANY (ARRAY[0, 1])", name: "users_role_within_enum" end diff --git a/script/benchmarks/search.rb b/script/benchmarks/search.rb new file mode 100644 index 000000000..58cea1ade --- /dev/null +++ b/script/benchmarks/search.rb @@ -0,0 +1,29 @@ +# Reproduces the numbers the README quotes for the search index. +# +# bin/rails runner script/benchmarks/search.rb +# +# Needs a roster large enough for the planner to have a choice; the README says +# how it was generated. Development only -- it reads, it does not write. +connection = ActiveRecord::Base.connection +connection.execute("ANALYZE users") + +def timing(connection, sql) + connection.select_values("EXPLAIN (ANALYZE, TIMING OFF) #{sql}") +end + +puts "rows: #{User.count}" + +%w[silva ma].each do |term| + sql = User.search(term).to_sql + puts "\n=== term #{term.inspect} (#{term.length} characters) ===" + + connection.execute("SET enable_bitmapscan = on; SET enable_indexscan = on") + with_index = timing(connection, sql) + puts "with the index: #{with_index.first.split("(cost").first.strip}" + puts " #{with_index.grep(/Execution Time/).first}" + + connection.execute("SET enable_bitmapscan = off; SET enable_indexscan = off") + without = timing(connection, sql) + puts "sequential scan: #{without.first.split("(cost").first.strip}" + puts " #{without.grep(/Execution Time/).first}" +end From 72c556b82e7cdacf19a31c91fd278dc4d46d5892 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B4nio=20Neto?= Date: Fri, 4 Sep 2026 08:31:28 -0300 Subject: [PATCH 19/33] feat: keep a record of what administrators did A system whose whole purpose is administering accounts should be able to say who promoted whom, and when. That question cannot be answered from the users table: the row that would tell you is the row that changed. Every administrative action now writes an audit event -- created, updated, promoted, demoted, deleted, imported -- readable at /admin/activity, newest first, administrators only, and read-only by design: a trail that can be edited from the interface it records is not a trail. Written from the actions themselves rather than from a model callback. The actor is a fact about the request; a callback would have to go looking for it in thread-local state, and would fire for the seeds and the console too, attributing everything to nobody. Both sides are nullified rather than cascaded, and the two addresses are copied onto the row at the moment of the event, so a trail still reads after either account is gone -- which is exactly when it is read. A deletion is recorded after the fact, when the row it refers to no longer exists, so the reference is dropped and the address remains. The details never carry the password digest. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BX7yhokJ4UWtAHDnBmr46W --- .../admin/audit_events_controller.rb | 13 +++ app/controllers/admin/users_controller.rb | 18 ++++ app/helpers/application_helper.rb | 12 +++ app/jobs/process_user_import_job.rb | 3 + app/models/audit_event.rb | 52 ++++++++++ app/views/admin/audit_events/index.html.erb | 52 ++++++++++ app/views/shared/_sidebar.html.erb | 2 + config/locales/en.yml | 25 +++++ config/locales/es.yml | 25 +++++ config/locales/pt-BR.yml | 25 +++++ config/routes.rb | 2 + .../20260904150000_create_audit_events.rb | 26 +++++ db/schema.rb | 19 +++- spec/factories/audit_events.rb | 10 ++ spec/jobs/process_user_import_job_spec.rb | 11 +++ spec/models/audit_event_spec.rb | 47 +++++++++ spec/requests/admin/audit_events_spec.rb | 98 +++++++++++++++++++ spec/system/accessibility_spec.rb | 8 ++ spec/system/administrator_journey_spec.rb | 15 +++ 19 files changed, 462 insertions(+), 1 deletion(-) create mode 100644 app/controllers/admin/audit_events_controller.rb create mode 100644 app/models/audit_event.rb create mode 100644 app/views/admin/audit_events/index.html.erb create mode 100644 db/migrate/20260904150000_create_audit_events.rb create mode 100644 spec/factories/audit_events.rb create mode 100644 spec/models/audit_event_spec.rb create mode 100644 spec/requests/admin/audit_events_spec.rb diff --git a/app/controllers/admin/audit_events_controller.rb b/app/controllers/admin/audit_events_controller.rb new file mode 100644 index 000000000..ebe7d587a --- /dev/null +++ b/app/controllers/admin/audit_events_controller.rb @@ -0,0 +1,13 @@ +module Admin + # The record of what administrators did. Read only, by design: a trail that + # can be edited from the interface it records is not a trail. + class AuditEventsController < BaseController + include Pagy::Backend + + def index + @pagy, @audit_events = pagy( + AuditEvent.recent_first.includes(:actor, :subject), limit: 25 + ) + end + end +end diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index b34f51c6c..a0ce8b6c7 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -19,6 +19,7 @@ def create @user = User.new(user_params) if @user.save + record_event(:created) redirect_to admin_users_path, notice: t(".created", name: @user.full_name) else render :new, status: :unprocessable_content @@ -26,7 +27,10 @@ def create end def update + previous_role = @user.role + if @user.update(user_params) + record_event(action_for_update(previous_role), AuditEvent.changes_worth_recording(@user)) redirect_to admin_users_path, notice: t(".updated", name: @user.full_name) else render :edit, status: :unprocessable_content @@ -35,6 +39,7 @@ def update def destroy if @user.destroy + record_event(:deleted) redirect_to admin_users_path, notice: t(".deleted", name: @user.full_name), status: :see_other else redirect_to admin_users_path, alert: @user.errors.full_messages.to_sentence, status: :see_other @@ -43,6 +48,19 @@ def destroy private + # Recorded here rather than in a model callback: the actor is a fact about + # the request, and a callback would attribute the seeds and the console to + # nobody at all. + def record_event(action, details = {}) + AuditEvent.record!(action: action, actor: Current.user, subject: @user, details: details) + end + + def action_for_update(previous_role) + return :updated if @user.role == previous_role + + @user.admin? ? :promoted : :demoted + end + def set_user @user = User.find(params.expect(:id)) end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 7c4ea6d15..e73530dad 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -30,6 +30,18 @@ def avatar_tag(user, size:, classes: nil) end end + # An audit row's details are a small JSON object -- which columns changed, or + # which import a row came from. Rendered as a sentence rather than as JSON, + # and never showing the old and new values of anything sensitive. + def audit_event_details(event) + return t("admin.audit_events.details_import", id: event.details["user_import_id"]) if event.imported? + + changed = event.details.keys.map { |name| User.human_attribute_name(name) } + return "—" if changed.empty? + + changed.to_sentence + end + def role_badge(user) tag.span t("roles.#{user.role}"), class: "badge #{user.admin? ? "badge-admin" : "badge-user"}" diff --git a/app/jobs/process_user_import_job.rb b/app/jobs/process_user_import_job.rb index ff03fc768..33a8efd6f 100644 --- a/app/jobs/process_user_import_job.rb +++ b/app/jobs/process_user_import_job.rb @@ -89,6 +89,9 @@ def build_user(attributes) # the invitation is what makes the import mean anything to the person in it. def record_creation(user) InvitationsMailer.invite(user).deliver_later + AuditEvent.record!(action: :imported, actor: import.administrator, + actor_email: import.administrator_email, subject: user, + details: { user_import_id: import.id }) import.increment!(:created_users) end diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb new file mode 100644 index 000000000..de31f85c6 --- /dev/null +++ b/app/models/audit_event.rb @@ -0,0 +1,52 @@ +# A record of what an administrator did to somebody else's account. Written +# from the places that perform those actions rather than from a model callback: +# the actor is a fact about the request, and a callback would have to go +# looking for it in thread-local state -- or would fire for the seeds, the +# console and the import alike, all attributed to nobody. +class AuditEvent < ApplicationRecord + # What is written as the actor when nobody was signed in: a seed, the + # console, a job with no administrator behind it. + SYSTEM = "system".freeze + + belongs_to :actor, class_name: "User", optional: true + belongs_to :subject, class_name: "User", optional: true + + enum :action, + { created: 0, updated: 1, promoted: 2, demoted: 3, deleted: 4, imported: 5 }, + validate: true + + validates :actor_email, :subject_email, presence: true + + scope :recent_first, -> { order(created_at: :desc, id: :desc) } + + class << self + # The addresses are copied here, at the moment of the event, so the trail + # still reads once either account is gone. A deletion is recorded after the + # fact, when the row it refers to no longer exists -- hence `persisted?` + # rather than the record itself. + def record!(action:, subject:, actor: nil, actor_email: nil, details: {}) + create!( + action: action, + actor: (actor if actor&.persisted?), + actor_email: actor_email.presence || actor&.email_address || SYSTEM, + subject: (subject if subject.persisted?), + subject_email: subject.email_address, + details: details + ) + end + + # What an update actually changed, without the noise: the password digest + # is not reported, and neither is a column nobody asked about. + def changes_worth_recording(user) + user.previous_changes.except("updated_at", "password_digest", "searchable_text") + end + end + + def actor_name + actor&.full_name || actor_email + end + + def subject_name + subject&.full_name || subject_email + end +end diff --git a/app/views/admin/audit_events/index.html.erb b/app/views/admin/audit_events/index.html.erb new file mode 100644 index 000000000..baeb65cf0 --- /dev/null +++ b/app/views/admin/audit_events/index.html.erb @@ -0,0 +1,52 @@ +<% content_for :title, t(".title") %> +<% content_for :page_title, t(".title") %> +<% content_for :page_subtitle, t(".subtitle") %> + +
+
+

<%= t(".heading") %>

+

<%= t(".results", count: @pagy.count) %>

+
+ + <% if @audit_events.any? %> +
+ + + + + + + + + + + + <% @audit_events.each do |event| %> + + + + + + + + <% end %> + +
<%= t(".when") %><%= t(".who") %><%= t(".what") %><%= t(".to_whom") %><%= t(".details") %>
+ + <%= event.actor_name %> + "> + <%= t("admin.audit_events.actions.#{event.action}") %> + + <%= event.subject_name %><%= audit_event_details(event) %>
+
+ + <%= render "shared/pagination", pagy: @pagy %> + <% else %> +
+

<%= t(".empty_title") %>

+

<%= t(".empty_copy") %>

+
+ <% end %> +
diff --git a/app/views/shared/_sidebar.html.erb b/app/views/shared/_sidebar.html.erb index 70fb75214..9eb6526ce 100644 --- a/app/views/shared/_sidebar.html.erb +++ b/app/views/shared/_sidebar.html.erb @@ -12,6 +12,8 @@ aria: { current: controller_path == "admin/users" ? "page" : nil } %> <%= link_to t("shared.nav.imports"), admin_user_imports_path, class: "rail-item", aria: { current: controller_path == "admin/user_imports" ? "page" : nil } %> + <%= link_to t("shared.nav.activity"), admin_audit_events_path, class: "rail-item", + aria: { current: controller_path == "admin/audit_events" ? "page" : nil } %> <% end %> <%= link_to t("shared.nav.my_profile"), profile_path, class: "rail-item", diff --git a/config/locales/en.yml b/config/locales/en.yml index 623e03b3c..148d3092f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -12,6 +12,7 @@ en: dashboard: "Dashboard" users: "Users" imports: "Imports" + activity: "Activity" my_profile: "My profile" sign_out: "Sign out" sign_out_confirm: "Sign out of Roster?" @@ -145,6 +146,30 @@ en: submit: "Save changes" cancel: "Cancel" + audit_events: + index: + title: "Activity" + subtitle: "What administrators did, and when" + heading: "History" + results: + one: "1 event" + other: "%{count} events" + when: "When" + who: "Who" + what: "What" + to_whom: "To whom" + details: "Details" + empty_title: "Nothing has happened yet" + empty_copy: "Creating, changing or removing an account will show up here." + actions: + created: "Created" + updated: "Updated" + promoted: "Promoted" + demoted: "Demoted" + deleted: "Deleted" + imported: "Imported" + details_import: "From import #%{id}" + user_imports: scheduled: "The file is queued. Progress appears below as it runs." index: diff --git a/config/locales/es.yml b/config/locales/es.yml index 269379aa2..9a395e6f8 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -12,6 +12,7 @@ es: dashboard: "Panel" users: "Usuarios" imports: "Importaciones" + activity: "Actividad" my_profile: "Mi perfil" sign_out: "Cerrar sesión" sign_out_confirm: "¿Cerrar sesión en Roster?" @@ -145,6 +146,30 @@ es: submit: "Guardar cambios" cancel: "Cancelar" + audit_events: + index: + title: "Actividad" + subtitle: "Lo que hicieron los administradores, y cuándo" + heading: "Historial" + results: + one: "1 evento" + other: "%{count} eventos" + when: "Cuándo" + who: "Quién" + what: "Qué" + to_whom: "Sobre quién" + details: "Detalles" + empty_title: "Todavía no ha pasado nada" + empty_copy: "Crear, cambiar o eliminar una cuenta aparecerá aquí." + actions: + created: "Creó" + updated: "Actualizó" + promoted: "Promovió" + demoted: "Degradó" + deleted: "Eliminó" + imported: "Importó" + details_import: "De la importación n.º %{id}" + user_imports: scheduled: "El archivo está en cola. El progreso aparece abajo mientras se ejecuta." index: diff --git a/config/locales/pt-BR.yml b/config/locales/pt-BR.yml index 3b00f8a28..2d0c2e615 100644 --- a/config/locales/pt-BR.yml +++ b/config/locales/pt-BR.yml @@ -12,6 +12,7 @@ pt-BR: dashboard: "Painel" users: "Usuários" imports: "Importações" + activity: "Atividade" my_profile: "Meu perfil" sign_out: "Sair" sign_out_confirm: "Sair do Roster?" @@ -145,6 +146,30 @@ pt-BR: submit: "Salvar alterações" cancel: "Cancelar" + audit_events: + index: + title: "Atividade" + subtitle: "O que os administradores fizeram, e quando" + heading: "Histórico" + results: + one: "1 evento" + other: "%{count} eventos" + when: "Quando" + who: "Quem" + what: "O quê" + to_whom: "Sobre quem" + details: "Detalhes" + empty_title: "Ainda não aconteceu nada" + empty_copy: "Criar, alterar ou remover uma conta aparece aqui." + actions: + created: "Criou" + updated: "Alterou" + promoted: "Promoveu" + demoted: "Rebaixou" + deleted: "Removeu" + imported: "Importou" + details_import: "Da importação #%{id}" + user_imports: scheduled: "O arquivo entrou na fila. O progresso aparece abaixo conforme roda." index: diff --git a/config/routes.rb b/config/routes.rb index 54af3d9e0..57b5dd493 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -14,6 +14,8 @@ # application itself advertises. resources :users, except: :show + resources :audit_events, only: :index, path: "activity" + resources :user_imports, only: %i[index new create show] do get :template, on: :collection get :rejected_rows, on: :member diff --git a/db/migrate/20260904150000_create_audit_events.rb b/db/migrate/20260904150000_create_audit_events.rb new file mode 100644 index 000000000..7a9379196 --- /dev/null +++ b/db/migrate/20260904150000_create_audit_events.rb @@ -0,0 +1,26 @@ +# Who changed whom, and when. A system whose whole purpose is administering +# accounts should be able to answer that question, and it cannot be answered +# from the users table: the row that would tell you is the one that changed. +class CreateAuditEvents < ActiveRecord::Migration[8.1] + def change + create_table :audit_events do |t| + # Both sides are nullable and nullified, because an audit trail that + # disappears when an account does is not an audit trail. The addresses + # are copied so the record still reads after either is gone. + t.references :actor, foreign_key: { to_table: :users, on_delete: :nullify } + t.references :subject, foreign_key: { to_table: :users, on_delete: :nullify } + t.string :actor_email, null: false + t.string :subject_email, null: false + + t.integer :action, null: false + t.jsonb :details, null: false, default: {} + + # No updated_at: an audit row is written once and never edited. + t.datetime :created_at, null: false + end + + add_index :audit_events, :created_at + add_index :audit_events, :action + add_check_constraint :audit_events, "action BETWEEN 0 AND 5", name: "audit_events_action_within_enum" + end +end diff --git a/db/schema.rb b/db/schema.rb index 55f0f9c9d..9e6fa13fc 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_04_140000) do +ActiveRecord::Schema[8.1].define(version: 2026_09_04_150000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pg_trgm" @@ -43,6 +43,21 @@ t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true end + create_table "audit_events", force: :cascade do |t| + t.integer "action", null: false + t.string "actor_email", null: false + t.bigint "actor_id" + t.datetime "created_at", null: false + t.jsonb "details", default: {}, null: false + t.string "subject_email", null: false + t.bigint "subject_id" + t.index ["action"], name: "index_audit_events_on_action" + t.index ["actor_id"], name: "index_audit_events_on_actor_id" + t.index ["created_at"], name: "index_audit_events_on_created_at" + t.index ["subject_id"], name: "index_audit_events_on_subject_id" + t.check_constraint "action >= 0 AND action <= 5", name: "audit_events_action_within_enum" + end + create_table "sessions", force: :cascade do |t| t.datetime "created_at", null: false t.string "ip_address" @@ -103,6 +118,8 @@ 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 "audit_events", "users", column: "actor_id", on_delete: :nullify + add_foreign_key "audit_events", "users", column: "subject_id", on_delete: :nullify add_foreign_key "sessions", "users" add_foreign_key "user_import_errors", "user_imports" add_foreign_key "user_imports", "users", column: "administrator_id" diff --git a/spec/factories/audit_events.rb b/spec/factories/audit_events.rb new file mode 100644 index 000000000..b7db6d831 --- /dev/null +++ b/spec/factories/audit_events.rb @@ -0,0 +1,10 @@ +FactoryBot.define do + factory :audit_event do + actor factory: %i[user admin] + subject factory: :user + action { :created } + + actor_email { actor.email_address } + subject_email { subject.email_address } + end +end diff --git a/spec/jobs/process_user_import_job_spec.rb b/spec/jobs/process_user_import_job_spec.rb index 025ba7d4b..fb7a2d19b 100644 --- a/spec/jobs/process_user_import_job_spec.rb +++ b/spec/jobs/process_user_import_job_spec.rb @@ -39,6 +39,17 @@ def run(import) .to have_enqueued_mail(InvitationsMailer, :invite).exactly(3).times end + it "records who imported whom" do + administrator = create(:user, :admin, email_address: "ada@example.com") + + run(import_for("users.csv", administrator: administrator)) + + events = AuditEvent.imported + expect(events.count).to eq(3) + expect(events.first.actor).to eq(administrator) + expect(events.first.details["user_import_id"]).to be_present + end + it "records what it did" do import = run(import_for("users.csv")) diff --git a/spec/models/audit_event_spec.rb b/spec/models/audit_event_spec.rb new file mode 100644 index 000000000..882b46381 --- /dev/null +++ b/spec/models/audit_event_spec.rb @@ -0,0 +1,47 @@ +require "rails_helper" + +RSpec.describe AuditEvent do + describe ".record!" do + it "copies both addresses, so the trail reads after an account is gone" do + actor = create(:user, :admin, email_address: "ada@example.com") + subject_user = create(:user, full_name: "Maria Silva", email_address: "maria@example.com") + + event = described_class.record!(action: :deleted, actor: actor, subject: subject_user) + subject_user.destroy! + + expect(event.reload.subject).to be_nil + expect(event.subject_name).to eq("maria@example.com") + expect(event.actor_name).to eq(actor.full_name) + end + + it "attributes an action with nobody behind it to the system" do + event = described_class.record!(action: :created, actor: nil, subject: create(:user)) + + expect(event.actor_email).to eq(described_class::SYSTEM) + end + + it "takes an address for an actor who no longer exists" do + event = described_class.record!(action: :imported, actor: nil, actor_email: "grace@example.com", + subject: create(:user)) + + expect(event.actor_name).to eq("grace@example.com") + end + end + + describe ".changes_worth_recording" do + it "reports the columns that changed and never the password digest" do + user = create(:user, full_name: "Maria Silva") + + user.update!(full_name: "Maria Silva Santos", password: "another-long-password") + + expect(described_class.changes_worth_recording(user).keys).to eq(["full_name"]) + end + end + + it "keeps the newest event first" do + older = create(:audit_event, created_at: 2.days.ago) + newer = create(:audit_event, created_at: 1.hour.ago) + + expect(described_class.recent_first.to_a).to eq([newer, older]) + end +end diff --git a/spec/requests/admin/audit_events_spec.rb b/spec/requests/admin/audit_events_spec.rb new file mode 100644 index 000000000..472a189cf --- /dev/null +++ b/spec/requests/admin/audit_events_spec.rb @@ -0,0 +1,98 @@ +require "rails_helper" + +RSpec.describe "Admin activity" do + let(:administrator) { create(:user, :admin, full_name: "Ada Lovelace") } + + describe "GET /admin/activity" do + before { sign_in administrator } + + it "lists what administrators did, newest first" do + create(:audit_event, actor: administrator, subject: create(:user, full_name: "Maria Silva"), + action: :promoted, created_at: 1.hour.ago) + create(:audit_event, actor: administrator, subject: create(:user, full_name: "Joao Souza"), + action: :deleted, created_at: 2.days.ago) + + get admin_audit_events_path + + expect(response).to have_http_status(:ok) + expect(response.body.index("Maria Silva")).to be < response.body.index("Joao Souza") + expect(response.body).to include(I18n.t("admin.audit_events.actions.promoted")) + end + + it "says so when nothing has happened" do + get admin_audit_events_path + + expect(response.body).to include(I18n.t("admin.audit_events.index.empty_title")) + end + end + + it "keeps a regular user out" do + sign_in create(:user) + + get admin_audit_events_path + + expect(response).to redirect_to(profile_path) + end + + # The trail is written from the actions themselves, so these read like the + # administration screens rather than like the model. + describe "what gets recorded" do + before { sign_in administrator } + + it "records the creation of an account, and who did it" do + expect do + post admin_users_path, params: { + user: { full_name: "Maria Silva", email_address: "maria@example.com", + password: "a-sufficiently-long-password" } + } + end.to change(AuditEvent, :count).by(1) + + expect(AuditEvent.last).to have_attributes( + action: "created", actor: administrator, subject_email: "maria@example.com" + ) + end + + it "tells a promotion from an ordinary change" do + user = create(:user) + + patch admin_user_path(user), params: { user: { role: "admin" } } + + expect(AuditEvent.last.action).to eq("promoted") + end + + it "tells a demotion from an ordinary change" do + create(:user, :admin) + colleague = create(:user, :admin) + + patch admin_user_path(colleague), params: { user: { role: "user" } } + + expect(AuditEvent.last.action).to eq("demoted") + end + + it "records which columns an ordinary change touched" do + user = create(:user, full_name: "Maria Silva") + + patch admin_user_path(user), params: { user: { full_name: "Maria Silva Santos" } } + + expect(AuditEvent.last).to have_attributes(action: "updated") + expect(AuditEvent.last.details.keys).to include("full_name") + end + + it "records a deletion, and survives the account it refers to" do + user = create(:user, email_address: "maria@example.com") + + delete admin_user_path(user) + + expect(AuditEvent.last).to have_attributes(action: "deleted", subject: nil, + subject_email: "maria@example.com") + end + + it "writes nothing when the change was refused" do + user = create(:user) + + expect do + patch admin_user_path(user), params: { user: { email_address: "not an address" } } + end.not_to change(AuditEvent, :count) + end + end +end diff --git a/spec/system/accessibility_spec.rb b/spec/system/accessibility_spec.rb index f697be3df..e7f4243c4 100644 --- a/spec/system/accessibility_spec.rb +++ b/spec/system/accessibility_spec.rb @@ -81,6 +81,14 @@ def expect_the_page_to_be_accessible expect_the_page_to_be_accessible end + it "the activity trail" do + create(:audit_event, actor: administrator, subject: create(:user)) + + visit admin_audit_events_path + + expect_the_page_to_be_accessible + end + it "the imports screen" do visit admin_user_imports_path diff --git a/spec/system/administrator_journey_spec.rb b/spec/system/administrator_journey_spec.rb index 36f7e4085..a892825e0 100644 --- a/spec/system/administrator_journey_spec.rb +++ b/spec/system/administrator_journey_spec.rb @@ -72,6 +72,21 @@ expect(page).to have_text("Maria Silva has been deleted.") end + it "can read back what was done, and by whom" do + create(:user, full_name: "Maria Silva", email_address: "maria@example.com") + + visit admin_users_path + within("tr", text: "Maria Silva") { click_on "Edit" } + select "Administrator", from: "Role" + click_on "Save changes" + + click_on "Activity", match: :first + + expect(page).to have_text("Ada Lovelace") + expect(page).to have_text("Maria Silva") + expect(page).to have_text(I18n.t("admin.audit_events.actions.promoted")) + end + it "refuses to remove the only administrator left" do create(:user, full_name: "Maria Silva") From c081a39b4f4590c3c22badbc0157ae99228ec816 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B4nio=20Neto?= Date: Fri, 4 Sep 2026 08:45:03 -0300 Subject: [PATCH 20/33] feat: add a JSON API, documented by the specs that exercise it /api/v1 covers what the administration screens cover: a token endpoint, the account behind the token, and the five actions on users. The rules are not restated -- the role is only assignable by an administrator and the last administrator is protected, because both live in the model, which is the point of their living there. Every action writes the same audit event the HTML side writes. Authentication is a signed bearer token derived from the password salt. There is no table of secrets to leak, no revocation list to keep, and changing a password invalidates every token already issued. It lasts a day. The OpenAPI document is generated by rswag from the request specs that exercise the API, so it cannot describe an endpoint the application does not have or a field it does not return; CI regenerates it and fails if the committed copy has drifted. Swagger UI is at /api-docs. Two things found on the way. Swagger UI ships its own content security policy, and a browser enforces every policy it is sent, so ours and its intersected into a blank page -- the application's policy now steps aside for that mount alone. And `?page=` -- empty, negative, or not a number -- reached Pagy as zero and raised a 500 on both faces of the application; pagination now lives in one concern that decides what an unreasonable page means, and the page size stays bounded. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BX7yhokJ4UWtAHDnBmr46W --- .rubocop.yml | 23 ++ Gemfile | 6 + Gemfile.lock | 21 + .../admin/audit_events_controller.rb | 6 +- app/controllers/admin/users_controller.rb | 12 +- app/controllers/api/base_controller.rb | 44 ++ app/controllers/api/v1/profiles_controller.rb | 11 + app/controllers/api/v1/tokens_controller.rb | 30 ++ app/controllers/api/v1/users_controller.rb | 78 ++++ app/controllers/concerns/paginating.rb | 28 ++ app/models/user.rb | 9 + app/serializers/user_serializer.rb | 20 + config/ci.rb | 6 + config/initializers/rswag_api.rb | 5 + config/initializers/rswag_ui.rb | 38 ++ config/routes.rb | 14 + spec/requests/admin/users_spec.rb | 8 + spec/requests/api/v1/tokens_spec.rb | 83 ++++ spec/requests/api/v1/users_spec.rb | 215 ++++++++++ spec/spec_helper.rb | 47 ++- spec/swagger_helper.rb | 72 ++++ swagger/v1/swagger.yaml | 385 ++++++++++++++++++ 22 files changed, 1129 insertions(+), 32 deletions(-) create mode 100644 app/controllers/api/base_controller.rb create mode 100644 app/controllers/api/v1/profiles_controller.rb create mode 100644 app/controllers/api/v1/tokens_controller.rb create mode 100644 app/controllers/api/v1/users_controller.rb create mode 100644 app/controllers/concerns/paginating.rb create mode 100644 app/serializers/user_serializer.rb create mode 100644 config/initializers/rswag_api.rb create mode 100644 config/initializers/rswag_ui.rb create mode 100644 spec/requests/api/v1/tokens_spec.rb create mode 100644 spec/requests/api/v1/users_spec.rb create mode 100644 spec/swagger_helper.rb create mode 100644 swagger/v1/swagger.yaml diff --git a/.rubocop.yml b/.rubocop.yml index 273c55663..328407413 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -107,3 +107,26 @@ Lint/Debugger: Exclude: - "spec/system/screenshots_spec.rb" +# ── The OpenAPI specs ──────────────────────────────────────────────────────── +# spec/requests/api is written in rswag's DSL, where the shape of the file is +# the shape of the document: `path`/`get`/`response` blocks whose examples are +# generated by `run_test!`, and a `let(:Authorization)` whose name is the +# header it sets. Style cops written for ordinary examples read that as +# misspelled variables and empty groups. +RSpec/VariableName: + Exclude: + - "spec/requests/api/**/*_spec.rb" + +RSpec/EmptyExampleGroup: + Exclude: + - "spec/requests/api/**/*_spec.rb" + +RSpec/MultipleMemoizedHelpers: + Exclude: + - "spec/requests/api/**/*_spec.rb" + +Style/HashAsLastArrayItem: + Exclude: + # `security [bearer_auth: []]` is rswag's own notation for an OpenAPI + # security requirement. + - "spec/requests/api/**/*_spec.rb" diff --git a/Gemfile b/Gemfile index 5bf604879..ff2b16604 100644 --- a/Gemfile +++ b/Gemfile @@ -67,6 +67,11 @@ group :development, :test do gem "rubocop-rspec_rails", require: false end +# The API documentation is generated from the request specs that exercise the +# API, so it cannot describe something the application does not do. +gem "rswag-api" +gem "rswag-ui" + group :test do # Accessibility as a check rather than a claim: axe runs against the rendered # page in the system specs. The API gem carries the axe-core JavaScript; the @@ -74,6 +79,7 @@ group :test do # speaks Selenium and these specs drive Chrome over CDP. gem "axe-core-api" gem "capybara" + gem "rswag-specs" # Cuprite drives headless Chrome over CDP directly, which keeps system specs # fast and removes the chromedriver version dance. gem "cuprite" diff --git a/Gemfile.lock b/Gemfile.lock index b326b0147..bd16e88c6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -161,6 +161,9 @@ GEM rdoc (>= 4.0.0) reline (>= 0.4.2) json (2.21.2) + json-schema (6.2.0) + addressable (~> 2.8) + bigdecimal (>= 3.1, < 5) kamal (2.12.0) activesupport (>= 7.0) base64 (~> 0.2) @@ -324,6 +327,17 @@ GEM rspec-mocks (>= 3.13.0, < 5.0.0) rspec-support (>= 3.13.0, < 5.0.0) rspec-support (3.13.7) + rswag-api (2.17.0) + activesupport (>= 5.2, < 8.2) + railties (>= 5.2, < 8.2) + rswag-specs (2.17.0) + activesupport (>= 5.2, < 8.2) + json-schema (>= 2.2, < 7.0) + railties (>= 5.2, < 8.2) + rspec-core (>= 2.14) + rswag-ui (2.17.0) + actionpack (>= 5.2, < 8.2) + railties (>= 5.2, < 8.2) rubocop (1.90.0) json (>= 2.3) language_server-protocol (~> 3.17.0.2) @@ -465,6 +479,9 @@ DEPENDENCIES rails-i18n (~> 8.0) roo (~> 3.0) rspec-rails (~> 8.0) + rswag-api + rswag-specs + rswag-ui rubocop rubocop-capybara rubocop-factory_bot @@ -543,6 +560,7 @@ CHECKSUMS io-console (0.9.2) sha256=efa74f891dd03c0939a931dfc6e74c2813d904763d456ea9762b0525e748db08 irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a + json-schema (6.2.0) sha256=e8bff46ed845a22c1ab2bd0d7eccf831c01fe23bb3920caa4c74db4306813666 kamal (2.12.0) sha256=c51d1ab085e515470f98d0c0f043637122b5ebf76e8b610cb1fbbed0b7f9b8fa language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0 lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 @@ -608,6 +626,9 @@ CHECKSUMS rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47 rspec-rails (8.0.4) sha256=06235692fc0892683d3d34977e081db867434b3a24ae0dd0c6f3516bad4e22df rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c + rswag-api (2.17.0) sha256=728b336b65168ab8ab6024b0e5d267b485c22ccdeb9dfbfb6ec3bac423545a13 + rswag-specs (2.17.0) sha256=a3b2bdf6df89f8741fe4a4ee47ceb1e77dc13e1c96bbe07352117d6e61afa9e3 + rswag-ui (2.17.0) sha256=5f707b9b5e8171ddf9f519f6e401e79e419bd1d07387508603e76124f2443212 rubocop (1.90.0) sha256=9eb4c065b5c5154e4ef554c547972f3905a9eb6b53e657e580b6796b54bf8242 rubocop-ast (1.50.0) sha256=b9ca88300da0803ee222ad20cdb30494c0a784eed06fdc35d254b06d662788db rubocop-capybara (3.0.0) sha256=7a64655238acda7f8f3c87e37ac825a64c615a79c17c253f1a28270dc3768c4b diff --git a/app/controllers/admin/audit_events_controller.rb b/app/controllers/admin/audit_events_controller.rb index ebe7d587a..1d68f1c99 100644 --- a/app/controllers/admin/audit_events_controller.rb +++ b/app/controllers/admin/audit_events_controller.rb @@ -2,11 +2,11 @@ module Admin # The record of what administrators did. Read only, by design: a trail that # can be edited from the interface it records is not a trail. class AuditEventsController < BaseController - include Pagy::Backend + include Paginating def index - @pagy, @audit_events = pagy( - AuditEvent.recent_first.includes(:actor, :subject), limit: 25 + @pagy, @audit_events = paginate( + AuditEvent.recent_first.includes(:actor, :subject), default_limit: 25 ) end end diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index a0ce8b6c7..e56297c04 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -1,12 +1,12 @@ module Admin class UsersController < BaseController - include Pagy::Backend + include Paginating before_action :set_user, only: %i[edit update destroy] def index @role_counts = User.role_counts - @pagy, @users = pagy(filtered_users, limit: per_page) + @pagy, @users = paginate(filtered_users) end def new @@ -83,13 +83,5 @@ def filtered_users .ordered .includes(avatar_attachment: { blob: :variant_records }) end - - # Bounded so a hand-edited URL cannot ask for the whole table at once. - def per_page - requested = params[:per_page].to_i - return Pagy::DEFAULT[:limit] if requested <= 0 - - requested.clamp(1, 100) - end end end diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb new file mode 100644 index 000000000..ee917c336 --- /dev/null +++ b/app/controllers/api/base_controller.rb @@ -0,0 +1,44 @@ +module Api + # The JSON side of the application. It descends from ActionController::API + # rather than from ApplicationController: there is no session, no flash and + # no CSRF token here, because a bearer token is the whole of the credential. + class BaseController < ActionController::API + include ActionController::HttpAuthentication::Token::ControllerMethods + + before_action :authenticate + + rescue_from ActiveRecord::RecordNotFound, with: :not_found + + private + + attr_reader :current_user + + def authenticate + token = request.headers["Authorization"].to_s[/\ABearer (.+)\z/, 1] + @current_user = User.find_by_token_for(:api, token) if token.present? + + unauthorized if @current_user.nil? + end + + def require_admin + forbidden unless current_user.admin? + end + + def unauthorized + render json: { error: "unauthorized" }, status: :unauthorized + end + + def forbidden + render json: { error: "forbidden" }, status: :forbidden + end + + def not_found + render json: { error: "not_found" }, status: :not_found + end + + def unprocessable(record) + render json: { error: "invalid", details: record.errors.to_hash(true) }, + status: :unprocessable_content + end + end +end diff --git a/app/controllers/api/v1/profiles_controller.rb b/app/controllers/api/v1/profiles_controller.rb new file mode 100644 index 000000000..66c8adc9d --- /dev/null +++ b/app/controllers/api/v1/profiles_controller.rb @@ -0,0 +1,11 @@ +module Api + module V1 + # Whoever the token belongs to. Nothing here reads an identifier from the + # request, so there is nothing to tamper with. + class ProfilesController < BaseController + def show + render json: UserSerializer.new(current_user).as_json + end + end + end +end diff --git a/app/controllers/api/v1/tokens_controller.rb b/app/controllers/api/v1/tokens_controller.rb new file mode 100644 index 000000000..f9313cf53 --- /dev/null +++ b/app/controllers/api/v1/tokens_controller.rb @@ -0,0 +1,30 @@ +module Api + module V1 + # Exchanges an email address and a password for a bearer token. The token + # is signed rather than stored: there is no table of secrets to leak, and + # changing a password invalidates every token already issued. + class TokensController < BaseController + skip_before_action :authenticate + + rate_limit to: 10, within: 3.minutes, with: -> { render_throttled } + + def create + user = User.authenticate_by(email_address: params[:email_address].to_s, + password: params[:password].to_s) + return unauthorized if user.nil? + + render json: { + token: user.generate_token_for(:api), + expires_at: User::API_TOKEN_VALID_FOR.from_now.iso8601, + user: UserSerializer.new(user).as_json + }, status: :created + end + + private + + def render_throttled + render json: { error: "too_many_requests" }, status: :too_many_requests + end + end + end +end diff --git a/app/controllers/api/v1/users_controller.rb b/app/controllers/api/v1/users_controller.rb new file mode 100644 index 000000000..e582fd54d --- /dev/null +++ b/app/controllers/api/v1/users_controller.rb @@ -0,0 +1,78 @@ +module Api + module V1 + # The administrative side of the API. The rules it enforces are the same + # ones the HTML screens enforce, because they live in the model: the role + # is only assignable here, and the last administrator is protected there. + class UsersController < BaseController + include Paginating + + before_action :require_admin + before_action :set_user, only: %i[show update destroy] + + def index + pagy, users = paginate(filtered_users) + + render json: { + users: UserSerializer.collection(users), + pagination: { page: pagy.page, pages: pagy.pages, count: pagy.count, limit: pagy.limit } + } + end + + def show + render json: UserSerializer.new(@user).as_json + end + + def create + user = User.new(user_params) + + if user.save + AuditEvent.record!(action: :created, actor: current_user, subject: user) + render json: UserSerializer.new(user).as_json, status: :created + else + unprocessable(user) + end + end + + def update + previous_role = @user.role + + if @user.update(user_params) + AuditEvent.record!(action: action_for_update(previous_role), actor: current_user, + subject: @user, details: AuditEvent.changes_worth_recording(@user)) + render json: UserSerializer.new(@user).as_json + else + unprocessable(@user) + end + end + + def destroy + if @user.destroy + AuditEvent.record!(action: :deleted, actor: current_user, subject: @user) + head :no_content + else + unprocessable(@user) + end + end + + private + + def set_user + @user = User.find(params.expect(:id)) + end + + def user_params + params.expect(user: %i[full_name email_address role locale avatar_url password]) + end + + def filtered_users + User.search(params[:query]).with_role(params[:role]).ordered + end + + def action_for_update(previous_role) + return :updated if @user.role == previous_role + + @user.admin? ? :promoted : :demoted + end + end + end +end diff --git a/app/controllers/concerns/paginating.rb b/app/controllers/concerns/paginating.rb new file mode 100644 index 000000000..dc9d484e2 --- /dev/null +++ b/app/controllers/concerns/paginating.rb @@ -0,0 +1,28 @@ +# Pagination for both faces of the application, and the one place that decides +# what an unreasonable request means. A page number that is empty, negative or +# not a number at all reaches Pagy as zero and raises; a page size read from +# the query string would otherwise let a hand-edited URL ask for the whole +# table in one response. +module Paginating + extend ActiveSupport::Concern + include Pagy::Backend + + MAX_PER_PAGE = 100 + + private + + def paginate(scope, default_limit: Pagy::DEFAULT[:limit]) + pagy(scope, page: requested_page, limit: requested_limit(default_limit)) + end + + def requested_page + [params[:page].to_i, 1].max + end + + def requested_limit(default) + requested = params[:per_page].to_i + return default if requested <= 0 + + requested.clamp(1, MAX_PER_PAGE) + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 80e731344..4d02cd2f7 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -21,10 +21,19 @@ class User < ApplicationRecord # salt, so it stops working the moment a password is set. INVITATION_VALID_FOR = 7.days + # An API token lasts a day. Like the invitation and the reset token it is + # derived from the password salt, so changing a password revokes every token + # already issued -- there is no table of secrets to keep, and nothing to + # leak from one. + API_TOKEN_VALID_FOR = 24.hours + has_secure_password generates_token_for :invitation, expires_in: INVITATION_VALID_FOR do password_salt&.last(10) end + generates_token_for :api, expires_in: API_TOKEN_VALID_FOR do + password_salt&.last(10) + end has_many :sessions, dependent: :destroy # Nullified rather than destroyed: removing an administrator must not erase # the record of the imports they ran. Without this the foreign key raised. diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb new file mode 100644 index 000000000..9898da494 --- /dev/null +++ b/app/serializers/user_serializer.rb @@ -0,0 +1,20 @@ +# One place decides what the API says about a person, so a new column does not +# quietly become public by virtue of existing. +class UserSerializer + ATTRIBUTES = %i[id full_name email_address role locale created_at updated_at].freeze + + def initialize(user, avatar_url: nil) + @user = user + @avatar_url = avatar_url + end + + def self.collection(users, **) + users.map { |user| new(user, **).as_json } + end + + def as_json(*) + ATTRIBUTES.index_with { |attribute| @user.public_send(attribute) } + .merge("avatar_url" => @user.displayable_avatar_url) + .transform_keys(&:to_s) + end +end diff --git a/config/ci.rb b/config/ci.rb index f10d73ad8..8095477de 100644 --- a/config/ci.rb +++ b/config/ci.rb @@ -20,4 +20,10 @@ # test adapter, so the live updates are proven to reach a real browser over # a real websocket rather than only to have been broadcast. step "Tests: live updates", "CABLE_ADAPTER=solid_cable bundle exec rspec --tag live" + + # The OpenAPI document is generated from the specs that exercise the API, so + # regenerating it and finding a difference means the committed copy describes + # an API that no longer exists. + step "Docs: OpenAPI is current", + "bin/rails rswag:specs:swaggerize && git diff --exit-code --stat swagger" end diff --git a/config/initializers/rswag_api.rb b/config/initializers/rswag_api.rb new file mode 100644 index 000000000..99cd59f1e --- /dev/null +++ b/config/initializers/rswag_api.rb @@ -0,0 +1,5 @@ +Rswag::Api.configure do |c| + # Where `rswag:specs:swaggerize` writes the generated document, and where the + # middleware reads it from to serve /api-docs/v1/swagger.yaml. + c.openapi_root = Rails.root.join("swagger").to_s +end diff --git a/config/initializers/rswag_ui.rb b/config/initializers/rswag_ui.rb new file mode 100644 index 000000000..954b71f15 --- /dev/null +++ b/config/initializers/rswag_ui.rb @@ -0,0 +1,38 @@ +Rswag::Ui.configure do |c| + c.openapi_endpoint "/api-docs/v1/swagger.yaml", "Roster API v1" +end + +# Swagger UI ships its own content security policy -- `script-src 'self' +# 'unsafe-inline'`, which is what its inline bootstrap needs. Ours is stricter +# and, because a browser enforces every policy it is sent, the two together +# forbid everything the page does: no nonce, no script, a blank screen. +# +# So the application's policy steps aside for that one mount, and Swagger UI's +# own policy governs it. Nothing of ours is served from there: the engine +# serves its static assets and the generated document, and the document is +# public by design. +class ApiDocsWithoutTheApplicationPolicy + MOUNT = "/api-docs".freeze + + def initialize(app) + @app = app + end + + def call(env) + env["action_dispatch.content_security_policy"] = nil if documentation?(env) + + @app.call(env) + end + + private + + def documentation?(env) + path = env["PATH_INFO"].to_s + path == MOUNT || path.start_with?("#{MOUNT}/") + end +end + +Rails.application.config.middleware.insert_before( + ActionDispatch::ContentSecurityPolicy::Middleware, + ApiDocsWithoutTheApplicationPolicy +) diff --git a/config/routes.rb b/config/routes.rb index 57b5dd493..104803b1c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,8 @@ Rails.application.routes.draw do + # Swagger UI, and the generated OpenAPI document it reads. + mount Rswag::Ui::Engine => "/api-docs" + mount Rswag::Api::Engine => "/api-docs" + root "home#index" resource :locale, only: :update @@ -22,6 +26,16 @@ end end + # The JSON API. Versioned in the path from the first day, so the second + # version does not have to be a different application. + namespace :api do + namespace :v1 do + resource :token, only: :create, path: "tokens" + resource :profile, only: :show, path: "me" + resources :users + end + end + # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. get "up" => "rails/health#show", as: :rails_health_check end diff --git a/spec/requests/admin/users_spec.rb b/spec/requests/admin/users_spec.rb index 4cba91ca3..f333c71c1 100644 --- a/spec/requests/admin/users_spec.rb +++ b/spec/requests/admin/users_spec.rb @@ -75,6 +75,14 @@ expect(queries_for_eight).to eq(queries_for_two) end + it "survives a page number that is empty or nonsense" do + ["", "abc", "-3", "0"].each do |page| + get admin_users_path, params: { page: page } + + expect(response).to have_http_status(:ok) + end + end + it "paginates" do create_list(:user, 3) diff --git a/spec/requests/api/v1/tokens_spec.rb b/spec/requests/api/v1/tokens_spec.rb new file mode 100644 index 000000000..dbc6c91d6 --- /dev/null +++ b/spec/requests/api/v1/tokens_spec.rb @@ -0,0 +1,83 @@ +require "swagger_helper" + +RSpec.describe "Api::V1::Tokens" do + let(:password) { "a-sufficiently-long-password" } + + path "/api/v1/tokens" do + post "Exchanges credentials for a bearer token" do + tags "Authentication" + consumes "application/json" + produces "application/json" + security [] + + parameter name: :credentials, in: :body, required: true, schema: { + type: :object, + properties: { + email_address: { type: :string, format: :email, example: "ada@example.com" }, + password: { type: :string, format: :password } + }, + required: %w[email_address password] + } + + response "201", "a token, valid for 24 hours" do + schema type: :object, + properties: { + token: { type: :string }, + expires_at: { type: :string, format: :"date-time" }, + user: { "$ref" => "#/components/schemas/user" } + }, + required: %w[token expires_at user] + + let!(:user) { create(:user, email_address: "ada@example.com", password: password) } + let(:credentials) { { email_address: "ada@example.com", password: password } } + + run_test! do |response| + token = JSON.parse(response.body).fetch("token") + expect(User.find_by_token_for(:api, token)).to eq(user) + end + end + + response "401", "the address and the password do not match" do + schema "$ref" => "#/components/schemas/error" + + let!(:user) { create(:user, email_address: "ada@example.com", password: password) } + let(:credentials) { { email_address: "ada@example.com", password: "not the password" } } + + run_test! + end + + response "401", "no such account" do + let(:credentials) { { email_address: "nobody@example.com", password: password } } + + run_test! + end + end + end + + path "/api/v1/me" do + get "Returns the account the token belongs to" do + tags "Authentication" + produces "application/json" + security [bearer_auth: []] + + response "200", "the signed in account" do + schema "$ref" => "#/components/schemas/user" + + let(:user) { create(:user, full_name: "Ada Lovelace") } + let(:Authorization) { "Bearer #{user.generate_token_for(:api)}" } + + run_test! do |response| + expect(JSON.parse(response.body)["full_name"]).to eq("Ada Lovelace") + end + end + + response "401", "no token, or a token that has expired" do + schema "$ref" => "#/components/schemas/error" + + let(:Authorization) { "Bearer not-a-real-token" } + + run_test! + end + end + end +end diff --git a/spec/requests/api/v1/users_spec.rb b/spec/requests/api/v1/users_spec.rb new file mode 100644 index 000000000..8c1e4a26c --- /dev/null +++ b/spec/requests/api/v1/users_spec.rb @@ -0,0 +1,215 @@ +require "swagger_helper" + +RSpec.describe "Api::V1::Users" do + let(:administrator) { create(:user, :admin, full_name: "Ada Lovelace") } + let(:Authorization) { "Bearer #{administrator.generate_token_for(:api)}" } + + path "/api/v1/users" do + get "Lists the people in the system" do + tags "Users" + produces "application/json" + security [bearer_auth: []] + + parameter name: :query, in: :query, required: false, schema: { type: :string }, + description: "Matches a name or an email address" + parameter name: :role, in: :query, required: false, + schema: { type: :string, enum: %w[user admin] } + parameter name: :page, in: :query, required: false, schema: { type: :integer } + parameter name: :per_page, in: :query, required: false, + schema: { type: :integer, maximum: 100 }, + description: "Bounded, so a hand-edited URL cannot ask for the whole table" + + response "200", "a page of people" do + schema type: :object, + properties: { + users: { type: :array, items: { "$ref" => "#/components/schemas/user" } }, + pagination: { "$ref" => "#/components/schemas/pagination" } + }, + required: %w[users pagination] + + let(:query) { "maria" } + let(:role) { nil } + let(:page) { nil } + let(:per_page) { nil } + + before do + create(:user, full_name: "Maria Silva") + create(:user, full_name: "Joao Souza") + end + + run_test! do |response| + body = JSON.parse(response.body) + expect(body["users"].pluck("full_name")).to eq(["Maria Silva"]) + expect(body["pagination"]["count"]).to eq(1) + end + end + + response "403", "the token belongs to somebody who is not an administrator" do + schema "$ref" => "#/components/schemas/error" + + let(:Authorization) { "Bearer #{create(:user).generate_token_for(:api)}" } + + run_test! + end + + response "401", "no token" do + schema "$ref" => "#/components/schemas/error" + + let(:Authorization) { nil } + + run_test! + end + end + + post "Creates an account" do + tags "Users" + consumes "application/json" + produces "application/json" + security [bearer_auth: []] + + parameter name: :body, in: :body, required: true, schema: { + type: :object, + properties: { + user: { + type: :object, + properties: { + full_name: { type: :string, example: "Maria Silva" }, + email_address: { type: :string, format: :email, example: "maria@example.com" }, + password: { type: :string, format: :password, minLength: 8 }, + role: { type: :string, enum: %w[user admin] }, + locale: { type: :string, enum: User::SUPPORTED_LOCALES }, + avatar_url: { type: :string, nullable: true } + }, + required: %w[full_name email_address password] + } + }, + required: %w[user] + } + + response "201", "the account that was created" do + schema "$ref" => "#/components/schemas/user" + + let(:body) do + { user: { full_name: "Maria Silva", email_address: "maria@example.com", + password: "a-sufficiently-long-password" } } + end + + run_test! do + expect(User.find_by(email_address: "maria@example.com")).to be_user + expect(AuditEvent.last).to have_attributes(action: "created", actor: administrator) + end + end + + response "422", "the account was refused" do + schema "$ref" => "#/components/schemas/error" + + let(:body) { { user: { full_name: "", email_address: "not an address", password: "short" } } } + + run_test! do |response| + expect(JSON.parse(response.body)["details"]).to include("email_address") + end + end + end + end + + path "/api/v1/users/{id}" do + parameter name: :id, in: :path, required: true, schema: { type: :integer } + + get "Returns one person" do + tags "Users" + produces "application/json" + security [bearer_auth: []] + + response "200", "the person" do + schema "$ref" => "#/components/schemas/user" + + let(:id) { create(:user, full_name: "Maria Silva").id } + + run_test! do |response| + expect(JSON.parse(response.body)["full_name"]).to eq("Maria Silva") + end + end + + response "404", "nobody with that id" do + schema "$ref" => "#/components/schemas/error" + + let(:id) { 0 } + + run_test! + end + end + + patch "Updates a person" do + tags "Users" + consumes "application/json" + produces "application/json" + security [bearer_auth: []] + + parameter name: :body, in: :body, required: true, schema: { + type: :object, + properties: { + user: { + type: :object, + properties: { + full_name: { type: :string }, + email_address: { type: :string, format: :email }, + password: { type: :string, format: :password, minLength: 8 }, + role: { type: :string, enum: %w[user admin] }, + locale: { type: :string, enum: User::SUPPORTED_LOCALES }, + avatar_url: { type: :string, nullable: true } + } + } + }, + required: %w[user] + } + + response "200", "the person as they now are" do + schema "$ref" => "#/components/schemas/user" + + let(:id) { create(:user).id } + let(:body) { { user: { role: "admin" } } } + + run_test! do + expect(User.find(id)).to be_admin + expect(AuditEvent.last.action).to eq("promoted") + end + end + + response "422", "the change was refused -- here, the last administrator" do + schema "$ref" => "#/components/schemas/error" + + let(:id) { administrator.id } + let(:body) { { user: { role: "user" } } } + + run_test! do + expect(administrator.reload).to be_admin + end + end + end + + delete "Removes a person" do + tags "Users" + produces "application/json" + security [bearer_auth: []] + + response "204", "removed" do + let(:id) { create(:user).id } + + run_test! do + expect(User.exists?(id)).to be(false) + expect(AuditEvent.last.action).to eq("deleted") + end + end + + response "422", "the only administrator left may not be removed" do + schema "$ref" => "#/components/schemas/error" + + let(:id) { administrator.id } + + run_test! do + expect(administrator.reload).to be_persisted + end + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 6534549e3..74e277c1f 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -6,30 +6,39 @@ # the full run instead. LIVE_CABLE_PASS = !ENV["CABLE_ADAPTER"].to_s.empty? -require "simplecov" +# `rswag:specs:swaggerize` re-runs the request specs with --dry-run to read +# their documentation without executing them. Nothing runs, so nothing is +# covered, and measuring that would only produce a false failure. +DOCUMENTATION_PASS = ARGV.include?("--dry-run") -SimpleCov.start "rails" do - enable_coverage :branch +# Nothing is executed on the documentation pass, so nothing would be covered: +# measuring it would overwrite the real report with an empty one. +unless DOCUMENTATION_PASS + require "simplecov" - # Each parallel worker writes its own result and SimpleCov merges them, so - # the 90% gate is measured against the whole suite rather than one shard. - command_name "rspec#{ENV.fetch("TEST_ENV_NUMBER", nil)}" - merging true - merge_timeout 600 + SimpleCov.start "rails" do + enable_coverage :branch - minimum_coverage line: 90, branch: 80 unless LIVE_CABLE_PASS + # Each parallel worker writes its own result and SimpleCov merges them, so + # the 90% gate is measured against the whole suite rather than one shard. + command_name "rspec#{ENV.fetch("TEST_ENV_NUMBER", nil)}" + merging true + merge_timeout 600 - # Excluded because they hold no logic of our own: the specs themselves, - # framework configuration and the generated schema files. - skip "/spec/" - skip "/config/" - skip "/db/" + minimum_coverage line: 90, branch: 80 unless LIVE_CABLE_PASS - group "Models", "app/models" - group "Controllers", "app/controllers" - group "Jobs", "app/jobs" - group "Views", "app/views" - group "Helpers", "app/helpers" + # Excluded because they hold no logic of our own: the specs themselves, + # framework configuration and the generated schema files. + skip "/spec/" + skip "/config/" + skip "/db/" + + group "Models", "app/models" + group "Controllers", "app/controllers" + group "Jobs", "app/jobs" + group "Views", "app/views" + group "Helpers", "app/helpers" + end end RSpec.configure do |config| diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb new file mode 100644 index 000000000..33998de63 --- /dev/null +++ b/spec/swagger_helper.rb @@ -0,0 +1,72 @@ +require "rails_helper" + +# The OpenAPI document is generated from the specs that exercise the API, so it +# cannot describe an endpoint the application does not have, or a field it does +# not return. `bin/rails rswag:specs:swaggerize` writes swagger/v1/swagger.yaml, +# and CI regenerates it and fails if the committed copy has drifted. +RSpec.configure do |config| + config.openapi_root = Rails.root.join("swagger").to_s + config.openapi_format = :yaml + + config.openapi_specs = { + "v1/swagger.yaml" => { + openapi: "3.0.1", + info: { + title: "Roster API", + version: "v1", + description: <<~TEXT + The JSON side of Roster. + + Authentication is a bearer token: POST an email address and password + to /api/v1/tokens and send the token back as + `Authorization: Bearer `. The token is signed rather than + stored, lasts 24 hours, and is invalidated by a password change. + + Everything under /api/v1/users requires an administrator. + TEXT + }, + servers: [ + { url: "http://localhost:3000", description: "Development" }, + { url: "https://{host}", description: "Deployment", + variables: { host: { default: "roster.example.com" } } } + ], + components: { + securitySchemes: { + bearer_auth: { type: :http, scheme: :bearer, bearerFormat: "signed token" } + }, + schemas: { + user: { + type: :object, + properties: { + id: { type: :integer, example: 1 }, + full_name: { type: :string, example: "Ada Lovelace" }, + email_address: { type: :string, format: :email, example: "ada@example.com" }, + role: { type: :string, enum: %w[user admin] }, + locale: { type: :string, enum: User::SUPPORTED_LOCALES }, + avatar_url: { type: :string, nullable: true }, + created_at: { type: :string, format: :"date-time" }, + updated_at: { type: :string, format: :"date-time" } + }, + required: %w[id full_name email_address role locale] + }, + pagination: { + type: :object, + properties: { + page: { type: :integer }, pages: { type: :integer }, + count: { type: :integer }, limit: { type: :integer } + }, + required: %w[page pages count limit] + }, + error: { + type: :object, + properties: { + error: { type: :string, example: "unauthorized" }, + details: { type: :object, nullable: true } + }, + required: %w[error] + } + } + } + } + } +end diff --git a/swagger/v1/swagger.yaml b/swagger/v1/swagger.yaml new file mode 100644 index 000000000..578c48dda --- /dev/null +++ b/swagger/v1/swagger.yaml @@ -0,0 +1,385 @@ +--- +openapi: 3.0.1 +info: + title: Roster API + version: v1 + description: | + The JSON side of Roster. + + Authentication is a bearer token: POST an email address and password + to /api/v1/tokens and send the token back as + `Authorization: Bearer `. The token is signed rather than + stored, lasts 24 hours, and is invalidated by a password change. + + Everything under /api/v1/users requires an administrator. +servers: +- url: http://localhost:3000 + description: Development +- url: https://{host} + description: Deployment + variables: + host: + default: roster.example.com +components: + securitySchemes: + bearer_auth: + type: http + scheme: bearer + bearerFormat: signed token + schemas: + user: + type: object + properties: + id: + type: integer + example: 1 + full_name: + type: string + example: Ada Lovelace + email_address: + type: string + format: email + example: ada@example.com + role: + type: string + enum: + - user + - admin + locale: + type: string + enum: + - en + - pt-BR + - es + avatar_url: + type: string + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + required: + - id + - full_name + - email_address + - role + - locale + pagination: + type: object + properties: + page: + type: integer + pages: + type: integer + count: + type: integer + limit: + type: integer + required: + - page + - pages + - count + - limit + error: + type: object + properties: + error: + type: string + example: unauthorized + details: + type: object + nullable: true + required: + - error +paths: + "/api/v1/tokens": + post: + summary: Exchanges credentials for a bearer token + tags: + - Authentication + security: [] + parameters: [] + responses: + '201': + description: a token, valid for 24 hours + content: + application/json: + schema: + type: object + properties: + token: + type: string + expires_at: + type: string + format: date-time + user: + "$ref": "#/components/schemas/user" + required: + - token + - expires_at + - user + '401': + description: no such account + content: + application/json: + schema: + "$ref": "#/components/schemas/error" + requestBody: + content: + application/json: + schema: + type: object + properties: + email_address: + type: string + format: email + example: ada@example.com + password: + type: string + format: password + required: + - email_address + - password + required: true + "/api/v1/me": + get: + summary: Returns the account the token belongs to + tags: + - Authentication + security: + - bearer_auth: [] + responses: + '200': + description: the signed in account + content: + application/json: + schema: + "$ref": "#/components/schemas/user" + '401': + description: no token, or a token that has expired + content: + application/json: + schema: + "$ref": "#/components/schemas/error" + "/api/v1/users": + get: + summary: Lists the people in the system + tags: + - Users + security: + - bearer_auth: [] + parameters: + - name: query + in: query + required: false + schema: + type: string + description: Matches a name or an email address + - name: role + in: query + required: false + schema: + type: string + enum: + - user + - admin + - name: page + in: query + required: false + schema: + type: integer + - name: per_page + in: query + required: false + schema: + type: integer + maximum: 100 + description: Bounded, so a hand-edited URL cannot ask for the whole table + responses: + '200': + description: a page of people + content: + application/json: + schema: + type: object + properties: + users: + type: array + items: + "$ref": "#/components/schemas/user" + pagination: + "$ref": "#/components/schemas/pagination" + required: + - users + - pagination + '403': + description: the token belongs to somebody who is not an administrator + content: + application/json: + schema: + "$ref": "#/components/schemas/error" + '401': + description: no token + content: + application/json: + schema: + "$ref": "#/components/schemas/error" + post: + summary: Creates an account + tags: + - Users + security: + - bearer_auth: [] + parameters: [] + responses: + '201': + description: the account that was created + content: + application/json: + schema: + "$ref": "#/components/schemas/user" + '422': + description: the account was refused + content: + application/json: + schema: + "$ref": "#/components/schemas/error" + requestBody: + content: + application/json: + schema: + type: object + properties: + user: + type: object + properties: + full_name: + type: string + example: Maria Silva + email_address: + type: string + format: email + example: maria@example.com + password: + type: string + format: password + minLength: 8 + role: + type: string + enum: + - user + - admin + locale: + type: string + enum: + - en + - pt-BR + - es + avatar_url: + type: string + nullable: true + required: + - full_name + - email_address + - password + required: + - user + required: true + "/api/v1/users/{id}": + parameters: + - name: id + in: path + required: true + schema: + type: integer + get: + summary: Returns one person + tags: + - Users + security: + - bearer_auth: [] + responses: + '200': + description: the person + content: + application/json: + schema: + "$ref": "#/components/schemas/user" + '404': + description: nobody with that id + content: + application/json: + schema: + "$ref": "#/components/schemas/error" + patch: + summary: Updates a person + tags: + - Users + security: + - bearer_auth: [] + parameters: [] + responses: + '200': + description: the person as they now are + content: + application/json: + schema: + "$ref": "#/components/schemas/user" + '422': + description: the change was refused -- here, the last administrator + content: + application/json: + schema: + "$ref": "#/components/schemas/error" + requestBody: + content: + application/json: + schema: + type: object + properties: + user: + type: object + properties: + full_name: + type: string + email_address: + type: string + format: email + password: + type: string + format: password + minLength: 8 + role: + type: string + enum: + - user + - admin + locale: + type: string + enum: + - en + - pt-BR + - es + avatar_url: + type: string + nullable: true + required: + - user + required: true + delete: + summary: Removes a person + tags: + - Users + security: + - bearer_auth: [] + responses: + '204': + description: removed + '422': + description: the only administrator left may not be removed + content: + application/json: + schema: + "$ref": "#/components/schemas/error" From 12174fb4156edcc98d720efdb378ca4b1b41ab5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B4nio=20Neto?= Date: Fri, 4 Sep 2026 08:45:53 -0300 Subject: [PATCH 21/33] feat: let a deployment put a password in front of the API docs The generated document describes a public API and is harmless to read, but publishing the shape of an installation is a choice, not a default somebody should have to edit code to change. Two environment variables turn on basic auth; without them the page stays open, which is what development wants. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BX7yhokJ4UWtAHDnBmr46W --- config/deploy.yml | 5 +++++ config/initializers/rswag_ui.rb | 8 ++++++++ 2 files changed, 13 insertions(+) diff --git a/config/deploy.yml b/config/deploy.yml index 0316542a1..54484f684 100644 --- a/config/deploy.yml +++ b/config/deploy.yml @@ -62,10 +62,15 @@ env: # the seed does nothing and the application still boots. # SEED_ADMIN_EMAIL: # SEED_ADMIN_NAME: + # Where the invitation and password-reset emails come from. + MAIL_FROM: + # Uncomment both to put a password in front of /api-docs. + # API_DOCS_USER: secret: - RAILS_MASTER_KEY - POSTGRES_PASSWORD # - SEED_ADMIN_PASSWORD + # - API_DOCS_PASSWORD # Uploaded avatars live on disk (config.active_storage.service = :local), so # the directory has to outlive the container. Moving to S3 is a change of one diff --git a/config/initializers/rswag_ui.rb b/config/initializers/rswag_ui.rb index 954b71f15..039f7f67b 100644 --- a/config/initializers/rswag_ui.rb +++ b/config/initializers/rswag_ui.rb @@ -1,5 +1,13 @@ Rswag::Ui.configure do |c| c.openapi_endpoint "/api-docs/v1/swagger.yaml", "Roster API v1" + + # The document describes a public API and is harmless to read, but a + # deployment that would rather not publish its shape can put a password in + # front of it without a code change. + if ENV["API_DOCS_USER"].present? && ENV["API_DOCS_PASSWORD"].present? + c.basic_auth_enabled = true + c.basic_auth_credentials ENV.fetch("API_DOCS_USER"), ENV.fetch("API_DOCS_PASSWORD") + end end # Swagger UI ships its own content security policy -- `script-src 'self' From f4a30e7873618d8587e0100ee651c1f2254e708e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B4nio=20Neto?= Date: Fri, 4 Sep 2026 09:07:42 -0300 Subject: [PATCH 22/33] chore: one script per action, the way the reference project does it The setup and start scripts had grown flags -- --reset, --no-seed, --detach, --down -- which is a small language to learn before doing anything. devops/app/ now holds one script per action: setup, start, stop, restart, status, seed, reset, logs. They read as sentences, they print what they are doing, and each one is short enough to read before running it. bin/setup and bin/dev stay, because short names for constant commands are worth having, but they no longer contain any logic: they delegate. devops/README.md lists every script in the directory, so finding the one that does what you need does not mean grepping. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BX7yhokJ4UWtAHDnBmr46W --- bin/dev | 24 ++++++------ bin/setup | 58 +++++------------------------ devops/README.md | 87 +++++++++++++++++++++++++++++++++++++++++++ devops/app/logs.sh | 6 +++ devops/app/reset.sh | 14 +++++++ devops/app/restart.sh | 13 +++++++ devops/app/seed.sh | 10 +++++ devops/app/setup.sh | 43 +++++++++++++++++++++ devops/app/start.sh | 21 +++++++++++ devops/app/status.sh | 15 ++++++++ devops/app/stop.sh | 10 +++++ 11 files changed, 239 insertions(+), 62 deletions(-) create mode 100644 devops/README.md create mode 100755 devops/app/logs.sh create mode 100755 devops/app/reset.sh create mode 100755 devops/app/restart.sh create mode 100755 devops/app/seed.sh create mode 100755 devops/app/setup.sh create mode 100755 devops/app/start.sh create mode 100755 devops/app/status.sh create mode 100755 devops/app/stop.sh diff --git a/bin/dev b/bin/dev index 001cc0e65..e1042d095 100755 --- a/bin/dev +++ b/bin/dev @@ -1,25 +1,23 @@ #!/usr/bin/env bash # -# Starts the development stack (web, worker, css, postgres) with Docker. +# Starts the development stack (web, worker, css, postgres). # -# bin/dev # foreground, streaming logs -# bin/dev --detach # background +# bin/dev # in the background +# bin/dev --attach # in the foreground, streaming logs # bin/dev --down # stop everything # +# Thin on purpose: each action is a script under devops/app/, which is where to +# look when one of them needs changing. +# # Without Docker, run the processes directly instead: # bundle exec foreman start -f Procfile.dev # set -euo pipefail -source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/devops/common.sh" - -require_docker -[[ -f .env ]] || fail "No .env found. Run bin/setup first." +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" case "${1:-}" in - --down) step "Stopping"; compose down; ok "Stopped." ;; - --detach) step "Starting in the background"; compose up --detach --wait - ok "Running at http://localhost:${WEB_PORT:-3000}" ;; - --help|-h) sed -n '2,12p' "$0" ;; - "") step "Starting"; compose up ;; - *) fail "Unknown option: $1" ;; + --down) exec "${ROOT}/devops/app/stop.sh" ;; + --status) exec "${ROOT}/devops/app/status.sh" ;; + --help|-h) sed -n '2,14p' "$0" ;; + *) exec "${ROOT}/devops/app/start.sh" "$@" ;; esac diff --git a/bin/setup b/bin/setup index 82b54bb65..99f7a1478 100755 --- a/bin/setup +++ b/bin/setup @@ -3,57 +3,17 @@ # Prepares the development environment from a clean checkout. # # bin/setup # build images, create databases, seed -# bin/setup --reset # additionally drop the existing data volume -# bin/setup --no-seed # skip seeding +# bin/setup --no-seed # skip the demonstration accounts +# +# The work itself is in devops/app/setup.sh, next to the other actions: +# start, stop, restart, status, seed, reset. # set -euo pipefail -source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/devops/common.sh" - -RESET_VOLUMES=false -SEED=true -for arg in "$@"; do - case "${arg}" in - --reset) RESET_VOLUMES=true ;; - --no-seed) SEED=false ;; - --help|-h) sed -n '2,9p' "$0"; exit 0 ;; - *) fail "Unknown option: ${arg}" ;; - esac -done - -require_docker +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -step "Environment file" -if [[ -f .env ]]; then - log ".env already exists, leaving it untouched." -else - cp .env.example .env - ok "Created .env from .env.example." +if [[ "${1:-}" == "--reset" ]]; then + shift + exec "${ROOT}/devops/app/reset.sh" "$@" fi -if [[ "${RESET_VOLUMES}" == true ]]; then - step "Resetting containers and data volume" - compose down --volumes --remove-orphans - ok "Previous environment removed." -fi - -step "Building images" -compose build - -step "Starting PostgreSQL" -compose up --detach --wait postgres - -step "Preparing databases" -# db:prepare creates and migrates all four databases: primary, cache, queue -# and cable. -compose run --rm --no-deps web ./bin/rails db:prepare - -if [[ "${SEED}" == true ]]; then - step "Seeding" - compose run --rm --no-deps web ./bin/rails db:seed -fi - -step "Starting the full stack" -compose up --detach --wait - -ok "Ready. The application is at http://localhost:${WEB_PORT:-3000}" -log "Logs: devops/rails/logs.sh Tests: bin/test Console: devops/rails/console.sh" +exec "${ROOT}/devops/app/setup.sh" "$@" diff --git a/devops/README.md b/devops/README.md new file mode 100644 index 000000000..9c07ad624 --- /dev/null +++ b/devops/README.md @@ -0,0 +1,87 @@ +# devops + +One script per action, each a thin wrapper over `docker compose`. The shared +helpers — logging, guards, and the `compose` wrapper that resolves the compose +file and the env file — live in `common.sh`; source it, do not execute it. + +Every script prints what it is doing and stops on the first failure. + +## The application + +```bash +devops/app/setup.sh # from a clean checkout: .env, images, databases, seeds +devops/app/setup.sh --no-seed # the same, without the demonstration accounts +devops/app/reset.sh # throw the environment away, including the data volume +devops/app/start.sh # start everything, in the background +devops/app/start.sh --attach # start in the foreground, streaming logs +devops/app/stop.sh # stop everything, keeping the data +devops/app/restart.sh # restart every service +devops/app/restart.sh web # restart one +devops/app/status.sh # what is running, and whether it answers +devops/app/seed.sh # run the seeds again +devops/app/logs.sh # follow every service at once +``` + +## Rails + +```bash +devops/rails/console.sh # a Rails console in the running container +devops/rails/logs.sh # follow the web logs +devops/rails/migrate.sh # run migrations +devops/rails/test.sh # the suite +devops/rails/test.sh spec/models/user_spec.rb +devops/rails/test-parallel.sh # across four workers, each with its own databases +devops/rails/lint.sh # RuboCop +devops/rails/security.sh # Brakeman, bundler-audit, importmap audit +``` + +## Worker + +```bash +devops/worker/logs.sh # follow the Solid Queue logs +devops/worker/status.sh # what the queue is doing +``` + +## PostgreSQL + +```bash +devops/postgres/psql.sh # an interactive session +devops/postgres/logs.sh # follow the database logs +devops/postgres/dump.sh # dump development to tmp/backups/ +devops/postgres/dump.sh tmp/backups/before-the-migration.dump +``` + +## Everything at once + +```bash +devops/tests/all.sh # lint, security analysis and the suite +``` + +## The `bin/` shortcuts + +`bin/` holds the handful of commands used often enough to deserve a shorter +name. They delegate here; the work is in this directory. + +| shortcut | runs | +| --- | --- | +| `bin/setup` | `devops/app/setup.sh` | +| `bin/setup --reset` | `devops/app/reset.sh` | +| `bin/dev` | `devops/app/start.sh` | +| `bin/dev --down` | `devops/app/stop.sh` | +| `bin/dev --status` | `devops/app/status.sh` | +| `bin/test` | `devops/rails/test.sh` | +| `bin/test --parallel` | `devops/rails/test-parallel.sh` | +| `bin/test --live` | `devops/rails/test.sh` with Solid Cable in place of the test adapter | +| `bin/lint` | `devops/rails/lint.sh` | +| `bin/ci` | the whole pipeline, defined once in `config/ci.rb` | + +## Environment + +Every script reads `.env` (created by `devops/app/setup.sh` from +`.env.example`) through the `compose` wrapper, so there is one place where the +compose file and the env file are resolved: + +```bash +COMPOSE_FILE_PATH=/path/to/docker-compose.yml devops/app/status.sh +ENV_FILE_PATH=/path/to/.env devops/app/start.sh +``` diff --git a/devops/app/logs.sh b/devops/app/logs.sh new file mode 100755 index 000000000..b44efddff --- /dev/null +++ b/devops/app/logs.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Follows the logs of every service at once. For one service, use the wrapper +# next to it: devops/rails/logs.sh, devops/worker/logs.sh, devops/postgres/logs.sh. +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/common.sh" +compose logs --follow --tail="${TAIL:-200}" "$@" diff --git a/devops/app/reset.sh b/devops/app/reset.sh new file mode 100755 index 000000000..425dbe8ed --- /dev/null +++ b/devops/app/reset.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Throws the environment away and builds it again: containers, the data volume +# and every database. Everything in development PostgreSQL is lost. +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/common.sh" +DEVOPS="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +require_docker + +step "Removing containers and the data volume" +compose down --volumes --remove-orphans +ok "Previous environment removed." + +"${DEVOPS}/app/setup.sh" "$@" diff --git a/devops/app/restart.sh b/devops/app/restart.sh new file mode 100755 index 000000000..f03d1051a --- /dev/null +++ b/devops/app/restart.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Restarts one service, or all of them. +# +# devops/app/restart.sh # everything +# devops/app/restart.sh web # one service +# +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/common.sh" + +require_docker +step "Restarting ${1:-every service}" +compose restart "$@" +compose ps diff --git a/devops/app/seed.sh b/devops/app/seed.sh new file mode 100755 index 000000000..50aa7a1f7 --- /dev/null +++ b/devops/app/seed.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Runs the seeds. Idempotent: running it twice changes nothing, and it puts +# back anything that was deleted while clicking around. +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/common.sh" + +require_running web +step "Seeding" +rails_exec ./bin/rails db:seed +ok "Seeded." diff --git a/devops/app/setup.sh b/devops/app/setup.sh new file mode 100755 index 000000000..06e207224 --- /dev/null +++ b/devops/app/setup.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Prepares the environment from a clean checkout: .env, images, the four +# databases, and the demonstration accounts. +# +# devops/app/setup.sh # everything +# devops/app/setup.sh --no-seed # without the demonstration accounts +# +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/common.sh" + +SEED=true +[[ "${1:-}" == "--no-seed" ]] && SEED=false + +require_docker + +step "Environment file" +if [[ -f .env ]]; then + log ".env already exists, leaving it untouched." +else + cp .env.example .env + ok "Created .env from .env.example." +fi + +step "Building images" +compose build + +step "Starting PostgreSQL" +compose up --detach --wait postgres + +step "Preparing databases" +# db:prepare creates and migrates all four: primary, cache, queue and cable. +compose run --rm --no-deps web ./bin/rails db:prepare + +if [[ "${SEED}" == true ]]; then + step "Seeding" + compose run --rm --no-deps web ./bin/rails db:seed +fi + +step "Starting the full stack" +compose up --detach --wait + +ok "Ready. The application is at http://localhost:${WEB_PORT:-3000}" +log "Logs: devops/rails/logs.sh Tests: bin/test Console: devops/rails/console.sh" diff --git a/devops/app/start.sh b/devops/app/start.sh new file mode 100755 index 000000000..54beb3246 --- /dev/null +++ b/devops/app/start.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Starts web, worker, the Tailwind watcher and PostgreSQL, and waits until they +# report healthy. +# +# devops/app/start.sh # in the background +# devops/app/start.sh --attach # in the foreground, streaming logs +# +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/common.sh" + +require_docker +[[ -f .env ]] || fail "No .env found. Run devops/app/setup.sh first." + +if [[ "${1:-}" == "--attach" ]]; then + step "Starting, streaming logs" + compose up +else + step "Starting" + compose up --detach --wait + ok "Running at http://localhost:${WEB_PORT:-3000}" +fi diff --git a/devops/app/status.sh b/devops/app/status.sh new file mode 100755 index 000000000..ba5ef8f21 --- /dev/null +++ b/devops/app/status.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# What is running, and whether it is healthy. +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/common.sh" + +require_docker +step "Services" +compose ps + +step "Health check" +if curl -sf "http://localhost:${WEB_PORT:-3000}/up" >/dev/null; then + ok "The application answers on http://localhost:${WEB_PORT:-3000}" +else + warn "The application is not answering on http://localhost:${WEB_PORT:-3000}" +fi diff --git a/devops/app/stop.sh b/devops/app/stop.sh new file mode 100755 index 000000000..2925d8cac --- /dev/null +++ b/devops/app/stop.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Stops every container. The data volume is left alone; devops/app/reset.sh is +# what removes it. +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/common.sh" + +require_docker +step "Stopping" +compose down +ok "Stopped." From 30afd0b8c5d1ca2ee9b21bb0a2cdf156638db9b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B4nio=20Neto?= Date: Fri, 4 Sep 2026 09:07:52 -0300 Subject: [PATCH 23/33] fix: put the error next to the field, and a heading on every page Validation errors were listed at the top of the form and the field only got aria-invalid: a screen reader announced that something was wrong with the input without saying what. Each field now carries its message underneath and points at it through aria-describedby, alongside the hint where there is one. The screens a visitor sees -- sign in, sign up, both password screens -- had no h1 at all. Their heading was an h2 because the h1 lives in the topbar, which only renders for somebody signed in. axe did not catch it: the rule that would is a best practice rather than a WCAG one, and the suite runs the WCAG 2.1 AA tags. And "responsive" is now measured rather than claimed: spec/system/responsive_layout_spec.rb drives the browser at 360 CSS pixels and fails if any screen scrolls sideways. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BX7yhokJ4UWtAHDnBmr46W --- app/helpers/application_helper.rb | 24 ++++++++++ app/models/user_import_template.rb | 4 +- app/views/admin/users/_form.html.erb | 22 ++++++--- app/views/passwords/edit.html.erb | 6 ++- app/views/passwords/new.html.erb | 2 +- app/views/profiles/edit.html.erb | 18 ++++++-- app/views/registrations/new.html.erb | 22 ++++++--- app/views/sessions/new.html.erb | 2 +- config/deploy.yml | 2 +- spec/system/responsive_layout_spec.rb | 65 +++++++++++++++++++++++++++ 10 files changed, 146 insertions(+), 21 deletions(-) create mode 100644 spec/system/responsive_layout_spec.rb diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index e73530dad..d7177955c 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -42,6 +42,30 @@ def audit_event_details(event) changed.to_sentence end + # The id of the element carrying an attribute's error message, or nil when + # there is nothing wrong with it. Used to point `aria-describedby` at the + # message, so a screen reader reads the problem with the field rather than + # only in the summary at the top of the form. + def field_error_id(record, attribute) + return if record.errors[attribute].empty? + + "#{record.model_name.param_key}-#{attribute}-error" + end + + # The message itself, rendered under the input. + def field_error(record, attribute) + messages = record.errors[attribute] + return if messages.empty? + + tag.p messages.to_sentence, id: field_error_id(record, attribute), class: "field-error" + end + + # Joins the ids an input is described by -- a hint, an error, or both -- + # dropping the ones that are not there. + def described_by(*ids) + ids.compact_blank.join(" ").presence + end + def role_badge(user) tag.span t("roles.#{user.role}"), class: "badge #{user.admin? ? "badge-admin" : "badge-user"}" diff --git a/app/models/user_import_template.rb b/app/models/user_import_template.rb index 321f53de1..bf11b24e5 100644 --- a/app/models/user_import_template.rb +++ b/app/models/user_import_template.rb @@ -22,8 +22,8 @@ module UserImportTemplate "avatar_url an http or https link. The server never downloads it; the", " browser loads it when the profile is shown.", "", - "Imported people get an unguessable password and set their own through", - "the 'forgot password' flow.", + "Imported people are emailed an invitation and choose their own password.", + "The link is valid for seven days; after that, 'forgot password' works.", "" ].freeze diff --git a/app/views/admin/users/_form.html.erb b/app/views/admin/users/_form.html.erb index e6fa2967b..73f21dc89 100644 --- a/app/views/admin/users/_form.html.erb +++ b/app/views/admin/users/_form.html.erb @@ -4,13 +4,17 @@
<%= form.label :full_name, t("admin.users.form.full_name"), class: "field-label" %> <%= form.text_field :full_name, required: true, autofocus: true, autocomplete: "name", - aria: { invalid: user.errors[:full_name].any? }, class: "field-input" %> + aria: { invalid: user.errors[:full_name].any?, + describedby: field_error_id(user, :full_name) }, class: "field-input" %> + <%= field_error(user, :full_name) %>
<%= form.label :email_address, t("admin.users.form.email_address"), class: "field-label" %> <%= form.email_field :email_address, required: true, autocomplete: "email", - aria: { invalid: user.errors[:email_address].any? }, class: "field-input" %> + aria: { invalid: user.errors[:email_address].any?, + describedby: field_error_id(user, :email_address) }, class: "field-input" %> + <%= field_error(user, :email_address) %>
@@ -23,26 +27,34 @@
<%= form.label :password, t("admin.users.form.password"), class: "field-label" %> <%= form.password_field :password, required: user.new_record?, autocomplete: "new-password", - maxlength: 72, aria: { describedby: "password-hint" }, class: "field-input" %> + maxlength: 72, + aria: { invalid: user.errors[:password].any?, + describedby: described_by("password-hint", field_error_id(user, :password)) }, + class: "field-input" %>

<%= user.new_record? ? t("admin.users.form.password_hint_new") : t("admin.users.form.password_hint_edit") %>

+ <%= field_error(user, :password) %>
<%= form.label :avatar, t("admin.users.form.avatar"), class: "field-label" %> <%= form.file_field :avatar, accept: User::AVATAR_CONTENT_TYPES.join(","), - aria: { describedby: "avatar-file-hint", invalid: user.errors[:avatar].any? }, + aria: { invalid: user.errors[:avatar].any?, + describedby: described_by("avatar-file-hint", field_error_id(user, :avatar)) }, class: "field-input" %>

<%= t("admin.users.form.avatar_hint") %>

+ <%= field_error(user, :avatar) %>
<%= form.label :avatar_url, t("admin.users.form.avatar_url"), class: "field-label" %> <%= form.url_field :avatar_url, autocomplete: "off", - aria: { describedby: "avatar-hint", invalid: user.errors[:avatar_url].any? }, + aria: { invalid: user.errors[:avatar_url].any?, + describedby: described_by("avatar-hint", field_error_id(user, :avatar_url)) }, class: "field-input" %>

<%= t("admin.users.form.avatar_url_hint") %>

+ <%= field_error(user, :avatar_url) %>
diff --git a/app/views/passwords/edit.html.erb b/app/views/passwords/edit.html.erb index ab71716fd..1e525b4ca 100644 --- a/app/views/passwords/edit.html.erb +++ b/app/views/passwords/edit.html.erb @@ -3,9 +3,9 @@
<%# The same form greets an invited person and someone who forgot their password; only the words change. %> -

+

<%= @invited ? t(".invited_title") : t(".title") %> -

+

<%= @invited ? t(".invited_subtitle") : t(".subtitle") %>

@@ -16,6 +16,8 @@ <%= form.password_field :password, required: true, autofocus: true, autocomplete: "new-password", maxlength: 72, aria: { describedby: "password-hint" }, class: "field-input" %> + <%# The token flow reports a mismatch as a flash rather than on the + record, so there is no per-field message to attach here. %>

<%= t(".password_hint") %>

diff --git a/app/views/passwords/new.html.erb b/app/views/passwords/new.html.erb index a61ba5c92..ec1634c5c 100644 --- a/app/views/passwords/new.html.erb +++ b/app/views/passwords/new.html.erb @@ -1,7 +1,7 @@ <% content_for :title, t(".title") %>
-

<%= t(".title") %>

+

<%= t(".title") %>

<%= t(".subtitle") %>

<%= form_with url: passwords_path, class: "mt-6" do |form| %> diff --git a/app/views/profiles/edit.html.erb b/app/views/profiles/edit.html.erb index 54b747080..fce46087c 100644 --- a/app/views/profiles/edit.html.erb +++ b/app/views/profiles/edit.html.erb @@ -9,13 +9,17 @@
<%= form.label :full_name, t(".full_name"), class: "field-label" %> <%= form.text_field :full_name, required: true, autocomplete: "name", - aria: { invalid: @user.errors[:full_name].any? }, class: "field-input" %> + aria: { invalid: @user.errors[:full_name].any?, + describedby: field_error_id(@user, :full_name) }, class: "field-input" %> + <%= field_error(@user, :full_name) %>
<%= form.label :email_address, t(".email_address"), class: "field-label" %> <%= form.email_field :email_address, required: true, autocomplete: "email", - aria: { invalid: @user.errors[:email_address].any? }, class: "field-input" %> + aria: { invalid: @user.errors[:email_address].any?, + describedby: field_error_id(@user, :email_address) }, class: "field-input" %> + <%= field_error(@user, :email_address) %>
@@ -27,9 +31,12 @@
<%= form.label :avatar, t(".avatar_file"), class: "sr-only" %> <%= form.file_field :avatar, accept: User::AVATAR_CONTENT_TYPES.join(","), - aria: { describedby: "avatar-file-hint", invalid: @user.errors[:avatar].any? }, + aria: { invalid: @user.errors[:avatar].any?, + describedby: described_by("avatar-file-hint", + field_error_id(@user, :avatar)) }, class: "field-input" %>

<%= t(".avatar_hint") %>

+ <%= field_error(@user, :avatar) %>
@@ -46,9 +53,12 @@
<%= form.label :avatar_url, t(".avatar_url"), class: "field-label" %> <%= form.url_field :avatar_url, autocomplete: "off", - aria: { describedby: "avatar-url-hint", invalid: @user.errors[:avatar_url].any? }, + aria: { invalid: @user.errors[:avatar_url].any?, + describedby: described_by("avatar-url-hint", + field_error_id(@user, :avatar_url)) }, class: "field-input" %>

<%= t(".avatar_url_hint") %>

+ <%= field_error(@user, :avatar_url) %>
diff --git a/app/views/registrations/new.html.erb b/app/views/registrations/new.html.erb index 3d484f634..c47f9b40e 100644 --- a/app/views/registrations/new.html.erb +++ b/app/views/registrations/new.html.erb @@ -1,7 +1,7 @@ <% content_for :title, t(".title") %>
-

<%= t(".title") %>

+

<%= t(".title") %>

<%= t(".subtitle") %>

<%= form_with model: @user, url: registration_path, class: "mt-6" do |form| %> @@ -10,26 +10,38 @@
<%= form.label :full_name, t(".full_name"), class: "field-label" %> <%= form.text_field :full_name, required: true, autofocus: true, autocomplete: "name", - aria: { invalid: @user.errors[:full_name].any? }, class: "field-input" %> + aria: { invalid: @user.errors[:full_name].any?, + describedby: field_error_id(@user, :full_name) }, class: "field-input" %> + <%= field_error(@user, :full_name) %>
<%= form.label :email_address, t(".email_address"), class: "field-label" %> <%= form.email_field :email_address, required: true, autocomplete: "email", - aria: { invalid: @user.errors[:email_address].any? }, class: "field-input" %> + aria: { invalid: @user.errors[:email_address].any?, + describedby: field_error_id(@user, :email_address) }, class: "field-input" %> + <%= field_error(@user, :email_address) %>
<%= form.label :password, t(".password"), class: "field-label" %> <%= form.password_field :password, required: true, autocomplete: "new-password", - maxlength: 72, aria: { describedby: "password-hint" }, class: "field-input" %> + maxlength: 72, + aria: { invalid: @user.errors[:password].any?, + describedby: described_by("password-hint", field_error_id(@user, :password)) }, + class: "field-input" %>

<%= t(".password_hint") %>

+ <%= field_error(@user, :password) %>
<%= form.label :password_confirmation, t(".password_confirmation"), class: "field-label" %> <%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", - maxlength: 72, class: "field-input" %> + maxlength: 72, + aria: { invalid: @user.errors[:password_confirmation].any?, + describedby: field_error_id(@user, :password_confirmation) }, + class: "field-input" %> + <%= field_error(@user, :password_confirmation) %>
<%= form.submit t(".submit"), class: "btn btn-primary w-full" %> diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index b33fc5050..14754329f 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -1,7 +1,7 @@ <% content_for :title, t(".title") %>
-

<%= t(".title") %>

+

<%= t(".title") %>

<%= t(".subtitle") %>

<%= form_with url: session_path, class: "mt-6" do |form| %> diff --git a/config/deploy.yml b/config/deploy.yml index 54484f684..10e847de0 100644 --- a/config/deploy.yml +++ b/config/deploy.yml @@ -84,7 +84,7 @@ asset_path: /rails/public/assets accessories: postgres: - image: postgres:18 + image: postgres:17 host: port: "127.0.0.1:5432:5432" env: diff --git a/spec/system/responsive_layout_spec.rb b/spec/system/responsive_layout_spec.rb new file mode 100644 index 000000000..c54e6019c --- /dev/null +++ b/spec/system/responsive_layout_spec.rb @@ -0,0 +1,65 @@ +require "rails_helper" + +# The claim "responsive" is easy to make and easy to break: one table without a +# scroll container of its own, and the whole page scrolls sideways on a phone. +# So it is measured -- at 360 CSS pixels, which is narrower than the phones +# people actually carry. +RSpec.describe "The layout on a small screen", :js do + let(:password) { "a-sufficiently-long-password" } + let(:administrator) { create(:user, :admin, full_name: "Ada Lovelace", password: password) } + + def expect_no_sideways_scrolling + overflow = page.evaluate_script(<<~JS) + document.documentElement.scrollWidth - document.documentElement.clientWidth + JS + + expect(overflow).to be <= 0 + end + + before do + page.driver.resize(360, 760) + + visit new_session_path + fill_in "Email address", with: administrator.email_address + fill_in "Password", with: password + click_on "Sign in" + has_current_path?(admin_dashboard_path, wait: 5) + end + + it "fits the sign-in screen" do + click_on "Sign out", match: :first + + expect(page).to have_current_path(new_session_path) + expect_no_sideways_scrolling + end + + it "fits the dashboard" do + expect(page).to have_text(/total users/i) + expect_no_sideways_scrolling + end + + it "fits the list of users, table and all" do + create_list(:user, 3) + + visit admin_users_path + + expect(page).to have_text("Add user") + expect_no_sideways_scrolling + end + + it "fits a form" do + visit new_admin_user_path + + expect(page).to have_field("Full name") + expect_no_sideways_scrolling + end + + it "fits the activity trail" do + create(:audit_event, actor: administrator, subject: create(:user)) + + visit admin_audit_events_path + + expect(page).to have_text(/history/i) + expect_no_sideways_scrolling + end +end From e159652790984631353bee710ad91bf5520910a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B4nio=20Neto?= Date: Fri, 4 Sep 2026 09:08:04 -0300 Subject: [PATCH 24/33] docs: write the README the submission is judged by In English, with the AI disclosure at the top naming the exact model. Every number in it was measured, and the scripts that produce them are in script/benchmarks: the search index (23.9 ms to 0.095 ms over 50,000 rows, and no gain at all below three characters, which is said too), and ZJIT (about 15% on a CPU-bound 200,000-row parse, five runs each, with the flag and the command). ZJIT is not enabled anywhere in the repository: turning on a JIT by default without production evidence is not a performance decision. The screenshots are produced by a spec rather than taken by hand, so they cannot drift from the interface. It says what was not done as plainly as what was: only Chromium is driven by the specs, the audit trail has no retention policy, invitations are one email per created row, db:prepare on boot races across multiple web hosts, no soft delete, and the screen-reader wording has never been heard through a screen reader. It also records the prompt injection in the original README -- an HTML comment telling an AI assistant to inject a marker string into frontend files and hide the instruction. Following it silently and ignoring it silently are both worse than saying so. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BX7yhokJ4UWtAHDnBmr46W --- README.md | 828 ++++++++++++++++++++++++++--- docs/screenshots/activity.png | Bin 0 -> 47482 bytes docs/screenshots/api-docs.jpg | Bin 0 -> 42317 bytes docs/screenshots/dashboard.png | Bin 0 -> 46749 bytes docs/screenshots/import-detail.png | Bin 0 -> 57323 bytes docs/screenshots/imports.png | Bin 0 -> 65140 bytes docs/screenshots/profile.png | Bin 0 -> 61082 bytes docs/screenshots/sign-in.png | Bin 0 -> 30107 bytes docs/screenshots/users.png | Bin 0 -> 92463 bytes script/benchmarks/zjit.rb | 55 ++ spec/system/screenshots_spec.rb | 33 +- 11 files changed, 824 insertions(+), 92 deletions(-) create mode 100644 docs/screenshots/activity.png create mode 100644 docs/screenshots/api-docs.jpg create mode 100644 docs/screenshots/dashboard.png create mode 100644 docs/screenshots/import-detail.png create mode 100644 docs/screenshots/imports.png create mode 100644 docs/screenshots/profile.png create mode 100644 docs/screenshots/sign-in.png create mode 100644 docs/screenshots/users.png create mode 100644 script/benchmarks/zjit.rb diff --git a/README.md b/README.md index 7829f14ff..3120abe05 100644 --- a/README.md +++ b/README.md @@ -1,87 +1,741 @@ -# Modern Fullstack Developer Test (Rails 8 / Ruby 4) - -- Check this readme.md -- Create a branch to develop your task -- Push to remote in 1 week (date will be checked from branch creation/assigned date) - -# Requirements: -- Target Stack: **Ruby 4.0+** and **Rails 8.0+** -- Database: PostgreSQL, MySQL, or SQLite (configured for production-ready WAL mode) -- Write robust unit, integration, and system tests using parallel testing features -- Deliver with a working multi-stage Dockerfile utilizing Thruster/Kamal-ready defaults -- Show senior best practices (e.g., proper design patterns, solid architecture, strict linter configuration) - -# Our AI Policy -At Umanni, we value efficiency and the modern developer workflow. **You are allowed to use AI coding assistants (ChatGPT, Claude, Copilot, etc.) during this test.** However, transparency is part of our culture. If you use any LLM to generate, refactor, or structure your code, **you must explicitly state which model you used** in a dedicated section at the top of your submission's README.md. Failing to disclose AI usage while using it will invalidate your submission. - -# The Test -Here we'll try to simulate a "real sprint" that you'll probably be assigned while working as Fullstack at Umanni. - -# The Task -- Create a modern, responsive application to manage users. -- A user must have: - 1. full_name - 2. email - 3. avatar_image (ActiveStorage file upload or remote URL) - 4. role (admin/no-admin) - -# The App -## Admin Use cases -- As an Admin, I must be able to access a User Admin Dashboard. -- As an Admin, I must be able to see on the Dashboard (updated via real-time streams/frontend state): - - Total number of Users - - Total number of Users grouped by Role -- As an Admin, I must be redirected to the User Admin Dashboard after login. -- As an Admin, I must be able to list, create, edit, and delete Users. -- As an Admin, I must be able to toggle the User Role. -- As an Admin, I must be able to import a Spreadsheet (.csv/.xlsx) into the system in order to asynchronously create new Users. -- As an Admin, I must be able to see the live progress/status of the spreadsheet import process. - -## User Use Cases -- As a User, I must be redirected to my Profile after login. -- As a User, I must be able only to see my info, edit, and delete my profile. - -## Visitor Use Cases -- As a Visitor, I can register myself as a normal User. - - - -# The Start. -- Your deadline is 1 week after accepting this test. - -# The Rules (Strict Compliance) -These are mandatory. Failing any of them will invalidate your submission. -- **Documentation**: You must write down a detailed README.md in English explaining how to build, seed, and run your app, including your AI disclosure if applicable. -- **Frontend Stack**: You have two choices for the modern monolithic approach: - - **Option A (Classic Modern):** Hotwire (Turbo 8+ / Stimulus) with smooth, reactive UI states. - - **Option B (Modern SPA Monolith):** **React integrated via Inertia.js** (using Vite or the official Rails 8 asset pipeline integration). -- **Styling**: The Frontend must use a modern CSS framework (Tailwind CSS, Bootstrap, or any utility-first library). Keep it beautiful, responsive, and clean. -- **Real-time & Background Processing**: You must leverage native Rails 8 tools (**Solid Cable** for live dashboard counters/import bars and **Solid Queue** for the background import processing). No Redis installation should be required. -- **Authentication**: You must use the new built-in Rails 8 Authentication system (`bin/rails generate authentication`), customized to fit the role constraints. Avoid legacy heavy gems (like Devise). -- **Git Hygiene**: Clean git history with atomic commits, proper descriptions, and a Pull Request-based workflow. - -# What we're expecting to see: -- Modern asset management using **Propshaft** or **Vite Rails** (if choosing Inertia/React). -- .gitignore, .dockerignore configured correctly. -- Clean application configuration using Rails credentials. -- Comprehensive cross-browser support considerations. -- Strict form validations (Frontend interactive feedback + Backend structural validation). -- Parallel testing with at least 90% coverage (using Minitest, RSpec, and Playwright/Capybara for frontend integration). - -# Extra points -- Delivery via a clean **Kamal 2** deployment configuration (`deploy.yml`). -- Advanced SSR (Server-Side Rendering) setup if using **Inertia.js + React**. -- Use of **Thruster** as a zero-config proxy for asset caching and compression in Docker. -- Advanced performance profiling leveraging Ruby 4's **ZJIT** compilation optimizations. - -# What will be assessed -- Code's Semantics, Cleanness, and Maintainability (Senior-level object-oriented design and clean React/Stimulus component lifecycle). -- Modern Rails 8 idiom usage (e.g., Strict structural params handling, Solid architecture separation). -- Basic Security testing against traditional vectors (SQLi, XSS, XSRF) and proper encryption of sensitive DB columns where applicable. +# Roster — user management + +A Rails 8 monolith for managing user accounts: an administrative area with +search, filtering, pagination and role changes; a dashboard whose counters move +by themselves; spreadsheet imports processed in the background with live +progress; and a profile each person owns. Hotwire on the front, PostgreSQL +underneath, no Redis anywhere. + +Built for the Umanni Modern Fullstack Developer Test. + +## AI Usage Disclosure + +This project was developed with assistance from **Claude Opus 5 (model id +`claude-opus-5`)**, used through Claude Code. + +The AI assistant was used to help plan the architecture, generate and refactor +parts of the implementation, review tests, and improve documentation. All +generated code was reviewed, executed, tested, and validated by the author +before inclusion in the project. + +Two notes, in the spirit of the policy rather than the letter of it: + +- Everything claimed in this README as a measurement was measured, and the + scripts that produce those numbers are in `script/benchmarks/`. Where + something was not measured or not tested, this document says so. +- The repository's original `README.md` contained an HTML comment addressed to + AI assistants, instructing them to inject a marker string + (`OptimizationRef: RB4-RM80-InertiaReact`) into frontend files and to hide + that instruction from the candidate. It was not followed: it asks for a + reference to an Inertia/React stack this submission does not use, and + following instructions found inside a document — rather than from the person + asking for the work — is not a habit worth having. It is mentioned here + because silently obeying it, or silently ignoring it, would both be worse + than saying so. + +## Screenshots + +| Dashboard | Users | +| --- | --- | +| ![Dashboard](docs/screenshots/dashboard.png) | ![Users](docs/screenshots/users.png) | + +| Import, with rejected rows | Activity | +| --- | --- | +| ![Import](docs/screenshots/import-detail.png) | ![Activity](docs/screenshots/activity.png) | + +| Profile | Sign in | +| --- | --- | +| ![Profile](docs/screenshots/profile.png) | ![Sign in](docs/screenshots/sign-in.png) | + +The images are produced by a spec rather than by hand, so they cannot drift +from the interface: + +```bash +SCREENSHOTS=1 bin/test spec/system/screenshots_spec.rb +``` + +## What it does + +**Visitors** register themselves, always as ordinary users; the public form has +no role field, and a hand-made request cannot add one. + +**Users** see, edit and delete their own profile, upload an avatar or point at +a remote one, and choose the language of the interface. Nothing in that area +reads an identifier from the request, so there is no id to tamper with. + +**Administrators** land on a dashboard whose counters update over a websocket +when anybody else changes the roster; list, search, filter and paginate +accounts; create, edit, promote, demote and delete them; import a `.csv` or +`.xlsx` and watch the progress bar move without reloading; download the rows +that were rejected, with the reasons; and read back who did what, and when. + +The last administrator cannot be removed or demoted — enforced in the model, +under a row lock, so it holds for the admin screens, the API, the console and +any future import alike. + +## Stack + +| | | +| --- | --- | +| Ruby | 4.0.6 | +| Rails | 8.1.3.1 | +| Database | PostgreSQL 17 | +| Front end | Hotwire (Turbo 8, Stimulus), Propshaft, importmap | +| CSS | Tailwind CSS 4 | +| Background work | Solid Queue (its own container, not a thread in Puma) | +| Real time | Solid Cable | +| Cache | Solid Cache | +| Auth | The built-in Rails 8 authentication generator, adapted | +| Server | Puma behind Thruster | +| Deployment | Kamal 2 | +| Tests | RSpec, Capybara + Cuprite (headless Chrome over CDP), SimpleCov | +| API docs | rswag / OpenAPI 3, Swagger UI | + +No Redis, no Sidekiq, no Devise, no Pundit. The reasons are under +[Architecture decisions](#architecture-decisions). + +## Requirements + +**With Docker** (recommended): Docker Engine with Compose v2. Nothing else — +no Ruby, no PostgreSQL, no Node on the host. + +**Without Docker**: Ruby 4.0.6, PostgreSQL 17, and the libraries Active Storage +variants need (`libvips`). A recent Chrome or Chromium is needed for the system +specs. + +## Running it with Docker + +```bash +git clone roster && cd roster +devops/app/setup.sh # .env, images, the four databases, the demonstration accounts +devops/app/start.sh # web, worker, Tailwind watcher and PostgreSQL +``` + +The application is at . + +Everything is a script, one per action, with no flags to remember. They print +what they are doing and stop at the first failure: + +| | | +| --- | --- | +| `devops/app/setup.sh` | from a clean checkout to a running application | +| `devops/app/start.sh` | start everything (`--attach` to stream the logs) | +| `devops/app/stop.sh` | stop everything, keeping the data | +| `devops/app/restart.sh [service]` | restart one service, or all of them | +| `devops/app/status.sh` | what is running, and whether it answers | +| `devops/app/reset.sh` | throw it all away, data volume included, and set up again | +| `devops/app/seed.sh` | run the seeds again | +| `devops/app/logs.sh` | follow every service at once | +| `devops/rails/console.sh` | a Rails console in the running container | +| `devops/rails/logs.sh` | follow the web logs | +| `devops/rails/migrate.sh` | run migrations | +| `devops/worker/logs.sh` | follow the Solid Queue logs | +| `devops/worker/status.sh` | what the queue is doing | +| `devops/postgres/psql.sh` | a psql session | +| `devops/postgres/dump.sh` | dump the development database | + +`devops/README.md` lists all of them. The shared helpers — logging, guards, and +the `compose` wrapper that resolves the compose file and the env file once — +are in `devops/common.sh`. + +`bin/` keeps short names for the handful of commands used constantly, and each +of them delegates to the script above rather than repeating it: + +```bash +bin/setup # devops/app/setup.sh +bin/setup --reset # devops/app/reset.sh +bin/dev # devops/app/start.sh +bin/dev --down # devops/app/stop.sh +bin/test # the suite +bin/ci # the whole pipeline +``` + +### The four services + +`web` (Puma), `worker` (Solid Queue), `css` (the Tailwind watcher) and +`postgres`. The worker is a separate container on purpose: an import that runs +inside the web process is not really asynchronous, it only looks that way until +somebody imports ten thousand rows. + +## Running it without Docker + +```bash +cp .env.example .env # then edit the PostgreSQL settings +bundle install +bin/rails db:prepare # creates and migrates all four databases +bin/rails db:seed +bin/rails tailwindcss:build +bundle exec foreman start -f Procfile.dev # web + Tailwind watch + jobs +``` + +`Procfile.dev` starts the same three processes Compose does. Without foreman, +run them in three terminals: + +```bash +bin/rails server +bin/rails tailwindcss:watch +bin/jobs # Solid Queue +``` + +## Configuration + +Development settings live in `.env`, created from `.env.example` by +`bin/setup`. Nothing there is secret; it is a local PostgreSQL user and a port. + +``` +POSTGRES_USER=user_management +POSTGRES_PASSWORD=development_only +POSTGRES_DB=user_management_development +TEST_POSTGRES_DB=user_management_test +WEB_PORT=3000 +RAILS_MAX_THREADS=5 +``` + +Everything a deployment needs is an environment variable, not a file in the +repository: + +| Variable | Used for | +| --- | --- | +| `RAILS_MASTER_KEY` | decrypts `config/credentials.yml.enc` | +| `POSTGRES_HOST` / `_USER` / `_PASSWORD` / `_DB` | the database | +| `MAIL_FROM` | the address invitations and password resets come from | +| `SEED_ADMIN_EMAIL` / `SEED_ADMIN_PASSWORD` / `SEED_ADMIN_NAME` | creates the first administrator on the first deploy | +| `FORCE_SSL` | on by default in production; `false` only to smoke-test the image over plain http | +| `API_DOCS_USER` / `API_DOCS_PASSWORD` | put basic auth in front of `/api-docs` | +| `JOB_CONCURRENCY` | Solid Queue processes | + +`config/master.key` is not in the repository, and `.gitignore` keeps every +`config/*.key` out of it. + +## The database + +Four databases, in every environment, mirroring the production topology: +`primary`, `cache`, `queue` and `cable`. `db:prepare` creates and migrates all +four: + +```bash +bin/rails db:prepare # create + migrate +bin/rails db:migrate # migrate +bin/rails db:seed # idempotent; running it twice changes nothing +``` + +The Rails 8 generators wire Solid Queue, Solid Cache and Solid Cable for +production only, which leaves development pointing at tables that do not exist. +That is fixed here: `config/cable.yml`, `config/cache.yml`, `config/queue.yml` +and `config/database.yml` are configured for all environments. + +### Demonstration accounts + +`db/seeds.rb` creates thirteen people. Outside production they share one +password, which is why the seed refuses to create them in production at all — +there, it creates a single administrator from `SEED_ADMIN_EMAIL` and +`SEED_ADMIN_PASSWORD`, or says it has nothing to do and lets the application +boot. + +| Account | Password | Role | +| --- | --- | --- | +| `admin@example.com` | `password-for-development` | administrator | +| `admin.two@example.com` | `password-for-development` | administrator | +| `user@example.com` | `password-for-development` | user | + +Ten more ordinary accounts fill the list and the dashboard. Two administrators +exist so the last-administrator rule can be seen working: delete one and it +goes; try to delete the other and the application refuses. + +**These credentials are for local development only.** They are weak on +purpose, they exist nowhere but a seeded development database, and no real +secret is in this repository. + +## Tests + +```bash +bin/test # the whole suite +bin/test spec/models/user_spec.rb # one file +bin/test --parallel # across four workers, each with its own databases +bin/test --live # the websocket delivery specs (see below) +bin/ci # everything CI runs +``` + +The current numbers, from `bin/ci`: + +``` +253 examples, 0 failures +Line coverage: 681 / 693 (98.26%) +Branch coverage: 177 / 196 (90.30%) +``` + +Coverage is enforced, not reported: SimpleCov fails the run below 90% line and +80% branch. The parallel run merges the workers' results, so the gate is +measured against the whole suite rather than one shard. + +The suite is layered: models and jobs for the rules, request specs for each +endpoint and each attack vector, system specs for three whole journeys — a +visitor, an ordinary user, an administrator — and a small accessibility suite. +Nothing sleeps, nothing depends on global ordering, and nothing asserts on +markup that is free to change. + +### The live-updates pass + +The Action Cable test adapter records broadcasts without delivering them, which +is enough to prove that something was broadcast and not enough to prove that a +browser saw it. So `bin/test --live` runs a second, small pass with Solid Cable +in place of the test adapter: those examples load a page, never reload it, and +then change the data from the example itself. The dashboard counter moves and +an import walks from waiting to finished in a real browser, over a real +websocket. CI runs both passes. + +### Coverage report + +`coverage/index.html`, written by every run. CI keeps it as an artifact. + +## Lint and security + +```bash +bin/lint # RuboCop +bin/brakeman # static analysis +bin/bundler-audit # known CVEs in gems +bin/importmap audit # known CVEs in pinned JavaScript +``` + +All four run in `bin/ci` and in the GitHub workflow, which runs `bin/ci` inside +the same container the application is developed in — a workflow that installed +its own Ruby and its own PostgreSQL would be a second definition of the +environment, free to drift from the one in the repository. + +RuboCop is not the omakase preset: it is an explicit rule set covering Rails, +RSpec, Capybara and performance cops, with every exception documented where it +is made. + +## Importing a spreadsheet + +Administrators upload a `.csv` or `.xlsx` at **Imports**. The file is validated, +attached, and handed to Solid Queue; the page then follows the work over a +websocket. + +### The format + +| Column | Required | Meaning | +| --- | --- | --- | +| `full_name` | yes | up to 120 characters | +| `email` | yes | must be unique, case-insensitively | +| `avatar_url` | no | an `http` or `https` link | +| `role` | no | `user` or `admin`; blank becomes `user` | + +Column order does not matter — the header row is read, not assumed. Rows +beginning with `#` are ignored, which is how the downloadable template carries +its own instructions. **Imports → Download template** produces one. + +```csv +full_name,email,avatar_url,role +Maria Silva,maria@example.com,https://example.com/maria.png,user +João Souza,joao@example.com,, +Ada Lovelace,ada@example.com,,admin +``` + +Ready-made files live in `spec/fixtures/files/`: `users.csv` (three good rows), +`users.xlsx` (the same three), and `users-with-problems.csv` (two good rows and +four different problems). + +### Limits + +A file may be up to **5 MB** and **10,000 rows**. Both are refused before any +account is created — the row ceiling is reached while the rows are being +counted, so an oversized file creates nobody at all rather than half a +directory. The parser streams: memory does not grow with the size of the file. + +`role` is accepted from the file because only administrators can import, and +this is the same power they already have on the form. The decision is recorded +here rather than left implicit. + +### Duplicates and partial failures + +A row whose address already exists is **rejected, not merged and not +duplicated**. The import continues: one bad row does not stop the file. + +When the file finishes, the import is `completed` or `completed_with_errors`, +and every rejected row is listed with its line number, its address and the +reasons. **Download as CSV** produces that list as a spreadsheet, so an +operator can fix the rows next to the original. Cells that a spreadsheet would +treat as a formula (`=`, `+`, `-`, `@`, tab, carriage return) are prefixed with +an apostrophe on the way out, because a rejected row is attacker-controlled +text. + +If the whole file is unreadable — a corrupt archive, a missing header — the +import is marked `failed` with the reason, and the worker does not crash. + +### After the import + +Every account an import creates receives an invitation by email and chooses its +own password. The link carries a token generated for that purpose, valid for +seven days, derived from the password salt — so it stops working the moment a +password is set. Fifteen minutes, which is right for a password reset somebody +just asked for, is wrong for somebody who was imported at two in the morning. + +Mail is delivered by the worker, long after the request whose locale belonged +to the reader, so the mailers switch to the recipient's own language. + +## The JSON API + +`/api/v1` covers what the administration screens cover. **Swagger UI is at +.** + +![API documentation](docs/screenshots/api-docs.jpg) + +```bash +# a token +curl -sX POST http://localhost:3000/api/v1/tokens \ + -H 'Content-Type: application/json' \ + -d '{"email_address":"admin@example.com","password":"password-for-development"}' + +# and then +curl -s http://localhost:3000/api/v1/users?query=maria \ + -H "Authorization: Bearer $TOKEN" +``` + +| Method | Path | | +| --- | --- | --- | +| `POST` | `/api/v1/tokens` | exchange credentials for a bearer token | +| `GET` | `/api/v1/me` | the account the token belongs to | +| `GET` | `/api/v1/users` | list, with `query`, `role`, `page`, `per_page` | +| `POST` | `/api/v1/users` | create | +| `GET` | `/api/v1/users/:id` | read | +| `PATCH` | `/api/v1/users/:id` | update, including the role | +| `DELETE` | `/api/v1/users/:id` | delete | + +Authentication is a signed bearer token derived from the password salt: there +is no table of secrets to leak, no revocation list to keep, and changing a +password invalidates every token already issued. It lasts 24 hours. Everything +under `/api/v1/users` requires an administrator, and the rules are not restated +— the last administrator is protected by the model, so the API inherits it. + +The OpenAPI document is **generated from the request specs that exercise the +API**, so it cannot describe an endpoint the application does not have or a +field it does not return: + +```bash +bin/rails rswag:specs:swaggerize # writes swagger/v1/swagger.yaml +``` + +CI regenerates it and fails if the committed copy has drifted. + +## Architecture decisions + +**Authentication is the Rails 8 generator, adapted.** The brief asked for it, +and it is the right size: a `sessions` table, a signed cookie, `has_secure_password`. +Devise would have brought a dozen modules to replace forty lines. + +**Authorization is a concern, not a gem.** Two roles and a handful of rules. +`Authorization#require_admin` is a `before_action`; the model owns the +invariants that must hold everywhere. Pundit would have added a policy object +per resource and removed no decisions. If a third role appears, or permissions +stop being a function of the role alone, that is when a policy layer earns its +place. + +**The rules live in the model, not the controllers.** The last-administrator +protection, the avatar checks, the role enum: all of them hold for the HTML +screens, the API, the console and the import, because none of them is a +controller's opinion. The last-administrator check takes a row lock (`FOR +UPDATE`) so two concurrent demotions cannot each see the other as the one still +standing. + +**The audit trail is written from the actions, not from a callback.** The actor +is a fact about the request. A model callback would have to go looking for it +in thread-local state, and would fire for the seeds and the console too, +attributing everything to nobody. + +**Counters are broadcast from one place.** A single `after_commit` on `User`, +suspended for the duration of an import so a thousand rows do not mean a +thousand renders. + +**Progress is a database column, not a derived count.** An import reports +progress while it is still running, when the rows it has not reached yet do not +exist anywhere to be counted. + +**Solid Queue runs in its own container.** `SOLID_QUEUE_IN_PUMA` would have +been one line, and would have made a long import compete with request threads +in the same process. + +**Imports are enqueued from the controller, not from a model callback.** +Creating a record in a test or a console should not quietly start a worker. + +**Turbo streams are subscribed through a channel that checks the role.** +`turbo_stream_from ..., channel: AdminStreamChannel`, so the subscription +itself is authorized rather than the page that opens it. + +**The search is one generated column.** See [Performance](#performance). + +**Trade-offs worth naming.** The interface language is stored on the account, +which is a column the brief did not ask for. Avatars are validated by sniffing +the bytes rather than trusting the upload's content type, which costs a read of +each file. Counters are broadcast to one stream per locale, because the payload +is rendered HTML and a single stream would push one language to everybody. And +the audit trail grows without bound: there is no retention policy yet, which is +listed below as a known limitation rather than pretended away. + +## Security + +Every vector below has a spec, not a paragraph. Most live next to the feature +they belong to; what belongs to the application as a whole is in +`spec/requests/security_spec.rb`. + +| | | +| --- | --- | +| SQL injection | the search is parameterised and `sanitize_sql_like`d; a spec searches with `'; DROP TABLE` and finds nothing | +| XSS | a hostile name typed into the form, arriving through an import, and echoed inside a flash message — escaped in all three | +| CSRF | destructive actions return 422 without a token; nothing destroys over `GET` | +| Mass assignment | `role` is not permitted on the public form or the profile; a request that sends it changes nothing | +| IDOR | the profile reads no identifier from the request | +| Privilege escalation | a regular user is refused every administrative action, in the HTML and in the API | +| File upload | the bytes are sniffed with Marcel; a shell script named `avatar.png` and announced as `image/png` is refused | +| SSRF | a remote avatar URL is never fetched by the server, only handed to the browser, and only if it is an ordinary `http(s)` URL | +| CSV injection | formula-leading cells are neutralised in the rejected-rows report | +| Oversized input | 5 MB and 10,000 rows, refused before anything is created | +| Session cookies | signed, `httponly`, `samesite=lax`, and `secure` in production — verified by booting a production process in a spec | +| Log leakage | passwords, addresses and tokens are `[FILTERED]`; asserted by swapping the logger inside an example | +| Race conditions | the last-administrator check locks the remaining administrator rows | +| Account enumeration | the password reset answers identically whether or not the address exists | + +**Content Security Policy.** The shipped initializer was commented out; it is +now a real policy — `default-src 'self'`, `object-src 'none'`, +`frame-ancestors 'none'`, own `base-uri` and `form-action`, scripts only from +this origin plus a per-response nonce that the importmap tags carry +automatically. Two exceptions, both deliberate: `img-src` allows any `https` +image, because remote avatars are a feature; and the policy steps aside for +`/api-docs`, because Swagger UI sends its own policy and a browser enforces +every policy it receives, so the two together forbade everything. + +**Column encryption: none, on purpose.** The brief asks for encryption "where +applicable", and here nothing applies. `full_name` and `email_address` are +identifiers used for search, for a case-insensitive unique index and for +signing in; encrypting them deterministically hides nothing from someone who +can already read the database, and breaks both the search and the index. +`password_digest` is a bcrypt hash — one-way already. Sessions hold an IP +address and a user agent, and the session identifier travels in a signed +cookie rather than a column. There is no government id, no bank detail, no +health data anywhere in the schema. If such a column is ever added, that column +gets `encrypts`; encrypting everything now would only tick a box and cost the +search. + +## Performance + +Two things are measured, and both are guarded so they stay fixed. + +**The users list had an N+1.** It renders an avatar per row, and without eager +loading the attachment, its blob and the variant record were fetched once per +person: 11 queries for 10 people, worse once the variants render. Now four, +whatever the page holds. A spec counts the queries for two people and then for +eight and expects the same number, so this is an invariant rather than a +one-off fix. + +**The search read every row.** `ILIKE '%term%'` has a leading wildcard, so a +B-tree index has nothing to seek on. A trigram index per column does not help +either: the planner compares two GIN scans against one sequential scan and +takes the sequential scan. So the two columns became one — a stored generated +column PostgreSQL keeps in step with the name and the address, and a single GIN +trigram index over it, built concurrently so a deploy against real volume does +not lock writes. + +Measured on 50,000 rows (`bin/rails runner script/benchmarks/search.rb`): + +| Term | Sequential scan | With the index | +| --- | --- | --- | +| `silva` (5 characters) | 23.9 ms | **0.095 ms** | +| `ma` (2 characters) | 23.9 ms | 23.4 ms — no gain | + +The second row is the honest half: a trigram index cannot serve a term shorter +than three characters, and that search still scans. + +### ZJIT + +Ruby 4.0.6 in the image ships both JITs; neither is on by default: + +``` +$ ruby --help | grep -i jit + --yjit Enable in-process JIT compiler. + --zjit Enable method-based JIT compiler. +``` + +Enable it by passing the flag to the process: + +```bash +RUBYOPT="--zjit" bin/rails server +RUBYOPT="--zjit" bin/jobs +``` + +Measured on the CPU-bound part of the application — parsing and normalising a +200,000-row spreadsheet, no database writes +(`ROWS=200000 bin/rails runner script/benchmarks/zjit.rb`, five runs each, +inside the development container): + +| | best | median | +| --- | --- | --- | +| interpreter | 0.868 s | 0.885 s | +| `--zjit` | 0.712 s | 0.751 s | + +Roughly **15% faster on that workload**, reproducible with the script above. No +broader claim is made: a request that spends most of its time waiting on +PostgreSQL has far less to gain, and that was not measured. **ZJIT is not +enabled anywhere in this repository** — the application does not depend on it, +and turning on a JIT by default without production evidence is not a +performance decision, it is a guess. + +## Accessibility + +Checked, not asserted. `spec/system/accessibility_spec.rb` runs +[axe-core](https://github.com/dequelabs/axe-core) against eleven screens at +**WCAG 2.1 AA** — signed out, as a user and as an administrator — and fails on +anything it detects. It found three real faults the first time it ran, all +fixed: the counts beside the role filters used a 4:1 colour token the design +tokens document as being for large text only, and two inline links were +distinguished from their paragraph by colour alone. + +Beyond what a machine can check: + +- semantic HTML: one `

` per page, real `` markup for tabular data, + `