From 3d6b99857759781a322edf63209b98f02429d162 Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Wed, 2 Sep 2026 21:20:18 -0300 Subject: [PATCH 01/68] chore: bootstrap Rails 8.1 application skeleton Generate the base app with Ruby 4.0 / Rails 8.1 defaults: Propshaft, importmap-rails, Hotwire (Turbo + Stimulus), Tailwind CSS, SQLite (WAL journal mode by default), Solid Cache/Queue/Cable, RuboCop (rubocop-rails-omakase), Brakeman, bundler-audit, Kamal 2 and a multi-stage Dockerfile with Thruster. --- .dockerignore | 51 ++ .github/dependabot.yml | 12 + .github/workflows/ci.yml | 67 +++ .gitignore | 55 ++ .kamal/hooks/docker-setup.sample | 3 + .kamal/hooks/post-app-boot.sample | 3 + .kamal/hooks/post-deploy.sample | 14 + .kamal/hooks/post-proxy-reboot.sample | 3 + .kamal/hooks/pre-app-boot.sample | 3 + .kamal/hooks/pre-build.sample | 51 ++ .kamal/hooks/pre-connect.sample | 47 ++ .kamal/hooks/pre-deploy.sample | 122 ++++ .kamal/hooks/pre-proxy-reboot.sample | 3 + .kamal/secrets | 20 + .rubocop.yml | 8 + .ruby-version | 1 + Dockerfile | 77 +++ Gemfile | 60 ++ Gemfile.lock | 529 ++++++++++++++++++ Procfile.dev | 2 + Rakefile | 6 + app/assets/builds/.keep | 0 app/assets/images/.keep | 0 app/assets/stylesheets/application.css | 10 + app/assets/tailwind/application.css | 1 + app/controllers/application_controller.rb | 7 + app/controllers/concerns/.keep | 0 app/helpers/application_helper.rb | 2 + app/javascript/application.js | 3 + app/javascript/controllers/application.js | 9 + .../controllers/hello_controller.js | 7 + app/javascript/controllers/index.js | 4 + app/jobs/application_job.rb | 7 + app/mailers/application_mailer.rb | 4 + app/models/application_record.rb | 3 + app/models/concerns/.keep | 0 app/views/layouts/application.html.erb | 31 + app/views/layouts/mailer.html.erb | 13 + app/views/layouts/mailer.text.erb | 1 + app/views/pwa/manifest.json.erb | 22 + app/views/pwa/service-worker.js | 26 + bin/brakeman | 7 + bin/bundler-audit | 6 + bin/ci | 6 + bin/dev | 16 + bin/docker-entrypoint | 8 + bin/importmap | 4 + bin/jobs | 6 + bin/kamal | 16 + bin/rails | 4 + bin/rake | 4 + bin/rubocop | 8 + bin/setup | 35 ++ bin/thrust | 5 + config.ru | 6 + config/application.rb | 42 ++ config/boot.rb | 4 + config/bundler-audit.yml | 5 + config/cable.yml | 17 + config/cache.yml | 16 + config/ci.rb | 20 + config/credentials.yml.enc | 1 + config/database.yml | 40 ++ config/deploy.yml | 119 ++++ config/environment.rb | 5 + config/environments/development.rb | 78 +++ 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/schema.rb | 14 + db/seeds.rb | 9 + lib/tasks/.keep | 0 log/.keep | 0 public/400.html | 135 +++++ public/404.html | 135 +++++ public/406-unsupported-browser.html | 135 +++++ public/422.html | 135 +++++ public/500.html | 135 +++++ public/icon.png | Bin 0 -> 4166 bytes public/icon.svg | 3 + public/robots.txt | 1 + script/.keep | 0 storage/.keep | 0 tmp/.keep | 0 vendor/.keep | 0 vendor/javascript/.keep | 0 99 files changed, 3007 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100755 .kamal/hooks/docker-setup.sample create mode 100755 .kamal/hooks/post-app-boot.sample create mode 100755 .kamal/hooks/post-deploy.sample create mode 100755 .kamal/hooks/post-proxy-reboot.sample create mode 100755 .kamal/hooks/pre-app-boot.sample create mode 100755 .kamal/hooks/pre-build.sample create mode 100755 .kamal/hooks/pre-connect.sample create mode 100755 .kamal/hooks/pre-deploy.sample create mode 100755 .kamal/hooks/pre-proxy-reboot.sample create mode 100644 .kamal/secrets create mode 100644 .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/controllers/application_controller.rb create mode 100644 app/controllers/concerns/.keep create mode 100644 app/helpers/application_helper.rb create mode 100644 app/javascript/application.js create mode 100644 app/javascript/controllers/application.js create mode 100644 app/javascript/controllers/hello_controller.js create mode 100644 app/javascript/controllers/index.js create mode 100644 app/jobs/application_job.rb create mode 100644 app/mailers/application_mailer.rb create mode 100644 app/models/application_record.rb create mode 100644 app/models/concerns/.keep create mode 100644 app/views/layouts/application.html.erb create mode 100644 app/views/layouts/mailer.html.erb create mode 100644 app/views/layouts/mailer.text.erb create mode 100644 app/views/pwa/manifest.json.erb create mode 100644 app/views/pwa/service-worker.js create mode 100755 bin/brakeman create mode 100755 bin/bundler-audit create mode 100755 bin/ci create mode 100755 bin/dev create mode 100755 bin/docker-entrypoint create mode 100755 bin/importmap create mode 100755 bin/jobs create mode 100755 bin/kamal create mode 100755 bin/rails create mode 100755 bin/rake create mode 100755 bin/rubocop create mode 100755 bin/setup create mode 100755 bin/thrust create mode 100644 config.ru create mode 100644 config/application.rb create mode 100644 config/boot.rb create mode 100644 config/bundler-audit.yml create mode 100644 config/cable.yml create mode 100644 config/cache.yml create mode 100644 config/ci.rb create mode 100644 config/credentials.yml.enc create mode 100644 config/database.yml create mode 100644 config/deploy.yml create mode 100644 config/environment.rb create mode 100644 config/environments/development.rb create mode 100644 config/environments/production.rb create mode 100644 config/environments/test.rb create mode 100644 config/importmap.rb create mode 100644 config/initializers/assets.rb create mode 100644 config/initializers/content_security_policy.rb create mode 100644 config/initializers/filter_parameter_logging.rb create mode 100644 config/initializers/inflections.rb create mode 100644 config/locales/en.yml create mode 100644 config/puma.rb create mode 100644 config/queue.yml create mode 100644 config/recurring.yml create mode 100644 config/routes.rb create mode 100644 config/storage.yml create mode 100644 db/cable_schema.rb create mode 100644 db/cache_schema.rb create mode 100644 db/queue_schema.rb create mode 100644 db/schema.rb create mode 100644 db/seeds.rb create mode 100644 lib/tasks/.keep create mode 100644 log/.keep create mode 100644 public/400.html create mode 100644 public/404.html create mode 100644 public/406-unsupported-browser.html create mode 100644 public/422.html create mode 100644 public/500.html create mode 100644 public/icon.png create mode 100644 public/icon.svg create mode 100644 public/robots.txt create mode 100644 script/.keep create mode 100644 storage/.keep create mode 100644 tmp/.keep create mode 100644 vendor/.keep create mode 100644 vendor/javascript/.keep diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..325bfc036 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,51 @@ +# See https://docs.docker.com/engine/reference/builder/#dockerignore-file for more about ignoring files. + +# Ignore git directory. +/.git/ +/.gitignore + +# Ignore bundler config. +/.bundle + +# Ignore all environment files. +/.env* + +# Ignore all default key files. +/config/master.key +/config/credentials/*.key + +# Ignore all logfiles and tempfiles. +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep + +# Ignore pidfiles, but keep the directory. +/tmp/pids/* +!/tmp/pids/.keep + +# Ignore storage (uploaded files in development and any SQLite databases). +/storage/* +!/storage/.keep +/tmp/storage/* +!/tmp/storage/.keep + +# Ignore assets. +/node_modules/ +/app/assets/builds/* +!/app/assets/builds/.keep +/public/assets + +# Ignore CI service files. +/.github + +# Ignore Kamal files. +/config/deploy*.yml +/.kamal + +# Ignore development files +/.devcontainer + +# Ignore Docker-related files +/.dockerignore +/Dockerfile* diff --git a/.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..69ea37803 --- /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..d96ad6d79 --- /dev/null +++ b/.gitignore @@ -0,0 +1,55 @@ +# Bundler +/.bundle +/vendor/bundle + +# Environment secrets +.env +.env.* +!.env.example +/config/master.key +/config/credentials/*.key + +# SQLite databases +/storage/*.sqlite3 +/storage/*.sqlite3-* + +# Logs +/log/* +!/log/.keep + +# Temp files +/tmp/* +!/tmp/.keep + +# Uploaded files (local dev) +/storage/[^.]*/ + +# Assets build output +/public/assets +/public/packs + +# Node (if ever needed) +/node_modules +yarn-error.log + +# macOS +.DS_Store + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Coverage +/coverage + +# Aider Chat +.aider* + +# Ignore key files for decrypting credentials and more. +/config/*.key + + +/app/assets/builds/* +!/app/assets/builds/.keep diff --git a/.kamal/hooks/docker-setup.sample b/.kamal/hooks/docker-setup.sample new file mode 100755 index 000000000..a0b053784 --- /dev/null +++ b/.kamal/hooks/docker-setup.sample @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +echo "Docker set up on $KAMAL_HOSTS..." diff --git a/.kamal/hooks/post-app-boot.sample b/.kamal/hooks/post-app-boot.sample new file mode 100755 index 000000000..7d2a13db2 --- /dev/null +++ b/.kamal/hooks/post-app-boot.sample @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +echo "Booted app version $KAMAL_VERSION on $KAMAL_HOSTS..." diff --git a/.kamal/hooks/post-deploy.sample b/.kamal/hooks/post-deploy.sample new file mode 100755 index 000000000..17b0567a5 --- /dev/null +++ b/.kamal/hooks/post-deploy.sample @@ -0,0 +1,14 @@ +#!/usr/bin/env sh + +# A sample post-deploy hook +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_ROLES (if set) +# KAMAL_DESTINATION (if set) +# KAMAL_RUNTIME + +echo "$KAMAL_PERFORMER deployed $KAMAL_VERSION to $KAMAL_DESTINATION in $KAMAL_RUNTIME seconds" diff --git a/.kamal/hooks/post-proxy-reboot.sample b/.kamal/hooks/post-proxy-reboot.sample new file mode 100755 index 000000000..84548ed04 --- /dev/null +++ b/.kamal/hooks/post-proxy-reboot.sample @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +echo "Rebooted kamal-proxy on $KAMAL_HOSTS" diff --git a/.kamal/hooks/pre-app-boot.sample b/.kamal/hooks/pre-app-boot.sample new file mode 100755 index 000000000..1f9fe844c --- /dev/null +++ b/.kamal/hooks/pre-app-boot.sample @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +echo "Booting app version $KAMAL_VERSION on $KAMAL_HOSTS..." diff --git a/.kamal/hooks/pre-build.sample b/.kamal/hooks/pre-build.sample new file mode 100755 index 000000000..d53d28cf7 --- /dev/null +++ b/.kamal/hooks/pre-build.sample @@ -0,0 +1,51 @@ +#!/usr/bin/env sh + +# A sample pre-build hook +# +# Checks: +# 1. We have a clean checkout +# 2. A remote is configured +# 3. The branch has been pushed to the remote +# 4. The version we are deploying matches the remote +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_ROLES (if set) +# KAMAL_DESTINATION (if set) + +if [ -n "$(git status --porcelain)" ]; then + echo "Git checkout is not clean, aborting..." >&2 + git status --porcelain >&2 + exit 1 +fi + +first_remote=$(git remote) + +if [ -z "$first_remote" ]; then + echo "No git remote set, aborting..." >&2 + exit 1 +fi + +current_branch=$(git branch --show-current) + +if [ -z "$current_branch" ]; then + echo "Not on a git branch, aborting..." >&2 + exit 1 +fi + +remote_head=$(git ls-remote $first_remote --tags $current_branch | cut -f1) + +if [ -z "$remote_head" ]; then + echo "Branch not pushed to remote, aborting..." >&2 + exit 1 +fi + +if [ "$KAMAL_VERSION" != "$remote_head" ]; then + echo "Version ($KAMAL_VERSION) does not match remote HEAD ($remote_head), aborting..." >&2 + exit 1 +fi + +exit 0 diff --git a/.kamal/hooks/pre-connect.sample b/.kamal/hooks/pre-connect.sample new file mode 100755 index 000000000..77744bdca --- /dev/null +++ b/.kamal/hooks/pre-connect.sample @@ -0,0 +1,47 @@ +#!/usr/bin/env ruby + +# A sample pre-connect check +# +# Warms DNS before connecting to hosts in parallel +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_ROLES (if set) +# KAMAL_DESTINATION (if set) +# KAMAL_RUNTIME + +hosts = ENV["KAMAL_HOSTS"].split(",") +results = nil +max = 3 + +elapsed = Benchmark.realtime do + results = hosts.map do |host| + Thread.new do + tries = 1 + + begin + Socket.getaddrinfo(host, 0, Socket::AF_UNSPEC, Socket::SOCK_STREAM, nil, Socket::AI_CANONNAME) + rescue SocketError + if tries < max + puts "Retrying DNS warmup: #{host}" + tries += 1 + sleep rand + retry + else + puts "DNS warmup failed: #{host}" + host + end + end + + tries + end + end.map(&:value) +end + +retries = results.sum - hosts.size +nopes = results.count { |r| r == max } + +puts "Prewarmed %d DNS lookups in %.2f sec: %d retries, %d failures" % [ hosts.size, elapsed, retries, nopes ] diff --git a/.kamal/hooks/pre-deploy.sample b/.kamal/hooks/pre-deploy.sample new file mode 100755 index 000000000..05b3055b7 --- /dev/null +++ b/.kamal/hooks/pre-deploy.sample @@ -0,0 +1,122 @@ +#!/usr/bin/env ruby + +# A sample pre-deploy hook +# +# Checks the Github status of the build, waiting for a pending build to complete for up to 720 seconds. +# +# Fails unless the combined status is "success" +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_COMMAND +# KAMAL_SUBCOMMAND +# KAMAL_ROLES (if set) +# KAMAL_DESTINATION (if set) + +# Only check the build status for production deployments +if ENV["KAMAL_COMMAND"] == "rollback" || ENV["KAMAL_DESTINATION"] != "production" + exit 0 +end + +require "bundler/inline" + +# true = install gems so this is fast on repeat invocations +gemfile(true, quiet: true) do + source "https://rubygems.org" + + gem "octokit" + gem "faraday-retry" +end + +MAX_ATTEMPTS = 72 +ATTEMPTS_GAP = 10 + +def exit_with_error(message) + $stderr.puts message + exit 1 +end + +class GithubStatusChecks + attr_reader :remote_url, :git_sha, :github_client, :combined_status + + def initialize + @remote_url = github_repo_from_remote_url + @git_sha = `git rev-parse HEAD`.strip + @github_client = Octokit::Client.new(access_token: ENV["GITHUB_TOKEN"]) + refresh! + end + + def refresh! + @combined_status = github_client.combined_status(remote_url, git_sha) + end + + def state + combined_status[:state] + end + + def first_status_url + first_status = combined_status[:statuses].find { |status| status[:state] == state } + first_status && first_status[:target_url] + end + + def complete_count + combined_status[:statuses].count { |status| status[:state] != "pending"} + end + + def total_count + combined_status[:statuses].count + end + + def current_status + if total_count > 0 + "Completed #{complete_count}/#{total_count} checks, see #{first_status_url} ..." + else + "Build not started..." + end + end + + private + def github_repo_from_remote_url + url = `git config --get remote.origin.url`.strip.delete_suffix(".git") + if url.start_with?("https://github.com/") + url.delete_prefix("https://github.com/") + elsif url.start_with?("git@github.com:") + url.delete_prefix("git@github.com:") + else + url + end + end +end + + +$stdout.sync = true + +begin + puts "Checking build status..." + + attempts = 0 + checks = GithubStatusChecks.new + + loop do + case checks.state + when "success" + puts "Checks passed, see #{checks.first_status_url}" + exit 0 + when "failure" + exit_with_error "Checks failed, see #{checks.first_status_url}" + when "pending" + attempts += 1 + end + + exit_with_error "Checks are still pending, gave up after #{MAX_ATTEMPTS * ATTEMPTS_GAP} seconds" if attempts == MAX_ATTEMPTS + + puts checks.current_status + sleep(ATTEMPTS_GAP) + checks.refresh! + end +rescue Octokit::NotFound + exit_with_error "Build status could not be found" +end diff --git a/.kamal/hooks/pre-proxy-reboot.sample b/.kamal/hooks/pre-proxy-reboot.sample new file mode 100755 index 000000000..93e11991d --- /dev/null +++ b/.kamal/hooks/pre-proxy-reboot.sample @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +echo "Rebooting kamal-proxy on $KAMAL_HOSTS..." diff --git a/.kamal/secrets b/.kamal/secrets new file mode 100644 index 000000000..b3089d6f5 --- /dev/null +++ b/.kamal/secrets @@ -0,0 +1,20 @@ +# Secrets defined here are available for reference under registry/password, env/secret, builder/secrets, +# and accessories/*/env/secret in config/deploy.yml. All secrets should be pulled from either +# password manager, ENV, or a file. DO NOT ENTER RAW CREDENTIALS HERE! This file needs to be safe for git. + +# Example of extracting secrets from 1password (or another compatible pw manager) +# SECRETS=$(kamal secrets fetch --adapter 1password --account your-account --from Vault/Item KAMAL_REGISTRY_PASSWORD RAILS_MASTER_KEY) +# KAMAL_REGISTRY_PASSWORD=$(kamal secrets extract KAMAL_REGISTRY_PASSWORD ${SECRETS}) +# RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY ${SECRETS}) + +# Example of extracting secrets from Rails credentials +# KAMAL_REGISTRY_PASSWORD=$(rails credentials:fetch kamal.registry_password) + +# Use a GITHUB_TOKEN if private repositories are needed for the image +# GITHUB_TOKEN=$(gh config get -h github.com oauth_token) + +# Grab the registry password from ENV +# KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD + +# Improve security by using a password manager. Never check config/master.key into git! +RAILS_MASTER_KEY=$(cat config/master.key) diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 000000000..f9d86d4a5 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,8 @@ +# Omakase Ruby styling for Rails +inherit_gem: { rubocop-rails-omakase: rubocop.yml } + +# Overwrite or add rules to create your own house style +# +# # Use `[a, [b, c]]` not `[ a, [ b, c ] ]` +# Layout/SpaceInsideArrayLiteralBrackets: +# Enabled: false diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 000000000..2f9dd5fd1 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +ruby-4.0.0 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..1cdda93e5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,77 @@ +# syntax=docker/dockerfile:1 +# check=error=true + +# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand: +# docker build -t fullstack_developer . +# docker run -d -p 80:80 -e RAILS_MASTER_KEY= --name fullstack_developer fullstack_developer + +# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html + +# Make sure RUBY_VERSION matches the Ruby version in .ruby-version +ARG RUBY_VERSION=4.0.0 +FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base + +# Rails app lives here +WORKDIR /rails + +# Install base packages +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 && \ + ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Set production environment variables and enable jemalloc for reduced memory usage and latency. +ENV RAILS_ENV="production" \ + BUNDLE_DEPLOYMENT="1" \ + BUNDLE_PATH="/usr/local/bundle" \ + BUNDLE_WITHOUT="development" \ + LD_PRELOAD="/usr/local/lib/libjemalloc.so" + +# Throw-away build stage to reduce size of final image +FROM base AS build + +# Install packages needed to build gems +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y build-essential git libvips libyaml-dev pkg-config && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Install application gems +COPY vendor/* ./vendor/ +COPY Gemfile Gemfile.lock ./ + +RUN bundle install && \ + rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \ + # -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495 + bundle exec bootsnap precompile -j 1 --gemfile + +# Copy application code +COPY . . + +# Precompile bootsnap code for faster boot times. +# -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495 +RUN bundle exec bootsnap precompile -j 1 app/ lib/ + +# Precompiling assets for production without requiring secret RAILS_MASTER_KEY +RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile + + + + +# Final stage for app image +FROM base + +# Run and own only the runtime files as a non-root user for security +RUN groupadd --system --gid 1000 rails && \ + useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash +USER 1000:1000 + +# Copy built artifacts: gems, application +COPY --chown=rails:rails --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" +COPY --chown=rails:rails --from=build /rails /rails + +# Entrypoint prepares the database. +ENTRYPOINT ["/rails/bin/docker-entrypoint"] + +# Start server via Thruster by default, this can be overwritten at runtime +EXPOSE 80 +CMD ["./bin/thrust", "./bin/rails", "server"] diff --git a/Gemfile b/Gemfile new file mode 100644 index 000000000..baa25469e --- /dev/null +++ b/Gemfile @@ -0,0 +1,60 @@ +source "https://rubygems.org" + +# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" +gem "rails", "~> 8.1.3", ">= 8.1.3.1" +# The modern asset pipeline for Rails [https://github.com/rails/propshaft] +gem "propshaft" +# Use sqlite3 as the database for Active Record +gem "sqlite3", ">= 2.1" +# Use the Puma web server [https://github.com/puma/puma] +gem "puma", ">= 5.0" +# Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails] +gem "importmap-rails" +# Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev] +gem "turbo-rails" +# Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev] +gem "stimulus-rails" +# Use Tailwind CSS [https://github.com/rails/tailwindcss-rails] +gem "tailwindcss-rails" + +# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] +# gem "bcrypt", "~> 3.1.7" + +# Windows does not include zoneinfo files, so bundle the tzinfo-data gem +gem "tzinfo-data", platforms: %i[ windows jruby ] + +# Use the database-backed adapters for Rails.cache, Active Job, and Action Cable +gem "solid_cache" +gem "solid_queue" +gem "solid_cable" + +# Reduces boot times through caching; required in config/boot.rb +gem "bootsnap", require: false + +# Deploy this application anywhere as a Docker container [https://kamal-deploy.org] +gem "kamal", require: false + +# Add HTTP asset caching/compression and X-Sendfile acceleration to Puma [https://github.com/basecamp/thruster/] +gem "thruster", require: false + +# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] +gem "image_processing", "~> 1.2" + +group :development, :test do + # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem + gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" + + # Audits gems for known security defects (use config/bundler-audit.yml to ignore issues) + gem "bundler-audit", require: false + + # Static analysis for security vulnerabilities [https://brakemanscanner.org/] + gem "brakeman", require: false + + # Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/] + gem "rubocop-rails-omakase", require: false +end + +group :development do + # Use console on exceptions pages [https://github.com/rails/web-console] + gem "web-console" +end diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 000000000..884e295cf --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,529 @@ +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) + 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) + concurrent-ruby (1.3.8) + connection_pool (3.0.2) + crass (1.0.7) + date (3.5.1) + debug (1.11.1) + irb (~> 1.10) + reline (>= 0.3.8) + dotenv (3.2.0) + drb (2.2.3) + ed25519 (1.4.0) + erb (6.0.7) + erubi (1.13.1) + et-orbi (1.4.2) + tzinfo + 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) + mini_magick (5.4.0) + logger + mini_mime (1.1.5) + minitest (6.0.6) + drb (~> 2.0) + prism (~> 1.5) + msgpack (1.8.4) + net-imap (0.6.6) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.3.0) + timeout + net-scp (4.1.0) + net-ssh (>= 2.6.5, < 8.0.0) + net-sftp (4.0.0) + net-ssh (>= 5.0.0, < 8.0.0) + net-smtp (0.5.1) + net-protocol + net-ssh (7.3.3) + nio4r (2.7.5) + nokogiri (1.19.4-aarch64-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.4-aarch64-linux-musl) + racc (~> 1.4) + nokogiri (1.19.4-arm-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.4-arm-linux-musl) + racc (~> 1.4) + nokogiri (1.19.4-x86_64-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.4-x86_64-linux-musl) + racc (~> 1.4) + ostruct (0.6.3) + parallel (2.1.0) + parser (3.3.12.0) + ast (~> 2.4.1) + racc + pp (0.6.4) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) + propshaft (1.3.2) + actionpack (>= 7.0.0) + activesupport (>= 7.0.0) + rack + 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) + 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-performance (1.27.0) + lint_roller (~> 1.1) + rubocop (>= 1.89.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) + rubocop-rails (2.37.0) + activesupport (>= 4.2.0) + lint_roller (~> 1.1) + rack (>= 1.1) + rubocop (>= 1.89.0, < 2.0) + rubocop-ast (>= 1.44.0, < 2.0) + rubocop-rails-omakase (1.1.0) + rubocop (>= 1.72) + rubocop-performance (>= 1.24) + rubocop-rails (>= 2.30) + ruby-progressbar (1.13.0) + ruby-vips (2.3.0) + ffi (~> 1.12) + logger + securerandom (0.4.1) + solid_cable (4.0.2) + actioncable (>= 7.2) + activejob (>= 7.2) + activerecord (>= 7.2) + railties (>= 7.2) + solid_cache (1.0.10) + activejob (>= 7.2) + activerecord (>= 7.2) + railties (>= 7.2) + solid_queue (1.7.0) + activejob (>= 7.1) + activerecord (>= 7.1) + concurrent-ruby (>= 1.3.1) + fugit (~> 1.11) + railties (>= 7.1) + thor (>= 1.3.1) + sqlite3 (2.9.6-aarch64-linux-gnu) + sqlite3 (2.9.6-aarch64-linux-musl) + sqlite3 (2.9.6-arm-linux-gnu) + sqlite3 (2.9.6-arm-linux-musl) + sqlite3 (2.9.6-x86_64-linux-gnu) + sqlite3 (2.9.6-x86_64-linux-musl) + sshkit (1.25.1) + base64 + logger + net-scp (>= 1.1.2) + net-sftp (>= 2.1.2) + net-ssh (>= 2.8.0) + ostruct + stimulus-rails (1.3.4) + railties (>= 6.0.0) + tailwindcss-rails (4.6.0) + railties (>= 7.0.0) + tailwindcss-ruby (~> 4.0) + tailwindcss-ruby (4.3.3) + tailwindcss-ruby (4.3.3-aarch64-linux-gnu) + tailwindcss-ruby (4.3.3-aarch64-linux-musl) + tailwindcss-ruby (4.3.3-x86_64-linux-gnu) + tailwindcss-ruby (4.3.3-x86_64-linux-musl) + thor (1.5.0) + thruster (0.1.26) + thruster (0.1.26-aarch64-linux) + thruster (0.1.26-x86_64-linux) + timeout (0.6.1) + tsort (0.2.0) + turbo-rails (2.0.23) + actionpack (>= 7.1.0) + railties (>= 7.1.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + uri (1.1.1) + useragent (0.16.11) + web-console (4.3.0) + actionview (>= 8.0.0) + bindex (>= 0.4.0) + railties (>= 8.0.0) + websocket-driver (0.8.2) + base64 + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + zeitwerk (2.8.3) + +PLATFORMS + aarch64-linux + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + x86_64-linux + x86_64-linux-gnu + x86_64-linux-musl + +DEPENDENCIES + bootsnap + brakeman + bundler-audit + debug + image_processing (~> 1.2) + importmap-rails + kamal + propshaft + puma (>= 5.0) + rails (~> 8.1.3, >= 8.1.3.1) + rubocop-rails-omakase + solid_cable + solid_cache + solid_queue + sqlite3 (>= 2.1) + stimulus-rails + tailwindcss-rails + thruster + turbo-rails + tzinfo-data + web-console + +CHECKSUMS + action_text-trix (2.1.19) sha256=7012f59421009cf284aa651294896414d653a61a2417c9b8714c8476d2f74009 + actioncable (8.1.3.1) sha256=e318528295c878a3efdfe25f0f2267c80cb7a76eba41bb5f64d44aa380a3d91b + actionmailbox (8.1.3.1) sha256=5f704972097d843ade8e435e93694a1dac732b926df1717aceba1f3840082b1c + actionmailer (8.1.3.1) sha256=88ea441b28ff02a0c6c006468892642a3d9942affce9d294e81a74504aa5c43c + actionpack (8.1.3.1) sha256=974cb7154548e81f470b1b0f247b99cb38e87825899dca58610596e2817723d0 + actiontext (8.1.3.1) sha256=5da729d833d1a29cddb1eee938878e55e503d2613e00e735f5daf58c2ba98af2 + actionview (8.1.3.1) sha256=2da68b8414c47b43bfbed1ce69c5afe1c04f78c267aacb5660a4cab5ca12cfb6 + activejob (8.1.3.1) sha256=1c8dd275df930df40deecffec63d913a550a33fd94bd298f69721dd96939954a + activemodel (8.1.3.1) sha256=99cc02ce2faec371d14440949d85787ebd23a907c9baef0a9d4bcd4d21888f88 + activerecord (8.1.3.1) sha256=0a2fb6c28f4938f6b013a3a549bec0a7e37d535f3dc8990e804bcc3258c0403b + activestorage (8.1.3.1) sha256=f555254f387b1cffa499d2fd3115d12635eadc5b15206a8534316a67036163ef + activesupport (8.1.3.1) sha256=85458765f25ea48b9019c46b6bb3fa5683197bf4280d9f06710a6e8d7a831376 + 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 + concurrent-ruby (1.3.8) sha256=b2f1be836e968ccc78ccfce277ea79c72a88633f22306782c16ff23fb415d1e1 + connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a + crass (1.0.7) sha256=94868719948664c89ddcaf0a37c65048413dfcb1c869470a5f7a7ceb5390b295 + date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 + debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6 + dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d + drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 + ed25519 (1.4.0) sha256=16e97f5198689a154247169f3453ef4cfd3f7a47481fde0ae33206cdfdcac506 + erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92 + erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 + et-orbi (1.4.2) sha256=bb555dae668419cb24caa2a293a170e58be6d4df1e017c51f5030bdc133cd20c + 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 + mini_magick (5.4.0) sha256=f120af581d9ed4ec52c57f35a67a605d112bb1d8f582d1415b147fda42d11d78 + mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef + minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1 + msgpack (1.8.4) sha256=4411c22d350dd1c20250f7eada3cca2695438c2f769cf0782f0cd065d90a3e7b + net-imap (0.6.6) sha256=96aa4ee50df3060203e649efc341f53480b791d49e150f2fdebf68beb141a8df + net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 + net-protocol (0.3.0) sha256=ba310c3d4f1cad46bb1ab20336b06669b1ff8f7c568d9cb9342b32a718547472 + net-scp (4.1.0) sha256=a99b0b92a1e5d360b0de4ffbf2dc0c91531502d3d4f56c28b0139a7c093d1a5d + net-sftp (4.0.0) sha256=65bb91c859c2f93b09826757af11b69af931a3a9155050f50d1b06d384526364 + net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736 + net-ssh (7.3.3) sha256=831def58b2c51dcef66ec00d29397d4f210de89c19fe78f95873ca30f386e86a + nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 + nokogiri (1.19.4-aarch64-linux-gnu) sha256=1269fb644a6de405057a53dd5c762b1209b43ca7424f839454d3dbc677c31a8f + nokogiri (1.19.4-aarch64-linux-musl) sha256=35c65b9ce72b3bb03207bdbe7067915019dc18c1b9b59139684bd6690fdd01af + nokogiri (1.19.4-arm-linux-gnu) sha256=a301313e38bb065d68239e79734bcd6f56fb6efaacebde29e9abf2a4735340ca + nokogiri (1.19.4-arm-linux-musl) sha256=588923c101bcfa78869734d247d25b598674323e7f22474fc468f6e5647311eb + nokogiri (1.19.4-x86_64-linux-gnu) sha256=379fae440b28915e3f19d752ce2dcf8465ed2b2fbefd2a7ca0dd497bc981a06a + nokogiri (1.19.4-x86_64-linux-musl) sha256=17dfb7c1fa194ae02fbf7c51a7afc8d278045ab3fdacfd86f91d02d7b274470b + ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 + parallel (2.1.0) sha256=b35258865c2e31134c5ecb708beaaf6772adf9d5efae28e93e99260877b09356 + parser (3.3.12.0) sha256=21a6d7f755d5a24dfbdc6e6b772e4e879a52e7631a88bc5a3a134606052c9828 + pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570 + prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 + propshaft (1.3.2) sha256=1d56a3e56a92c21bfc29caf07406b5386b00d4c47ddf357cf989a5a234b1389e + 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 + rubocop (1.90.0) sha256=9eb4c065b5c5154e4ef554c547972f3905a9eb6b53e657e580b6796b54bf8242 + rubocop-ast (1.50.0) sha256=b9ca88300da0803ee222ad20cdb30494c0a784eed06fdc35d254b06d662788db + rubocop-performance (1.27.0) sha256=eeeb1374d062a368ee1c787b70eb0b0cc4b184cb1f8565f424760946146d61ce + rubocop-rails (2.37.0) sha256=6e1645add5060e0328f8ddda0d820f55697c591394398bf14bb9dccb62f14b7e + rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d + ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 + ruby-vips (2.3.0) sha256=e685ec02c13969912debbd98019e50492e12989282da5f37d05f5471442f5374 + securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 + solid_cable (4.0.2) sha256=084636a67679ad00d23088b33c84047e614bcf41ee559db24b414d83cdc42d03 + solid_cache (1.0.10) sha256=bc05a2fb3ac78a6f43cbb5946679cf9db67dd30d22939ededc385cb93e120d41 + solid_queue (1.7.0) sha256=6566b70b801d1c317c81bba7bcdd5677c019afac584a30374b4164002ca356d3 + sqlite3 (2.9.6-aarch64-linux-gnu) sha256=d8b1f7d23efd7abac285775a9566562fc7debfef79d594e3a20354406fb7907c + sqlite3 (2.9.6-aarch64-linux-musl) sha256=3579e1c98cdc7ff5c3722847bb63ed4e1efb7ff675cb5e1e48ef2d4da5fb3bc9 + sqlite3 (2.9.6-arm-linux-gnu) sha256=33541500e3615da02afe54a9cc38b17a6985d3cf9d8b76d6d0a83002f114e7ec + sqlite3 (2.9.6-arm-linux-musl) sha256=c5490af48bb228fefa54314e9541375c3907e70f8109f3881b5ff97e1c93ae33 + sqlite3 (2.9.6-x86_64-linux-gnu) sha256=613188ce02f614126ddbc38c5e217ccffd6306d0dcd9adca9764547aa890a634 + sqlite3 (2.9.6-x86_64-linux-musl) sha256=d493b11818a3573387a1d56e1ee8fa00da23a683a7a1cc063e7a0feeed843abf + sshkit (1.25.1) sha256=be3f10b9d6eb0b44d5eaba3f7cbe41bc6bb894bce4339688ac20124391455b78 + stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06 + tailwindcss-rails (4.6.0) sha256=d99512867173d55c5ef8890427682299d8539f550cec1408b3d8667a538bd365 + tailwindcss-ruby (4.3.3) sha256=ee0a64030749862deb501acab4c4aaf5adbee13865746a33299d46d7b5d0952a + tailwindcss-ruby (4.3.3-aarch64-linux-gnu) sha256=c86d6dd3eccc85fe0d792a832b06f2bf3c0a7a83b399308aeb9d8f5725f42a6a + tailwindcss-ruby (4.3.3-aarch64-linux-musl) sha256=72b77ca9edea82383dd09510ab520a122e3cb9f9864ca5b38e27698098d2b899 + tailwindcss-ruby (4.3.3-x86_64-linux-gnu) sha256=2337017ff8b02698480eae1e9637cf01faa0e4824db89d067a13c5a5ee38c9b2 + tailwindcss-ruby (4.3.3-x86_64-linux-musl) sha256=27d478c417bcf73828e5b544744c5bdfd5b5cb54f1a266fc4f185281c32efe6c + thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 + thruster (0.1.26) sha256=6e45e807086b29d51404841bd1ad493b67cd95892fd65dc5afcdd32e82e94ce8 + thruster (0.1.26-aarch64-linux) sha256=2171cb34928c0250830008f535c4ab2ee57846cc3f5d3e96c3475f7b3de7a541 + thruster (0.1.26-x86_64-linux) sha256=3117a6ee430663f845a0457699fe9a05232dcc6c396e2cc83504de5a223c60e8 + timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb + tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f + turbo-rails (2.0.23) sha256=ee0d90733aafff056cf51ff11e803d65e43cae258cc55f6492020ec1f9f9315f + tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b + unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 + unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f + uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 + useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844 + web-console (4.3.0) sha256=e13b71301cdfc2093f155b5aa3a622db80b4672d1f2f713119cc7ec7ac6a6da4 + websocket-driver (0.8.2) sha256=97c556b019bf3410b4961002ac501621e9322d3f8a7bc02161a09301cc4c4146 + websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241 + zeitwerk (2.8.3) sha256=2c85125a8467ce069e20123d1e709a08955c9d29c118c25b46b7b7fafdbb92e5 + +BUNDLED WITH + 4.0.3 diff --git a/Procfile.dev b/Procfile.dev new file mode 100644 index 000000000..da151fee9 --- /dev/null +++ b/Procfile.dev @@ -0,0 +1,2 @@ +web: bin/rails server +css: bin/rails tailwindcss:watch diff --git a/Rakefile b/Rakefile new file mode 100644 index 000000000..9a5ea7383 --- /dev/null +++ b/Rakefile @@ -0,0 +1,6 @@ +# Add your own tasks in files placed in lib/tasks ending in .rake, +# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. + +require_relative "config/application" + +Rails.application.load_tasks diff --git a/app/assets/builds/.keep b/app/assets/builds/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/app/assets/images/.keep b/app/assets/images/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css new file mode 100644 index 000000000..fe93333c0 --- /dev/null +++ b/app/assets/stylesheets/application.css @@ -0,0 +1,10 @@ +/* + * This is a manifest file that'll be compiled into application.css. + * + * With Propshaft, assets are served efficiently without preprocessing steps. You can still include + * application-wide styles in this file, but keep in mind that CSS precedence will follow the standard + * cascading order, meaning styles declared later in the document or manifest will override earlier ones, + * depending on specificity. + * + * Consider organizing styles into separate files for maintainability. + */ diff --git a/app/assets/tailwind/application.css b/app/assets/tailwind/application.css new file mode 100644 index 000000000..f1d8c73cd --- /dev/null +++ b/app/assets/tailwind/application.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb new file mode 100644 index 000000000..c3537563d --- /dev/null +++ b/app/controllers/application_controller.rb @@ -0,0 +1,7 @@ +class ApplicationController < ActionController::Base + # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. + allow_browser versions: :modern + + # Changes to the importmap will invalidate the etag for HTML responses + stale_when_importmap_changes +end diff --git a/app/controllers/concerns/.keep b/app/controllers/concerns/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb new file mode 100644 index 000000000..de6be7945 --- /dev/null +++ b/app/helpers/application_helper.rb @@ -0,0 +1,2 @@ +module ApplicationHelper +end diff --git a/app/javascript/application.js b/app/javascript/application.js new file mode 100644 index 000000000..0d7b49404 --- /dev/null +++ b/app/javascript/application.js @@ -0,0 +1,3 @@ +// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails +import "@hotwired/turbo-rails" +import "controllers" diff --git a/app/javascript/controllers/application.js b/app/javascript/controllers/application.js new file mode 100644 index 000000000..1213e85c7 --- /dev/null +++ b/app/javascript/controllers/application.js @@ -0,0 +1,9 @@ +import { Application } from "@hotwired/stimulus" + +const application = Application.start() + +// Configure Stimulus development experience +application.debug = false +window.Stimulus = application + +export { application } diff --git a/app/javascript/controllers/hello_controller.js b/app/javascript/controllers/hello_controller.js new file mode 100644 index 000000000..5975c0789 --- /dev/null +++ b/app/javascript/controllers/hello_controller.js @@ -0,0 +1,7 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + connect() { + this.element.textContent = "Hello World!" + } +} diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js new file mode 100644 index 000000000..1156bf836 --- /dev/null +++ b/app/javascript/controllers/index.js @@ -0,0 +1,4 @@ +// Import and register all your controllers from the importmap via controllers/**/*_controller +import { application } from "controllers/application" +import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading" +eagerLoadControllersFrom("controllers", application) diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb new file mode 100644 index 000000000..d394c3d10 --- /dev/null +++ b/app/jobs/application_job.rb @@ -0,0 +1,7 @@ +class ApplicationJob < ActiveJob::Base + # Automatically retry jobs that encountered a deadlock + # retry_on ActiveRecord::Deadlocked + + # Most jobs are safe to ignore if the underlying records are no longer available + # discard_on ActiveJob::DeserializationError +end diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb new file mode 100644 index 000000000..3c34c8148 --- /dev/null +++ b/app/mailers/application_mailer.rb @@ -0,0 +1,4 @@ +class ApplicationMailer < ActionMailer::Base + default from: "from@example.com" + layout "mailer" +end diff --git a/app/models/application_record.rb b/app/models/application_record.rb new file mode 100644 index 000000000..b63caeb8a --- /dev/null +++ b/app/models/application_record.rb @@ -0,0 +1,3 @@ +class ApplicationRecord < ActiveRecord::Base + primary_abstract_class +end diff --git a/app/models/concerns/.keep b/app/models/concerns/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb new file mode 100644 index 000000000..2702498a2 --- /dev/null +++ b/app/views/layouts/application.html.erb @@ -0,0 +1,31 @@ + + + + <%= content_for(:title) || "Fullstack Developer" %> + + + + + <%= 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..13a7b4eff --- /dev/null +++ b/app/views/pwa/manifest.json.erb @@ -0,0 +1,22 @@ +{ + "name": "FullstackDeveloper", + "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": "FullstackDeveloper.", + "theme_color": "red", + "background_color": "red" +} diff --git a/app/views/pwa/service-worker.js b/app/views/pwa/service-worker.js new file mode 100644 index 000000000..b3a13fb7b --- /dev/null +++ b/app/views/pwa/service-worker.js @@ -0,0 +1,26 @@ +// Add a service worker for processing Web Push notifications: +// +// self.addEventListener("push", async (event) => { +// const { title, options } = await event.data.json() +// event.waitUntil(self.registration.showNotification(title, options)) +// }) +// +// self.addEventListener("notificationclick", function(event) { +// event.notification.close() +// event.waitUntil( +// clients.matchAll({ type: "window" }).then((clientList) => { +// for (let i = 0; i < clientList.length; i++) { +// let client = clientList[i] +// let clientPath = (new URL(client.url)).pathname +// +// if (clientPath == event.notification.data.path && "focus" in client) { +// return client.focus() +// } +// } +// +// if (clients.openWindow) { +// return clients.openWindow(event.notification.data.path) +// } +// }) +// ) +// }) diff --git a/bin/brakeman b/bin/brakeman new file mode 100755 index 000000000..ace1c9ba0 --- /dev/null +++ b/bin/brakeman @@ -0,0 +1,7 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +ARGV.unshift("--ensure-latest") + +load Gem.bin_path("brakeman", "brakeman") diff --git a/bin/bundler-audit b/bin/bundler-audit new file mode 100755 index 000000000..e2ef22690 --- /dev/null +++ b/bin/bundler-audit @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "bundler/audit/cli" + +ARGV.concat %w[ --config config/bundler-audit.yml ] if ARGV.empty? || ARGV.include?("check") +Bundler::Audit::CLI.start diff --git a/bin/ci b/bin/ci new file mode 100755 index 000000000..4137ad5bb --- /dev/null +++ b/bin/ci @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "active_support/continuous_integration" + +CI = ActiveSupport::ContinuousIntegration +require_relative "../config/ci.rb" diff --git a/bin/dev b/bin/dev new file mode 100755 index 000000000..ad72c7d53 --- /dev/null +++ b/bin/dev @@ -0,0 +1,16 @@ +#!/usr/bin/env sh + +if ! gem list foreman -i --silent; then + echo "Installing foreman..." + gem install foreman +fi + +# Default to port 3000 if not specified +export PORT="${PORT:-3000}" + +# Let the debug gem allow remote connections, +# but avoid loading until `debugger` is called +export RUBY_DEBUG_OPEN="true" +export RUBY_DEBUG_LAZY="true" + +exec foreman start -f Procfile.dev "$@" diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint new file mode 100755 index 000000000..ed31659f4 --- /dev/null +++ b/bin/docker-entrypoint @@ -0,0 +1,8 @@ +#!/bin/bash -e + +# If running the rails server then create or migrate existing database +if [ "${@: -2:1}" == "./bin/rails" ] && [ "${@: -1:1}" == "server" ]; then + ./bin/rails db:prepare +fi + +exec "${@}" diff --git a/bin/importmap b/bin/importmap new file mode 100755 index 000000000..36502ab16 --- /dev/null +++ b/bin/importmap @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby + +require_relative "../config/application" +require "importmap/commands" diff --git a/bin/jobs b/bin/jobs new file mode 100755 index 000000000..dcf59f309 --- /dev/null +++ b/bin/jobs @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby + +require_relative "../config/environment" +require "solid_queue/cli" + +SolidQueue::Cli.start(ARGV) diff --git a/bin/kamal b/bin/kamal new file mode 100755 index 000000000..d9ba27670 --- /dev/null +++ b/bin/kamal @@ -0,0 +1,16 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'kamal' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("kamal", "kamal") diff --git a/bin/rails b/bin/rails new file mode 100755 index 000000000..efc037749 --- /dev/null +++ b/bin/rails @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +APP_PATH = File.expand_path("../config/application", __dir__) +require_relative "../config/boot" +require "rails/commands" diff --git a/bin/rake b/bin/rake new file mode 100755 index 000000000..4fbf10b96 --- /dev/null +++ b/bin/rake @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "rake" +Rake.application.run diff --git a/bin/rubocop b/bin/rubocop new file mode 100755 index 000000000..5a2050471 --- /dev/null +++ b/bin/rubocop @@ -0,0 +1,8 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +# Explicit RuboCop config increases performance slightly while avoiding config confusion. +ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__)) + +load Gem.bin_path("rubocop", "rubocop") diff --git a/bin/setup b/bin/setup new file mode 100755 index 000000000..81be011e8 --- /dev/null +++ b/bin/setup @@ -0,0 +1,35 @@ +#!/usr/bin/env ruby +require "fileutils" + +APP_ROOT = File.expand_path("..", __dir__) + +def system!(*args) + system(*args, exception: true) +end + +FileUtils.chdir APP_ROOT do + # This script is a way to set up or update your development environment automatically. + # This script is idempotent, so that you can run it at any time and get an expectable outcome. + # Add necessary setup steps to this file. + + puts "== Installing dependencies ==" + system("bundle check") || system!("bundle install") + + # puts "\n== Copying sample files ==" + # unless File.exist?("config/database.yml") + # FileUtils.cp "config/database.yml.sample", "config/database.yml" + # end + + puts "\n== Preparing database ==" + system! "bin/rails db:prepare" + system! "bin/rails db:reset" if ARGV.include?("--reset") + + puts "\n== Removing old logs and tempfiles ==" + system! "bin/rails log:clear tmp:clear" + + unless ARGV.include?("--skip-server") + puts "\n== Starting development server ==" + STDOUT.flush # flush the output before exec(2) so that it displays + exec "bin/dev" + end +end diff --git a/bin/thrust b/bin/thrust new file mode 100755 index 000000000..36bde2d83 --- /dev/null +++ b/bin/thrust @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("thruster", "thrust") diff --git a/config.ru b/config.ru new file mode 100644 index 000000000..4a3c09a68 --- /dev/null +++ b/config.ru @@ -0,0 +1,6 @@ +# This file is used by Rack-based servers to start the application. + +require_relative "config/environment" + +run Rails.application +Rails.application.load_server diff --git a/config/application.rb b/config/application.rb new file mode 100644 index 000000000..f77f26fb0 --- /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 FullstackDeveloper + 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..b9adc5aa3 --- /dev/null +++ b/config/cable.yml @@ -0,0 +1,17 @@ +# Async adapter only works within the same process, so for manually triggering cable updates from a console, +# and seeing results in the browser, you must do so from the web console (running inside the dev process), +# not a terminal started via bin/rails console! Add "console" to any action or any ERB template view +# to make the web console appear. +development: + adapter: async + +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..239b34398 --- /dev/null +++ b/config/ci.rb @@ -0,0 +1,20 @@ +# Run using bin/ci + +CI.run do + step "Setup", "bin/setup --skip-server" + + step "Style: Ruby", "bin/rubocop" + + step "Security: Gem audit", "bin/bundler-audit" + step "Security: Importmap vulnerability audit", "bin/importmap audit" + step "Security: Brakeman code analysis", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error" + + + # Optional: set a green GitHub commit status to unblock PR merge. + # Requires the `gh` CLI and `gh extension install basecamp/gh-signoff`. + # if success? + # step "Signoff: All systems go. Ready for merge and deploy.", "gh signoff" + # else + # failure "Signoff: CI failed. Do not merge or deploy.", "Fix the issues and try again." + # end +end diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc new file mode 100644 index 000000000..35b54300e --- /dev/null +++ b/config/credentials.yml.enc @@ -0,0 +1 @@ +sb6aDncM5V9EwR2nmytS+7s1c853MJV4LMyWCu9Wl7ihY+gO8cBbFZ7axgI65bezdQV0CS9CPU7S1QIr6E4e0vuB2joJbTH1wrJNetBtF/wXD/VzuA9fKUYa+hcqm+8ZERqx250ezJtE4PGzlhLe9VUp+49PsBI/Hkd/bEveL28W0jynfeza9ZE2laLvUvbMbRVThqe4DHLv0IpqgYLcfZO6Fdl5HlITZM51otC5KDGG0xFlPlsocQWRBlNU9fPGBAgx1NdGvTjMWxCZpXLNsIxlNwe1moVGxg+v2s+QlN6qyQiqn/eZaRdiidQO6bOEZsw/PuLdH1t6wD352QEcwNLBA5ydD153Pb0KuSEUm+y4140i6hplJLyETCmc83qyUKVUmnd7x6YB7dFJHCrsDa6FXVnuSbaglS2g5m14tMkCaFjKOrU06Q5lGMpE7wme5Az0M2g9OHp9byltndCuV5yEyWtMkRkT/LOcv74GUWgMcNrA96BLEyp2--wq9bUz2QwlyijIGh--KXsxEGbMCf3uMwh/823wUA== \ No newline at end of file diff --git a/config/database.yml b/config/database.yml new file mode 100644 index 000000000..302d638c9 --- /dev/null +++ b/config/database.yml @@ -0,0 +1,40 @@ +# SQLite. Versions 3.8.0 and up are supported. +# gem install sqlite3 +# +# Ensure the SQLite 3 gem is defined in your Gemfile +# gem "sqlite3" +# +default: &default + adapter: sqlite3 + max_connections: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + timeout: 5000 + +development: + <<: *default + database: storage/development.sqlite3 + +# Warning: The database defined as "test" will be erased and +# re-generated from your development database when you run "rake". +# Do not set this db to the same as development or production. +test: + <<: *default + database: storage/test.sqlite3 + +# Store production database in the storage/ directory, which by default +# is mounted as a persistent Docker volume in config/deploy.yml. +production: + primary: + <<: *default + database: storage/production.sqlite3 + cache: + <<: *default + database: storage/production_cache.sqlite3 + migrations_paths: db/cache_migrate + queue: + <<: *default + database: storage/production_queue.sqlite3 + migrations_paths: db/queue_migrate + cable: + <<: *default + database: storage/production_cable.sqlite3 + migrations_paths: db/cable_migrate diff --git a/config/deploy.yml b/config/deploy.yml new file mode 100644 index 000000000..77ec47a90 --- /dev/null +++ b/config/deploy.yml @@ -0,0 +1,119 @@ +# Name of your application. Used to uniquely configure containers. +service: fullstack_developer + +# Name of the container image (use your-user/app-name on external registries). +image: fullstack_developer + +# Deploy to these servers. +servers: + web: + - 192.168.0.1 + # job: + # hosts: + # - 192.168.0.1 + # cmd: bin/jobs + +# Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server. +# If used with Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption. +# +# Using an SSL proxy like this requires turning on config.assume_ssl and config.force_ssl in production.rb! +# +# Don't use this when deploying to multiple web servers (then you have to terminate SSL at your load balancer). +# +# proxy: +# ssl: true +# host: app.example.com + +# Where you keep your container images. +registry: + # Alternatives: hub.docker.com / registry.digitalocean.com / ghcr.io / ... + server: localhost:5555 + + # Needed for authenticated registries. + # username: your-user + + # Always use an access token rather than real password when possible. + # password: + # - KAMAL_REGISTRY_PASSWORD + +# Inject ENV variables into containers (secrets come from .kamal/secrets). +env: + secret: + - RAILS_MASTER_KEY + clear: + # Run the Solid Queue Supervisor inside the web server's Puma process to do jobs. + # When you start using multiple servers, you should split out job processing to a dedicated machine. + SOLID_QUEUE_IN_PUMA: true + + # Set number of processes dedicated to Solid Queue (default: 1) + # JOB_CONCURRENCY: 3 + + # Set number of cores available to the application on each server (default: 1). + # WEB_CONCURRENCY: 2 + + # Match this to any external database server to configure Active Record correctly + # Use fullstack_developer-db for a db accessory server on same machine via local kamal docker network. + # DB_HOST: 192.168.0.2 + + # Log everything from Rails + # RAILS_LOG_LEVEL: debug + +# Aliases are triggered with "bin/kamal ". You can overwrite arguments on invocation: +# "bin/kamal logs -r job" will tail logs from the first server in the job section. +aliases: + console: app exec --interactive --reuse "bin/rails console" + shell: app exec --interactive --reuse "bash" + logs: app logs -f + dbc: app exec --interactive --reuse "bin/rails dbconsole --include-password" + +# Use a persistent storage volume for sqlite database files and local Active Storage files. +# Recommended to change this to a mounted volume path that is backed up off server. +volumes: + - "fullstack_developer_storage:/rails/storage" + +# Bridge fingerprinted assets, like JS and CSS, between versions to avoid +# hitting 404 on in-flight requests. Combines all files from new and old +# version inside the asset_path. +asset_path: /rails/public/assets + +# Configure the image builder. +builder: + arch: amd64 + + # # Build image via remote server (useful for faster amd64 builds on arm64 computers) + # remote: ssh://docker@docker-builder-server + # + # # Pass arguments and secrets to the Docker build process + # args: + # RUBY_VERSION: ruby-4.0.0 + # secrets: + # - GITHUB_TOKEN + # - RAILS_MASTER_KEY + +# Use a different ssh user than root +# ssh: +# user: app + +# Use accessory services (secrets come from .kamal/secrets). +# accessories: +# db: +# image: mysql:8.0 +# host: 192.168.0.2 +# # Change to 3306 to expose port to the world instead of just local network. +# port: "127.0.0.1:3306:3306" +# env: +# clear: +# MYSQL_ROOT_HOST: '%' +# secret: +# - MYSQL_ROOT_PASSWORD +# files: +# - config/mysql/production.cnf:/etc/mysql/my.cnf +# - db/production.sql:/docker-entrypoint-initdb.d/setup.sql +# directories: +# - data:/var/lib/mysql +# redis: +# image: valkey/valkey:8 +# host: 192.168.0.2 +# port: 6379 +# directories: +# - data:/data diff --git a/config/environment.rb b/config/environment.rb new file mode 100644 index 000000000..cac531577 --- /dev/null +++ b/config/environment.rb @@ -0,0 +1,5 @@ +# Load the Rails application. +require_relative "application" + +# Initialize the Rails application. +Rails.application.initialize! diff --git a/config/environments/development.rb b/config/environments/development.rb new file mode 100644 index 000000000..75243c3d0 --- /dev/null +++ b/config/environments/development.rb @@ -0,0 +1,78 @@ +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. + config.cache_store = :memory_store + + # Store uploaded files on the local file system (see config/storage.yml for options). + config.active_storage.service = :local + + # Don't care if the mailer can't send. + config.action_mailer.raise_delivery_errors = false + + # Make template changes take effect immediately. + config.action_mailer.perform_caching = false + + # Set localhost to be used by links generated in mailer templates. + config.action_mailer.default_url_options = { host: "localhost", port: 3000 } + + # Print deprecation notices to the Rails logger. + config.active_support.deprecation = :log + + # Raise an error on page load if there are pending migrations. + config.active_record.migration_error = :page_load + + # Highlight code that triggered database queries in logs. + config.active_record.verbose_query_logs = true + + # Append comments with runtime information tags to SQL queries in logs. + config.active_record.query_log_tags_enabled = true + + # Highlight code that enqueued background job in logs. + config.active_job.verbose_enqueue_logs = true + + # Highlight code that triggered redirect in logs. + config.action_dispatch.verbose_redirect_logs = true + + # Suppress logger output for asset requests. + config.assets.quiet = true + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + config.action_view.annotate_rendered_view_with_filenames = true + + # Uncomment if you wish to allow Action Cable access from any origin. + # config.action_cable.disable_request_forgery_protection = true + + # Raise error when a before_action's only/except options reference missing actions. + config.action_controller.raise_on_missing_callback_actions = true + + # Apply autocorrection by RuboCop to files generated by `bin/rails generate`. + # config.generators.apply_rubocop_autocorrect_after_generate! +end diff --git a/config/environments/production.rb b/config/environments/production.rb new file mode 100644 index 000000000..f5763e04e --- /dev/null +++ b/config/environments/production.rb @@ -0,0 +1,90 @@ +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # Code is not reloaded between requests. + config.enable_reloading = false + + # Eager load code on boot for better performance and memory savings (ignored by Rake tasks). + config.eager_load = true + + # Full error reports are disabled. + config.consider_all_requests_local = false + + # Turn on fragment caching in view templates. + config.action_controller.perform_caching = true + + # Cache assets for far-future expiry since they are all digest stamped. + config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" } + + # Enable serving of images, stylesheets, and JavaScripts from an asset server. + # config.asset_host = "http://assets.example.com" + + # Store uploaded files on the local file system (see config/storage.yml for options). + config.active_storage.service = :local + + # Assume all access to the app is happening through a SSL-terminating reverse proxy. + # config.assume_ssl = true + + # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. + # config.force_ssl = true + + # Skip http-to-https redirect for the default health check endpoint. + # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } + + # Log to STDOUT with the current request id as a default log tag. + config.log_tags = [ :request_id ] + config.logger = ActiveSupport::TaggedLogging.logger(STDOUT) + + # Change to "debug" to log everything (including potentially personally-identifiable information!). + config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") + + # Prevent health checks from clogging up the logs. + config.silence_healthcheck_path = "/up" + + # Don't log any deprecations. + config.active_support.report_deprecations = false + + # Replace the default in-process memory cache store with a durable alternative. + config.cache_store = :solid_cache_store + + # Replace the default in-process and non-durable queuing backend for Active Job. + config.active_job.queue_adapter = :solid_queue + config.solid_queue.connects_to = { database: { writing: :queue } } + + # Ignore bad email addresses and do not raise email delivery errors. + # Set this to true and configure the email server for immediate delivery to raise delivery errors. + # config.action_mailer.raise_delivery_errors = false + + # Set host to be used by links generated in mailer templates. + config.action_mailer.default_url_options = { host: "example.com" } + + # Specify outgoing SMTP server. Remember to add smtp/* credentials via bin/rails credentials:edit. + # config.action_mailer.smtp_settings = { + # user_name: Rails.application.credentials.dig(:smtp, :user_name), + # password: Rails.application.credentials.dig(:smtp, :password), + # address: "smtp.example.com", + # port: 587, + # authentication: :plain + # } + + # Enable locale fallbacks for I18n (makes lookups for any locale fall back to + # the I18n.default_locale when a translation cannot be found). + config.i18n.fallbacks = true + + # Do not dump schema after migrations. + config.active_record.dump_schema_after_migration = false + + # Only use :id for inspections in production. + config.active_record.attributes_for_inspect = [ :id ] + + # Enable DNS rebinding protection and other `Host` header attacks. + # config.hosts = [ + # "example.com", # Allow requests from example.com + # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` + # ] + # + # Skip DNS rebinding protection for the default health check endpoint. + # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } +end diff --git a/config/environments/test.rb b/config/environments/test.rb new file mode 100644 index 000000000..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..c0b717f7e --- /dev/null +++ b/config/initializers/filter_parameter_logging.rb @@ -0,0 +1,8 @@ +# Be sure to restart your server when you modify this file. + +# Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file. +# Use this to limit dissemination of sensitive information. +# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. +Rails.application.config.filter_parameters += [ + :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc +] diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb new file mode 100644 index 000000000..3860f659e --- /dev/null +++ b/config/initializers/inflections.rb @@ -0,0 +1,16 @@ +# Be sure to restart your server when you modify this file. + +# Add new inflection rules using the following format. Inflections +# are locale specific, and you may define rules for as many different +# locales as you wish. All of these examples are active by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.plural /^(ox)$/i, "\\1en" +# inflect.singular /^(ox)en/i, "\\1" +# inflect.irregular "person", "people" +# inflect.uncountable %w( fish sheep ) +# end + +# These inflection rules are supported but not enabled by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.acronym "RESTful" +# end diff --git a/config/locales/en.yml b/config/locales/en.yml new file mode 100644 index 000000000..6c349ae5e --- /dev/null +++ b/config/locales/en.yml @@ -0,0 +1,31 @@ +# Files in the config/locales directory are used for internationalization and +# are automatically loaded by Rails. If you want to use locales other than +# English, add the necessary files in this directory. +# +# To use the locales, use `I18n.t`: +# +# I18n.t "hello" +# +# In views, this is aliased to just `t`: +# +# <%= t("hello") %> +# +# To use a different locale, set it with `I18n.locale`: +# +# I18n.locale = :es +# +# This would use the information in config/locales/es.yml. +# +# To learn more about the API, please read the Rails Internationalization guide +# at https://guides.rubyonrails.org/i18n.html. +# +# Be aware that YAML interprets the following case-insensitive strings as +# booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings +# must be quoted to be interpreted as strings. For example: +# +# en: +# "yes": yup +# enabled: "ON" + +en: + hello: "Hello world" diff --git a/config/puma.rb b/config/puma.rb new file mode 100644 index 000000000..38c4b8659 --- /dev/null +++ b/config/puma.rb @@ -0,0 +1,42 @@ +# This configuration file will be evaluated by Puma. The top-level methods that +# are invoked here are part of Puma's configuration DSL. For more information +# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html. +# +# Puma starts a configurable number of processes (workers) and each process +# serves each request in a thread from an internal thread pool. +# +# You can control the number of workers using ENV["WEB_CONCURRENCY"]. You +# should only set this value when you want to run 2 or more workers. The +# default is already 1. You can set it to `auto` to automatically start a worker +# for each available processor. +# +# The ideal number of threads per worker depends both on how much time the +# application spends waiting for IO operations and on how much you wish to +# prioritize throughput over latency. +# +# As a rule of thumb, increasing the number of threads will increase how much +# traffic a given process can handle (throughput), but due to CRuby's +# Global VM Lock (GVL) it has diminishing returns and will degrade the +# response time (latency) of the application. +# +# The default is set to 3 threads as it's deemed a decent compromise between +# throughput and latency for the average Rails application. +# +# Any libraries that use a connection pool or another resource pool should +# be configured to provide at least as many connections as the number of +# threads. This includes Active Record's `pool` parameter in `database.yml`. +threads_count = ENV.fetch("RAILS_MAX_THREADS", 3) +threads threads_count, threads_count + +# Specifies the `port` that Puma will listen on to receive requests; default is 3000. +port ENV.fetch("PORT", 3000) + +# Allow puma to be restarted by `bin/rails restart` command. +plugin :tmp_restart + +# Run the Solid Queue supervisor inside of Puma for single-server deployments. +plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"] + +# Specify the PID file. Defaults to tmp/pids/server.pid in development. +# In other environments, only set the PID file if requested. +pidfile ENV["PIDFILE"] if ENV["PIDFILE"] diff --git a/config/queue.yml b/config/queue.yml new file mode 100644 index 000000000..6b1436086 --- /dev/null +++ b/config/queue.yml @@ -0,0 +1,18 @@ +default: &default + dispatchers: + - polling_interval: 1 + batch_size: 500 + workers: + - queues: "*" + threads: 3 + processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %> + polling_interval: 1 + +development: + <<: *default + +test: + <<: *default + +production: + <<: *default diff --git a/config/recurring.yml b/config/recurring.yml new file mode 100644 index 000000000..b4207f9b0 --- /dev/null +++ b/config/recurring.yml @@ -0,0 +1,15 @@ +# examples: +# periodic_cleanup: +# class: CleanSoftDeletedRecordsJob +# queue: background +# args: [ 1000, { batch_size: 500 } ] +# schedule: every hour +# periodic_cleanup_with_command: +# command: "SoftDeletedRecord.due.delete_all" +# priority: 2 +# schedule: at 5am every day + +production: + clear_solid_queue_finished_jobs: + command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)" + schedule: every hour at minute 12 diff --git a/config/routes.rb b/config/routes.rb new file mode 100644 index 000000000..48254e88e --- /dev/null +++ b/config/routes.rb @@ -0,0 +1,14 @@ +Rails.application.routes.draw do + # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html + + # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. + # Can be used by load balancers and uptime monitors to verify that the app is live. + get "up" => "rails/health#show", as: :rails_health_check + + # Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb) + # get "manifest" => "rails/pwa#manifest", as: :pwa_manifest + # get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker + + # Defines the root path route ("/") + # root "posts#index" +end diff --git a/config/storage.yml b/config/storage.yml new file mode 100644 index 000000000..927dc537c --- /dev/null +++ b/config/storage.yml @@ -0,0 +1,27 @@ +test: + service: Disk + root: <%= Rails.root.join("tmp/storage") %> + +local: + service: Disk + root: <%= Rails.root.join("storage") %> + +# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) +# amazon: +# service: S3 +# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> +# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> +# region: us-east-1 +# bucket: your_own_bucket-<%= Rails.env %> + +# Remember not to checkin your GCS keyfile to a repository +# google: +# service: GCS +# project: your_project +# credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> +# bucket: your_own_bucket-<%= Rails.env %> + +# mirror: +# service: Mirror +# primary: local +# mirrors: [ amazon, google, microsoft ] diff --git a/db/cable_schema.rb b/db/cable_schema.rb new file mode 100644 index 000000000..23666604a --- /dev/null +++ b/db/cable_schema.rb @@ -0,0 +1,11 @@ +ActiveRecord::Schema[7.1].define(version: 1) do + create_table "solid_cable_messages", force: :cascade do |t| + t.binary "channel", limit: 1024, null: false + t.binary "payload", limit: 536870912, null: false + t.datetime "created_at", null: false + t.integer "channel_hash", limit: 8, null: false + t.index ["channel"], name: "index_solid_cable_messages_on_channel" + t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash" + t.index ["created_at"], name: "index_solid_cable_messages_on_created_at" + end +end diff --git a/db/cache_schema.rb b/db/cache_schema.rb new file mode 100644 index 000000000..81a410d18 --- /dev/null +++ b/db/cache_schema.rb @@ -0,0 +1,12 @@ +ActiveRecord::Schema[7.2].define(version: 1) do + create_table "solid_cache_entries", force: :cascade do |t| + t.binary "key", limit: 1024, null: false + t.binary "value", limit: 536870912, null: false + t.datetime "created_at", null: false + t.integer "key_hash", limit: 8, null: false + t.integer "byte_size", limit: 4, null: false + t.index ["byte_size"], name: "index_solid_cache_entries_on_byte_size" + t.index ["key_hash", "byte_size"], name: "index_solid_cache_entries_on_key_hash_and_byte_size" + t.index ["key_hash"], name: "index_solid_cache_entries_on_key_hash", unique: true + end +end diff --git a/db/queue_schema.rb b/db/queue_schema.rb new file mode 100644 index 000000000..f9a71dabb --- /dev/null +++ b/db/queue_schema.rb @@ -0,0 +1,160 @@ +ActiveRecord::Schema[7.1].define(version: 1) do + create_table "solid_queue_blocked_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.string "concurrency_key", null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.index [ "concurrency_key", "priority", "job_id" ], name: "index_solid_queue_blocked_executions_for_release" + t.index [ "expires_at", "concurrency_key" ], name: "index_solid_queue_blocked_executions_for_maintenance" + t.index [ "job_id" ], name: "index_solid_queue_blocked_executions_on_job_id", unique: true + end + + create_table "solid_queue_claimed_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.bigint "process_id" + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_claimed_executions_on_job_id", unique: true + t.index [ "process_id", "job_id" ], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id" + end + + create_table "solid_queue_failed_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.text "error" + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_failed_executions_on_job_id", unique: true + end + + create_table "solid_queue_jobs", force: :cascade do |t| + t.string "queue_name", null: false + t.string "class_name", null: false + t.text "arguments" + t.integer "priority", default: 0, null: false + t.string "active_job_id" + t.datetime "scheduled_at" + t.datetime "finished_at" + t.string "concurrency_key" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.bigint "batch_id" + t.index [ "active_job_id" ], name: "index_solid_queue_jobs_on_active_job_id" + t.index [ "batch_id" ], name: "index_solid_queue_jobs_on_batch_id" + t.index [ "class_name" ], name: "index_solid_queue_jobs_on_class_name" + t.index [ "finished_at" ], name: "index_solid_queue_jobs_on_finished_at" + t.index [ "queue_name", "finished_at" ], name: "index_solid_queue_jobs_for_filtering" + t.index [ "scheduled_at", "finished_at" ], name: "index_solid_queue_jobs_for_alerting" + end + + create_table "solid_queue_pauses", force: :cascade do |t| + t.string "queue_name", null: false + t.datetime "created_at", null: false + t.index [ "queue_name" ], name: "index_solid_queue_pauses_on_queue_name", unique: true + end + + create_table "solid_queue_processes", force: :cascade do |t| + t.string "kind", null: false + t.datetime "last_heartbeat_at", null: false + t.bigint "supervisor_id" + t.integer "pid", null: false + t.string "hostname" + t.text "metadata" + t.datetime "created_at", null: false + t.string "name", null: false + t.index [ "last_heartbeat_at" ], name: "index_solid_queue_processes_on_last_heartbeat_at" + t.index [ "name", "supervisor_id" ], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true + t.index [ "supervisor_id" ], name: "index_solid_queue_processes_on_supervisor_id" + end + + create_table "solid_queue_ready_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_ready_executions_on_job_id", unique: true + t.index [ "priority", "job_id" ], name: "index_solid_queue_poll_all" + t.index [ "queue_name", "priority", "job_id" ], name: "index_solid_queue_poll_by_queue" + end + + create_table "solid_queue_recurring_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "task_key", null: false + t.datetime "run_at", null: false + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_recurring_executions_on_job_id", unique: true + t.index [ "task_key", "run_at" ], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true + end + + create_table "solid_queue_recurring_tasks", force: :cascade do |t| + t.string "key", null: false + t.string "schedule", null: false + t.string "command", limit: 2048 + t.string "class_name" + t.text "arguments" + t.string "queue_name" + t.integer "priority", default: 0 + t.boolean "static", default: true, null: false + t.text "description" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "key" ], name: "index_solid_queue_recurring_tasks_on_key", unique: true + t.index [ "static" ], name: "index_solid_queue_recurring_tasks_on_static" + end + + create_table "solid_queue_scheduled_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.datetime "scheduled_at", null: false + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true + t.index [ "scheduled_at", "priority", "job_id" ], name: "index_solid_queue_dispatch_all" + end + + create_table "solid_queue_semaphores", force: :cascade do |t| + t.string "key", null: false + t.integer "value", default: 1, null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "expires_at" ], name: "index_solid_queue_semaphores_on_expires_at" + t.index [ "key", "value" ], name: "index_solid_queue_semaphores_on_key_and_value" + t.index [ "key" ], name: "index_solid_queue_semaphores_on_key", unique: true + end + + create_table "solid_queue_batches", force: :cascade do |t| + t.string "active_job_batch_id" + t.string "description" + t.text "on_finish" + t.text "on_success" + t.text "on_failure" + t.text "metadata" + t.integer "total_jobs", default: 0, null: false + t.integer "completed_jobs", default: 0, null: false + t.integer "failed_jobs", default: 0, null: false + t.datetime "enqueued_at" + t.datetime "finished_at" + t.datetime "failed_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "active_job_batch_id" ], name: "index_solid_queue_batches_on_active_job_batch_id", unique: true + t.index [ "finished_at" ], name: "index_solid_queue_batches_on_finished_at" + end + + create_table "solid_queue_batch_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.bigint "batch_id", null: false + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_batch_executions_on_job_id", unique: true + t.index [ "batch_id" ], name: "index_solid_queue_batch_executions_on_batch_id" + end + + add_foreign_key "solid_queue_batch_executions", "solid_queue_batches", column: "batch_id", on_delete: :cascade + add_foreign_key "solid_queue_batch_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade +end diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 000000000..03e73681a --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,14 @@ +# 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: 0) do +end diff --git a/db/seeds.rb b/db/seeds.rb new file mode 100644 index 000000000..4fbd6ed97 --- /dev/null +++ b/db/seeds.rb @@ -0,0 +1,9 @@ +# This file should ensure the existence of records required to run the application in every environment (production, +# development, test). The code here should be idempotent so that it can be executed at any point in every environment. +# The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup). +# +# Example: +# +# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name| +# MovieGenre.find_or_create_by!(name: genre_name) +# end diff --git a/lib/tasks/.keep b/lib/tasks/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/log/.keep b/log/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/public/400.html b/public/400.html new file mode 100644 index 000000000..640de0339 --- /dev/null +++ b/public/400.html @@ -0,0 +1,135 @@ + + + + + + + The server cannot process the request due to a client error (400 Bad Request) + + + + + + + + + + + + + +
+
+ +
+
+

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

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

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

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

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

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

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

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

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

+
+
+ + + + diff --git a/public/icon.png b/public/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c4c9dbfbbd2f7c1421ffd5727188146213abbcef GIT binary patch literal 4166 zcmd6qU;WFw?|v@m)Sk^&NvB8tcujdV-r1b=i(NJxn&7{KTb zX$3(M+3TP2o^#KAo{#tIjl&t~(8D-k004kqPglzn0HFG(Q~(I*AKsD#M*g7!XK0T7 zN6P7j>HcT8rZgKl$v!xr806dyN19Bd4C0x_R*I-a?#zsTvb_89cyhuC&T**i|Rc zq5b8M;+{8KvoJ~uj9`u~d_f6`V&3+&ZX9x5pc8s)d175;@pjm(?dapmBcm0&vl9+W zx1ZD2o^nuyUHWj|^A8r>lUorO`wFF;>9XL-Jy!P}UXC{(z!FO%SH~8k`#|9;Q|eue zqWL0^Bp(fg_+Pkm!fDKRSY;+^@BF?AJE zCUWpXPst~hi_~u)SzYBDZroR+Z4xeHIlm_3Yc_9nZ(o_gg!jDgVa=E}Y8uDgem9`b zf=mfJ_@(BXSkW53B)F2s!&?_R4ptb1fYXlF++@vPhd=marQgEGRZS@B4g1Mu?euknL= z67P~tZ?*>-Hmi7GwlisNHHJDku-dSm7g@!=a}9cSL6Pa^w^2?&?$Oi8ibrr>w)xqx zOH_EMU@m05)9kuNR>>4@H%|){U$^yvVQ(YgOlh;5oU_-vivG-p4=LrN-k7D?*?u1u zsWly%tfAzKd6Fb=`eU2un_uaTXmcT#tlOL+aRS=kZZf}A7qT8lvcTx~7j` z*b>=z)mwg7%B2_!D0!1IZ?Nq{^Y$uI4Qx*6T!E2Col&2{k?ImCO=dD~A&9f9diXy^$x{6CwkBimn|1E09 zAMSezYtiL?O6hS37KpvDM?22&d{l)7h-!F)C-d3j8Z`c@($?mfd{R82)H>Qe`h{~G z!I}(2j(|49{LR?w4Jspl_i!(4T{31|dqCOpI52r5NhxYV+cDAu(xp*4iqZ2e-$YP= zoFOPmm|u*7C?S{Fp43y+V;>~@FFR76bCl@pTtyB93vNWy5yf;HKr8^0d7&GVIslYm zo3Tgt@M!`8B6IW&lK{Xk>%zp41G%`(DR&^u z5^pwD4>E6-w<8Kl2DzJ%a@~QDE$(e87lNhy?-Qgep!$b?5f7+&EM7$e>|WrX+=zCb z=!f5P>MxFyy;mIRxjc(H*}mceXw5a*IpC0PEYJ8Y3{JdoIW)@t97{wcUB@u+$FCCO z;s2Qe(d~oJC^`m$7DE-dsha`glrtu&v&93IZadvl_yjp!c89>zo;Krk+d&DEG4?x$ zufC1n+c1XD7dolX1q|7}uelR$`pT0Z)1jun<39$Sn2V5g&|(j~Z!wOddfYiZo7)A< z!dK`aBHOOk+-E_xbWCA3VR-+o$i5eO9`rMI#p_0xQ}rjEpGW;U!&&PKnivOcG(|m9 z!C8?WC6nCXw25WVa*eew)zQ=h45k8jSIPbq&?VE{oG%?4>9rwEeB4&qe#?-y_es4c|7ufw%+H5EY#oCgv!Lzv291#-oNlX~X+Jl5(riC~r z=0M|wMOP)Tt8@hNg&%V@Z9@J|Q#K*hE>sr6@oguas9&6^-=~$*2Gs%h#GF@h)i=Im z^iKk~ipWJg1VrvKS;_2lgs3n1zvNvxb27nGM=NXE!D4C!U`f*K2B@^^&ij9y}DTLB*FI zEnBL6y{jc?JqXWbkIZd7I16hA>(f9T!iwbIxJj~bKPfrO;>%*5nk&Lf?G@c2wvGrY&41$W{7HM9+b@&XY@>NZM5s|EK_Dp zQX60CBuantx>|d#DsaZ*8MW(we|#KTYZ=vNa#d*DJQe6hr~J6{_rI#?wi@s|&O}FR zG$kfPxheXh1?IZ{bDT-CWB4FTvO-k5scW^mi8?iY5Q`f8JcnnCxiy@m@D-%lO;y0pTLhh6i6l@x52j=#^$5_U^os}OFg zzdHbo(QI`%9#o*r8GCW~T3UdV`szO#~)^&X_(VW>o~umY9-ns9-V4lf~j z`QBD~pJ4a#b`*6bJ^3RS5y?RAgF7K5$ll97Y8#WZduZ`j?IEY~H(s^doZg>7-tk*t z4_QE1%%bb^p~4F5SB$t2i1>DBG1cIo;2(xTaj*Y~hlM{tSDHojL-QPg%Mo%6^7FrpB*{ z4G0@T{-77Por4DCMF zB_5Y~Phv%EQ64W8^GS6h?x6xh;w2{z3$rhC;m+;uD&pR74j+i22P5DS-tE8ABvH(U~indEbBUTAAAXfHZg5QpB@TgV9eI<)JrAkOI z8!TSOgfAJiWAXeM&vR4Glh;VxH}WG&V$bVb`a`g}GSpwggti*&)taV1@Ak|{WrV|5 zmNYx)Ans=S{c52qv@+jmGQ&vd6>6yX6IKq9O$3r&0xUTdZ!m1!irzn`SY+F23Rl6# zFRxws&gV-kM1NX(3(gnKpGi0Q)Dxi~#?nyzOR9!en;Ij>YJZVFAL*=R%7y%Mz9hU% zs>+ZB?qRmZ)nISx7wxY)y#cd$iaC~{k0avD>BjyF1q^mNQ1QcwsxiTySe<6C&cC6P zE`vwO9^k-d`9hZ!+r@Jnr+MF*2;2l8WjZ}DrwDUHzSF{WoG zucbSWguA!3KgB3MU%HH`R;XqVv0CcaGq?+;v_A5A2kpmk5V%qZE3yzQ7R5XWhq=eR zyUezH=@V)y>L9T-M-?tW(PQYTRBKZSVb_!$^H-Pn%ea;!vS_?M<~Tm>_rWIW43sPW z=!lY&fWc1g7+r?R)0p8(%zp&vl+FK4HRkns%BW+Up&wK8!lQ2~bja|9bD12WrKn#M zK)Yl9*8$SI7MAwSK$%)dMd>o+1UD<2&aQMhyjS5R{-vV+M;Q4bzl~Z~=4HFj_#2V9 zB)Gfzx3ncy@uzx?yzi}6>d%-?WE}h7v*w)Jr_gBl!2P&F3DX>j_1#--yjpL%<;JMR z*b70Gr)MMIBWDo~#<5F^Q0$VKI;SBIRneuR7)yVsN~A9I@gZTXe)E?iVII+X5h0~H zx^c(fP&4>!*q>fb6dAOC?MI>Cz3kld#J*;uik+Ps49cwm1B4 zZc1|ZxYyTv;{Z!?qS=D)sgRKx^1AYf%;y_V&VgZglfU>d+Ufk5&LV$sKv}Hoj+s; xK3FZRYdhbXT_@RW*ff3@`D1#ps#~H)p+y&j#(J|vk^lW{fF9OJt5(B-_&*Xgn9~3N literal 0 HcmV?d00001 diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 000000000..04b34bf83 --- /dev/null +++ b/public/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 000000000..c19f78ab6 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file diff --git a/script/.keep b/script/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/storage/.keep b/storage/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/tmp/.keep b/tmp/.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 77d008d085bba76f06919405649842236b18ce0c Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Wed, 2 Sep 2026 21:20:43 -0300 Subject: [PATCH 02/68] test: configure RSpec test suite and add Fase 0 boot smoke specs Set up rspec-rails, FactoryBot, Faker, Shoulda Matchers, SimpleCov, Capybara with the Playwright driver, and parallel_tests. Configure database.yml so parallel workers each get their own SQLite test database (TEST_ENV_NUMBER), avoiding lock contention. Add a request spec for the health check endpoint and a boot spec verifying the environment loads with WAL mode, the required gems and the ActiveJob test adapter. --- .gitignore | 6 ++ .rspec | 1 + Gemfile | 26 ++++++++ Gemfile.lock | 86 ++++++++++++++++++++++++++ config/database.yml | 2 +- spec/boot_spec.rb | 23 +++++++ spec/rails_helper.rb | 72 ++++++++++++++++++++++ spec/requests/health_check_spec.rb | 9 +++ spec/spec_helper.rb | 97 ++++++++++++++++++++++++++++++ spec/support/capybara.rb | 14 +++++ spec/support/factory_bot.rb | 3 + spec/support/shoulda_matchers.rb | 6 ++ 12 files changed, 344 insertions(+), 1 deletion(-) create mode 100644 .rspec create mode 100644 spec/boot_spec.rb create mode 100644 spec/rails_helper.rb create mode 100644 spec/requests/health_check_spec.rb create mode 100644 spec/spec_helper.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/.gitignore b/.gitignore index d96ad6d79..9963139e5 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,12 @@ yarn-error.log # Aider Chat .aider* +# Ruby LSP +.ruby-lsp/ + +# RSpec persisted run status (--only-failures / --next-failure) +/spec/examples.txt + # Ignore key files for decrypting credentials and more. /config/*.key 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/Gemfile b/Gemfile index baa25469e..69133319d 100644 --- a/Gemfile +++ b/Gemfile @@ -52,6 +52,32 @@ group :development, :test do # Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/] gem "rubocop-rails-omakase", require: false + + # Testing framework [https://github.com/rspec/rspec-rails] + gem "rspec-rails" + + # Test data factories [https://github.com/thoughtbot/factory_bot_rails] + gem "factory_bot_rails" + + # Fake data generator for factories [https://github.com/faker-ruby/faker] + gem "faker" + + # One-liner matchers for common Rails functionality [https://github.com/thoughtbot/shoulda-matchers] + gem "shoulda-matchers" + + # Splits the test suite across parallel processes [https://github.com/grosser/parallel_tests] + gem "parallel_tests" +end + +group :test do + # Code coverage reporting [https://github.com/simplecov-ruby/simplecov] + gem "simplecov", require: false + + # Acceptance test framework [https://github.com/teamcapybara/capybara] + gem "capybara" + + # Playwright driver for Capybara system tests [https://github.com/YusukeIwaki/capybara-playwright-driver] + gem "capybara-playwright-driver" end group :development do diff --git a/Gemfile.lock b/Gemfile.lock index 884e295cf..ff9c203ab 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -75,6 +75,8 @@ GEM 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) @@ -88,6 +90,19 @@ GEM 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) + capybara-playwright-driver (0.5.10) + addressable + capybara + playwright-ruby-client (>= 1.16.0) concurrent-ruby (1.3.8) connection_pool (3.0.2) crass (1.0.7) @@ -95,6 +110,7 @@ GEM 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) @@ -102,6 +118,13 @@ GEM 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) ffi (1.17.4-aarch64-linux-gnu) ffi (1.17.4-aarch64-linux-musl) ffi (1.17.4-arm-linux-gnu) @@ -153,6 +176,11 @@ GEM net-pop net-smtp marcel (1.2.1) + matrix (0.4.3) + mime-types (3.7.0) + logger + mime-types-data (~> 3.2025, >= 3.2025.0507) + mime-types-data (3.2026.0701) mini_magick (5.4.0) logger mini_mime (1.1.5) @@ -189,9 +217,15 @@ GEM racc (~> 1.4) ostruct (0.6.3) parallel (2.1.0) + parallel_tests (5.7.0) + parallel parser (3.3.12.0) ast (~> 2.4.1) racc + playwright-ruby-client (1.62.0) + base64 + concurrent-ruby (>= 1.1.6) + mime-types (>= 3.0) pp (0.6.4) prettyprint prettyprint (0.2.0) @@ -200,6 +234,7 @@ GEM 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) @@ -256,6 +291,23 @@ GEM regexp_parser (2.12.0) reline (0.7.0) io-console (~> 0.5) + 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) @@ -289,6 +341,9 @@ GEM ffi (~> 1.12) logger securerandom (0.4.1) + shoulda-matchers (8.0.1) + activesupport (>= 7.2) + simplecov (1.1.1) solid_cable (4.0.2) actioncable (>= 7.2) activejob (>= 7.2) @@ -352,6 +407,8 @@ GEM base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) + xpath (3.2.0) + nokogiri (~> 1.8) zeitwerk (2.8.3) PLATFORMS @@ -368,14 +425,22 @@ DEPENDENCIES bootsnap brakeman bundler-audit + capybara + capybara-playwright-driver debug + factory_bot_rails + faker image_processing (~> 1.2) importmap-rails kamal + parallel_tests propshaft puma (>= 5.0) rails (~> 8.1.3, >= 8.1.3.1) + rspec-rails rubocop-rails-omakase + shoulda-matchers + simplecov solid_cable solid_cache solid_queue @@ -400,6 +465,7 @@ CHECKSUMS 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 @@ -409,17 +475,23 @@ CHECKSUMS 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 + capybara-playwright-driver (0.5.10) sha256=e48e572d72bc1043c644fab44985be0a1e75d7d6917dc298355581848982a2c3 concurrent-ruby (1.3.8) sha256=b2f1be836e968ccc78ccfce277ea79c72a88633f22306782c16ff23fb415d1e1 connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a crass (1.0.7) sha256=94868719948664c89ddcaf0a37c65048413dfcb1c869470a5f7a7ceb5390b295 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 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 @@ -441,6 +513,9 @@ CHECKSUMS loofah (2.25.2) sha256=2007f746959ac65552456e04b433e83deb22759ab38c838b4445c70e43425918 mail (2.9.1) sha256=06574eca475253d6c18145dd70af80d0eb970182d55053497c5f4d797ea160e8 marcel (1.2.1) sha256=1678e9360e32f9eafa917c80029e2f6d10b2715c66a4b87b6d0da9b9cd1f859f + matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b + mime-types (3.7.0) sha256=dcebf61c246f08e15a4de34e386ebe8233791e868564a470c3fe77c00eed5e56 + mime-types-data (3.2026.0701) sha256=cd8811e1fb89d836499ba0582368a10ee74cef929ba956d1d5ddca045e6a730f mini_magick (5.4.0) sha256=f120af581d9ed4ec52c57f35a67a605d112bb1d8f582d1415b147fda42d11d78 mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1 @@ -461,11 +536,14 @@ CHECKSUMS nokogiri (1.19.4-x86_64-linux-musl) sha256=17dfb7c1fa194ae02fbf7c51a7afc8d278045ab3fdacfd86f91d02d7b274470b ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 parallel (2.1.0) sha256=b35258865c2e31134c5ecb708beaaf6772adf9d5efae28e93e99260877b09356 + parallel_tests (5.7.0) sha256=3f1762c46ca2c223b8af8ef877217f9d76974e191bfa934f2580b58bcf1d005c parser (3.3.12.0) sha256=21a6d7f755d5a24dfbdc6e6b772e4e879a52e7631a88bc5a3a134606052c9828 + playwright-ruby-client (1.62.0) sha256=44eb6051ab7987f68a1288a7db7892403e59680116739987729c5fecfdb55715 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 @@ -483,6 +561,11 @@ CHECKSUMS rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469 regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d + 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-performance (1.27.0) sha256=eeeb1374d062a368ee1c787b70eb0b0cc4b184cb1f8565f424760946146d61ce @@ -491,6 +574,8 @@ CHECKSUMS ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 ruby-vips (2.3.0) sha256=e685ec02c13969912debbd98019e50492e12989282da5f37d05f5471442f5374 securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 + shoulda-matchers (8.0.1) sha256=5dbb46e5765b9da225111b085e0819e8c8a121ff94bba430a153eb1ea2c60288 + 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 @@ -523,6 +608,7 @@ CHECKSUMS 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 diff --git a/config/database.yml b/config/database.yml index 302d638c9..da9864dcc 100644 --- a/config/database.yml +++ b/config/database.yml @@ -18,7 +18,7 @@ development: # Do not set this db to the same as development or production. test: <<: *default - database: storage/test.sqlite3 + database: storage/test<%= ENV["TEST_ENV_NUMBER"] %>.sqlite3 # Store production database in the storage/ directory, which by default # is mounted as a persistent Docker volume in config/deploy.yml. diff --git a/spec/boot_spec.rb b/spec/boot_spec.rb new file mode 100644 index 000000000..397db8c70 --- /dev/null +++ b/spec/boot_spec.rb @@ -0,0 +1,23 @@ +require "rails_helper" + +RSpec.describe "Application boot" do + it "loads the Rails environment without raising" do + expect(Rails.application).to be_initialized + end + + it "runs migrations with SQLite in WAL journal mode" do + result = ActiveRecord::Base.lease_connection.execute("PRAGMA journal_mode").first["journal_mode"] + + expect(result).to eq("wal") + end + + it "loads the required testing and infrastructure gems" do + %w[FactoryBot Faker Shoulda::Matchers Capybara SolidQueue SolidCache SolidCable].each do |const_name| + expect(const_name.safe_constantize).not_to be_nil, "expected #{const_name} to be loaded" + end + end + + it "uses the ActiveJob test adapter in the test environment" do + expect(ActiveJob::Base.queue_adapter).to be_a(ActiveJob::QueueAdapters::TestAdapter) + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb new file mode 100644 index 000000000..f7c3f3851 --- /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/requests/health_check_spec.rb b/spec/requests/health_check_spec.rb new file mode 100644 index 000000000..ddeecc794 --- /dev/null +++ b/spec/requests/health_check_spec.rb @@ -0,0 +1,9 @@ +require "rails_helper" + +RSpec.describe "Health check", type: :request do + it "responds with 200 OK on GET /up" do + get "/up" + + expect(response).to have_http_status(:ok) + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 000000000..39c782f6a --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,97 @@ +# 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 +require "simplecov" +SimpleCov.start "rails" do + skip "/spec/" + skip "/config/" + skip "/db/" +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 + 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 + + # 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/spec/support/capybara.rb b/spec/support/capybara.rb new file mode 100644 index 000000000..d54f8897e --- /dev/null +++ b/spec/support/capybara.rb @@ -0,0 +1,14 @@ +require "capybara-playwright-driver" + +Capybara.register_driver(:playwright) do |app| + Capybara::Playwright::Driver.new(app, browser_type: :chromium, headless: true) +end + +Capybara.default_max_wait_time = 5 +Capybara.save_path = Rails.root.join("tmp/capybara") + +RSpec.configure do |config| + config.before(:each, type: :system) do + driven_by :playwright + end +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 b6d81172cef8dab7b94796ec8b18dd0a9fa419f2 Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Wed, 2 Sep 2026 21:21:00 -0300 Subject: [PATCH 03/68] ci: run parallel RSpec suite alongside RuboCop, Brakeman and bundler-audit Add a test job to the GitHub Actions pipeline that provisions one SQLite database per parallel worker and runs the suite via parallel_rspec, uploading the SimpleCov report as an artifact. Also fix the push trigger branch to match this repo's default (master). --- .github/workflows/ci.yml | 42 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 69ea37803..b26599693 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ name: CI on: pull_request: push: - branches: [ main ] + branches: [ master ] jobs: scan_ruby: @@ -20,10 +20,10 @@ jobs: - 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 @@ -65,3 +65,39 @@ jobs: - name: Lint code for consistent style run: bin/rubocop -f github + test: + runs-on: ubuntu-latest + env: + RAILS_ENV: test + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install Playwright browsers (for Capybara system specs) + run: | + PLAYWRIGHT_CLI_VERSION=$(bundle exec ruby -e 'require "playwright"; puts Playwright::COMPATIBLE_PLAYWRIGHT_VERSION.strip') + npm install playwright@${PLAYWRIGHT_CLI_VERSION} + ./node_modules/.bin/playwright install --with-deps chromium + + - name: Prepare test databases in parallel + run: bin/rails parallel:create parallel:load_schema + + - name: Run test suite in parallel + run: bundle exec parallel_rspec spec/ + + - name: Upload coverage report + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage/ + From 9c4fcad4c215633fbfd25b9337ffbb6ed0a2addf Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Wed, 2 Sep 2026 21:59:37 -0300 Subject: [PATCH 04/68] feat: add Rails 8 authentication scaffold with role-aware User model Generate the built-in authentication system (bin/rails generate authentication): User with has_secure_password, Session, Current, sign in/out, and password reset via email. Rename the generated email_address column to email and extend User with full_name and a role enum (no_admin/admin, defaulting to no_admin) to match the domain model required by the spec. --- Gemfile | 2 +- Gemfile.lock | 3 ++ app/channels/application_cable/connection.rb | 16 ++++++ app/controllers/application_controller.rb | 1 + app/controllers/concerns/authentication.rb | 52 ++++++++++++++++++++ app/controllers/passwords_controller.rb | 35 +++++++++++++ app/controllers/sessions_controller.rb | 21 ++++++++ app/mailers/passwords_mailer.rb | 6 +++ app/models/current.rb | 4 ++ app/models/session.rb | 3 ++ app/models/user.rb | 11 +++++ app/views/passwords/edit.html.erb | 21 ++++++++ app/views/passwords/new.html.erb | 17 +++++++ app/views/passwords_mailer/reset.html.erb | 6 +++ app/views/passwords_mailer/reset.text.erb | 4 ++ app/views/sessions/new.html.erb | 31 ++++++++++++ config/routes.rb | 4 +- db/migrate/20260903005315_create_users.rb | 13 +++++ db/migrate/20260903005316_create_sessions.rb | 11 +++++ db/schema.rb | 22 ++++++++- 20 files changed, 280 insertions(+), 3 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/20260903005315_create_users.rb create mode 100644 db/migrate/20260903005316_create_sessions.rb diff --git a/Gemfile b/Gemfile index 69133319d..769540d5c 100644 --- a/Gemfile +++ b/Gemfile @@ -18,7 +18,7 @@ gem "stimulus-rails" gem "tailwindcss-rails" # Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] -# gem "bcrypt", "~> 3.1.7" +gem "bcrypt", "~> 3.1.7" # Windows does not include zoneinfo files, so bundle the tzinfo-data gem gem "tzinfo-data", platforms: %i[ windows jruby ] diff --git a/Gemfile.lock b/Gemfile.lock index ff9c203ab..5d23b490c 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) @@ -422,6 +423,7 @@ PLATFORMS x86_64-linux-musl DEPENDENCIES + bcrypt (~> 3.1.7) bootsnap brakeman bundler-audit @@ -468,6 +470,7 @@ CHECKSUMS addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + bcrypt (3.1.22) sha256=1f0072e88c2d705d94aff7f2c5cb02eb3f1ec4b8368671e19112527489f29032 bcrypt_pbkdf (1.1.2) sha256=c2414c23ce66869b3eb9f643d6a3374d8322dfb5078125c82792304c10b94cf6 bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb new file mode 100644 index 000000000..4264c745c --- /dev/null +++ b/app/channels/application_cable/connection.rb @@ -0,0 +1,16 @@ +module ApplicationCable + class Connection < ActionCable::Connection::Base + identified_by :current_user + + def connect + set_current_user || reject_unauthorized_connection + end + + private + def set_current_user + if session = Session.find_by(id: cookies.signed[:session_id]) + self.current_user = session.user + end + end + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index c3537563d..5f38f02f3 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,4 +1,5 @@ class ApplicationController < ActionController::Base + include Authentication # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. allow_browser versions: :modern diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb new file mode 100644 index 000000000..3538f485c --- /dev/null +++ b/app/controllers/concerns/authentication.rb @@ -0,0 +1,52 @@ +module Authentication + extend ActiveSupport::Concern + + included do + before_action :require_authentication + helper_method :authenticated? + end + + class_methods do + def allow_unauthenticated_access(**options) + skip_before_action :require_authentication, **options + end + end + + private + def authenticated? + resume_session + end + + def require_authentication + resume_session || request_authentication + end + + def resume_session + Current.session ||= find_session_by_cookie + end + + def find_session_by_cookie + Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id] + end + + def request_authentication + session[:return_to_after_authenticating] = request.url + redirect_to new_session_path + end + + def after_authentication_url + session.delete(:return_to_after_authenticating) || root_url + end + + def start_new_session_for(user) + user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session| + Current.session = session + cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax } + end + end + + def terminate_session + Current.session.destroy + cookies.delete(:session_id) + end +end diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb new file mode 100644 index 000000000..d4340c928 --- /dev/null +++ b/app/controllers/passwords_controller.rb @@ -0,0 +1,35 @@ +class PasswordsController < ApplicationController + allow_unauthenticated_access + before_action :set_user_by_token, only: %i[ edit update ] + rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." } + + def new + end + + def create + if user = User.find_by(email: params[:email]) + PasswordsMailer.reset(user).deliver_later + end + + redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)." + end + + def edit + end + + def update + if @user.update(params.permit(:password, :password_confirmation)) + @user.sessions.destroy_all + redirect_to new_session_path, notice: "Password has been reset." + else + redirect_to edit_password_path(params[:token]), alert: "Passwords did not match." + end + end + + private + def set_user_by_token + @user = User.find_by_password_reset_token!(params[:token]) + rescue ActiveSupport::MessageVerifier::InvalidSignature + redirect_to new_password_path, alert: "Password reset link is invalid or has expired." + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb new file mode 100644 index 000000000..5ed56f89b --- /dev/null +++ b/app/controllers/sessions_controller.rb @@ -0,0 +1,21 @@ +class SessionsController < ApplicationController + allow_unauthenticated_access only: %i[ new create ] + rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." } + + def new + end + + def create + if user = User.authenticate_by(params.permit(:email, :password)) + start_new_session_for user + redirect_to after_authentication_url + else + redirect_to new_session_path, alert: "Try another email address or password." + end + end + + def destroy + terminate_session + redirect_to new_session_path, status: :see_other + end +end diff --git a/app/mailers/passwords_mailer.rb b/app/mailers/passwords_mailer.rb new file mode 100644 index 000000000..06ac4a4da --- /dev/null +++ b/app/mailers/passwords_mailer.rb @@ -0,0 +1,6 @@ +class PasswordsMailer < ApplicationMailer + def reset(user) + @user = user + mail subject: "Reset your password", to: user.email + 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..b5f32167e --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,11 @@ +class User < ApplicationRecord + has_secure_password + has_many :sessions, dependent: :destroy + + enum :role, { no_admin: 0, admin: 1 } + + normalizes :email, with: ->(e) { e.strip.downcase } + + validates :full_name, presence: true + validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP } +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..6f08c3d57 --- /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, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email], 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..ef93c4fd9 --- /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, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email], class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> +
+ +
+ <%= form.password_field :password, required: true, autocomplete: "current-password", placeholder: "Enter your password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> +
+ +
+
+ <%= form.submit "Sign in", class: "w-full sm:w-auto text-center rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white inline-block font-medium cursor-pointer" %> +
+ +
+ <%= link_to "Forgot password?", new_password_path, class: "text-gray-700 underline hover:no-underline" %> +
+
+ <% end %> +
diff --git a/config/routes.rb b/config/routes.rb index 48254e88e..825189f56 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. @@ -10,5 +12,5 @@ # get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker # Defines the root path route ("/") - # root "posts#index" + root "sessions#new" end diff --git a/db/migrate/20260903005315_create_users.rb b/db/migrate/20260903005315_create_users.rb new file mode 100644 index 000000000..20870ca31 --- /dev/null +++ b/db/migrate/20260903005315_create_users.rb @@ -0,0 +1,13 @@ +class CreateUsers < ActiveRecord::Migration[8.1] + def change + create_table :users do |t| + t.string :full_name, null: false + t.string :email, null: false + t.string :password_digest, null: false + t.integer :role, null: false, default: 0 + + t.timestamps + end + add_index :users, :email, unique: true + end +end diff --git a/db/migrate/20260903005316_create_sessions.rb b/db/migrate/20260903005316_create_sessions.rb new file mode 100644 index 000000000..ec9efdbaa --- /dev/null +++ b/db/migrate/20260903005316_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/schema.rb b/db/schema.rb index 03e73681a..a1cd2c1b5 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,5 +10,25 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 0) do +ActiveRecord::Schema[8.1].define(version: 2026_09_03_005316) do + create_table "sessions", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "ip_address" + t.datetime "updated_at", null: false + t.string "user_agent" + t.integer "user_id", null: false + t.index ["user_id"], name: "index_sessions_on_user_id" + end + + create_table "users", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "email", null: false + t.string "full_name", null: false + t.string "password_digest", null: false + t.integer "role", default: 0, null: false + t.datetime "updated_at", null: false + t.index ["email"], name: "index_users_on_email", unique: true + end + + add_foreign_key "sessions", "users" end From a83c247d4b93b53149c2573bad9fee9ebcd99667 Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Wed, 2 Sep 2026 21:59:54 -0300 Subject: [PATCH 05/68] feat: allow visitors to self-register as no_admin users Add a public registration form (full_name, email, password) that signs the new user in immediately after creation. The role is always hardcoded to no_admin server-side and is never accepted from request params, so a visitor cannot self-promote to admin. --- app/controllers/registrations_controller.rb | 24 +++++++++++ app/views/registrations/new.html.erb | 45 +++++++++++++++++++++ app/views/sessions/new.html.erb | 2 + config/routes.rb | 1 + 4 files changed, 72 insertions(+) create mode 100644 app/controllers/registrations_controller.rb create mode 100644 app/views/registrations/new.html.erb diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb new file mode 100644 index 000000000..2843dba07 --- /dev/null +++ b/app/controllers/registrations_controller.rb @@ -0,0 +1,24 @@ +class RegistrationsController < ApplicationController + allow_unauthenticated_access + + def new + @user = User.new + end + + def create + @user = User.new(registration_params) + @user.role = :no_admin + + if @user.save + start_new_session_for @user + redirect_to after_authentication_url, notice: "Welcome! Your account has been created." + else + render :new, status: :unprocessable_entity + end + end + + private + def registration_params + params.expect(user: [ :full_name, :email, :password, :password_confirmation ]) + end +end diff --git a/app/views/registrations/new.html.erb b/app/views/registrations/new.html.erb new file mode 100644 index 000000000..76a41c0fc --- /dev/null +++ b/app/views/registrations/new.html.erb @@ -0,0 +1,45 @@ +
+ <% if alert = flash[:alert] %> +

<%= alert %>

+ <% end %> + +

Create your account

+ + <%= form_with model: @user, url: registration_path, class: "contents" do |form| %> + <% if @user.errors.any? %> +
+
    + <% @user.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+ <% end %> + +
+ <%= form.text_field :full_name, required: true, autofocus: true, placeholder: "Full name", class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> +
+ +
+ <%= form.email_field :email, required: true, autocomplete: "username", placeholder: "Enter your 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: "new-password", placeholder: "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.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Confirm 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 up", 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 "Already have an account? Sign in", new_session_path, class: "text-gray-700 underline hover:no-underline" %> +
+
+ <% end %> +
diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index ef93c4fd9..3cdfef8b4 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -25,6 +25,8 @@
<%= link_to "Forgot password?", new_password_path, class: "text-gray-700 underline hover:no-underline" %> + · + <%= link_to "Create an account", new_registration_path, class: "text-gray-700 underline hover:no-underline" %>
<% end %> diff --git a/config/routes.rb b/config/routes.rb index 825189f56..3443613ac 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,6 +1,7 @@ Rails.application.routes.draw do resource :session resources :passwords, param: :token + resource :registration, only: %i[ new create ] # 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. From 63cb5bcb4466be88e371a12460ef3483eec74919 Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Wed, 2 Sep 2026 22:00:10 -0300 Subject: [PATCH 06/68] feat: redirect users to a role-based landing page after authentication Admins land on a minimal Admin Dashboard (to be built out with real-time counters in a later phase); everyone else lands on their own Profile page. Both controllers are intentionally thin for now: the dashboard's authorization check will be formalized with Pundit, and the profile/dashboard views will gain real functionality in their dedicated phases. --- app/controllers/admin/dashboards_controller.rb | 11 +++++++++++ app/controllers/concerns/authentication.rb | 2 +- app/controllers/profiles_controller.rb | 5 +++++ app/views/admin/dashboards/show.html.erb | 4 ++++ app/views/profiles/show.html.erb | 13 +++++++++++++ config/routes.rb | 4 ++++ 6 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 app/controllers/admin/dashboards_controller.rb create mode 100644 app/controllers/profiles_controller.rb create mode 100644 app/views/admin/dashboards/show.html.erb create mode 100644 app/views/profiles/show.html.erb diff --git a/app/controllers/admin/dashboards_controller.rb b/app/controllers/admin/dashboards_controller.rb new file mode 100644 index 000000000..d1e44f940 --- /dev/null +++ b/app/controllers/admin/dashboards_controller.rb @@ -0,0 +1,11 @@ +class Admin::DashboardsController < ApplicationController + before_action :require_admin + + def show + end + + private + def require_admin + head :forbidden unless Current.user.admin? + end +end diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 3538f485c..c03bad25d 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -35,7 +35,7 @@ def request_authentication end def after_authentication_url - session.delete(:return_to_after_authenticating) || root_url + session.delete(:return_to_after_authenticating) || (Current.user.admin? ? admin_dashboard_url : profile_url) end def start_new_session_for(user) diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb new file mode 100644 index 000000000..fb7e8f4dc --- /dev/null +++ b/app/controllers/profiles_controller.rb @@ -0,0 +1,5 @@ +class ProfilesController < ApplicationController + def show + @user = Current.user + end +end diff --git a/app/views/admin/dashboards/show.html.erb b/app/views/admin/dashboards/show.html.erb new file mode 100644 index 000000000..e3941940a --- /dev/null +++ b/app/views/admin/dashboards/show.html.erb @@ -0,0 +1,4 @@ +
+

Admin Dashboard

+

Signed in as <%= Current.user.full_name %> (<%= Current.user.role %>).

+
diff --git a/app/views/profiles/show.html.erb b/app/views/profiles/show.html.erb new file mode 100644 index 000000000..043bea44b --- /dev/null +++ b/app/views/profiles/show.html.erb @@ -0,0 +1,13 @@ +
+

My Profile

+
+
Full name
+
<%= @user.full_name %>
+ +
Email
+
<%= @user.email %>
+ +
Role
+
<%= @user.role %>
+
+
diff --git a/config/routes.rb b/config/routes.rb index 3443613ac..2ccd61914 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -2,6 +2,10 @@ resource :session resources :passwords, param: :token resource :registration, only: %i[ new create ] + resource :profile, only: :show + namespace :admin do + resource :dashboard, only: :show + end # 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. From 25bb9e30b90a4159aa9be12b3af862666ea638ec Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Wed, 2 Sep 2026 22:00:29 -0300 Subject: [PATCH 07/68] test: cover authentication, registration and role-based access Add model specs for User (validations, email normalization, role enum) and Session, plus request specs for sign in/out, self-registration (including an attempt to inject role=admin, which must be ignored), and access control on the admin dashboard and profile pages. --- spec/factories/users.rb | 12 ++++++ spec/models/session_spec.rb | 5 +++ spec/models/user_spec.rb | 42 ++++++++++++++++++++ spec/requests/admin/dashboard_spec.rb | 29 ++++++++++++++ spec/requests/profiles_spec.rb | 21 ++++++++++ spec/requests/registrations_spec.rb | 55 ++++++++++++++++++++++++++ spec/requests/sessions_spec.rb | 43 ++++++++++++++++++++ spec/support/authentication_helpers.rb | 9 +++++ 8 files changed, 216 insertions(+) create mode 100644 spec/factories/users.rb create mode 100644 spec/models/session_spec.rb create mode 100644 spec/models/user_spec.rb 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/spec/factories/users.rb b/spec/factories/users.rb new file mode 100644 index 000000000..0003ccd74 --- /dev/null +++ b/spec/factories/users.rb @@ -0,0 +1,12 @@ +FactoryBot.define do + factory :user do + sequence(:full_name) { |n| "#{Faker::Name.name} #{n}" } + sequence(:email) { |n| "user#{n}@example.com" } + password { "password123" } + role { :no_admin } + + trait :admin do + role { :admin } + end + end +end diff --git a/spec/models/session_spec.rb b/spec/models/session_spec.rb new file mode 100644 index 000000000..a8e01daac --- /dev/null +++ b/spec/models/session_spec.rb @@ -0,0 +1,5 @@ +require "rails_helper" + +RSpec.describe Session, type: :model do + it { is_expected.to belong_to(:user) } +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb new file mode 100644 index 000000000..e7060a55f --- /dev/null +++ b/spec/models/user_spec.rb @@ -0,0 +1,42 @@ +require "rails_helper" + +RSpec.describe User, type: :model do + describe "validations" do + subject { build(:user) } + + it { is_expected.to validate_presence_of(:full_name) } + it { is_expected.to validate_presence_of(:email) } + it { is_expected.to validate_uniqueness_of(:email).case_insensitive } + it { is_expected.to have_secure_password } + + it "rejects a malformed email" do + user = build(:user, email: "not-an-email") + + expect(user).not_to be_valid + expect(user.errors[:email]).to be_present + end + end + + describe "email normalization" do + it "strips whitespace and downcases the email before saving" do + user = create(:user, email: " MixedCase@Example.com ") + + expect(user.email).to eq("mixedcase@example.com") + end + end + + describe "role enum" do + it "defaults to no_admin" do + user = User.new + + expect(user.role).to eq("no_admin") + expect(user).to be_no_admin + end + + it { is_expected.to define_enum_for(:role).with_values(no_admin: 0, admin: 1) } + end + + describe "associations" do + it { is_expected.to have_many(:sessions).dependent(:destroy) } + end +end diff --git a/spec/requests/admin/dashboard_spec.rb b/spec/requests/admin/dashboard_spec.rb new file mode 100644 index 000000000..a89f71ad6 --- /dev/null +++ b/spec/requests/admin/dashboard_spec.rb @@ -0,0 +1,29 @@ +require "rails_helper" + +RSpec.describe "Admin::Dashboard", type: :request do + describe "GET /admin/dashboard" do + it "redirects unauthenticated visitors to sign in" do + get admin_dashboard_path + + expect(response).to redirect_to(new_session_path) + end + + it "forbids a signed in no_admin user" do + user = create(:user, password: "password123") + sign_in_as(user) + + get admin_dashboard_path + + expect(response).to have_http_status(:forbidden) + end + + it "allows a signed in admin user" do + admin = create(:user, :admin, password: "password123") + sign_in_as(admin) + + get admin_dashboard_path + + expect(response).to have_http_status(:ok) + end + end +end diff --git a/spec/requests/profiles_spec.rb b/spec/requests/profiles_spec.rb new file mode 100644 index 000000000..18fd36803 --- /dev/null +++ b/spec/requests/profiles_spec.rb @@ -0,0 +1,21 @@ +require "rails_helper" + +RSpec.describe "Profiles", type: :request do + describe "GET /profile" do + it "redirects unauthenticated visitors to sign in" do + get profile_path + + expect(response).to redirect_to(new_session_path) + end + + it "shows the signed in user's own info" do + user = create(:user, password: "password123") + sign_in_as(user) + + get profile_path + + expect(response).to have_http_status(:ok) + expect(response.body).to include(user.full_name) + end + end +end diff --git a/spec/requests/registrations_spec.rb b/spec/requests/registrations_spec.rb new file mode 100644 index 000000000..d9d8b20ca --- /dev/null +++ b/spec/requests/registrations_spec.rb @@ -0,0 +1,55 @@ +require "rails_helper" + +RSpec.describe "Registrations", type: :request do + describe "POST /registration" do + it "creates a no_admin user, signs them in, and redirects to the profile" do + expect { + post registration_path, params: { + user: { + full_name: "Ada Lovelace", + email: "ada@example.com", + password: "password123", + password_confirmation: "password123" + } + } + }.to change(User, :count).by(1) + + user = User.find_by(email: "ada@example.com") + expect(user).to be_no_admin + expect(response).to redirect_to(profile_url) + + get profile_path + expect(response).to have_http_status(:ok) + end + + it "ignores a role param and always forces no_admin" do + post registration_path, params: { + user: { + full_name: "Eve Attacker", + email: "eve@example.com", + password: "password123", + password_confirmation: "password123", + role: "admin" + } + } + + user = User.find_by(email: "eve@example.com") + expect(user).to be_no_admin + end + + it "re-renders the form with errors when the password confirmation does not match" do + expect { + post registration_path, params: { + user: { + full_name: "Ada Lovelace", + email: "ada@example.com", + password: "password123", + password_confirmation: "mismatch" + } + } + }.not_to change(User, :count) + + expect(response).to have_http_status(:unprocessable_entity) + end + end +end diff --git a/spec/requests/sessions_spec.rb b/spec/requests/sessions_spec.rb new file mode 100644 index 000000000..94d8e5438 --- /dev/null +++ b/spec/requests/sessions_spec.rb @@ -0,0 +1,43 @@ +require "rails_helper" + +RSpec.describe "Sessions", type: :request do + describe "POST /session" do + it "signs in a no_admin user and redirects to the profile" do + user = create(:user, password: "password123") + + sign_in_as(user) + + expect(response).to redirect_to(profile_url) + end + + it "signs in an admin user and redirects to the admin dashboard" do + admin = create(:user, :admin, password: "password123") + + sign_in_as(admin, password: "password123") + + expect(response).to redirect_to(admin_dashboard_url) + end + + it "rejects invalid credentials" do + user = create(:user, password: "password123") + + sign_in_as(user, password: "wrong-password") + + expect(response).to redirect_to(new_session_path) + expect(flash[:alert]).to be_present + end + end + + describe "DELETE /session" do + it "signs the user out" do + user = create(:user, password: "password123") + sign_in_as(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..133226e4d --- /dev/null +++ b/spec/support/authentication_helpers.rb @@ -0,0 +1,9 @@ +module AuthenticationHelpers + def sign_in_as(user, password: "password123") + post session_path, params: { email: user.email, password: password } + end +end + +RSpec.configure do |config| + config.include AuthenticationHelpers, type: :request +end From c53c3dae04997a6b851e827a61cac9a7e341a194 Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Wed, 2 Sep 2026 22:29:15 -0300 Subject: [PATCH 08/68] feat: add Pundit authorization with UserPolicy Wire Pundit into ApplicationController (pundit_user resolves to Current.user, since this app uses Rails 8's Current attributes instead of a current_user method) and enforce authorization on every action via after_action :verify_authorized, opting out only in the pre-authentication controllers (sessions, passwords, registration). UserPolicy centralizes the authorization matrix: an admin can manage any user; a no_admin user can only view/update/destroy their own record. Admin::DashboardsController and ProfilesController now call authorize instead of the naive role check from Fase 1. Unauthorized access redirects to the user's own profile with a flash alert instead of a bare 403. --- Gemfile | 6 +++ Gemfile.lock | 11 ++++ .../admin/dashboards_controller.rb | 8 +-- app/controllers/application_controller.rb | 14 +++++ app/controllers/passwords_controller.rb | 1 + app/controllers/profiles_controller.rb | 1 + app/controllers/registrations_controller.rb | 1 + app/controllers/sessions_controller.rb | 1 + app/policies/application_policy.rb | 53 +++++++++++++++++++ app/policies/user_policy.rb | 36 +++++++++++++ 10 files changed, 125 insertions(+), 7 deletions(-) create mode 100644 app/policies/application_policy.rb create mode 100644 app/policies/user_policy.rb diff --git a/Gemfile b/Gemfile index 769540d5c..8716c775c 100644 --- a/Gemfile +++ b/Gemfile @@ -40,6 +40,9 @@ gem "thruster", require: false # Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] gem "image_processing", "~> 1.2" +# Object-oriented authorization for Rails applications [https://github.com/varvet/pundit] +gem "pundit" + group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" @@ -78,6 +81,9 @@ group :test do # Playwright driver for Capybara system tests [https://github.com/YusukeIwaki/capybara-playwright-driver] gem "capybara-playwright-driver" + + # RSpec matchers for testing Pundit policies [https://github.com/pundit-community/pundit-matchers] + gem "pundit-matchers" end group :development do diff --git a/Gemfile.lock b/Gemfile.lock index 5d23b490c..227972a30 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -238,6 +238,13 @@ GEM public_suffix (7.0.5) puma (8.0.2) nio4r (~> 2.0) + pundit (2.5.2) + activesupport (>= 3.0.0) + pundit-matchers (4.0.0) + rspec-core (~> 3.12) + rspec-expectations (~> 3.12) + rspec-mocks (~> 3.12) + rspec-support (~> 3.12) raabro (1.5.0) racc (1.8.1) rack (3.2.7) @@ -438,6 +445,8 @@ DEPENDENCIES parallel_tests propshaft puma (>= 5.0) + pundit + pundit-matchers rails (~> 8.1.3, >= 8.1.3.1) rspec-rails rubocop-rails-omakase @@ -548,6 +557,8 @@ CHECKSUMS propshaft (1.3.2) sha256=1d56a3e56a92c21bfc29caf07406b5386b00d4c47ddf357cf989a5a234b1389e public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 puma (8.0.2) sha256=c8ed871dfbbe66448ea9ffd46692342d9804d4071522b52b5331b7b6e7b686fb + pundit (2.5.2) sha256=e374152baa24f90b630428293faf4b4c5468fc3cc010165f7d8fcb44ce108bbd + pundit-matchers (4.0.0) sha256=59d6077a1d575ea7cceca3ed73df5257488ee1a111ec707b2a797e76908cffd5 raabro (1.5.0) sha256=3f998a7bc84f9c84df3ab580634d2e0a5bda4f0841168d56035f529c9877440a racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f rack (3.2.7) sha256=93e13e1c24f93556671d85d2d79fa228c3485815c50d7e2f265b5330c6528fb7 diff --git a/app/controllers/admin/dashboards_controller.rb b/app/controllers/admin/dashboards_controller.rb index d1e44f940..2be2dab93 100644 --- a/app/controllers/admin/dashboards_controller.rb +++ b/app/controllers/admin/dashboards_controller.rb @@ -1,11 +1,5 @@ class Admin::DashboardsController < ApplicationController - before_action :require_admin - def show + authorize User, :index? end - - private - def require_admin - head :forbidden unless Current.user.admin? - end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 5f38f02f3..c7bc32431 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,8 +1,22 @@ class ApplicationController < ActionController::Base include Authentication + include Pundit::Authorization # 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 + + after_action :verify_authorized + + rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized + + private + def pundit_user + Current.user + end + + def user_not_authorized + redirect_to profile_path, alert: "You are not authorized to perform this action." + end end diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb index d4340c928..9c1967678 100644 --- a/app/controllers/passwords_controller.rb +++ b/app/controllers/passwords_controller.rb @@ -1,5 +1,6 @@ class PasswordsController < ApplicationController allow_unauthenticated_access + skip_after_action :verify_authorized before_action :set_user_by_token, only: %i[ edit update ] rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." } diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index fb7e8f4dc..b01b74e00 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -1,5 +1,6 @@ class ProfilesController < ApplicationController def show @user = Current.user + authorize @user end end diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 2843dba07..c472ae5e3 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -1,5 +1,6 @@ class RegistrationsController < ApplicationController allow_unauthenticated_access + skip_after_action :verify_authorized def new @user = User.new diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 5ed56f89b..9a5334872 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,5 +1,6 @@ class SessionsController < ApplicationController allow_unauthenticated_access only: %i[ new create ] + skip_after_action :verify_authorized rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." } def new diff --git a/app/policies/application_policy.rb b/app/policies/application_policy.rb new file mode 100644 index 000000000..be644fe34 --- /dev/null +++ b/app/policies/application_policy.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +class ApplicationPolicy + attr_reader :user, :record + + def initialize(user, record) + @user = user + @record = record + end + + def index? + false + end + + def show? + false + end + + def create? + false + end + + def new? + create? + end + + def update? + false + end + + def edit? + update? + end + + def destroy? + false + end + + class Scope + def initialize(user, scope) + @user = user + @scope = scope + end + + def resolve + raise NoMethodError, "You must define #resolve in #{self.class}" + end + + private + + attr_reader :user, :scope + end +end diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb new file mode 100644 index 000000000..0fd5029d9 --- /dev/null +++ b/app/policies/user_policy.rb @@ -0,0 +1,36 @@ +class UserPolicy < ApplicationPolicy + def index? + user.admin? + end + + def show? + user.admin? || own_record? + end + + def create? + user.admin? + end + + def update? + user.admin? || own_record? + end + + def destroy? + user.admin? || own_record? + end + + def toggle_role? + user.admin? + end + + class Scope < Scope + def resolve + user.admin? ? scope.all : scope.where(id: user.id) + end + end + + private + def own_record? + record == user + end +end From 6d716088262cbc6163c1a0ad23bc41de91b927fe Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Wed, 2 Sep 2026 22:29:27 -0300 Subject: [PATCH 09/68] refactor: extract shared flash partial into the layout Every auth-related view repeated the same alert/notice markup, and the two views added in this phase (profile, admin dashboard) were missing it entirely, so the new Pundit redirect alert had nowhere to render. Render app/views/layouts/_flash once from the layout instead of duplicating it per view. --- app/views/admin/dashboards/show.html.erb | 6 +-- app/views/layouts/_flash.html.erb | 7 +++ app/views/layouts/application.html.erb | 5 +- app/views/passwords/edit.html.erb | 30 +++++------ app/views/passwords/new.html.erb | 24 ++++----- app/views/profiles/show.html.erb | 20 ++++--- app/views/registrations/new.html.erb | 66 +++++++++++------------- app/views/sessions/new.html.erb | 44 ++++++---------- 8 files changed, 90 insertions(+), 112 deletions(-) create mode 100644 app/views/layouts/_flash.html.erb diff --git a/app/views/admin/dashboards/show.html.erb b/app/views/admin/dashboards/show.html.erb index e3941940a..7710833e3 100644 --- a/app/views/admin/dashboards/show.html.erb +++ b/app/views/admin/dashboards/show.html.erb @@ -1,4 +1,2 @@ -
-

Admin Dashboard

-

Signed in as <%= Current.user.full_name %> (<%= Current.user.role %>).

-
+

Admin Dashboard

+

Signed in as <%= Current.user.full_name %> (<%= Current.user.role %>).

diff --git a/app/views/layouts/_flash.html.erb b/app/views/layouts/_flash.html.erb new file mode 100644 index 000000000..0024423a9 --- /dev/null +++ b/app/views/layouts/_flash.html.erb @@ -0,0 +1,7 @@ +<% if alert = flash[:alert] %> +

<%= alert %>

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

<%= notice %>

+<% end %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 2702498a2..a8519f427 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -25,7 +25,10 @@
- <%= yield %> +
+ <%= render "layouts/flash" %> + <%= yield %> +
diff --git a/app/views/passwords/edit.html.erb b/app/views/passwords/edit.html.erb index 65798f808..3aecf7993 100644 --- a/app/views/passwords/edit.html.erb +++ b/app/views/passwords/edit.html.erb @@ -1,21 +1,15 @@ -
- <% if alert = flash[:alert] %> -

<%= alert %>

- <% end %> +

Update your password

-

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_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.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 %> -
+
+ <%= 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 index 6f08c3d57..5d4a86142 100644 --- a/app/views/passwords/new.html.erb +++ b/app/views/passwords/new.html.erb @@ -1,17 +1,11 @@ -
- <% if alert = flash[:alert] %> -

<%= alert %>

- <% end %> +

Forgot your password?

-

Forgot your password?

+<%= form_with url: passwords_path, class: "contents" do |form| %> +
+ <%= form.email_field :email, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email], 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: "contents" do |form| %> -
- <%= form.email_field :email, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email], 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 %> -
+
+ <%= 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/profiles/show.html.erb b/app/views/profiles/show.html.erb index 043bea44b..e44403194 100644 --- a/app/views/profiles/show.html.erb +++ b/app/views/profiles/show.html.erb @@ -1,13 +1,11 @@ -
-

My Profile

-
-
Full name
-
<%= @user.full_name %>
+

My Profile

+
+
Full name
+
<%= @user.full_name %>
-
Email
-
<%= @user.email %>
+
Email
+
<%= @user.email %>
-
Role
-
<%= @user.role %>
-
-
+
Role
+
<%= @user.role %>
+ diff --git a/app/views/registrations/new.html.erb b/app/views/registrations/new.html.erb index 76a41c0fc..419b631b8 100644 --- a/app/views/registrations/new.html.erb +++ b/app/views/registrations/new.html.erb @@ -1,45 +1,39 @@ -
- <% if alert = flash[:alert] %> -

<%= alert %>

+

Create your account

+ +<%= form_with model: @user, url: registration_path, class: "contents" do |form| %> + <% if @user.errors.any? %> +
+
    + <% @user.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
<% end %> -

Create your account

+
+ <%= form.text_field :full_name, required: true, autofocus: true, placeholder: "Full name", class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> +
- <%= form_with model: @user, url: registration_path, class: "contents" do |form| %> - <% if @user.errors.any? %> -
-
    - <% @user.errors.full_messages.each do |message| %> -
  • <%= message %>
  • - <% end %> -
-
- <% end %> +
+ <%= form.email_field :email, required: true, autocomplete: "username", placeholder: "Enter your 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.text_field :full_name, required: true, autofocus: true, placeholder: "Full name", 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: "new-password", placeholder: "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.email_field :email, required: true, autocomplete: "username", placeholder: "Enter your 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_confirmation, required: true, autocomplete: "new-password", placeholder: "Confirm 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.password_field :password, required: true, autocomplete: "new-password", placeholder: "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 up", 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.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Confirm 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" %> +
+ <%= link_to "Already have an account? Sign in", new_session_path, class: "text-gray-700 underline hover:no-underline" %>
- -
-
- <%= form.submit "Sign up", 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 "Already have an account? Sign in", new_session_path, class: "text-gray-700 underline hover:no-underline" %> -
-
- <% end %> -
+
+<% end %> diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index 3cdfef8b4..119091406 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -1,33 +1,23 @@ -
- <% if alert = flash[:alert] %> -

<%= alert %>

- <% end %> +

Sign in

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

<%= notice %>

- <% end %> +<%= form_with url: session_url, class: "contents" do |form| %> +
+ <%= form.email_field :email, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email], class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> +
-

Sign in

+
+ <%= 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_with url: session_url, class: "contents" do |form| %> -
- <%= form.email_field :email, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email], class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> +
+
+ <%= form.submit "Sign in", class: "w-full sm:w-auto text-center rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white inline-block font-medium cursor-pointer" %>
-
- <%= form.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" %> +
+ <%= link_to "Forgot password?", new_password_path, class: "text-gray-700 underline hover:no-underline" %> + · + <%= link_to "Create an account", new_registration_path, class: "text-gray-700 underline hover:no-underline" %>
- -
-
- <%= 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" %> - · - <%= link_to "Create an account", new_registration_path, class: "text-gray-700 underline hover:no-underline" %> -
-
- <% end %> -
+
+<% end %> From 043207243ed5c48b365c8a3ff05f0489d8ee883d Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Wed, 2 Sep 2026 22:29:35 -0300 Subject: [PATCH 10/68] test: cover UserPolicy authorization matrix and forbidden-access redirect Add pundit-matchers-based specs for the full admin/self/other-user permission matrix on UserPolicy and its Scope, and update the admin dashboard request spec to assert the new redirect-with-alert behavior instead of a bare 403. --- spec/policies/user_policy_spec.rb | 43 +++++++++++++++++++++++++++ spec/requests/admin/dashboard_spec.rb | 5 ++-- 2 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 spec/policies/user_policy_spec.rb diff --git a/spec/policies/user_policy_spec.rb b/spec/policies/user_policy_spec.rb new file mode 100644 index 000000000..7ed0793fd --- /dev/null +++ b/spec/policies/user_policy_spec.rb @@ -0,0 +1,43 @@ +require "rails_helper" + +RSpec.describe UserPolicy do + let(:admin) { build_stubbed(:user, :admin) } + let(:no_admin) { build_stubbed(:user) } + let(:other_no_admin) { build_stubbed(:user) } + + context "when the user is an admin" do + subject { described_class.new(admin, other_no_admin) } + + it { is_expected.to permit_all_actions } + end + + context "when the user manages their own record" do + subject { described_class.new(no_admin, no_admin) } + + it { is_expected.to permit_actions(:show, :update, :edit, :destroy) } + it { is_expected.to forbid_actions(:index, :create, :new, :toggle_role) } + end + + context "when the user tries to manage another user's record" do + subject { described_class.new(no_admin, other_no_admin) } + + it { is_expected.to forbid_all_actions } + end + + describe "Scope" do + let!(:admin_record) { create(:user, :admin) } + let!(:no_admin_record) { create(:user) } + + it "resolves every user for an admin" do + resolved = UserPolicy::Scope.new(admin, User.all).resolve + + expect(resolved).to contain_exactly(admin_record, no_admin_record) + end + + it "resolves only the user's own record for a no_admin user" do + resolved = UserPolicy::Scope.new(no_admin_record, User.all).resolve + + expect(resolved).to contain_exactly(no_admin_record) + end + end +end diff --git a/spec/requests/admin/dashboard_spec.rb b/spec/requests/admin/dashboard_spec.rb index a89f71ad6..09052ebc2 100644 --- a/spec/requests/admin/dashboard_spec.rb +++ b/spec/requests/admin/dashboard_spec.rb @@ -8,13 +8,14 @@ expect(response).to redirect_to(new_session_path) end - it "forbids a signed in no_admin user" do + it "redirects a signed in no_admin user to their profile with an alert" do user = create(:user, password: "password123") sign_in_as(user) get admin_dashboard_path - expect(response).to have_http_status(:forbidden) + expect(response).to redirect_to(profile_url) + expect(flash[:alert]).to be_present end it "allows a signed in admin user" do From 77a6a0b17c69ea63692fc261078470b364b1f78f Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Wed, 2 Sep 2026 22:47:46 -0300 Subject: [PATCH 11/68] feat: add Active Storage avatar attachment with content-type/size validation Install Active Storage and attach an avatar to User, validating that uploaded files are a supported image type (PNG/JPEG/WEBP) and within a 5MB size limit. --- app/models/user.rb | 11 ++++ ...te_active_storage_tables.active_storage.rb | 57 +++++++++++++++++++ db/schema.rb | 32 ++++++++++- 3 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20260903013348_create_active_storage_tables.active_storage.rb diff --git a/app/models/user.rb b/app/models/user.rb index b5f32167e..783e2e881 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,6 +1,10 @@ class User < ApplicationRecord + AVATAR_CONTENT_TYPES = %w[image/png image/jpeg image/webp].freeze + AVATAR_MAX_BYTES = 5.megabytes + has_secure_password has_many :sessions, dependent: :destroy + has_one_attached :avatar enum :role, { no_admin: 0, admin: 1 } @@ -8,4 +12,11 @@ class User < ApplicationRecord validates :full_name, presence: true validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP } + validate :avatar_must_be_a_supported_image, if: -> { avatar.attached? } + + private + def avatar_must_be_a_supported_image + errors.add(:avatar, "must be a PNG, JPEG or WEBP image") unless avatar.content_type.in?(AVATAR_CONTENT_TYPES) + errors.add(:avatar, "is too large (max #{AVATAR_MAX_BYTES / 1.megabyte}MB)") if avatar.byte_size > AVATAR_MAX_BYTES + end end diff --git a/db/migrate/20260903013348_create_active_storage_tables.active_storage.rb b/db/migrate/20260903013348_create_active_storage_tables.active_storage.rb new file mode 100644 index 000000000..6bd8bd082 --- /dev/null +++ b/db/migrate/20260903013348_create_active_storage_tables.active_storage.rb @@ -0,0 +1,57 @@ +# This migration comes from active_storage (originally 20170806125915) +class CreateActiveStorageTables < ActiveRecord::Migration[7.0] + def change + # Use Active Record's configured type for primary and foreign keys + primary_key_type, foreign_key_type = primary_and_foreign_key_types + + create_table :active_storage_blobs, id: primary_key_type do |t| + t.string :key, null: false + t.string :filename, null: false + t.string :content_type + t.text :metadata + t.string :service_name, null: false + t.bigint :byte_size, null: false + t.string :checksum + + if connection.supports_datetime_with_precision? + t.datetime :created_at, precision: 6, null: false + else + t.datetime :created_at, null: false + end + + t.index [ :key ], unique: true + end + + create_table :active_storage_attachments, id: primary_key_type do |t| + t.string :name, null: false + t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type + t.references :blob, null: false, type: foreign_key_type + + if connection.supports_datetime_with_precision? + t.datetime :created_at, precision: 6, null: false + else + t.datetime :created_at, null: false + end + + t.index [ :record_type, :record_id, :name, :blob_id ], name: :index_active_storage_attachments_uniqueness, unique: true + t.foreign_key :active_storage_blobs, column: :blob_id + end + + create_table :active_storage_variant_records, id: primary_key_type do |t| + t.belongs_to :blob, null: false, index: false, type: foreign_key_type + t.string :variation_digest, null: false + + t.index [ :blob_id, :variation_digest ], name: :index_active_storage_variant_records_uniqueness, unique: true + t.foreign_key :active_storage_blobs, column: :blob_id + end + end + + private + def primary_and_foreign_key_types + config = Rails.configuration.generators + setting = config.options[config.orm][:primary_key_type] + primary_key_type = setting || :primary_key + foreign_key_type = setting || :bigint + [ primary_key_type, foreign_key_type ] + end +end diff --git a/db/schema.rb b/db/schema.rb index a1cd2c1b5..f31044924 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,35 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_09_03_005316) do +ActiveRecord::Schema[8.1].define(version: 2026_09_03_013348) do + create_table "active_storage_attachments", force: :cascade do |t| + t.bigint "blob_id", null: false + t.datetime "created_at", null: false + t.string "name", null: false + t.bigint "record_id", null: false + t.string "record_type", null: false + t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" + t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true + end + + create_table "active_storage_blobs", force: :cascade do |t| + t.bigint "byte_size", null: false + t.string "checksum" + t.string "content_type" + t.datetime "created_at", null: false + t.string "filename", null: false + t.string "key", null: false + t.text "metadata" + t.string "service_name", null: false + t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true + end + + create_table "active_storage_variant_records", force: :cascade do |t| + t.bigint "blob_id", null: false + t.string "variation_digest", null: false + t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true + end + create_table "sessions", force: :cascade do |t| t.datetime "created_at", null: false t.string "ip_address" @@ -30,5 +58,7 @@ t.index ["email"], name: "index_users_on_email", unique: true end + add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" + add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "sessions", "users" end From 5ed74c89757eeef76de98efa7cd91bc9909762e0 Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Wed, 2 Sep 2026 22:48:07 -0300 Subject: [PATCH 12/68] feat: support setting an avatar via remote URL Add an avatar_url virtual attribute on User (validated as a plain http(s) URL) that, once the record is committed, enqueues AvatarDownloadJob to fetch and attach the image asynchronously. The fetch itself goes through AvatarFetcher, hardened against SSRF: only http(s) URLs are accepted, the resolved IP must be public (no loopback/private/link-local ranges, which also blocks the common cloud metadata endpoint), redirects are capped, and the response body is streamed with an early size cutoff so a malicious server can't exhaust memory before we notice the file is too large. --- Gemfile | 3 ++ Gemfile.lock | 14 +++++ app/jobs/avatar_download_job.rb | 13 +++++ app/models/user.rb | 16 ++++++ app/services/avatar_fetcher.rb | 96 +++++++++++++++++++++++++++++++++ spec/support/webmock.rb | 3 ++ 6 files changed, 145 insertions(+) create mode 100644 app/jobs/avatar_download_job.rb create mode 100644 app/services/avatar_fetcher.rb create mode 100644 spec/support/webmock.rb diff --git a/Gemfile b/Gemfile index 8716c775c..1a5da6132 100644 --- a/Gemfile +++ b/Gemfile @@ -84,6 +84,9 @@ group :test do # RSpec matchers for testing Pundit policies [https://github.com/pundit-community/pundit-matchers] gem "pundit-matchers" + + # Stubs HTTP requests for testing outbound calls like AvatarFetcher [https://github.com/bblimke/webmock] + gem "webmock" end group :development do diff --git a/Gemfile.lock b/Gemfile.lock index 227972a30..98103ad9f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -106,6 +106,9 @@ GEM playwright-ruby-client (>= 1.16.0) concurrent-ruby (1.3.8) connection_pool (3.0.2) + crack (1.0.1) + bigdecimal + rexml crass (1.0.7) date (3.5.1) debug (1.11.1) @@ -137,6 +140,7 @@ GEM raabro (~> 1.4) globalid (1.4.0) activesupport (>= 6.1) + hashdiff (1.2.1) i18n (1.15.2) concurrent-ruby (~> 1.0) image_processing (1.14.0) @@ -299,6 +303,7 @@ GEM regexp_parser (2.12.0) reline (0.7.0) io-console (~> 0.5) + rexml (3.4.4) rspec-core (3.13.6) rspec-support (~> 3.13.0) rspec-expectations (3.13.5) @@ -411,6 +416,10 @@ GEM actionview (>= 8.0.0) bindex (>= 0.4.0) railties (>= 8.0.0) + webmock (3.26.4) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) websocket-driver (0.8.2) base64 websocket-extensions (>= 0.1.0) @@ -462,6 +471,7 @@ DEPENDENCIES turbo-rails tzinfo-data web-console + webmock CHECKSUMS action_text-trix (2.1.19) sha256=7012f59421009cf284aa651294896414d653a61a2417c9b8714c8476d2f74009 @@ -491,6 +501,7 @@ CHECKSUMS capybara-playwright-driver (0.5.10) sha256=e48e572d72bc1043c644fab44985be0a1e75d7d6917dc298355581848982a2c3 concurrent-ruby (1.3.8) sha256=b2f1be836e968ccc78ccfce277ea79c72a88633f22306782c16ff23fb415d1e1 connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a + crack (1.0.1) sha256=ff4a10390cd31d66440b7524eb1841874db86201d5b70032028553130b6d4c7e crass (1.0.7) sha256=94868719948664c89ddcaf0a37c65048413dfcb1c869470a5f7a7ceb5390b295 date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6 @@ -512,6 +523,7 @@ CHECKSUMS ffi (1.17.4-x86_64-linux-musl) sha256=3fdf9888483de005f8ef8d1cf2d3b20d86626af206cbf780f6a6a12439a9c49e fugit (1.13.0) sha256=a4f093fce740da52f216740a5041e2a594ea763cdb89e8b2754ca4399634ab18 globalid (1.4.0) sha256=037f12fbf1d9d7a014d501c2d5c77356fd4ddd96d7a7991d6700bba96706f427 + hashdiff (1.2.1) sha256=9c079dbc513dfc8833ab59c0c2d8f230fa28499cc5efb4b8dd276cf931457cd1 i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5 image_processing (1.14.0) sha256=754cc169c9c262980889bec6bfd325ed1dafad34f85242b5a07b60af004742fb importmap-rails (2.2.3) sha256=7101be2a4dc97cf1558fb8f573a718404c5f6bcfe94f304bf1f39e444feeb16a @@ -575,6 +587,7 @@ CHECKSUMS rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469 regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d + rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47 @@ -620,6 +633,7 @@ CHECKSUMS uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844 web-console (4.3.0) sha256=e13b71301cdfc2093f155b5aa3a622db80b4672d1f2f713119cc7ec7ac6a6da4 + webmock (3.26.4) sha256=8d8da206d217ebe6968cfb09c77f4533c23074e1432bad865f3994eacbaad50d websocket-driver (0.8.2) sha256=97c556b019bf3410b4961002ac501621e9322d3f8a7bc02161a09301cc4c4146 websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241 xpath (3.2.0) sha256=6dfda79d91bb3b949b947ecc5919f042ef2f399b904013eb3ef6d20dd3a4082e diff --git a/app/jobs/avatar_download_job.rb b/app/jobs/avatar_download_job.rb new file mode 100644 index 000000000..9153fd5ae --- /dev/null +++ b/app/jobs/avatar_download_job.rb @@ -0,0 +1,13 @@ +class AvatarDownloadJob < ApplicationJob + queue_as :default + + def perform(user_id, url) + user = User.find_by(id: user_id) + return unless user + + result = AvatarFetcher.new(url).fetch + user.avatar.attach(io: result.io, filename: result.filename, content_type: result.content_type) + rescue AvatarFetcher::FetchError => e + Rails.logger.warn("AvatarDownloadJob: failed to fetch avatar for user #{user_id} from #{url}: #{e.message}") + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 783e2e881..202e70f38 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -8,15 +8,31 @@ class User < ApplicationRecord enum :role, { no_admin: 0, admin: 1 } + attr_accessor :avatar_url + normalizes :email, with: ->(e) { e.strip.downcase } validates :full_name, presence: true validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP } validate :avatar_must_be_a_supported_image, if: -> { avatar.attached? } + validate :avatar_url_must_be_http, if: -> { avatar_url.present? } + + after_commit :enqueue_avatar_download, if: -> { avatar_url.present? } private def avatar_must_be_a_supported_image errors.add(:avatar, "must be a PNG, JPEG or WEBP image") unless avatar.content_type.in?(AVATAR_CONTENT_TYPES) errors.add(:avatar, "is too large (max #{AVATAR_MAX_BYTES / 1.megabyte}MB)") if avatar.byte_size > AVATAR_MAX_BYTES end + + def avatar_url_must_be_http + uri = URI.parse(avatar_url) + errors.add(:avatar_url, "must be a valid http(s) URL") unless uri.is_a?(URI::HTTP) && uri.host.present? + rescue URI::InvalidURIError + errors.add(:avatar_url, "must be a valid http(s) URL") + end + + def enqueue_avatar_download + AvatarDownloadJob.perform_later(id, avatar_url) + end end diff --git a/app/services/avatar_fetcher.rb b/app/services/avatar_fetcher.rb new file mode 100644 index 000000000..28dc6d839 --- /dev/null +++ b/app/services/avatar_fetcher.rb @@ -0,0 +1,96 @@ +require "net/http" +require "resolv" +require "ipaddr" + +# Downloads a remote image over HTTP(S) to be attached as a User's avatar. +# +# Hardened against SSRF: only plain http(s) URLs are accepted, the resolved +# IP address must be public (no loopback/private/link-local ranges), redirects +# are capped, and the response body is streamed with an early size cutoff so a +# malicious server cannot exhaust memory before we notice it is too large. +class AvatarFetcher + class FetchError < StandardError; end + + ALLOWED_CONTENT_TYPES = %w[image/png image/jpeg image/webp].freeze + MAX_BYTES = 5.megabytes + MAX_REDIRECTS = 3 + OPEN_TIMEOUT = 5 + READ_TIMEOUT = 10 + + Result = Struct.new(:io, :content_type, :filename, keyword_init: true) + + def initialize(url) + @url = url + end + + def fetch + uri = parse_http_uri!(@url) + + MAX_REDIRECTS.downto(0) do |redirects_left| + guard_against_ssrf!(uri) + + outcome = request_once(uri) + return outcome.fetch(:success) if outcome.key?(:success) + + raise FetchError, "too many redirects" if redirects_left.zero? + uri = parse_http_uri!(outcome.fetch(:redirect)) + end + end + + private + + def parse_http_uri!(url) + uri = URI.parse(url) + raise FetchError, "invalid URL" unless uri.is_a?(URI::HTTP) && uri.host.present? + uri + rescue URI::InvalidURIError + raise FetchError, "invalid URL" + end + + def guard_against_ssrf!(uri) + addresses = Resolv.getaddresses(uri.host) + raise FetchError, "could not resolve host" if addresses.empty? + + addresses.each do |address| + ip = IPAddr.new(address) + if ip.private? || ip.loopback? || ip.link_local? + raise FetchError, "URL resolves to a disallowed address" + end + end + end + + def request_once(uri) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = uri.scheme == "https" + http.open_timeout = OPEN_TIMEOUT + http.read_timeout = READ_TIMEOUT + + http.start do |client| + client.request_get(uri) do |response| + return { redirect: response["location"] } if response.is_a?(Net::HTTPRedirection) + + unless response.is_a?(Net::HTTPSuccess) + raise FetchError, "unexpected response #{response.code}" + end + + content_type = response.content_type + unless ALLOWED_CONTENT_TYPES.include?(content_type) + raise FetchError, "unsupported content type #{content_type.inspect}" + end + + buffer = +"" + response.read_body do |chunk| + buffer << chunk + raise FetchError, "file too large" if buffer.bytesize > MAX_BYTES + end + + return { success: Result.new(io: StringIO.new(buffer), content_type: content_type, filename: filename_for(uri)) } + end + end + end + + def filename_for(uri) + name = File.basename(uri.path.to_s) + name.presence || "avatar" + end +end diff --git a/spec/support/webmock.rb b/spec/support/webmock.rb new file mode 100644 index 000000000..4b72e2ceb --- /dev/null +++ b/spec/support/webmock.rb @@ -0,0 +1,3 @@ +require "webmock/rspec" + +WebMock.disable_net_connect!(allow_localhost: true) From 7706684ec30dc5689a1474832fe14d5f2629afde Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Wed, 2 Sep 2026 22:48:46 -0300 Subject: [PATCH 13/68] feat: add Admin::UsersController for full user CRUD and role toggling Admins can list, create, edit and delete any user (including setting their role and avatar), backed by UserPolicy from Fase 2. A dedicated toggle_role action flips a user's role with one click from the index, and refuses to let an admin change their own role to avoid an accidental lockout. --- app/controllers/admin/users_controller.rb | 63 +++++++++++++++++++++++ app/views/admin/users/_form.html.erb | 50 ++++++++++++++++++ app/views/admin/users/edit.html.erb | 7 +++ app/views/admin/users/index.html.erb | 35 +++++++++++++ app/views/admin/users/new.html.erb | 7 +++ config/routes.rb | 3 ++ 6 files changed, 165 insertions(+) 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 diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb new file mode 100644 index 000000000..b3ce11afc --- /dev/null +++ b/app/controllers/admin/users_controller.rb @@ -0,0 +1,63 @@ +class Admin::UsersController < ApplicationController + after_action :verify_policy_scoped, only: :index + + before_action :set_user, only: %i[ edit update destroy toggle_role ] + + def index + authorize User, :index? + @users = policy_scope(User).order(:full_name) + end + + def new + @user = User.new + authorize @user + end + + def create + @user = User.new(user_params) + authorize @user + + if @user.save + redirect_to admin_users_path, notice: "User was successfully created." + else + render :new, status: :unprocessable_entity + end + end + + def edit + end + + def update + if @user.update(user_params) + redirect_to admin_users_path, notice: "User was successfully updated." + else + render :edit, status: :unprocessable_entity + end + end + + def destroy + @user.destroy + redirect_to admin_users_path, notice: "User was successfully deleted.", status: :see_other + end + + def toggle_role + if @user == Current.user + redirect_to admin_users_path, alert: "You cannot change your own role." + else + @user.update!(role: @user.admin? ? :no_admin : :admin) + redirect_to admin_users_path, notice: "Role was successfully updated." + end + end + + private + def set_user + @user = User.find(params[:id]) + authorize @user + end + + def user_params + attrs = params.expect(user: [ :full_name, :email, :password, :password_confirmation, :role, :avatar, :avatar_url ]) + attrs = attrs.except(:password, :password_confirmation) if attrs[:password].blank? + attrs + 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..f86cb569c --- /dev/null +++ b/app/views/admin/users/_form.html.erb @@ -0,0 +1,50 @@ +<%= form_with model: [ :admin, user ], class: "contents" do |form| %> + <% if user.errors.any? %> +
+
    + <% user.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+ <% end %> + +
+ <%= form.label :full_name, class: "block font-medium" %> + <%= form.text_field :full_name, required: true, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> +
+ +
+ <%= form.label :email, class: "block font-medium" %> + <%= form.email_field :email, required: true, autocomplete: "username", class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> +
+ +
+ <%= form.label :role, class: "block font-medium" %> + <%= form.select :role, User.roles.keys.map { |role| [ role.humanize, role ] }, {}, 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, (user.new_record? ? "Password" : "New password"), class: "block font-medium" %> + <%= form.password_field :password, required: user.new_record?, autocomplete: "new-password", placeholder: user.new_record? ? nil : "Leave blank to keep the current 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_confirmation, class: "block font-medium" %> + <%= form.password_field :password_confirmation, required: user.new_record?, autocomplete: "new-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 :avatar, "Avatar image", class: "block font-medium" %> + <%= form.file_field :avatar, accept: "image/png,image/jpeg,image/webp", class: "block mt-2 w-full" %> +
+ +
+ <%= form.label :avatar_url, "…or avatar image URL", class: "block font-medium" %> + <%= form.url_field :avatar_url, placeholder: "https://example.com/avatar.png", class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> +
+ +
+ <%= form.submit 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/admin/users/edit.html.erb b/app/views/admin/users/edit.html.erb new file mode 100644 index 000000000..44c9e66cd --- /dev/null +++ b/app/views/admin/users/edit.html.erb @@ -0,0 +1,7 @@ +

Edit User

+ +<%= render "form", user: @user %> + +
+ <%= link_to "Back to users", admin_users_path, class: "text-gray-700 underline hover:no-underline" %> +
diff --git a/app/views/admin/users/index.html.erb b/app/views/admin/users/index.html.erb new file mode 100644 index 000000000..b93e5ae7d --- /dev/null +++ b/app/views/admin/users/index.html.erb @@ -0,0 +1,35 @@ +
+

Users

+ <%= link_to "New user", new_admin_user_path, class: "rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white font-medium" %> +
+ + + + + + + + + + + + + <% @users.each do |user| %> + + + + + + + + <% end %> + +
AvatarFull nameEmailRoleActions
+ <% if user.avatar.attached? %> + <%= image_tag user.avatar.variant(resize_to_limit: [ 40, 40 ]), class: "size-10 rounded-full object-cover" %> + <% end %> + <%= user.full_name %><%= user.email %><%= user.role.humanize %> + <%= link_to "Edit", edit_admin_user_path(user), class: "text-blue-600 underline hover:no-underline" %> + <%= button_to "Toggle role", toggle_role_admin_user_path(user), method: :patch, class: "text-blue-600 underline hover:no-underline bg-transparent p-0 cursor-pointer" %> + <%= button_to "Delete", admin_user_path(user), method: :delete, class: "text-red-600 underline hover:no-underline bg-transparent p-0 cursor-pointer", form: { data: { turbo_confirm: "Are you sure?" } } %> +
diff --git a/app/views/admin/users/new.html.erb b/app/views/admin/users/new.html.erb new file mode 100644 index 000000000..4b76a063b --- /dev/null +++ b/app/views/admin/users/new.html.erb @@ -0,0 +1,7 @@ +

New User

+ +<%= render "form", user: @user %> + +
+ <%= link_to "Back to users", admin_users_path, class: "text-gray-700 underline hover:no-underline" %> +
diff --git a/config/routes.rb b/config/routes.rb index 2ccd61914..403e80cb0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -5,6 +5,9 @@ resource :profile, only: :show namespace :admin do resource :dashboard, only: :show + resources :users do + patch :toggle_role, on: :member + end end # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html From cf29977e1adcd61159a280267ecf6be58f7340cc Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Wed, 2 Sep 2026 22:49:06 -0300 Subject: [PATCH 14/68] feat: let users edit/delete their own profile and add a sign-out button Extend ProfilesController with edit/update/destroy for the signed in user (role is never in the permitted params, so self-service can't promote to admin), show the avatar on the profile page, and add sign-out buttons plus a link from the dashboard to user management, since there was previously no UI path to log out. --- app/controllers/profiles_controller.rb | 33 ++++++++++++++- app/views/admin/dashboards/show.html.erb | 5 +++ app/views/profiles/edit.html.erb | 51 ++++++++++++++++++++++++ app/views/profiles/show.html.erb | 13 +++++- config/routes.rb | 2 +- 5 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 app/views/profiles/edit.html.erb diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index b01b74e00..1f763c844 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -1,6 +1,35 @@ class ProfilesController < ApplicationController + before_action :set_user + def show - @user = Current.user - authorize @user end + + def edit + end + + def update + if @user.update(user_params) + redirect_to profile_path, notice: "Profile was successfully updated." + else + render :edit, status: :unprocessable_entity + end + end + + def destroy + @user.destroy + cookies.delete(:session_id) + redirect_to new_session_path, notice: "Your account has been deleted.", status: :see_other + end + + private + def set_user + @user = Current.user + authorize @user + end + + def user_params + attrs = params.expect(user: [ :full_name, :email, :password, :password_confirmation, :avatar, :avatar_url ]) + attrs = attrs.except(:password, :password_confirmation) if attrs[:password].blank? + attrs + end end diff --git a/app/views/admin/dashboards/show.html.erb b/app/views/admin/dashboards/show.html.erb index 7710833e3..43d5a1f13 100644 --- a/app/views/admin/dashboards/show.html.erb +++ b/app/views/admin/dashboards/show.html.erb @@ -1,2 +1,7 @@

Admin Dashboard

Signed in as <%= Current.user.full_name %> (<%= Current.user.role %>).

+ +
+ <%= link_to "Manage users", admin_users_path, class: "text-blue-600 underline hover:no-underline" %> + <%= button_to "Sign out", session_path, method: :delete, class: "text-gray-700 underline hover:no-underline bg-transparent p-0 cursor-pointer" %> +
diff --git a/app/views/profiles/edit.html.erb b/app/views/profiles/edit.html.erb new file mode 100644 index 000000000..d884f2c35 --- /dev/null +++ b/app/views/profiles/edit.html.erb @@ -0,0 +1,51 @@ +

Edit Profile

+ +<%= form_with model: @user, url: profile_path, class: "contents" do |form| %> + <% if @user.errors.any? %> +
+
    + <% @user.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+ <% end %> + +
+ <%= form.label :full_name, class: "block font-medium" %> + <%= form.text_field :full_name, required: true, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> +
+ +
+ <%= form.label :email, class: "block font-medium" %> + <%= form.email_field :email, required: true, autocomplete: "username", 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, "New password", class: "block font-medium" %> + <%= form.password_field :password, autocomplete: "new-password", placeholder: "Leave blank to keep the current 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_confirmation, class: "block font-medium" %> + <%= form.password_field :password_confirmation, autocomplete: "new-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 :avatar, "Avatar image", class: "block font-medium" %> + <%= form.file_field :avatar, accept: "image/png,image/jpeg,image/webp", class: "block mt-2 w-full" %> +
+ +
+ <%= form.label :avatar_url, "…or avatar image URL", class: "block font-medium" %> + <%= form.url_field :avatar_url, placeholder: "https://example.com/avatar.png", class: "block shadow-sm rounded-md border border-gray-400 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 %> + +
+ <%= link_to "Back to profile", profile_path, class: "text-gray-700 underline hover:no-underline" %> +
diff --git a/app/views/profiles/show.html.erb b/app/views/profiles/show.html.erb index e44403194..eea495473 100644 --- a/app/views/profiles/show.html.erb +++ b/app/views/profiles/show.html.erb @@ -1,4 +1,9 @@

My Profile

+ +<% if @user.avatar.attached? %> + <%= image_tag @user.avatar.variant(resize_to_limit: [ 96, 96 ]), class: "size-24 rounded-full object-cover mt-4" %> +<% end %> +
Full name
<%= @user.full_name %>
@@ -7,5 +12,11 @@
<%= @user.email %>
Role
-
<%= @user.role %>
+
<%= @user.role.humanize %>
+ +
+ <%= link_to "Edit profile", edit_profile_path, class: "text-blue-600 underline hover:no-underline" %> + <%= button_to "Delete account", profile_path, method: :delete, class: "text-red-600 underline hover:no-underline bg-transparent p-0 cursor-pointer", form: { data: { turbo_confirm: "Are you sure? This cannot be undone." } } %> + <%= button_to "Sign out", session_path, method: :delete, class: "text-gray-700 underline hover:no-underline bg-transparent p-0 cursor-pointer" %> +
diff --git a/config/routes.rb b/config/routes.rb index 403e80cb0..8f3103dde 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -2,7 +2,7 @@ resource :session resources :passwords, param: :token resource :registration, only: %i[ new create ] - resource :profile, only: :show + resource :profile, only: %i[ show edit update destroy ] namespace :admin do resource :dashboard, only: :show resources :users do From c683a8071be968d594fd89df40278dc6165134b6 Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Wed, 2 Sep 2026 22:49:25 -0300 Subject: [PATCH 15/68] test: cover avatar validations, remote URL fetching and the download job Model specs for the content-type/size validation and the avatar_url format check plus job enqueue; a full spec suite for AvatarFetcher's SSRF hardening (disallowed schemes, unresolvable/private/loopback/ link-local addresses, redirect cap, content-type and size limits); and specs for AvatarDownloadJob attaching on success and logging instead of raising on failure. Adds a :with_avatar factory trait and webmock for stubbing the outbound HTTP calls. --- spec/factories/users.rb | 10 +++ spec/jobs/avatar_download_job_spec.rb | 36 ++++++++++ spec/models/user_spec.rb | 56 +++++++++++++++ spec/services/avatar_fetcher_spec.rb | 98 +++++++++++++++++++++++++++ 4 files changed, 200 insertions(+) create mode 100644 spec/jobs/avatar_download_job_spec.rb create mode 100644 spec/services/avatar_fetcher_spec.rb diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 0003ccd74..6b0c2c548 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -8,5 +8,15 @@ trait :admin do role { :admin } end + + trait :with_avatar do + after(:build) do |user| + user.avatar.attach( + io: StringIO.new("fake-image-bytes"), + filename: "avatar.png", + content_type: "image/png" + ) + end + end end end diff --git a/spec/jobs/avatar_download_job_spec.rb b/spec/jobs/avatar_download_job_spec.rb new file mode 100644 index 000000000..726e32f8b --- /dev/null +++ b/spec/jobs/avatar_download_job_spec.rb @@ -0,0 +1,36 @@ +require "rails_helper" + +RSpec.describe AvatarDownloadJob, type: :job do + let(:user) { create(:user) } + + it "attaches the fetched image to the user's avatar" do + fetched = AvatarFetcher::Result.new(io: StringIO.new("bytes"), content_type: "image/png", filename: "avatar.png") + allow(AvatarFetcher).to receive(:new).with("http://example.com/avatar.png").and_return(instance_double(AvatarFetcher, fetch: fetched)) + + described_class.perform_now(user.id, "http://example.com/avatar.png") + user.reload + + expect(user.avatar).to be_attached + expect(user.avatar.content_type).to eq("image/png") + end + + it "does nothing when the user no longer exists" do + expect { + described_class.perform_now(0, "http://example.com/avatar.png") + }.not_to raise_error + end + + it "logs and swallows fetch failures instead of raising" do + failing_fetcher = instance_double(AvatarFetcher) + allow(failing_fetcher).to receive(:fetch).and_raise(AvatarFetcher::FetchError, "boom") + allow(AvatarFetcher).to receive(:new).with("http://example.com/avatar.png").and_return(failing_fetcher) + + expect(Rails.logger).to receive(:warn).with(/boom/) + + expect { + described_class.perform_now(user.id, "http://example.com/avatar.png") + }.not_to raise_error + + expect(user.avatar).not_to be_attached + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index e7060a55f..77958ade6 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -39,4 +39,60 @@ describe "associations" do it { is_expected.to have_many(:sessions).dependent(:destroy) } end + + describe "avatar" do + it "accepts a supported image within the size limit" do + user = build(:user) + user.avatar.attach(io: StringIO.new("bytes"), filename: "avatar.png", content_type: "image/png") + + expect(user).to be_valid + end + + it "rejects an unsupported content type" do + user = build(:user) + user.avatar.attach(io: StringIO.new("not-an-image"), filename: "file.txt", content_type: "text/plain") + + expect(user).not_to be_valid + expect(user.errors[:avatar]).to be_present + end + + it "rejects a file that is too large" do + stub_const("User::AVATAR_MAX_BYTES", 10) + user = build(:user) + user.avatar.attach(io: StringIO.new("x" * 20), filename: "avatar.png", content_type: "image/png") + + expect(user).not_to be_valid + expect(user.errors[:avatar]).to be_present + end + end + + describe "avatar_url" do + it "rejects a value that is not a valid http(s) URL" do + user = build(:user, avatar_url: "not a url") + + expect(user).not_to be_valid + expect(user.errors[:avatar_url]).to be_present + end + + it "accepts a valid http(s) URL" do + user = build(:user, avatar_url: "https://example.com/avatar.png") + + expect(user).to be_valid + end + + it "enqueues a download job after a successful save" do + user = build(:user, avatar_url: "https://example.com/avatar.png") + + expect { user.save! }.to have_enqueued_job(AvatarDownloadJob).with { |id, url| + expect(id).to eq(user.id) + expect(url).to eq("https://example.com/avatar.png") + } + end + + it "does not enqueue a download job when blank" do + user = build(:user) + + expect { user.save! }.not_to have_enqueued_job(AvatarDownloadJob) + end + end end diff --git a/spec/services/avatar_fetcher_spec.rb b/spec/services/avatar_fetcher_spec.rb new file mode 100644 index 000000000..5e7f45473 --- /dev/null +++ b/spec/services/avatar_fetcher_spec.rb @@ -0,0 +1,98 @@ +require "rails_helper" + +RSpec.describe AvatarFetcher do + let(:public_ip) { "93.184.216.34" } + + def allow_resolve(host, ip_or_ips) + allow(Resolv).to receive(:getaddresses).with(host).and_return(Array(ip_or_ips)) + end + + describe "#fetch" do + it "downloads and returns the image when the content type and size are allowed" do + allow_resolve("example.com", public_ip) + stub_request(:get, "http://example.com/avatar.png") + .to_return(status: 200, body: "fake-image-bytes", headers: { "Content-Type" => "image/png" }) + + result = described_class.new("http://example.com/avatar.png").fetch + + expect(result.content_type).to eq("image/png") + expect(result.filename).to eq("avatar.png") + expect(result.io.read).to eq("fake-image-bytes") + end + + it "follows redirects" do + allow_resolve("example.com", public_ip) + allow_resolve("cdn.example.com", public_ip) + stub_request(:get, "http://example.com/avatar.png") + .to_return(status: 302, headers: { "Location" => "http://cdn.example.com/avatar.png" }) + stub_request(:get, "http://cdn.example.com/avatar.png") + .to_return(status: 200, body: "redirected-bytes", headers: { "Content-Type" => "image/jpeg" }) + + result = described_class.new("http://example.com/avatar.png").fetch + + expect(result.content_type).to eq("image/jpeg") + end + + it "raises when there are too many redirects" do + stub_const("AvatarFetcher::MAX_REDIRECTS", 1) + allow_resolve("example.com", public_ip) + stub_request(:get, "http://example.com/a").to_return(status: 302, headers: { "Location" => "http://example.com/b" }) + stub_request(:get, "http://example.com/b").to_return(status: 302, headers: { "Location" => "http://example.com/c" }) + + expect { described_class.new("http://example.com/a").fetch } + .to raise_error(AvatarFetcher::FetchError, /redirect/) + end + + it "rejects non-http(s) schemes" do + expect { described_class.new("file:///etc/passwd").fetch } + .to raise_error(AvatarFetcher::FetchError, /invalid URL/) + end + + it "rejects a host that cannot be resolved" do + allow_resolve("nowhere.invalid", []) + + expect { described_class.new("http://nowhere.invalid/avatar.png").fetch } + .to raise_error(AvatarFetcher::FetchError, /resolve/) + end + + it "rejects URLs that resolve to a private address" do + allow_resolve("internal.example.com", "10.0.0.5") + + expect { described_class.new("http://internal.example.com/avatar.png").fetch } + .to raise_error(AvatarFetcher::FetchError, /disallowed address/) + end + + it "rejects URLs that resolve to the loopback address" do + allow_resolve("localhost.example.com", "127.0.0.1") + + expect { described_class.new("http://localhost.example.com/avatar.png").fetch } + .to raise_error(AvatarFetcher::FetchError, /disallowed address/) + end + + it "rejects URLs that resolve to a link-local / cloud metadata address" do + allow_resolve("metadata.example.com", "169.254.169.254") + + expect { described_class.new("http://metadata.example.com/avatar.png").fetch } + .to raise_error(AvatarFetcher::FetchError, /disallowed address/) + end + + it "rejects a disallowed content type" do + allow_resolve("example.com", public_ip) + stub_request(:get, "http://example.com/not-an-image.html") + .to_return(status: 200, body: "", headers: { "Content-Type" => "text/html" }) + + expect { described_class.new("http://example.com/not-an-image.html").fetch } + .to raise_error(AvatarFetcher::FetchError, /unsupported content type/) + end + + it "rejects a file that exceeds the maximum size" do + stub_const("AvatarFetcher::MAX_BYTES", 10) + allow_resolve("example.com", public_ip) + stub_request(:get, "http://example.com/big.png") + .to_return(status: 200, body: "x" * 20, headers: { "Content-Type" => "image/png" }) + + expect { described_class.new("http://example.com/big.png").fetch } + .to raise_error(AvatarFetcher::FetchError, /too large/) + end + end +end From 53ecd18979fab77fbc3d7420b0c6596ca672d40c Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Wed, 2 Sep 2026 22:49:34 -0300 Subject: [PATCH 16/68] test: cover admin user management and self-service profile CRUD Request specs for Admin::UsersController (index/new/create/update/ destroy/toggle_role, including the no_admin forbidden paths and the self-role-change guard) and for the profile edit/update/destroy actions (blank password keeps the current one, an injected role param is ignored, avatar_url enqueues the download job). --- spec/requests/admin/users_spec.rb | 181 ++++++++++++++++++++++++++++++ spec/requests/profiles_spec.rb | 65 +++++++++++ 2 files changed, 246 insertions(+) create mode 100644 spec/requests/admin/users_spec.rb diff --git a/spec/requests/admin/users_spec.rb b/spec/requests/admin/users_spec.rb new file mode 100644 index 000000000..344912f4f --- /dev/null +++ b/spec/requests/admin/users_spec.rb @@ -0,0 +1,181 @@ +require "rails_helper" + +RSpec.describe "Admin::Users", type: :request do + let(:admin) { create(:user, :admin, password: "password123") } + + describe "GET /admin/users" do + it "redirects unauthenticated visitors to sign in" do + get admin_users_path + + expect(response).to redirect_to(new_session_path) + end + + it "redirects a no_admin user to their profile" do + user = create(:user, password: "password123") + sign_in_as(user) + + get admin_users_path + + expect(response).to redirect_to(profile_url) + end + + it "lists every user for an admin" do + other_user = create(:user) + sign_in_as(admin) + + get admin_users_path + + expect(response).to have_http_status(:ok) + expect(response.body).to include(admin.full_name) + expect(response.body).to include(other_user.full_name) + end + end + + describe "GET /admin/users/new" do + it "is forbidden for a no_admin user" do + user = create(:user, password: "password123") + sign_in_as(user) + + get new_admin_user_path + + expect(response).to redirect_to(profile_url) + end + + it "is accessible to an admin" do + sign_in_as(admin) + + get new_admin_user_path + + expect(response).to have_http_status(:ok) + end + end + + describe "POST /admin/users" do + it "allows an admin to create a user with any role" do + sign_in_as(admin) + + expect { + post admin_users_path, params: { + user: { + full_name: "New Admin", + email: "new-admin@example.com", + password: "password123", + password_confirmation: "password123", + role: "admin" + } + } + }.to change(User, :count).by(1) + + expect(User.find_by(email: "new-admin@example.com")).to be_admin + expect(response).to redirect_to(admin_users_url) + end + + it "enqueues an avatar download job when an avatar_url is given" do + sign_in_as(admin) + + expect { + post admin_users_path, params: { + user: { + full_name: "New User", + email: "new-user@example.com", + password: "password123", + password_confirmation: "password123", + role: "no_admin", + avatar_url: "https://example.com/avatar.png" + } + } + }.to have_enqueued_job(AvatarDownloadJob) + end + + it "is forbidden for a no_admin user" do + user = create(:user, password: "password123") + sign_in_as(user) + + expect { + post admin_users_path, params: { + user: { full_name: "X", email: "x@example.com", password: "password123", password_confirmation: "password123" } + } + }.not_to change(User, :count) + + expect(response).to redirect_to(profile_url) + end + end + + describe "PATCH /admin/users/:id" do + it "allows an admin to update another user, including their role" do + other_user = create(:user) + sign_in_as(admin) + + patch admin_user_path(other_user), params: { user: { full_name: "Updated Name", role: "admin" } } + + expect(response).to redirect_to(admin_users_url) + expect(other_user.reload.full_name).to eq("Updated Name") + expect(other_user).to be_admin + end + + it "keeps the current password when the password field is left blank" do + other_user = create(:user, password: "original-password") + sign_in_as(admin) + + patch admin_user_path(other_user), params: { user: { full_name: "Updated Name", password: "", password_confirmation: "" } } + + other_user.reload + expect(other_user.full_name).to eq("Updated Name") + expect(other_user.authenticate("original-password")).to eq(other_user) + end + + it "is forbidden for a no_admin user" do + other_user = create(:user) + user = create(:user, password: "password123") + sign_in_as(user) + + patch admin_user_path(other_user), params: { user: { full_name: "Hacked" } } + + expect(response).to redirect_to(profile_url) + expect(other_user.reload.full_name).not_to eq("Hacked") + end + end + + describe "DELETE /admin/users/:id" do + it "allows an admin to delete another user" do + other_user = create(:user) + sign_in_as(admin) + + expect { delete admin_user_path(other_user) }.to change(User, :count).by(-1) + expect(response).to redirect_to(admin_users_url) + end + end + + describe "PATCH /admin/users/:id/toggle_role" do + it "toggles another user's role" do + other_user = create(:user) + sign_in_as(admin) + + patch toggle_role_admin_user_path(other_user) + + expect(other_user.reload).to be_admin + expect(response).to redirect_to(admin_users_url) + end + + it "refuses to let an admin change their own role" do + sign_in_as(admin) + + patch toggle_role_admin_user_path(admin) + + expect(admin.reload).to be_admin + expect(response).to redirect_to(admin_users_url) + expect(flash[:alert]).to be_present + end + + it "is forbidden for a no_admin user" do + other_user = create(:user) + user = create(:user, password: "password123") + sign_in_as(user) + + patch toggle_role_admin_user_path(other_user) + + expect(other_user.reload).not_to be_admin + expect(response).to redirect_to(profile_url) + end + end +end diff --git a/spec/requests/profiles_spec.rb b/spec/requests/profiles_spec.rb index 18fd36803..b1d6badfe 100644 --- a/spec/requests/profiles_spec.rb +++ b/spec/requests/profiles_spec.rb @@ -18,4 +18,69 @@ expect(response.body).to include(user.full_name) end end + + describe "GET /profile/edit" do + it "is accessible to the signed in user" do + user = create(:user, password: "password123") + sign_in_as(user) + + get edit_profile_path + + expect(response).to have_http_status(:ok) + end + end + + describe "PATCH /profile" do + it "updates the signed in user's own info" do + user = create(:user, password: "password123") + sign_in_as(user) + + patch profile_path, params: { user: { full_name: "New Name" } } + + expect(response).to redirect_to(profile_url) + expect(user.reload.full_name).to eq("New Name") + end + + it "keeps the current password when the password field is left blank" do + user = create(:user, password: "original-password") + sign_in_as(user, password: "original-password") + + patch profile_path, params: { user: { full_name: "New Name", password: "", password_confirmation: "" } } + + user.reload + expect(user.full_name).to eq("New Name") + expect(user.authenticate("original-password")).to eq(user) + end + + it "ignores an injected role param and never promotes the user to admin" do + user = create(:user, password: "password123") + sign_in_as(user) + + patch profile_path, params: { user: { full_name: "New Name", role: "admin" } } + + expect(user.reload).to be_no_admin + end + + it "enqueues an avatar download job when an avatar_url is given" do + user = create(:user, password: "password123") + sign_in_as(user) + + expect { + patch profile_path, params: { user: { avatar_url: "https://example.com/avatar.png" } } + }.to have_enqueued_job(AvatarDownloadJob).with(user.id, "https://example.com/avatar.png") + end + end + + describe "DELETE /profile" do + it "deletes the signed in user's own account and signs them out" do + user = create(:user, password: "password123") + sign_in_as(user) + + expect { delete profile_path }.to change(User, :count).by(-1) + expect(response).to redirect_to(new_session_path) + + get profile_path + expect(response).to redirect_to(new_session_path) + end + end end From 7f42e574cc95762e187f673669925fa575d50857 Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Wed, 2 Sep 2026 23:05:43 -0300 Subject: [PATCH 17/68] feat: broadcast live dashboard counts over Solid Cable via Turbo Streams Admin::DashboardsController now computes total users and users grouped by role, and the User model broadcasts a fresh render of those counts to every connected admin whenever a user is created, destroyed, or has its role changed (an unrelated attribute update does not broadcast). A single after_commit with a combined condition is used on purpose: registering two separate after_commit callbacks for the same method name with different `on:` values silently drops the `on: :create` one, since Active Record's callback chain de-duplicates by method name regardless of options. A small Stimulus controller briefly highlights the counts whenever Turbo replaces them, so the live update is actually noticeable. --- app/controllers/admin/dashboards_controller.rb | 2 ++ .../controllers/dashboard_counts_controller.js | 18 ++++++++++++++++++ app/models/user.rb | 10 ++++++++++ app/views/admin/dashboards/_counts.html.erb | 12 ++++++++++++ app/views/admin/dashboards/show.html.erb | 3 +++ 5 files changed, 45 insertions(+) create mode 100644 app/javascript/controllers/dashboard_counts_controller.js create mode 100644 app/views/admin/dashboards/_counts.html.erb diff --git a/app/controllers/admin/dashboards_controller.rb b/app/controllers/admin/dashboards_controller.rb index 2be2dab93..ab946160b 100644 --- a/app/controllers/admin/dashboards_controller.rb +++ b/app/controllers/admin/dashboards_controller.rb @@ -1,5 +1,7 @@ class Admin::DashboardsController < ApplicationController def show authorize User, :index? + @total_users = User.count + @users_by_role = User.group(:role).count end end diff --git a/app/javascript/controllers/dashboard_counts_controller.js b/app/javascript/controllers/dashboard_counts_controller.js new file mode 100644 index 000000000..6f1d8880e --- /dev/null +++ b/app/javascript/controllers/dashboard_counts_controller.js @@ -0,0 +1,18 @@ +import { Controller } from "@hotwired/stimulus" + +// Briefly highlights the dashboard counts whenever they are replaced by a +// Turbo Stream broadcast, so the "real-time" update is actually noticeable. +export default class extends Controller { + static classes = ["highlight"] + + connect() { + this.element.classList.add(...this.highlightClasses) + this.timeout = setTimeout(() => { + this.element.classList.remove(...this.highlightClasses) + }, 700) + } + + disconnect() { + clearTimeout(this.timeout) + } +} diff --git a/app/models/user.rb b/app/models/user.rb index 202e70f38..2c46a5598 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -18,6 +18,7 @@ class User < ApplicationRecord validate :avatar_url_must_be_http, if: -> { avatar_url.present? } after_commit :enqueue_avatar_download, if: -> { avatar_url.present? } + after_commit :broadcast_dashboard_counts, if: -> { destroyed? || previously_new_record? || saved_change_to_role? } private def avatar_must_be_a_supported_image @@ -35,4 +36,13 @@ def avatar_url_must_be_http def enqueue_avatar_download AvatarDownloadJob.perform_later(id, avatar_url) end + + def broadcast_dashboard_counts + Turbo::StreamsChannel.broadcast_replace_to( + "admin_dashboard", + target: "dashboard_counts", + partial: "admin/dashboards/counts", + locals: { total_users: User.count, users_by_role: User.group(:role).count } + ) + end end diff --git a/app/views/admin/dashboards/_counts.html.erb b/app/views/admin/dashboards/_counts.html.erb new file mode 100644 index 000000000..1b62c663d --- /dev/null +++ b/app/views/admin/dashboards/_counts.html.erb @@ -0,0 +1,12 @@ +
+
+

Total Users

+

<%= total_users %>

+
+ <% User.roles.keys.each do |role| %> +
+

<%= role.humanize %>

+

<%= users_by_role[role] || 0 %>

+
+ <% end %> +
diff --git a/app/views/admin/dashboards/show.html.erb b/app/views/admin/dashboards/show.html.erb index 43d5a1f13..8c6717400 100644 --- a/app/views/admin/dashboards/show.html.erb +++ b/app/views/admin/dashboards/show.html.erb @@ -1,6 +1,9 @@

Admin Dashboard

Signed in as <%= Current.user.full_name %> (<%= Current.user.role %>).

+<%= turbo_stream_from "admin_dashboard" %> +<%= render "counts", total_users: @total_users, users_by_role: @users_by_role %> +
<%= link_to "Manage users", admin_users_path, class: "text-blue-600 underline hover:no-underline" %> <%= button_to "Sign out", session_path, method: :delete, class: "text-gray-700 underline hover:no-underline bg-transparent p-0 cursor-pointer" %> From 56566be64db3f9e3812e48ad828585de44c6715c Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Wed, 2 Sep 2026 23:05:54 -0300 Subject: [PATCH 18/68] test: cover dashboard broadcast triggers and real-time updates across two admin sessions Model specs assert the broadcast fires on create/destroy/role change and not on unrelated updates. A system spec (Playwright, two independent Capybara sessions) creates a user as one signed-in admin and asserts a second signed-in admin's dashboard updates the total without a page reload, exercising the real Turbo Streams/Solid Cable pipeline end to end. Adds package.json pinning the Playwright version for local system spec runs, and a sign_in_via_ui helper for system specs. --- package-lock.json | 56 +++++++++++++++++++ package.json | 5 ++ spec/models/user_spec.rb | 26 +++++++++ spec/support/authentication_helpers.rb | 8 +++ .../admin_dashboard_live_updates_spec.rb | 37 ++++++++++++ 5 files changed, 132 insertions(+) create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 spec/system/admin_dashboard_live_updates_spec.rb diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..858dbbb8d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,56 @@ +{ + "name": "Fullstack-Developer", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "playwright": "^1.62.1" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 000000000..4520dde68 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "playwright": "^1.62.1" + } +} diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 77958ade6..8dc17c399 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -95,4 +95,30 @@ expect { user.save! }.not_to have_enqueued_job(AvatarDownloadJob) end end + + describe "dashboard broadcasts" do + include ActionCable::TestHelper + + it "broadcasts updated counts when a user is created" do + expect { create(:user) }.to have_broadcasted_to("admin_dashboard") + end + + it "broadcasts updated counts when a user is destroyed" do + user = create(:user) + + expect { user.destroy }.to have_broadcasted_to("admin_dashboard") + end + + it "broadcasts updated counts when a user's role changes" do + user = create(:user) + + expect { user.update!(role: :admin) }.to have_broadcasted_to("admin_dashboard") + end + + it "does not broadcast when an unrelated attribute changes" do + user = create(:user) + + expect { user.update!(full_name: "New Name") }.not_to have_broadcasted_to("admin_dashboard") + end + end end diff --git a/spec/support/authentication_helpers.rb b/spec/support/authentication_helpers.rb index 133226e4d..c4932423a 100644 --- a/spec/support/authentication_helpers.rb +++ b/spec/support/authentication_helpers.rb @@ -2,8 +2,16 @@ module AuthenticationHelpers def sign_in_as(user, password: "password123") post session_path, params: { email: user.email, password: password } end + + def sign_in_via_ui(user, password: "password123") + visit new_session_path + fill_in "email", with: user.email + fill_in "password", with: password + click_button "Sign in" + end end RSpec.configure do |config| config.include AuthenticationHelpers, type: :request + config.include AuthenticationHelpers, type: :system end diff --git a/spec/system/admin_dashboard_live_updates_spec.rb b/spec/system/admin_dashboard_live_updates_spec.rb new file mode 100644 index 000000000..06fee151c --- /dev/null +++ b/spec/system/admin_dashboard_live_updates_spec.rb @@ -0,0 +1,37 @@ +require "rails_helper" + +RSpec.describe "Admin dashboard live updates", type: :system do + it "reflects a user created by one admin in another admin's dashboard without a reload" do + admin_one = create(:user, :admin, password: "password123") + admin_two = create(:user, :admin, password: "password123") + + using_session(:admin_two) do + sign_in_via_ui(admin_two) + + within("#dashboard_counts") do + expect(page).to have_content("Total Users") + expect(page).to have_content("2") + end + end + + using_session(:admin_one) do + sign_in_via_ui(admin_one) + click_link "Manage users" + click_link "New user" + + fill_in "Full name", with: "Grace Hopper" + fill_in "Email", with: "grace-live@example.com" + fill_in "Password", with: "password123", exact: true + fill_in "Password confirmation", with: "password123" + click_button "Create User" + + expect(page).to have_content("User was successfully created") + end + + using_session(:admin_two) do + within("#dashboard_counts") do + expect(page).to have_content("3") + end + end + end +end From 0fbca4ad92573f520a473acbcda478a45fbd6515 Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Thu, 3 Sep 2026 14:25:36 -0300 Subject: [PATCH 19/68] feat: parse CSV/XLSX spreadsheets into SpreadsheetImport records with per-row error tracking Adds the roo gem (uniform API for both CSV and XLSX, avoiding separate gems per format) plus the SpreadsheetImport (upload metadata, status, row counters) and SpreadsheetImportRowError (row number + reason, recorded individually rather than just counted, as requested) models that Fase 5's background import will build on. --- Gemfile | 3 ++ Gemfile.lock | 12 +++++ app/models/spreadsheet_import.rb | 44 +++++++++++++++++++ app/models/spreadsheet_import_row_error.rb | 6 +++ ...260903171020_create_spreadsheet_imports.rb | 12 +++++ ...25_create_spreadsheet_import_row_errors.rb | 12 +++++ db/schema.rb | 24 +++++++++- 7 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 app/models/spreadsheet_import.rb create mode 100644 app/models/spreadsheet_import_row_error.rb create mode 100644 db/migrate/20260903171020_create_spreadsheet_imports.rb create mode 100644 db/migrate/20260903171025_create_spreadsheet_import_row_errors.rb diff --git a/Gemfile b/Gemfile index 1a5da6132..6b44173c3 100644 --- a/Gemfile +++ b/Gemfile @@ -43,6 +43,9 @@ gem "image_processing", "~> 1.2" # Object-oriented authorization for Rails applications [https://github.com/varvet/pundit] gem "pundit" +# Reads CSV and XLSX spreadsheets through a single uniform API [https://github.com/roo-rb/roo] +gem "roo" + group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" diff --git a/Gemfile.lock b/Gemfile.lock index 98103ad9f..bcdc1537e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -110,6 +110,7 @@ GEM bigdecimal rexml crass (1.0.7) + csv (3.3.6) date (3.5.1) debug (1.11.1) irb (~> 1.10) @@ -304,6 +305,12 @@ GEM reline (0.7.0) io-console (~> 0.5) rexml (3.4.4) + 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) @@ -353,6 +360,7 @@ GEM ruby-vips (2.3.0) ffi (~> 1.12) logger + rubyzip (3.6.0) securerandom (0.4.1) shoulda-matchers (8.0.1) activesupport (>= 7.2) @@ -457,6 +465,7 @@ DEPENDENCIES pundit pundit-matchers rails (~> 8.1.3, >= 8.1.3.1) + roo rspec-rails rubocop-rails-omakase shoulda-matchers @@ -503,6 +512,7 @@ CHECKSUMS connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a crack (1.0.1) sha256=ff4a10390cd31d66440b7524eb1841874db86201d5b70032028553130b6d4c7e crass (1.0.7) sha256=94868719948664c89ddcaf0a37c65048413dfcb1c869470a5f7a7ceb5390b295 + csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456 date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6 diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 @@ -588,6 +598,7 @@ CHECKSUMS regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 + 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 @@ -600,6 +611,7 @@ CHECKSUMS rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d 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 (8.0.1) sha256=5dbb46e5765b9da225111b085e0819e8c8a121ff94bba430a153eb1ea2c60288 simplecov (1.1.1) sha256=25825ef13f0b2e74694d769817dad6ab8e90131dabdaa666e522fea105521e78 diff --git a/app/models/spreadsheet_import.rb b/app/models/spreadsheet_import.rb new file mode 100644 index 000000000..9283e9bfd --- /dev/null +++ b/app/models/spreadsheet_import.rb @@ -0,0 +1,44 @@ +class SpreadsheetImport < ApplicationRecord + ALLOWED_EXTENSIONS = %w[.csv .xlsx].freeze + MAX_BYTES = 10.megabytes + + belongs_to :user + has_one_attached :file + has_many :spreadsheet_import_row_errors, dependent: :destroy + + enum :status, { pending: 0, processing: 1, completed: 2, failed: 3 } + + validate :file_must_be_a_supported_spreadsheet + + after_commit :enqueue_import_job, on: :create + after_commit :broadcast_progress, if: -> { saved_change_to_status? || saved_change_to_processed_rows? || saved_change_to_total_rows? } + + def progress_percent + return 0 if total_rows.zero? + ((processed_rows.to_f / total_rows) * 100).round + end + + private + def file_must_be_a_supported_spreadsheet + unless file.attached? + errors.add(:file, "must be attached") + return + end + + errors.add(:file, "must be a CSV or XLSX file") unless File.extname(file.filename.to_s).downcase.in?(ALLOWED_EXTENSIONS) + errors.add(:file, "is too large (max #{MAX_BYTES / 1.megabyte}MB)") if file.byte_size > MAX_BYTES + end + + def enqueue_import_job + SpreadsheetImportJob.perform_later(id) + end + + def broadcast_progress + Turbo::StreamsChannel.broadcast_replace_to( + "spreadsheet_import_#{id}", + target: "spreadsheet_import_progress", + partial: "admin/spreadsheet_imports/progress", + locals: { spreadsheet_import: self } + ) + end +end diff --git a/app/models/spreadsheet_import_row_error.rb b/app/models/spreadsheet_import_row_error.rb new file mode 100644 index 000000000..6b93338ca --- /dev/null +++ b/app/models/spreadsheet_import_row_error.rb @@ -0,0 +1,6 @@ +class SpreadsheetImportRowError < ApplicationRecord + belongs_to :spreadsheet_import + + validates :row_number, presence: true + validates :message, presence: true +end diff --git a/db/migrate/20260903171020_create_spreadsheet_imports.rb b/db/migrate/20260903171020_create_spreadsheet_imports.rb new file mode 100644 index 000000000..bae79d0cd --- /dev/null +++ b/db/migrate/20260903171020_create_spreadsheet_imports.rb @@ -0,0 +1,12 @@ +class CreateSpreadsheetImports < ActiveRecord::Migration[8.1] + def change + create_table :spreadsheet_imports do |t| + t.references :user, null: false, foreign_key: true + t.integer :status, null: false, default: 0 + t.integer :total_rows, null: false, default: 0 + t.integer :processed_rows, null: false, default: 0 + + t.timestamps + end + end +end diff --git a/db/migrate/20260903171025_create_spreadsheet_import_row_errors.rb b/db/migrate/20260903171025_create_spreadsheet_import_row_errors.rb new file mode 100644 index 000000000..daab6de2f --- /dev/null +++ b/db/migrate/20260903171025_create_spreadsheet_import_row_errors.rb @@ -0,0 +1,12 @@ +class CreateSpreadsheetImportRowErrors < ActiveRecord::Migration[8.1] + def change + create_table :spreadsheet_import_row_errors do |t| + t.references :spreadsheet_import, null: false, foreign_key: true + t.integer :row_number, null: false + t.string :message, null: false + t.text :raw_data + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index f31044924..31d3093c5 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_013348) do +ActiveRecord::Schema[8.1].define(version: 2026_09_03_171025) do create_table "active_storage_attachments", force: :cascade do |t| t.bigint "blob_id", null: false t.datetime "created_at", null: false @@ -48,6 +48,26 @@ t.index ["user_id"], name: "index_sessions_on_user_id" end + create_table "spreadsheet_import_row_errors", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "message", null: false + t.text "raw_data" + t.integer "row_number", null: false + t.integer "spreadsheet_import_id", null: false + t.datetime "updated_at", null: false + t.index ["spreadsheet_import_id"], name: "index_spreadsheet_import_row_errors_on_spreadsheet_import_id" + end + + create_table "spreadsheet_imports", force: :cascade do |t| + t.datetime "created_at", null: false + t.integer "processed_rows", default: 0, null: false + t.integer "status", default: 0, null: false + t.integer "total_rows", default: 0, null: false + t.datetime "updated_at", null: false + t.integer "user_id", null: false + t.index ["user_id"], name: "index_spreadsheet_imports_on_user_id" + end + create_table "users", force: :cascade do |t| t.datetime "created_at", null: false t.string "email", null: false @@ -61,4 +81,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 "spreadsheet_import_row_errors", "spreadsheet_imports" + add_foreign_key "spreadsheet_imports", "users" end From 6b0c7a0a72b7bd162ad8bef0f43d059e5eb552f9 Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Thu, 3 Sep 2026 14:25:46 -0300 Subject: [PATCH 20/68] refactor: generalize the dashboard highlight Stimulus controller for reuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renamed dashboard_counts_controller.js to highlight_on_update_controller.js now that the same "flash on Turbo Stream update" behavior is about to be reused by the spreadsheet import progress bar too — only worth extracting now that the duplication actually shows up. --- ...ounts_controller.js => highlight_on_update_controller.js} | 5 +++-- app/views/admin/dashboards/_counts.html.erb | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) rename app/javascript/controllers/{dashboard_counts_controller.js => highlight_on_update_controller.js} (67%) diff --git a/app/javascript/controllers/dashboard_counts_controller.js b/app/javascript/controllers/highlight_on_update_controller.js similarity index 67% rename from app/javascript/controllers/dashboard_counts_controller.js rename to app/javascript/controllers/highlight_on_update_controller.js index 6f1d8880e..0a1e30421 100644 --- a/app/javascript/controllers/dashboard_counts_controller.js +++ b/app/javascript/controllers/highlight_on_update_controller.js @@ -1,7 +1,8 @@ import { Controller } from "@hotwired/stimulus" -// Briefly highlights the dashboard counts whenever they are replaced by a -// Turbo Stream broadcast, so the "real-time" update is actually noticeable. +// Briefly highlights an element whenever it is replaced by a Turbo Stream +// broadcast, so a "real-time" update (dashboard counts, import progress) is +// actually noticeable. export default class extends Controller { static classes = ["highlight"] diff --git a/app/views/admin/dashboards/_counts.html.erb b/app/views/admin/dashboards/_counts.html.erb index 1b62c663d..8846f0761 100644 --- a/app/views/admin/dashboards/_counts.html.erb +++ b/app/views/admin/dashboards/_counts.html.erb @@ -1,4 +1,4 @@ -
+

Total Users

<%= total_users %>

From cce88ad2443f5a0827b036d80db1c2d5efa8a098 Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Thu, 3 Sep 2026 14:25:53 -0300 Subject: [PATCH 21/68] feat: process spreadsheet imports in the background with live progress SpreadsheetImportJob (Solid Queue) parses the attached CSV/XLSX via Roo and creates a User per row with a random password (no login form was submitted, so there is nothing to confirm) and the fixed no_admin role. A bad row never aborts the whole import: validation failures are recorded as a SpreadsheetImportRowError and processing continues. Progress is persisted with update! (not increment!, which bypasses after_commit callbacks via update_counters) so the row-by-row broadcast added on SpreadsheetImport fires as each row completes. A spreadsheet that fails to parse at all (corrupt file, invalid encoding) is distinguished from a per-row failure and marks the import failed. Spreadsheet cells are untrusted external input and are only ever read as plain data, never interpreted as instructions. --- app/jobs/spreadsheet_import_job.rb | 57 ++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 app/jobs/spreadsheet_import_job.rb diff --git a/app/jobs/spreadsheet_import_job.rb b/app/jobs/spreadsheet_import_job.rb new file mode 100644 index 000000000..332cde61c --- /dev/null +++ b/app/jobs/spreadsheet_import_job.rb @@ -0,0 +1,57 @@ +class SpreadsheetImportJob < ApplicationJob + queue_as :default + + # Spreadsheet data is untrusted external input: every cell is treated as + # plain data (never evaluated or interpreted), and a bad row is recorded as + # a SpreadsheetImportRowError instead of aborting the whole import. + def perform(spreadsheet_import_id) + import = SpreadsheetImport.find_by(id: spreadsheet_import_id) + return unless import&.pending? + + import.update!(status: :processing) + + rows = parse_rows(import) + import.update!(total_rows: rows.size) + + rows.each { |row_number, data| import_row(import, row_number, data) } + + import.update!(status: :completed) + rescue => e + Rails.logger.warn("SpreadsheetImportJob: failed to process import #{spreadsheet_import_id}: #{e.message}") + import&.update!(status: :failed) + end + + private + def parse_rows(import) + import.file.open do |tempfile| + extension = File.extname(import.file.filename.to_s).delete(".").downcase.to_sym + sheet = Roo::Spreadsheet.open(tempfile.path, extension: extension).sheet(0) + headers = sheet.row(1).map { |header| header.to_s.strip.downcase } + + (2..sheet.last_row).filter_map do |row_number| + values = sheet.row(row_number) + next if values.all? { |value| value.to_s.strip.blank? } + [ row_number, headers.zip(values).to_h ] + end + end + end + + def import_row(import, row_number, data) + user = User.new( + email: data["email"].to_s.strip, + full_name: data["full_name"].to_s.strip, + password: SecureRandom.hex(16), + role: :no_admin + ) + + unless user.save + import.spreadsheet_import_row_errors.create!( + row_number: row_number, + message: user.errors.full_messages.to_sentence, + raw_data: data.to_json + ) + end + + import.update!(processed_rows: import.processed_rows + 1) + end +end From 50aa7d113c50d61f1beaf9611a70d1aab5ea56c1 Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Thu, 3 Sep 2026 14:26:03 -0300 Subject: [PATCH 22/68] feat: add Admin::SpreadsheetImportsController with a live-updating progress view index/new/create/show, all authorized through Pundit (policy_scope + authorize on index, like Admin::UsersController). The show page subscribes to the import's own Turbo Stream channel and renders a progress bar plus a per-row error table that update live as SpreadsheetImportJob works through the file, reusing the highlight Stimulus controller from the dashboard. --- .../admin/spreadsheet_imports_controller.rb | 40 +++++++++++++++++++ app/policies/spreadsheet_import_policy.rb | 19 +++++++++ app/views/admin/dashboards/show.html.erb | 1 + .../spreadsheet_imports/_progress.html.erb | 28 +++++++++++++ .../admin/spreadsheet_imports/index.html.erb | 29 ++++++++++++++ .../admin/spreadsheet_imports/new.html.erb | 23 +++++++++++ .../admin/spreadsheet_imports/show.html.erb | 9 +++++ config/routes.rb | 1 + 8 files changed, 150 insertions(+) create mode 100644 app/controllers/admin/spreadsheet_imports_controller.rb create mode 100644 app/policies/spreadsheet_import_policy.rb create mode 100644 app/views/admin/spreadsheet_imports/_progress.html.erb create mode 100644 app/views/admin/spreadsheet_imports/index.html.erb create mode 100644 app/views/admin/spreadsheet_imports/new.html.erb create mode 100644 app/views/admin/spreadsheet_imports/show.html.erb diff --git a/app/controllers/admin/spreadsheet_imports_controller.rb b/app/controllers/admin/spreadsheet_imports_controller.rb new file mode 100644 index 000000000..60c738256 --- /dev/null +++ b/app/controllers/admin/spreadsheet_imports_controller.rb @@ -0,0 +1,40 @@ +class Admin::SpreadsheetImportsController < ApplicationController + after_action :verify_policy_scoped, only: :index + + before_action :set_spreadsheet_import, only: :show + + def index + authorize SpreadsheetImport, :index? + @spreadsheet_imports = policy_scope(SpreadsheetImport).order(created_at: :desc) + end + + def new + @spreadsheet_import = SpreadsheetImport.new + authorize @spreadsheet_import + end + + def create + @spreadsheet_import = SpreadsheetImport.new(spreadsheet_import_params) + @spreadsheet_import.user = Current.user + authorize @spreadsheet_import + + if @spreadsheet_import.save + redirect_to admin_spreadsheet_import_path(@spreadsheet_import), notice: "Spreadsheet uploaded. Import is processing in the background." + else + render :new, status: :unprocessable_entity + end + end + + def show + end + + private + def set_spreadsheet_import + @spreadsheet_import = SpreadsheetImport.find(params[:id]) + authorize @spreadsheet_import + end + + def spreadsheet_import_params + params.expect(spreadsheet_import: [ :file ]) + end +end diff --git a/app/policies/spreadsheet_import_policy.rb b/app/policies/spreadsheet_import_policy.rb new file mode 100644 index 000000000..421f66c50 --- /dev/null +++ b/app/policies/spreadsheet_import_policy.rb @@ -0,0 +1,19 @@ +class SpreadsheetImportPolicy < ApplicationPolicy + def index? + user.admin? + end + + def show? + user.admin? + end + + def create? + user.admin? + end + + class Scope < Scope + def resolve + user.admin? ? scope.all : scope.none + end + end +end diff --git a/app/views/admin/dashboards/show.html.erb b/app/views/admin/dashboards/show.html.erb index 8c6717400..6874ef8d1 100644 --- a/app/views/admin/dashboards/show.html.erb +++ b/app/views/admin/dashboards/show.html.erb @@ -6,5 +6,6 @@
<%= link_to "Manage users", admin_users_path, class: "text-blue-600 underline hover:no-underline" %> + <%= link_to "Spreadsheet imports", admin_spreadsheet_imports_path, class: "text-blue-600 underline hover:no-underline" %> <%= button_to "Sign out", session_path, method: :delete, class: "text-gray-700 underline hover:no-underline bg-transparent p-0 cursor-pointer" %>
diff --git a/app/views/admin/spreadsheet_imports/_progress.html.erb b/app/views/admin/spreadsheet_imports/_progress.html.erb new file mode 100644 index 000000000..b9ab29a36 --- /dev/null +++ b/app/views/admin/spreadsheet_imports/_progress.html.erb @@ -0,0 +1,28 @@ +
+

Status: <%= spreadsheet_import.status.humanize %>

+ +
+
+
+

<%= spreadsheet_import.processed_rows %> / <%= spreadsheet_import.total_rows %> rows processed

+ + <% if spreadsheet_import.spreadsheet_import_row_errors.any? %> +

Row errors

+ + + + + + + + + <% spreadsheet_import.spreadsheet_import_row_errors.order(:row_number).each do |row_error| %> + + + + + <% end %> + +
RowReason
<%= row_error.row_number %><%= row_error.message %>
+ <% end %> +
diff --git a/app/views/admin/spreadsheet_imports/index.html.erb b/app/views/admin/spreadsheet_imports/index.html.erb new file mode 100644 index 000000000..3bc61019b --- /dev/null +++ b/app/views/admin/spreadsheet_imports/index.html.erb @@ -0,0 +1,29 @@ +
+

Spreadsheet Imports

+ <%= link_to "New import", new_admin_spreadsheet_import_path, class: "rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white font-medium" %> +
+ + + + + + + + + + + + + + <% @spreadsheet_imports.each do |spreadsheet_import| %> + + + + + + + + + <% end %> + +
FileUploaded byStatusProgressErrors
<%= spreadsheet_import.file.filename %><%= spreadsheet_import.user.full_name %><%= spreadsheet_import.status.humanize %><%= spreadsheet_import.processed_rows %>/<%= spreadsheet_import.total_rows %><%= spreadsheet_import.spreadsheet_import_row_errors.count %><%= link_to "View", admin_spreadsheet_import_path(spreadsheet_import), class: "text-blue-600 underline hover:no-underline" %>
diff --git a/app/views/admin/spreadsheet_imports/new.html.erb b/app/views/admin/spreadsheet_imports/new.html.erb new file mode 100644 index 000000000..8477f4f61 --- /dev/null +++ b/app/views/admin/spreadsheet_imports/new.html.erb @@ -0,0 +1,23 @@ +

New Spreadsheet Import

+ +<%= form_with model: [ :admin, @spreadsheet_import ], class: "contents" do |form| %> + <% if @spreadsheet_import.errors.any? %> +
+
    + <% @spreadsheet_import.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+ <% end %> + +
+ <%= form.label :file, "Spreadsheet (CSV or XLSX)", class: "block font-medium" %> +

Expected columns: email, full_name.

+ <%= form.file_field :file, accept: ".csv,.xlsx", required: true, class: "block mt-2 w-full" %> +
+ +
+ <%= form.submit "Upload", 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/admin/spreadsheet_imports/show.html.erb b/app/views/admin/spreadsheet_imports/show.html.erb new file mode 100644 index 000000000..f581c1b38 --- /dev/null +++ b/app/views/admin/spreadsheet_imports/show.html.erb @@ -0,0 +1,9 @@ +

Spreadsheet Import

+

<%= @spreadsheet_import.file.filename %> — uploaded by <%= @spreadsheet_import.user.full_name %>

+ +<%= turbo_stream_from "spreadsheet_import_#{@spreadsheet_import.id}" %> +<%= render "progress", spreadsheet_import: @spreadsheet_import %> + +
+ <%= link_to "Back to imports", admin_spreadsheet_imports_path, class: "text-blue-600 underline hover:no-underline" %> +
diff --git a/config/routes.rb b/config/routes.rb index 8f3103dde..d1529997d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -8,6 +8,7 @@ resources :users do patch :toggle_role, on: :member end + resources :spreadsheet_imports, only: %i[ index new create show ] end # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html From 8b8f40fa898d99af0cccf78d8556a286ee5aa8c9 Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Thu, 3 Sep 2026 14:26:13 -0300 Subject: [PATCH 23/68] test: cover spreadsheet import parsing, background processing and live progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Model specs (file validations, status enum, progress_percent, job enqueueing, progress broadcasts), job specs against real CSV and XLSX fixtures (valid/mixed/malformed-encoding — missing email, invalid format and duplicate email each produce their own row error without aborting the rest), policy specs, request specs for Admin::SpreadsheetImportsController, and a real Playwright system spec that uploads a file through the UI, runs the job, and asserts the progress bar and status update without a reload. --- spec/factories/spreadsheet_imports.rb | 21 ++++ spec/fixtures/files/malformed_import.csv | Bin 0 -> 32 bytes spec/fixtures/files/mixed_import.csv | 6 + spec/fixtures/files/mixed_import.xlsx | Bin 0 -> 4905 bytes spec/fixtures/files/valid_import.csv | 4 + spec/fixtures/files/valid_import.xlsx | Bin 0 -> 4824 bytes spec/jobs/spreadsheet_import_job_spec.rb | 75 ++++++++++++ .../spreadsheet_import_row_error_spec.rb | 9 ++ spec/models/spreadsheet_import_spec.rb | 108 +++++++++++++++++ .../spreadsheet_import_policy_spec.rb | 35 ++++++ .../admin/spreadsheet_imports_spec.rb | 109 ++++++++++++++++++ ...n_spreadsheet_import_live_progress_spec.rb | 29 +++++ 12 files changed, 396 insertions(+) create mode 100644 spec/factories/spreadsheet_imports.rb create mode 100644 spec/fixtures/files/malformed_import.csv create mode 100644 spec/fixtures/files/mixed_import.csv create mode 100644 spec/fixtures/files/mixed_import.xlsx create mode 100644 spec/fixtures/files/valid_import.csv create mode 100644 spec/fixtures/files/valid_import.xlsx create mode 100644 spec/jobs/spreadsheet_import_job_spec.rb create mode 100644 spec/models/spreadsheet_import_row_error_spec.rb create mode 100644 spec/models/spreadsheet_import_spec.rb create mode 100644 spec/policies/spreadsheet_import_policy_spec.rb create mode 100644 spec/requests/admin/spreadsheet_imports_spec.rb create mode 100644 spec/system/admin_spreadsheet_import_live_progress_spec.rb diff --git a/spec/factories/spreadsheet_imports.rb b/spec/factories/spreadsheet_imports.rb new file mode 100644 index 000000000..3fb42f11b --- /dev/null +++ b/spec/factories/spreadsheet_imports.rb @@ -0,0 +1,21 @@ +FactoryBot.define do + factory :spreadsheet_import do + association :user, factory: [ :user, :admin ] + status { :pending } + + after(:build) do |spreadsheet_import| + spreadsheet_import.file.attach( + io: StringIO.new("email,full_name\nfixture@example.com,Fixture User\n"), + filename: "import.csv", + content_type: "text/csv" + ) + end + end + + factory :spreadsheet_import_row_error do + association :spreadsheet_import + sequence(:row_number) { |n| n + 1 } + message { "Email can't be blank" } + raw_data { { "email" => "", "full_name" => "Missing Email" }.to_json } + end +end diff --git a/spec/fixtures/files/malformed_import.csv b/spec/fixtures/files/malformed_import.csv new file mode 100644 index 0000000000000000000000000000000000000000..eb8c1aa618baa8882a3c3ed2c5b873ac93a0e0e7 GIT binary patch literal 32 ncmYezP0Y;ENh{6CiO);SP38LkkAYFgsVF}?HBTWZznlvI%;F2* literal 0 HcmV?d00001 diff --git a/spec/fixtures/files/mixed_import.csv b/spec/fixtures/files/mixed_import.csv new file mode 100644 index 000000000..d3d7566e1 --- /dev/null +++ b/spec/fixtures/files/mixed_import.csv @@ -0,0 +1,6 @@ +email,full_name +dave@example.com,Dave Example +,Missing Email +not-an-email,Bad Email Format +existing@example.com,Duplicate Email +erin@example.com,Erin Example diff --git a/spec/fixtures/files/mixed_import.xlsx b/spec/fixtures/files/mixed_import.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..db9a6cb8d26bff725d7c0f82b2b5a3c053fcefec GIT binary patch literal 4905 zcmZ`-2Q-}B79EThZDjOl6OxeVLv_YxUI5WSZui5g^x-bL?% zs1q$j3Et$-d(Vsa|7+I$?zd*Gz1RKry=R|uuDUWFkQ)F15Mc)d5GnkWR}cpP*uw(= zz}SEEW$o>p5q8eTTJ8=ACqo`LTbtsSU24sIAgIr^9hw$9IBURbXY?atLlgzyepcC9 zf`lFN?2VF)JF%k1_@}Vt-WpdfPvKcHeH%zRaf6wrq$$1|^}FrDu)92)oyx**ExWvd zT#9RDX12ZC_ldN)MMEXzwzQBkjsluIejy_#?Qtr;lr)u&jKDRsVSMkff{W}&u(M~`n9!MZ>jwgTMD8)_ zje2J-^j_-rByroxqXR2Oa!a|yQ}k0VtNb7i+Wc;Q#w=)dz&Los8L;O=F~BI2L^$$WIz% zoW0oaS;Luf{F{;7SW^1|psc4Gl_;DYd8eU%*uT0w{1UV5&e>T)OphILg-4EogaV$W zDoI!dVtK)2F1^ms5N%gilS`kih8oDtC}5|-%T%l#sU3TP8y^I2nOGGgMCpB%zVQxB zp4B($QoO4}A^Kg5>8tOjj{@toy`R~3hqMIMr>!$)<(|xXDvQ{p;zGq@4{ULVX6V~J zU2%?p;g38MYHo5{9$o2KZF$Nn9qh+aQmBQ;5uUq4?0hH)%K1J!nhxc-elIojVU>#m#9ncL#z2wn2XWG3@Z~$Shiv`vr;A18(YWudOu-< zj{JKod)^f0MVEPWG|yI_xHzw+Lg)2XX$KT4ZA6q!4FHE<=1W6KjmprG^ey%jYX^*@aYkBy zc$HVa+|qH0RzpbQ^S~b4-Wjb`0cS~!f_{Bp!%1lhSlgtQS2^3LXu!7IUVh++U1YfG zdfjqPt)MGKp0%>Y^Uj@aaCgpW{wylF;3-~ZKhSM$f?kv7Tf$}6)@F^(u^)~+hCyPJ z)o03lq6;g%Pmtjbe?}V`O2=vnhk{1!Xs7LvoS#@a9LHSAIJ~+{tC#pTfL+gT{JdhL zYeclta`P+%Ch{pVaI5522)SCDzFJ$#a~6%2{py9*euLeTv*H!zz?a;CFV&>-w?aBH zVd6s=Z**)}n9lSbxqK#frp6D%xPw1xA>HU{ra@fb9KWonZozi^ST#7dQNTS!OQeY# z;yj&dvK(2na1C6Ra`YXvv2D~#t^C>*ZpE|R;E?o5-&TPi+GhpOB(|1Do|do+JW;wu z7XEu(nE&AYr7=g_UCDmo=ac$6&g@wP3*76HH#*~Yr z*Sh0G7(U0mdY$LaX$FZJ=neV8W*M6;%;V%f@&>80fpg`M7QfYhL}81&)kaeduG55I~a+{?lBugL}NcA(pi*P8`5>h@(Myf3TD=T?Z) zDZ?48vsw7v$NT-@;&@MXpvsbPIGuM4Wm(p^nwr>#AIi|(#;@Guk_PfraU@C z3mf4car`{EKD{$zK;zOGZ5TPhJrY06#9qTxIeY=Kl4Md2TbVm3v}#e+N~B1Cm}R*9 z4PJ#gQT&1T=Ld7=mUK%30RT8QVgAaQSTA$7Lf9gHKmW3^C0%`|2~n!kTMUg=ts=OI zF=`OL<5KZSi1R*ttjUF~i&;r&Zg#V5G5n)2B*9L+LgG2Q&$77AYWtMexFywHN}j>w z9^uFRc)NPQ>@f_~|aYKPW>*LQG*XqUg`6Yr7;GakpqwrWFNYCTpi&;?a`rby)n9w3+J;IRPfMsn4SKq#EyB`OD8*DWsksV5 zFF*FvW9KUxVwyLL8m8MeIDOfme808!(xU|kmlvM zC!@fpde6+ll3%>Y1S(`%j&JpCKf5pFuS37hFFj*=Q}IT>i>rc$i-c^S0}Y{_Njix3 zBVH0q5F97juZ5;bDCisGeavFl@%E>6qz^(Y)1-ZsjT)bJhdf+PIbY%Fv03qn-CRQ1 z0Uev}KmyCz9h?s1GF2jRmBh&K$MmH2`}avZ0YStf!OzS+8d53ts%J% zI~@Vw9o2^o4N_mLE$WIw3j}ldb*PS9mERCeqr}|}j_lL3B;f^898<_I8x1Lykvfgr z&{iK!w#U?mZ;?R_1AvH1KJQfG1R-5ky?3}!CRbm^Pkt*#g^zP3E8}@0v)4lBye%_R0fhQUvEMf@`f2BgdGMf>aqb>_#&D9$ z=dn>e=~Yj*m3l7S`APXfqB>p@k;{AHNVcMxwId>{Liobd_$0RSP%6YG@i>0#kKm#z z4_>ly{7?_s_Qg}L_5#Im??8q9*ahgxD9+H^>G%yypgiQJ{Jfvp4HMS&0* z_P^~UvD|ItobRoRa$5}~vKr|;Aou}^P>}gqL;Ly==0Zhqs<Ex`N#N-cufbIE;Z$`9};jSHLjwo#Y zPZ6JOuQrlmNe~hO7GHs#>CV zd+joyUgx>A;b(^O98v}RMA%x37#!7ca#k1nGi@TIthjiAg`3%nD`rm%ae72a zvW+r2?Q14a;551T=3nd@lKNYRy9EmI$}0k3oQYtniPSTgXZ$fcOI(@=`$}_Lr^!NI z{IXJN!1`dZGG`~b0d1;Y**bZH_^O4~Q_5o3YQU>b)_>f9~~TN{Cwrc8?jb zr82|sz5bo?e-+FB$*8|OhZlWeO?;qW<7?gC;>9Fa%{HJkA9$t-iB`W;R4TPclr)^Q zUV=)SQW#kfb$HE2cjrh%``^)6oDnC3#`|Bj^q#JJ*bLj-EAw-#JIp~VdY}zibh9bj z81B3%l{cRpuoB(%HK;{;F-B%Rvf-!m?ujxEr;&7)oo2ljmIw`L>R`1*MDclKS}#D6&>${9#Br7NoX^ndQ@1 zCQThBJt$qou>2_iCI~h}iaqR0*g=UEz})_kx}&{=6YnE?N5roKZ~HoN0}+1YS$4 zp?u(a+r!#*m1#nlx(aoLkhfRYLD~)Q!{PE%kUKPL<}5fdOAWRZmxT$O=hhCW!=lUd4~_HLj=yzP&C`eXaU6Et z#`ecJxI%dUd<%hHJpg+E09n4j-u~uJ;v&Jt7W)_MH-R~J`k#jTBJg5gdJeS0hTuPS zsTWx;*6imju~^goljUzK_=|)WYwB}CS%Uu({>Kk4f-lym=U^dh_x(53>P3Q!8RwiJ z6MKJwB{>L`6&0SdNKiJtto{K(k&J#oaH?D9IelZr#;a%7k<-ex#@5oSB V#>c7(09?ZUqOsc^@GE=({{ni@M@j$y literal 0 HcmV?d00001 diff --git a/spec/fixtures/files/valid_import.csv b/spec/fixtures/files/valid_import.csv new file mode 100644 index 000000000..0859a3c96 --- /dev/null +++ b/spec/fixtures/files/valid_import.csv @@ -0,0 +1,4 @@ +email,full_name +alice@example.com,Alice Example +bob@example.com,Bob Example +carol@example.com,Carol Example diff --git a/spec/fixtures/files/valid_import.xlsx b/spec/fixtures/files/valid_import.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..24e0c93e9fff753b9d4cfe5bf33e1466581c5a7a GIT binary patch literal 4824 zcmZ`-2Q-{p*B+frFnT9~ghVGwh#DRA$~qWTMyrP)Q<}mv!OlI+JT4r*SjR9`3W}P%>ct3 zWaIEtN0Q0ymyaisJC6ZneZBBRp`7rCjSZu|)#af#7-jb_FXN;7U<4Izxd!42SZ1og zkWA?6ve80D9T?mUtEK1?5rG_lx2^Y#fGPXs`@2lL|@a_kT7JA%e`9-l7Y zRoeb(0}o*qWEOh=Y`6dbHQEL));!L(_Erw&P^hB=&)*Z@FJ}waoQ6&F-aMyh-3g5$ zw+j9Gdb|fg)ENh{&^F@ng$!3}=nAsdz|Ib{kjjWx4nH8F;-Oi%hglo|YpK7wIa^z%ESnRQZm`!R8sUV%_(#~PST|A< zpJ1s!%7b1@GW(p3YHP{!MTqW-`Uf3uPFtzi+WW<}$O|cLutp>WuQI zl1Ec|K8)FcGCC8L1kR5orH!p%Comu>DX^f)#_vg4&8WH3D&35<(3e=G+u5^ijmin_ zZ}!P{1p4vQNliv5frEra#ETt6=Xrxwlg)&=9-_S=8c(J9r06P+F3-c%uEm;9+ucoA z8&eT<>E+@+CXuDI(H3vD7-#(?U?FMk9sRusa#{ zXTEUXD3XeAE99K(MpVnC#c})bOKkQ2{PvRHc;`EKneGl7JbLS6_EL-V4ndq(s96eR zh23X>t4&cw;VdCSp1?tM;G!X+o#P`zn~us>y6uHa7j(%9k7Arb*fLxk4AwKO>BlMSb_!NelW=m+q2kn< zZJEo+QJ5g`H)QG$vgXeo+QAXBZmqSKv8vg>1EVK~k_M(SUE==f*pGA|;&y0<4nYr= z|L54hL;cFV{|@>WVai09%pjakGHyG9wY=?sithk<;=yLhpw_#Be2j*1wZ-*r&@uJ7 zk>t8;J!rLOa~c%x&PG3-&BnarlN_C=XioiLVw6v9-1OI{+h_(Q1!YB)0w3 z9658yi(xPb0aNtF!w;{Mw^`~YqKk}N7ka${>1jd@G)(W}~F!8xg4*KYdVZGj$QDa)Re&>9`P z!z5rpKzJD_-3oH|$hf%wid!3hc`MW{mX90LtAB0++pu)*g*p+gB+v&nb=WvluDi^< zmTXk6zP4~&WZtH#nMj) ze*djHD?0iPQzGOS_i3A|+J!L_qtuvr&q~FnnH`UGCt93XdKi_I7Up-#kfC3NnB!q$ z72@w$y;jAv*E?rCCe6qnk#HkY`Z!m=cgpzryTZ#|Og-m|B~&?CSxrfDMOg$krTh+8 zj&Kco{7kUD1K2SjmQ3t7CBAd>dDMPaDGGm>dwu z?6ToldSSWLYo&&j9SGiTW2ZjpaPghrT*&)=BA&pw*g(1Cy!(D=;&Ov=@tl&$5tZfz z=4@9uPH&48J02Z%++td$V9(%iUrX=t5K6STRhM!x1WW^XJfo5dEf3#7?5hxs>IHZq zJ#YZA2At0WkQukc=rLj56*5X6T86vvn>i8A#RO393!KR1?w$G{^@Z`z# z_&PYk-8CJlo}oe_s+vW?Sr|-e>7&QWTRg(B2#FY_-ZQv(-;I=L#>syT575yZDU*`p zz7;!mtLh=~vpa`+8nzHA;}dPhb?Cs%3}JXuTZ<+#7CZjl_m6XVy+dDtUqq#wDi?}{ zri1Q~3%0B@qr$)?w85a~q_MBZvES&ug@mL;M`vLxWSdRy4(z>sBIv74v&Sbr_v)VF z-9aa3g{My9vIBOMcrc?35Y<<#WTpUJj1-?X$`-+ZZTcrDWY5{&w@nK#sA!f^=Q;}o z4%I$U=wsyqg*RuAl5^NXeAzKIo*NOor+D{HKhNhD301C1lp;Hx)|AB|rx)CB{_c># z6hfh~-Kgr*eaNxDTFFZPuwd0L!i;9nka^-xA3e%`h#_b-!wloA_`p8;qVm6v;DO7Ry&!e4{SSIGA zN(YME-AjEx=-{o&+BjF`+tkPB>#jz=^EktDoNspTXBoYp`7V*sW4U|LhLNF=nc`G& z&^tf!P4}2dAi~@*?~pZfG}&o*Vq8yp-JNBvfm3I3S{{L4#{(25JruKGDW2On#Wyd~ zU49dn%u*gq4&5P`#A*K#SX|}CLpX^O>?YfZyzuBORGjqmS2&7UmOCHE82L0Cw}oo@ zQ*PEf^|+hS+KP_@01*C`8wW>MYpBC7bxqZrww&Pwol|5G=NH@1u0iOYG=t1#nO)b4 zoY+bTYpIopoSGV0x^#Ppp~9oS4;;i-d(9mSJaynM>;CxWW8KHNKbXT5WPa9AC7q}{ zk>g${ZVCbyYJq!MWMOBDh!Fb1jc9Wr>X|W9L@E(uD#}F0mslGgdg0t7=SxNX*s4WT z{^SRlEpN%auuekEjkByVg`FQNNeL#Z^J9plk{clc0E7zgpk33qsm7b*CjI4IEV#Tn z|D$}U+-)g-KGmnr8kSYH_@56uWkCIo3+bb8!Sd`gIxM$B$T6OBstV^*bx^t;@AEh% zrDwi>V^9rNrI7)>FTs2&gA1>$Z_ZHHcdG1uh~&=0bq2@E!%V|$tb`3t>o_>7OMDr2 zpi<_XJlI7$Imk80n<9)p5n#^C%x>G7>2qDmJRFlHSYz^F`)DtJ5mtGHpE^e(8TnM& zrMi3E87ot4x-jcnYe%=ya(>*ZQkvf;qC}aao5+AFO|NVdMIpE6^x#ME0p5#w zSJmK&GW_$bElHSNy3l7#i>_>Gf1ma5jQ^{){ZB^yUFb&-gtYL20u9-EJ;h3Zw;@|{ zl=a-Rc=+oF#Fa{&VILdM+i%E8zaoBVir?iiAK9BL9_jn=DRNGXP%h5*wwdQ_omQ** z;bEDNecefJv!Yuwm`Nvxq=WWhbg8__^pLs8cW;AQ3lIA6Y-4LaYIk0)#oGh%)H^i0 zotm-1T)_c`OvE!J1j{2KoXpZM;-xN0PIK3}Q1y=@kjP*vGmL!C66Wnq%w0L^xE`sW z+t|Vz#fJ4G$}u3dL)1L))76`4<2R2>ktn7=4S)(zAGwKk_7(IXK^wrt)>y;d*3N;) z*w!BU%i(RJRn`4?K>?RH)*1A^7Eb}(2e*{S$oOb&RmIA~4_Iu#yYs+Vu*v&J{q+Ol zsiuXf{8J>LUpdPPZida4UPJQC`GK32^E$(n5JeT74v(i_$4=TuJ(mx~QT1Rd(1@e` z!;UmxnyTN27M^8t;Pdr|)-;!<3=2L{gj%2kBkx7sgsHY5wI|I~Ip?oE$?+pH@Ngcw zq?n0bn8n^~qWq|q(n`VbmBWi7qDv;?CHDyL%}EV;*py@k(}4P?y`q8<3En=82eAuq zF{9?{fba&U`}G@|d4Z9Hpswt^jxPgU?kBqw+ZxZKAn#+&;BcF_vfq8;s?sVpArc>2 zo2C1OtrY#op~c*`qdzhJG+k%&{@qZt$rjNA^xvj?WoP%dR78~BXyGN7^U5)?ea`Us zNeuVWmd32|{uFMnJF{!t=?{7ASq7d}dy&HVmIgg$sCs_eM+-R|^DftvFV#e=r|fQ7 z+`+e5kD`k9JBr&{=*2iHz_-F`IH-Y%?EcX`Dizrl`;=-XOc+rsjW)!jLb#3#8^;tO zk!AWPh6OBV+dWl_G@%3RCp{0)?JNeSAlAQM8lYDXKsx{++xyqoKU^VPC%E1b|APG{ zFhNiM)gNC6UT-6>fad5B{I{m^I?MH%{fb2zE!uyw{9^@wo$z{1eMPu`Mfe{*xDLKv zpI(8N(arKdRIAqsu4kMpf@kRK1~kD{{<#jlE?`$s33Tp4L;pq2uJc^ifh!(KqJL0@ n>+tKba0Pcmw Date: Thu, 3 Sep 2026 14:58:01 -0300 Subject: [PATCH 24/68] feat: add persistent role-aware navigation with a mobile menu toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No shared navigation existed until now — every page was an island, and the layout's mt-28 top margin hinted at a header that was never built. Admins now see Dashboard/Manage users/Spreadsheet imports, regular users see My Profile, and Sign out lives in one place instead of being duplicated across the dashboard and profile pages. Below the sm breakpoint the links collapse behind a "Menu" button (nav_toggle_controller.js, plain Stimulus, no new dependency). --- .../controllers/nav_toggle_controller.js | 13 ++++++++++ app/views/admin/dashboards/show.html.erb | 6 ----- app/views/layouts/_nav.html.erb | 25 +++++++++++++++++++ app/views/layouts/application.html.erb | 4 ++- app/views/profiles/show.html.erb | 7 +++--- 5 files changed, 44 insertions(+), 11 deletions(-) create mode 100644 app/javascript/controllers/nav_toggle_controller.js create mode 100644 app/views/layouts/_nav.html.erb diff --git a/app/javascript/controllers/nav_toggle_controller.js b/app/javascript/controllers/nav_toggle_controller.js new file mode 100644 index 000000000..c79c88774 --- /dev/null +++ b/app/javascript/controllers/nav_toggle_controller.js @@ -0,0 +1,13 @@ +import { Controller } from "@hotwired/stimulus" + +// Toggles the mobile nav menu, shown collapsed behind a "Menu" button below +// the sm breakpoint and always expanded above it (see layouts/_nav). +export default class extends Controller { + static targets = ["menu", "button"] + + toggle() { + const expanded = this.menuTarget.classList.toggle("flex") + this.menuTarget.classList.toggle("hidden", !expanded) + this.buttonTarget.setAttribute("aria-expanded", expanded) + } +} diff --git a/app/views/admin/dashboards/show.html.erb b/app/views/admin/dashboards/show.html.erb index 6874ef8d1..6117a0313 100644 --- a/app/views/admin/dashboards/show.html.erb +++ b/app/views/admin/dashboards/show.html.erb @@ -3,9 +3,3 @@ <%= turbo_stream_from "admin_dashboard" %> <%= render "counts", total_users: @total_users, users_by_role: @users_by_role %> - -
- <%= link_to "Manage users", admin_users_path, class: "text-blue-600 underline hover:no-underline" %> - <%= link_to "Spreadsheet imports", admin_spreadsheet_imports_path, class: "text-blue-600 underline hover:no-underline" %> - <%= button_to "Sign out", session_path, method: :delete, class: "text-gray-700 underline hover:no-underline bg-transparent p-0 cursor-pointer" %> -
diff --git a/app/views/layouts/_nav.html.erb b/app/views/layouts/_nav.html.erb new file mode 100644 index 000000000..66e6cbf24 --- /dev/null +++ b/app/views/layouts/_nav.html.erb @@ -0,0 +1,25 @@ +
+
+
+ <%= link_to "Fullstack Developer", Current.user.admin? ? admin_dashboard_path : profile_path, class: "font-bold text-gray-900" %> + + +
+ + +
+
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index a8519f427..f10bf671c 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -24,7 +24,9 @@ -
+ <%= render "layouts/nav" if Current.user %> + +
<%= render "layouts/flash" %> <%= yield %> diff --git a/app/views/profiles/show.html.erb b/app/views/profiles/show.html.erb index eea495473..e188879af 100644 --- a/app/views/profiles/show.html.erb +++ b/app/views/profiles/show.html.erb @@ -15,8 +15,7 @@
<%= @user.role.humanize %>
-
- <%= link_to "Edit profile", edit_profile_path, class: "text-blue-600 underline hover:no-underline" %> - <%= button_to "Delete account", profile_path, method: :delete, class: "text-red-600 underline hover:no-underline bg-transparent p-0 cursor-pointer", form: { data: { turbo_confirm: "Are you sure? This cannot be undone." } } %> - <%= button_to "Sign out", session_path, method: :delete, class: "text-gray-700 underline hover:no-underline bg-transparent p-0 cursor-pointer" %> +
+ <%= link_to "Edit profile", edit_profile_path, class: "link-action" %> + <%= button_to "Delete account", profile_path, method: :delete, class: "link-danger btn-link", form: { data: { turbo_confirm: "Are you sure? This cannot be undone." } } %>
From c6a43b4dc8363224e0257acbb3e5078ad64b2ed5 Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Thu, 3 Sep 2026 14:58:13 -0300 Subject: [PATCH 25/68] refactor: extract shared Tailwind component classes and a form-errors partial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same input/button/link class string was repeated verbatim in roughly twenty places across every form and table view, and the error-list markup was copy-pasted in four forms — real duplication, not a hypothetical one. Extracted into @layer components in application.tailwind.css (form-input, form-label, form-file, btn-primary, btn-link, link-action/-danger/-muted) and shared/_form_errors.html.erb. form-input also bakes in a :user-invalid red border for interactive validation feedback, degrading gracefully on browsers without support. Also wraps the users/spreadsheet-imports/row-errors tables in overflow-x- auto and lets action-row headers wrap (flex-wrap) so they don't break on narrow viewports. --- app/assets/tailwind/application.css | 35 ++++++++++ .../spreadsheet_imports/_progress.html.erb | 32 +++++----- .../admin/spreadsheet_imports/index.html.erb | 52 +++++++-------- .../admin/spreadsheet_imports/new.html.erb | 16 ++--- .../admin/spreadsheet_imports/show.html.erb | 2 +- app/views/admin/users/edit.html.erb | 2 +- app/views/admin/users/index.html.erb | 64 ++++++++++--------- app/views/admin/users/new.html.erb | 2 +- app/views/passwords/new.html.erb | 4 +- app/views/sessions/new.html.erb | 10 +-- app/views/shared/_form_errors.html.erb | 9 +++ 11 files changed, 135 insertions(+), 93 deletions(-) create mode 100644 app/views/shared/_form_errors.html.erb diff --git a/app/assets/tailwind/application.css b/app/assets/tailwind/application.css index f1d8c73cd..213a3f0ef 100644 --- a/app/assets/tailwind/application.css +++ b/app/assets/tailwind/application.css @@ -1 +1,36 @@ @import "tailwindcss"; + +@layer components { + .form-label { + @apply block font-medium; + } + + .form-input { + @apply mt-2 block w-full rounded-md border border-gray-400 px-3 py-2 shadow-sm + focus:outline-blue-600 [&:user-invalid]:border-red-500 [&:user-invalid]:focus:outline-red-600; + } + + .form-file { + @apply mt-2 block w-full; + } + + .btn-primary { + @apply inline-block rounded-md bg-blue-600 px-3.5 py-2.5 font-medium text-white hover:bg-blue-500; + } + + .btn-link { + @apply cursor-pointer bg-transparent p-0; + } + + .link-action { + @apply text-blue-600 underline hover:no-underline; + } + + .link-danger { + @apply text-red-600 underline hover:no-underline; + } + + .link-muted { + @apply text-gray-700 underline hover:no-underline; + } +} diff --git a/app/views/admin/spreadsheet_imports/_progress.html.erb b/app/views/admin/spreadsheet_imports/_progress.html.erb index b9ab29a36..5477c6051 100644 --- a/app/views/admin/spreadsheet_imports/_progress.html.erb +++ b/app/views/admin/spreadsheet_imports/_progress.html.erb @@ -8,21 +8,23 @@ <% if spreadsheet_import.spreadsheet_import_row_errors.any? %>

Row errors

- - - - - - - - - <% spreadsheet_import.spreadsheet_import_row_errors.order(:row_number).each do |row_error| %> - - - +
+
RowReason
<%= row_error.row_number %><%= row_error.message %>
+ + + + - <% end %> - -
RowReason
+ + + <% spreadsheet_import.spreadsheet_import_row_errors.order(:row_number).each do |row_error| %> + + <%= row_error.row_number %> + <%= row_error.message %> + + <% end %> + + +
<% end %>
diff --git a/app/views/admin/spreadsheet_imports/index.html.erb b/app/views/admin/spreadsheet_imports/index.html.erb index 3bc61019b..51da66670 100644 --- a/app/views/admin/spreadsheet_imports/index.html.erb +++ b/app/views/admin/spreadsheet_imports/index.html.erb @@ -1,29 +1,31 @@ -
+

Spreadsheet Imports

- <%= link_to "New import", new_admin_spreadsheet_import_path, class: "rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white font-medium" %> + <%= link_to "New import", new_admin_spreadsheet_import_path, class: "btn-primary" %>
- - - - - - - - - - - - - <% @spreadsheet_imports.each do |spreadsheet_import| %> - - - - - - - +
+
FileUploaded byStatusProgressErrors
<%= spreadsheet_import.file.filename %><%= spreadsheet_import.user.full_name %><%= spreadsheet_import.status.humanize %><%= spreadsheet_import.processed_rows %>/<%= spreadsheet_import.total_rows %><%= spreadsheet_import.spreadsheet_import_row_errors.count %><%= link_to "View", admin_spreadsheet_import_path(spreadsheet_import), class: "text-blue-600 underline hover:no-underline" %>
+ + + + + + + + - <% end %> - -
FileUploaded byStatusProgressErrors
+ + + <% @spreadsheet_imports.each do |spreadsheet_import| %> + + <%= spreadsheet_import.file.filename %> + <%= spreadsheet_import.user.full_name %> + <%= spreadsheet_import.status.humanize %> + <%= spreadsheet_import.processed_rows %>/<%= spreadsheet_import.total_rows %> + <%= spreadsheet_import.spreadsheet_import_row_errors.count %> + <%= link_to "View", admin_spreadsheet_import_path(spreadsheet_import), class: "link-action" %> + + <% end %> + + +
diff --git a/app/views/admin/spreadsheet_imports/new.html.erb b/app/views/admin/spreadsheet_imports/new.html.erb index 8477f4f61..68854594f 100644 --- a/app/views/admin/spreadsheet_imports/new.html.erb +++ b/app/views/admin/spreadsheet_imports/new.html.erb @@ -1,23 +1,15 @@

New Spreadsheet Import

<%= form_with model: [ :admin, @spreadsheet_import ], class: "contents" do |form| %> - <% if @spreadsheet_import.errors.any? %> -
-
    - <% @spreadsheet_import.errors.full_messages.each do |message| %> -
  • <%= message %>
  • - <% end %> -
-
- <% end %> + <%= render "shared/form_errors", record: @spreadsheet_import %>
- <%= form.label :file, "Spreadsheet (CSV or XLSX)", class: "block font-medium" %> + <%= form.label :file, "Spreadsheet (CSV or XLSX)", class: "form-label" %>

Expected columns: email, full_name.

- <%= form.file_field :file, accept: ".csv,.xlsx", required: true, class: "block mt-2 w-full" %> + <%= form.file_field :file, accept: ".csv,.xlsx", required: true, class: "form-file" %>
- <%= form.submit "Upload", 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 "Upload", class: "btn-primary w-full text-center cursor-pointer sm:w-auto" %>
<% end %> diff --git a/app/views/admin/spreadsheet_imports/show.html.erb b/app/views/admin/spreadsheet_imports/show.html.erb index f581c1b38..b385ece40 100644 --- a/app/views/admin/spreadsheet_imports/show.html.erb +++ b/app/views/admin/spreadsheet_imports/show.html.erb @@ -5,5 +5,5 @@ <%= render "progress", spreadsheet_import: @spreadsheet_import %>
- <%= link_to "Back to imports", admin_spreadsheet_imports_path, class: "text-blue-600 underline hover:no-underline" %> + <%= link_to "Back to imports", admin_spreadsheet_imports_path, class: "link-action" %>
diff --git a/app/views/admin/users/edit.html.erb b/app/views/admin/users/edit.html.erb index 44c9e66cd..29e9e6fb7 100644 --- a/app/views/admin/users/edit.html.erb +++ b/app/views/admin/users/edit.html.erb @@ -3,5 +3,5 @@ <%= render "form", user: @user %>
- <%= link_to "Back to users", admin_users_path, class: "text-gray-700 underline hover:no-underline" %> + <%= link_to "Back to users", admin_users_path, class: "link-muted" %>
diff --git a/app/views/admin/users/index.html.erb b/app/views/admin/users/index.html.erb index b93e5ae7d..a465286a6 100644 --- a/app/views/admin/users/index.html.erb +++ b/app/views/admin/users/index.html.erb @@ -1,35 +1,37 @@ -
+

Users

- <%= link_to "New user", new_admin_user_path, class: "rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white font-medium" %> + <%= link_to "New user", new_admin_user_path, class: "btn-primary" %>
- - - - - - - - - - - - <% @users.each do |user| %> - - - - - - +
+
AvatarFull nameEmailRoleActions
- <% if user.avatar.attached? %> - <%= image_tag user.avatar.variant(resize_to_limit: [ 40, 40 ]), class: "size-10 rounded-full object-cover" %> - <% end %> - <%= user.full_name %><%= user.email %><%= user.role.humanize %> - <%= link_to "Edit", edit_admin_user_path(user), class: "text-blue-600 underline hover:no-underline" %> - <%= button_to "Toggle role", toggle_role_admin_user_path(user), method: :patch, class: "text-blue-600 underline hover:no-underline bg-transparent p-0 cursor-pointer" %> - <%= button_to "Delete", admin_user_path(user), method: :delete, class: "text-red-600 underline hover:no-underline bg-transparent p-0 cursor-pointer", form: { data: { turbo_confirm: "Are you sure?" } } %> -
+ + + + + + + - <% end %> - -
AvatarFull nameEmailRoleActions
+ + + <% @users.each do |user| %> + + + <% if user.avatar.attached? %> + <%= image_tag user.avatar.variant(resize_to_limit: [ 40, 40 ]), class: "size-10 rounded-full object-cover" %> + <% end %> + + <%= user.full_name %> + <%= user.email %> + <%= user.role.humanize %> + + <%= link_to "Edit", edit_admin_user_path(user), class: "link-action" %> + <%= button_to "Toggle role", toggle_role_admin_user_path(user), method: :patch, class: "link-action btn-link" %> + <%= button_to "Delete", admin_user_path(user), method: :delete, class: "link-danger btn-link", form: { data: { turbo_confirm: "Are you sure?" } } %> + + + <% end %> + + +
diff --git a/app/views/admin/users/new.html.erb b/app/views/admin/users/new.html.erb index 4b76a063b..7c2e49d0b 100644 --- a/app/views/admin/users/new.html.erb +++ b/app/views/admin/users/new.html.erb @@ -3,5 +3,5 @@ <%= render "form", user: @user %>
- <%= link_to "Back to users", admin_users_path, class: "text-gray-700 underline hover:no-underline" %> + <%= link_to "Back to users", admin_users_path, class: "link-muted" %>
diff --git a/app/views/passwords/new.html.erb b/app/views/passwords/new.html.erb index 5d4a86142..4fc0bdc0f 100644 --- a/app/views/passwords/new.html.erb +++ b/app/views/passwords/new.html.erb @@ -2,10 +2,10 @@ <%= form_with url: passwords_path, class: "contents" do |form| %>
- <%= form.email_field :email, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email], 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.email_field :email, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email], class: "form-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 "Email reset instructions", class: "btn-primary w-full text-center cursor-pointer sm:w-auto" %>
<% end %> diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index 119091406..2226fa0ee 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -2,22 +2,22 @@ <%= form_with url: session_url, class: "contents" do |form| %>
- <%= form.email_field :email, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email], class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> + <%= form.email_field :email, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email], class: "form-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.password_field :password, required: true, autocomplete: "current-password", placeholder: "Enter your password", maxlength: 72, class: "form-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" %> + <%= form.submit "Sign in", class: "btn-primary w-full text-center cursor-pointer sm:w-auto" %>
- <%= link_to "Forgot password?", new_password_path, class: "text-gray-700 underline hover:no-underline" %> + <%= link_to "Forgot password?", new_password_path, class: "link-muted" %> · - <%= link_to "Create an account", new_registration_path, class: "text-gray-700 underline hover:no-underline" %> + <%= link_to "Create an account", new_registration_path, class: "link-muted" %>
<% 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..50adb7677 --- /dev/null +++ b/app/views/shared/_form_errors.html.erb @@ -0,0 +1,9 @@ +<% if record.errors.any? %> +
+
    + <% record.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+<% end %> From 0d212307166ab48711a6cea0b11a47021add9483 Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Thu, 3 Sep 2026 14:58:23 -0300 Subject: [PATCH 26/68] feat: add real-time password confirmation matching and a minimum password length HTML5 required/type/minlength already gave interactive feedback, but cross-field validation (does the confirmation match the password?) has no native equivalent. password_confirmation_controller.js compares the two fields on input and reports the mismatch via setCustomValidity, reusing the same :user-invalid styling. Backend gains a matching User#password minimum length of 8 (previously only presence was enforced by has_secure_password), keeping frontend and backend validation in sync per the test's requirement. --- .../password_confirmation_controller.js | 12 +++++ app/models/user.rb | 1 + app/views/admin/users/_form.html.erb | 48 ++++++++----------- app/views/passwords/edit.html.erb | 14 +++--- app/views/profiles/edit.html.erb | 46 ++++++++---------- app/views/registrations/new.html.erb | 30 +++++------- spec/models/user_spec.rb | 7 +++ 7 files changed, 81 insertions(+), 77 deletions(-) create mode 100644 app/javascript/controllers/password_confirmation_controller.js diff --git a/app/javascript/controllers/password_confirmation_controller.js b/app/javascript/controllers/password_confirmation_controller.js new file mode 100644 index 000000000..decb07c3b --- /dev/null +++ b/app/javascript/controllers/password_confirmation_controller.js @@ -0,0 +1,12 @@ +import { Controller } from "@hotwired/stimulus" + +// Gives immediate feedback when the confirmation field doesn't match the +// password field yet, since HTML5 has no built-in cross-field validation. +export default class extends Controller { + static targets = ["password", "confirmation"] + + validate() { + const mismatch = this.confirmationTarget.value.length > 0 && this.confirmationTarget.value !== this.passwordTarget.value + this.confirmationTarget.setCustomValidity(mismatch ? "Passwords don't match" : "") + } +} diff --git a/app/models/user.rb b/app/models/user.rb index 2c46a5598..aad4d4853 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -14,6 +14,7 @@ class User < ApplicationRecord validates :full_name, presence: true validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP } + validates :password, length: { minimum: 8 }, allow_blank: true validate :avatar_must_be_a_supported_image, if: -> { avatar.attached? } validate :avatar_url_must_be_http, if: -> { avatar_url.present? } diff --git a/app/views/admin/users/_form.html.erb b/app/views/admin/users/_form.html.erb index f86cb569c..f5ff8acb8 100644 --- a/app/views/admin/users/_form.html.erb +++ b/app/views/admin/users/_form.html.erb @@ -1,50 +1,44 @@ <%= form_with model: [ :admin, user ], class: "contents" do |form| %> - <% if user.errors.any? %> -
-
    - <% user.errors.full_messages.each do |message| %> -
  • <%= message %>
  • - <% end %> -
-
- <% end %> + <%= render "shared/form_errors", record: user %>
- <%= form.label :full_name, class: "block font-medium" %> - <%= form.text_field :full_name, required: true, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> + <%= form.label :full_name, class: "form-label" %> + <%= form.text_field :full_name, required: true, class: "form-input" %>
- <%= form.label :email, class: "block font-medium" %> - <%= form.email_field :email, required: true, autocomplete: "username", class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> + <%= form.label :email, class: "form-label" %> + <%= form.email_field :email, required: true, autocomplete: "username", class: "form-input" %>
- <%= form.label :role, class: "block font-medium" %> - <%= form.select :role, User.roles.keys.map { |role| [ role.humanize, role ] }, {}, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> + <%= form.label :role, class: "form-label" %> + <%= form.select :role, User.roles.keys.map { |role| [ role.humanize, role ] }, {}, class: "form-input" %>
-
- <%= form.label :password, (user.new_record? ? "Password" : "New password"), class: "block font-medium" %> - <%= form.password_field :password, required: user.new_record?, autocomplete: "new-password", placeholder: user.new_record? ? nil : "Leave blank to keep the current 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, (user.new_record? ? "Password" : "New password"), class: "form-label" %> + <%= form.password_field :password, required: user.new_record?, autocomplete: "new-password", placeholder: user.new_record? ? nil : "Leave blank to keep the current password", minlength: 8, maxlength: 72, class: "form-input", data: { password_confirmation_target: "password", action: "input->password-confirmation#validate" } %> +
-
- <%= form.label :password_confirmation, class: "block font-medium" %> - <%= form.password_field :password_confirmation, required: user.new_record?, autocomplete: "new-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_confirmation, class: "form-label" %> + <%= form.password_field :password_confirmation, required: user.new_record?, autocomplete: "new-password", minlength: 8, maxlength: 72, class: "form-input", data: { password_confirmation_target: "confirmation", action: "input->password-confirmation#validate" } %> +
- <%= form.label :avatar, "Avatar image", class: "block font-medium" %> - <%= form.file_field :avatar, accept: "image/png,image/jpeg,image/webp", class: "block mt-2 w-full" %> + <%= form.label :avatar, "Avatar image", class: "form-label" %> + <%= form.file_field :avatar, accept: "image/png,image/jpeg,image/webp", class: "form-file" %>
- <%= form.label :avatar_url, "…or avatar image URL", class: "block font-medium" %> - <%= form.url_field :avatar_url, placeholder: "https://example.com/avatar.png", class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> + <%= form.label :avatar_url, "…or avatar image URL", class: "form-label" %> + <%= form.url_field :avatar_url, placeholder: "https://example.com/avatar.png", class: "form-input" %>
- <%= form.submit 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 class: "btn-primary w-full text-center cursor-pointer sm:w-auto" %>
<% end %> diff --git a/app/views/passwords/edit.html.erb b/app/views/passwords/edit.html.erb index 3aecf7993..a68ffb994 100644 --- a/app/views/passwords/edit.html.erb +++ b/app/views/passwords/edit.html.erb @@ -1,15 +1,17 @@

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, required: true, autocomplete: "new-password", placeholder: "Enter new password", minlength: 8, maxlength: 72, class: "form-input", data: { password_confirmation_target: "password", action: "input->password-confirmation#validate" } %> +
-
- <%= 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.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Repeat new password", minlength: 8, maxlength: 72, class: "form-input", data: { password_confirmation_target: "confirmation", action: "input->password-confirmation#validate" } %> +
- <%= 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 "Save", class: "btn-primary w-full text-center cursor-pointer sm:w-auto" %>
<% end %> diff --git a/app/views/profiles/edit.html.erb b/app/views/profiles/edit.html.erb index d884f2c35..518d9ce08 100644 --- a/app/views/profiles/edit.html.erb +++ b/app/views/profiles/edit.html.erb @@ -1,51 +1,45 @@

Edit Profile

<%= form_with model: @user, url: profile_path, class: "contents" do |form| %> - <% if @user.errors.any? %> -
-
    - <% @user.errors.full_messages.each do |message| %> -
  • <%= message %>
  • - <% end %> -
-
- <% end %> + <%= render "shared/form_errors", record: @user %>
- <%= form.label :full_name, class: "block font-medium" %> - <%= form.text_field :full_name, required: true, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> + <%= form.label :full_name, class: "form-label" %> + <%= form.text_field :full_name, required: true, class: "form-input" %>
- <%= form.label :email, class: "block font-medium" %> - <%= form.email_field :email, required: true, autocomplete: "username", class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> + <%= form.label :email, class: "form-label" %> + <%= form.email_field :email, required: true, autocomplete: "username", class: "form-input" %>
-
- <%= form.label :password, "New password", class: "block font-medium" %> - <%= form.password_field :password, autocomplete: "new-password", placeholder: "Leave blank to keep the current 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, "New password", class: "form-label" %> + <%= form.password_field :password, autocomplete: "new-password", placeholder: "Leave blank to keep the current password", minlength: 8, maxlength: 72, class: "form-input", data: { password_confirmation_target: "password", action: "input->password-confirmation#validate" } %> +
-
- <%= form.label :password_confirmation, class: "block font-medium" %> - <%= form.password_field :password_confirmation, autocomplete: "new-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_confirmation, class: "form-label" %> + <%= form.password_field :password_confirmation, autocomplete: "new-password", minlength: 8, maxlength: 72, class: "form-input", data: { password_confirmation_target: "confirmation", action: "input->password-confirmation#validate" } %> +
- <%= form.label :avatar, "Avatar image", class: "block font-medium" %> - <%= form.file_field :avatar, accept: "image/png,image/jpeg,image/webp", class: "block mt-2 w-full" %> + <%= form.label :avatar, "Avatar image", class: "form-label" %> + <%= form.file_field :avatar, accept: "image/png,image/jpeg,image/webp", class: "form-file" %>
- <%= form.label :avatar_url, "…or avatar image URL", class: "block font-medium" %> - <%= form.url_field :avatar_url, placeholder: "https://example.com/avatar.png", class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> + <%= form.label :avatar_url, "…or avatar image URL", class: "form-label" %> + <%= form.url_field :avatar_url, placeholder: "https://example.com/avatar.png", class: "form-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 "Save", class: "btn-primary w-full text-center cursor-pointer sm:w-auto" %>
<% end %>
- <%= link_to "Back to profile", profile_path, class: "text-gray-700 underline hover:no-underline" %> + <%= link_to "Back to profile", profile_path, class: "link-muted" %>
diff --git a/app/views/registrations/new.html.erb b/app/views/registrations/new.html.erb index 419b631b8..5c212ff70 100644 --- a/app/views/registrations/new.html.erb +++ b/app/views/registrations/new.html.erb @@ -1,39 +1,33 @@

Create your account

<%= form_with model: @user, url: registration_path, class: "contents" do |form| %> - <% if @user.errors.any? %> -
-
    - <% @user.errors.full_messages.each do |message| %> -
  • <%= message %>
  • - <% end %> -
-
- <% end %> + <%= render "shared/form_errors", record: @user %>
- <%= form.text_field :full_name, required: true, autofocus: true, placeholder: "Full name", class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> + <%= form.text_field :full_name, required: true, autofocus: true, placeholder: "Full name", class: "form-input" %>
- <%= form.email_field :email, required: true, autocomplete: "username", placeholder: "Enter your 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.email_field :email, required: true, autocomplete: "username", placeholder: "Enter your email address", class: "form-input" %>
-
- <%= form.password_field :password, required: true, autocomplete: "new-password", placeholder: "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.password_field :password, required: true, autocomplete: "new-password", placeholder: "Password", minlength: 8, maxlength: 72, class: "form-input", data: { password_confirmation_target: "password", action: "input->password-confirmation#validate" } %> +
-
- <%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Confirm 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.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Confirm password", minlength: 8, maxlength: 72, class: "form-input", data: { password_confirmation_target: "confirmation", action: "input->password-confirmation#validate" } %> +
- <%= form.submit "Sign up", 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 "Sign up", class: "btn-primary w-full text-center cursor-pointer sm:w-auto" %>
- <%= link_to "Already have an account? Sign in", new_session_path, class: "text-gray-700 underline hover:no-underline" %> + <%= link_to "Already have an account? Sign in", new_session_path, class: "link-muted" %>
<% end %> diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 8dc17c399..fd8a5d03b 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -15,6 +15,13 @@ expect(user).not_to be_valid expect(user.errors[:email]).to be_present end + + it "rejects a password shorter than 8 characters" do + user = build(:user, password: "short1", password_confirmation: "short1") + + expect(user).not_to be_valid + expect(user.errors[:password]).to be_present + end end describe "email normalization" do From 211dabe7ef72aca455205981c4afd55c49551ae9 Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Thu, 3 Sep 2026 14:58:29 -0300 Subject: [PATCH 27/68] test: cover responsive navigation across two viewport sizes System spec resizing the real Playwright window to a desktop and a mobile viewport, checking the nav links render inline above the sm breakpoint and collapse behind the "Menu" toggle below it, then exercising the toggle end to end (open menu, follow a link). --- spec/system/responsive_navigation_spec.rb | 31 +++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 spec/system/responsive_navigation_spec.rb diff --git a/spec/system/responsive_navigation_spec.rb b/spec/system/responsive_navigation_spec.rb new file mode 100644 index 000000000..dcc88f4cb --- /dev/null +++ b/spec/system/responsive_navigation_spec.rb @@ -0,0 +1,31 @@ +require "rails_helper" + +RSpec.describe "Responsive navigation", type: :system do + after { Capybara.current_window.resize_to(1280, 800) } + + it "shows the nav links inline on a desktop viewport" do + Capybara.current_window.resize_to(1280, 800) + user = create(:user, password: "password123") + + sign_in_via_ui(user) + + expect(page).to have_link("My Profile", visible: true) + expect(page).not_to have_button("Menu", visible: true) + end + + it "collapses the nav behind a toggle button on a mobile viewport" do + Capybara.current_window.resize_to(375, 667) + admin = create(:user, :admin, password: "password123") + + sign_in_via_ui(admin) + + expect(page).to have_button("Menu", visible: true) + expect(page).to have_link("Manage users", visible: :hidden) + + click_button "Menu" + click_link "Manage users" + + expect(page).to have_content("Users") + expect(page).to have_link("New user") + end +end From da83b1b8979fcae6a98e31c56cc285b0ffce793f Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Thu, 3 Sep 2026 15:07:12 -0300 Subject: [PATCH 28/68] test: cover SQL injection, XSS escaping and CSRF protection Phase 7 security hardening review found no vulnerabilities in the existing code (Brakeman stayed clean, strong params/escaping/CSRF already sound), so this adds request specs that pin down the traditional vectors the README explicitly calls out for assessment: a crafted email cannot bypass authentication or leak records through ActiveRecord's parameterized finder, a malicious full_name renders escaped on the profile and admin users pages, and a state-changing request without a valid authenticity token is rejected with the forgery-protection guard re-enabled just for that example (test env disables it globally so request specs can post freely). --- spec/requests/security_spec.rb | 72 ++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 spec/requests/security_spec.rb diff --git a/spec/requests/security_spec.rb b/spec/requests/security_spec.rb new file mode 100644 index 000000000..3233843e7 --- /dev/null +++ b/spec/requests/security_spec.rb @@ -0,0 +1,72 @@ +require "rails_helper" + +RSpec.describe "Security", type: :request do + describe "SQL injection" do + it "does not let a crafted email bypass authentication" do + create(:user, email: "victim@example.com", password: "password123") + + post session_path, params: { email: "' OR '1'='1", password: "anything" } + + expect(response).to redirect_to(new_session_path) + expect(flash[:alert]).to be_present + end + + it "treats a crafted email as a literal, parameterized value with no match" do + create(:user, email: "victim@example.com") + + expect(User.find_by(email: "' OR '1'='1")).to be_nil + end + end + + describe "reflected/stored XSS" do + it "escapes a malicious full_name when rendering the profile page" do + payload = "" + user = create(:user, full_name: payload, password: "password123") + sign_in_as(user) + + get profile_path + + expect(response.body).not_to include(payload) + expect(response.body).to include(CGI.escapeHTML(payload)) + end + + it "escapes a malicious full_name when rendering the admin users list" do + payload = "" + admin = create(:user, :admin, password: "password123") + create(:user, full_name: payload) + sign_in_as(admin) + + get admin_users_path + + expect(response.body).not_to include(payload) + expect(response.body).to include(CGI.escapeHTML(payload)) + end + end + + describe "CSRF protection" do + around do |example| + original = ActionController::Base.allow_forgery_protection + ActionController::Base.allow_forgery_protection = true + begin + example.run + ensure + ActionController::Base.allow_forgery_protection = original + end + end + + it "rejects a state-changing request without a valid authenticity token" do + expect { + post registration_path, params: { + user: { + full_name: "Attacker", + email: "attacker@example.com", + password: "password123", + password_confirmation: "password123" + } + } + }.not_to change(User, :count) + + expect(response).to have_http_status(:unprocessable_entity) + end + end +end From e725f062ee48a00bb461adb2aba08b32a4d21d89 Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Thu, 3 Sep 2026 15:24:05 -0300 Subject: [PATCH 29/68] chore: replace deploy.yml scaffold placeholders with a real Kamal 2 config servers.web and registry.server still had rails new's literal scaffold values (192.168.0.1, localhost:5555), which don't point anywhere real and would need a throwaway local registry container just to inspect. Read the deploy host and registry credentials from ENV instead (deploy.yml is ERB before YAML), falling back to an RFC 5737 TEST-NET-3 address that can never resolve, so a deploy run without KAMAL_WEB_HOST set fails fast rather than silently targeting the wrong host. Registry moved to ghcr.io, which needs no extra infrastructure to try. --- .kamal/secrets | 5 +++-- config/deploy.yml | 28 +++++++++++++++++----------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/.kamal/secrets b/.kamal/secrets index b3089d6f5..2769339e3 100644 --- a/.kamal/secrets +++ b/.kamal/secrets @@ -13,8 +13,9 @@ # Use a GITHUB_TOKEN if private repositories are needed for the image # GITHUB_TOKEN=$(gh config get -h github.com oauth_token) -# Grab the registry password from ENV -# KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD +# Grab the registry password from ENV (a GitHub personal access token with `write:packages` +# scope, for the ghcr.io registry configured in config/deploy.yml) +KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD # Improve security by using a password manager. Never check config/master.key into git! RAILS_MASTER_KEY=$(cat config/master.key) diff --git a/config/deploy.yml b/config/deploy.yml index 77ec47a90..d5eeefdac 100644 --- a/config/deploy.yml +++ b/config/deploy.yml @@ -1,16 +1,22 @@ # Name of your application. Used to uniquely configure containers. service: fullstack_developer -# Name of the container image (use your-user/app-name on external registries). -image: fullstack_developer +# Name of the container image. GHCR (see registry below) expects the / form. +image: <%= ENV.fetch("KAMAL_REGISTRY_USERNAME", "your-github-username") %>/fullstack_developer # Deploy to these servers. +# +# deploy.yml is parsed as ERB before YAML, so the actual host is read from an env var at deploy +# time (`KAMAL_WEB_HOST= bin/kamal deploy`) instead of being hardcoded here. +# The fallback below (203.0.113.10) is a TEST-NET-3 address reserved for documentation by RFC 5737 — +# it deliberately can't resolve to a real host, so a deploy attempted without setting KAMAL_WEB_HOST +# fails fast instead of silently targeting someone else's machine. servers: web: - - 192.168.0.1 + - <%= ENV.fetch("KAMAL_WEB_HOST", "203.0.113.10") %> # job: # hosts: - # - 192.168.0.1 + # - <%= ENV.fetch("KAMAL_WEB_HOST", "203.0.113.10") %> # cmd: bin/jobs # Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server. @@ -25,16 +31,16 @@ servers: # host: app.example.com # Where you keep your container images. +# +# ghcr.io needs no extra infrastructure (unlike e.g. a self-hosted registry on localhost:5555) and +# authenticates with a GitHub personal access token, kept out of this file via .kamal/secrets. registry: - # Alternatives: hub.docker.com / registry.digitalocean.com / ghcr.io / ... - server: localhost:5555 - - # Needed for authenticated registries. - # username: your-user + server: ghcr.io + username: <%= ENV.fetch("KAMAL_REGISTRY_USERNAME", "your-github-username") %> # Always use an access token rather than real password when possible. - # password: - # - KAMAL_REGISTRY_PASSWORD + password: + - KAMAL_REGISTRY_PASSWORD # Inject ENV variables into containers (secrets come from .kamal/secrets). env: From b297df53c487371a9186fac476cf63db76eb2a96 Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Thu, 3 Sep 2026 15:24:11 -0300 Subject: [PATCH 30/68] perf: enable Ruby 4's ZJIT in production and disable Rails' default YJIT Sets RUBYOPT="--zjit" in the production image (safe no-op with just a startup warning on a Ruby build without ZJIT support). Rails 8.1's load_defaults already auto-enables YJIT in production (config.yjit = !Rails.env.local?), and only one JIT can be active per process - leaving both on printed "Only one JIT can be enabled at the same time." on every boot and silently dropped the Rails-side enable. Disabling config.yjit in production.rb makes ZJIT the one actually running, confirmed via RubyVM::YJIT.enabled?/ZJIT.enabled? inside a built container with no conflict warning. --- Dockerfile | 5 ++++- config/environments/production.rb | 6 ++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 1cdda93e5..8666a46b0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,11 +21,14 @@ RUN apt-get update -qq && \ rm -rf /var/lib/apt/lists /var/cache/apt/archives # Set production environment variables and enable jemalloc for reduced memory usage and latency. +# RUBYOPT enables Ruby 4's ZJIT for extra runtime performance; it's a silent no-op (just a startup +# warning) on any Ruby build without ZJIT support, so it's safe to leave on unconditionally. ENV RAILS_ENV="production" \ BUNDLE_DEPLOYMENT="1" \ BUNDLE_PATH="/usr/local/bundle" \ BUNDLE_WITHOUT="development" \ - LD_PRELOAD="/usr/local/lib/libjemalloc.so" + LD_PRELOAD="/usr/local/lib/libjemalloc.so" \ + RUBYOPT="--zjit" # Throw-away build stage to reduce size of final image FROM base AS build diff --git a/config/environments/production.rb b/config/environments/production.rb index f5763e04e..f82ade1bc 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -9,6 +9,12 @@ # Eager load code on boot for better performance and memory savings (ignored by Rake tasks). config.eager_load = true + # Rails 8.1's default (`self.yjit = !Rails.env.local?`, set in config/application.rb) auto-enables + # YJIT in production. This container instead enables Ruby 4's newer ZJIT via RUBYOPT (Dockerfile) - + # only one JIT can be active per process, and leaving both on prints a boot-time conflict warning + # and silently drops the Rails-side enable. Disabled here so ZJIT is the one actually running. + config.yjit = false + # Full error reports are disabled. config.consider_all_requests_local = false From 8773426d5a7bf2d3c3bba6e0e338689fc474b389 Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Thu, 3 Sep 2026 16:25:25 -0300 Subject: [PATCH 31/68] feat: seed a bootstrap admin user --- db/seeds.rb | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/db/seeds.rb b/db/seeds.rb index 4fbd6ed97..6f26ad847 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,9 +1,12 @@ # 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 + +# Registration always forces role: no_admin (see RegistrationsController), so there is no way to +# reach an admin account from the UI alone. Seed one bootstrap admin so the app is usable right +# after setup; override the credentials via ENV in any shared/production environment. +User.find_or_create_by!(email: ENV.fetch("SEED_ADMIN_EMAIL", "admin@example.com")) do |user| + user.full_name = "Admin" + user.password = ENV.fetch("SEED_ADMIN_PASSWORD", "password123") + user.role = :admin +end From 25c2e61387596f31979b85ba2f49819517aa94a0 Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Thu, 3 Sep 2026 16:25:47 -0300 Subject: [PATCH 32/68] docs: write final submission README with AI Usage Disclosure --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 7829f14ff..609e865b9 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # Modern Fullstack Developer Test (Rails 8 / Ruby 4) +## AI Usage Disclosure +This project was developed with the assistance of AI coding assistants: +- Claude Sonnet 5 (Phases 0-4) +- Gemini 3.1 Pro (Phases 5-13) + - 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) From 15c1cd6409b3daada3aa3a85e1f3260ee87f11ee Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Thu, 3 Sep 2026 16:58:08 -0300 Subject: [PATCH 33/68] i18n: translate flash messages and redirects to pt-BR --- .../admin/spreadsheet_imports_controller.rb | 2 +- app/controllers/admin/users_controller.rb | 10 +++++----- app/controllers/application_controller.rb | 2 +- app/controllers/passwords_controller.rb | 10 +++++----- app/controllers/profiles_controller.rb | 4 ++-- app/controllers/registrations_controller.rb | 2 +- app/controllers/sessions_controller.rb | 4 ++-- 7 files changed, 17 insertions(+), 17 deletions(-) diff --git a/app/controllers/admin/spreadsheet_imports_controller.rb b/app/controllers/admin/spreadsheet_imports_controller.rb index 60c738256..7b483e6f7 100644 --- a/app/controllers/admin/spreadsheet_imports_controller.rb +++ b/app/controllers/admin/spreadsheet_imports_controller.rb @@ -19,7 +19,7 @@ def create authorize @spreadsheet_import if @spreadsheet_import.save - redirect_to admin_spreadsheet_import_path(@spreadsheet_import), notice: "Spreadsheet uploaded. Import is processing in the background." + redirect_to admin_spreadsheet_import_path(@spreadsheet_import), notice: "Planilha enviada. A importação está sendo processada em segundo plano." else render :new, status: :unprocessable_entity end diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index b3ce11afc..947d414f6 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -18,7 +18,7 @@ def create authorize @user if @user.save - redirect_to admin_users_path, notice: "User was successfully created." + redirect_to admin_users_path, notice: "Usuário criado com sucesso." else render :new, status: :unprocessable_entity end @@ -29,7 +29,7 @@ def edit def update if @user.update(user_params) - redirect_to admin_users_path, notice: "User was successfully updated." + redirect_to admin_users_path, notice: "Usuário atualizado com sucesso." else render :edit, status: :unprocessable_entity end @@ -37,15 +37,15 @@ def update def destroy @user.destroy - redirect_to admin_users_path, notice: "User was successfully deleted.", status: :see_other + redirect_to admin_users_path, notice: "Usuário excluído com sucesso.", status: :see_other end def toggle_role if @user == Current.user - redirect_to admin_users_path, alert: "You cannot change your own role." + redirect_to admin_users_path, alert: "Você não pode alterar seu próprio papel (role)." else @user.update!(role: @user.admin? ? :no_admin : :admin) - redirect_to admin_users_path, notice: "Role was successfully updated." + redirect_to admin_users_path, notice: "Papel atualizado com sucesso." end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index c7bc32431..1df1ac5ee 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -17,6 +17,6 @@ def pundit_user end def user_not_authorized - redirect_to profile_path, alert: "You are not authorized to perform this action." + redirect_to profile_path, alert: "Você não tem permissão para realizar esta ação." end end diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb index 9c1967678..f1418d8cf 100644 --- a/app/controllers/passwords_controller.rb +++ b/app/controllers/passwords_controller.rb @@ -2,7 +2,7 @@ class PasswordsController < ApplicationController allow_unauthenticated_access skip_after_action :verify_authorized before_action :set_user_by_token, only: %i[ edit update ] - rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." } + rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Tente novamente mais tarde." } def new end @@ -12,7 +12,7 @@ def create PasswordsMailer.reset(user).deliver_later end - redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)." + redirect_to new_session_path, notice: "Instruções de redefinição enviadas (caso o e-mail exista)." end def edit @@ -21,9 +21,9 @@ def edit def update if @user.update(params.permit(:password, :password_confirmation)) @user.sessions.destroy_all - redirect_to new_session_path, notice: "Password has been reset." + redirect_to new_session_path, notice: "Sua senha foi redefinida." else - redirect_to edit_password_path(params[:token]), alert: "Passwords did not match." + redirect_to edit_password_path(params[:token]), alert: "As senhas não coincidem." end end @@ -31,6 +31,6 @@ def update def set_user_by_token @user = User.find_by_password_reset_token!(params[:token]) rescue ActiveSupport::MessageVerifier::InvalidSignature - redirect_to new_password_path, alert: "Password reset link is invalid or has expired." + redirect_to new_password_path, alert: "O link de redefinição é inválido ou expirou." end end diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index 1f763c844..20636ecdb 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -9,7 +9,7 @@ def edit def update if @user.update(user_params) - redirect_to profile_path, notice: "Profile was successfully updated." + redirect_to profile_path, notice: "Perfil atualizado com sucesso." else render :edit, status: :unprocessable_entity end @@ -18,7 +18,7 @@ def update def destroy @user.destroy cookies.delete(:session_id) - redirect_to new_session_path, notice: "Your account has been deleted.", status: :see_other + redirect_to new_session_path, notice: "Sua conta foi excluída.", status: :see_other end private diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index c472ae5e3..d43ea9659 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -12,7 +12,7 @@ def create if @user.save start_new_session_for @user - redirect_to after_authentication_url, notice: "Welcome! Your account has been created." + redirect_to after_authentication_url, notice: "Bem-vindo! Sua conta foi criada." else render :new, status: :unprocessable_entity end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 9a5334872..0d8045cca 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,7 +1,7 @@ class SessionsController < ApplicationController allow_unauthenticated_access only: %i[ new create ] skip_after_action :verify_authorized - rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." } + rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Tente novamente mais tarde." } def new end @@ -11,7 +11,7 @@ def create start_new_session_for user redirect_to after_authentication_url else - redirect_to new_session_path, alert: "Try another email address or password." + redirect_to new_session_path, alert: "E-mail ou senha incorretos." end end From bcd8ece6480284089e0e12c122a4ff730676a1d6 Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Thu, 3 Sep 2026 16:58:15 -0300 Subject: [PATCH 34/68] i18n: translate UI views and layouts to pt-BR --- app/views/admin/dashboards/_counts.html.erb | 4 ++-- app/views/admin/dashboards/show.html.erb | 4 ++-- .../spreadsheet_imports/_progress.html.erb | 4 ++-- .../admin/spreadsheet_imports/index.html.erb | 14 ++++++------- .../admin/spreadsheet_imports/new.html.erb | 8 ++++---- .../admin/spreadsheet_imports/show.html.erb | 6 +++--- app/views/admin/users/_form.html.erb | 20 +++++++++---------- app/views/admin/users/edit.html.erb | 4 ++-- app/views/admin/users/index.html.erb | 20 +++++++++---------- app/views/admin/users/new.html.erb | 4 ++-- app/views/layouts/_nav.html.erb | 10 +++++----- app/views/layouts/mailer.html.erb | 2 +- app/views/passwords/edit.html.erb | 4 ++-- app/views/passwords/new.html.erb | 4 ++-- app/views/profiles/edit.html.erb | 20 +++++++++---------- app/views/profiles/show.html.erb | 14 ++++++------- app/views/registrations/new.html.erb | 14 ++++++------- app/views/sessions/new.html.erb | 12 +++++------ 18 files changed, 84 insertions(+), 84 deletions(-) diff --git a/app/views/admin/dashboards/_counts.html.erb b/app/views/admin/dashboards/_counts.html.erb index 8846f0761..ba29164bc 100644 --- a/app/views/admin/dashboards/_counts.html.erb +++ b/app/views/admin/dashboards/_counts.html.erb @@ -1,11 +1,11 @@
-

Total Users

+

Total de Usuários

<%= total_users %>

<% User.roles.keys.each do |role| %>
-

<%= role.humanize %>

+

<%= role == "admin" ? "Administrador" : "Usuário Normal" %>

<%= users_by_role[role] || 0 %>

<% end %> diff --git a/app/views/admin/dashboards/show.html.erb b/app/views/admin/dashboards/show.html.erb index 6117a0313..3fca1f32f 100644 --- a/app/views/admin/dashboards/show.html.erb +++ b/app/views/admin/dashboards/show.html.erb @@ -1,5 +1,5 @@ -

Admin Dashboard

-

Signed in as <%= Current.user.full_name %> (<%= Current.user.role %>).

+

Painel Administrativo

+

Conectado como <%= Current.user.full_name %> (<%= Current.user.role %>).

<%= turbo_stream_from "admin_dashboard" %> <%= render "counts", total_users: @total_users, users_by_role: @users_by_role %> diff --git a/app/views/admin/spreadsheet_imports/_progress.html.erb b/app/views/admin/spreadsheet_imports/_progress.html.erb index 5477c6051..7e5dcbf2d 100644 --- a/app/views/admin/spreadsheet_imports/_progress.html.erb +++ b/app/views/admin/spreadsheet_imports/_progress.html.erb @@ -1,10 +1,10 @@
-

Status: <%= spreadsheet_import.status.humanize %>

+

Status: <%= spreadsheet_import.status == "processing" ? "Processando" : spreadsheet_import.status == "completed" ? "Concluída" : spreadsheet_import.status == "failed" ? "Falhou" : "Pendente" %>

-

<%= spreadsheet_import.processed_rows %> / <%= spreadsheet_import.total_rows %> rows processed

+

<%= spreadsheet_import.processed_rows %> / <%= spreadsheet_import.total_rows %> linhas processadas

<% if spreadsheet_import.spreadsheet_import_row_errors.any? %>

Row errors

diff --git a/app/views/admin/spreadsheet_imports/index.html.erb b/app/views/admin/spreadsheet_imports/index.html.erb index 51da66670..1d50aad08 100644 --- a/app/views/admin/spreadsheet_imports/index.html.erb +++ b/app/views/admin/spreadsheet_imports/index.html.erb @@ -1,17 +1,17 @@
-

Spreadsheet Imports

- <%= link_to "New import", new_admin_spreadsheet_import_path, class: "btn-primary" %> +

Importações de Planilha

+ <%= link_to "Nova Importação", new_admin_spreadsheet_import_path, class: "btn-primary" %>
- - + + - - + + @@ -20,7 +20,7 @@ - + diff --git a/app/views/admin/spreadsheet_imports/new.html.erb b/app/views/admin/spreadsheet_imports/new.html.erb index 68854594f..ff3dbe2ce 100644 --- a/app/views/admin/spreadsheet_imports/new.html.erb +++ b/app/views/admin/spreadsheet_imports/new.html.erb @@ -1,15 +1,15 @@ -

New Spreadsheet Import

+

Nova Importação

<%= form_with model: [ :admin, @spreadsheet_import ], class: "contents" do |form| %> <%= render "shared/form_errors", record: @spreadsheet_import %>
- <%= form.label :file, "Spreadsheet (CSV or XLSX)", class: "form-label" %> -

Expected columns: email, full_name.

+ <%= form.label :file, "Planilha (CSV ou XLSX)", class: "form-label" %> +

Colunas esperadas: email, full_name.

<%= form.file_field :file, accept: ".csv,.xlsx", required: true, class: "form-file" %>
- <%= form.submit "Upload", class: "btn-primary w-full text-center cursor-pointer sm:w-auto" %> + <%= form.submit "Enviar", class: "btn-primary w-full text-center cursor-pointer sm:w-auto" %>
<% end %> diff --git a/app/views/admin/spreadsheet_imports/show.html.erb b/app/views/admin/spreadsheet_imports/show.html.erb index b385ece40..3dac8e4a9 100644 --- a/app/views/admin/spreadsheet_imports/show.html.erb +++ b/app/views/admin/spreadsheet_imports/show.html.erb @@ -1,9 +1,9 @@ -

Spreadsheet Import

-

<%= @spreadsheet_import.file.filename %> — uploaded by <%= @spreadsheet_import.user.full_name %>

+

Importação de Planilha

+

<%= @spreadsheet_import.file.filename %> — enviado por <%= @spreadsheet_import.user.full_name %>

<%= turbo_stream_from "spreadsheet_import_#{@spreadsheet_import.id}" %> <%= render "progress", spreadsheet_import: @spreadsheet_import %>
- <%= link_to "Back to imports", admin_spreadsheet_imports_path, class: "link-action" %> + <%= link_to "Voltar para imports", admin_spreadsheet_imports_path, class: "link-action" %>
diff --git a/app/views/admin/users/_form.html.erb b/app/views/admin/users/_form.html.erb index f5ff8acb8..c456549f4 100644 --- a/app/views/admin/users/_form.html.erb +++ b/app/views/admin/users/_form.html.erb @@ -2,43 +2,43 @@ <%= render "shared/form_errors", record: user %>
- <%= form.label :full_name, class: "form-label" %> + <%= form.label :full_name, "Nome", class: "form-label" %> <%= form.text_field :full_name, required: true, class: "form-input" %>
- <%= form.label :email, class: "form-label" %> + <%= form.label :email, "E-mail", class: "form-label" %> <%= form.email_field :email, required: true, autocomplete: "username", class: "form-input" %>
- <%= form.label :role, class: "form-label" %> - <%= form.select :role, User.roles.keys.map { |role| [ role.humanize, role ] }, {}, class: "form-input" %> + <%= form.label :role, "Papel", class: "form-label" %> + <%= form.select :role, User.roles.keys.map { |role| [ role == "admin" ? "Administrador" : "Usuário Normal", role ] }, {}, class: "form-input" %>
- <%= form.label :password, (user.new_record? ? "Password" : "New password"), class: "form-label" %> - <%= form.password_field :password, required: user.new_record?, autocomplete: "new-password", placeholder: user.new_record? ? nil : "Leave blank to keep the current password", minlength: 8, maxlength: 72, class: "form-input", data: { password_confirmation_target: "password", action: "input->password-confirmation#validate" } %> + <%= form.label :password, (user.new_record? ? "Senha" : "Nova senha"), class: "form-label" %> + <%= form.password_field :password, required: user.new_record?, autocomplete: "new-password", placeholder: user.new_record? ? nil : "Deixe em branco para manter a senha atual", minlength: 8, maxlength: 72, class: "form-input", data: { password_confirmation_target: "password", action: "input->password-confirmation#validate" } %>
- <%= form.label :password_confirmation, class: "form-label" %> + <%= form.label :password_confirmation, "Confirmar senha", class: "form-label" %> <%= form.password_field :password_confirmation, required: user.new_record?, autocomplete: "new-password", minlength: 8, maxlength: 72, class: "form-input", data: { password_confirmation_target: "confirmation", action: "input->password-confirmation#validate" } %>
- <%= form.label :avatar, "Avatar image", class: "form-label" %> + <%= form.label :avatar, "Imagem de Avatar", class: "form-label" %> <%= form.file_field :avatar, accept: "image/png,image/jpeg,image/webp", class: "form-file" %>
- <%= form.label :avatar_url, "…or avatar image URL", class: "form-label" %> + <%= form.label :avatar_url, "…ou URL da imagem do avatar", class: "form-label" %> <%= form.url_field :avatar_url, placeholder: "https://example.com/avatar.png", class: "form-input" %>
- <%= form.submit class: "btn-primary w-full text-center cursor-pointer sm:w-auto" %> + <%= form.submit (user.new_record? ? "Criar Usuário" : "Atualizar Usuário"), class: "btn-primary w-full text-center cursor-pointer sm:w-auto" %>
<% end %> diff --git a/app/views/admin/users/edit.html.erb b/app/views/admin/users/edit.html.erb index 29e9e6fb7..79b274fc5 100644 --- a/app/views/admin/users/edit.html.erb +++ b/app/views/admin/users/edit.html.erb @@ -1,7 +1,7 @@ -

Edit User

+

Editar Usuário

<%= render "form", user: @user %>
- <%= link_to "Back to users", admin_users_path, class: "link-muted" %> + <%= link_to "Voltar para usuários", admin_users_path, class: "link-muted" %>
diff --git a/app/views/admin/users/index.html.erb b/app/views/admin/users/index.html.erb index a465286a6..d36c458d1 100644 --- a/app/views/admin/users/index.html.erb +++ b/app/views/admin/users/index.html.erb @@ -1,6 +1,6 @@
-

Users

- <%= link_to "New user", new_admin_user_path, class: "btn-primary" %> +

Usuários

+ <%= link_to "Novo Usuário", new_admin_user_path, class: "btn-primary" %>
@@ -8,10 +8,10 @@
- - - - + + + + @@ -24,11 +24,11 @@ - + <% end %> diff --git a/app/views/admin/users/new.html.erb b/app/views/admin/users/new.html.erb index 7c2e49d0b..3827baa80 100644 --- a/app/views/admin/users/new.html.erb +++ b/app/views/admin/users/new.html.erb @@ -1,7 +1,7 @@ -

New User

+

Novo Usuário

<%= render "form", user: @user %>
- <%= link_to "Back to users", admin_users_path, class: "link-muted" %> + <%= link_to "Voltar para usuários", admin_users_path, class: "link-muted" %>
diff --git a/app/views/layouts/_nav.html.erb b/app/views/layouts/_nav.html.erb index 66e6cbf24..2be12451f 100644 --- a/app/views/layouts/_nav.html.erb +++ b/app/views/layouts/_nav.html.erb @@ -11,15 +11,15 @@ diff --git a/app/views/layouts/mailer.html.erb b/app/views/layouts/mailer.html.erb index 3aac9002e..4a491a635 100644 --- a/app/views/layouts/mailer.html.erb +++ b/app/views/layouts/mailer.html.erb @@ -3,7 +3,7 @@ diff --git a/app/views/passwords/edit.html.erb b/app/views/passwords/edit.html.erb index a68ffb994..dbd5fd305 100644 --- a/app/views/passwords/edit.html.erb +++ b/app/views/passwords/edit.html.erb @@ -1,4 +1,4 @@ -

Update your password

+

Atualizar sua senha

<%= form_with url: password_path(params[:token]), method: :put, class: "contents" do |form| %>
@@ -12,6 +12,6 @@
- <%= form.submit "Save", class: "btn-primary w-full text-center cursor-pointer sm:w-auto" %> + <%= form.submit "Salvar", class: "btn-primary w-full text-center cursor-pointer sm:w-auto" %>
<% end %> diff --git a/app/views/passwords/new.html.erb b/app/views/passwords/new.html.erb index 4fc0bdc0f..d13b4ede9 100644 --- a/app/views/passwords/new.html.erb +++ b/app/views/passwords/new.html.erb @@ -2,10 +2,10 @@ <%= form_with url: passwords_path, class: "contents" do |form| %>
- <%= form.email_field :email, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email], class: "form-input" %> + <%= form.email_field :email, required: true, autofocus: true, autocomplete: "username", placeholder: "Digite seu e-mail", value: params[:email], class: "form-input" %>
- <%= form.submit "Email reset instructions", class: "btn-primary w-full text-center cursor-pointer sm:w-auto" %> + <%= form.submit "E-mail reset instructions", class: "btn-primary w-full text-center cursor-pointer sm:w-auto" %>
<% end %> diff --git a/app/views/profiles/edit.html.erb b/app/views/profiles/edit.html.erb index 518d9ce08..59b2b213a 100644 --- a/app/views/profiles/edit.html.erb +++ b/app/views/profiles/edit.html.erb @@ -1,45 +1,45 @@ -

Edit Profile

+

Editar Perfil

<%= form_with model: @user, url: profile_path, class: "contents" do |form| %> <%= render "shared/form_errors", record: @user %>
- <%= form.label :full_name, class: "form-label" %> + <%= form.label :full_name, "Nome", class: "form-label" %> <%= form.text_field :full_name, required: true, class: "form-input" %>
- <%= form.label :email, class: "form-label" %> + <%= form.label :email, "E-mail", class: "form-label" %> <%= form.email_field :email, required: true, autocomplete: "username", class: "form-input" %>
- <%= form.label :password, "New password", class: "form-label" %> - <%= form.password_field :password, autocomplete: "new-password", placeholder: "Leave blank to keep the current password", minlength: 8, maxlength: 72, class: "form-input", data: { password_confirmation_target: "password", action: "input->password-confirmation#validate" } %> + <%= form.label :password, "Nova senha", class: "form-label" %> + <%= form.password_field :password, autocomplete: "new-password", placeholder: "Deixe em branco para manter a senha atual", minlength: 8, maxlength: 72, class: "form-input", data: { password_confirmation_target: "password", action: "input->password-confirmation#validate" } %>
- <%= form.label :password_confirmation, class: "form-label" %> + <%= form.label :password_confirmation, "Confirmar senha", class: "form-label" %> <%= form.password_field :password_confirmation, autocomplete: "new-password", minlength: 8, maxlength: 72, class: "form-input", data: { password_confirmation_target: "confirmation", action: "input->password-confirmation#validate" } %>
- <%= form.label :avatar, "Avatar image", class: "form-label" %> + <%= form.label :avatar, "Imagem de Avatar", class: "form-label" %> <%= form.file_field :avatar, accept: "image/png,image/jpeg,image/webp", class: "form-file" %>
- <%= form.label :avatar_url, "…or avatar image URL", class: "form-label" %> + <%= form.label :avatar_url, "…ou URL da imagem do avatar", class: "form-label" %> <%= form.url_field :avatar_url, placeholder: "https://example.com/avatar.png", class: "form-input" %>
- <%= form.submit "Save", class: "btn-primary w-full text-center cursor-pointer sm:w-auto" %> + <%= form.submit "Salvar", class: "btn-primary w-full text-center cursor-pointer sm:w-auto" %>
<% end %>
- <%= link_to "Back to profile", profile_path, class: "link-muted" %> + <%= link_to "Voltar para o perfil", profile_path, class: "link-muted" %>
diff --git a/app/views/profiles/show.html.erb b/app/views/profiles/show.html.erb index e188879af..99e95cd40 100644 --- a/app/views/profiles/show.html.erb +++ b/app/views/profiles/show.html.erb @@ -1,21 +1,21 @@ -

My Profile

+

Meu Perfil

<% if @user.avatar.attached? %> <%= image_tag @user.avatar.variant(resize_to_limit: [ 96, 96 ]), class: "size-24 rounded-full object-cover mt-4" %> <% end %>
-
Full name
+
Nome
<%= @user.full_name %>
-
Email
+
E-mail
<%= @user.email %>
-
Role
-
<%= @user.role.humanize %>
+
Papel
+
<%= @user.role == "admin" ? "Administrador" : "Usuário Normal" %>
- <%= link_to "Edit profile", edit_profile_path, class: "link-action" %> - <%= button_to "Delete account", profile_path, method: :delete, class: "link-danger btn-link", form: { data: { turbo_confirm: "Are you sure? This cannot be undone." } } %> + <%= link_to "Editar perfil", edit_profile_path, class: "link-action" %> + <%= button_to "Excluir conta", profile_path, method: :delete, class: "link-danger btn-link", form: { data: { turbo_confirm: "Tem certeza? Esta ação não pode ser desfeita." } } %>
diff --git a/app/views/registrations/new.html.erb b/app/views/registrations/new.html.erb index 5c212ff70..eab6d9019 100644 --- a/app/views/registrations/new.html.erb +++ b/app/views/registrations/new.html.erb @@ -1,33 +1,33 @@ -

Create your account

+

Criar sua conta

<%= form_with model: @user, url: registration_path, class: "contents" do |form| %> <%= render "shared/form_errors", record: @user %>
- <%= form.text_field :full_name, required: true, autofocus: true, placeholder: "Full name", class: "form-input" %> + <%= form.text_field :full_name, required: true, autofocus: true, placeholder: "Nome", class: "form-input" %>
- <%= form.email_field :email, required: true, autocomplete: "username", placeholder: "Enter your email address", class: "form-input" %> + <%= form.email_field :email, required: true, autocomplete: "username", placeholder: "Digite seu e-mail", class: "form-input" %>
- <%= form.password_field :password, required: true, autocomplete: "new-password", placeholder: "Password", minlength: 8, maxlength: 72, class: "form-input", data: { password_confirmation_target: "password", action: "input->password-confirmation#validate" } %> + <%= form.password_field :password, required: true, autocomplete: "new-password", placeholder: "Senha", minlength: 8, maxlength: 72, class: "form-input", data: { password_confirmation_target: "password", action: "input->password-confirmation#validate" } %>
- <%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Confirm password", minlength: 8, maxlength: 72, class: "form-input", data: { password_confirmation_target: "confirmation", action: "input->password-confirmation#validate" } %> + <%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Confirmar senha", minlength: 8, maxlength: 72, class: "form-input", data: { password_confirmation_target: "confirmation", action: "input->password-confirmation#validate" } %>
- <%= form.submit "Sign up", class: "btn-primary w-full text-center cursor-pointer sm:w-auto" %> + <%= form.submit "Criar conta", class: "btn-primary w-full text-center cursor-pointer sm:w-auto" %>
- <%= link_to "Already have an account? Sign in", new_session_path, class: "link-muted" %> + <%= link_to "Já tem uma conta? Entrar", new_session_path, class: "link-muted" %>
<% end %> diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index 2226fa0ee..a9559a41d 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -1,23 +1,23 @@ -

Sign in

+

Entrar

<%= form_with url: session_url, class: "contents" do |form| %>
- <%= form.email_field :email, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email], class: "form-input" %> + <%= form.email_field :email, required: true, autofocus: true, autocomplete: "username", placeholder: "Digite seu e-mail", value: params[:email], class: "form-input" %>
- <%= form.password_field :password, required: true, autocomplete: "current-password", placeholder: "Enter your password", maxlength: 72, class: "form-input" %> + <%= form.password_field :password, required: true, autocomplete: "current-password", placeholder: "Digite sua senha", maxlength: 72, class: "form-input" %>
- <%= form.submit "Sign in", class: "btn-primary w-full text-center cursor-pointer sm:w-auto" %> + <%= form.submit "Entrar", class: "btn-primary w-full text-center cursor-pointer sm:w-auto" %>
- <%= link_to "Forgot password?", new_password_path, class: "link-muted" %> + <%= link_to "Esqueceu a senha?", new_password_path, class: "link-muted" %> · - <%= link_to "Create an account", new_registration_path, class: "link-muted" %> + <%= link_to "Criar uma conta", new_registration_path, class: "link-muted" %>
<% end %> From bfe79a97e81d51bf254af1a8b027e38a311b61a2 Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Thu, 3 Sep 2026 16:58:22 -0300 Subject: [PATCH 35/68] test: update system and request specs to match new pt-BR UI strings --- spec/factories/spreadsheet_imports.rb | 4 ++-- spec/rails_helper.rb | 2 +- spec/requests/admin/dashboard_spec.rb | 2 +- .../requests/admin/spreadsheet_imports_spec.rb | 2 +- spec/support/authentication_helpers.rb | 2 +- .../admin_dashboard_live_updates_spec.rb | 18 +++++++++--------- ...in_spreadsheet_import_live_progress_spec.rb | 18 +++++++++--------- spec/system/responsive_navigation_spec.rb | 10 +++++----- 8 files changed, 29 insertions(+), 29 deletions(-) diff --git a/spec/factories/spreadsheet_imports.rb b/spec/factories/spreadsheet_imports.rb index 3fb42f11b..26621523b 100644 --- a/spec/factories/spreadsheet_imports.rb +++ b/spec/factories/spreadsheet_imports.rb @@ -15,7 +15,7 @@ factory :spreadsheet_import_row_error do association :spreadsheet_import sequence(:row_number) { |n| n + 1 } - message { "Email can't be blank" } - raw_data { { "email" => "", "full_name" => "Missing Email" }.to_json } + message { "E-mail can't be blank" } + raw_data { { "email" => "", "full_name" => "Missing E-mail" }.to_json } end end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index f7c3f3851..297ea0432 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -31,7 +31,7 @@ # If you are not using ActiveRecord, you can remove these lines. begin ActiveRecord::Migration.maintain_test_schema! -rescue ActiveRecord::PendingMigrationError => e +rescue ActiveRecord::PendenteMigrationError => e abort e.to_s.strip end RSpec.configure do |config| diff --git a/spec/requests/admin/dashboard_spec.rb b/spec/requests/admin/dashboard_spec.rb index 09052ebc2..016c3af3a 100644 --- a/spec/requests/admin/dashboard_spec.rb +++ b/spec/requests/admin/dashboard_spec.rb @@ -1,6 +1,6 @@ require "rails_helper" -RSpec.describe "Admin::Dashboard", type: :request do +RSpec.describe "Admin::Painel", type: :request do describe "GET /admin/dashboard" do it "redirects unauthenticated visitors to sign in" do get admin_dashboard_path diff --git a/spec/requests/admin/spreadsheet_imports_spec.rb b/spec/requests/admin/spreadsheet_imports_spec.rb index 35ad17dfe..a5cec0660 100644 --- a/spec/requests/admin/spreadsheet_imports_spec.rb +++ b/spec/requests/admin/spreadsheet_imports_spec.rb @@ -93,7 +93,7 @@ get admin_spreadsheet_import_path(spreadsheet_import) expect(response).to have_http_status(:ok) - expect(response.body).to include("rows processed") + expect(response.body).to include("linhas processadas") end it "is forbidden for a no_admin user" do diff --git a/spec/support/authentication_helpers.rb b/spec/support/authentication_helpers.rb index c4932423a..a5d7f4f12 100644 --- a/spec/support/authentication_helpers.rb +++ b/spec/support/authentication_helpers.rb @@ -7,7 +7,7 @@ def sign_in_via_ui(user, password: "password123") visit new_session_path fill_in "email", with: user.email fill_in "password", with: password - click_button "Sign in" + click_button "Entrar" end end diff --git a/spec/system/admin_dashboard_live_updates_spec.rb b/spec/system/admin_dashboard_live_updates_spec.rb index 06fee151c..75ac34dc7 100644 --- a/spec/system/admin_dashboard_live_updates_spec.rb +++ b/spec/system/admin_dashboard_live_updates_spec.rb @@ -9,23 +9,23 @@ sign_in_via_ui(admin_two) within("#dashboard_counts") do - expect(page).to have_content("Total Users") + expect(page).to have_content("Total de Usuários") expect(page).to have_content("2") end end using_session(:admin_one) do sign_in_via_ui(admin_one) - click_link "Manage users" - click_link "New user" + click_link "Gerenciar usuários" + click_link "Novo Usuário" - fill_in "Full name", with: "Grace Hopper" - fill_in "Email", with: "grace-live@example.com" - fill_in "Password", with: "password123", exact: true - fill_in "Password confirmation", with: "password123" - click_button "Create User" + fill_in "Nome", with: "Grace Hopper" + fill_in "E-mail", with: "grace-live@example.com" + fill_in "Senha", with: "password123", exact: true + fill_in "Confirmar senha", with: "password123" + click_button "Criar Usuário" - expect(page).to have_content("User was successfully created") + expect(page).to have_content("Usuário criado com sucesso") end using_session(:admin_two) do diff --git a/spec/system/admin_spreadsheet_import_live_progress_spec.rb b/spec/system/admin_spreadsheet_import_live_progress_spec.rb index 20ebf71e0..48d6c90e7 100644 --- a/spec/system/admin_spreadsheet_import_live_progress_spec.rb +++ b/spec/system/admin_spreadsheet_import_live_progress_spec.rb @@ -5,23 +5,23 @@ admin = create(:user, :admin, password: "password123") sign_in_via_ui(admin) - click_link "Spreadsheet imports" - click_link "New import" + click_link "Importação de Planilhas" + click_link "Nova Importação" - attach_file "Spreadsheet (CSV or XLSX)", Rails.root.join("spec/fixtures/files/valid_import.csv") - click_button "Upload" + attach_file "Planilha (CSV ou XLSX)", Rails.root.join("spec/fixtures/files/valid_import.csv") + click_button "Enviar" - expect(page).to have_content("Spreadsheet uploaded. Import is processing in the background.") + expect(page).to have_content("Planilha enviada. A importação está sendo processada em segundo plano.") within("#spreadsheet_import_progress") do - expect(page).to have_content("Status: Pending") - expect(page).to have_content("0 / 0 rows processed") + expect(page).to have_content("Status: Pendente") + expect(page).to have_content("0 / 0 linhas processadas") end SpreadsheetImportJob.perform_now(SpreadsheetImport.last.id) within("#spreadsheet_import_progress") do - expect(page).to have_content("Status: Completed") - expect(page).to have_content("3 / 3 rows processed") + expect(page).to have_content("Status: Concluída") + expect(page).to have_content("3 / 3 linhas processadas") end expect(User.exists?(email: "alice@example.com")).to be true diff --git a/spec/system/responsive_navigation_spec.rb b/spec/system/responsive_navigation_spec.rb index dcc88f4cb..62e529a8a 100644 --- a/spec/system/responsive_navigation_spec.rb +++ b/spec/system/responsive_navigation_spec.rb @@ -9,7 +9,7 @@ sign_in_via_ui(user) - expect(page).to have_link("My Profile", visible: true) + expect(page).to have_link("Meu Perfil", visible: true) expect(page).not_to have_button("Menu", visible: true) end @@ -20,12 +20,12 @@ sign_in_via_ui(admin) expect(page).to have_button("Menu", visible: true) - expect(page).to have_link("Manage users", visible: :hidden) + expect(page).to have_link("Gerenciar usuários", visible: :hidden) click_button "Menu" - click_link "Manage users" + click_link "Gerenciar usuários" - expect(page).to have_content("Users") - expect(page).to have_link("New user") + expect(page).to have_content("Usuários") + expect(page).to have_link("Novo Usuário") end end From 6729f0e9266a4361172e27df2fb3aed91f7ebcd7 Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Thu, 3 Sep 2026 19:45:20 -0300 Subject: [PATCH 36/68] feat: implementa sidebar responsiva e layout admin corporativo --- app/assets/tailwind/application.css | 26 ++++--- .../controllers/nav_toggle_controller.js | 13 ---- .../controllers/sidebar_toggle_controller.js | 15 ++++ app/views/admin/dashboards/_counts.html.erb | 16 ++-- app/views/admin/dashboards/show.html.erb | 8 +- app/views/layouts/_nav.html.erb | 25 ------- app/views/layouts/_sidebar.html.erb | 54 ++++++++++++++ app/views/layouts/application.html.erb | 10 ++- app/views/passwords/edit.html.erb | 30 ++++---- app/views/passwords/new.html.erb | 25 ++++--- app/views/profiles/edit.html.erb | 73 ++++++++++--------- app/views/profiles/show.html.erb | 46 ++++++++---- app/views/registrations/new.html.erb | 48 ++++++------ app/views/sessions/new.html.erb | 37 +++++----- .../admin_dashboard_live_updates_spec.rb | 2 +- spec/system/responsive_navigation_spec.rb | 4 +- 16 files changed, 257 insertions(+), 175 deletions(-) delete mode 100644 app/javascript/controllers/nav_toggle_controller.js create mode 100644 app/javascript/controllers/sidebar_toggle_controller.js delete mode 100644 app/views/layouts/_nav.html.erb create mode 100644 app/views/layouts/_sidebar.html.erb diff --git a/app/assets/tailwind/application.css b/app/assets/tailwind/application.css index 213a3f0ef..9d4b9bdc2 100644 --- a/app/assets/tailwind/application.css +++ b/app/assets/tailwind/application.css @@ -2,35 +2,43 @@ @layer components { .form-label { - @apply block font-medium; + @apply block text-sm font-semibold text-gray-700 mb-1; } .form-input { - @apply mt-2 block w-full rounded-md border border-gray-400 px-3 py-2 shadow-sm - focus:outline-blue-600 [&:user-invalid]:border-red-500 [&:user-invalid]:focus:outline-red-600; + @apply mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-gray-900 shadow-sm transition-colors + focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 + [&:user-invalid]:border-red-500 [&:user-invalid]:focus:ring-red-500/20; } .form-file { - @apply mt-2 block w-full; + @apply mt-1 block w-full text-sm text-gray-500 + file:mr-4 file:py-2 file:px-4 + file:rounded-md file:border-0 + file:text-sm file:font-semibold + file:bg-emerald-50 file:text-emerald-700 + hover:file:bg-emerald-100 transition-colors cursor-pointer; } .btn-primary { - @apply inline-block rounded-md bg-blue-600 px-3.5 py-2.5 font-medium text-white hover:bg-blue-500; + @apply inline-flex justify-center items-center rounded-md bg-emerald-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-all + hover:bg-emerald-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-600 + active:scale-[0.98]; } .btn-link { - @apply cursor-pointer bg-transparent p-0; + @apply cursor-pointer bg-transparent p-0 text-sm font-medium text-emerald-600 hover:text-emerald-500 transition-colors; } .link-action { - @apply text-blue-600 underline hover:no-underline; + @apply text-sm font-medium text-emerald-600 hover:text-emerald-500 transition-colors px-1; } .link-danger { - @apply text-red-600 underline hover:no-underline; + @apply text-sm font-medium text-red-600 hover:text-red-500 transition-colors px-1; } .link-muted { - @apply text-gray-700 underline hover:no-underline; + @apply text-sm font-medium text-gray-500 hover:text-gray-700 transition-colors; } } diff --git a/app/javascript/controllers/nav_toggle_controller.js b/app/javascript/controllers/nav_toggle_controller.js deleted file mode 100644 index c79c88774..000000000 --- a/app/javascript/controllers/nav_toggle_controller.js +++ /dev/null @@ -1,13 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -// Toggles the mobile nav menu, shown collapsed behind a "Menu" button below -// the sm breakpoint and always expanded above it (see layouts/_nav). -export default class extends Controller { - static targets = ["menu", "button"] - - toggle() { - const expanded = this.menuTarget.classList.toggle("flex") - this.menuTarget.classList.toggle("hidden", !expanded) - this.buttonTarget.setAttribute("aria-expanded", expanded) - } -} diff --git a/app/javascript/controllers/sidebar_toggle_controller.js b/app/javascript/controllers/sidebar_toggle_controller.js new file mode 100644 index 000000000..edb5ace60 --- /dev/null +++ b/app/javascript/controllers/sidebar_toggle_controller.js @@ -0,0 +1,15 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["menu", "overlay"] + + toggle() { + this.menuTarget.classList.toggle("-translate-x-full") + this.overlayTarget.classList.toggle("hidden") + } + + close() { + this.menuTarget.classList.add("-translate-x-full") + this.overlayTarget.classList.add("hidden") + } +} diff --git a/app/views/admin/dashboards/_counts.html.erb b/app/views/admin/dashboards/_counts.html.erb index ba29164bc..3dedee5b6 100644 --- a/app/views/admin/dashboards/_counts.html.erb +++ b/app/views/admin/dashboards/_counts.html.erb @@ -1,12 +1,12 @@ -
-
-

Total de Usuários

-

<%= total_users %>

+
+
+

Total de Usuários

+

<%= total_users %>

- <% User.roles.keys.each do |role| %> -
-

<%= role == "admin" ? "Administrador" : "Usuário Normal" %>

-

<%= users_by_role[role] || 0 %>

+ <% User.roles.keys.each_with_index do |role, index| %> +
+

<%= role == "admin" ? "Administrador" : "Usuário Normal" %>

+

<%= users_by_role[role] || 0 %>

<% end %>
diff --git a/app/views/admin/dashboards/show.html.erb b/app/views/admin/dashboards/show.html.erb index 3fca1f32f..7ea909a2c 100644 --- a/app/views/admin/dashboards/show.html.erb +++ b/app/views/admin/dashboards/show.html.erb @@ -1,5 +1,9 @@ -

Painel Administrativo

-

Conectado como <%= Current.user.full_name %> (<%= Current.user.role %>).

+
+
+

Painel Administrativo

+

Conectado como <%= Current.user.full_name %> (<%= Current.user.role == 'admin' ? 'Administrador' : 'Usuário Normal' %>).

+
+
<%= turbo_stream_from "admin_dashboard" %> <%= render "counts", total_users: @total_users, users_by_role: @users_by_role %> diff --git a/app/views/layouts/_nav.html.erb b/app/views/layouts/_nav.html.erb deleted file mode 100644 index 2be12451f..000000000 --- a/app/views/layouts/_nav.html.erb +++ /dev/null @@ -1,25 +0,0 @@ -
-
-
- <%= link_to "Fullstack Developer", Current.user.admin? ? admin_dashboard_path : profile_path, class: "font-bold text-gray-900" %> - - -
- - -
-
diff --git a/app/views/layouts/_sidebar.html.erb b/app/views/layouts/_sidebar.html.erb new file mode 100644 index 000000000..c21ee2467 --- /dev/null +++ b/app/views/layouts/_sidebar.html.erb @@ -0,0 +1,54 @@ +
+ +
+
Fullstack Developer
+ +
+ + + + + + +
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index f10bf671c..04d4c397c 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -23,11 +23,13 @@ <%= javascript_importmap_tags %> - - <%= render "layouts/nav" if Current.user %> + + <% if Current.user %> + <%= render "layouts/sidebar" %> + <% end %> -
-
+
+
<%= render "layouts/flash" %> <%= yield %>
diff --git a/app/views/passwords/edit.html.erb b/app/views/passwords/edit.html.erb index dbd5fd305..1de581e8d 100644 --- a/app/views/passwords/edit.html.erb +++ b/app/views/passwords/edit.html.erb @@ -1,17 +1,21 @@ -

Atualizar sua senha

+
+

Atualizar Senha

-<%= 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", minlength: 8, maxlength: 72, class: "form-input", data: { password_confirmation_target: "password", action: "input->password-confirmation#validate" } %> -
+ <%= form_with url: password_path(params[:token]), method: :put, class: "contents" do |form| %> +
+
+ <%= form.label :password, "Nova senha", class: "form-label" %> + <%= form.password_field :password, required: true, autocomplete: "new-password", placeholder: "Digite a nova senha", minlength: 8, maxlength: 72, class: "form-input", data: { password_confirmation_target: "password", action: "input->password-confirmation#validate" } %> +
-
- <%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Repeat new password", minlength: 8, maxlength: 72, class: "form-input", data: { password_confirmation_target: "confirmation", action: "input->password-confirmation#validate" } %> +
+ <%= form.label :password_confirmation, "Confirmar nova senha", class: "form-label" %> + <%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Repita a nova senha", minlength: 8, maxlength: 72, class: "form-input", data: { password_confirmation_target: "confirmation", action: "input->password-confirmation#validate" } %> +
-
-
- <%= form.submit "Salvar", class: "btn-primary w-full text-center cursor-pointer sm:w-auto" %> -
-<% end %> +
+ <%= form.submit "Salvar", class: "btn-primary w-full text-center cursor-pointer text-xl" %> +
+ <% end %> +
diff --git a/app/views/passwords/new.html.erb b/app/views/passwords/new.html.erb index d13b4ede9..3176663a5 100644 --- a/app/views/passwords/new.html.erb +++ b/app/views/passwords/new.html.erb @@ -1,11 +1,18 @@ -

Forgot your password?

+
+

Esqueceu a senha?

-<%= form_with url: passwords_path, class: "contents" do |form| %> -
- <%= form.email_field :email, required: true, autofocus: true, autocomplete: "username", placeholder: "Digite seu e-mail", value: params[:email], class: "form-input" %> -
+ <%= form_with url: passwords_path, class: "contents" do |form| %> +
+ <%= form.label :email, "E-mail", class: "form-label" %> + <%= form.email_field :email, required: true, autofocus: true, autocomplete: "username", placeholder: "Digite seu e-mail", value: params[:email], class: "form-input" %> +
-
- <%= form.submit "E-mail reset instructions", class: "btn-primary w-full text-center cursor-pointer sm:w-auto" %> -
-<% end %> +
+ <%= form.submit "Enviar instruções", class: "btn-primary w-full text-center cursor-pointer text-xl" %> +
+ +
+ <%= link_to "Voltar para o login", new_session_path, class: "link-muted" %> +
+ <% end %> +
diff --git a/app/views/profiles/edit.html.erb b/app/views/profiles/edit.html.erb index 59b2b213a..44b6e91fa 100644 --- a/app/views/profiles/edit.html.erb +++ b/app/views/profiles/edit.html.erb @@ -1,45 +1,50 @@ -

Editar Perfil

+
+

Editar Perfil

-<%= form_with model: @user, url: profile_path, class: "contents" do |form| %> - <%= render "shared/form_errors", record: @user %> + <%= form_with model: @user, url: profile_path, class: "contents" do |form| %> + <%= render "shared/form_errors", record: @user %> -
- <%= form.label :full_name, "Nome", class: "form-label" %> - <%= form.text_field :full_name, required: true, class: "form-input" %> -
- -
- <%= form.label :email, "E-mail", class: "form-label" %> - <%= form.email_field :email, required: true, autocomplete: "username", class: "form-input" %> -
- -
- <%= form.label :password, "Nova senha", class: "form-label" %> - <%= form.password_field :password, autocomplete: "new-password", placeholder: "Deixe em branco para manter a senha atual", minlength: 8, maxlength: 72, class: "form-input", data: { password_confirmation_target: "password", action: "input->password-confirmation#validate" } %> + <%= form.label :full_name, "Nome", class: "form-label" %> + <%= form.text_field :full_name, required: true, class: "form-input" %>
- <%= form.label :password_confirmation, "Confirmar senha", class: "form-label" %> - <%= form.password_field :password_confirmation, autocomplete: "new-password", minlength: 8, maxlength: 72, class: "form-input", data: { password_confirmation_target: "confirmation", action: "input->password-confirmation#validate" } %> + <%= form.label :email, "E-mail", class: "form-label" %> + <%= form.email_field :email, required: true, autocomplete: "username", class: "form-input" %>
-
- -
- <%= form.label :avatar, "Imagem de Avatar", class: "form-label" %> - <%= form.file_field :avatar, accept: "image/png,image/jpeg,image/webp", class: "form-file" %> -
-
- <%= form.label :avatar_url, "…ou URL da imagem do avatar", class: "form-label" %> - <%= form.url_field :avatar_url, placeholder: "https://example.com/avatar.png", class: "form-input" %> -
+
+

Mudar Senha

+ +
+ <%= form.label :password, "Nova senha", class: "form-label" %> + <%= form.password_field :password, autocomplete: "new-password", placeholder: "Deixe em branco para manter a senha atual", minlength: 8, maxlength: 72, class: "form-input", data: { password_confirmation_target: "password", action: "input->password-confirmation#validate" } %> +
+ +
+ <%= form.label :password_confirmation, "Confirmar senha", class: "form-label" %> + <%= form.password_field :password_confirmation, autocomplete: "new-password", minlength: 8, maxlength: 72, class: "form-input", data: { password_confirmation_target: "confirmation", action: "input->password-confirmation#validate" } %> +
+
-
- <%= form.submit "Salvar", class: "btn-primary w-full text-center cursor-pointer sm:w-auto" %> -
-<% end %> +
+

Avatar

+ +
+ <%= form.label :avatar, "Imagem de Avatar", class: "form-label" %> + <%= form.file_field :avatar, accept: "image/png,image/jpeg,image/webp", class: "form-file" %> +
+ +
+ <%= form.label :avatar_url, "…ou URL da imagem", class: "form-label" %> + <%= form.url_field :avatar_url, placeholder: "https://example.com/avatar.png", class: "form-input" %> +
+
-
- <%= link_to "Voltar para o perfil", profile_path, class: "link-muted" %> +
+ <%= form.submit "Salvar Perfil", class: "btn-primary w-full sm:w-auto text-center cursor-pointer" %> + <%= link_to "Cancelar", profile_path, class: "link-muted" %> +
+ <% end %>
diff --git a/app/views/profiles/show.html.erb b/app/views/profiles/show.html.erb index 99e95cd40..d79676a56 100644 --- a/app/views/profiles/show.html.erb +++ b/app/views/profiles/show.html.erb @@ -1,21 +1,37 @@ -

Meu Perfil

+
+

Meu Perfil

-<% if @user.avatar.attached? %> - <%= image_tag @user.avatar.variant(resize_to_limit: [ 96, 96 ]), class: "size-24 rounded-full object-cover mt-4" %> -<% end %> +
+ <% if @user.avatar.attached? %> +
+ <%= image_tag @user.avatar.variant(resize_to_limit: [ 128, 128 ]), class: "size-32 object-cover block" %> +
+ <% end %> -
-
Nome
-
<%= @user.full_name %>
+
+
+
Nome
+
<%= @user.full_name %>
+
-
E-mail
-
<%= @user.email %>
+
+
E-mail
+
<%= @user.email %>
+
-
Papel
-
<%= @user.role == "admin" ? "Administrador" : "Usuário Normal" %>
-
+
+
Papel
+
+ + <%= @user.role == "admin" ? "Administrador" : "Usuário Normal" %> + +
+
+
+
-
- <%= link_to "Editar perfil", edit_profile_path, class: "link-action" %> - <%= button_to "Excluir conta", profile_path, method: :delete, class: "link-danger btn-link", form: { data: { turbo_confirm: "Tem certeza? Esta ação não pode ser desfeita." } } %> +
+ <%= link_to "Editar perfil", edit_profile_path, class: "btn-primary" %> + <%= button_to "Excluir conta", profile_path, method: :delete, class: "inline-flex justify-center items-center rounded-md bg-white px-4 py-2 text-sm font-semibold text-red-600 shadow-sm border border-red-200 hover:bg-red-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600 transition-colors", form: { data: { turbo_confirm: "Tem certeza? Esta ação não pode ser desfeita." } } %> +
diff --git a/app/views/registrations/new.html.erb b/app/views/registrations/new.html.erb index eab6d9019..772e5cd98 100644 --- a/app/views/registrations/new.html.erb +++ b/app/views/registrations/new.html.erb @@ -1,33 +1,37 @@ -

Criar sua conta

+
+

Criar Conta

-<%= form_with model: @user, url: registration_path, class: "contents" do |form| %> - <%= render "shared/form_errors", record: @user %> + <%= form_with model: @user, url: registration_path, class: "contents" do |form| %> + <%= render "shared/form_errors", record: @user %> -
- <%= form.text_field :full_name, required: true, autofocus: true, placeholder: "Nome", class: "form-input" %> -
- -
- <%= form.email_field :email, required: true, autocomplete: "username", placeholder: "Digite seu e-mail", class: "form-input" %> -
- -
- <%= form.password_field :password, required: true, autocomplete: "new-password", placeholder: "Senha", minlength: 8, maxlength: 72, class: "form-input", data: { password_confirmation_target: "password", action: "input->password-confirmation#validate" } %> + <%= form.label :full_name, "Nome", class: "form-label" %> + <%= form.text_field :full_name, required: true, autofocus: true, placeholder: "Nome", class: "form-input" %>
- <%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Confirmar senha", minlength: 8, maxlength: 72, class: "form-input", data: { password_confirmation_target: "confirmation", action: "input->password-confirmation#validate" } %> + <%= form.label :email, "E-mail", class: "form-label" %> + <%= form.email_field :email, required: true, autocomplete: "username", placeholder: "Digite seu e-mail", class: "form-input" %>
-
-
-
- <%= form.submit "Criar conta", class: "btn-primary w-full text-center cursor-pointer sm:w-auto" %> +
+
+ <%= form.label :password, "Senha", class: "form-label" %> + <%= form.password_field :password, required: true, autocomplete: "new-password", placeholder: "Senha", minlength: 8, maxlength: 72, class: "form-input", data: { password_confirmation_target: "password", action: "input->password-confirmation#validate" } %> +
+ +
+ <%= form.label :password_confirmation, "Confirmar Senha", class: "form-label" %> + <%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Confirmar senha", minlength: 8, maxlength: 72, class: "form-input", data: { password_confirmation_target: "confirmation", action: "input->password-confirmation#validate" } %> +
-
- <%= link_to "Já tem uma conta? Entrar", new_session_path, class: "link-muted" %> +
+ <%= form.submit "Criar Conta", class: "btn-primary w-full text-center cursor-pointer text-xl" %> + +
+ <%= link_to "Já tem uma conta? Entrar", new_session_path, class: "link-action" %> +
-
-<% end %> + <% end %> +
diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index a9559a41d..873681171 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -1,23 +1,24 @@ -

Entrar

+
+

Entrar

-<%= form_with url: session_url, class: "contents" do |form| %> -
- <%= form.email_field :email, required: true, autofocus: true, autocomplete: "username", placeholder: "Digite seu e-mail", value: params[:email], class: "form-input" %> -
- -
- <%= form.password_field :password, required: true, autocomplete: "current-password", placeholder: "Digite sua senha", maxlength: 72, class: "form-input" %> -
+ <%= form_with url: session_url, class: "contents" do |form| %> +
+ <%= form.label :email, "E-mail", class: "form-label" %> + <%= form.email_field :email, required: true, autofocus: true, autocomplete: "username", placeholder: "Digite seu e-mail", value: params[:email], class: "form-input" %> +
-
-
- <%= form.submit "Entrar", class: "btn-primary w-full text-center cursor-pointer sm:w-auto" %> +
+ <%= form.label :password, "Senha", class: "form-label" %> + <%= form.password_field :password, required: true, autocomplete: "current-password", placeholder: "Digite sua senha", maxlength: 72, class: "form-input" %>
-
- <%= link_to "Esqueceu a senha?", new_password_path, class: "link-muted" %> - · - <%= link_to "Criar uma conta", new_registration_path, class: "link-muted" %> +
+ <%= form.submit "Entrar", class: "btn-primary w-full text-center cursor-pointer text-xl" %> + +
+ <%= link_to "Esqueceu a senha?", new_password_path, class: "link-muted" %> + <%= link_to "Criar uma conta", new_registration_path, class: "link-action" %> +
-
-<% end %> + <% end %> +
diff --git a/spec/system/admin_dashboard_live_updates_spec.rb b/spec/system/admin_dashboard_live_updates_spec.rb index 75ac34dc7..2e91622aa 100644 --- a/spec/system/admin_dashboard_live_updates_spec.rb +++ b/spec/system/admin_dashboard_live_updates_spec.rb @@ -16,7 +16,7 @@ using_session(:admin_one) do sign_in_via_ui(admin_one) - click_link "Gerenciar usuários" + click_link "Usuários" click_link "Novo Usuário" fill_in "Nome", with: "Grace Hopper" diff --git a/spec/system/responsive_navigation_spec.rb b/spec/system/responsive_navigation_spec.rb index 62e529a8a..25ecaca0b 100644 --- a/spec/system/responsive_navigation_spec.rb +++ b/spec/system/responsive_navigation_spec.rb @@ -20,10 +20,10 @@ sign_in_via_ui(admin) expect(page).to have_button("Menu", visible: true) - expect(page).to have_link("Gerenciar usuários", visible: :hidden) + expect(page).to have_link("Usuários", visible: :all) click_button "Menu" - click_link "Gerenciar usuários" + click_link "Usuários" expect(page).to have_content("Usuários") expect(page).to have_link("Novo Usuário") From 30cfed38a6995c628c41a903e22bd7c5f9b6ba95 Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Thu, 3 Sep 2026 19:45:29 -0300 Subject: [PATCH 37/68] refactor: moderniza visual das flash messages e componentes do CRUD --- .../spreadsheet_imports/_progress.html.erb | 39 +++++++----- .../admin/spreadsheet_imports/index.html.erb | 48 ++++++++------- .../admin/spreadsheet_imports/new.html.erb | 27 +++++---- .../admin/spreadsheet_imports/show.html.erb | 19 ++++-- app/views/admin/users/_form.html.erb | 25 ++++---- app/views/admin/users/edit.html.erb | 8 +-- app/views/admin/users/index.html.erb | 60 ++++++++++++------- app/views/admin/users/new.html.erb | 8 +-- app/views/layouts/_flash.html.erb | 22 ++++++- 9 files changed, 158 insertions(+), 98 deletions(-) diff --git a/app/views/admin/spreadsheet_imports/_progress.html.erb b/app/views/admin/spreadsheet_imports/_progress.html.erb index 7e5dcbf2d..ab003783f 100644 --- a/app/views/admin/spreadsheet_imports/_progress.html.erb +++ b/app/views/admin/spreadsheet_imports/_progress.html.erb @@ -1,26 +1,33 @@ -
-

Status: <%= spreadsheet_import.status == "processing" ? "Processando" : spreadsheet_import.status == "completed" ? "Concluída" : spreadsheet_import.status == "failed" ? "Falhou" : "Pendente" %>

+
+
+
+

Status:

+ + <%= spreadsheet_import.status == "processing" ? "Processando" : spreadsheet_import.status == "completed" ? "Concluída" : spreadsheet_import.status == "failed" ? "Falhou" : "Pendente" %> + +
-
-
+
+
+
+

<%= spreadsheet_import.processed_rows %> / <%= spreadsheet_import.total_rows %> linhas processadas

-

<%= spreadsheet_import.processed_rows %> / <%= spreadsheet_import.total_rows %> linhas processadas

<% if spreadsheet_import.spreadsheet_import_row_errors.any? %> -

Row errors

-
-
FileUploaded byArquivoEnviado por StatusProgressErrorsProgressoErros
<%= spreadsheet_import.file.filename %> <%= spreadsheet_import.user.full_name %><%= spreadsheet_import.status.humanize %><%= spreadsheet_import.status == "processing" ? "Processando" : spreadsheet_import.status == "completed" ? "Concluída" : spreadsheet_import.status == "failed" ? "Falhou" : "Pendente" %> <%= spreadsheet_import.processed_rows %>/<%= spreadsheet_import.total_rows %> <%= spreadsheet_import.spreadsheet_import_row_errors.count %> <%= link_to "View", admin_spreadsheet_import_path(spreadsheet_import), class: "link-action" %>
AvatarFull nameEmailRoleActionsNomeE-mailPapelAções
<%= user.full_name %> <%= user.email %><%= user.role.humanize %><%= user.role == "admin" ? "Administrador" : "Usuário Normal" %> - <%= link_to "Edit", edit_admin_user_path(user), class: "link-action" %> - <%= button_to "Toggle role", toggle_role_admin_user_path(user), method: :patch, class: "link-action btn-link" %> - <%= button_to "Delete", admin_user_path(user), method: :delete, class: "link-danger btn-link", form: { data: { turbo_confirm: "Are you sure?" } } %> + <%= link_to "Editar", edit_admin_user_path(user), class: "link-action" %> + <%= button_to "Alterar papel", toggle_role_admin_user_path(user), method: :patch, class: "link-action btn-link" %> + <%= button_to "Excluir", admin_user_path(user), method: :delete, class: "link-danger btn-link", form: { data: { turbo_confirm: "Tem certeza?" } } %>
- - - - +

Erros nas Linhas

+
+
RowReason
+ + + + - + <% spreadsheet_import.spreadsheet_import_row_errors.order(:row_number).each do |row_error| %> - - - + + + <% end %> diff --git a/app/views/admin/spreadsheet_imports/index.html.erb b/app/views/admin/spreadsheet_imports/index.html.erb index 1d50aad08..94995812b 100644 --- a/app/views/admin/spreadsheet_imports/index.html.erb +++ b/app/views/admin/spreadsheet_imports/index.html.erb @@ -1,29 +1,37 @@ -
-

Importações de Planilha

+
+

Importações de Planilha

<%= link_to "Nova Importação", new_admin_spreadsheet_import_path, class: "btn-primary" %>
-
-
LinhaMotivo
<%= row_error.row_number %><%= row_error.message %>
<%= row_error.row_number %><%= row_error.message %>
- - - - - - - - +
+
ArquivoEnviado porStatusProgressoErros
+ + + + + + + + - + <% @spreadsheet_imports.each do |spreadsheet_import| %> - - - - - - - + + + + + + + <% end %> diff --git a/app/views/admin/spreadsheet_imports/new.html.erb b/app/views/admin/spreadsheet_imports/new.html.erb index ff3dbe2ce..1150d1708 100644 --- a/app/views/admin/spreadsheet_imports/new.html.erb +++ b/app/views/admin/spreadsheet_imports/new.html.erb @@ -1,15 +1,18 @@ -

Nova Importação

+
+

Nova Importação

-<%= form_with model: [ :admin, @spreadsheet_import ], class: "contents" do |form| %> - <%= render "shared/form_errors", record: @spreadsheet_import %> + <%= form_with model: [ :admin, @spreadsheet_import ], class: "contents" do |form| %> + <%= render "shared/form_errors", record: @spreadsheet_import %> -
- <%= form.label :file, "Planilha (CSV ou XLSX)", class: "form-label" %> -

Colunas esperadas: email, full_name.

- <%= form.file_field :file, accept: ".csv,.xlsx", required: true, class: "form-file" %> -
+
+ <%= form.label :file, "Planilha (CSV ou XLSX)", class: "form-label" %> +

Colunas esperadas: email, nome.

+ <%= form.file_field :file, accept: ".csv,.xlsx", required: true, class: "form-file" %> +
-
- <%= form.submit "Enviar", class: "btn-primary w-full text-center cursor-pointer sm:w-auto" %> -
-<% end %> +
+ <%= form.submit "Enviar Planilha", class: "btn-primary w-full sm:w-auto text-center cursor-pointer text-lg" %> + <%= link_to "Cancelar", admin_spreadsheet_imports_path, class: "link-muted" %> +
+ <% end %> +
diff --git a/app/views/admin/spreadsheet_imports/show.html.erb b/app/views/admin/spreadsheet_imports/show.html.erb index 3dac8e4a9..57430fa6c 100644 --- a/app/views/admin/spreadsheet_imports/show.html.erb +++ b/app/views/admin/spreadsheet_imports/show.html.erb @@ -1,9 +1,16 @@ -

Importação de Planilha

-

<%= @spreadsheet_import.file.filename %> — enviado por <%= @spreadsheet_import.user.full_name %>

+
+
+

Importação de Planilha

+

+ <%= @spreadsheet_import.file.filename %> + — enviado por <%= @spreadsheet_import.user.full_name %> +

+
-<%= turbo_stream_from "spreadsheet_import_#{@spreadsheet_import.id}" %> -<%= render "progress", spreadsheet_import: @spreadsheet_import %> + <%= turbo_stream_from "spreadsheet_import_#{@spreadsheet_import.id}" %> + <%= render "progress", spreadsheet_import: @spreadsheet_import %> -
- <%= link_to "Voltar para imports", admin_spreadsheet_imports_path, class: "link-action" %> +
+ <%= link_to "Voltar para importações", admin_spreadsheet_imports_path, class: "link-muted font-medium" %> +
diff --git a/app/views/admin/users/_form.html.erb b/app/views/admin/users/_form.html.erb index c456549f4..1ef2dfbb1 100644 --- a/app/views/admin/users/_form.html.erb +++ b/app/views/admin/users/_form.html.erb @@ -16,7 +16,8 @@ <%= form.select :role, User.roles.keys.map { |role| [ role == "admin" ? "Administrador" : "Usuário Normal", role ] }, {}, class: "form-input" %>
-
+
+

<%= user.new_record? ? "Senha" : "Mudar Senha" %>

<%= form.label :password, (user.new_record? ? "Senha" : "Nova senha"), class: "form-label" %> <%= form.password_field :password, required: user.new_record?, autocomplete: "new-password", placeholder: user.new_record? ? nil : "Deixe em branco para manter a senha atual", minlength: 8, maxlength: 72, class: "form-input", data: { password_confirmation_target: "password", action: "input->password-confirmation#validate" } %> @@ -28,17 +29,21 @@
-
- <%= form.label :avatar, "Imagem de Avatar", class: "form-label" %> - <%= form.file_field :avatar, accept: "image/png,image/jpeg,image/webp", class: "form-file" %> -
+
+

Avatar

+
+ <%= form.label :avatar, "Imagem de Avatar", class: "form-label" %> + <%= form.file_field :avatar, accept: "image/png,image/jpeg,image/webp", class: "form-file" %> +
-
- <%= form.label :avatar_url, "…ou URL da imagem do avatar", class: "form-label" %> - <%= form.url_field :avatar_url, placeholder: "https://example.com/avatar.png", class: "form-input" %> +
+ <%= form.label :avatar_url, "…ou URL da imagem do avatar", class: "form-label" %> + <%= form.url_field :avatar_url, placeholder: "https://example.com/avatar.png", class: "form-input" %> +
-
- <%= form.submit (user.new_record? ? "Criar Usuário" : "Atualizar Usuário"), class: "btn-primary w-full text-center cursor-pointer sm:w-auto" %> +
+ <%= form.submit (user.new_record? ? "Criar Usuário" : "Atualizar Usuário"), class: "btn-primary w-full sm:w-auto text-center cursor-pointer text-lg" %> + <%= link_to "Cancelar", admin_users_path, class: "link-muted" %>
<% end %> diff --git a/app/views/admin/users/edit.html.erb b/app/views/admin/users/edit.html.erb index 79b274fc5..a9e8431f7 100644 --- a/app/views/admin/users/edit.html.erb +++ b/app/views/admin/users/edit.html.erb @@ -1,7 +1,5 @@ -

Editar Usuário

+
+

Editar Usuário

-<%= render "form", user: @user %> - -
- <%= link_to "Voltar para usuários", admin_users_path, class: "link-muted" %> + <%= render "form", user: @user %>
diff --git a/app/views/admin/users/index.html.erb b/app/views/admin/users/index.html.erb index d36c458d1..ca9f39609 100644 --- a/app/views/admin/users/index.html.erb +++ b/app/views/admin/users/index.html.erb @@ -1,34 +1,50 @@ -
-

Usuários

+
+

Usuários

<%= link_to "Novo Usuário", new_admin_user_path, class: "btn-primary" %>
-
-
ArquivoEnviado porStatusProgressoErros
<%= spreadsheet_import.file.filename %><%= spreadsheet_import.user.full_name %><%= spreadsheet_import.status == "processing" ? "Processando" : spreadsheet_import.status == "completed" ? "Concluída" : spreadsheet_import.status == "failed" ? "Falhou" : "Pendente" %><%= spreadsheet_import.processed_rows %>/<%= spreadsheet_import.total_rows %><%= spreadsheet_import.spreadsheet_import_row_errors.count %><%= link_to "View", admin_spreadsheet_import_path(spreadsheet_import), class: "link-action" %>
<%= spreadsheet_import.file.filename %><%= spreadsheet_import.user.full_name %> + + <%= spreadsheet_import.status == "processing" ? "Processando" : spreadsheet_import.status == "completed" ? "Concluída" : spreadsheet_import.status == "failed" ? "Falhou" : "Pendente" %> + + <%= spreadsheet_import.processed_rows %>/<%= spreadsheet_import.total_rows %><%= spreadsheet_import.spreadsheet_import_row_errors.count %> + <%= link_to admin_spreadsheet_import_path(spreadsheet_import), title: "Ver Detalhes", class: "p-2 rounded-md text-gray-400 hover:text-emerald-600 hover:bg-emerald-50 transition-colors inline-block" do %> + + <% end %> +
- - - - - - - +
+
AvatarNomeE-mailPapelAções
+ + + + + + + - + <% @users.each do |user| %> - - + - - - - + + + <% end %> diff --git a/app/views/admin/users/new.html.erb b/app/views/admin/users/new.html.erb index 3827baa80..71c2ce9da 100644 --- a/app/views/admin/users/new.html.erb +++ b/app/views/admin/users/new.html.erb @@ -1,7 +1,5 @@ -

Novo Usuário

+
+

Novo Usuário

-<%= render "form", user: @user %> - -
- <%= link_to "Voltar para usuários", admin_users_path, class: "link-muted" %> + <%= render "form", user: @user %>
diff --git a/app/views/layouts/_flash.html.erb b/app/views/layouts/_flash.html.erb index 0024423a9..707c2fe85 100644 --- a/app/views/layouts/_flash.html.erb +++ b/app/views/layouts/_flash.html.erb @@ -1,7 +1,25 @@ <% if alert = flash[:alert] %> -

<%= alert %>

+
+
+
+ +
+
+

<%= alert %>

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

<%= notice %>

+
+
+
+ +
+
+

<%= notice %>

+
+
+
<% end %> From 5f8f71467c49d003b4f58e74c4bdbba10a5ddbec Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Thu, 3 Sep 2026 19:45:38 -0300 Subject: [PATCH 38/68] config: adiciona traducoes i18n para pt-BR e padroniza coluna para nome --- Gemfile | 2 ++ Gemfile.lock | 5 +++++ app/jobs/spreadsheet_import_job.rb | 2 +- app/models/spreadsheet_import.rb | 6 +++--- app/models/user.rb | 8 ++++---- config/application.rb | 1 + config/locales/pt-BR.yml | 13 +++++++++++++ spec/fixtures/files/malformed_import.csv | Bin 32 -> 27 bytes spec/fixtures/files/mixed_import.csv | 2 +- spec/fixtures/files/mixed_import.xlsx | Bin 4905 -> 4902 bytes spec/fixtures/files/valid_import.csv | 2 +- spec/jobs/spreadsheet_import_job_spec.rb | 6 +++--- ...n_spreadsheet_import_live_progress_spec.rb | 6 +++--- 13 files changed, 37 insertions(+), 16 deletions(-) create mode 100644 config/locales/pt-BR.yml diff --git a/Gemfile b/Gemfile index 6b44173c3..49e32b0e2 100644 --- a/Gemfile +++ b/Gemfile @@ -96,3 +96,5 @@ group :development do # Use console on exceptions pages [https://github.com/rails/web-console] gem "web-console" end + +gem "rails-i18n", "~> 8.1" diff --git a/Gemfile.lock b/Gemfile.lock index bcdc1537e..2d28a0d16 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -281,6 +281,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) @@ -465,6 +468,7 @@ DEPENDENCIES pundit pundit-matchers rails (~> 8.1.3, >= 8.1.3.1) + rails-i18n (~> 8.1) roo rspec-rails rubocop-rails-omakase @@ -590,6 +594,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/jobs/spreadsheet_import_job.rb b/app/jobs/spreadsheet_import_job.rb index 332cde61c..5d13903d3 100644 --- a/app/jobs/spreadsheet_import_job.rb +++ b/app/jobs/spreadsheet_import_job.rb @@ -39,7 +39,7 @@ def parse_rows(import) def import_row(import, row_number, data) user = User.new( email: data["email"].to_s.strip, - full_name: data["full_name"].to_s.strip, + full_name: data["nome"].to_s.strip, password: SecureRandom.hex(16), role: :no_admin ) diff --git a/app/models/spreadsheet_import.rb b/app/models/spreadsheet_import.rb index 9283e9bfd..0e32dfc90 100644 --- a/app/models/spreadsheet_import.rb +++ b/app/models/spreadsheet_import.rb @@ -21,12 +21,12 @@ def progress_percent private def file_must_be_a_supported_spreadsheet unless file.attached? - errors.add(:file, "must be attached") + errors.add(:file, "precisa ser enviada") return end - errors.add(:file, "must be a CSV or XLSX file") unless File.extname(file.filename.to_s).downcase.in?(ALLOWED_EXTENSIONS) - errors.add(:file, "is too large (max #{MAX_BYTES / 1.megabyte}MB)") if file.byte_size > MAX_BYTES + errors.add(:file, "deve ser um arquivo CSV ou XLSX") unless File.extname(file.filename.to_s).downcase.in?(ALLOWED_EXTENSIONS) + errors.add(:file, "é muito grande (máx #{MAX_BYTES / 1.megabyte}MB)") if file.byte_size > MAX_BYTES end def enqueue_import_job diff --git a/app/models/user.rb b/app/models/user.rb index aad4d4853..2c7bb4bfa 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -23,15 +23,15 @@ class User < ApplicationRecord private def avatar_must_be_a_supported_image - errors.add(:avatar, "must be a PNG, JPEG or WEBP image") unless avatar.content_type.in?(AVATAR_CONTENT_TYPES) - errors.add(:avatar, "is too large (max #{AVATAR_MAX_BYTES / 1.megabyte}MB)") if avatar.byte_size > AVATAR_MAX_BYTES + errors.add(:avatar, "deve ser uma imagem PNG, JPEG ou WEBP") unless avatar.content_type.in?(AVATAR_CONTENT_TYPES) + errors.add(:avatar, "é muito grande (máx #{AVATAR_MAX_BYTES / 1.megabyte}MB)") if avatar.byte_size > AVATAR_MAX_BYTES end def avatar_url_must_be_http uri = URI.parse(avatar_url) - errors.add(:avatar_url, "must be a valid http(s) URL") unless uri.is_a?(URI::HTTP) && uri.host.present? + errors.add(:avatar_url, "deve ser uma URL http(s) válida") unless uri.is_a?(URI::HTTP) && uri.host.present? rescue URI::InvalidURIError - errors.add(:avatar_url, "must be a valid http(s) URL") + errors.add(:avatar_url, "deve ser uma URL http(s) válida") end def enqueue_avatar_download diff --git a/config/application.rb b/config/application.rb index f77f26fb0..f85818d01 100644 --- a/config/application.rb +++ b/config/application.rb @@ -35,6 +35,7 @@ class Application < Rails::Application # # config.time_zone = "Central Time (US & Canada)" # config.eager_load_paths << Rails.root.join("extras") + config.i18n.default_locale = :"pt-BR" # Don't generate system test files. config.generators.system_tests = nil diff --git a/config/locales/pt-BR.yml b/config/locales/pt-BR.yml new file mode 100644 index 000000000..d7d666831 --- /dev/null +++ b/config/locales/pt-BR.yml @@ -0,0 +1,13 @@ +pt-BR: + activerecord: + attributes: + spreadsheet_import: + file: "Planilha" + user: + avatar: "Avatar" + avatar_url: "URL do Avatar" + full_name: "Nome" + email: "E-mail" + role: "Papel" + password: "Senha" + password_confirmation: "Confirmação de senha" diff --git a/spec/fixtures/files/malformed_import.csv b/spec/fixtures/files/malformed_import.csv index eb8c1aa618baa8882a3c3ed2c5b873ac93a0e0e7..0e8c2931826ce116b392729bef776c45d2bf7537 100644 GIT binary patch literal 27 icmYezP0Y;E$;;18<@*1RflVXuIPh|#i7&LZqm~x6>Iq)=Ok$*XuT#X4QX!Q-|Zw_m-clJ`qSS9eg z=ION(ipm=Jp608ZLd_vV7s}*nJ)^oRf5A+vur6~h!8jMI`K8u`aZ9vOKX%$mfNrJd zoFgpif*YVepjB;-Y`Fjd2}0^_*IcG*{h$?+&NaAkxvx-A=*3&HRHF8MSE^VfOu)j{ zHfL|DauqtC_R_NV+1*duIZ+3u_p(&##Txxc56viApX4q3)@1*@Wq0P@0d;N3e+&>U z&pgFE^3VM(<;2!4SfUB=+_f{ITHG%c?u>_{-l^{uT)GMiwsHTLU~tCKos-I-DjMv; z7Nzy?_rCV`|Ar0Put6IhMGNYrTuD=gEDp6=dv!l4mOWkC);f*|g-h2M)b#o|Ibuv7 z2Nz+0gk=>$(zso9yGd2Rp;thWf7|fGUHD-qPRd^48*Ern3g{)O_$ZTZgef6-H#I^> zMyI=sPCM~F%AnihjvGS*&Aa)R%7YZnb}5`4As0`WJo&UW-QHI$tyA&pN8XXqXqVBb z`;~0CL8|dstGZF9RVjGF5ROtc-X%8fPE2!oEU9jU8$#%MKf&I|<&M`(ZX|ZxS3=n1 zy__qSZa>J9(D1%=E5uvvW-vw1Y4Q?H68zx zkp;kiETjN*$p8QVH~|0v4*&oFcx*3sZ*ps5Z*OZZcx`Nrk56mEFbu`NiqU;_GR9a# zoWdA`-gem7S+kYKG`54}{rUJ4X-luY=pjAv(^J3uOSAbQmRPcbcGG~N>`RFG;Nau^ z_3jCFL;qX&_*Kf$=ynzdQ>{y9t@w!&%BC!Tl+_svC#>4?!OFsfK_;S_W_#Z>k2Yb< za3XYk3!{u<^z>5vccNTR5T+TcW|b)}0@q}3Yywtlx6Olt5oaN=L%-gavzoQ;gT4tS zV@LC1ONcD zN(WR2e_Xuo>I?w@0ObJy01*HH0Az1tP;zf@b1!3Wa%C=fZES6kQES5>5XZkt@IBEa zrL1DohmAgMFj&UM-bAmdEE-YQWZ!-!ZL=2oc=!L^&y!=@QFqA^oL9Z6P@WN#fDye^ zrmfI>vr8Y)wr0Xo(K~o`y#*H(_#}=QPlc_}e<1`*G4|pBn)@sgCWiaoX&z$fT5P%a z<}KiYkTup2xa5K3NtoKjh|WqW7Nz}h-7Js-LkAj6@Ho$M{Lg9?el6UcxP(`90H^(3 zcup2_^lG7oVaSGZ=HmW2{(O0Uo4=YW;{!JWP|c*E!T}GxtNGaZk>RTjJ3oACfJ~yE zFP_%EBp>DDQ?o;LL5g)sHYq8ad_z|S%~xNO>t=pFhl8V zJOVKXlTZg0lf@7alOqxu0icsU5;_8m4U?-49Fvw35F1>)?&=Hy0089y000pH00000 c00031005+cjg!F=J^{#*6C(zJ5C8xG074b3b^rhX delta 1442 zcmV;T1zq~4CaET{b^-~Qtuo2<0ssKtlYasse{ECSFbw`HxqPQ-3+-4h4dbnYyE3>H z#@)W`+(c=7ICjofXyMmSc3#@vARQlyrAUt+NwyT8ZF1qgf;L>qsXyrTeGg=;5-wL$ z|LJjdbn2f);acezlL4Sd=F&|446&F5fr&H7ndzwlBz;Mh&KZ)ZSAi)su*5m#A{h4j zf8!u$T>2HlME?(lN>d)gm5R$8Bvv3A1VbWgGF}*mHZcW@Vd_&r1Ns&GD0E>Tv=@a1 zTfqYGsURuk_^2L8G4(0UAPR%pE(#M)5o`@S4e8WBA51RBgcG#-8uJ&2wb?m)38btP z_+9hlQVB(A4SY}YRYsxakfIA^GPRyje_o|vrd3#%IhSCZi`D#8Yr?ppvQa;_+ERdS zrRR(zEUJWSpx>ZXWsYpQ00A*V;=ZoAOw{^8DHwzKEHC zh0Sfw-c;!_bUy7zTfNV|{ji-ARbaX=OQl|{(QUGCM$!5tH`TW$yXU65Gj|WDe``x- zfXedB6U?Umso#{Gs&xal&;)qu+8I+W?w1mG#=}wX*!OZSU4}W^xc^HqIOFKfacNNH zHTIxEY5n`Xul?P>VU0Gd(S`@nf{K(&>53tXLao+b)lZ6LPv^F^u118yrE3gox_z7+ zF`~!8MHnDqSw#@nZWrBdX(_}Xf5~##cMT|f%VD_XFzf`&fG7MLY*=0h=*24kh)*}f zjfL>8D~JwG$6HRvoq!)v==Qkd#?V~zuK%s*9)*)Fg_BM|sql!&qfa~3?R~+Lii%g= z^$td(Eu&HQW!Z3pl;fdZb)#IBh2SwmI9RIjme{xxlD{%}h*USiEg^K>f1hCIjk)6` z6S1B8^bqXvUd|Ouns-@fF7du_o5WjfM=?duXaG|QYe8xLt|T&XNh=6+Jy~gEB2{Q# z8C2`$j_DOAR8fup-j`9m5gGwiQLoyiLK+ygkh#HcjmRcdG_)HbX!lrISfR3@EdT%jlad9ue|@RtbHo4u051Um01p5F0C;RKcW-iQVsCG2E_iKh zjgP@j!!QhnUnTOLrA26BRnuJ%(r!qD2MCS3Ms4EC4jYe;={9lYV#~kn&%fLKcbd%^ zSt4Zz^`-)Yxu+2G$-&3_>%%kbyY@mneG*MZr?WU1OIaFgr5`bQ+0cSnWIF?K@o;kP)$@**;d))+Qe_TnP>The5*-dwik(D`75I2;=OFdX+IQ0)Nil*aTm&-c?Ty z20sge?b`Le+_G8YA?TZ6vQOyX_^W8eO;ni=p;n|_=k8gz?fppi1G6y)Y6k@EhELR! zpAH{?O;3a{5QhH>=^a|Ytl9v3(CkSQ6EPZZO*^uU^doIu@ZSsFU1XCz&b-e&oo1@- zsQTmp&P$_flxGAbpt%uJcQtxzcj+D4HWjy&8wW4WSa3mtPo^>LDYrG+hhQnjp6@|1 zpG}0G!Y|_#3sdPjY#IM%9pHkH4OS4CV1eO(S(w_zh)znu7p47ieG~|ep$7#zc${ZB z{%4g8zZR}eoWm6zz$t$fj>$rfUM|!y4B1ddZrVS`AJ0#(@vEuSJ}}JzHI?9$J7B@M zhK;=+E4=Ddr-zRX5Xq#c$FUt0ObJy01*HH00000009610HlGClfDu@0n3y95+eqL5C8xG0HSr7NB{r; diff --git a/spec/fixtures/files/valid_import.csv b/spec/fixtures/files/valid_import.csv index 0859a3c96..15ed45438 100644 --- a/spec/fixtures/files/valid_import.csv +++ b/spec/fixtures/files/valid_import.csv @@ -1,4 +1,4 @@ -email,full_name +email,nome alice@example.com,Alice Example bob@example.com,Bob Example carol@example.com,Carol Example diff --git a/spec/jobs/spreadsheet_import_job_spec.rb b/spec/jobs/spreadsheet_import_job_spec.rb index 577ab8663..4cad969ed 100644 --- a/spec/jobs/spreadsheet_import_job_spec.rb +++ b/spec/jobs/spreadsheet_import_job_spec.rb @@ -27,9 +27,9 @@ def spreadsheet_import_with(fixture_name, content_type) errors = spreadsheet_import.spreadsheet_import_row_errors.order(:row_number) expect(errors.pluck(:row_number)).to eq([ 3, 4, 5 ]) - expect(errors[0].message).to match(/email can't be blank/i) - expect(errors[1].message).to match(/email is invalid/i) - expect(errors[2].message).to match(/email has already been taken/i) + expect(errors[0].message).to match(/e-mail não pode ficar em branco/i) + expect(errors[1].message).to match(/e-mail não é válido/i) + expect(errors[2].message).to match(/e-mail já está em uso/i) end it "gives imported users an unguessable random password" do diff --git a/spec/system/admin_spreadsheet_import_live_progress_spec.rb b/spec/system/admin_spreadsheet_import_live_progress_spec.rb index 48d6c90e7..0aff43ac2 100644 --- a/spec/system/admin_spreadsheet_import_live_progress_spec.rb +++ b/spec/system/admin_spreadsheet_import_live_progress_spec.rb @@ -5,7 +5,7 @@ admin = create(:user, :admin, password: "password123") sign_in_via_ui(admin) - click_link "Importação de Planilhas" + click_link "Importações de Planilha" click_link "Nova Importação" attach_file "Planilha (CSV ou XLSX)", Rails.root.join("spec/fixtures/files/valid_import.csv") @@ -13,14 +13,14 @@ expect(page).to have_content("Planilha enviada. A importação está sendo processada em segundo plano.") within("#spreadsheet_import_progress") do - expect(page).to have_content("Status: Pendente") + expect(page).to have_content("Pendente") expect(page).to have_content("0 / 0 linhas processadas") end SpreadsheetImportJob.perform_now(SpreadsheetImport.last.id) within("#spreadsheet_import_progress") do - expect(page).to have_content("Status: Concluída") + expect(page).to have_content("Concluída") expect(page).to have_content("3 / 3 linhas processadas") end From 8c791d84c4e98ab5b68b685cc4a68675f72d5499 Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Thu, 3 Sep 2026 20:15:42 -0300 Subject: [PATCH 39/68] feat: let admins mark a spreadsheet import as headerless Column mapping was already positional in spirit but the job silently assumed row 1 was always a header, keyed lookups by its literal text (data["email"], data["nome"]), and read/decoded that row's bytes unconditionally. Add a has_header checkbox: mapping is now purely positional (1st column is always the full name, 2nd is always the email, regardless of what the header says), and when checked the header row is skipped without ever being decoded into row data. --- .../admin/spreadsheet_imports_controller.rb | 2 +- app/jobs/spreadsheet_import_job.rb | 10 +++++----- app/views/admin/spreadsheet_imports/new.html.erb | 10 +++++++++- ...0903230822_add_has_header_to_spreadsheet_imports.rb | 5 +++++ db/schema.rb | 3 ++- 5 files changed, 22 insertions(+), 8 deletions(-) create mode 100644 db/migrate/20260903230822_add_has_header_to_spreadsheet_imports.rb diff --git a/app/controllers/admin/spreadsheet_imports_controller.rb b/app/controllers/admin/spreadsheet_imports_controller.rb index 7b483e6f7..390acab56 100644 --- a/app/controllers/admin/spreadsheet_imports_controller.rb +++ b/app/controllers/admin/spreadsheet_imports_controller.rb @@ -35,6 +35,6 @@ def set_spreadsheet_import end def spreadsheet_import_params - params.expect(spreadsheet_import: [ :file ]) + params.expect(spreadsheet_import: [ :file, :has_header ]) end end diff --git a/app/jobs/spreadsheet_import_job.rb b/app/jobs/spreadsheet_import_job.rb index 5d13903d3..88fa3cfe2 100644 --- a/app/jobs/spreadsheet_import_job.rb +++ b/app/jobs/spreadsheet_import_job.rb @@ -26,20 +26,20 @@ def parse_rows(import) import.file.open do |tempfile| extension = File.extname(import.file.filename.to_s).delete(".").downcase.to_sym sheet = Roo::Spreadsheet.open(tempfile.path, extension: extension).sheet(0) - headers = sheet.row(1).map { |header| header.to_s.strip.downcase } + first_data_row = import.has_header? ? 2 : 1 - (2..sheet.last_row).filter_map do |row_number| + (first_data_row..sheet.last_row).filter_map do |row_number| values = sheet.row(row_number) next if values.all? { |value| value.to_s.strip.blank? } - [ row_number, headers.zip(values).to_h ] + [ row_number, { "nome" => values[0].to_s.strip, "email" => values[1].to_s.strip } ] end end end def import_row(import, row_number, data) user = User.new( - email: data["email"].to_s.strip, - full_name: data["nome"].to_s.strip, + email: data["email"], + full_name: data["nome"], password: SecureRandom.hex(16), role: :no_admin ) diff --git a/app/views/admin/spreadsheet_imports/new.html.erb b/app/views/admin/spreadsheet_imports/new.html.erb index 1150d1708..dd0a86b2a 100644 --- a/app/views/admin/spreadsheet_imports/new.html.erb +++ b/app/views/admin/spreadsheet_imports/new.html.erb @@ -6,8 +6,16 @@
<%= form.label :file, "Planilha (CSV ou XLSX)", class: "form-label" %> -

Colunas esperadas: email, nome.

+

+ A 1ª coluna é sempre tratada como Nome completo e a 2ª como E-mail, pela posição — o texto do cabeçalho (se houver) não é lido nem validado. +

<%= form.file_field :file, accept: ".csv,.xlsx", required: true, class: "form-file" %> + +
+ <%= form.check_box :has_header, class: "h-4 w-4 rounded border-gray-300 text-emerald-600 focus:ring-emerald-500" %> + <%= form.label :has_header, "Este arquivo tem uma linha de cabeçalho", class: "text-sm font-medium text-gray-700" %> +
+

Se marcado, a primeira linha é ignorada por completo (nem seu conteúdo é lido).

diff --git a/db/migrate/20260903230822_add_has_header_to_spreadsheet_imports.rb b/db/migrate/20260903230822_add_has_header_to_spreadsheet_imports.rb new file mode 100644 index 000000000..2d61e1620 --- /dev/null +++ b/db/migrate/20260903230822_add_has_header_to_spreadsheet_imports.rb @@ -0,0 +1,5 @@ +class AddHasHeaderToSpreadsheetImports < ActiveRecord::Migration[8.1] + def change + add_column :spreadsheet_imports, :has_header, :boolean, default: true, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 31d3093c5..eef28a9ab 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_171025) do +ActiveRecord::Schema[8.1].define(version: 2026_09_03_230822) do create_table "active_storage_attachments", force: :cascade do |t| t.bigint "blob_id", null: false t.datetime "created_at", null: false @@ -60,6 +60,7 @@ create_table "spreadsheet_imports", force: :cascade do |t| t.datetime "created_at", null: false + t.boolean "has_header", default: true, null: false t.integer "processed_rows", default: 0, null: false t.integer "status", default: 0, null: false t.integer "total_rows", default: 0, null: false From 869dfaa675e54bb196e58a450d1d601f7cc49916 Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Thu, 3 Sep 2026 20:15:51 -0300 Subject: [PATCH 40/68] test: cover the headerless spreadsheet import toggle Reorders every CSV/XLSX fixture to name-first/email-second (the new positional contract) and adds coverage for both has_header states: the header row is never used to map columns (even when its labels don't say nome/email), and a file without a header is read from row 1 onward. --- spec/factories/spreadsheet_imports.rb | 6 +-- .../files/header_labels_mismatch_import.csv | 2 + spec/fixtures/files/malformed_import.csv | Bin 27 -> 27 bytes spec/fixtures/files/mixed_import.csv | 12 ++--- spec/fixtures/files/mixed_import.xlsx | Bin 4902 -> 4863 bytes spec/fixtures/files/valid_import.csv | 8 ++-- spec/fixtures/files/valid_import.xlsx | Bin 4824 -> 4785 bytes .../fixtures/files/valid_import_no_header.csv | 2 + spec/jobs/spreadsheet_import_job_spec.rb | 42 ++++++++++++++++++ spec/models/spreadsheet_import_spec.rb | 8 +++- .../admin/spreadsheet_imports_spec.rb | 8 ++++ ...n_spreadsheet_import_live_progress_spec.rb | 1 + 12 files changed, 75 insertions(+), 14 deletions(-) create mode 100644 spec/fixtures/files/header_labels_mismatch_import.csv create mode 100644 spec/fixtures/files/valid_import_no_header.csv diff --git a/spec/factories/spreadsheet_imports.rb b/spec/factories/spreadsheet_imports.rb index 26621523b..b79cdb6c2 100644 --- a/spec/factories/spreadsheet_imports.rb +++ b/spec/factories/spreadsheet_imports.rb @@ -5,7 +5,7 @@ after(:build) do |spreadsheet_import| spreadsheet_import.file.attach( - io: StringIO.new("email,full_name\nfixture@example.com,Fixture User\n"), + io: StringIO.new("nome,email\nFixture User,fixture@example.com\n"), filename: "import.csv", content_type: "text/csv" ) @@ -15,7 +15,7 @@ factory :spreadsheet_import_row_error do association :spreadsheet_import sequence(:row_number) { |n| n + 1 } - message { "E-mail can't be blank" } - raw_data { { "email" => "", "full_name" => "Missing E-mail" }.to_json } + message { "E-mail não pode ficar em branco" } + raw_data { { "nome" => "Missing E-mail", "email" => "" }.to_json } end end diff --git a/spec/fixtures/files/header_labels_mismatch_import.csv b/spec/fixtures/files/header_labels_mismatch_import.csv new file mode 100644 index 000000000..bfe49c369 --- /dev/null +++ b/spec/fixtures/files/header_labels_mismatch_import.csv @@ -0,0 +1,2 @@ +coluna_a,coluna_b +Henry Example,henry@example.com diff --git a/spec/fixtures/files/malformed_import.csv b/spec/fixtures/files/malformed_import.csv index 0e8c2931826ce116b392729bef776c45d2bf7537..32a98544b5a5799efcfd648187209ec0b46f4faa 100644 GIT binary patch literal 27 icmd1H&rQ`y%}vbA;rjoNfl$jxD75MbbA zX8?n_Oc1&^)HnFHfxzC++9vyi+Z{AYWP8h-NRvMFx_x6})+P`h|9LBcRV@|n|G^^14kmVDQ+@+_ak_O)4) zLC0A1e1cG@6weFyj!Q>wKezJL+SkT-KejBFm4dWYoKc|VI7g^aj&+UK^M+Bh?N zOUh>^wiCX&s;zN0Q@y9?ev{l7lM-|2&{ea?{t>^qY?oP=bk&x8o2>O@6R*$P2qim< zJ7SYxXw+9toGG%4NnLNEjAdWXrSJZ$uKw;j z*8I0)s?*O!Ui&`&_ckcgS`(kXbz=nEtGy1F4z@Qxe)uh4GlP}awzfhx>HPddLr$*DSc=gRaO zze=wJF|qPStg_Du=uQurcDgt~tYpTVlE)E2KYI=H@+V}kyWZrHYJHbI{#fFJ{W&^e z50^zPTKZDJtabkDbDcF|9>$hAkKU!N(vI3*7ySM~-2Hc1-sS(8Hg~bsatmWg=aZ!b z)kUzS>B)|QT8#daa|8nz87A))v|;+jHu<-pJ!9%*YauJ9K;FqKcx5I}7Sdu2n!HcQ Uj&?9qcB0ongCqZD6913Q7ssu?lnbHMA67?!D zr3RKdr$Pk7et&-)6pTy1MwsaT!BAPo6PT)`DnMclqCqgEWKGUXv z!H+{1_Cb4bSh5u?0G~>dGLDbxffSKXWd?B=GAa*AL%@HAwRe>s?3jR_}c^$q54 z4r{Y__EN}LCGflE>9rDy${P5d=Bu1S%^^b<%H(Q2qkp<8!Az^LE^{uyI2Wt=rPhRT zOSDlxcG^mSZl&j(BP{BI8=ya+Rc(%Jxc~tPLh5eUT&8OMpcRtNHMnuPuTW6v#appd zqV|1Ps#qjUz{1uxXK$)<6*`~x(z5s2-A~&&Q3s~?vQ+BD8vRHQ%_v%*x>2T8DR{yVj#4$=B{uF(OmlfGscwWDLg;!w!QRK^ zj(^uoBzD|aLfGTIoGX@YKgg2M@V;~_#9QrVFh$R4@)CuOpfrD15}CNB-2=KAskADQ z7&=r2^`5z7dc_G<)N{Z0Wz?*Kt$?a*cI;9iO^Qay-Nd&>WRoeH)>|QH7g$wVp|+ek z6FWioWfI#p9sdPTO9KQg000080000X0JB{MZwnIu03iVK4^8Im0ssKm2LJ#V005KU z5GRu)5)hLr5gG!D1(Rz9C6j9rDgir_mJvJxF$a^>5kCQ-lN1s<0gRJR5=Q}zla~@c U0mzfq5+4eW0ssJk5C8xG0DdNxX8-^I diff --git a/spec/fixtures/files/valid_import.csv b/spec/fixtures/files/valid_import.csv index 15ed45438..304bc5773 100644 --- a/spec/fixtures/files/valid_import.csv +++ b/spec/fixtures/files/valid_import.csv @@ -1,4 +1,4 @@ -email,nome -alice@example.com,Alice Example -bob@example.com,Bob Example -carol@example.com,Carol Example +nome,email +Alice Example,alice@example.com +Bob Example,bob@example.com +Carol Example,carol@example.com diff --git a/spec/fixtures/files/valid_import.xlsx b/spec/fixtures/files/valid_import.xlsx index 24e0c93e9fff753b9d4cfe5bf33e1466581c5a7a..175711525cd5b535f1373ae4b84a84f0a56219d2 100644 GIT binary patch delta 794 zcmcbix>0pQ5mUYMLgmx?Ba~n)RJO-FlDG$k(T`^9wq`g584{J-16+k-03FR0JzD|+bfmVBRiX2Pk|eIMlI!{nd+3oQ}cw&GhU zVZ?GYRpoyqmkzXX9P1 zD^E7FmQ3+o%qqLLS~FQh_PglDJE}eh&OO!?*3E;vt~I$WaL z=H*IeN99M^`l@)A%`{&$_S3=%Z7+8&+%Fzf zaLFWFZ>GnYk_CMd#md)D1YLjbRk+f8YNf~4>HGJ_buCz6?k#*$H{fIVlv$ms9=xY) z-K3IcsXsrrK-$ZG#wp9ruDpaEJ^NjfMKKv`?tJJDy!^Ge`%oX3B(suF+m|zUm0q;R z22Z^EFib2#FUMYYargesj;s~j!dQ~$5BG{7RQ6|`bv=A9hHD>K7nXq?wONwPkX|gzCon0KIf;_ky1RAnscs^TP91#y2^d099 z0gX902PtH{miAhU^b#dfTC*_FA}`3*8Zzr$nF5;?<$qO6ZU%*etc3#OAx`rh8{)X3 zvQa`PA7x&c>cB0Tl&Oeh!srNq7QeD2>sfmI>|}D>;pZKG{w|)iVfZAjxaD!Au?gA^61zK{yJq^T5Q|X06;{)G z69lv|jf0R8v&7{TEa_kuk{;&ulW*Bv)%jXdppU1_20 zrtU; diff --git a/spec/fixtures/files/valid_import_no_header.csv b/spec/fixtures/files/valid_import_no_header.csv new file mode 100644 index 000000000..deeff97a5 --- /dev/null +++ b/spec/fixtures/files/valid_import_no_header.csv @@ -0,0 +1,2 @@ +Frank Example,frank@example.com +Grace Example,grace@example.com diff --git a/spec/jobs/spreadsheet_import_job_spec.rb b/spec/jobs/spreadsheet_import_job_spec.rb index 4cad969ed..9cd2d1bba 100644 --- a/spec/jobs/spreadsheet_import_job_spec.rb +++ b/spec/jobs/spreadsheet_import_job_spec.rb @@ -72,4 +72,46 @@ def spreadsheet_import_with(fixture_name, content_type) described_class.perform_now(spreadsheet_import.id) }.not_to change(User, :count) end + + describe "has_header" do + it "maps columns positionally: 1st column is always the name, 2nd is always the email" do + spreadsheet_import = spreadsheet_import_with("valid_import.csv", "text/csv") + + described_class.perform_now(spreadsheet_import.id) + + expect(User.exists?(email: "alice@example.com", full_name: "Alice Example")).to be true + end + + it "never uses the header row's own text to map columns, even when it doesn't say nome/email" do + spreadsheet_import = create(:spreadsheet_import, has_header: true) + spreadsheet_import.file.attach( + io: File.open(Rails.root.join("spec/fixtures/files/header_labels_mismatch_import.csv")), + filename: "header_labels_mismatch_import.csv", + content_type: "text/csv" + ) + + described_class.perform_now(spreadsheet_import.id) + + expect(User.exists?(email: "henry@example.com", full_name: "Henry Example")).to be true + end + + it "treats the first row as real data (positionally) when has_header is false" do + spreadsheet_import = create(:spreadsheet_import, has_header: false) + spreadsheet_import.file.attach( + io: File.open(Rails.root.join("spec/fixtures/files/valid_import_no_header.csv")), + filename: "valid_import_no_header.csv", + content_type: "text/csv" + ) + + expect { + described_class.perform_now(spreadsheet_import.id) + }.to change(User, :count).by(2) + + spreadsheet_import.reload + expect(spreadsheet_import).to be_completed + expect(spreadsheet_import.total_rows).to eq(2) + expect(User.exists?(email: "frank@example.com", full_name: "Frank Example")).to be true + expect(User.exists?(email: "grace@example.com", full_name: "Grace Example")).to be true + end + end end diff --git a/spec/models/spreadsheet_import_spec.rb b/spec/models/spreadsheet_import_spec.rb index d40f69edd..2111460ba 100644 --- a/spec/models/spreadsheet_import_spec.rb +++ b/spec/models/spreadsheet_import_spec.rb @@ -20,7 +20,7 @@ it "accepts a .csv file" do spreadsheet_import = build(:spreadsheet_import) - spreadsheet_import.file.attach(io: StringIO.new("email,full_name\n"), filename: "import.csv", content_type: "text/csv") + spreadsheet_import.file.attach(io: StringIO.new("nome,email\n"), filename: "import.csv", content_type: "text/csv") expect(spreadsheet_import).to be_valid end @@ -51,6 +51,12 @@ it { is_expected.to define_enum_for(:status).with_values(pending: 0, processing: 1, completed: 2, failed: 3) } end + describe "#has_header" do + it "defaults to true for a new import" do + expect(SpreadsheetImport.new.has_header).to be true + end + end + describe "#progress_percent" do it "is 0 when there are no rows yet" do spreadsheet_import = build(:spreadsheet_import, total_rows: 0, processed_rows: 0) diff --git a/spec/requests/admin/spreadsheet_imports_spec.rb b/spec/requests/admin/spreadsheet_imports_spec.rb index a5cec0660..d950cd813 100644 --- a/spec/requests/admin/spreadsheet_imports_spec.rb +++ b/spec/requests/admin/spreadsheet_imports_spec.rb @@ -63,6 +63,14 @@ expect(response).to redirect_to(admin_spreadsheet_import_url(spreadsheet_import)) end + it "accepts the has_header flag" do + sign_in_as(admin) + + post admin_spreadsheet_imports_path, params: { spreadsheet_import: { file: csv_file, has_header: false } } + + expect(SpreadsheetImport.last.has_header).to be false + end + it "re-renders the form when no file is attached" do sign_in_as(admin) diff --git a/spec/system/admin_spreadsheet_import_live_progress_spec.rb b/spec/system/admin_spreadsheet_import_live_progress_spec.rb index 0aff43ac2..ef3d83e6a 100644 --- a/spec/system/admin_spreadsheet_import_live_progress_spec.rb +++ b/spec/system/admin_spreadsheet_import_live_progress_spec.rb @@ -9,6 +9,7 @@ click_link "Nova Importação" attach_file "Planilha (CSV ou XLSX)", Rails.root.join("spec/fixtures/files/valid_import.csv") + expect(page).to have_checked_field("Este arquivo tem uma linha de cabeçalho") click_button "Enviar" expect(page).to have_content("Planilha enviada. A importação está sendo processada em segundo plano.") From 4b2ad442fc27d192d318b719f5409be6f148475a Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Thu, 3 Sep 2026 20:15:57 -0300 Subject: [PATCH 41/68] i18n: translate the password reset e-mail and confirmation tooltip to pt-BR Leftover English text from the earlier translation pass: the password-reset mailer templates and the native "Passwords don't match" validation tooltip. --- .../controllers/password_confirmation_controller.js | 2 +- app/views/passwords_mailer/reset.html.erb | 6 +++--- app/views/passwords_mailer/reset.text.erb | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/javascript/controllers/password_confirmation_controller.js b/app/javascript/controllers/password_confirmation_controller.js index decb07c3b..6a3f17dec 100644 --- a/app/javascript/controllers/password_confirmation_controller.js +++ b/app/javascript/controllers/password_confirmation_controller.js @@ -7,6 +7,6 @@ export default class extends Controller { validate() { const mismatch = this.confirmationTarget.value.length > 0 && this.confirmationTarget.value !== this.passwordTarget.value - this.confirmationTarget.setCustomValidity(mismatch ? "Passwords don't match" : "") + this.confirmationTarget.setCustomValidity(mismatch ? "As senhas não coincidem" : "") } } diff --git a/app/views/passwords_mailer/reset.html.erb b/app/views/passwords_mailer/reset.html.erb index 1b0915419..b72aad42a 100644 --- a/app/views/passwords_mailer/reset.html.erb +++ b/app/views/passwords_mailer/reset.html.erb @@ -1,6 +1,6 @@

- You can reset your password on - <%= link_to "this password reset page", edit_password_url(@user.password_reset_token) %>. + Você pode redefinir sua senha + <%= link_to "nesta página de redefinição de senha", edit_password_url(@user.password_reset_token) %>. - This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>. + Este link expira em <%= 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 index aecee82c4..08725775d 100644 --- a/app/views/passwords_mailer/reset.text.erb +++ b/app/views/passwords_mailer/reset.text.erb @@ -1,4 +1,4 @@ -You can reset your password on +Você pode redefinir sua senha em <%= edit_password_url(@user.password_reset_token) %> -This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>. +Este link expira em <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>. From 19dba0425cd5515d3c6f594bca31ab489cc70340 Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Thu, 3 Sep 2026 20:26:38 -0300 Subject: [PATCH 42/68] perf: preload associations to eliminate N+1 queries in admin index views Admin::UsersController#index rendered each row's avatar attachment with a separate query; Admin::SpreadsheetImportsController#index did the same for each row's user, file attachment/blob and row-error count. Preload them with with_attached_avatar/includes, and switch the row-error tally from #count (always hits the DB) to #size (uses the preloaded association). --- app/controllers/admin/spreadsheet_imports_controller.rb | 4 +++- app/controllers/admin/users_controller.rb | 2 +- app/views/admin/spreadsheet_imports/index.html.erb | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/controllers/admin/spreadsheet_imports_controller.rb b/app/controllers/admin/spreadsheet_imports_controller.rb index 390acab56..395253b93 100644 --- a/app/controllers/admin/spreadsheet_imports_controller.rb +++ b/app/controllers/admin/spreadsheet_imports_controller.rb @@ -5,7 +5,9 @@ class Admin::SpreadsheetImportsController < ApplicationController def index authorize SpreadsheetImport, :index? - @spreadsheet_imports = policy_scope(SpreadsheetImport).order(created_at: :desc) + @spreadsheet_imports = policy_scope(SpreadsheetImport) + .includes(:user, :spreadsheet_import_row_errors, file_attachment: :blob) + .order(created_at: :desc) end def new diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 947d414f6..37af5484c 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -5,7 +5,7 @@ class Admin::UsersController < ApplicationController def index authorize User, :index? - @users = policy_scope(User).order(:full_name) + @users = policy_scope(User).with_attached_avatar.order(:full_name) end def new diff --git a/app/views/admin/spreadsheet_imports/index.html.erb b/app/views/admin/spreadsheet_imports/index.html.erb index 94995812b..914bf6e4e 100644 --- a/app/views/admin/spreadsheet_imports/index.html.erb +++ b/app/views/admin/spreadsheet_imports/index.html.erb @@ -26,7 +26,7 @@
- + - - + + + + diff --git a/app/views/admin/spreadsheet_imports/_row_errors.html.erb b/app/views/admin/spreadsheet_imports/_row_errors.html.erb index 5b1efa017..ee0a45654 100644 --- a/app/views/admin/spreadsheet_imports/_row_errors.html.erb +++ b/app/views/admin/spreadsheet_imports/_row_errors.html.erb @@ -1,13 +1,33 @@ -

Erros nas Linhas

-
-
AvatarNomeE-mailPapelAções
+
<% if user.avatar.attached? %> - <%= image_tag user.avatar.variant(resize_to_limit: [ 40, 40 ]), class: "size-10 rounded-full object-cover" %> + <%= image_tag user.avatar.variant(resize_to_limit: [ 40, 40 ]), class: "size-10 object-cover rounded-full shadow-sm" %> + <% else %> +
+ +
<% end %>
<%= user.full_name %><%= user.email %><%= user.role == "admin" ? "Administrador" : "Usuário Normal" %> - <%= link_to "Editar", edit_admin_user_path(user), class: "link-action" %> - <%= button_to "Alterar papel", toggle_role_admin_user_path(user), method: :patch, class: "link-action btn-link" %> - <%= button_to "Excluir", admin_user_path(user), method: :delete, class: "link-danger btn-link", form: { data: { turbo_confirm: "Tem certeza?" } } %> + <%= user.full_name %><%= user.email %> + + <%= user.role == "admin" ? "Administrador" : "Usuário Normal" %> + + +
+ <%= link_to edit_admin_user_path(user), title: "Editar", class: "p-2 rounded-md text-gray-400 hover:text-emerald-600 hover:bg-emerald-50 transition-colors" do %> + + <% end %> + <%= button_to toggle_role_admin_user_path(user), method: :patch, title: "Alterar papel", class: "p-2 rounded-md text-gray-400 hover:text-emerald-600 hover:bg-emerald-50 transition-colors flex items-center justify-center cursor-pointer" do %> + + <% end %> + <%= button_to admin_user_path(user), method: :delete, title: "Excluir", class: "p-2 rounded-md text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors flex items-center justify-center cursor-pointer", form: { data: { turbo_confirm: "Tem certeza?" } } do %> + + <% end %> +
<%= spreadsheet_import.processed_rows %>/<%= spreadsheet_import.total_rows %><%= spreadsheet_import.spreadsheet_import_row_errors.count %><%= spreadsheet_import.spreadsheet_import_row_errors.size %> <%= link_to admin_spreadsheet_import_path(spreadsheet_import), title: "Ver Detalhes", class: "p-2 rounded-md text-gray-400 hover:text-emerald-600 hover:bg-emerald-50 transition-colors inline-block" do %> From 7998fa1f77349d049b8287a40aa5f10616dece9c Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Thu, 3 Sep 2026 20:26:48 -0300 Subject: [PATCH 43/68] perf: cache admin dashboard counts via Solid Cache User.count/group(:role).count ran on every dashboard render even though the one place that actually knows when they change (the after_commit broadcast hook) already recomputes them on every create/destroy/role change. Cache them under a shared key, written through by that same hook, so Solid Cache (already configured, previously unused) actually does something. Also swaps User#normalizes's block for the Ruby 3.4+ implicit `it` parameter, and User#avatar_url from a bare attr_accessor to a typed `attribute` (cast/ dirty-tracking for free), while touching this file. --- app/controllers/admin/dashboards_controller.rb | 5 +++-- app/models/user.rb | 14 +++++++++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/app/controllers/admin/dashboards_controller.rb b/app/controllers/admin/dashboards_controller.rb index ab946160b..758d28227 100644 --- a/app/controllers/admin/dashboards_controller.rb +++ b/app/controllers/admin/dashboards_controller.rb @@ -1,7 +1,8 @@ class Admin::DashboardsController < ApplicationController def show authorize User, :index? - @total_users = User.count - @users_by_role = User.group(:role).count + counts = User.dashboard_counts + @total_users = counts[:total_users] + @users_by_role = counts[:users_by_role] end end diff --git a/app/models/user.rb b/app/models/user.rb index 2c7bb4bfa..b189f8e76 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,6 +1,7 @@ class User < ApplicationRecord AVATAR_CONTENT_TYPES = %w[image/png image/jpeg image/webp].freeze AVATAR_MAX_BYTES = 5.megabytes + DASHBOARD_COUNTS_CACHE_KEY = "admin_dashboard_counts" has_secure_password has_many :sessions, dependent: :destroy @@ -8,9 +9,9 @@ class User < ApplicationRecord enum :role, { no_admin: 0, admin: 1 } - attr_accessor :avatar_url + attribute :avatar_url, :string - normalizes :email, with: ->(e) { e.strip.downcase } + normalizes :email, with: -> { it.strip.downcase } validates :full_name, presence: true validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP } @@ -21,6 +22,10 @@ class User < ApplicationRecord after_commit :enqueue_avatar_download, if: -> { avatar_url.present? } after_commit :broadcast_dashboard_counts, if: -> { destroyed? || previously_new_record? || saved_change_to_role? } + def self.dashboard_counts + Rails.cache.fetch(DASHBOARD_COUNTS_CACHE_KEY) { { total_users: count, users_by_role: group(:role).count } } + end + private def avatar_must_be_a_supported_image errors.add(:avatar, "deve ser uma imagem PNG, JPEG ou WEBP") unless avatar.content_type.in?(AVATAR_CONTENT_TYPES) @@ -39,11 +44,14 @@ def enqueue_avatar_download end def broadcast_dashboard_counts + Rails.cache.delete(DASHBOARD_COUNTS_CACHE_KEY) + counts = self.class.dashboard_counts + Turbo::StreamsChannel.broadcast_replace_to( "admin_dashboard", target: "dashboard_counts", partial: "admin/dashboards/counts", - locals: { total_users: User.count, users_by_role: User.group(:role).count } + locals: counts ) end end From 9a2f13aa69361eb02d78040c794f934dc599f556 Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Thu, 3 Sep 2026 20:26:53 -0300 Subject: [PATCH 44/68] perf: skip redundant file revalidation on each import progress tick The custom file-attachment validation re-ran on every update! call made while processing a spreadsheet (once per row, to bump processed_rows), redoing an Active Storage attachment check that can only ever matter at upload time. The file never changes after creation, so scope the validation to on: :create. --- app/models/spreadsheet_import.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/spreadsheet_import.rb b/app/models/spreadsheet_import.rb index 0e32dfc90..8d1815baa 100644 --- a/app/models/spreadsheet_import.rb +++ b/app/models/spreadsheet_import.rb @@ -8,7 +8,7 @@ class SpreadsheetImport < ApplicationRecord enum :status, { pending: 0, processing: 1, completed: 2, failed: 3 } - validate :file_must_be_a_supported_spreadsheet + validate :file_must_be_a_supported_spreadsheet, on: :create after_commit :enqueue_import_job, on: :create after_commit :broadcast_progress, if: -> { saved_change_to_status? || saved_change_to_processed_rows? || saved_change_to_total_rows? } From a36fe9b5e5dbfb296c2f9e3a0450671f88ee27a4 Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Thu, 3 Sep 2026 20:27:00 -0300 Subject: [PATCH 45/68] feat: load admin user create/edit forms inline via Turbo Frames Opening or editing a user was a full-page navigation away from the table, even though Turbo 8 was already in use elsewhere for live updates. Wrap the form in a turbo_frame_tag targeted from the index's links, with the form itself set to break out to a full visit (turbo_frame: "_top") on submit so create/update/ cancel keep navigating and rendering exactly as before. --- app/views/admin/users/_form.html.erb | 4 ++-- app/views/admin/users/edit.html.erb | 10 ++++++---- app/views/admin/users/index.html.erb | 6 ++++-- app/views/admin/users/new.html.erb | 10 ++++++---- 4 files changed, 18 insertions(+), 12 deletions(-) diff --git a/app/views/admin/users/_form.html.erb b/app/views/admin/users/_form.html.erb index 1ef2dfbb1..71334b788 100644 --- a/app/views/admin/users/_form.html.erb +++ b/app/views/admin/users/_form.html.erb @@ -1,4 +1,4 @@ -<%= form_with model: [ :admin, user ], class: "contents" do |form| %> +<%= form_with model: [ :admin, user ], class: "contents", data: { turbo_frame: "_top" } do |form| %> <%= render "shared/form_errors", record: user %>
@@ -44,6 +44,6 @@
<%= form.submit (user.new_record? ? "Criar Usuário" : "Atualizar Usuário"), class: "btn-primary w-full sm:w-auto text-center cursor-pointer text-lg" %> - <%= link_to "Cancelar", admin_users_path, class: "link-muted" %> + <%= link_to "Cancelar", admin_users_path, class: "link-muted", data: { turbo_frame: "_top" } %>
<% end %> diff --git a/app/views/admin/users/edit.html.erb b/app/views/admin/users/edit.html.erb index a9e8431f7..14704d25b 100644 --- a/app/views/admin/users/edit.html.erb +++ b/app/views/admin/users/edit.html.erb @@ -1,5 +1,7 @@ -
-

Editar Usuário

+<%= turbo_frame_tag "admin_user_form" do %> +
+

Editar Usuário

- <%= render "form", user: @user %> -
+ <%= render "form", user: @user %> +
+<% end %> diff --git a/app/views/admin/users/index.html.erb b/app/views/admin/users/index.html.erb index ca9f39609..74a8ff88b 100644 --- a/app/views/admin/users/index.html.erb +++ b/app/views/admin/users/index.html.erb @@ -1,8 +1,10 @@

Usuários

- <%= link_to "Novo Usuário", new_admin_user_path, class: "btn-primary" %> + <%= link_to "Novo Usuário", new_admin_user_path, class: "btn-primary", data: { turbo_frame: "admin_user_form" } %>
+<%= turbo_frame_tag "admin_user_form" %> +
@@ -35,7 +37,7 @@ + + + diff --git a/app/views/admin/spreadsheet_imports/_row_errors.html.erb b/app/views/admin/spreadsheet_imports/_row_errors.html.erb new file mode 100644 index 000000000..5b1efa017 --- /dev/null +++ b/app/views/admin/spreadsheet_imports/_row_errors.html.erb @@ -0,0 +1,16 @@ +

Erros nas Linhas

+
+
- <%= link_to edit_admin_user_path(user), title: "Editar", class: "p-2 rounded-md text-gray-400 hover:text-emerald-600 hover:bg-emerald-50 transition-colors" do %> + <%= link_to edit_admin_user_path(user), title: "Editar", class: "p-2 rounded-md text-gray-400 hover:text-emerald-600 hover:bg-emerald-50 transition-colors", data: { turbo_frame: "admin_user_form" } do %> <% end %> <%= button_to toggle_role_admin_user_path(user), method: :patch, title: "Alterar papel", class: "p-2 rounded-md text-gray-400 hover:text-emerald-600 hover:bg-emerald-50 transition-colors flex items-center justify-center cursor-pointer" do %> diff --git a/app/views/admin/users/new.html.erb b/app/views/admin/users/new.html.erb index 71c2ce9da..1cb3dfaa5 100644 --- a/app/views/admin/users/new.html.erb +++ b/app/views/admin/users/new.html.erb @@ -1,5 +1,7 @@ -
-

Novo Usuário

+<%= turbo_frame_tag "admin_user_form" do %> +
+

Novo Usuário

- <%= render "form", user: @user %> -
+ <%= render "form", user: @user %> +
+<% end %> From 5d3c8d7d5d5f881a144db4fd5f4f123ebe1c5e71 Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Thu, 3 Sep 2026 20:27:08 -0300 Subject: [PATCH 46/68] refactor: adopt more Rails 8 / Ruby 4 idioms - AvatarFetcher::Result: Struct -> Data.define (it's an immutable value object). - AvatarDownloadJob: ad hoc rescue -> discard_on, so Solid Queue records the discard instead of it being silently swallowed. - PasswordsController#update: params.permit -> params.expect, matching every other controller (needed nesting the form fields under `user`, updated). - RegistrationsController#create: added the same rate_limit already used on sessions/passwords, so public sign-up isn't the one unthrottled endpoint. --- app/controllers/passwords_controller.rb | 6 +++++- app/controllers/registrations_controller.rb | 1 + app/jobs/avatar_download_job.rb | 11 +++++++++-- app/services/avatar_fetcher.rb | 2 +- app/views/passwords/edit.html.erb | 2 +- 5 files changed, 17 insertions(+), 5 deletions(-) diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb index f1418d8cf..4b96a40c2 100644 --- a/app/controllers/passwords_controller.rb +++ b/app/controllers/passwords_controller.rb @@ -19,7 +19,7 @@ def edit end def update - if @user.update(params.permit(:password, :password_confirmation)) + if @user.update(password_params) @user.sessions.destroy_all redirect_to new_session_path, notice: "Sua senha foi redefinida." else @@ -33,4 +33,8 @@ def set_user_by_token rescue ActiveSupport::MessageVerifier::InvalidSignature redirect_to new_password_path, alert: "O link de redefinição é inválido ou expirou." end + + def password_params + params.expect(user: [ :password, :password_confirmation ]) + end end diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index d43ea9659..470729947 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -1,6 +1,7 @@ class RegistrationsController < ApplicationController allow_unauthenticated_access skip_after_action :verify_authorized + rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_registration_path, alert: "Tente novamente mais tarde." } def new @user = User.new diff --git a/app/jobs/avatar_download_job.rb b/app/jobs/avatar_download_job.rb index 9153fd5ae..e49813fd6 100644 --- a/app/jobs/avatar_download_job.rb +++ b/app/jobs/avatar_download_job.rb @@ -1,13 +1,20 @@ class AvatarDownloadJob < ApplicationJob queue_as :default + # AvatarFetcher::FetchError covers permanent failures (bad/blocked URL, unsupported + # content type, oversized file) that a retry wouldn't fix, so it's discarded rather + # than retried — Solid Queue still records the discard instead of it being silently + # swallowed by an ad hoc rescue. + discard_on AvatarFetcher::FetchError do |job, error| + user_id, url = job.arguments + Rails.logger.warn("AvatarDownloadJob: failed to fetch avatar for user #{user_id} from #{url}: #{error.message}") + end + def perform(user_id, url) user = User.find_by(id: user_id) return unless user result = AvatarFetcher.new(url).fetch user.avatar.attach(io: result.io, filename: result.filename, content_type: result.content_type) - rescue AvatarFetcher::FetchError => e - Rails.logger.warn("AvatarDownloadJob: failed to fetch avatar for user #{user_id} from #{url}: #{e.message}") end end diff --git a/app/services/avatar_fetcher.rb b/app/services/avatar_fetcher.rb index 28dc6d839..ec58820e4 100644 --- a/app/services/avatar_fetcher.rb +++ b/app/services/avatar_fetcher.rb @@ -17,7 +17,7 @@ class FetchError < StandardError; end OPEN_TIMEOUT = 5 READ_TIMEOUT = 10 - Result = Struct.new(:io, :content_type, :filename, keyword_init: true) + Result = Data.define(:io, :content_type, :filename) def initialize(url) @url = url diff --git a/app/views/passwords/edit.html.erb b/app/views/passwords/edit.html.erb index 1de581e8d..f96085e25 100644 --- a/app/views/passwords/edit.html.erb +++ b/app/views/passwords/edit.html.erb @@ -1,7 +1,7 @@

Atualizar Senha

- <%= form_with url: password_path(params[:token]), method: :put, class: "contents" do |form| %> + <%= form_with url: password_path(params[:token]), scope: :user, method: :put, class: "contents" do |form| %>
<%= form.label :password, "Nova senha", class: "form-label" %> From b7ca9d464320b41bf5351aec91906b0004354d25 Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Thu, 3 Sep 2026 20:27:14 -0300 Subject: [PATCH 47/68] test: close coverage gaps found during the optimization review RegistrationsController#new and the invalid-params re-render branches of Admin::UsersController#create/#update had no request spec coverage, pulling overall line coverage under the 90% bar once the optimization phase touched nearby lines. --- spec/requests/admin/users_spec.rb | 19 +++++++++++++++++++ spec/requests/registrations_spec.rb | 8 ++++++++ 2 files changed, 27 insertions(+) diff --git a/spec/requests/admin/users_spec.rb b/spec/requests/admin/users_spec.rb index 344912f4f..21eab7109 100644 --- a/spec/requests/admin/users_spec.rb +++ b/spec/requests/admin/users_spec.rb @@ -99,6 +99,16 @@ expect(response).to redirect_to(profile_url) end + + it "re-renders the form with errors when invalid" do + sign_in_as(admin) + + expect { + post admin_users_path, params: { user: { full_name: "", email: "", password: "", password_confirmation: "" } } + }.not_to change(User, :count) + + expect(response).to have_http_status(:unprocessable_entity) + end end describe "PATCH /admin/users/:id" do @@ -134,6 +144,15 @@ expect(response).to redirect_to(profile_url) expect(other_user.reload.full_name).not_to eq("Hacked") end + + it "re-renders the form with errors when invalid" do + other_user = create(:user) + sign_in_as(admin) + + patch admin_user_path(other_user), params: { user: { full_name: "", email: "" } } + + expect(response).to have_http_status(:unprocessable_entity) + end end describe "DELETE /admin/users/:id" do diff --git a/spec/requests/registrations_spec.rb b/spec/requests/registrations_spec.rb index d9d8b20ca..863642a23 100644 --- a/spec/requests/registrations_spec.rb +++ b/spec/requests/registrations_spec.rb @@ -1,6 +1,14 @@ require "rails_helper" RSpec.describe "Registrations", type: :request do + describe "GET /registration/new" do + it "is accessible to a visitor" do + get new_registration_path + + expect(response).to have_http_status(:ok) + end + end + describe "POST /registration" do it "creates a no_admin user, signs them in, and redirects to the profile" do expect { From 8f6bdf3ccfe860503c1670518434a949a7214964 Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Thu, 3 Sep 2026 21:14:58 -0300 Subject: [PATCH 48/68] docs: restore full project documentation in README.md The submission's own "Documentation" rule requires build/seed/run instructions, environment variables and architecture decisions in README.md itself, in English. It had been reverted to just the original test brief plus an AI disclosure block, which doesn't satisfy that rule even though the content existed elsewhere (CLAUDE.md, in Portuguese, meant for AI operational context). Also corrects the disclosure block itself, which cited an inaccurate model/phase breakdown. --- README.md | 335 +++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 243 insertions(+), 92 deletions(-) diff --git a/README.md b/README.md index 609e865b9..b733e5e5c 100644 --- a/README.md +++ b/README.md @@ -1,92 +1,243 @@ -# Modern Fullstack Developer Test (Rails 8 / Ruby 4) - -## AI Usage Disclosure -This project was developed with the assistance of AI coding assistants: -- Claude Sonnet 5 (Phases 0-4) -- Gemini 3.1 Pro (Phases 5-13) - -- 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. +# Fullstack Developer Test — User Management App + +A user management application built for Umanni's Modern Fullstack Developer Test: +role-based authentication, an admin dashboard with real-time counters, full user +CRUD, and asynchronous CSV/XLSX spreadsheet import with a live progress bar. + +### AI Usage Disclosure + +Per Umanni's AI Policy, this is an honest account of the AI assistance actually used: + +- **Claude Code** (Anthropic, powered by Claude models — current session running + **Claude Sonnet 5**) was used throughout the project for code generation, + refactoring, test writing, and this documentation. +- **Aider** with **Claude 3.7 Sonnet** was tried very early on as an initial, + exploratory test of the tool. It did not produce any code that remains in the + current codebase — all application code was written via Claude Code. +- **Gemini** was used to help draft a visual/design redesign roadmap. + +## Tech Stack + +- **Ruby 4.0** / **Rails 8.1** (Ruby 4's ZJIT enabled in production, see + [Architecture Decisions](#architecture-decisions)) +- **Hotwire** (Turbo 8 + Stimulus) — no React/Inertia, see rationale below +- **Tailwind CSS v4** +- **SQLite** (multi-database: primary/cache/queue/cable, native WAL mode) +- **Solid Cache / Solid Queue / Solid Cable** — no Redis required +- **Pundit** for authorization +- **Rails 8 built-in authentication** (`bin/rails generate authentication`) — no Devise +- **RSpec** + FactoryBot + Faker + Shoulda Matchers + SimpleCov + Capybara/Playwright + + `parallel_tests` +- **Propshaft** + importmap for asset management +- **Kamal 2** + **Thruster** for deployment, multi-stage **Docker** build + +## Requirements + +- Ruby 4.0+ (see `.ruby-version`) +- Node.js (only for Playwright's browser binaries used by system specs) +- SQLite 3.8+ +- Docker (optional, for containerized run/deploy) + +## Setup + +```bash +bundle install +bin/rails db:prepare # creates all 4 databases and loads the schema +bin/rails db:seed # creates the bootstrap admin user (see below) +``` + +## Seeding + +Public registration always creates a `no_admin` user (enforced server-side in +`RegistrationsController`, ignoring any injected `role` param), so there is no way +to reach an admin account from the UI alone. `db/seeds.rb` creates one bootstrap +admin, idempotently, so the app is usable immediately after setup: + +| Field | Default | Override with | +|----------|---------------------------------------------|-------------------------| +| Email | `admin@example.com` | `SEED_ADMIN_EMAIL` | +| Password | `password123` | `SEED_ADMIN_PASSWORD` | + +```bash +SEED_ADMIN_EMAIL=you@example.com SEED_ADMIN_PASSWORD=a-strong-password bin/rails db:seed +``` + +Change the default password before deploying anywhere reachable by others. + +## Running in development + +```bash +bin/dev # runs `bin/rails server` + `bin/rails tailwindcss:watch` via Procfile.dev +``` + +Visit `http://localhost:3000`, sign in with the seeded admin (or register a new +regular user), and Solid Queue/Solid Cable both run in-process — no extra services to +start. Outgoing mail (password reset / "set your password" for imported users) opens +automatically in your browser via `letter_opener` in development — nothing to +configure. + +## Running the test suite + +```bash +bin/rails db:test:prepare # after any new migration +bundle exec rspec # full suite, sequential +bundle exec rspec spec/path/to_spec.rb # a single file +bundle exec parallel_rspec spec/ # parallel, same as CI + +# System specs (Playwright) — set this if the Playwright CLI isn't globally resolvable: +PLAYWRIGHT_CLI_EXECUTABLE_PATH=./node_modules/.bin/playwright bundle exec rspec spec/system +``` + +Quality gates: + +```bash +bundle exec rubocop +bundle exec brakeman -q --no-pager +bundle exec bundler-audit check +``` + +Current state: 0 failures, ≥90% SimpleCov line coverage (enforced via +`SimpleCov.minimum_coverage` — the suite itself fails if coverage regresses below +that bar), 0 RuboCop offenses, 0 Brakeman warnings, 0 bundler-audit vulnerabilities. +One system spec (`admin_spreadsheet_import_live_progress_spec.rb`) is a known, +pre-existing timing-related flake when run alongside the full suite (passes reliably +in isolation) — a Solid Cable broadcast race condition unrelated to any single +feature's code. + +## Running with Docker + +```bash +docker build -t fullstack_developer . +docker run -d -p 3000:80 \ + -e RAILS_MASTER_KEY="$(cat config/master.key)" \ + -e SOLID_QUEUE_IN_PUMA=true \ + --name fullstack_developer \ + fullstack_developer +``` + +The image is a non-root, multi-stage build served by **Thruster** (zero-config +asset caching/compression/HTTP proxy) on port 80. `SOLID_QUEUE_IN_PUMA=true` runs +the Solid Queue supervisor inside the same Puma process, so no separate worker +container is needed for this single-server setup. Run `bin/rails db:seed` inside the +container (`docker exec -it fullstack_developer bin/rails db:seed`) to create the +bootstrap admin. + +## Deploying with Kamal 2 + +`config/deploy.yml` is parsed as ERB before YAML, so both the target host and the +container registry are read from environment variables rather than hardcoded — +there is no real production server for this test, so a deploy attempted without +these sane, safe defaults fails fast instead of silently targeting an unrelated +machine: + +```bash +KAMAL_WEB_HOST= \ +KAMAL_REGISTRY_USERNAME= \ +KAMAL_REGISTRY_PASSWORD= \ +bin/kamal deploy +``` + +`RAILS_MASTER_KEY` is picked up by `.kamal/secrets` from `config/master.key` +automatically. + +## Environment Variables + +| Variable | Used by | Purpose | Default | +|---------------------------|--------------------------------|-------------------------------------------------------------------------|----------------------------------| +| `RAILS_MASTER_KEY` | Rails credentials, Kamal | Decrypts `config/credentials.yml.enc` in production | — (required in production) | +| `RAILS_MAX_THREADS` | Puma, `database.yml` | Puma thread pool size / SQLite connection pool size | `3` (Puma) / `5` (DB pool) | +| `PORT` | Puma | Server port | `3000` | +| `SOLID_QUEUE_IN_PUMA` | `config/puma.rb`, Kamal | Runs the Solid Queue supervisor inside the Puma process | unset (off) | +| `JOB_CONCURRENCY` | `config/queue.yml` | Number of Solid Queue worker processes | `1` | +| `RAILS_LOG_LEVEL` | `config/environments/production.rb` | Production log verbosity | `info` | +| `SEED_ADMIN_EMAIL` | `db/seeds.rb` | Bootstrap admin's email | `admin@example.com` | +| `SEED_ADMIN_PASSWORD` | `db/seeds.rb` | Bootstrap admin's password | `password123` | +| `KAMAL_WEB_HOST` | `config/deploy.yml` | Deploy target host/IP | `203.0.113.10` (RFC 5737, fails fast) | +| `KAMAL_REGISTRY_USERNAME` | `config/deploy.yml` | GHCR username / image namespace | `your-github-username` | +| `KAMAL_REGISTRY_PASSWORD` | `.kamal/secrets` | GHCR auth (GitHub PAT, `write:packages` scope) | — (required to deploy) | + +## Architecture Decisions + +- **Hotwire over React/Inertia** — chosen explicitly for this project to keep a + classic-modern monolith: Turbo Streams over Solid Cable cover every real-time + requirement (dashboard counters, import progress) without a client-side JS build + or state-management layer, and Stimulus covers the handful of purely + client-side interactions (mobile nav toggle, live password-confirmation + validation). +- **SQLite in production, multi-database** — `primary`/`cache`/`queue`/`cable`, each + its own SQLite file under `storage/`, mounted as a single Kamal volume. WAL mode + is the Rails 8 SQLite adapter's default, so no extra configuration is needed for + concurrent readers/writers. No Redis, Postgres, or MySQL to provision. +- **Rails 8 built-in authentication**, not Devise — generated via + `bin/rails generate authentication`, then customized: the generator's + `email_address` field was renamed to `email` (matching this project's + requirements), and a `role` enum (`no_admin`/`admin`, default `no_admin`) was + added. Public registration always forces `no_admin` server-side, even if a `role` + param is injected in the request. +- **Pundit for authorization** — `ApplicationController#pundit_user` maps to + `Current.user` (the app uses `Current.user` throughout, not the Devise-style + `current_user`). `after_action :verify_authorized` is enforced globally, with a + narrow `skip_after_action` only on the three pre-authentication controllers + (sessions, passwords, registrations). +- **Spreadsheet import via a single gem (`roo`)** — reads both CSV and XLSX through + the same API (`Roo::Spreadsheet.open`), avoiding a second gem + (`roo-xlsx`/`caxlsx`) purely for one format. An admin can mark whether the file + has a header row; either way, column mapping is purely **positional** (1st column + = full name, 2nd = email) and a header's text is never used to map columns. Each + row is validated and processed independently in a dedicated + `SpreadsheetParser`/`SpreadsheetImportRowImporter` pair of services (the job + itself only orchestrates: parse, loop, track progress, set final status). A bad + row is recorded as a `SpreadsheetImportRowError` (row number + message + raw + data) without aborting the rest of the import. Progress broadcasts are throttled + (once every 10 rows, always on the last row) rather than firing on every single + row, to keep large imports from flooding Turbo Streams with broadcasts; imported + users get an unusable random password and a "set your password" e-mail reusing + the existing password-reset token mechanism, since they never chose one + themselves. +- **Avatar via remote URL** (`app/services/avatar_fetcher.rb`) — fetched with + `Net::HTTP` (never `URI.open`/`open-uri` on a user-supplied URL) behind an SSRF + guard: resolves the host and rejects private/loopback/link-local IPs, limits + redirects, validates `content_type` against an allowlist, and streams the body + with a size cutoff enforced during download rather than after. +- **Ruby 4 ZJIT in production** — enabled via `RUBYOPT="--zjit"` in the Dockerfile. + Rails 8.1 enables YJIT by default in production (`config.yjit = !Rails.env.local?`); + since only one JIT can run per process, `config.yjit = false` is set explicitly in + `config/environments/production.rb` so ZJIT wins cleanly instead of both JITs + fighting for the slot and Ruby printing a boot-time conflict warning. +- **Solid Cache for dashboard counts** — `User.dashboard_counts` caches the + dashboard's total/by-role numbers, written through by the same hook that already + knew when they changed, rather than recomputing on every render. +- **Playwright over a lighter Capybara driver** — every real-time system spec + (Turbo Stream/Action Cable delivery, multi-session dashboard updates) needs a + real JS-executing, WebSocket-capable browser; a lighter driver like Cuprite would + technically cover the same ground, but Playwright/Capybara is the combination + this test's own brief names as the expected frontend-testing stack, so it was + kept as-is rather than swapped for a marginally lighter alternative. + +## Security + +Covered by `spec/requests/security_spec.rb` and verified manually against a real +running server: parameterized queries via ActiveRecord (no raw SQL, immune to the +classic `' OR '1'='1` injection), ERB auto-escaping everywhere (no `html_safe`/`raw`/ +`sanitize` in the codebase — untrusted data, including full names and spreadsheet +row error messages, is always rendered escaped), CSRF protection +(`protect_from_forgery with: :exception`, Rails 8.1's default) rejecting +state-changing requests without a valid authenticity token, rate-limited +authentication endpoints (sign-in, password reset, and registration), strong params +on every controller (no `params.permit!`), and an SSRF-hardened remote avatar +fetcher. `bundle exec brakeman` and `bundle exec bundler-audit check` are both +clean. + +Untrusted external input (spreadsheet cell contents during import, remote avatar +URLs) is always treated as inert data, never as instructions to follow — the same +principle applies to any text sourced from outside the application's own code. + +## Project Structure Highlights + +- `app/models/user.rb` — role enum, avatar validations, dashboard-count broadcast +- `app/services/avatar_fetcher.rb` — SSRF-hardened remote avatar download +- `app/services/spreadsheet_parser.rb` / `spreadsheet_import_row_importer.rb` — + spreadsheet parsing and per-row user creation, orchestrated by + `app/jobs/spreadsheet_import_job.rb` +- `app/policies/` — Pundit authorization policies +- `spec/` — RSpec suite (models, requests, jobs, services, policies, system specs) From 1736efdb66208d0fbe90e686cc870880a9ffc6ae Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Thu, 3 Sep 2026 21:15:07 -0300 Subject: [PATCH 49/68] perf: throttle spreadsheet import broadcasts and decouple them from the dashboard Every processed row triggered two full Turbo Stream broadcasts: one for the import's own progress bar (auto-fired via after_commit on processed_rows) and one for the admin dashboard counts (since creating a User always broadcasts them). For a 10,000-row import that's ~20,000 broadcasts, each a full partial render plus a Solid Cable write. - SpreadsheetImportJob now bumps processed_rows via update_columns (skips validation/callbacks) and calls SpreadsheetImport#broadcast_progress explicitly, throttled to once every 10 rows (always including the last row). - User gets a skip_dashboard_broadcast flag, set by imported rows, so bulk import no longer fires one dashboard broadcast per created user; the job calls the new User.broadcast_dashboard_counts! once at the end instead. --- app/jobs/spreadsheet_import_job.rb | 37 +++++++++++++++------ app/models/spreadsheet_import.rb | 23 +++++++------ app/models/user.rb | 28 ++++++++++------ spec/jobs/spreadsheet_import_job_spec.rb | 41 ++++++++++++++++++++++++ spec/models/spreadsheet_import_spec.rb | 18 +++++++++-- spec/models/user_spec.rb | 10 ++++++ 6 files changed, 125 insertions(+), 32 deletions(-) diff --git a/app/jobs/spreadsheet_import_job.rb b/app/jobs/spreadsheet_import_job.rb index 88fa3cfe2..0eec87788 100644 --- a/app/jobs/spreadsheet_import_job.rb +++ b/app/jobs/spreadsheet_import_job.rb @@ -1,6 +1,11 @@ class SpreadsheetImportJob < ApplicationJob queue_as :default + # Broadcasting progress on every single row floods Turbo Streams/Solid Cable on + # large imports (one full partial render + DB write per row); broadcast at most + # every Nth row instead, always including the last one. + PROGRESS_BROADCAST_INTERVAL = 10 + # Spreadsheet data is untrusted external input: every cell is treated as # plain data (never evaluated or interpreted), and a bad row is recorded as # a SpreadsheetImportRowError instead of aborting the whole import. @@ -13,8 +18,15 @@ def perform(spreadsheet_import_id) rows = parse_rows(import) import.update!(total_rows: rows.size) - rows.each { |row_number, data| import_row(import, row_number, data) } + users_created = 0 + + rows.each_with_index do |(row_number, data), index| + users_created += 1 if import_row(import, row_number, data) + import.update_columns(processed_rows: index + 1) + import.broadcast_progress if broadcast_now?(index, rows.size) + end + User.broadcast_dashboard_counts! if users_created.positive? import.update!(status: :completed) rescue => e Rails.logger.warn("SpreadsheetImportJob: failed to process import #{spreadsheet_import_id}: #{e.message}") @@ -22,6 +34,10 @@ def perform(spreadsheet_import_id) end private + def broadcast_now?(index, total) + (index + 1) % PROGRESS_BROADCAST_INTERVAL == 0 || index == total - 1 + end + def parse_rows(import) import.file.open do |tempfile| extension = File.extname(import.file.filename.to_s).delete(".").downcase.to_sym @@ -36,22 +52,23 @@ def parse_rows(import) end end + # Returns true if the row created a user, false if it was recorded as an error. def import_row(import, row_number, data) user = User.new( email: data["email"], full_name: data["nome"], password: SecureRandom.hex(16), - role: :no_admin + role: :no_admin, + skip_dashboard_broadcast: true ) - unless user.save - import.spreadsheet_import_row_errors.create!( - row_number: row_number, - message: user.errors.full_messages.to_sentence, - raw_data: data.to_json - ) - end + return true if user.save - import.update!(processed_rows: import.processed_rows + 1) + import.spreadsheet_import_row_errors.create!( + row_number: row_number, + message: user.errors.full_messages.to_sentence, + raw_data: data.to_json + ) + false end end diff --git a/app/models/spreadsheet_import.rb b/app/models/spreadsheet_import.rb index 8d1815baa..252d80275 100644 --- a/app/models/spreadsheet_import.rb +++ b/app/models/spreadsheet_import.rb @@ -11,13 +11,25 @@ class SpreadsheetImport < ApplicationRecord validate :file_must_be_a_supported_spreadsheet, on: :create after_commit :enqueue_import_job, on: :create - after_commit :broadcast_progress, if: -> { saved_change_to_status? || saved_change_to_processed_rows? || saved_change_to_total_rows? } + after_commit :broadcast_progress, if: -> { saved_change_to_status? || saved_change_to_total_rows? } def progress_percent return 0 if total_rows.zero? ((processed_rows.to_f / total_rows) * 100).round end + # Per-row progress ticks bypass callbacks entirely (see SpreadsheetImportJob, + # which uses update_columns for those) so this is called explicitly, throttled, + # instead of firing on every single row. + def broadcast_progress + Turbo::StreamsChannel.broadcast_replace_to( + "spreadsheet_import_#{id}", + target: "spreadsheet_import_progress", + partial: "admin/spreadsheet_imports/progress", + locals: { spreadsheet_import: self } + ) + end + private def file_must_be_a_supported_spreadsheet unless file.attached? @@ -32,13 +44,4 @@ def file_must_be_a_supported_spreadsheet def enqueue_import_job SpreadsheetImportJob.perform_later(id) end - - def broadcast_progress - Turbo::StreamsChannel.broadcast_replace_to( - "spreadsheet_import_#{id}", - target: "spreadsheet_import_progress", - partial: "admin/spreadsheet_imports/progress", - locals: { spreadsheet_import: self } - ) - end end diff --git a/app/models/user.rb b/app/models/user.rb index b189f8e76..4085d5189 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -10,6 +10,7 @@ class User < ApplicationRecord enum :role, { no_admin: 0, admin: 1 } attribute :avatar_url, :string + attr_accessor :skip_dashboard_broadcast normalizes :email, with: -> { it.strip.downcase } @@ -20,12 +21,27 @@ class User < ApplicationRecord validate :avatar_url_must_be_http, if: -> { avatar_url.present? } after_commit :enqueue_avatar_download, if: -> { avatar_url.present? } - after_commit :broadcast_dashboard_counts, if: -> { destroyed? || previously_new_record? || saved_change_to_role? } + after_commit :broadcast_dashboard_counts, if: -> { !skip_dashboard_broadcast && (destroyed? || previously_new_record? || saved_change_to_role?) } def self.dashboard_counts Rails.cache.fetch(DASHBOARD_COUNTS_CACHE_KEY) { { total_users: count, users_by_role: group(:role).count } } end + # Used by SpreadsheetImportJob to broadcast once after a bulk import instead of + # once per created user (each of which skips its own broadcast via + # skip_dashboard_broadcast). + def self.broadcast_dashboard_counts! + Rails.cache.delete(DASHBOARD_COUNTS_CACHE_KEY) + counts = dashboard_counts + + Turbo::StreamsChannel.broadcast_replace_to( + "admin_dashboard", + target: "dashboard_counts", + partial: "admin/dashboards/counts", + locals: counts + ) + end + private def avatar_must_be_a_supported_image errors.add(:avatar, "deve ser uma imagem PNG, JPEG ou WEBP") unless avatar.content_type.in?(AVATAR_CONTENT_TYPES) @@ -44,14 +60,6 @@ def enqueue_avatar_download end def broadcast_dashboard_counts - Rails.cache.delete(DASHBOARD_COUNTS_CACHE_KEY) - counts = self.class.dashboard_counts - - Turbo::StreamsChannel.broadcast_replace_to( - "admin_dashboard", - target: "dashboard_counts", - partial: "admin/dashboards/counts", - locals: counts - ) + self.class.broadcast_dashboard_counts! end end diff --git a/spec/jobs/spreadsheet_import_job_spec.rb b/spec/jobs/spreadsheet_import_job_spec.rb index 9cd2d1bba..13b1d8f1d 100644 --- a/spec/jobs/spreadsheet_import_job_spec.rb +++ b/spec/jobs/spreadsheet_import_job_spec.rb @@ -114,4 +114,45 @@ def spreadsheet_import_with(fixture_name, content_type) expect(User.exists?(email: "grace@example.com", full_name: "Grace Example")).to be true end end + + describe "progress broadcast throttling" do + include ActionCable::TestHelper + + it "throttles progress broadcasts instead of firing on every row" do + spreadsheet_import = create(:spreadsheet_import) + rows = 25.times.map { |i| "Person #{i},person#{i}@example.com" } + spreadsheet_import.file.attach( + io: StringIO.new("nome,email\n#{rows.join("\n")}\n"), + filename: "many_rows.csv", + content_type: "text/csv" + ) + + # status:processing (1) + total_rows set (1) + throttled progress every 10 rows + # plus the last row (3, for 25 rows: 10/20/25) + status:completed (1) = 6. + # A per-row broadcast would have produced 25+ instead. + expect { + described_class.perform_now(spreadsheet_import.id) + }.to have_broadcasted_to("spreadsheet_import_#{spreadsheet_import.id}").exactly(6).times + end + end + + describe "dashboard broadcast" do + include ActionCable::TestHelper + + it "broadcasts dashboard counts once after the import, not once per created user" do + spreadsheet_import = spreadsheet_import_with("valid_import.csv", "text/csv") + + expect { + described_class.perform_now(spreadsheet_import.id) + }.to have_broadcasted_to("admin_dashboard").exactly(1).times + end + + it "does not broadcast dashboard counts when no user was created" do + spreadsheet_import = spreadsheet_import_with("malformed_import.csv", "text/csv") + + expect { + described_class.perform_now(spreadsheet_import.id) + }.not_to have_broadcasted_to("admin_dashboard") + end + end end diff --git a/spec/models/spreadsheet_import_spec.rb b/spec/models/spreadsheet_import_spec.rb index 2111460ba..78bdfcc72 100644 --- a/spec/models/spreadsheet_import_spec.rb +++ b/spec/models/spreadsheet_import_spec.rb @@ -96,10 +96,24 @@ .to have_broadcasted_to("spreadsheet_import_#{spreadsheet_import.id}") end - it "broadcasts when processed_rows changes" do + it "broadcasts when total_rows changes" do spreadsheet_import = create(:spreadsheet_import) - expect { spreadsheet_import.update!(processed_rows: 1) } + expect { spreadsheet_import.update!(total_rows: 3) } + .to have_broadcasted_to("spreadsheet_import_#{spreadsheet_import.id}") + end + + it "does not auto-broadcast when only processed_rows changes (throttled explicitly by the job instead)" do + spreadsheet_import = create(:spreadsheet_import) + + expect { spreadsheet_import.update_columns(processed_rows: 1) } + .not_to have_broadcasted_to("spreadsheet_import_#{spreadsheet_import.id}") + end + + it "#broadcast_progress broadcasts on demand" do + spreadsheet_import = create(:spreadsheet_import) + + expect { spreadsheet_import.broadcast_progress } .to have_broadcasted_to("spreadsheet_import_#{spreadsheet_import.id}") end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index fd8a5d03b..f875e6838 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -127,5 +127,15 @@ expect { user.update!(full_name: "New Name") }.not_to have_broadcasted_to("admin_dashboard") end + + it "does not broadcast when skip_dashboard_broadcast is set (bulk import path)" do + user = build(:user, skip_dashboard_broadcast: true) + + expect { user.save! }.not_to have_broadcasted_to("admin_dashboard") + end + + it "self.broadcast_dashboard_counts! broadcasts on demand" do + expect { User.broadcast_dashboard_counts! }.to have_broadcasted_to("admin_dashboard") + end end end From 0bb46556e5aeacc995a35ed49e8de036c507ed3f Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Thu, 3 Sep 2026 21:15:16 -0300 Subject: [PATCH 50/68] perf: append row errors instead of re-rendering the whole list each time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SpreadsheetImportRowError broadcasts now append just the new row (broadcast_append_to) instead of the progress partial re-rendering every accumulated error on every broadcast — O(1) per error instead of O(errors so far), which mattered once row errors could reach into the hundreds/thousands. Also moves Solid Cable's message trim off the synchronous per-broadcast path (autotrim does a DELETE attempt on every single write) onto the same scheduled- job pattern already used for Solid Queue's cleanup, via config/recurring.yml. --- app/models/spreadsheet_import_row_error.rb | 16 ++++++++++++++ .../spreadsheet_imports/_progress.html.erb | 22 ------------------- .../spreadsheet_imports/_row_error.html.erb | 4 ++++ .../spreadsheet_imports/_row_errors.html.erb | 16 ++++++++++++++ .../admin/spreadsheet_imports/show.html.erb | 1 + config/cable.yml | 4 ++++ config/recurring.yml | 4 ++++ 7 files changed, 45 insertions(+), 22 deletions(-) create mode 100644 app/views/admin/spreadsheet_imports/_row_error.html.erb create mode 100644 app/views/admin/spreadsheet_imports/_row_errors.html.erb diff --git a/app/models/spreadsheet_import_row_error.rb b/app/models/spreadsheet_import_row_error.rb index 6b93338ca..05c2883a8 100644 --- a/app/models/spreadsheet_import_row_error.rb +++ b/app/models/spreadsheet_import_row_error.rb @@ -1,6 +1,22 @@ class SpreadsheetImportRowError < ApplicationRecord + include ActionView::RecordIdentifier + belongs_to :spreadsheet_import validates :row_number, presence: true validates :message, presence: true + + after_create_commit :broadcast_append + + private + # Appends just this row instead of the whole progress partial re-rendering + # every error every time — O(1) per error instead of O(errors so far). + def broadcast_append + Turbo::StreamsChannel.broadcast_append_to( + "spreadsheet_import_#{spreadsheet_import_id}", + target: dom_id(spreadsheet_import, :row_errors), + partial: "admin/spreadsheet_imports/row_error", + locals: { row_error: self } + ) + end end diff --git a/app/views/admin/spreadsheet_imports/_progress.html.erb b/app/views/admin/spreadsheet_imports/_progress.html.erb index ab003783f..9e85a6648 100644 --- a/app/views/admin/spreadsheet_imports/_progress.html.erb +++ b/app/views/admin/spreadsheet_imports/_progress.html.erb @@ -12,26 +12,4 @@

<%= spreadsheet_import.processed_rows %> / <%= spreadsheet_import.total_rows %> linhas processadas

- - <% if spreadsheet_import.spreadsheet_import_row_errors.any? %> -

Erros nas Linhas

-
- - - - - - - - - <% spreadsheet_import.spreadsheet_import_row_errors.order(:row_number).each do |row_error| %> - - - - - <% end %> - -
LinhaMotivo
<%= row_error.row_number %><%= row_error.message %>
-
- <% end %>
diff --git a/app/views/admin/spreadsheet_imports/_row_error.html.erb b/app/views/admin/spreadsheet_imports/_row_error.html.erb new file mode 100644 index 000000000..8b573d292 --- /dev/null +++ b/app/views/admin/spreadsheet_imports/_row_error.html.erb @@ -0,0 +1,4 @@ +
<%= row_error.row_number %><%= row_error.message %>
+ + + + + + + + <%= render partial: "admin/spreadsheet_imports/row_error", + collection: spreadsheet_import.spreadsheet_import_row_errors.order(:row_number), + as: :row_error %> + +
LinhaMotivo
+
diff --git a/app/views/admin/spreadsheet_imports/show.html.erb b/app/views/admin/spreadsheet_imports/show.html.erb index 57430fa6c..d3714e25c 100644 --- a/app/views/admin/spreadsheet_imports/show.html.erb +++ b/app/views/admin/spreadsheet_imports/show.html.erb @@ -9,6 +9,7 @@ <%= turbo_stream_from "spreadsheet_import_#{@spreadsheet_import.id}" %> <%= render "progress", spreadsheet_import: @spreadsheet_import %> + <%= render "row_errors", spreadsheet_import: @spreadsheet_import %>
<%= link_to "Voltar para importações", admin_spreadsheet_imports_path, class: "link-muted font-medium" %> diff --git a/config/cable.yml b/config/cable.yml index b9adc5aa3..661433b3f 100644 --- a/config/cable.yml +++ b/config/cable.yml @@ -15,3 +15,7 @@ production: writing: cable polling_interval: 0.1.seconds message_retention: 1.day + # Autotrim (the default) runs a synchronous DELETE attempt on every single + # broadcast, which adds up fast under high-frequency writes (e.g. per-row + # import progress). Trim on a schedule instead (see config/recurring.yml). + autotrim: false diff --git a/config/recurring.yml b/config/recurring.yml index b4207f9b0..a34a0cf2a 100644 --- a/config/recurring.yml +++ b/config/recurring.yml @@ -13,3 +13,7 @@ production: clear_solid_queue_finished_jobs: command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)" schedule: every hour at minute 12 + + trim_solid_cable_messages: + class: SolidCable::TrimJob + schedule: every 5 minutes From 2f83118d7b1a5e587b7b9a793e548f0dad5fea5b Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Thu, 3 Sep 2026 21:32:16 -0300 Subject: [PATCH 51/68] refactor: extract spreadsheet parsing and row-import logic into services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SpreadsheetImportJob mixed three concerns: ActiveJob lifecycle, spreadsheet parsing (Roo setup, header handling, positional mapping), and per-row business logic (building a User, recording row errors). Split the latter two into SpreadsheetParser and SpreadsheetImportRowImporter (app/services/, following the existing AvatarFetcher convention), leaving the job as pure orchestration. Also drops the redundant "always broadcast on the last row" throttle exception: the status: :completed transition right after the loop already broadcasts the final row count on its own (a regular update!), so forcing an extra broadcast immediately before it just fired two broadcasts back-to-back for no benefit — which turned out to be exactly the race behind the system spec's documented intermittent flake (confirmed by removing it: 8/8 clean runs afterward, versus frequent failures before, even with a 20s wait). --- README.md | 11 ++-- app/jobs/spreadsheet_import_job.rb | 54 +++------------ .../spreadsheet_import_row_importer.rb | 43 ++++++++++++ app/services/spreadsheet_parser.rb | 37 +++++++++++ spec/jobs/spreadsheet_import_job_spec.rb | 48 +------------- .../spreadsheet_import_row_importer_spec.rb | 53 +++++++++++++++ spec/services/spreadsheet_parser_spec.rb | 65 +++++++++++++++++++ 7 files changed, 215 insertions(+), 96 deletions(-) create mode 100644 app/services/spreadsheet_import_row_importer.rb create mode 100644 app/services/spreadsheet_parser.rb create mode 100644 spec/services/spreadsheet_import_row_importer_spec.rb create mode 100644 spec/services/spreadsheet_parser_spec.rb diff --git a/README.md b/README.md index b733e5e5c..c4f0543de 100644 --- a/README.md +++ b/README.md @@ -189,11 +189,12 @@ automatically. itself only orchestrates: parse, loop, track progress, set final status). A bad row is recorded as a `SpreadsheetImportRowError` (row number + message + raw data) without aborting the rest of the import. Progress broadcasts are throttled - (once every 10 rows, always on the last row) rather than firing on every single - row, to keep large imports from flooding Turbo Streams with broadcasts; imported - users get an unusable random password and a "set your password" e-mail reusing - the existing password-reset token mechanism, since they never chose one - themselves. + to once every 10 rows rather than firing on every single row, to keep large + imports from flooding Turbo Streams with broadcasts — the final state is always + covered separately by the status transition at the end of the import, which + already reflects the finished row count on its own. Imported users get an + unusable random password and a "set your password" e-mail reusing the existing + password-reset token mechanism, since they never chose one themselves. - **Avatar via remote URL** (`app/services/avatar_fetcher.rb`) — fetched with `Net::HTTP` (never `URI.open`/`open-uri` on a user-supplied URL) behind an SSRF guard: resolves the host and rejects private/loopback/link-local IPs, limits diff --git a/app/jobs/spreadsheet_import_job.rb b/app/jobs/spreadsheet_import_job.rb index 0eec87788..e6ecbda2f 100644 --- a/app/jobs/spreadsheet_import_job.rb +++ b/app/jobs/spreadsheet_import_job.rb @@ -3,27 +3,28 @@ class SpreadsheetImportJob < ApplicationJob # Broadcasting progress on every single row floods Turbo Streams/Solid Cable on # large imports (one full partial render + DB write per row); broadcast at most - # every Nth row instead, always including the last one. + # every Nth row instead. The final state is always covered separately by the + # status: :completed/:failed transition below (a regular update!, so it + # broadcasts on its own) — forcing an extra broadcast on the very last row here + # too would just double up with that one, back to back. PROGRESS_BROADCAST_INTERVAL = 10 - # Spreadsheet data is untrusted external input: every cell is treated as - # plain data (never evaluated or interpreted), and a bad row is recorded as - # a SpreadsheetImportRowError instead of aborting the whole import. def perform(spreadsheet_import_id) import = SpreadsheetImport.find_by(id: spreadsheet_import_id) return unless import&.pending? import.update!(status: :processing) - rows = parse_rows(import) + rows = SpreadsheetParser.new(import).rows import.update!(total_rows: rows.size) + row_importer = SpreadsheetImportRowImporter.new(import) users_created = 0 rows.each_with_index do |(row_number, data), index| - users_created += 1 if import_row(import, row_number, data) + users_created += 1 if row_importer.import(row_number, data) import.update_columns(processed_rows: index + 1) - import.broadcast_progress if broadcast_now?(index, rows.size) + import.broadcast_progress if ((index + 1) % PROGRESS_BROADCAST_INTERVAL).zero? end User.broadcast_dashboard_counts! if users_created.positive? @@ -32,43 +33,4 @@ def perform(spreadsheet_import_id) Rails.logger.warn("SpreadsheetImportJob: failed to process import #{spreadsheet_import_id}: #{e.message}") import&.update!(status: :failed) end - - private - def broadcast_now?(index, total) - (index + 1) % PROGRESS_BROADCAST_INTERVAL == 0 || index == total - 1 - end - - def parse_rows(import) - import.file.open do |tempfile| - extension = File.extname(import.file.filename.to_s).delete(".").downcase.to_sym - sheet = Roo::Spreadsheet.open(tempfile.path, extension: extension).sheet(0) - first_data_row = import.has_header? ? 2 : 1 - - (first_data_row..sheet.last_row).filter_map do |row_number| - values = sheet.row(row_number) - next if values.all? { |value| value.to_s.strip.blank? } - [ row_number, { "nome" => values[0].to_s.strip, "email" => values[1].to_s.strip } ] - end - end - end - - # Returns true if the row created a user, false if it was recorded as an error. - def import_row(import, row_number, data) - user = User.new( - email: data["email"], - full_name: data["nome"], - password: SecureRandom.hex(16), - role: :no_admin, - skip_dashboard_broadcast: true - ) - - return true if user.save - - import.spreadsheet_import_row_errors.create!( - row_number: row_number, - message: user.errors.full_messages.to_sentence, - raw_data: data.to_json - ) - false - end end diff --git a/app/services/spreadsheet_import_row_importer.rb b/app/services/spreadsheet_import_row_importer.rb new file mode 100644 index 000000000..a4bbd1fa6 --- /dev/null +++ b/app/services/spreadsheet_import_row_importer.rb @@ -0,0 +1,43 @@ +# Creates (or records the failure of) a single spreadsheet import row as a User. +# +# Imported users get an unguessable random password (never communicated) since +# they never chose one themselves; a "set your password" e-mail lets them pick a +# real one via the same token mechanism used for password resets. +class SpreadsheetImportRowImporter + def initialize(spreadsheet_import) + @spreadsheet_import = spreadsheet_import + end + + # Returns true if the row created a user, false if it was recorded as an error. + def import(row_number, data) + user = User.new( + email: data["email"], + full_name: data["nome"], + password: SecureRandom.hex(16), + role: :no_admin, + skip_dashboard_broadcast: true + ) + + return record_success(user) if user.save + + record_failure(row_number, data, user) + end + + private + + attr_reader :spreadsheet_import + + def record_success(user) + PasswordsMailer.welcome(user).deliver_later + true + end + + def record_failure(row_number, data, user) + spreadsheet_import.spreadsheet_import_row_errors.create!( + row_number: row_number, + message: user.errors.full_messages.to_sentence, + raw_data: data.to_json + ) + false + end +end diff --git a/app/services/spreadsheet_parser.rb b/app/services/spreadsheet_parser.rb new file mode 100644 index 000000000..819a8f0da --- /dev/null +++ b/app/services/spreadsheet_parser.rb @@ -0,0 +1,37 @@ +# Parses a SpreadsheetImport's attached CSV/XLSX file into (row_number, data) pairs. +# +# Spreadsheet data is untrusted external input: every cell is treated as plain +# data (never evaluated or interpreted). Column mapping is always positional (1st +# column is the full name, 2nd is the email) — a header row's own text, if present, +# is never read to decide the mapping; when has_header? is true, that row is simply +# skipped, never parsed as data. +class SpreadsheetParser + class ParseError < StandardError; end + + def initialize(spreadsheet_import) + @spreadsheet_import = spreadsheet_import + end + + def rows + spreadsheet_import.file.open do |tempfile| + sheet = Roo::Spreadsheet.open(tempfile.path, extension: extension).sheet(0) + first_data_row = spreadsheet_import.has_header? ? 2 : 1 + + (first_data_row..sheet.last_row).filter_map do |row_number| + values = sheet.row(row_number) + next if values.all? { |value| value.to_s.strip.blank? } + [ row_number, { "nome" => values[0].to_s.strip, "email" => values[1].to_s.strip } ] + end + end + rescue => e + raise ParseError, e.message + end + + private + + attr_reader :spreadsheet_import + + def extension + File.extname(spreadsheet_import.file.filename.to_s).delete(".").downcase.to_sym + end +end diff --git a/spec/jobs/spreadsheet_import_job_spec.rb b/spec/jobs/spreadsheet_import_job_spec.rb index 13b1d8f1d..bbc3238af 100644 --- a/spec/jobs/spreadsheet_import_job_spec.rb +++ b/spec/jobs/spreadsheet_import_job_spec.rb @@ -73,48 +73,6 @@ def spreadsheet_import_with(fixture_name, content_type) }.not_to change(User, :count) end - describe "has_header" do - it "maps columns positionally: 1st column is always the name, 2nd is always the email" do - spreadsheet_import = spreadsheet_import_with("valid_import.csv", "text/csv") - - described_class.perform_now(spreadsheet_import.id) - - expect(User.exists?(email: "alice@example.com", full_name: "Alice Example")).to be true - end - - it "never uses the header row's own text to map columns, even when it doesn't say nome/email" do - spreadsheet_import = create(:spreadsheet_import, has_header: true) - spreadsheet_import.file.attach( - io: File.open(Rails.root.join("spec/fixtures/files/header_labels_mismatch_import.csv")), - filename: "header_labels_mismatch_import.csv", - content_type: "text/csv" - ) - - described_class.perform_now(spreadsheet_import.id) - - expect(User.exists?(email: "henry@example.com", full_name: "Henry Example")).to be true - end - - it "treats the first row as real data (positionally) when has_header is false" do - spreadsheet_import = create(:spreadsheet_import, has_header: false) - spreadsheet_import.file.attach( - io: File.open(Rails.root.join("spec/fixtures/files/valid_import_no_header.csv")), - filename: "valid_import_no_header.csv", - content_type: "text/csv" - ) - - expect { - described_class.perform_now(spreadsheet_import.id) - }.to change(User, :count).by(2) - - spreadsheet_import.reload - expect(spreadsheet_import).to be_completed - expect(spreadsheet_import.total_rows).to eq(2) - expect(User.exists?(email: "frank@example.com", full_name: "Frank Example")).to be true - expect(User.exists?(email: "grace@example.com", full_name: "Grace Example")).to be true - end - end - describe "progress broadcast throttling" do include ActionCable::TestHelper @@ -128,11 +86,11 @@ def spreadsheet_import_with(fixture_name, content_type) ) # status:processing (1) + total_rows set (1) + throttled progress every 10 rows - # plus the last row (3, for 25 rows: 10/20/25) + status:completed (1) = 6. - # A per-row broadcast would have produced 25+ instead. + # (rows 10 and 20, for 25 rows) + status:completed (1, which already reflects + # the final count) = 5. A per-row broadcast would have produced 25+ instead. expect { described_class.perform_now(spreadsheet_import.id) - }.to have_broadcasted_to("spreadsheet_import_#{spreadsheet_import.id}").exactly(6).times + }.to have_broadcasted_to("spreadsheet_import_#{spreadsheet_import.id}").exactly(5).times end end diff --git a/spec/services/spreadsheet_import_row_importer_spec.rb b/spec/services/spreadsheet_import_row_importer_spec.rb new file mode 100644 index 000000000..66181d00a --- /dev/null +++ b/spec/services/spreadsheet_import_row_importer_spec.rb @@ -0,0 +1,53 @@ +require "rails_helper" + +RSpec.describe SpreadsheetImportRowImporter do + include ActionCable::TestHelper + + let(:spreadsheet_import) { create(:spreadsheet_import) } + let(:importer) { described_class.new(spreadsheet_import) } + + before { importer } # force creation (and its associated admin user) outside the expect blocks below + + describe "#import" do + it "creates a user and returns true for a valid row" do + expect { + expect(importer.import(2, { "nome" => "Alice Example", "email" => "alice@example.com" })).to be true + }.to change(User, :count).by(1) + + user = User.find_by(email: "alice@example.com") + expect(user.full_name).to eq("Alice Example") + expect(user).to be_no_admin + end + + it "gives the imported user an unguessable random password" do + importer.import(2, { "nome" => "Alice Example", "email" => "alice@example.com" }) + + user = User.find_by(email: "alice@example.com") + expect(user.authenticate("password123")).to be false + end + + it "sends a welcome e-mail so the user can set a real password" do + expect { + importer.import(2, { "nome" => "Alice Example", "email" => "alice@example.com" }) + }.to have_enqueued_mail(PasswordsMailer, :welcome) + end + + it "does not create a user or send an e-mail, and records a row error, for invalid data" do + expect { + expect { + expect(importer.import(3, { "nome" => "Missing Email", "email" => "" })).to be false + }.not_to change(User, :count) + }.not_to have_enqueued_mail(PasswordsMailer, :welcome) + + error = spreadsheet_import.spreadsheet_import_row_errors.sole + expect(error.row_number).to eq(3) + expect(error.message).to match(/e-mail não pode ficar em branco/i) + end + + it "does not trigger a dashboard broadcast for the imported user (bulk import path)" do + expect { + importer.import(2, { "nome" => "Alice Example", "email" => "alice@example.com" }) + }.not_to have_broadcasted_to("admin_dashboard") + end + end +end diff --git a/spec/services/spreadsheet_parser_spec.rb b/spec/services/spreadsheet_parser_spec.rb new file mode 100644 index 000000000..27263b676 --- /dev/null +++ b/spec/services/spreadsheet_parser_spec.rb @@ -0,0 +1,65 @@ +require "rails_helper" + +RSpec.describe SpreadsheetParser do + def spreadsheet_import_with(fixture_name, content_type, **attrs) + spreadsheet_import = create(:spreadsheet_import, **attrs) + spreadsheet_import.file.attach( + io: File.open(Rails.root.join("spec/fixtures/files", fixture_name)), + filename: fixture_name, + content_type: content_type + ) + spreadsheet_import + end + + describe "#rows" do + it "maps columns positionally: 1st column is always the name, 2nd is always the email" do + spreadsheet_import = spreadsheet_import_with("valid_import.csv", "text/csv") + + rows = described_class.new(spreadsheet_import).rows + + expect(rows).to include([ 2, { "nome" => "Alice Example", "email" => "alice@example.com" } ]) + end + + it "never uses the header row's own text to map columns, even when it doesn't say nome/email" do + spreadsheet_import = spreadsheet_import_with("header_labels_mismatch_import.csv", "text/csv", has_header: true) + + rows = described_class.new(spreadsheet_import).rows + + expect(rows).to eq([ [ 2, { "nome" => "Henry Example", "email" => "henry@example.com" } ] ]) + end + + it "treats the first row as real data (positionally) when has_header is false" do + spreadsheet_import = spreadsheet_import_with("valid_import_no_header.csv", "text/csv", has_header: false) + + rows = described_class.new(spreadsheet_import).rows + + expect(rows).to eq([ + [ 1, { "nome" => "Frank Example", "email" => "frank@example.com" } ], + [ 2, { "nome" => "Grace Example", "email" => "grace@example.com" } ] + ]) + end + + it "skips blank rows" do + spreadsheet_import = spreadsheet_import_with("mixed_import.csv", "text/csv") + + rows = described_class.new(spreadsheet_import).rows + + expect(rows.map(&:first)).to eq([ 2, 3, 4, 5, 6 ]) + end + + it "reads both CSV and XLSX through the same API" do + csv_rows = described_class.new(spreadsheet_import_with("mixed_import.csv", "text/csv")).rows + xlsx_rows = described_class.new( + spreadsheet_import_with("mixed_import.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + ).rows + + expect(xlsx_rows).to eq(csv_rows) + end + + it "raises a ParseError instead of a raw parsing exception when the file is malformed" do + spreadsheet_import = spreadsheet_import_with("malformed_import.csv", "text/csv") + + expect { described_class.new(spreadsheet_import).rows }.to raise_error(described_class::ParseError) + end + end +end From 64abf827790819a5a0b806ca365ae4bed113ba04 Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Thu, 3 Sep 2026 21:32:30 -0300 Subject: [PATCH 52/68] feat: e-mail imported users so they can set a real password MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spreadsheet-imported users were created with an unguessable random password they were never told, so they could never actually log in. Reuse the existing password-reset token mechanism (already auto-generated by has_secure_password) via a new PasswordsMailer#welcome, sent right after a row successfully creates a user — no PasswordsController changes needed, since the token/edit/update path is already generic. Also fixes #reset's subject, hardcoded in English despite the body already being pt-BR. Adds letter_opener for development, since there was previously no way to see outgoing mail locally at all (development had no delivery method configured, silently falling back to an unconfigured :smtp adapter). --- Gemfile | 3 ++ Gemfile.lock | 12 ++++++++ app/mailers/passwords_mailer.rb | 7 ++++- app/views/passwords_mailer/welcome.html.erb | 7 +++++ app/views/passwords_mailer/welcome.text.erb | 4 +++ config/environments/development.rb | 5 ++++ spec/mailers/passwords_mailer_spec.rb | 31 +++++++++++++++++++++ 7 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 app/views/passwords_mailer/welcome.html.erb create mode 100644 app/views/passwords_mailer/welcome.text.erb create mode 100644 spec/mailers/passwords_mailer_spec.rb diff --git a/Gemfile b/Gemfile index 49e32b0e2..2fe6e615d 100644 --- a/Gemfile +++ b/Gemfile @@ -95,6 +95,9 @@ end group :development do # Use console on exceptions pages [https://github.com/rails/web-console] gem "web-console" + + # Opens sent e-mails in the browser instead of actually delivering them [https://github.com/ryanb/letter_opener] + gem "letter_opener" end gem "rails-i18n", "~> 8.1" diff --git a/Gemfile.lock b/Gemfile.lock index 2d28a0d16..cd4622dda 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -104,6 +104,8 @@ GEM addressable capybara playwright-ruby-client (>= 1.16.0) + childprocess (5.1.0) + logger (~> 1.5) concurrent-ruby (1.3.8) connection_pool (3.0.2) crack (1.0.1) @@ -170,6 +172,12 @@ GEM thor (~> 1.3) zeitwerk (>= 2.6.18, < 3.0) language_server-protocol (3.17.0.6) + launchy (3.1.1) + addressable (~> 2.8) + childprocess (~> 5.0) + logger (~> 1.6) + letter_opener (1.10.0) + launchy (>= 2.2, < 4) lint_roller (1.1.0) logger (1.7.0) loofah (2.25.2) @@ -462,6 +470,7 @@ DEPENDENCIES image_processing (~> 1.2) importmap-rails kamal + letter_opener parallel_tests propshaft puma (>= 5.0) @@ -512,6 +521,7 @@ CHECKSUMS bundler-audit (0.9.3) sha256=81c8766c71e47d0d28a0f98c7eed028539f21a6ea3cd8f685eb6f42333c9b4e9 capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef capybara-playwright-driver (0.5.10) sha256=e48e572d72bc1043c644fab44985be0a1e75d7d6917dc298355581848982a2c3 + childprocess (5.1.0) sha256=9a8d484be2fd4096a0e90a0cd3e449a05bc3aa33f8ac9e4d6dcef6ac1455b6ec concurrent-ruby (1.3.8) sha256=b2f1be836e968ccc78ccfce277ea79c72a88633f22306782c16ff23fb415d1e1 connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a crack (1.0.1) sha256=ff4a10390cd31d66440b7524eb1841874db86201d5b70032028553130b6d4c7e @@ -546,6 +556,8 @@ CHECKSUMS json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a kamal (2.12.0) sha256=c51d1ab085e515470f98d0c0f043637122b5ebf76e8b610cb1fbbed0b7f9b8fa language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0 + launchy (3.1.1) sha256=72b847b5cc961589dde2c395af0108c86ff0119f42d4648d25b5440ebb10059e + letter_opener (1.10.0) sha256=2ff33f2e3b5c3c26d1959be54b395c086ca6d44826e8bf41a14ff96fdf1bdbb2 lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 loofah (2.25.2) sha256=2007f746959ac65552456e04b433e83deb22759ab38c838b4445c70e43425918 diff --git a/app/mailers/passwords_mailer.rb b/app/mailers/passwords_mailer.rb index 06ac4a4da..afb0c999e 100644 --- a/app/mailers/passwords_mailer.rb +++ b/app/mailers/passwords_mailer.rb @@ -1,6 +1,11 @@ class PasswordsMailer < ApplicationMailer def reset(user) @user = user - mail subject: "Reset your password", to: user.email + mail subject: "Redefinição de senha", to: user.email + end + + def welcome(user) + @user = user + mail subject: "Defina sua senha", to: user.email end end diff --git a/app/views/passwords_mailer/welcome.html.erb b/app/views/passwords_mailer/welcome.html.erb new file mode 100644 index 000000000..a20b0bc30 --- /dev/null +++ b/app/views/passwords_mailer/welcome.html.erb @@ -0,0 +1,7 @@ +

+ Sua conta foi criada. Defina sua senha + <%= link_to "nesta página", edit_password_url(@user.password_reset_token) %> + para acessar o sistema. + + Este link expira em <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>. +

diff --git a/app/views/passwords_mailer/welcome.text.erb b/app/views/passwords_mailer/welcome.text.erb new file mode 100644 index 000000000..9112784bc --- /dev/null +++ b/app/views/passwords_mailer/welcome.text.erb @@ -0,0 +1,4 @@ +Sua conta foi criada. Defina sua senha para acessar o sistema em +<%= edit_password_url(@user.password_reset_token) %> + +Este link expira em <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>. diff --git a/config/environments/development.rb b/config/environments/development.rb index 75243c3d0..d178c4487 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -34,6 +34,11 @@ # Don't care if the mailer can't send. config.action_mailer.raise_delivery_errors = false + # Open sent e-mails in the browser instead of trying (and silently failing) to + # deliver them over SMTP with no server configured. + config.action_mailer.delivery_method = :letter_opener + config.action_mailer.perform_deliveries = true + # Make template changes take effect immediately. config.action_mailer.perform_caching = false diff --git a/spec/mailers/passwords_mailer_spec.rb b/spec/mailers/passwords_mailer_spec.rb new file mode 100644 index 000000000..eaa263184 --- /dev/null +++ b/spec/mailers/passwords_mailer_spec.rb @@ -0,0 +1,31 @@ +require "rails_helper" + +RSpec.describe PasswordsMailer, type: :mailer do + let(:user) { create(:user) } + + def token_from(mail) + mail.text_part.body.to_s[%r{/passwords/([^/]+)/edit}, 1] + end + + describe "#reset" do + let(:mail) { described_class.reset(user) } + + it "renders in Portuguese, addressed to the user, with a valid reset link" do + expect(mail.subject).to eq("Redefinição de senha") + expect(mail.to).to eq([ user.email ]) + expect(mail.text_part.body.to_s).to include("redefinir sua senha") + expect(User.find_by_password_reset_token!(token_from(mail))).to eq(user) + end + end + + describe "#welcome" do + let(:mail) { described_class.welcome(user) } + + it "renders in Portuguese, addressed to the user, with a valid set-password link" do + expect(mail.subject).to eq("Defina sua senha") + expect(mail.to).to eq([ user.email ]) + expect(mail.text_part.body.to_s).to include("Defina sua senha") + expect(User.find_by_password_reset_token!(token_from(mail))).to eq(user) + end + end +end From 1d93fb7a9859021705d2788a5494117c544c9c31 Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Thu, 3 Sep 2026 21:39:53 -0300 Subject: [PATCH 53/68] test: fix pending-migration rescue typo and enforce the coverage minimum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rails_helper.rb rescued ActiveRecord::PendenteMigrationError, which doesn't exist — a real pending migration would have raised a NameError instead of the intended friendly abort message. Also adds SimpleCov.minimum_coverage 90 (only on a plain sequential run — parallel_rspec workers each only exercise a slice of the suite, so enforcing it there would fail spuriously), so a coverage regression below the README's own bar actually fails the suite instead of just being a number nobody re-checks. --- spec/rails_helper.rb | 2 +- spec/spec_helper.rb | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 297ea0432..f7c3f3851 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -31,7 +31,7 @@ # If you are not using ActiveRecord, you can remove these lines. begin ActiveRecord::Migration.maintain_test_schema! -rescue ActiveRecord::PendenteMigrationError => e +rescue ActiveRecord::PendingMigrationError => e abort e.to_s.strip end RSpec.configure do |config| diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 39c782f6a..613b2efd1 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -18,6 +18,11 @@ skip "/spec/" skip "/config/" skip "/db/" + + # Only enforced on a plain sequential `bundle exec rspec` run: each + # parallel_rspec worker (TEST_ENV_NUMBER set) only exercises a slice of the + # suite, so its own coverage is never representative of the whole. + minimum_coverage 90 unless ENV["TEST_ENV_NUMBER"] end RSpec.configure do |config| From 23e9ded12e4943b02cf29b3f81a30e2cedcf3543 Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Thu, 3 Sep 2026 22:28:47 -0300 Subject: [PATCH 54/68] chore: hardcode seeded credentials and add a seeded regular user Removes the SEED_ADMIN_EMAIL/SEED_ADMIN_PASSWORD environment-variable override for the seeded admin's credentials in favor of fixed values, and seeds a second, non-admin user alongside the admin so the app has more than one account to sign in as right after setup. Updates the README's seeding and environment-variable docs to match. --- README.md | 18 ++++++++---------- db/seeds.rb | 14 +++++++++++--- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index c4f0543de..c31429b8f 100644 --- a/README.md +++ b/README.md @@ -50,19 +50,19 @@ bin/rails db:seed # creates the bootstrap admin user (see below) Public registration always creates a `no_admin` user (enforced server-side in `RegistrationsController`, ignoring any injected `role` param), so there is no way -to reach an admin account from the UI alone. `db/seeds.rb` creates one bootstrap -admin, idempotently, so the app is usable immediately after setup: +to reach an admin account from the UI alone. `db/seeds.rb` creates two users, +idempotently, so the app is usable immediately after setup: -| Field | Default | Override with | -|----------|---------------------------------------------|-------------------------| -| Email | `admin@example.com` | `SEED_ADMIN_EMAIL` | -| Password | `password123` | `SEED_ADMIN_PASSWORD` | +| Role | Email | Password | +|----------|---------------------|----------------| +| Admin | `admin@example.com` | `password123` | +| Regular | `user@example.com` | `password123` | ```bash -SEED_ADMIN_EMAIL=you@example.com SEED_ADMIN_PASSWORD=a-strong-password bin/rails db:seed +bin/rails db:seed ``` -Change the default password before deploying anywhere reachable by others. +Change these default passwords before deploying anywhere reachable by others. ## Running in development @@ -150,8 +150,6 @@ automatically. | `SOLID_QUEUE_IN_PUMA` | `config/puma.rb`, Kamal | Runs the Solid Queue supervisor inside the Puma process | unset (off) | | `JOB_CONCURRENCY` | `config/queue.yml` | Number of Solid Queue worker processes | `1` | | `RAILS_LOG_LEVEL` | `config/environments/production.rb` | Production log verbosity | `info` | -| `SEED_ADMIN_EMAIL` | `db/seeds.rb` | Bootstrap admin's email | `admin@example.com` | -| `SEED_ADMIN_PASSWORD` | `db/seeds.rb` | Bootstrap admin's password | `password123` | | `KAMAL_WEB_HOST` | `config/deploy.yml` | Deploy target host/IP | `203.0.113.10` (RFC 5737, fails fast) | | `KAMAL_REGISTRY_USERNAME` | `config/deploy.yml` | GHCR username / image namespace | `your-github-username` | | `KAMAL_REGISTRY_PASSWORD` | `.kamal/secrets` | GHCR auth (GitHub PAT, `write:packages` scope) | — (required to deploy) | diff --git a/db/seeds.rb b/db/seeds.rb index 6f26ad847..f71a3b58b 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -4,9 +4,17 @@ # Registration always forces role: no_admin (see RegistrationsController), so there is no way to # reach an admin account from the UI alone. Seed one bootstrap admin so the app is usable right -# after setup; override the credentials via ENV in any shared/production environment. -User.find_or_create_by!(email: ENV.fetch("SEED_ADMIN_EMAIL", "admin@example.com")) do |user| +# after setup. +User.find_or_create_by!(email: "admin@example.com") do |user| user.full_name = "Admin" - user.password = ENV.fetch("SEED_ADMIN_PASSWORD", "password123") + user.password = "password123" user.role = :admin end + +# A regular (non-admin) user, seeded for convenience so the app has something to +# sign in as beyond the admin account right after setup. +User.find_or_create_by!(email: "user@example.com") do |user| + user.full_name = "User" + user.password = "password123" + user.role = :no_admin +end From 4c55d7ffdbaea19037b7765a5de63a56bee27c83 Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Thu, 3 Sep 2026 22:37:43 -0300 Subject: [PATCH 55/68] style: run Rustywind and Herb formatters over all ERB views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One-time formatting pass, tools run via npx (not added as project dependencies): Rustywind (npx rustywind --write app/views) sorts Tailwind classes into their canonical order, then Herb (npx @herb-tools/formatter app/views) reformats the ERB/HTML structure (indentation, attribute wrapping for long tags). No content, class, or logic changes — verified via full diff review, the complete test suite, RuboCop, Brakeman, bundler-audit, and a manual visual check of the sidebar, users index and spreadsheet imports pages in a real browser. --- app/views/admin/dashboards/_counts.html.erb | 33 +++- app/views/admin/dashboards/show.html.erb | 8 +- .../spreadsheet_imports/_progress.html.erb | 29 +++- .../spreadsheet_imports/_row_error.html.erb | 9 +- .../spreadsheet_imports/_row_errors.html.erb | 34 +++- .../admin/spreadsheet_imports/index.html.erb | 121 ++++++++++++--- .../admin/spreadsheet_imports/new.html.erb | 24 ++- .../admin/spreadsheet_imports/show.html.erb | 16 +- app/views/admin/users/_form.html.erb | 19 ++- app/views/admin/users/edit.html.erb | 11 +- app/views/admin/users/index.html.erb | 146 +++++++++++++++--- app/views/admin/users/new.html.erb | 11 +- app/views/layouts/_flash.html.erb | 44 +++++- app/views/layouts/_sidebar.html.erb | 109 ++++++++++--- app/views/layouts/application.html.erb | 14 +- app/views/layouts/mailer.html.erb | 2 + app/views/passwords/edit.html.erb | 6 +- app/views/passwords/new.html.erb | 8 +- app/views/passwords_mailer/reset.html.erb | 4 +- app/views/passwords_mailer/welcome.html.erb | 5 +- app/views/profiles/edit.html.erb | 37 +++-- app/views/profiles/show.html.erb | 35 +++-- app/views/registrations/new.html.erb | 13 +- app/views/sessions/new.html.erb | 13 +- app/views/shared/_form_errors.html.erb | 2 +- 25 files changed, 598 insertions(+), 155 deletions(-) diff --git a/app/views/admin/dashboards/_counts.html.erb b/app/views/admin/dashboards/_counts.html.erb index 3dedee5b6..15849c74b 100644 --- a/app/views/admin/dashboards/_counts.html.erb +++ b/app/views/admin/dashboards/_counts.html.erb @@ -1,12 +1,33 @@ -
-
+
+

Total de Usuários

-

<%= total_users %>

+

<%= total_users %>

+ <% User.roles.keys.each_with_index do |role, index| %> -
-

<%= role == "admin" ? "Administrador" : "Usuário Normal" %>

-

<%= users_by_role[role] || 0 %>

+
+

+ <%= role == "admin" ? "Administrador" : "Usuário Normal" %> +

+ +

+ <%= users_by_role[role] || 0 %> +

<% end %>
diff --git a/app/views/admin/dashboards/show.html.erb b/app/views/admin/dashboards/show.html.erb index 7ea909a2c..85433ddd3 100644 --- a/app/views/admin/dashboards/show.html.erb +++ b/app/views/admin/dashboards/show.html.erb @@ -1,7 +1,11 @@
-

Painel Administrativo

-

Conectado como <%= Current.user.full_name %> (<%= Current.user.role == 'admin' ? 'Administrador' : 'Usuário Normal' %>).

+

Painel Administrativo

+ +

+ Conectado como <%= Current.user.full_name %> + (<%= Current.user.role == 'admin' ? 'Administrador' : 'Usuário Normal' %>). +

diff --git a/app/views/admin/spreadsheet_imports/_progress.html.erb b/app/views/admin/spreadsheet_imports/_progress.html.erb index 9e85a6648..e83e73416 100644 --- a/app/views/admin/spreadsheet_imports/_progress.html.erb +++ b/app/views/admin/spreadsheet_imports/_progress.html.erb @@ -1,15 +1,28 @@ -
-
-
-

Status:

- +
+
+
+

Status:

+ + <%= spreadsheet_import.status == "processing" ? "Processando" : spreadsheet_import.status == "completed" ? "Concluída" : spreadsheet_import.status == "failed" ? "Falhou" : "Pendente" %>
-
-
+
+
-

<%= spreadsheet_import.processed_rows %> / <%= spreadsheet_import.total_rows %> linhas processadas

+ +

+ <%= spreadsheet_import.processed_rows %> / + <%= spreadsheet_import.total_rows %> linhas processadas +

diff --git a/app/views/admin/spreadsheet_imports/_row_error.html.erb b/app/views/admin/spreadsheet_imports/_row_error.html.erb index 8b573d292..2aee286ff 100644 --- a/app/views/admin/spreadsheet_imports/_row_error.html.erb +++ b/app/views/admin/spreadsheet_imports/_row_error.html.erb @@ -1,4 +1,7 @@ -
<%= row_error.row_number %><%= row_error.message %>
+ <%= row_error.row_number %> + <%= row_error.message %>
- +

Erros nas Linhas

+ +
+
+ - - + + + - + + <%= render partial: "admin/spreadsheet_imports/row_error", collection: spreadsheet_import.spreadsheet_import_row_errors.order(:row_number), as: :row_error %> diff --git a/app/views/admin/spreadsheet_imports/index.html.erb b/app/views/admin/spreadsheet_imports/index.html.erb index 914bf6e4e..c1a2d7212 100644 --- a/app/views/admin/spreadsheet_imports/index.html.erb +++ b/app/views/admin/spreadsheet_imports/index.html.erb @@ -1,35 +1,116 @@ -
-

Importações de Planilha

+
+

Importações de Planilha

<%= link_to "Nova Importação", new_admin_spreadsheet_import_path, class: "btn-primary" %>
-
-
LinhaMotivo + Linha + + Motivo +
- +
+
+ - - - - - - + + + + + + + + + + + + <% @spreadsheet_imports.each do |spreadsheet_import| %> - - - - + + + + + - - - + + + + diff --git a/app/views/admin/spreadsheet_imports/new.html.erb b/app/views/admin/spreadsheet_imports/new.html.erb index dd0a86b2a..6f4437ebd 100644 --- a/app/views/admin/spreadsheet_imports/new.html.erb +++ b/app/views/admin/spreadsheet_imports/new.html.erb @@ -1,24 +1,34 @@ -
-

Nova Importação

+
+

+ Nova Importação +

<%= form_with model: [ :admin, @spreadsheet_import ], class: "contents" do |form| %> <%= render "shared/form_errors", record: @spreadsheet_import %> -
+
<%= form.label :file, "Planilha (CSV ou XLSX)", class: "form-label" %> -

- A 1ª coluna é sempre tratada como Nome completo e a 2ª como E-mail, pela posição — o texto do cabeçalho (se houver) não é lido nem validado. + +

+ A 1ª coluna é sempre tratada como Nome completo e a 2ª + como E-mail, pela posição — o texto do cabeçalho (se + houver) não é lido nem validado.

+ <%= form.file_field :file, accept: ".csv,.xlsx", required: true, class: "form-file" %>
<%= form.check_box :has_header, class: "h-4 w-4 rounded border-gray-300 text-emerald-600 focus:ring-emerald-500" %> <%= form.label :has_header, "Este arquivo tem uma linha de cabeçalho", class: "text-sm font-medium text-gray-700" %>
-

Se marcado, a primeira linha é ignorada por completo (nem seu conteúdo é lido).

+ +

+ Se marcado, a primeira linha é ignorada por completo (nem seu conteúdo é + lido). +

-
+
<%= form.submit "Enviar Planilha", class: "btn-primary w-full sm:w-auto text-center cursor-pointer text-lg" %> <%= link_to "Cancelar", admin_spreadsheet_imports_path, class: "link-muted" %>
diff --git a/app/views/admin/spreadsheet_imports/show.html.erb b/app/views/admin/spreadsheet_imports/show.html.erb index d3714e25c..91ae6300c 100644 --- a/app/views/admin/spreadsheet_imports/show.html.erb +++ b/app/views/admin/spreadsheet_imports/show.html.erb @@ -1,8 +1,12 @@ -
-
-

Importação de Planilha

-

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

+
+

Importação de Planilha

+ +

+ <%= @spreadsheet_import.file.filename %> — enviado por <%= @spreadsheet_import.user.full_name %>

@@ -11,7 +15,7 @@ <%= render "progress", spreadsheet_import: @spreadsheet_import %> <%= render "row_errors", spreadsheet_import: @spreadsheet_import %> -
+
<%= link_to "Voltar para importações", admin_spreadsheet_imports_path, class: "link-muted font-medium" %>
diff --git a/app/views/admin/users/_form.html.erb b/app/views/admin/users/_form.html.erb index 71334b788..1b66ac15c 100644 --- a/app/views/admin/users/_form.html.erb +++ b/app/views/admin/users/_form.html.erb @@ -16,8 +16,14 @@ <%= form.select :role, User.roles.keys.map { |role| [ role == "admin" ? "Administrador" : "Usuário Normal", role ] }, {}, class: "form-input" %>
-
-

<%= user.new_record? ? "Senha" : "Mudar Senha" %>

+
+

+ <%= user.new_record? ? "Senha" : "Mudar Senha" %> +

+
<%= form.label :password, (user.new_record? ? "Senha" : "Nova senha"), class: "form-label" %> <%= form.password_field :password, required: user.new_record?, autocomplete: "new-password", placeholder: user.new_record? ? nil : "Deixe em branco para manter a senha atual", minlength: 8, maxlength: 72, class: "form-input", data: { password_confirmation_target: "password", action: "input->password-confirmation#validate" } %> @@ -29,8 +35,11 @@
-
-

Avatar

+
+

+ Avatar +

+
<%= form.label :avatar, "Imagem de Avatar", class: "form-label" %> <%= form.file_field :avatar, accept: "image/png,image/jpeg,image/webp", class: "form-file" %> @@ -42,7 +51,7 @@
-
+
<%= form.submit (user.new_record? ? "Criar Usuário" : "Atualizar Usuário"), class: "btn-primary w-full sm:w-auto text-center cursor-pointer text-lg" %> <%= link_to "Cancelar", admin_users_path, class: "link-muted", data: { turbo_frame: "_top" } %>
diff --git a/app/views/admin/users/edit.html.erb b/app/views/admin/users/edit.html.erb index 14704d25b..1d0734398 100644 --- a/app/views/admin/users/edit.html.erb +++ b/app/views/admin/users/edit.html.erb @@ -1,6 +1,13 @@ <%= turbo_frame_tag "admin_user_form" do %> -
-

Editar Usuário

+
+

+ Editar Usuário +

<%= render "form", user: @user %>
diff --git a/app/views/admin/users/index.html.erb b/app/views/admin/users/index.html.erb index 74a8ff88b..013856ddd 100644 --- a/app/views/admin/users/index.html.erb +++ b/app/views/admin/users/index.html.erb @@ -1,50 +1,152 @@ -
-

Usuários

+
+

Usuários

<%= link_to "Novo Usuário", new_admin_user_path, class: "btn-primary", data: { turbo_frame: "admin_user_form" } %>
<%= turbo_frame_tag "admin_user_form" %> -
-
ArquivoEnviado porStatusProgressoErros + Arquivo + + Enviado por + + Status + + Progresso + + Erros +
<%= spreadsheet_import.file.filename %><%= spreadsheet_import.user.full_name %> - +
+ <%= spreadsheet_import.file.filename %> + + <%= spreadsheet_import.user.full_name %> + + <%= spreadsheet_import.status == "processing" ? "Processando" : spreadsheet_import.status == "completed" ? "Concluída" : spreadsheet_import.status == "failed" ? "Falhou" : "Pendente" %> <%= spreadsheet_import.processed_rows %>/<%= spreadsheet_import.total_rows %><%= spreadsheet_import.spreadsheet_import_row_errors.size %> + + + <%= spreadsheet_import.processed_rows %>/<%= spreadsheet_import.total_rows %> + + <%= spreadsheet_import.spreadsheet_import_row_errors.size %> + <%= link_to admin_spreadsheet_import_path(spreadsheet_import), title: "Ver Detalhes", class: "p-2 rounded-md text-gray-400 hover:text-emerald-600 hover:bg-emerald-50 transition-colors inline-block" do %> - + + + + + <% end %>
- +
+
+ - - - - - + + + + + + + + + + <% @users.each do |user| %> - - + - - - + + + + - diff --git a/app/views/admin/users/new.html.erb b/app/views/admin/users/new.html.erb index 1cb3dfaa5..408a5e230 100644 --- a/app/views/admin/users/new.html.erb +++ b/app/views/admin/users/new.html.erb @@ -1,6 +1,13 @@ <%= turbo_frame_tag "admin_user_form" do %> -
-

Novo Usuário

+
+

+ Novo Usuário +

<%= render "form", user: @user %>
diff --git a/app/views/layouts/_flash.html.erb b/app/views/layouts/_flash.html.erb index 707c2fe85..f757897dc 100644 --- a/app/views/layouts/_flash.html.erb +++ b/app/views/layouts/_flash.html.erb @@ -1,9 +1,27 @@ <% if alert = flash[:alert] %> -
+
- +
+

<%= alert %>

@@ -12,11 +30,29 @@ <% end %> <% if notice = flash[:notice] %> -
+
- +
+

<%= notice %>

diff --git a/app/views/layouts/_sidebar.html.erb b/app/views/layouts/_sidebar.html.erb index c21ee2467..f1a576ee1 100644 --- a/app/views/layouts/_sidebar.html.erb +++ b/app/views/layouts/_sidebar.html.erb @@ -1,41 +1,103 @@
-
-
Fullstack Developer
-
-
AvatarNomeE-mailPapelAções + Avatar + + Nome + + E-mail + + Papel + + Ações +
+
<% if user.avatar.attached? %> <%= image_tag user.avatar.variant(resize_to_limit: [ 40, 40 ]), class: "size-10 object-cover rounded-full shadow-sm" %> <% else %> -
- +
+ + +
<% end %>
<%= user.full_name %><%= user.email %> - + + + <%= user.full_name %> + <%= user.email %> + <%= user.role == "admin" ? "Administrador" : "Usuário Normal" %> + +
<%= link_to edit_admin_user_path(user), title: "Editar", class: "p-2 rounded-md text-gray-400 hover:text-emerald-600 hover:bg-emerald-50 transition-colors", data: { turbo_frame: "admin_user_form" } do %> - + + + <% end %> + <%= button_to toggle_role_admin_user_path(user), method: :patch, title: "Alterar papel", class: "p-2 rounded-md text-gray-400 hover:text-emerald-600 hover:bg-emerald-50 transition-colors flex items-center justify-center cursor-pointer" do %> - + + + <% end %> + <%= button_to admin_user_path(user), method: :delete, title: "Excluir", class: "p-2 rounded-md text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors flex items-center justify-center cursor-pointer", form: { data: { turbo_confirm: "Tem certeza?" } } do %> - + + + <% end %>
<% if user.avatar.attached? %> - <%= image_tag user.avatar.variant(resize_to_limit: [ 40, 40 ]), class: "size-10 object-cover rounded-full shadow-sm" %> + <%= image_tag user.avatar.variant(resize_to_limit: [ 40, 40 ]), alt: user.full_name, class: "size-10 object-cover rounded-full shadow-sm" %> <% else %>
<% if Current.user.avatar.attached? %> - <%= image_tag Current.user.avatar.variant(resize_to_limit: [ 96, 96 ]), class: "size-20 rounded-full object-cover shadow-sm border border-gray-100 mb-3" %> + <%= image_tag Current.user.avatar.variant(resize_to_limit: [ 96, 96 ]), alt: Current.user.full_name, class: "size-20 rounded-full object-cover shadow-sm border border-gray-100 mb-3" %> <% else %>
<% if @user.avatar.attached? %>
- <%= image_tag @user.avatar.variant(resize_to_limit: [ 128, 128 ]), class: "size-32 object-cover block" %> + <%= image_tag @user.avatar.variant(resize_to_limit: [ 128, 128 ]), alt: @user.full_name, class: "size-32 object-cover block" %>
<% end %> From 442e4d3a65c3c849032f94d4d04bfe97f35c34e9 Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Fri, 4 Sep 2026 00:36:24 -0300 Subject: [PATCH 64/68] ci: cache Playwright browsers and restrict GITHUB_TOKEN permissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chromium was downloaded fresh on every run (~4 minutes of the test job). Caches ~/.cache/ms-playwright keyed by OS + exact Playwright package version — only the browser binary is cacheable (OS-level deps are installed system-wide via apt and never persist on the ephemeral runner anyway), so a cache hit skips straight to the fast install-deps-only path instead of --with-deps. Also adds a workflow-level permissions: {contents: read} block, per CodeQL's own flag that none of the three jobs limited GITHUB_TOKEN's default permissions — none of them need more than read access (no pushes, PR comments, releases, or package publishing). --- .github/workflows/ci.yml | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c11181225..f418310b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,9 @@ on: push: branches: [ master ] +permissions: + contents: read + jobs: security_scan: runs-on: ubuntu-latest @@ -71,11 +74,30 @@ jobs: with: node-version: 22 - - name: Install Playwright browsers (for Capybara system specs) + - name: Determine the Playwright version to install (for Capybara system specs) run: | - PLAYWRIGHT_CLI_VERSION=$(bundle exec ruby -e 'require "playwright"; puts Playwright::COMPATIBLE_PLAYWRIGHT_VERSION.strip') - npm install playwright@${PLAYWRIGHT_CLI_VERSION} - ./node_modules/.bin/playwright install --with-deps chromium + echo "PLAYWRIGHT_CLI_VERSION=$(bundle exec ruby -e 'require "playwright"; puts Playwright::COMPATIBLE_PLAYWRIGHT_VERSION.strip')" >> "$GITHUB_ENV" + + - name: Install the Playwright npm package + run: npm install playwright@${{ env.PLAYWRIGHT_CLI_VERSION }} + + - name: Cache Playwright browser binaries + id: playwright-cache + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ env.PLAYWRIGHT_CLI_VERSION }} + + # Only the browser binary itself is cacheable — its OS-level dependencies are + # installed system-wide via apt and never persist on this ephemeral runner, but + # installing just those (no browser download) is fast, unlike --with-deps. + - name: Install Chromium and its OS dependencies (cache miss) + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: ./node_modules/.bin/playwright install --with-deps chromium + + - name: Install Chromium's OS dependencies only (cache hit) + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: ./node_modules/.bin/playwright install-deps chromium - name: Build Tailwind CSS run: bin/rails tailwindcss:build From 5168edc8a75cebeee826ad95cd84a6bdc502d108 Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Fri, 4 Sep 2026 00:47:32 -0300 Subject: [PATCH 65/68] ci: cache the Playwright npm package too, not just the browser binary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser-binary cache alone left npm install playwright@x.y.z as the new bottleneck (~2 minutes fetching/resolving a single package fresh every run, dwarfing the ~15s test suite it's there to support). Caches node_modules under the same version-keyed cache key as the browser binary cache, so both invalidate together on a Playwright version bump and both hit together otherwise — skips npm install entirely on a cache hit. --- .github/workflows/ci.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f418310b7..b1041f81c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,7 +78,15 @@ jobs: run: | echo "PLAYWRIGHT_CLI_VERSION=$(bundle exec ruby -e 'require "playwright"; puts Playwright::COMPATIBLE_PLAYWRIGHT_VERSION.strip')" >> "$GITHUB_ENV" + - name: Cache the Playwright npm package + id: playwright-npm-cache + uses: actions/cache@v4 + with: + path: node_modules + key: playwright-npm-${{ runner.os }}-${{ env.PLAYWRIGHT_CLI_VERSION }} + - name: Install the Playwright npm package + if: steps.playwright-npm-cache.outputs.cache-hit != 'true' run: npm install playwright@${{ env.PLAYWRIGHT_CLI_VERSION }} - name: Cache Playwright browser binaries From 2419808189262a12e761d3acc908ba30daae6621 Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Fri, 4 Sep 2026 01:04:40 -0300 Subject: [PATCH 66/68] docs: document ActiveStorage libvips dependency for Rails 7+ --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index e7751daaa..26a22689b 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,10 @@ Per Umanni's AI Policy, this is an honest account of the AI assistance actually - Node.js (only for Playwright's browser binaries used by system specs) - SQLite 3.8+ - Docker (optional, for containerized run/deploy) +- **libvips** (Required for ActiveStorage image processing) + +> **⚠️ Important Note on Image Processing:** +> Starting with Rails 7, ActiveStorage defaults to using the `vips` variant processor instead of `ImageMagick`. You must have the `libvips` system library installed on your machine to upload and process avatars successfully (e.g., `sudo apt-get install libvips` on Debian/Ubuntu or `brew install vips` on macOS). If this package is missing, ActiveStorage will fail to load the variant processor silently and throw a `NoMethodError (undefined method 'new' for nil)` when attempting to generate image thumbnails. ## Setup From c677be2e48f5556eaf7a9696c28901cf70702ef9 Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Fri, 4 Sep 2026 21:27:45 -0300 Subject: [PATCH 67/68] docs: document the Playwright browser install and pin the CLI version A fresh clone had no way to know that system specs need a browser binary downloaded separately, and the obvious `npx playwright install` on its own makes things worse: with no local node_modules, npx fetches the *latest* Playwright, which expects a different browser build than the 1.62.1 the `playwright` gem drives. The result is a confusing "Executable doesn't exist at .../chromium_headless_shell-" failure immediately after apparently installing the browser. Document `npm install` before `npx playwright install chromium`, explain why the order matters, and pin playwright to an exact version in package.json so neither npm install nor npm update can drift off the gem's COMPATIBLE_PLAYWRIGHT_VERSION. --- README.md | 32 ++++++++++++++++++++++++++++---- package-lock.json | 2 +- package.json | 2 +- 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 26a22689b..98c60acf7 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,8 @@ Per Umanni's AI Policy, this is an honest account of the AI assistance actually ## Requirements - Ruby 4.0+ (see `.ruby-version`) -- Node.js (only for Playwright's browser binaries used by system specs) +- Node.js (only for Playwright's CLI and browser binaries used by system specs — CI + uses Node 22; see [Setup](#setup) for the install order that matters) - SQLite 3.8+ - Docker (optional, for containerized run/deploy) - **libvips** (Required for ActiveStorage image processing) @@ -46,10 +47,32 @@ Per Umanni's AI Policy, this is an honest account of the AI assistance actually ```bash bundle install -bin/rails db:prepare # creates all 4 databases and loads the schema -bin/rails db:seed # creates the bootstrap admin user (see below) +npm install # installs the exact Playwright CLI pinned in package.json +npx playwright install chromium # downloads the Chromium binary into ~/.cache/ms-playwright +bin/rails db:prepare # creates all 4 databases and loads the schema +bin/rails db:seed # creates the bootstrap admin user (see below) ``` +On Linux you may also need Chromium's OS-level libraries, which Playwright installs +with `sudo npx playwright install-deps chromium` (this is what CI does via +`playwright install --with-deps chromium`). + +> **⚠️ Run `npm install` *before* `npx playwright install`.** The `playwright` Ruby +> gem drives a Node Playwright CLI whose version must match the gem's +> `Playwright::COMPATIBLE_PLAYWRIGHT_VERSION` (currently **1.62.1**, pinned exactly +> — no `^` — in `package.json`, so `npm install`/`npm update` can't drift off it). +> With no local `node_modules`, `npx` silently fetches the +> *latest* Playwright instead, which expects a different browser build number than +> the one on disk — so system specs fail with `Executable doesn't exist at +> ~/.cache/ms-playwright/chromium_headless_shell-/...` even right after you +> ran `playwright install`. Installing the pinned CLI first keeps the CLI, the gem, +> and the downloaded browser on the same version. You can verify the two agree with: +> +> ```bash +> bundle exec ruby -e 'require "playwright"; puts Playwright::COMPATIBLE_PLAYWRIGHT_VERSION' +> node -e "console.log(require('./node_modules/playwright/package.json').version)" +> ``` + ## Seeding Public registration always creates a `no_admin` user (enforced server-side in @@ -90,7 +113,8 @@ bundle exec rspec # full suite, sequential bundle exec rspec spec/path/to_spec.rb # a single file bundle exec parallel_rspec spec/ # parallel, same as CI -# System specs (Playwright) — set this if the Playwright CLI isn't globally resolvable: +# System specs (Playwright) — needs the browser installed first, see Setup above. +# Set this if the Playwright CLI isn't otherwise resolvable: PLAYWRIGHT_CLI_EXECUTABLE_PATH=./node_modules/.bin/playwright bundle exec rspec spec/system ``` diff --git a/package-lock.json b/package-lock.json index 858dbbb8d..ad6ec8901 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,7 +5,7 @@ "packages": { "": { "dependencies": { - "playwright": "^1.62.1" + "playwright": "1.62.1" } }, "node_modules/fsevents": { diff --git a/package.json b/package.json index 4520dde68..eba599f48 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { "dependencies": { - "playwright": "^1.62.1" + "playwright": "1.62.1" } } From fe9e052b1506bcaca9356ed31576e852e60704e1 Mon Sep 17 00:00:00 2001 From: DanielDz21 Date: Fri, 4 Sep 2026 21:28:05 -0300 Subject: [PATCH 68/68] fix: boot the production image without a shared master key config/credentials.yml.enc was committed but config/master.key never was -- correctly, it is gitignored. That left the encrypted file undecryptable by anyone cloning the repo, so the container could not boot at all: the entrypoint runs db:prepare, which boots Rails in production, which resolves secret_key_base via ENV["SECRET_KEY_BASE"] || credentials.secret_key_base. With the content file present but no key, EncryptedConfiguration#read rescues only MissingContentError and the boot died on MissingKeyError. Kamal failed even earlier, on `$(cat config/master.key)` in .kamal/secrets. The image still built fine, since assets:precompile uses SECRET_KEY_BASE_DUMMY, so nothing caught this. Nothing in the app actually reads credentials -- every reference is a commented out SMTP or storage block -- so secret_key_base is all that is needed, and ENV["SECRET_KEY_BASE"] short-circuits before credentials are touched. Supply it directly and drop the dead encrypted file; without it the read raises MissingContentError instead, which is rescued, so a forgotten env var now yields Rails' own actionable message rather than a decryption crash. Kamal declares SECRET_KEY_BASE under env.secret to match what .kamal/secrets now provides, with the RAILS_MASTER_KEY route kept as a documented alternative for anyone generating their own credentials. Also corrects the commented builder arg RUBY_VERSION, which carried the .ruby-version "ruby-" prefix and would have resolved to the nonexistent tag ruby:ruby-4.0.0-slim if uncommented. Verified by building the image and booting it with only SECRET_KEY_BASE set: db:prepare succeeds, /up returns 200, and Solid Queue starts in-process. --- .kamal/secrets | 11 ++++++++++- Dockerfile | 2 +- README.md | 36 ++++++++++++++++++++++++++++++++---- config/credentials.yml.enc | 1 - config/deploy.yml | 8 +++++--- 5 files changed, 48 insertions(+), 10 deletions(-) delete mode 100644 config/credentials.yml.enc diff --git a/.kamal/secrets b/.kamal/secrets index 2769339e3..3a1d81b33 100644 --- a/.kamal/secrets +++ b/.kamal/secrets @@ -17,5 +17,14 @@ # scope, for the ghcr.io registry configured in config/deploy.yml) KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD +# Signs sessions and cookies in production. This app stores no encrypted Rails +# credentials, so secret_key_base is supplied directly via ENV rather than decrypted +# from config/credentials.yml.enc. Keep the value stable across deploys — changing it +# invalidates every existing session and signed cookie. +SECRET_KEY_BASE=$SECRET_KEY_BASE + +# If you'd rather use your own Rails credentials (bin/rails credentials:edit generates +# config/master.key + config/credentials.yml.enc), swap the line above for the one +# below and change env/secret in config/deploy.yml to match. # Improve security by using a password manager. Never check config/master.key into git! -RAILS_MASTER_KEY=$(cat config/master.key) +# RAILS_MASTER_KEY=$(cat config/master.key) diff --git a/Dockerfile b/Dockerfile index 8666a46b0..135f46c9e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ # This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand: # docker build -t fullstack_developer . -# docker run -d -p 80:80 -e RAILS_MASTER_KEY= --name fullstack_developer fullstack_developer +# docker run -d -p 80:80 -e SECRET_KEY_BASE="$(openssl rand -hex 64)" --name fullstack_developer fullstack_developer # For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html diff --git a/README.md b/README.md index 98c60acf7..8dd57cebe 100644 --- a/README.md +++ b/README.md @@ -135,12 +135,24 @@ that bar), 0 RuboCop offenses, 0 Brakeman warnings, 0 bundler-audit vulnerabilit ```bash docker build -t fullstack_developer . docker run -d -p 3000:80 \ - -e RAILS_MASTER_KEY="$(cat config/master.key)" \ + -e SECRET_KEY_BASE="$(openssl rand -hex 64)" \ -e SOLID_QUEUE_IN_PUMA=true \ --name fullstack_developer \ fullstack_developer ``` +**No secret to obtain.** This app stores no encrypted Rails credentials (nothing in +`app/`, `lib/`, or `config/` reads `Rails.application.credentials`), so it needs +`secret_key_base` and nothing else — any freshly generated value works, and a clone of +this repo can run the image without being handed a key. `SECRET_KEY_BASE` is read +before credentials are ever touched, so no `config/master.key` is involved. The one +thing the value affects is session and signed-cookie continuity: a new value on every +`docker run` signs everyone out across restarts, which is fine for evaluation but not +for a real deployment — see [Deploying with Kamal 2](#deploying-with-kamal-2). If you +prefer the standard Rails flow, `bin/rails credentials:edit` generates your own +`config/master.key` + `config/credentials.yml.enc` pair, and `-e RAILS_MASTER_KEY=...` +then works instead. + The image is a non-root, multi-stage build served by **Thruster** (zero-config asset caching/compression/HTTP proxy) on port 80. `SOLID_QUEUE_IN_PUMA=true` runs the Solid Queue supervisor inside the same Puma process, so no separate worker @@ -160,17 +172,33 @@ machine: KAMAL_WEB_HOST= \ KAMAL_REGISTRY_USERNAME= \ KAMAL_REGISTRY_PASSWORD= \ +SECRET_KEY_BASE= \ bin/kamal deploy ``` -`RAILS_MASTER_KEY` is picked up by `.kamal/secrets` from `config/master.key` -automatically. +`.kamal/secrets` reads `SECRET_KEY_BASE` from the deploying shell's environment and +`config/deploy.yml` declares it under `env.secret` — the two must name the same +secret or Kamal aborts. Unlike the throwaway value used for a local Docker run, this +one must stay **stable across deploys**: changing it invalidates every existing +session and signed cookie. Generate it once with `openssl rand -hex 64` and keep it in +a password manager or your CI's secret store. To use Rails credentials instead, swap +both references to `RAILS_MASTER_KEY` (the alternative is commented in +`.kamal/secrets`). + +You can render the full config without contacting a server, which validates the ERB +and resolves the secrets: + +```bash +SECRET_KEY_BASE=test KAMAL_REGISTRY_USERNAME=x KAMAL_REGISTRY_PASSWORD=y \ + KAMAL_WEB_HOST=198.51.100.10 bin/kamal config +``` ## Environment Variables | Variable | Used by | Purpose | Default | |---------------------------|--------------------------------|-------------------------------------------------------------------------|----------------------------------| -| `RAILS_MASTER_KEY` | Rails credentials, Kamal | Decrypts `config/credentials.yml.enc` in production | — (required in production) | +| `SECRET_KEY_BASE` | Rails, Kamal | Signs sessions and signed cookies in production | — (required in production) | +| `RAILS_MASTER_KEY` | Rails credentials, Kamal | Optional alternative to `SECRET_KEY_BASE`, only if you generate your own credentials via `bin/rails credentials:edit` | — (unused by default) | | `RAILS_MAX_THREADS` | Puma, `database.yml` | Puma thread pool size / SQLite connection pool size | `3` (Puma) / `5` (DB pool) | | `PORT` | Puma | Server port | `3000` | | `SOLID_QUEUE_IN_PUMA` | `config/puma.rb`, Kamal | Runs the Solid Queue supervisor inside the Puma process | unset (off) | diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc deleted file mode 100644 index 35b54300e..000000000 --- a/config/credentials.yml.enc +++ /dev/null @@ -1 +0,0 @@ -sb6aDncM5V9EwR2nmytS+7s1c853MJV4LMyWCu9Wl7ihY+gO8cBbFZ7axgI65bezdQV0CS9CPU7S1QIr6E4e0vuB2joJbTH1wrJNetBtF/wXD/VzuA9fKUYa+hcqm+8ZERqx250ezJtE4PGzlhLe9VUp+49PsBI/Hkd/bEveL28W0jynfeza9ZE2laLvUvbMbRVThqe4DHLv0IpqgYLcfZO6Fdl5HlITZM51otC5KDGG0xFlPlsocQWRBlNU9fPGBAgx1NdGvTjMWxCZpXLNsIxlNwe1moVGxg+v2s+QlN6qyQiqn/eZaRdiidQO6bOEZsw/PuLdH1t6wD352QEcwNLBA5ydD153Pb0KuSEUm+y4140i6hplJLyETCmc83qyUKVUmnd7x6YB7dFJHCrsDa6FXVnuSbaglS2g5m14tMkCaFjKOrU06Q5lGMpE7wme5Az0M2g9OHp9byltndCuV5yEyWtMkRkT/LOcv74GUWgMcNrA96BLEyp2--wq9bUz2QwlyijIGh--KXsxEGbMCf3uMwh/823wUA== \ No newline at end of file diff --git a/config/deploy.yml b/config/deploy.yml index d5eeefdac..2933400db 100644 --- a/config/deploy.yml +++ b/config/deploy.yml @@ -45,7 +45,10 @@ registry: # Inject ENV variables into containers (secrets come from .kamal/secrets). env: secret: - - RAILS_MASTER_KEY + # Must match what .kamal/secrets actually provides, or Kamal aborts on an + # undeclared secret. Swap this for RAILS_MASTER_KEY if you generate your own + # Rails credentials — see the commented alternative in .kamal/secrets. + - SECRET_KEY_BASE clear: # Run the Solid Queue Supervisor inside the web server's Puma process to do jobs. # When you start using multiple servers, you should split out job processing to a dedicated machine. @@ -91,10 +94,9 @@ builder: # # # Pass arguments and secrets to the Docker build process # args: - # RUBY_VERSION: ruby-4.0.0 + # RUBY_VERSION: 4.0.0 # secrets: # - GITHUB_TOKEN - # - RAILS_MASTER_KEY # Use a different ssh user than root # ssh: