From 675b840c0942d7dfe67848d487867fd4053d2fe4 Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Wed, 9 Sep 2026 08:31:15 -0300 Subject: [PATCH 01/82] add .ruby-version file with Ruby version 4.0.6 --- .ruby-version | 1 + 1 file changed, 1 insertion(+) create mode 100644 .ruby-version diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 000000000..d13e837c8 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +4.0.6 From adb6b6f9397867b73e35dcd9cd273586e5d5ea8b Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Wed, 9 Sep 2026 08:41:04 -0300 Subject: [PATCH 02/82] Add custom error pages and assets - Created 422.html for handling "Unprocessable Entity" errors with a user-friendly message and SVG icon. - Created 500.html for handling "Internal Server Error" with a corresponding message and SVG icon. - Added icon.png and icon.svg for branding and visual representation on error pages. - Included a robots.txt file to manage web crawler access. - Added placeholder .keep files in various directories to ensure they are tracked by version control. --- .dockerignore | 51 ++ .gitattributes | 9 + .github/dependabot.yml | 12 + .github/workflows/ci.yml | 67 +++ .gitignore | 38 ++ .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 + Dockerfile | 77 +++ Gemfile | 60 ++ Gemfile.lock | 539 ++++++++++++++++++ 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 | 104 ++++ 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/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 tmp/pids/.keep | 0 tmp/storage/.keep | 0 vendor/.keep | 0 vendor/javascript/.keep | 0 100 files changed, 3058 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitattributes 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 Dockerfile create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 Procfile.dev create mode 100644 Rakefile create mode 100644 app/assets/builds/.keep create mode 100644 app/assets/images/.keep create mode 100644 app/assets/stylesheets/application.css create mode 100644 app/assets/tailwind/application.css create mode 100644 app/controllers/application_controller.rb create mode 100644 app/controllers/concerns/.keep create mode 100644 app/helpers/application_helper.rb create mode 100644 app/javascript/application.js create mode 100644 app/javascript/controllers/application.js create mode 100644 app/javascript/controllers/hello_controller.js create mode 100644 app/javascript/controllers/index.js create mode 100644 app/jobs/application_job.rb create mode 100644 app/mailers/application_mailer.rb create mode 100644 app/models/application_record.rb create mode 100644 app/models/concerns/.keep create mode 100644 app/views/layouts/application.html.erb create mode 100644 app/views/layouts/mailer.html.erb create mode 100644 app/views/layouts/mailer.text.erb create mode 100644 app/views/pwa/manifest.json.erb create mode 100644 app/views/pwa/service-worker.js create mode 100755 bin/brakeman create mode 100755 bin/bundler-audit create mode 100755 bin/ci create mode 100755 bin/dev create mode 100755 bin/docker-entrypoint create mode 100755 bin/importmap create mode 100755 bin/jobs create mode 100755 bin/kamal create mode 100755 bin/rails create mode 100755 bin/rake create mode 100755 bin/rubocop create mode 100755 bin/setup create mode 100755 bin/thrust create mode 100644 config.ru create mode 100644 config/application.rb create mode 100644 config/boot.rb create mode 100644 config/bundler-audit.yml create mode 100644 config/cable.yml create mode 100644 config/cache.yml create mode 100644 config/ci.rb create mode 100644 config/credentials.yml.enc create mode 100644 config/database.yml create mode 100644 config/deploy.yml create mode 100644 config/environment.rb create mode 100644 config/environments/development.rb create mode 100644 config/environments/production.rb create mode 100644 config/environments/test.rb create mode 100644 config/importmap.rb create mode 100644 config/initializers/assets.rb create mode 100644 config/initializers/content_security_policy.rb create mode 100644 config/initializers/filter_parameter_logging.rb create mode 100644 config/initializers/inflections.rb create mode 100644 config/locales/en.yml create mode 100644 config/puma.rb create mode 100644 config/queue.yml create mode 100644 config/recurring.yml create mode 100644 config/routes.rb create mode 100644 config/storage.yml create mode 100644 db/cable_schema.rb create mode 100644 db/cache_schema.rb create mode 100644 db/queue_schema.rb create mode 100644 db/seeds.rb create mode 100644 lib/tasks/.keep create mode 100644 log/.keep create mode 100644 public/400.html create mode 100644 public/404.html create mode 100644 public/406-unsupported-browser.html create mode 100644 public/422.html create mode 100644 public/500.html create mode 100644 public/icon.png create mode 100644 public/icon.svg create mode 100644 public/robots.txt create mode 100644 script/.keep create mode 100644 storage/.keep create mode 100644 tmp/.keep create mode 100644 tmp/pids/.keep create mode 100644 tmp/storage/.keep create mode 100644 vendor/.keep create mode 100644 vendor/javascript/.keep diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..325bfc036 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,51 @@ +# See https://docs.docker.com/engine/reference/builder/#dockerignore-file for more about ignoring files. + +# Ignore git directory. +/.git/ +/.gitignore + +# Ignore bundler config. +/.bundle + +# Ignore all environment files. +/.env* + +# Ignore all default key files. +/config/master.key +/config/credentials/*.key + +# Ignore all logfiles and tempfiles. +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep + +# Ignore pidfiles, but keep the directory. +/tmp/pids/* +!/tmp/pids/.keep + +# Ignore storage (uploaded files in development and any SQLite databases). +/storage/* +!/storage/.keep +/tmp/storage/* +!/tmp/storage/.keep + +# Ignore assets. +/node_modules/ +/app/assets/builds/* +!/app/assets/builds/.keep +/public/assets + +# Ignore CI service files. +/.github + +# Ignore Kamal files. +/config/deploy*.yml +/.kamal + +# Ignore development files +/.devcontainer + +# Ignore Docker-related files +/.dockerignore +/Dockerfile* diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..8dc432343 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# See https://git-scm.com/docs/gitattributes for more about git attribute files. + +# Mark the database schema as having been generated. +db/schema.rb linguist-generated + +# Mark any vendored files as having been vendored. +vendor/* linguist-vendored +config/credentials/*.yml.enc diff=rails_credentials +config/credentials.yml.enc diff=rails_credentials diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..83610cfa4 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: +- package-ecosystem: bundler + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 10 +- package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 10 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..d58c2aa4c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,67 @@ +name: CI + +on: + pull_request: + push: + branches: [ main ] + +jobs: + scan_ruby: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Scan for common Rails security vulnerabilities using static analysis + run: bin/brakeman --no-pager + + - name: Scan for known security vulnerabilities in gems used + run: bin/bundler-audit + + scan_js: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Scan for security vulnerabilities in JavaScript dependencies + run: bin/importmap audit + + lint: + runs-on: ubuntu-latest + env: + RUBOCOP_CACHE_ROOT: tmp/rubocop + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Prepare RuboCop cache + uses: actions/cache@v4 + env: + DEPENDENCIES_HASH: ${{ hashFiles('.ruby-version', '**/.rubocop.yml', '**/.rubocop_todo.yml', 'Gemfile.lock') }} + with: + path: ${{ env.RUBOCOP_CACHE_ROOT }} + key: rubocop-${{ runner.os }}-${{ env.DEPENDENCIES_HASH }}-${{ github.ref_name == github.event.repository.default_branch && github.run_id || 'default' }} + restore-keys: | + rubocop-${{ runner.os }}-${{ env.DEPENDENCIES_HASH }}- + + - name: Lint code for consistent style + run: bin/rubocop -f github + diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..e953825f7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# See https://help.github.com/articles/ignoring-files for more about ignoring files. +# +# Temporary files generated by your text editor or operating system +# belong in git's global ignore instead: +# `$XDG_CONFIG_HOME/git/ignore` or `~/.config/git/ignore` + +# Ignore bundler config. +/.bundle + +# Ignore all environment files. +/.env* + +# Ignore all logfiles and tempfiles. +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep + +# Ignore pidfiles, but keep the directory. +/tmp/pids/* +!/tmp/pids/ +!/tmp/pids/.keep + +# Ignore storage (uploaded files in development and any SQLite databases). +/storage/* +!/storage/.keep +/tmp/storage/* +!/tmp/storage/ +!/tmp/storage/.keep + +/public/assets + +# Ignore key files for decrypting credentials and more. +/config/*.key + + +/app/assets/builds/* +!/app/assets/builds/.keep 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/Dockerfile b/Dockerfile new file mode 100644 index 000000000..6afd1d3de --- /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.6 +FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base + +# Rails app lives here +WORKDIR /rails + +# Install base packages +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y curl libjemalloc2 libvips postgresql-client && \ + ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# 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 libpq-dev 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..6d1b45d77 --- /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 postgresql as the database for Active Record +gem "pg", "~> 1.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..4659db30a --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,539 @@ +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.26.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-arm64-darwin) + 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 (3.0.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-arm64-darwin) + 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.2.0) + parser (3.3.12.0) + ast (~> 2.4.1) + racc + pg (1.6.3) + pg (1.6.3-aarch64-linux) + pg (1.6.3-aarch64-linux-musl) + pg (1.6.3-arm64-darwin) + pg (1.6.3-x86_64-linux) + pg (1.6.3-x86_64-linux-musl) + pp (0.6.4) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) + propshaft (1.3.2) + actionpack (>= 7.0.0) + activesupport (>= 7.0.0) + rack + 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) + 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-arm64-darwin) + 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-arm64-darwin) + 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 + arm64-darwin-25 + x86_64-linux + x86_64-linux-gnu + x86_64-linux-musl + +DEPENDENCIES + bootsnap + brakeman + bundler-audit + debug + image_processing (~> 1.2) + importmap-rails + kamal + pg (~> 1.1) + propshaft + puma (>= 5.0) + rails (~> 8.1.3, >= 8.1.3.1) + rubocop-rails-omakase + solid_cable + solid_cache + solid_queue + stimulus-rails + tailwindcss-rails + thruster + turbo-rails + tzinfo-data + web-console + +CHECKSUMS + action_text-trix (2.1.19) sha256=7012f59421009cf284aa651294896414d653a61a2417c9b8714c8476d2f74009 + actioncable (8.1.3.1) sha256=e318528295c878a3efdfe25f0f2267c80cb7a76eba41bb5f64d44aa380a3d91b + actionmailbox (8.1.3.1) sha256=5f704972097d843ade8e435e93694a1dac732b926df1717aceba1f3840082b1c + actionmailer (8.1.3.1) sha256=88ea441b28ff02a0c6c006468892642a3d9942affce9d294e81a74504aa5c43c + actionpack (8.1.3.1) sha256=974cb7154548e81f470b1b0f247b99cb38e87825899dca58610596e2817723d0 + actiontext (8.1.3.1) sha256=5da729d833d1a29cddb1eee938878e55e503d2613e00e735f5daf58c2ba98af2 + actionview (8.1.3.1) sha256=2da68b8414c47b43bfbed1ce69c5afe1c04f78c267aacb5660a4cab5ca12cfb6 + activejob (8.1.3.1) sha256=1c8dd275df930df40deecffec63d913a550a33fd94bd298f69721dd96939954a + activemodel (8.1.3.1) sha256=99cc02ce2faec371d14440949d85787ebd23a907c9baef0a9d4bcd4d21888f88 + activerecord (8.1.3.1) sha256=0a2fb6c28f4938f6b013a3a549bec0a7e37d535f3dc8990e804bcc3258c0403b + activestorage (8.1.3.1) sha256=f555254f387b1cffa499d2fd3115d12635eadc5b15206a8534316a67036163ef + activesupport (8.1.3.1) sha256=85458765f25ea48b9019c46b6bb3fa5683197bf4280d9f06710a6e8d7a831376 + 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.26.0) sha256=ca96237015e6cd74a02963d5821cf00ac5ea134653b323e8cd6d702a7718bf1b + 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-arm64-darwin) sha256=19071aaf1419251b0a46852abf960e77330a3b334d13a4ab51d58b31a937001b + 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 (3.0.2) sha256=8e6d7e7b11384c21230430cef90b71f14849a34a1f4452796670f7c981bd19df + 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-arm64-darwin) sha256=a46db9853286e6597b36ebc6953817d15acf3a299583eb3f89fdc6f91dd63527 + 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.2.0) sha256=e1059c5fd7b649558a0aec38a769f06a42942bdb40503d005a59c352fe011cd8 + parser (3.3.12.0) sha256=21a6d7f755d5a24dfbdc6e6b772e4e879a52e7631a88bc5a3a134606052c9828 + pg (1.6.3) sha256=1388d0563e13d2758c1089e35e973a3249e955c659592d10e5b77c468f628a99 + pg (1.6.3-aarch64-linux) sha256=0698ad563e02383c27510b76bf7d4cd2de19cd1d16a5013f375dd473e4be72ea + pg (1.6.3-aarch64-linux-musl) sha256=06a75f4ea04b05140146f2a10550b8e0d9f006a79cdaf8b5b130cde40e3ecc2c + pg (1.6.3-arm64-darwin) sha256=7240330b572e6355d7c75a7de535edb5dfcbd6295d9c7777df4d9dddfb8c0e5f + pg (1.6.3-x86_64-linux) sha256=5d9e188c8f7a0295d162b7b88a768d8452a899977d44f3274d1946d67920ae8d + pg (1.6.3-x86_64-linux-musl) sha256=9c9c90d98c72f78eb04c0f55e9618fe55d1512128e411035fe229ff427864009 + pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570 + prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 + propshaft (1.3.2) sha256=1d56a3e56a92c21bfc29caf07406b5386b00d4c47ddf357cf989a5a234b1389e + 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 + 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-arm64-darwin) sha256=776c51fc734aac64bde0c253eadd2ed2e610b2ba0d8e760501fe7cbec55fd6cf + 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-arm64-darwin) sha256=40676164c433abf31313422305d9e9e9210cf941b1befad88e2428b3b8dcc36c + 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.16 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..55f6e6f03 --- /dev/null +++ b/config/credentials.yml.enc @@ -0,0 +1 @@ +GdTutywriVqicIzZ90Zoz5K/uR+CsKWKSymQDh8QNsplJC0BosziyOSTtWRX0ANvznPL4GPVuDHPSQKn/Ev2eL2un+lH2HAIcw+9w//DvlVrPqJUO9eAFPgkSkHsoAJ9Lg+EtX9QvEKYByf+X5gtLA1/FfuSpiQYiyBusT98/H4VIbtVECkulkpMfy0MIc4vL/cnPyN413EaRNOnuVvKkgHEOOm719UAUD0Vf41FF7K/Hppw5tOPuDD/3UET4Z3f41nYloxtXWS07eTsBQoBLKbY/Rt3YDzKqMTje8i3n3BzJFfjBPvkcsXtb8hE68ixWAKnefXB+mGCvj8NJVu0/YQfE+ia5LmSVHrtfFVGwyLGdieB3r7zGo0K12MOE8wJa4Guv6C3jq3/sNS2bQ5sNcNJuAuAhXrN/rLKdMjj6ujMkvXyIEOZuOmJNuvrB4EAfvN431QnnUsiHNJYAsXXlVTb8TPIPi7vet+yYk4uO2sc4DHs1Cz+2XSC--6Za1LfeYwQcSQGT8--FG3erv1ih6j3FQUHTmokBg== \ No newline at end of file diff --git a/config/database.yml b/config/database.yml new file mode 100644 index 000000000..fa81eaeba --- /dev/null +++ b/config/database.yml @@ -0,0 +1,104 @@ +# PostgreSQL. Versions 9.5 and up are supported. +# +# Install the pg driver: +# gem install pg +# On macOS with Homebrew: +# gem install pg -- --with-pg-config=/opt/homebrew/bin/pg_config +# On Windows: +# gem install pg +# Choose the win32 build. +# Install PostgreSQL and put its /bin directory on your path. +# +# Configure Using Gemfile +# gem "pg" +# +default: &default + adapter: postgresql + encoding: unicode + # For details on connection pooling, see Rails configuration guide + # https://guides.rubyonrails.org/configuring.html#database-pooling + max_connections: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + + +development: + <<: *default + database: fullstack_developer_development + + # The specified database role being used to connect to PostgreSQL. + # To create additional roles in PostgreSQL see `$ createuser --help`. + # When left blank, PostgreSQL will use the default role. This is + # the same name as the operating system user running Rails. + #username: fullstack_developer + + # The password associated with the PostgreSQL role (username). + #password: + + # Connect on a TCP socket. Omitted by default since the client uses a + # domain socket that doesn't need configuration. Windows does not have + # domain sockets, so uncomment these lines. + #host: localhost + + # The TCP port the server listens on. Defaults to 5432. + # If your server runs on a different port number, change accordingly. + #port: 5432 + + # Schema search path. The server defaults to $user,public + #schema_search_path: myapp,sharedapp,public + + # Minimum log levels, in increasing order: + # debug5, debug4, debug3, debug2, debug1, + # log, notice, warning, error, fatal, and panic + # Defaults to warning. + #min_messages: notice + +# 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: fullstack_developer_test + +# As with config/credentials.yml, you never want to store sensitive information, +# like your database password, in your source code. If your source code is +# ever seen by anyone, they now have access to your database. +# +# Instead, provide the password or a full connection URL as an environment +# variable when you boot the app. For example: +# +# DATABASE_URL="postgres://myuser:mypass@localhost/somedatabase" +# +# If the connection URL is provided in the special DATABASE_URL environment +# variable, Rails will automatically merge its configuration values on top of +# the values provided in this file. Alternatively, you can specify a connection +# URL environment variable explicitly: +# +# production: +# url: <%= ENV["MY_APP_DATABASE_URL"] %> +# +# Connection URLs for non-primary databases can also be configured using +# environment variables. The variable name is formed by concatenating the +# connection name with `_DATABASE_URL`. For example: +# +# CACHE_DATABASE_URL="postgres://cacheuser:cachepass@localhost/cachedatabase" +# +# Read https://guides.rubyonrails.org/configuring.html#configuring-a-database +# for a full overview on how database connection configuration can be specified. +# +production: + primary: &primary_production + <<: *default + database: fullstack_developer_production + username: fullstack_developer + password: <%= ENV["FULLSTACK_DEVELOPER_DATABASE_PASSWORD"] %> + cache: + <<: *primary_production + database: fullstack_developer_production_cache + migrations_paths: db/cache_migrate + queue: + <<: *primary_production + database: fullstack_developer_production_queue + migrations_paths: db/queue_migrate + cable: + <<: *primary_production + database: fullstack_developer_production_cable + migrations_paths: db/cable_migrate diff --git a/config/deploy.yml b/config/deploy.yml new file mode 100644 index 000000000..af06c2a9e --- /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: 4.0.6 + # secrets: + # - GITHUB_TOKEN + # - RAILS_MASTER_KEY + +# Use a different ssh user than root +# ssh: +# user: app + +# Use accessory services (secrets come from .kamal/secrets). +# accessories: +# db: +# image: mysql:8.0 +# host: 192.168.0.2 +# # Change to 3306 to expose port to the world instead of just local network. +# port: "127.0.0.1:3306:3306" +# env: +# clear: +# MYSQL_ROOT_HOST: '%' +# secret: +# - MYSQL_ROOT_PASSWORD +# files: +# - config/mysql/production.cnf:/etc/mysql/my.cnf +# - db/production.sql:/docker-entrypoint-initdb.d/setup.sql +# directories: +# - data:/var/lib/mysql +# redis: +# image: valkey/valkey:8 +# host: 192.168.0.2 +# port: 6379 +# directories: +# - data:/data diff --git a/config/environment.rb b/config/environment.rb new file mode 100644 index 000000000..cac531577 --- /dev/null +++ b/config/environment.rb @@ -0,0 +1,5 @@ +# Load the Rails application. +require_relative "application" + +# Initialize the Rails application. +Rails.application.initialize! diff --git a/config/environments/development.rb b/config/environments/development.rb new file mode 100644 index 000000000..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/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/tmp/pids/.keep b/tmp/pids/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/tmp/storage/.keep b/tmp/storage/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/vendor/.keep b/vendor/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/vendor/javascript/.keep b/vendor/javascript/.keep new file mode 100644 index 000000000..e69de29bb From 8a790f24516b322835e415864cf37d37268010e2 Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Wed, 9 Sep 2026 08:41:37 -0300 Subject: [PATCH 03/82] Add initial database schema file --- db/schema.rb | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 db/schema.rb diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 000000000..f8be1d34a --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,17 @@ +# 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 + # These are extensions that must be enabled in order to support this database + enable_extension "pg_catalog.plpgsql" + +end From 4396bd7668ff1978f6ec76c98af19a73ef716911 Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Wed, 9 Sep 2026 08:45:36 -0300 Subject: [PATCH 04/82] chore: initialize project with TypeScript, Vite, and RSpec setup - Added package.json with dependencies for React, Inertia.js, Tailwind CSS, and Vite. - Created rails_helper.rb and spec_helper.rb for RSpec configuration. - Added TypeScript configuration files: tsconfig.app.json, tsconfig.node.json, and tsconfig.json. - Set up Vite configuration for React, Inertia.js, and Tailwind CSS integration. --- .gitignore | 8 + .rspec | 1 + Gemfile | 12 + Gemfile.lock | 91 + Procfile.dev | 1 + app/controllers/inertia_controller.rb | 7 + app/controllers/inertia_example_controller.rb | 12 + app/javascript/assets/inertia.svg | 1 + app/javascript/assets/rails.svg | 9 + app/javascript/assets/react.svg | 1 + app/javascript/assets/vite_ruby.svg | 1 + app/javascript/entrypoints/application.css | 4 + app/javascript/entrypoints/application.ts | 28 + app/javascript/entrypoints/inertia.tsx | 30 + .../pages/inertia_example/index.module.css | 102 + .../pages/inertia_example/index.tsx | 59 + app/javascript/types/globals.d.ts | 9 + app/javascript/types/index.ts | 6 + app/javascript/types/vite-env.d.ts | 1 + app/views/layouts/application.html.erb | 18 +- bin/dev | 27 +- bin/setup | 1 + bin/vite | 16 + .../initializers/content_security_policy.rb | 9 + config/initializers/inertia_rails.rb | 9 + config/routes.rb | 7 + config/vite.json | 17 + package-lock.json | 2004 +++++++++++++++++ package.json | 26 + spec/rails_helper.rb | 72 + spec/spec_helper.rb | 94 + tsconfig.app.json | 33 + tsconfig.json | 11 + tsconfig.node.json | 13 + vite.config.ts | 14 + 35 files changed, 2743 insertions(+), 11 deletions(-) create mode 100644 .rspec create mode 100644 app/controllers/inertia_controller.rb create mode 100644 app/controllers/inertia_example_controller.rb create mode 100644 app/javascript/assets/inertia.svg create mode 100644 app/javascript/assets/rails.svg create mode 100644 app/javascript/assets/react.svg create mode 100644 app/javascript/assets/vite_ruby.svg create mode 100644 app/javascript/entrypoints/application.css create mode 100644 app/javascript/entrypoints/application.ts create mode 100644 app/javascript/entrypoints/inertia.tsx create mode 100644 app/javascript/pages/inertia_example/index.module.css create mode 100644 app/javascript/pages/inertia_example/index.tsx create mode 100644 app/javascript/types/globals.d.ts create mode 100644 app/javascript/types/index.ts create mode 100644 app/javascript/types/vite-env.d.ts create mode 100755 bin/vite create mode 100644 config/initializers/inertia_rails.rb create mode 100644 config/vite.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 spec/rails_helper.rb create mode 100644 spec/spec_helper.rb create mode 100644 tsconfig.app.json create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore index e953825f7..617fd797b 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,11 @@ /app/assets/builds/* !/app/assets/builds/.keep + +# Vite Ruby +/public/vite* +node_modules +# Vite uses dotenv and suggests to ignore local-only env files. See +# https://vitejs.dev/guide/env-and-mode.html#env-files +*.local + 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 6d1b45d77..04b2ff79d 100644 --- a/Gemfile +++ b/Gemfile @@ -17,6 +17,8 @@ gem "stimulus-rails" # Use Tailwind CSS [https://github.com/rails/tailwindcss-rails] gem "tailwindcss-rails" +gem "inertia_rails" + # Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] # gem "bcrypt", "~> 3.1.7" @@ -52,9 +54,19 @@ group :development, :test do # Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/] gem "rubocop-rails-omakase", require: false + +end + +group :test do + gem "rspec-rails" + gem "factory_bot_rails" + gem "capybara" + gem "selenium-webdriver" end group :development do # Use console on exceptions pages [https://github.com/rails/web-console] gem "web-console" end + +gem "vite_rails", "~> 3.11" diff --git a/Gemfile.lock b/Gemfile.lock index 4659db30a..169fc07e2 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,15 @@ 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) concurrent-ruby (1.3.8) connection_pool (3.0.2) crass (1.0.7) @@ -95,13 +106,20 @@ 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) + dry-cli (1.4.1) ed25519 (1.4.0) erb (6.0.7) erubi (1.13.1) et-orbi (1.4.2) tzinfo + factory_bot (6.6.0) + activesupport (>= 6.1.0) + factory_bot_rails (6.5.1) + factory_bot (~> 6.5) + railties (>= 6.1.0) ffi (1.17.4-aarch64-linux-gnu) ffi (1.17.4-aarch64-linux-musl) ffi (1.17.4-arm-linux-gnu) @@ -123,6 +141,8 @@ GEM actionpack (>= 6.0.0) activesupport (>= 6.0.0) railties (>= 6.0.0) + inertia_rails (3.22.0) + railties (>= 6) io-console (0.9.2) irb (1.18.0) pp (>= 0.6.0) @@ -154,6 +174,7 @@ GEM net-pop net-smtp marcel (1.2.1) + matrix (0.4.3) mini_magick (5.4.0) logger mini_mime (1.1.5) @@ -161,6 +182,7 @@ GEM drb (~> 2.0) prism (~> 1.5) msgpack (1.8.4) + mutex_m (0.3.0) net-imap (0.6.6) date net-protocol @@ -209,11 +231,14 @@ 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) racc (1.8.1) rack (3.2.7) + rack-proxy (2.0.0) + rack (>= 2.0, < 4) rack-session (2.1.2) base64 (>= 0.1.0) rack (>= 3.0.0) @@ -265,6 +290,24 @@ 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) + 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) @@ -297,7 +340,14 @@ GEM ruby-vips (2.3.0) ffi (~> 1.12) logger + rubyzip (3.6.0) securerandom (0.4.1) + selenium-webdriver (4.48.0) + base64 (~> 0.2) + logger (~> 1.4) + rexml (~> 3.2, >= 3.2.5) + rubyzip (>= 1.2.2, < 4.0) + websocket (~> 1.0) solid_cable (4.0.2) actioncable (>= 7.2) activejob (>= 7.2) @@ -349,14 +399,26 @@ GEM unicode-emoji (4.2.0) uri (1.1.1) useragent (0.16.11) + vite_rails (3.11.1) + railties (>= 5.1, < 9) + vite_ruby (~> 3.0, >= 3.2.2) + vite_ruby (3.10.5) + dry-cli (>= 0.7, < 2) + logger (~> 1.6) + mutex_m + rack-proxy (>= 0.6.1) + zeitwerk (~> 2.2) web-console (4.3.0) actionview (>= 8.0.0) bindex (>= 0.4.0) railties (>= 8.0.0) + websocket (1.2.11) websocket-driver (0.8.2) base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) + xpath (3.2.0) + nokogiri (~> 1.8) zeitwerk (2.8.3) PLATFORMS @@ -374,15 +436,20 @@ DEPENDENCIES bootsnap brakeman bundler-audit + capybara debug + factory_bot_rails image_processing (~> 1.2) importmap-rails + inertia_rails kamal pg (~> 1.1) propshaft puma (>= 5.0) rails (~> 8.1.3, >= 8.1.3.1) + rspec-rails rubocop-rails-omakase + selenium-webdriver solid_cable solid_cache solid_queue @@ -391,6 +458,7 @@ DEPENDENCIES thruster turbo-rails tzinfo-data + vite_rails (~> 3.11) web-console CHECKSUMS @@ -406,6 +474,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 @@ -415,17 +484,22 @@ 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 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 + dry-cli (1.4.1) sha256=b8015bb76c708aa8705a36faf694973e75eeeffca39b89c8e172dc6f66a7d874 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 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 @@ -438,6 +512,7 @@ CHECKSUMS i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5 image_processing (1.14.0) sha256=754cc169c9c262980889bec6bfd325ed1dafad34f85242b5a07b60af004742fb importmap-rails (2.2.3) sha256=7101be2a4dc97cf1558fb8f573a718404c5f6bcfe94f304bf1f39e444feeb16a + inertia_rails (3.22.0) sha256=39c20120de472015d2831fa461f8a09672c68e91c41d3d660e0b1d16b787b7b1 io-console (0.9.2) sha256=efa74f891dd03c0939a931dfc6e74c2813d904763d456ea9762b0525e748db08 irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 json (3.0.2) sha256=8e6d7e7b11384c21230430cef90b71f14849a34a1f4452796670f7c981bd19df @@ -448,10 +523,12 @@ 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 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 + mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751 net-imap (0.6.6) sha256=96aa4ee50df3060203e649efc341f53480b791d49e150f2fdebf68beb141a8df net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 net-protocol (0.3.0) sha256=ba310c3d4f1cad46bb1ab20336b06669b1ff8f7c568d9cb9342b32a718547472 @@ -480,10 +557,12 @@ CHECKSUMS prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 propshaft (1.3.2) sha256=1d56a3e56a92c21bfc29caf07406b5386b00d4c47ddf357cf989a5a234b1389e + public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 puma (8.0.2) sha256=c8ed871dfbbe66448ea9ffd46692342d9804d4071522b52b5331b7b6e7b686fb raabro (1.5.0) sha256=3f998a7bc84f9c84df3ab580634d2e0a5bda4f0841168d56035f529c9877440a racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f rack (3.2.7) sha256=93e13e1c24f93556671d85d2d79fa228c3485815c50d7e2f265b5330c6528fb7 + rack-proxy (2.0.0) sha256=4f1d435d82afe93bc916d1226df8be307c1b808551f0ecdb56e0b668fd5756e6 rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8 rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463 rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868 @@ -497,6 +576,12 @@ 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 + 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 @@ -504,7 +589,9 @@ 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 + selenium-webdriver (4.48.0) sha256=0c8376ebc8a0a4879343fe6fe6eccdcea76748611cd25de370b33eded2077a94 solid_cable (4.0.2) sha256=084636a67679ad00d23088b33c84047e614bcf41ee559db24b414d83cdc42d03 solid_cache (1.0.10) sha256=bc05a2fb3ac78a6f43cbb5946679cf9db67dd30d22939ededc385cb93e120d41 solid_queue (1.7.0) sha256=6566b70b801d1c317c81bba7bcdd5677c019afac584a30374b4164002ca356d3 @@ -530,9 +617,13 @@ CHECKSUMS unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844 + vite_rails (3.11.1) sha256=61fa4a7c9248fc28f22a05e0760810bf79f645b776c721d888a815c5d21dd338 + vite_ruby (3.10.5) sha256=e9ee92be1cb31c0b6360b02182cfe09d3ac2b7fc278db7870e3f32ae64dde49e web-console (4.3.0) sha256=e13b71301cdfc2093f155b5aa3a622db80b4672d1f2f713119cc7ec7ac6a6da4 + websocket (1.2.11) sha256=b7e7a74e2410b5e85c25858b26b3322f29161e300935f70a0e0d3c35e0462737 websocket-driver (0.8.2) sha256=97c556b019bf3410b4961002ac501621e9322d3f8a7bc02161a09301cc4c4146 websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241 + xpath (3.2.0) sha256=6dfda79d91bb3b949b947ecc5919f042ef2f399b904013eb3ef6d20dd3a4082e zeitwerk (2.8.3) sha256=2c85125a8467ce069e20123d1e709a08955c9d29c118c25b46b7b7fafdbb92e5 BUNDLED WITH diff --git a/Procfile.dev b/Procfile.dev index da151fee9..e6ad037be 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -1,2 +1,3 @@ web: bin/rails server css: bin/rails tailwindcss:watch +vite: bin/vite dev diff --git a/app/controllers/inertia_controller.rb b/app/controllers/inertia_controller.rb new file mode 100644 index 000000000..2d86313af --- /dev/null +++ b/app/controllers/inertia_controller.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class InertiaController < ApplicationController + # Share data with all Inertia responses + # see https://inertia-rails.dev/guide/shared-data + # inertia_share user: -> { Current.user&.as_json(only: [:id, :name, :email]) } +end diff --git a/app/controllers/inertia_example_controller.rb b/app/controllers/inertia_example_controller.rb new file mode 100644 index 000000000..3792b4df1 --- /dev/null +++ b/app/controllers/inertia_example_controller.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class InertiaExampleController < InertiaController + def index + render inertia: { + rails_version: Rails.version, + ruby_version: RUBY_DESCRIPTION, + rack_version: Rack.release, + inertia_rails_version: InertiaRails::VERSION, + } + end +end diff --git a/app/javascript/assets/inertia.svg b/app/javascript/assets/inertia.svg new file mode 100644 index 000000000..61ec585c3 --- /dev/null +++ b/app/javascript/assets/inertia.svg @@ -0,0 +1 @@ + diff --git a/app/javascript/assets/rails.svg b/app/javascript/assets/rails.svg new file mode 100644 index 000000000..92f66e7c8 --- /dev/null +++ b/app/javascript/assets/rails.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/javascript/assets/react.svg b/app/javascript/assets/react.svg new file mode 100644 index 000000000..ae3e3f227 --- /dev/null +++ b/app/javascript/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/assets/vite_ruby.svg b/app/javascript/assets/vite_ruby.svg new file mode 100644 index 000000000..c4d427016 --- /dev/null +++ b/app/javascript/assets/vite_ruby.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/entrypoints/application.css b/app/javascript/entrypoints/application.css new file mode 100644 index 000000000..d93dbc6fa --- /dev/null +++ b/app/javascript/entrypoints/application.css @@ -0,0 +1,4 @@ +@import 'tailwindcss'; + +@plugin '@tailwindcss/typography'; +@plugin '@tailwindcss/forms'; diff --git a/app/javascript/entrypoints/application.ts b/app/javascript/entrypoints/application.ts new file mode 100644 index 000000000..ff27427fd --- /dev/null +++ b/app/javascript/entrypoints/application.ts @@ -0,0 +1,28 @@ +// To see this message, add the following to the `` section in your +// views/layouts/application.html.erb +// +// <%= vite_client_tag %> +// <%= vite_javascript_tag 'application' %> +console.log('Vite ⚡️ Rails') + +// If using a TypeScript entrypoint file: +// <%= vite_typescript_tag 'application' %> +// +// If you want to use .jsx or .tsx, add the extension: +// <%= vite_javascript_tag 'application.jsx' %> + +console.log('Visit the guide for more information: ', 'https://vite-ruby.netlify.app/guide/rails') + +// Example: Load Rails libraries in Vite. +// +// import * as Turbo from '@hotwired/turbo' +// Turbo.start() +// +// import ActiveStorage from '@rails/activestorage' +// ActiveStorage.start() +// +// // Import all channels. +// const channels = import.meta.glob('./**/*_channel.js', { eager: true }) + +// Example: Import a stylesheet in app/frontend/index.css +// import '~/index.css' diff --git a/app/javascript/entrypoints/inertia.tsx b/app/javascript/entrypoints/inertia.tsx new file mode 100644 index 000000000..3da2cca0e --- /dev/null +++ b/app/javascript/entrypoints/inertia.tsx @@ -0,0 +1,30 @@ +import { createInertiaApp } from '@inertiajs/react' + +void createInertiaApp({ + pages: "../pages", + + strictMode: true, + + defaults: { + form: { + forceIndicesArrayFormatInFormData: false, + withAllErrors: true, + }, + visitOptions: () => { + return { queryStringArrayFormat: "brackets" } + }, + }, +}).catch((error) => { + // This ensures this entrypoint is only loaded on Inertia pages + // by checking for the presence of the root element (#app by default). + // Feel free to remove this `catch` if you don't need it. + if (document.getElementById("app")) { + throw error + } else { + console.error( + "Missing root element.\n\n" + + "If you see this error, it probably means you loaded Inertia.js on non-Inertia pages.\n" + + 'Consider moving <%= vite_typescript_tag "inertia.tsx" %> to the Inertia-specific layout instead.', + ) + } +}) diff --git a/app/javascript/pages/inertia_example/index.module.css b/app/javascript/pages/inertia_example/index.module.css new file mode 100644 index 000000000..1aae5e40a --- /dev/null +++ b/app/javascript/pages/inertia_example/index.module.css @@ -0,0 +1,102 @@ +.root { + box-sizing: border-box; + margin: 0; + padding: 0; + align-items: center; + background-color: #F0E7E9; + background-image: url(data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjEwMjQiIHZpZXdCb3g9IjAgMCAxNDQwIDEwMjQiIHdpZHRoPSIxNDQwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxwYXRoIGQ9Im0xNDQwIDUxMC4wMDA2NDh2LTUxMC4wMDA2NDhoLTE0NDB2Mzg0LjAwMDY0OGM0MTcuMzExOTM5IDEzMS4xNDIxNzkgODkxIDE3MS41MTMgMTQ0MCAxMjZ6IiBmaWxsPSIjZmZmIi8+PC9zdmc+); + background-position: center center; + background-repeat: no-repeat; + background-size: cover; + color: #261B23; + display: flex; + flex-direction: column; + font-family: Sans-Serif; + font-size: calc(0.9em + 0.5vw); + font-style: normal; + font-weight: 400; + justify-content: center; + line-height: 1.25; + min-height: 100vh; + text-align: center; +} + +@media (prefers-color-scheme: dark) { + .root { + background-color: #1a1a1a; + background-image: url(data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjEwMjQiIHZpZXdCb3g9IjAgMCAxNDQwIDEwMjQiIHdpZHRoPSIxNDQwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxwYXRoIGQ9Im0xNDQwIDUxMC4wMDA2NDh2LTUxMC4wMDA2NDhoLTE0NDB2Mzg0LjAwMDY0OGM0MTcuMzExOTM5IDEzMS4xNDIxNzkgODkxIDE3MS41MTMgMTQ0MCAxMjZ6IiBmaWxsPSIjMzMzIi8+PC9zdmc+); + color: #e0e0e0; + } +} + +.logo { + display: inline-block; + height: 9.8vw; + min-height: 130px; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; + filter: drop-shadow(0 20px 13px rgb(0 0 0 / 0.03)) drop-shadow(0 8px 5px rgb(0 0 0 / 0.08)); +} +.logo.inertia:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} +.logo.rails:hover { + filter: drop-shadow(0 0 2em rgb(211 0 1 / 0.6)); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + .logo.react { + animation: logo-spin infinite 20s linear; + } +} + +@media (prefers-color-scheme: dark) { + .logo { + filter: drop-shadow(0 20px 13px rgb(255 255 255 / 0.03)) drop-shadow(0 8px 5px rgb(255 255 255 / 0.08)); + } +} + +.card { + padding: 2em; + font-size: 0.7em; + color: #948e90; +} + +.footer { + bottom: 0; + left: 0; + margin: 0 2rem 2rem 2rem; + position: absolute; + right: 0; +} + +.footer ul { + list-style: none; +} + +.footer ul li { + display: inline; +} + +.footer ul ul li:after { + content: " | "; + font-weight: 300; + color: #948e90; +} + +.footer ul ul li:last-child:after { + content: ""; +} diff --git a/app/javascript/pages/inertia_example/index.tsx b/app/javascript/pages/inertia_example/index.tsx new file mode 100644 index 000000000..4518ab79e --- /dev/null +++ b/app/javascript/pages/inertia_example/index.tsx @@ -0,0 +1,59 @@ +import { Head } from '@inertiajs/react' +import { version as react_version } from 'react' + +import railsSvg from '/assets/rails.svg' +import inertiaSvg from '/assets/inertia.svg' +import reactSvg from '/assets/react.svg' + +import cs from './index.module.css' + +export default function InertiaExample( + { rails_version, ruby_version, rack_version, inertia_rails_version }: + { rails_version: string, ruby_version: string, rack_version: string, inertia_rails_version: string } +) { + return ( +
+ + + + +
+
+

+ Edit app/javascript/pages/inertia_example/index.tsx and save to test HMR. +

+
+ +
    +
  • +
      +
    • Rails version: {rails_version}
    • +
    • Rack version: {rack_version}
    • +
    +
  • +
  • Ruby version: {ruby_version}
  • +
  • +
      +
    • Inertia Rails version: {inertia_rails_version}
    • +
    • React version: {react_version}
    • +
    +
  • +
+
+
+ ) +} diff --git a/app/javascript/types/globals.d.ts b/app/javascript/types/globals.d.ts new file mode 100644 index 000000000..506babdce --- /dev/null +++ b/app/javascript/types/globals.d.ts @@ -0,0 +1,9 @@ +import type { FlashData, SharedProps } from '@/types' + +declare module '@inertiajs/core' { + export interface InertiaConfig { + sharedPageProps: SharedProps + flashDataType: FlashData + errorValueType: string[] + } +} diff --git a/app/javascript/types/index.ts b/app/javascript/types/index.ts new file mode 100644 index 000000000..4a1370430 --- /dev/null +++ b/app/javascript/types/index.ts @@ -0,0 +1,6 @@ +export type FlashData = { + notice?: string + alert?: string +} + +export type SharedProps = {} diff --git a/app/javascript/types/vite-env.d.ts b/app/javascript/types/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/app/javascript/types/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 2702498a2..6cfa679f0 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -1,7 +1,7 @@ - <%= content_for(:title) || "Fullstack Developer" %> + <%= content_for(:title) || "Fullstack Developer" %> @@ -21,6 +21,22 @@ <%# Includes all stylesheet files in app/assets/stylesheets %> <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> <%= javascript_importmap_tags %> + <%= vite_stylesheet_tag "application" %> + <%= vite_react_refresh_tag %> + <%= vite_client_tag %> + <%= vite_typescript_tag "inertia.tsx" %> + <%= inertia_ssr_head %> + <%= vite_typescript_tag 'application' %> + + diff --git a/bin/dev b/bin/dev index ad72c7d53..ef33f02c7 100755 --- a/bin/dev +++ b/bin/dev @@ -1,16 +1,23 @@ #!/usr/bin/env sh -if ! gem list foreman -i --silent; then - echo "Installing foreman..." - gem install foreman +export PORT="${PORT:-3000}" + +if command -v overmind 1> /dev/null 2>&1 +then + overmind start -f Procfile.dev "$@" + exit $? fi -# Default to port 3000 if not specified -export PORT="${PORT:-3000}" +if command -v hivemind 1> /dev/null 2>&1 +then + echo "Hivemind is installed. Running the application with Hivemind..." + exec hivemind Procfile.dev "$@" + exit $? +fi -# Let the debug gem allow remote connections, -# but avoid loading until `debugger` is called -export RUBY_DEBUG_OPEN="true" -export RUBY_DEBUG_LAZY="true" +if gem list --no-installed --exact --silent foreman; then + echo "Installing foreman..." + gem install foreman +fi -exec foreman start -f Procfile.dev "$@" +foreman start -f Procfile.dev "$@" diff --git a/bin/setup b/bin/setup index 81be011e8..91bcc3e43 100755 --- a/bin/setup +++ b/bin/setup @@ -14,6 +14,7 @@ FileUtils.chdir APP_ROOT do puts "== Installing dependencies ==" system("bundle check") || system!("bundle install") + system! "npm install" # puts "\n== Copying sample files ==" # unless File.exist?("config/database.yml") diff --git a/bin/vite b/bin/vite new file mode 100755 index 000000000..9664d0d98 --- /dev/null +++ b/bin/vite @@ -0,0 +1,16 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'vite' 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("vite_ruby", "vite") diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index d51d71397..94221e688 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -11,7 +11,16 @@ # policy.img_src :self, :https, :data # policy.object_src :none # policy.script_src :self, :https + # Allow @vite/client to hot reload javascript changes in development +# policy.script_src *policy.script_src, :unsafe_eval, "http://#{ ViteRuby.config.host_with_port }" if Rails.env.development? + + # You may need to enable this in production as well depending on your setup. +# policy.script_src *policy.script_src, :blob if Rails.env.test? + # policy.style_src :self, :https + # Allow @vite/client to hot reload style changes in development +# policy.style_src *policy.style_src, :unsafe_inline if Rails.env.development? + # # Specify URI for violation reports # # policy.report_uri "/csp-violation-report-endpoint" # end diff --git a/config/initializers/inertia_rails.rb b/config/initializers/inertia_rails.rb new file mode 100644 index 000000000..4349da9f7 --- /dev/null +++ b/config/initializers/inertia_rails.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +InertiaRails.configure do |config| + config.version = ViteRuby.digest + config.encrypt_history = true + config.always_include_errors_hash = true + config.use_script_element_for_initial_page = true + config.use_data_inertia_head_attribute = true +end diff --git a/config/routes.rb b/config/routes.rb index 48254e88e..fcd8a1dd5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,11 @@ Rails.application.routes.draw do + + # Redirect to localhost from 127.0.0.1 to use same IP address with Vite server + constraints(host: "127.0.0.1") do + get "(*path)", to: redirect { |params, req| "#{req.protocol}localhost:#{req.port}/#{params[:path]}" } + end + root 'inertia_example#index' + get 'inertia-example', to: 'inertia_example#index' # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. diff --git a/config/vite.json b/config/vite.json new file mode 100644 index 000000000..476dcf667 --- /dev/null +++ b/config/vite.json @@ -0,0 +1,17 @@ +{ + "all": { + "sourceCodeDir": "app/javascript", + "watchAdditionalPaths": [] + }, + "development": { + "autoBuild": true, + "skipProxy": true, + "publicOutputDir": "vite-dev", + "port": 3036 + }, + "test": { + "autoBuild": true, + "publicOutputDir": "vite-test", + "port": 3037 + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..b16b004b2 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2004 @@ +{ + "name": "Fullstack-Developer", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@inertiajs/react": "^3.7.0", + "@inertiajs/vite": "^3.7.0", + "@tailwindcss/forms": "^0.5.11", + "@tailwindcss/typography": "^0.5.20", + "@tailwindcss/vite": "^4.3.3", + "@vitejs/plugin-react": "^6.1.1", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "tailwindcss": "^4.3.3" + }, + "devDependencies": { + "@inertiajs/core": "^3.7.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.7", + "typescript": "^7.0.2", + "vite": "^8.2.2", + "vite-plugin-ruby": "^5.2.3" + } + }, + "node_modules/@inertiajs/core": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-3.7.0.tgz", + "integrity": "sha512-JzysXTPsOpKcnR7ohwpgKE+UWBrKwW7z23DydWHSxjfQ02tJAKmNnZJAxnX7zH3zUSTqbtPge6QC8XhvfjNXmw==", + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "es-toolkit": "^1.33.0", + "laravel-precognition": "^2.0.0" + }, + "peerDependencies": { + "axios": "^1.15.2" + }, + "peerDependenciesMeta": { + "axios": { + "optional": true + } + } + }, + "node_modules/@inertiajs/react": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@inertiajs/react/-/react-3.7.0.tgz", + "integrity": "sha512-rc/TsVT7ihDk+cMeCuU3BjdUfTq6HJwXqGy7oPaysLQCeyfhIaP2QgExo5R1QSCFIwl9un0MeZnzTOG+i38jLw==", + "license": "MIT", + "dependencies": { + "@inertiajs/core": "3.7.0", + "es-toolkit": "^1.33.0", + "laravel-precognition": "^2.0.0" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, + "node_modules/@inertiajs/vite": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@inertiajs/vite/-/vite-3.7.0.tgz", + "integrity": "sha512-eoOqvIEgmsktC/b5NuZjsj3VvCJ7GsbvHFN2IgT/7DXjLVdXGygWeyYHmS57AGSbb6yOyebeBR6cYkk6YyHOmg==", + "license": "MIT", + "dependencies": { + "@inertiajs/core": "3.7.0", + "tinyglobby": "^0.2.15" + }, + "peerDependencies": { + "vite": "^7.0.0 || ^8.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.149.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz", + "integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz", + "integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz", + "integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz", + "integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz", + "integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz", + "integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz", + "integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz", + "integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz", + "integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz", + "integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz", + "integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz", + "integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz", + "integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz", + "integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz", + "integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz", + "integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "license": "MIT" + }, + "node_modules/@tailwindcss/forms": { + "version": "0.5.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.11.tgz", + "integrity": "sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA==", + "license": "MIT", + "dependencies": { + "mini-svg-data-uri": "^1.2.3" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.20", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.20.tgz", + "integrity": "sha512-hwbzQuNUfcPvbegQFatVPl/MY/tcM9KLl963hQ5laJKPh81TEZ1+dNG9PirGvcaDBkp+BCshExAyKVPW91dozw==", + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || >=4.0.0 || insiders" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/laravel-precognition": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/laravel-precognition/-/laravel-precognition-2.0.0.tgz", + "integrity": "sha512-dmA4HGc9m+TsVNsJs9/XQBI8u6j7coilN+qKkBuhuXQzH3HypwS/c5dFQ4UqUGjBbcxIM7zdk91kM/SRZwIvWQ==", + "license": "MIT", + "dependencies": { + "es-toolkit": "^1.32.0" + }, + "peerDependencies": { + "axios": "^1.4.0" + }, + "peerDependenciesMeta": { + "axios": { + "optional": true + } + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mini-svg-data-uri": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", + "license": "MIT", + "bin": { + "mini-svg-data-uri": "cli.js" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/rolldown": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz", + "integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.149.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.8", + "@rolldown/binding-android-arm64": "1.2.8", + "@rolldown/binding-darwin-arm64": "1.2.8", + "@rolldown/binding-darwin-x64": "1.2.8", + "@rolldown/binding-freebsd-x64": "1.2.8", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.8", + "@rolldown/binding-linux-arm64-gnu": "1.2.8", + "@rolldown/binding-linux-arm64-musl": "1.2.8", + "@rolldown/binding-linux-ppc64-gnu": "1.2.8", + "@rolldown/binding-linux-s390x-gnu": "1.2.8", + "@rolldown/binding-linux-x64-gnu": "1.2.8", + "@rolldown/binding-linux-x64-musl": "1.2.8", + "@rolldown/binding-openharmony-arm64": "1.2.8", + "@rolldown/binding-win32-arm64-msvc": "1.2.8", + "@rolldown/binding-win32-x64-msvc": "1.2.8" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-ruby": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/vite-plugin-ruby/-/vite-plugin-ruby-5.2.3.tgz", + "integrity": "sha512-WwUa91eE1A5veI2UiU2WVtTKmmvha8zoCccSi3rT978W43QT+SXzAHYuJ8cirPq58E1ct4okmOCWpsyEbUl6Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "obug": "^2.0", + "tinyglobby": "^0.2.12" + }, + "peerDependencies": { + "vite": ">=5.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 000000000..f81693c8f --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "private": true, + "type": "module", + "devDependencies": { + "@inertiajs/core": "^3.7.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.7", + "typescript": "^7.0.2", + "vite": "^8.2.2", + "vite-plugin-ruby": "^5.2.3" + }, + "scripts": { + "check": "tsc -p tsconfig.app.json && tsc -p tsconfig.node.json" + }, + "dependencies": { + "@inertiajs/react": "^3.7.0", + "@inertiajs/vite": "^3.7.0", + "@tailwindcss/forms": "^0.5.11", + "@tailwindcss/typography": "^0.5.20", + "@tailwindcss/vite": "^4.3.3", + "@vitejs/plugin-react": "^6.1.1", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "tailwindcss": "^4.3.3" + } +} diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb new file mode 100644 index 000000000..ef75d4677 --- /dev/null +++ b/spec/rails_helper.rb @@ -0,0 +1,72 @@ +# This file is copied to spec/ when you run 'rails generate rspec:install' +require 'spec_helper' +ENV['RAILS_ENV'] ||= 'test' +require_relative '../config/environment' +# Prevent database truncation if the environment is production +abort("The Rails environment is running in production mode!") if Rails.env.production? +# Uncomment the line below in case you have `--require rails_helper` in the `.rspec` file +# that will avoid rails generators crashing because migrations haven't been run yet +# return unless Rails.env.test? +require 'rspec/rails' +# Add additional requires below this line. Rails is not loaded until this point! + +# Requires supporting ruby files with custom matchers and macros, etc, in +# spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are +# run as spec files by default. This means that files in spec/support that end +# in _spec.rb will both be required and run as specs, causing the specs to be +# run twice. It is recommended that you do not name files matching this glob to +# end with _spec.rb. You can configure this pattern with the --pattern +# option on the command line or in ~/.rspec, .rspec or `.rspec-local`. +# +# The following line is provided for convenience purposes. It has the downside +# of increasing the boot-up time by auto-requiring all files in the support +# directory. Alternatively, in the individual `*_spec.rb` files, manually +# require only the support files necessary. +# +# Rails.root.glob('spec/support/**/*.rb').sort_by(&:to_s).each { |f| require f } + +# Ensures that the test database schema matches the current schema file. +# If there are pending migrations it will invoke `db:test:prepare` to +# recreate the test database by loading the schema. +# If you are not using ActiveRecord, you can remove these lines. +begin + ActiveRecord::Migration.maintain_test_schema! +rescue ActiveRecord::PendingMigrationError => e + abort e.to_s.strip +end +RSpec.configure do |config| + # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures + config.fixture_paths = [ + Rails.root.join('spec/fixtures') + ] + + # If you're not using ActiveRecord, or you'd prefer not to run each of your + # examples within a transaction, remove the following line or assign false + # instead of true. + config.use_transactional_fixtures = true + + # You can uncomment this line to turn off ActiveRecord support entirely. + # config.use_active_record = false + + # RSpec Rails uses metadata to mix in different behaviours to your tests, + # for example enabling you to call `get` and `post` in request specs. e.g.: + # + # RSpec.describe UsersController, type: :request do + # # ... + # end + # + # The different available types are documented in the features, such as in + # https://rspec.info/features/8-0/rspec-rails + # + # You can also infer these behaviours automatically by location, e.g. + # /spec/models would pull in the same behaviour as `type: :model` but this + # behaviour is considered legacy and will be removed in a future version. + # + # To enable this behaviour uncomment the line below. + # config.infer_spec_type_from_file_location! + + # Filter lines from Rails gems in backtraces. + config.filter_rails_from_backtrace! + # arbitrary gems may also be filtered via: + # config.filter_gems_from_backtrace("gem name") +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 000000000..327b58ea1 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,94 @@ +# This file was generated by the `rails generate rspec:install` command. Conventionally, all +# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. +# The generated `.rspec` file contains `--require spec_helper` which will cause +# this file to always be loaded, without a need to explicitly require it in any +# files. +# +# Given that it is always loaded, you are encouraged to keep this file as +# light-weight as possible. Requiring heavyweight dependencies from this file +# will add to the boot time of your test suite on EVERY test run, even for an +# individual file that may not need all of that loaded. Instead, consider making +# a separate helper file that requires the additional dependencies and performs +# the additional setup, and require it from the spec files that actually need +# it. +# +# See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration +RSpec.configure do |config| + # rspec-expectations config goes here. You can use an alternate + # assertion/expectation library such as wrong or the stdlib/minitest + # assertions if you prefer. + config.expect_with :rspec do |expectations| + # This option will default to `true` in RSpec 4. It makes the `description` + # and `failure_message` of custom matchers include text for helper methods + # defined using `chain`, e.g.: + # be_bigger_than(2).and_smaller_than(4).description + # # => "be bigger than 2 and smaller than 4" + # ...rather than: + # # => "be bigger than 2" + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + # rspec-mocks config goes here. You can use an alternate test double + # library (such as bogus or mocha) by changing the `mock_with` option here. + config.mock_with :rspec do |mocks| + # Prevents you from mocking or stubbing a method that does not exist on + # a real object. This is generally recommended, and will default to + # `true` in RSpec 4. + mocks.verify_partial_doubles = true + end + + # This option will default to `:apply_to_host_groups` in RSpec 4 (and will + # have no way to turn it off -- the option exists only for backwards + # compatibility in RSpec 3). It causes shared context metadata to be + # inherited by the metadata hash of host groups and examples, rather than + # triggering implicit auto-inclusion in groups with matching metadata. + config.shared_context_metadata_behavior = :apply_to_host_groups + +# The settings below are suggested to provide a good initial experience +# with RSpec, but feel free to customize to your heart's content. +=begin + # 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 +end diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 000000000..efa8eb231 --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + + /* Aliases */ + "paths": { + "@/*": ["./app/javascript/*"], + "~/*": ["./app/javascript/*"] + } + }, + "include": ["app/javascript"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..ea9d0cd82 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,11 @@ +{ + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.node.json" + } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 000000000..3afdd6e38 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true, + "noEmit": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 000000000..756a2b2de --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,14 @@ +import react from '@vitejs/plugin-react' +import inertia from '@inertiajs/vite' +import tailwindcss from '@tailwindcss/vite' +import { defineConfig } from 'vite' +import RubyPlugin from 'vite-plugin-ruby' + +export default defineConfig({ + plugins: [ + tailwindcss(), + RubyPlugin(), + inertia(), + react(), + ], +}) From 027b80e1838e328036e3006388dc4f374aac4c32 Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Wed, 9 Sep 2026 08:45:52 -0300 Subject: [PATCH 05/82] feat: implement user authentication with password reset functionality --- Gemfile | 2 +- Gemfile.lock | 3 ++ app/channels/application_cable/connection.rb | 16 ++++++ app/controllers/application_controller.rb | 1 + app/controllers/concerns/authentication.rb | 52 ++++++++++++++++++++ app/controllers/passwords_controller.rb | 35 +++++++++++++ app/controllers/sessions_controller.rb | 21 ++++++++ app/mailers/passwords_mailer.rb | 6 +++ app/models/current.rb | 4 ++ app/models/session.rb | 3 ++ app/models/user.rb | 6 +++ app/views/passwords/edit.html.erb | 21 ++++++++ app/views/passwords/new.html.erb | 17 +++++++ app/views/passwords_mailer/reset.html.erb | 6 +++ app/views/passwords_mailer/reset.text.erb | 4 ++ app/views/sessions/new.html.erb | 31 ++++++++++++ config/routes.rb | 2 + db/migrate/20260909114545_create_users.rb | 11 +++++ db/migrate/20260909114546_create_sessions.rb | 11 +++++ 19 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 app/channels/application_cable/connection.rb create mode 100644 app/controllers/concerns/authentication.rb create mode 100644 app/controllers/passwords_controller.rb create mode 100644 app/controllers/sessions_controller.rb create mode 100644 app/mailers/passwords_mailer.rb create mode 100644 app/models/current.rb create mode 100644 app/models/session.rb create mode 100644 app/models/user.rb create mode 100644 app/views/passwords/edit.html.erb create mode 100644 app/views/passwords/new.html.erb create mode 100644 app/views/passwords_mailer/reset.html.erb create mode 100644 app/views/passwords_mailer/reset.text.erb create mode 100644 app/views/sessions/new.html.erb create mode 100644 db/migrate/20260909114545_create_users.rb create mode 100644 db/migrate/20260909114546_create_sessions.rb diff --git a/Gemfile b/Gemfile index 04b2ff79d..76da7f16b 100644 --- a/Gemfile +++ b/Gemfile @@ -20,7 +20,7 @@ gem "tailwindcss-rails" gem "inertia_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 169fc07e2..c0c0b69d2 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) @@ -433,6 +434,7 @@ PLATFORMS x86_64-linux-musl DEPENDENCIES + bcrypt (~> 3.1.7) bootsnap brakeman bundler-audit @@ -477,6 +479,7 @@ CHECKSUMS addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + bcrypt (3.1.22) sha256=1f0072e88c2d705d94aff7f2c5cb02eb3f1ec4b8368671e19112527489f29032 bcrypt_pbkdf (1.1.2) sha256=c2414c23ce66869b3eb9f643d6a3374d8322dfb5078125c82792304c10b94cf6 bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb new file mode 100644 index 000000000..4264c745c --- /dev/null +++ b/app/channels/application_cable/connection.rb @@ -0,0 +1,16 @@ +module ApplicationCable + class Connection < ActionCable::Connection::Base + identified_by :current_user + + def connect + set_current_user || reject_unauthorized_connection + end + + private + def set_current_user + if session = Session.find_by(id: cookies.signed[:session_id]) + self.current_user = session.user + end + end + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index c3537563d..5f38f02f3 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,4 +1,5 @@ class ApplicationController < ActionController::Base + include Authentication # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. allow_browser versions: :modern diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb new file mode 100644 index 000000000..3538f485c --- /dev/null +++ b/app/controllers/concerns/authentication.rb @@ -0,0 +1,52 @@ +module Authentication + extend ActiveSupport::Concern + + included do + before_action :require_authentication + helper_method :authenticated? + end + + class_methods do + def allow_unauthenticated_access(**options) + skip_before_action :require_authentication, **options + end + end + + private + def authenticated? + resume_session + end + + def require_authentication + resume_session || request_authentication + end + + def resume_session + Current.session ||= find_session_by_cookie + end + + def find_session_by_cookie + Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id] + end + + def request_authentication + session[:return_to_after_authenticating] = request.url + redirect_to new_session_path + end + + def after_authentication_url + session.delete(:return_to_after_authenticating) || root_url + end + + def start_new_session_for(user) + user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session| + Current.session = session + cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax } + end + end + + def terminate_session + Current.session.destroy + cookies.delete(:session_id) + end +end diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb new file mode 100644 index 000000000..f95ec7874 --- /dev/null +++ b/app/controllers/passwords_controller.rb @@ -0,0 +1,35 @@ +class PasswordsController < ApplicationController + allow_unauthenticated_access + before_action :set_user_by_token, only: %i[ edit update ] + rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." } + + def new + end + + def create + if user = User.find_by(email_address: params[:email_address]) + PasswordsMailer.reset(user).deliver_later + end + + redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)." + end + + def edit + end + + def update + if @user.update(params.permit(:password, :password_confirmation)) + @user.sessions.destroy_all + redirect_to new_session_path, notice: "Password has been reset." + else + redirect_to edit_password_path(params[:token]), alert: "Passwords did not match." + end + end + + private + def set_user_by_token + @user = User.find_by_password_reset_token!(params[:token]) + rescue ActiveSupport::MessageVerifier::InvalidSignature + redirect_to new_password_path, alert: "Password reset link is invalid or has expired." + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb new file mode 100644 index 000000000..cf7fccd12 --- /dev/null +++ b/app/controllers/sessions_controller.rb @@ -0,0 +1,21 @@ +class SessionsController < ApplicationController + allow_unauthenticated_access only: %i[ new create ] + rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." } + + def new + end + + def create + if user = User.authenticate_by(params.permit(:email_address, :password)) + start_new_session_for user + redirect_to after_authentication_url + else + redirect_to new_session_path, alert: "Try another email address or password." + end + end + + def destroy + terminate_session + redirect_to new_session_path, status: :see_other + end +end diff --git a/app/mailers/passwords_mailer.rb b/app/mailers/passwords_mailer.rb new file mode 100644 index 000000000..4f0ac7fd9 --- /dev/null +++ b/app/mailers/passwords_mailer.rb @@ -0,0 +1,6 @@ +class PasswordsMailer < ApplicationMailer + def reset(user) + @user = user + mail subject: "Reset your password", to: user.email_address + end +end diff --git a/app/models/current.rb b/app/models/current.rb new file mode 100644 index 000000000..2bef56dad --- /dev/null +++ b/app/models/current.rb @@ -0,0 +1,4 @@ +class Current < ActiveSupport::CurrentAttributes + attribute :session + delegate :user, to: :session, allow_nil: true +end diff --git a/app/models/session.rb b/app/models/session.rb new file mode 100644 index 000000000..cf376fb28 --- /dev/null +++ b/app/models/session.rb @@ -0,0 +1,3 @@ +class Session < ApplicationRecord + belongs_to :user +end diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 000000000..c88d5b034 --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,6 @@ +class User < ApplicationRecord + has_secure_password + has_many :sessions, dependent: :destroy + + normalizes :email_address, with: ->(e) { e.strip.downcase } +end diff --git a/app/views/passwords/edit.html.erb b/app/views/passwords/edit.html.erb new file mode 100644 index 000000000..65798f808 --- /dev/null +++ b/app/views/passwords/edit.html.erb @@ -0,0 +1,21 @@ +
+ <% if alert = flash[:alert] %> +

<%= alert %>

+ <% end %> + +

Update your password

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

<%= alert %>

+ <% end %> + +

Forgot your password?

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

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

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

<%= alert %>

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

<%= notice %>

+ <% end %> + +

Sign in

+ + <%= form_with url: session_url, class: "contents" do |form| %> +
+ <%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address], class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> +
+ +
+ <%= form.password_field :password, required: true, autocomplete: "current-password", placeholder: "Enter your password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> +
+ +
+
+ <%= form.submit "Sign in", class: "w-full sm:w-auto text-center rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white inline-block font-medium cursor-pointer" %> +
+ +
+ <%= link_to "Forgot password?", new_password_path, class: "text-gray-700 underline hover:no-underline" %> +
+
+ <% end %> +
diff --git a/config/routes.rb b/config/routes.rb index fcd8a1dd5..fb916a719 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,6 @@ Rails.application.routes.draw do + resource :session + resources :passwords, param: :token # Redirect to localhost from 127.0.0.1 to use same IP address with Vite server constraints(host: "127.0.0.1") do diff --git a/db/migrate/20260909114545_create_users.rb b/db/migrate/20260909114545_create_users.rb new file mode 100644 index 000000000..71f2ff188 --- /dev/null +++ b/db/migrate/20260909114545_create_users.rb @@ -0,0 +1,11 @@ +class CreateUsers < ActiveRecord::Migration[8.1] + def change + create_table :users do |t| + t.string :email_address, null: false + t.string :password_digest, null: false + + t.timestamps + end + add_index :users, :email_address, unique: true + end +end diff --git a/db/migrate/20260909114546_create_sessions.rb b/db/migrate/20260909114546_create_sessions.rb new file mode 100644 index 000000000..ec9efdbaa --- /dev/null +++ b/db/migrate/20260909114546_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 From afea0cbd58f40f4eb7b5b1349837e661f87e501e Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Wed, 9 Sep 2026 08:46:17 -0300 Subject: [PATCH 06/82] feat: update database schema with users and sessions tables --- db/schema.rb | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/db/schema.rb b/db/schema.rb index f8be1d34a..cd6706648 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,8 +10,26 @@ # # 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_09_114546) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" + create_table "sessions", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "ip_address" + t.datetime "updated_at", null: false + t.string "user_agent" + t.bigint "user_id", null: false + t.index ["user_id"], name: "index_sessions_on_user_id" + end + + create_table "users", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "email_address", null: false + t.string "password_digest", null: false + t.datetime "updated_at", null: false + t.index ["email_address"], name: "index_users_on_email_address", unique: true + end + + add_foreign_key "sessions", "users" end From 466f3d3f21867c7a8afee4ed17a74e41b0cc94c3 Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Wed, 9 Sep 2026 08:54:15 -0300 Subject: [PATCH 07/82] feat: add new gems for spreadsheet parsing, testing, and environment management --- Gemfile | 20 +++++++++++++++----- Gemfile.lock | 47 ++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/Gemfile b/Gemfile index 76da7f16b..22b35c18a 100644 --- a/Gemfile +++ b/Gemfile @@ -39,6 +39,12 @@ gem "kamal", require: false # Add HTTP asset caching/compression and X-Sendfile acceleration to Puma [https://github.com/basecamp/thruster/] gem "thruster", require: false +# Spreadsheet parsing (xlsx) [https://github.com/roo-rb/roo] +gem "roo", "~> 2.10" +# roo requires csv at runtime but does not declare it; no longer a default gem on Ruby 4.0 +gem "csv" + + # Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] gem "image_processing", "~> 1.2" @@ -54,14 +60,18 @@ group :development, :test do # Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/] gem "rubocop-rails-omakase", require: false - -end -group :test do - gem "rspec-rails" + gem "rspec-rails", "~> 8.0" gem "factory_bot_rails" - gem "capybara" + gem "faker" + gem "dotenv-rails" +end + +group :test do gem "capybara" + gem "capybara-playwright-driver" gem "selenium-webdriver" + gem "shoulda-matchers", "~> 6.0" + gem "simplecov", require: false end group :development do diff --git a/Gemfile.lock b/Gemfile.lock index c0c0b69d2..b7b7bd554 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -100,15 +100,23 @@ GEM 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) + csv (3.3.6) date (3.5.1) debug (1.11.1) irb (~> 1.10) reline (>= 0.3.8) diff-lcs (1.6.2) dotenv (3.2.0) + dotenv-rails (3.2.0) + dotenv (= 3.2.0) + railties (>= 6.1) drb (2.2.3) dry-cli (1.4.1) ed25519 (1.4.0) @@ -121,6 +129,8 @@ GEM 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) @@ -176,6 +186,10 @@ GEM 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) @@ -224,6 +238,10 @@ GEM pg (1.6.3-arm64-darwin) pg (1.6.3-x86_64-linux) pg (1.6.3-x86_64-linux-musl) + 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) @@ -292,6 +310,9 @@ GEM reline (0.7.0) io-console (~> 0.5) rexml (3.4.4) + roo (2.10.1) + nokogiri (~> 1) + rubyzip (>= 1.3.0, < 3.0.0) rspec-core (3.13.6) rspec-support (~> 3.13.0) rspec-expectations (3.13.5) @@ -341,7 +362,7 @@ GEM ruby-vips (2.3.0) ffi (~> 1.12) logger - rubyzip (3.6.0) + rubyzip (2.4.1) securerandom (0.4.1) selenium-webdriver (4.48.0) base64 (~> 0.2) @@ -349,6 +370,9 @@ GEM rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 4.0) websocket (~> 1.0) + shoulda-matchers (6.5.0) + activesupport (>= 5.2.0) + simplecov (1.2.0) solid_cable (4.0.2) actioncable (>= 7.2) activejob (>= 7.2) @@ -439,8 +463,12 @@ DEPENDENCIES brakeman bundler-audit capybara + capybara-playwright-driver + csv debug + dotenv-rails factory_bot_rails + faker image_processing (~> 1.2) importmap-rails inertia_rails @@ -449,9 +477,12 @@ DEPENDENCIES propshaft puma (>= 5.0) rails (~> 8.1.3, >= 8.1.3.1) - rspec-rails + roo (~> 2.10) + rspec-rails (~> 8.0) rubocop-rails-omakase selenium-webdriver + shoulda-matchers (~> 6.0) + simplecov solid_cable solid_cache solid_queue @@ -488,13 +519,16 @@ CHECKSUMS 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 + 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 dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d + dotenv-rails (3.2.0) sha256=657e25554ba622ffc95d8c4f1670286510f47f2edda9f68293c3f661b303beab drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 dry-cli (1.4.1) sha256=b8015bb76c708aa8705a36faf694973e75eeeffca39b89c8e172dc6f66a7d874 ed25519 (1.4.0) sha256=16e97f5198689a154247169f3453ef4cfd3f7a47481fde0ae33206cdfdcac506 @@ -503,6 +537,7 @@ CHECKSUMS 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 @@ -527,6 +562,8 @@ CHECKSUMS 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 @@ -556,6 +593,7 @@ CHECKSUMS pg (1.6.3-arm64-darwin) sha256=7240330b572e6355d7c75a7de535edb5dfcbd6295d9c7777df4d9dddfb8c0e5f pg (1.6.3-x86_64-linux) sha256=5d9e188c8f7a0295d162b7b88a768d8452a899977d44f3274d1946d67920ae8d pg (1.6.3-x86_64-linux-musl) sha256=9c9c90d98c72f78eb04c0f55e9618fe55d1512128e411035fe229ff427864009 + 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 @@ -580,6 +618,7 @@ CHECKSUMS regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 + roo (2.10.1) sha256=cbb43bc955f9c110e74b721c835fb9bd3515b63af88ec709ac87fbf30f8be70e rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47 @@ -592,9 +631,11 @@ 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 + rubyzip (2.4.1) sha256=8577c88edc1fde8935eb91064c5cb1aef9ad5494b940cf19c775ee833e075615 securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 selenium-webdriver (4.48.0) sha256=0c8376ebc8a0a4879343fe6fe6eccdcea76748611cd25de370b33eded2077a94 + shoulda-matchers (6.5.0) sha256=ef6b572b2bed1ac4aba6ab2c5ff345a24b6d055a93a3d1c3bfc86d9d499e3f44 + simplecov (1.2.0) sha256=ea6acd05eece5a41990e2a5171c57d15700d329326c7666c85ee8c6a0dd0977e solid_cable (4.0.2) sha256=084636a67679ad00d23088b33c84047e614bcf41ee559db24b414d83cdc42d03 solid_cache (1.0.10) sha256=bc05a2fb3ac78a6f43cbb5946679cf9db67dd30d22939ededc385cb93e120d41 solid_queue (1.7.0) sha256=6566b70b801d1c317c81bba7bcdd5677c019afac584a30374b4164002ca356d3 From 1f7656523f046e0a1e67c4cfcd1b3729d4d05142 Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Wed, 9 Sep 2026 08:55:35 -0300 Subject: [PATCH 08/82] feat: configure SimpleCov for test coverage and ignore coverage reports --- .gitignore | 2 ++ spec/rails_helper.rb | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/.gitignore b/.gitignore index 617fd797b..b7c4599a9 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,5 @@ node_modules # https://vitejs.dev/guide/env-and-mode.html#env-files *.local +# Ignore SimpleCov coverage reports. +/coverage/ diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index ef75d4677..3d37e5bc5 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -1,4 +1,16 @@ # This file is copied to spec/ when you run 'rails generate rspec:install' + +# SimpleCov must start before any application code is loaded (i.e. before +# config/environment below), otherwise files autoloaded at boot report 0% hit. +require "simplecov" +SimpleCov.start "rails" do + enable_coverage :branch + minimum_coverage line: 90, branch: 80 + add_filter %w[/spec/ /config/ /db/ /app/channels/application_cable/] + # Give each parallel_tests worker its own result set so they merge instead of clobbering. + command_name "rspec_#{ENV['TEST_ENV_NUMBER']}" if ENV["TEST_ENV_NUMBER"] +end + require 'spec_helper' ENV['RAILS_ENV'] ||= 'test' require_relative '../config/environment' @@ -9,6 +21,7 @@ # return unless Rails.env.test? require 'rspec/rails' # Add additional requires below this line. Rails is not loaded until this point! +require 'shoulda/matchers' # Requires supporting ruby files with custom matchers and macros, etc, in # spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are @@ -40,6 +53,9 @@ Rails.root.join('spec/fixtures') ] + # Use `create(...)` / `build(...)` directly instead of `FactoryBot.create(...)`. + config.include FactoryBot::Syntax::Methods + # 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. @@ -70,3 +86,10 @@ # arbitrary gems may also be filtered via: # config.filter_gems_from_backtrace("gem name") end + +Shoulda::Matchers.configure do |config| + config.integrate do |with| + with.test_framework :rspec + with.library :rails + end +end From e6cb9e66bcc1a8e3c2d030566afc279ef26e575b Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Wed, 9 Sep 2026 08:58:45 -0300 Subject: [PATCH 09/82] feat: update SimpleCov configuration and add Capybara Playwright support --- spec/rails_helper.rb | 5 +++-- spec/support/capybara.rb | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 spec/support/capybara.rb diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 3d37e5bc5..6bbff4e07 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -6,7 +6,8 @@ SimpleCov.start "rails" do enable_coverage :branch minimum_coverage line: 90, branch: 80 - add_filter %w[/spec/ /config/ /db/ /app/channels/application_cable/] + # `add_filter` is deprecated as of SimpleCov 1.2; `skip` takes the same arguments. + skip %w[/spec/ /config/ /db/ /app/channels/application_cable/] # Give each parallel_tests worker its own result set so they merge instead of clobbering. command_name "rspec_#{ENV['TEST_ENV_NUMBER']}" if ENV["TEST_ENV_NUMBER"] end @@ -36,7 +37,7 @@ # 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 } +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 diff --git a/spec/support/capybara.rb b/spec/support/capybara.rb new file mode 100644 index 000000000..588e3e902 --- /dev/null +++ b/spec/support/capybara.rb @@ -0,0 +1,14 @@ +require "capybara/playwright" + +Capybara.register_driver(:playwright) do |app| + Capybara::Playwright::Driver.new(app, browser_type: :chromium, headless: true) +end + +Capybara.default_driver = :rack_test +Capybara.javascript_driver = :playwright +Capybara.server = :puma, { Silent: true } + +RSpec.configure do |config| + config.before(:each, type: :system) { driven_by :rack_test } + config.before(:each, type: :system, js: true) { driven_by :playwright } +end From a44548c15e1dec094df658528d01ab854a6bf900 Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Wed, 9 Sep 2026 08:59:49 -0300 Subject: [PATCH 10/82] Rename database config to sample template This commit renames config/database.yml to config/database.yml.sample to keep environment-specific database settings out of version control. The sample file can still be copied and customized locally without committing sensitive or machine-specific values. --- config/{database.yml => database.yml.sample} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename config/{database.yml => database.yml.sample} (100%) diff --git a/config/database.yml b/config/database.yml.sample similarity index 100% rename from config/database.yml rename to config/database.yml.sample From d62908c6e38919e7a60af6f64caec1311a9c5e14 Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Wed, 9 Sep 2026 09:04:09 -0300 Subject: [PATCH 11/82] feat: update cable configuration, add active record encryption, and create database.yml for development and production --- config/cable.yml | 6 ++- config/credentials.yml.enc | 2 +- config/database.yml | 74 ++++++++++++++++++++++++++++++ config/environments/development.rb | 3 ++ 4 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 config/database.yml diff --git a/config/cable.yml b/config/cable.yml index b9adc5aa3..e1f7aff0c 100644 --- a/config/cable.yml +++ b/config/cable.yml @@ -3,7 +3,11 @@ # 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 + adapter: solid_cable + connects_to: + database: { writing: cable } + polling_interval: 0.1.seconds + message_retention: 1.day test: adapter: test diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc index 55f6e6f03..83e411b70 100644 --- a/config/credentials.yml.enc +++ b/config/credentials.yml.enc @@ -1 +1 @@ -GdTutywriVqicIzZ90Zoz5K/uR+CsKWKSymQDh8QNsplJC0BosziyOSTtWRX0ANvznPL4GPVuDHPSQKn/Ev2eL2un+lH2HAIcw+9w//DvlVrPqJUO9eAFPgkSkHsoAJ9Lg+EtX9QvEKYByf+X5gtLA1/FfuSpiQYiyBusT98/H4VIbtVECkulkpMfy0MIc4vL/cnPyN413EaRNOnuVvKkgHEOOm719UAUD0Vf41FF7K/Hppw5tOPuDD/3UET4Z3f41nYloxtXWS07eTsBQoBLKbY/Rt3YDzKqMTje8i3n3BzJFfjBPvkcsXtb8hE68ixWAKnefXB+mGCvj8NJVu0/YQfE+ia5LmSVHrtfFVGwyLGdieB3r7zGo0K12MOE8wJa4Guv6C3jq3/sNS2bQ5sNcNJuAuAhXrN/rLKdMjj6ujMkvXyIEOZuOmJNuvrB4EAfvN431QnnUsiHNJYAsXXlVTb8TPIPi7vet+yYk4uO2sc4DHs1Cz+2XSC--6Za1LfeYwQcSQGT8--FG3erv1ih6j3FQUHTmokBg== \ No newline at end of file +2GQlHdV8N7vgA6G33moC2qk88Zo5y1px+NK+tcxsLfGdCcclgoAHRFOd9a9GmAVoQVERcA9+2oxxI3SB4JivqDU2mQObnuio0i2BcHO3xQwfk+f5CcamRgEZDRgHETsGSKfwVMAQfNZnuiGiqK0+yoGhv+FIf5cdS0CXGjxpEpfhL4M3nNCmNP9Mh8ylHRuqQX0GMuHUoeGlmZy1Y6QzFzSkBrnh32Meqm7L3n5vrALR2R2SiTiIxYP5AhecRifA5Ol8SytzzyqPZAswv3K5/2ECvc3sY7+11UpKQwFomE0iwftU/FOPtnmwhAV1shZmqfJTfhBAdsSYLe7fkpsDsDiA+llW4wjZz9ShTNlUGEl62S3CNpXiN4LhrXbw5i5JxdOK+kzrhQ9ae81Q/8feHvR92h5IdBbZGunAZTTZP1tel5y+ve0JdKCJ/ZpofjtjKlFIUN7qRs+pnxNop+J9bcU1AtWLROg/xEfnchqMWrEDTk2XeR9sVF13CjJXQbwEU/oQhK5DA5iJ5GcQkC0N4r98VTzi5qklEPCwVtz2wXjLwC0NJG1rROCJk69nxtac17rNzdvbwliHeNFpiDe0j/GfYpFPUhZApERGhS0CwkmqqxRRHGIt1Qys1bnBSb2NPp75Sg3kgN//lC76SpkH6WzsO+PYKpBv3/d7/uNy3ggS4E37BJ3L+/n3NnMhIz6CnCxk3BHAdfLVz/5fUVyttVpPuJX6c2I5xrm72GdbCQtJ--rdIvnudLeyvQp7fM--d+BOnrDeotZbNPExAzplyQ== \ No newline at end of file diff --git a/config/database.yml b/config/database.yml new file mode 100644 index 000000000..f4e41f541 --- /dev/null +++ b/config/database.yml @@ -0,0 +1,74 @@ +default: &default + adapter: postgresql + encoding: unicode + username: postgres + host: localhost + max_connections: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + + +development: + primary: + <<: *default + database: umanni_users_development + queue: + <<: *default + database: umanni_users_development_queue + migrations_paths: db/queue_migrate + cache: + <<: *default + database: umanni_users_development_cache + migrations_paths: db/cache_migrate + cable: + <<: *default + database: umanni_users_development_cable + migrations_paths: db/cable_migrate + + +test: + <<: *default + database: fullstack_developer_test + +# As with config/credentials.yml, you never want to store sensitive information, +# like your database password, in your source code. If your source code is +# ever seen by anyone, they now have access to your database. +# +# Instead, provide the password or a full connection URL as an environment +# variable when you boot the app. For example: +# +# DATABASE_URL="postgres://myuser:mypass@localhost/somedatabase" +# +# If the connection URL is provided in the special DATABASE_URL environment +# variable, Rails will automatically merge its configuration values on top of +# the values provided in this file. Alternatively, you can specify a connection +# URL environment variable explicitly: +# +# production: +# url: <%= ENV["MY_APP_DATABASE_URL"] %> +# +# Connection URLs for non-primary databases can also be configured using +# environment variables. The variable name is formed by concatenating the +# connection name with `_DATABASE_URL`. For example: +# +# CACHE_DATABASE_URL="postgres://cacheuser:cachepass@localhost/cachedatabase" +# +# Read https://guides.rubyonrails.org/configuring.html#configuring-a-database +# for a full overview on how database connection configuration can be specified. +# +production: + primary: &primary_production + <<: *default + database: fullstack_developer_production + username: fullstack_developer + password: <%= ENV["FULLSTACK_DEVELOPER_DATABASE_PASSWORD"] %> + cache: + <<: *primary_production + database: fullstack_developer_production_cache + migrations_paths: db/cache_migrate + queue: + <<: *primary_production + database: fullstack_developer_production_queue + migrations_paths: db/queue_migrate + cable: + <<: *primary_production + database: fullstack_developer_production_cable + migrations_paths: db/cable_migrate diff --git a/config/environments/development.rb b/config/environments/development.rb index 75243c3d0..3e185031a 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -75,4 +75,7 @@ # Apply autocorrection by RuboCop to files generated by `bin/rails generate`. # config.generators.apply_rubocop_autocorrect_after_generate! + + config.active_job.queue_adapter = :solid_queue + config.solid_queue.connects_to = { database: { writing: :queue } } end From 962b013093ecf3e3feb62752ed4149bac47567a5 Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Wed, 9 Sep 2026 09:05:36 -0300 Subject: [PATCH 12/82] feat: update database schema and add profile fields to users --- db/cable_schema.rb | 23 +- db/cache_schema.rb | 25 ++- ...60909120500_add_profile_fields_to_users.rb | 8 + db/queue_schema.rb | 197 ++++++++++-------- db/schema.rb | 6 +- spec/factories/users.rb | 6 + spec/models/user_spec.rb | 5 + 7 files changed, 169 insertions(+), 101 deletions(-) create mode 100644 db/migrate/20260909120500_add_profile_fields_to_users.rb create mode 100644 spec/factories/users.rb create mode 100644 spec/models/user_spec.rb diff --git a/db/cable_schema.rb b/db/cable_schema.rb index 23666604a..593da414c 100644 --- a/db/cable_schema.rb +++ b/db/cable_schema.rb @@ -1,9 +1,24 @@ -ActiveRecord::Schema[7.1].define(version: 1) do +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.1].define(version: 1) do + # These are extensions that must be enabled in order to support this database + enable_extension "pg_catalog.plpgsql" + create_table "solid_cable_messages", force: :cascade do |t| - t.binary "channel", limit: 1024, null: false - t.binary "payload", limit: 536870912, null: false + t.binary "channel", null: false + t.bigint "channel_hash", null: false t.datetime "created_at", null: false - t.integer "channel_hash", limit: 8, null: false + t.binary "payload", null: false t.index ["channel"], name: "index_solid_cable_messages_on_channel" t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash" t.index ["created_at"], name: "index_solid_cable_messages_on_created_at" diff --git a/db/cache_schema.rb b/db/cache_schema.rb index 81a410d18..96be0dcaf 100644 --- a/db/cache_schema.rb +++ b/db/cache_schema.rb @@ -1,10 +1,25 @@ -ActiveRecord::Schema[7.2].define(version: 1) do +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.1].define(version: 1) do + # These are extensions that must be enabled in order to support this database + enable_extension "pg_catalog.plpgsql" + create_table "solid_cache_entries", force: :cascade do |t| - t.binary "key", limit: 1024, null: false - t.binary "value", limit: 536870912, null: false + t.integer "byte_size", null: false t.datetime "created_at", null: false - t.integer "key_hash", limit: 8, null: false - t.integer "byte_size", limit: 4, null: false + t.binary "key", null: false + t.bigint "key_hash", null: false + t.binary "value", null: false t.index ["byte_size"], name: "index_solid_cache_entries_on_byte_size" t.index ["key_hash", "byte_size"], name: "index_solid_cache_entries_on_key_hash_and_byte_size" t.index ["key_hash"], name: "index_solid_cache_entries_on_key_hash", unique: true diff --git a/db/migrate/20260909120500_add_profile_fields_to_users.rb b/db/migrate/20260909120500_add_profile_fields_to_users.rb new file mode 100644 index 000000000..272421ac2 --- /dev/null +++ b/db/migrate/20260909120500_add_profile_fields_to_users.rb @@ -0,0 +1,8 @@ +class AddProfileFieldsToUsers < ActiveRecord::Migration[8.1] + def change + add_column :users, :full_name, :string, null: false, default: "" + add_column :users, :role, :integer, null: false, default: 0 + add_column :users, :avatar_url, :string + add_index :users, :role + end +end diff --git a/db/queue_schema.rb b/db/queue_schema.rb index f9a71dabb..00929c207 100644 --- a/db/queue_schema.rb +++ b/db/queue_schema.rb @@ -1,152 +1,167 @@ -ActiveRecord::Schema[7.1].define(version: 1) do - create_table "solid_queue_blocked_executions", force: :cascade do |t| +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.1].define(version: 1) do + # These are extensions that must be enabled in order to support this database + enable_extension "pg_catalog.plpgsql" + + create_table "solid_queue_batch_executions", force: :cascade do |t| + t.bigint "batch_id", null: false + t.datetime "created_at", null: false t.bigint "job_id", null: false - t.string "queue_name", null: false - t.integer "priority", default: 0, null: false + t.index ["batch_id"], name: "index_solid_queue_batch_executions_on_batch_id" + t.index ["job_id"], name: "index_solid_queue_batch_executions_on_job_id", unique: true + end + + create_table "solid_queue_batches", force: :cascade do |t| + t.string "active_job_batch_id" + t.integer "completed_jobs", default: 0, null: false + t.datetime "created_at", null: false + t.string "description" + t.datetime "enqueued_at" + t.datetime "failed_at" + t.integer "failed_jobs", default: 0, null: false + t.datetime "finished_at" + t.text "metadata" + t.text "on_failure" + t.text "on_finish" + t.text "on_success" + t.integer "total_jobs", default: 0, null: false + t.datetime "updated_at", null: false + t.index ["active_job_batch_id"], name: "index_solid_queue_batches_on_active_job_batch_id", unique: true + t.index ["finished_at"], name: "index_solid_queue_batches_on_finished_at" + end + + create_table "solid_queue_blocked_executions", force: :cascade do |t| t.string "concurrency_key", null: false - t.datetime "expires_at", null: false t.datetime "created_at", null: false - t.index [ "concurrency_key", "priority", "job_id" ], name: "index_solid_queue_blocked_executions_for_release" - t.index [ "expires_at", "concurrency_key" ], name: "index_solid_queue_blocked_executions_for_maintenance" - t.index [ "job_id" ], name: "index_solid_queue_blocked_executions_on_job_id", unique: true + t.datetime "expires_at", null: false + t.bigint "job_id", null: false + t.integer "priority", default: 0, null: false + t.string "queue_name", null: false + t.index ["concurrency_key", "priority", "job_id"], name: "index_solid_queue_blocked_executions_for_release" + t.index ["expires_at", "concurrency_key"], name: "index_solid_queue_blocked_executions_for_maintenance" + t.index ["job_id"], name: "index_solid_queue_blocked_executions_on_job_id", unique: true end create_table "solid_queue_claimed_executions", force: :cascade do |t| + t.datetime "created_at", null: false t.bigint "job_id", null: false t.bigint "process_id" - t.datetime "created_at", null: false - t.index [ "job_id" ], name: "index_solid_queue_claimed_executions_on_job_id", unique: true - t.index [ "process_id", "job_id" ], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id" + t.index ["job_id"], name: "index_solid_queue_claimed_executions_on_job_id", unique: true + t.index ["process_id", "job_id"], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id" end create_table "solid_queue_failed_executions", force: :cascade do |t| - t.bigint "job_id", null: false - t.text "error" t.datetime "created_at", null: false - t.index [ "job_id" ], name: "index_solid_queue_failed_executions_on_job_id", unique: true + t.text "error" + t.bigint "job_id", null: false + t.index ["job_id"], name: "index_solid_queue_failed_executions_on_job_id", unique: true end create_table "solid_queue_jobs", force: :cascade do |t| - t.string "queue_name", null: false - t.string "class_name", null: false - t.text "arguments" - t.integer "priority", default: 0, null: false t.string "active_job_id" - t.datetime "scheduled_at" - t.datetime "finished_at" + t.text "arguments" + t.bigint "batch_id" + t.string "class_name", null: false t.string "concurrency_key" t.datetime "created_at", null: false + t.datetime "finished_at" + t.integer "priority", default: 0, null: false + t.string "queue_name", null: false + t.datetime "scheduled_at" t.datetime "updated_at", null: false - t.bigint "batch_id" - t.index [ "active_job_id" ], name: "index_solid_queue_jobs_on_active_job_id" - t.index [ "batch_id" ], name: "index_solid_queue_jobs_on_batch_id" - t.index [ "class_name" ], name: "index_solid_queue_jobs_on_class_name" - t.index [ "finished_at" ], name: "index_solid_queue_jobs_on_finished_at" - t.index [ "queue_name", "finished_at" ], name: "index_solid_queue_jobs_for_filtering" - t.index [ "scheduled_at", "finished_at" ], name: "index_solid_queue_jobs_for_alerting" + t.index ["active_job_id"], name: "index_solid_queue_jobs_on_active_job_id" + t.index ["batch_id"], name: "index_solid_queue_jobs_on_batch_id" + t.index ["class_name"], name: "index_solid_queue_jobs_on_class_name" + t.index ["finished_at"], name: "index_solid_queue_jobs_on_finished_at" + t.index ["queue_name", "finished_at"], name: "index_solid_queue_jobs_for_filtering" + t.index ["scheduled_at", "finished_at"], name: "index_solid_queue_jobs_for_alerting" end create_table "solid_queue_pauses", force: :cascade do |t| - t.string "queue_name", null: false t.datetime "created_at", null: false - t.index [ "queue_name" ], name: "index_solid_queue_pauses_on_queue_name", unique: true + t.string "queue_name", null: false + t.index ["queue_name"], name: "index_solid_queue_pauses_on_queue_name", unique: true end create_table "solid_queue_processes", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "hostname" t.string "kind", null: false t.datetime "last_heartbeat_at", null: false - t.bigint "supervisor_id" - t.integer "pid", null: false - t.string "hostname" t.text "metadata" - t.datetime "created_at", null: false t.string "name", null: false - t.index [ "last_heartbeat_at" ], name: "index_solid_queue_processes_on_last_heartbeat_at" - t.index [ "name", "supervisor_id" ], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true - t.index [ "supervisor_id" ], name: "index_solid_queue_processes_on_supervisor_id" + t.integer "pid", null: false + t.bigint "supervisor_id" + t.index ["last_heartbeat_at"], name: "index_solid_queue_processes_on_last_heartbeat_at" + t.index ["name", "supervisor_id"], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true + t.index ["supervisor_id"], name: "index_solid_queue_processes_on_supervisor_id" end create_table "solid_queue_ready_executions", force: :cascade do |t| + t.datetime "created_at", null: false t.bigint "job_id", null: false - t.string "queue_name", null: false t.integer "priority", default: 0, null: false - t.datetime "created_at", null: false - t.index [ "job_id" ], name: "index_solid_queue_ready_executions_on_job_id", unique: true - t.index [ "priority", "job_id" ], name: "index_solid_queue_poll_all" - t.index [ "queue_name", "priority", "job_id" ], name: "index_solid_queue_poll_by_queue" + t.string "queue_name", null: false + t.index ["job_id"], name: "index_solid_queue_ready_executions_on_job_id", unique: true + t.index ["priority", "job_id"], name: "index_solid_queue_poll_all" + t.index ["queue_name", "priority", "job_id"], name: "index_solid_queue_poll_by_queue" end create_table "solid_queue_recurring_executions", force: :cascade do |t| + t.datetime "created_at", null: false t.bigint "job_id", null: false - t.string "task_key", null: false t.datetime "run_at", null: false - t.datetime "created_at", null: false - t.index [ "job_id" ], name: "index_solid_queue_recurring_executions_on_job_id", unique: true - t.index [ "task_key", "run_at" ], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true + t.string "task_key", null: false + t.index ["job_id"], name: "index_solid_queue_recurring_executions_on_job_id", unique: true + t.index ["task_key", "run_at"], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true end create_table "solid_queue_recurring_tasks", force: :cascade do |t| - t.string "key", null: false - t.string "schedule", null: false - t.string "command", limit: 2048 - t.string "class_name" t.text "arguments" - t.string "queue_name" + t.string "class_name" + t.string "command", limit: 2048 + t.datetime "created_at", null: false + t.text "description" + t.string "key", null: false t.integer "priority", default: 0 + t.string "queue_name" + t.string "schedule", null: false t.boolean "static", default: true, null: false - t.text "description" - t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index [ "key" ], name: "index_solid_queue_recurring_tasks_on_key", unique: true - t.index [ "static" ], name: "index_solid_queue_recurring_tasks_on_static" + t.index ["key"], name: "index_solid_queue_recurring_tasks_on_key", unique: true + t.index ["static"], name: "index_solid_queue_recurring_tasks_on_static" end create_table "solid_queue_scheduled_executions", force: :cascade do |t| + t.datetime "created_at", null: false t.bigint "job_id", null: false - t.string "queue_name", null: false t.integer "priority", default: 0, null: false + t.string "queue_name", null: false t.datetime "scheduled_at", null: false - t.datetime "created_at", null: false - t.index [ "job_id" ], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true - t.index [ "scheduled_at", "priority", "job_id" ], name: "index_solid_queue_dispatch_all" + t.index ["job_id"], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true + t.index ["scheduled_at", "priority", "job_id"], name: "index_solid_queue_dispatch_all" end create_table "solid_queue_semaphores", force: :cascade do |t| - t.string "key", null: false - t.integer "value", default: 1, null: false - t.datetime "expires_at", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index [ "expires_at" ], name: "index_solid_queue_semaphores_on_expires_at" - t.index [ "key", "value" ], name: "index_solid_queue_semaphores_on_key_and_value" - t.index [ "key" ], name: "index_solid_queue_semaphores_on_key", unique: true - end - - create_table "solid_queue_batches", force: :cascade do |t| - t.string "active_job_batch_id" - t.string "description" - t.text "on_finish" - t.text "on_success" - t.text "on_failure" - t.text "metadata" - t.integer "total_jobs", default: 0, null: false - t.integer "completed_jobs", default: 0, null: false - t.integer "failed_jobs", default: 0, null: false - t.datetime "enqueued_at" - t.datetime "finished_at" - t.datetime "failed_at" t.datetime "created_at", null: false + t.datetime "expires_at", null: false + t.string "key", null: false t.datetime "updated_at", null: false - t.index [ "active_job_batch_id" ], name: "index_solid_queue_batches_on_active_job_batch_id", unique: true - t.index [ "finished_at" ], name: "index_solid_queue_batches_on_finished_at" - end - - create_table "solid_queue_batch_executions", force: :cascade do |t| - t.bigint "job_id", null: false - t.bigint "batch_id", null: false - t.datetime "created_at", null: false - t.index [ "job_id" ], name: "index_solid_queue_batch_executions_on_job_id", unique: true - t.index [ "batch_id" ], name: "index_solid_queue_batch_executions_on_batch_id" + t.integer "value", default: 1, null: false + t.index ["expires_at"], name: "index_solid_queue_semaphores_on_expires_at" + t.index ["key", "value"], name: "index_solid_queue_semaphores_on_key_and_value" + t.index ["key"], name: "index_solid_queue_semaphores_on_key", unique: true end add_foreign_key "solid_queue_batch_executions", "solid_queue_batches", column: "batch_id", on_delete: :cascade diff --git a/db/schema.rb b/db/schema.rb index cd6706648..a650b57a2 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_09_114546) do +ActiveRecord::Schema[8.1].define(version: 2026_09_09_120500) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -24,11 +24,15 @@ end create_table "users", force: :cascade do |t| + t.string "avatar_url" t.datetime "created_at", null: false t.string "email_address", null: false + t.string "full_name", default: "", null: false t.string "password_digest", null: false + t.integer "role", default: 0, null: false t.datetime "updated_at", null: false t.index ["email_address"], name: "index_users_on_email_address", unique: true + t.index ["role"], name: "index_users_on_role" end add_foreign_key "sessions", "users" diff --git a/spec/factories/users.rb b/spec/factories/users.rb new file mode 100644 index 000000000..054b21791 --- /dev/null +++ b/spec/factories/users.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :user do + email_address { "user@example.com" } + password { "password" } + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb new file mode 100644 index 000000000..47a31bb43 --- /dev/null +++ b/spec/models/user_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe User, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end From 8655266b691ef097ffadd3f2591671a6a93318c8 Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Wed, 9 Sep 2026 09:10:30 -0300 Subject: [PATCH 13/82] feat: add Active Storage validations and enhance User model with avatar support --- Gemfile | 2 + Gemfile.lock | 8 +++ app/models/user.rb | 40 ++++++++++++- ...te_active_storage_tables.active_storage.rb | 57 +++++++++++++++++++ db/schema.rb | 32 ++++++++++- 5 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 db/migrate/20260909120845_create_active_storage_tables.active_storage.rb diff --git a/Gemfile b/Gemfile index 22b35c18a..bccec98cf 100644 --- a/Gemfile +++ b/Gemfile @@ -47,6 +47,8 @@ gem "csv" # Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] gem "image_processing", "~> 1.2" +# Adds `content_type` / `size` validators for Active Storage attachments (not in Rails core) +gem "active_storage_validations" group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem diff --git a/Gemfile.lock b/Gemfile.lock index b7b7bd554..9a9715441 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -47,6 +47,12 @@ GEM erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) + active_storage_validations (4.1.1) + activejob (>= 7.0.1) + activemodel (>= 7.0.1) + activestorage (>= 7.0.1) + activesupport (>= 7.0.1) + marcel (>= 1.0.3) activejob (8.1.3.1) activesupport (= 8.1.3.1) globalid (>= 0.3.6) @@ -458,6 +464,7 @@ PLATFORMS x86_64-linux-musl DEPENDENCIES + active_storage_validations bcrypt (~> 3.1.7) bootsnap brakeman @@ -502,6 +509,7 @@ CHECKSUMS actionpack (8.1.3.1) sha256=974cb7154548e81f470b1b0f247b99cb38e87825899dca58610596e2817723d0 actiontext (8.1.3.1) sha256=5da729d833d1a29cddb1eee938878e55e503d2613e00e735f5daf58c2ba98af2 actionview (8.1.3.1) sha256=2da68b8414c47b43bfbed1ce69c5afe1c04f78c267aacb5660a4cab5ca12cfb6 + active_storage_validations (4.1.1) sha256=0975eb88921bf6095b4b9ea9f9cd1187e873f8952c2b77132bb362bd0f3d6a0f activejob (8.1.3.1) sha256=1c8dd275df930df40deecffec63d913a550a33fd94bd298f69721dd96939954a activemodel (8.1.3.1) sha256=99cc02ce2faec371d14440949d85787ebd23a907c9baef0a9d4bcd4d21888f88 activerecord (8.1.3.1) sha256=0a2fb6c28f4938f6b013a3a549bec0a7e37d535f3dc8990e804bcc3258c0403b diff --git a/app/models/user.rb b/app/models/user.rb index c88d5b034..92678dc9a 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,6 +1,44 @@ class User < ApplicationRecord has_secure_password has_many :sessions, dependent: :destroy + has_one_attached :avatar_image - normalizes :email_address, with: ->(e) { e.strip.downcase } + enum :role, { member: 0, admin: 1 }, default: :member, validate: true + + encrypts :email_address, deterministic: true + + normalizes :email_address, with: ->(value) { value.to_s.strip.downcase } + normalizes :full_name, with: ->(value) { value.to_s.squish } + + validates :full_name, presence: true, length: { in: 2..120 } + # `case_sensitive: false` would wrap the column in SQL LOWER(), which here applies + # to the *ciphertext* -- bypassing the unique index and comparing base64 case-blind. + # `normalizes` already downcases, so an exact match is both correct and index-backed. + validates :email_address, + presence: true, + uniqueness: true, + format: { with: URI::MailTo::EMAIL_REGEXP } + validates :avatar_url, + format: { with: %r{\Ahttps://\S+\z} }, + allow_blank: true + validates :avatar_image, + content_type: %w[image/png image/jpeg image/webp], + size: { less_than: 5.megabytes }, + if: -> { avatar_image.attached? } + + before_destroy :ensure_not_last_admin, prepend: true + + def avatar_source + return avatar_image if avatar_image.attached? + avatar_url.presence + end + + private + + def ensure_not_last_admin + return unless admin? && User.admin.count <= 1 + + errors.add(:base, "Cannot remove the last administrator") + throw :abort + end end diff --git a/db/migrate/20260909120845_create_active_storage_tables.active_storage.rb b/db/migrate/20260909120845_create_active_storage_tables.active_storage.rb new file mode 100644 index 000000000..6bd8bd082 --- /dev/null +++ b/db/migrate/20260909120845_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 a650b57a2..3f916be19 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,10 +10,38 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_09_09_120500) do +ActiveRecord::Schema[8.1].define(version: 2026_09_09_120845) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" + create_table "active_storage_attachments", force: :cascade do |t| + t.bigint "blob_id", null: false + t.datetime "created_at", null: false + t.string "name", null: false + t.bigint "record_id", null: false + t.string "record_type", null: false + t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" + t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true + end + + create_table "active_storage_blobs", force: :cascade do |t| + t.bigint "byte_size", null: false + t.string "checksum" + t.string "content_type" + t.datetime "created_at", null: false + t.string "filename", null: false + t.string "key", null: false + t.text "metadata" + t.string "service_name", null: false + t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true + end + + create_table "active_storage_variant_records", force: :cascade do |t| + t.bigint "blob_id", null: false + t.string "variation_digest", null: false + t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true + end + create_table "sessions", force: :cascade do |t| t.datetime "created_at", null: false t.string "ip_address" @@ -35,5 +63,7 @@ t.index ["role"], name: "index_users_on_role" 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 8b99e6d49d0b4686f5a23f280dd9eabbf2cdb1f6 Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Wed, 9 Sep 2026 09:24:41 -0300 Subject: [PATCH 14/82] feat: implement user authentication with session management and password recovery features --- Gemfile | 1 + Gemfile.lock | 5 +- app/policies/application_policy.rb | 20 ++ app/policies/user_policy.rb | 22 ++ spec/factories/sessions.rb | 7 + spec/factories/users.rb | 21 +- spec/fixtures/files/avatar.png | Bin 0 -> 71 bytes spec/fixtures/files/document.txt | 1 + spec/mailers/passwords_mailer_spec.rb | 31 +++ spec/models/session_spec.rb | 29 +++ spec/models/user_spec.rb | 279 ++++++++++++++++++++++- spec/policies/application_policy_spec.rb | 57 +++++ spec/policies/user_policy_spec.rb | 126 ++++++++++ spec/requests/passwords_spec.rb | 102 +++++++++ spec/requests/sessions_spec.rb | 98 ++++++++ spec/support/authentication_helpers.rb | 12 + spec/support/capybara.rb | 5 + spec/system/authentication_spec.rb | 46 ++++ 18 files changed, 857 insertions(+), 5 deletions(-) create mode 100644 app/policies/application_policy.rb create mode 100644 app/policies/user_policy.rb create mode 100644 spec/factories/sessions.rb create mode 100644 spec/fixtures/files/avatar.png create mode 100644 spec/fixtures/files/document.txt create mode 100644 spec/mailers/passwords_mailer_spec.rb create mode 100644 spec/models/session_spec.rb create mode 100644 spec/policies/application_policy_spec.rb create mode 100644 spec/policies/user_policy_spec.rb create mode 100644 spec/requests/passwords_spec.rb create mode 100644 spec/requests/sessions_spec.rb create mode 100644 spec/support/authentication_helpers.rb create mode 100644 spec/system/authentication_spec.rb diff --git a/Gemfile b/Gemfile index bccec98cf..185e0a359 100644 --- a/Gemfile +++ b/Gemfile @@ -2,6 +2,7 @@ source "https://rubygems.org" # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" gem "rails", "~> 8.1.3", ">= 8.1.3.1" +gem "json", "~> 2.9" # The modern asset pipeline for Rails [https://github.com/rails/propshaft] gem "propshaft" # Use postgresql as the database for Active Record diff --git a/Gemfile.lock b/Gemfile.lock index 9a9715441..4e4e7fb5e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -166,7 +166,7 @@ GEM prism (>= 1.3.0) rdoc (>= 4.0.0) reline (>= 0.4.2) - json (3.0.2) + json (2.21.2) kamal (2.12.0) activesupport (>= 7.0) base64 (~> 0.2) @@ -479,6 +479,7 @@ DEPENDENCIES image_processing (~> 1.2) importmap-rails inertia_rails + json (~> 2.9) kamal pg (~> 1.1) propshaft @@ -561,7 +562,7 @@ CHECKSUMS inertia_rails (3.22.0) sha256=39c20120de472015d2831fa461f8a09672c68e91c41d3d660e0b1d16b787b7b1 io-console (0.9.2) sha256=efa74f891dd03c0939a931dfc6e74c2813d904763d456ea9762b0525e748db08 irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 - json (3.0.2) sha256=8e6d7e7b11384c21230430cef90b71f14849a34a1f4452796670f7c981bd19df + 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 diff --git a/app/policies/application_policy.rb b/app/policies/application_policy.rb new file mode 100644 index 000000000..2c9af7095 --- /dev/null +++ b/app/policies/application_policy.rb @@ -0,0 +1,20 @@ +class ApplicationPolicy + attr_reader :user, :record + + def initialize(user, record) + @user = user + @record = record + end + + def index? = false + def show? = false + def create? = false + def update? = false + def destroy? = false + + def permitted_attributes = [] + + private + + def owner? = record.is_a?(User) && user == record +end diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb new file mode 100644 index 000000000..7ff9123ec --- /dev/null +++ b/app/policies/user_policy.rb @@ -0,0 +1,22 @@ +class UserPolicy < ApplicationPolicy + BASE_ATTRIBUTES = %i[full_name email_address password password_confirmation + avatar_url avatar_image].freeze + ADMIN_ATTRIBUTES = (BASE_ATTRIBUTES + %i[role]).freeze + + def index? = user.admin? + def show? = user.admin? || owner? + def create? = user.admin? + def update? = user.admin? || owner? + def destroy? = user.admin? || owner? + + # An admin must not be able to demote or delete themselves out of access. + def toggle_role? = user.admin? && !owner? + + def permitted_attributes + user.admin? ? ADMIN_ATTRIBUTES : BASE_ATTRIBUTES + end + + def scope + user.admin? ? User.all : User.where(id: user.id) + end +end diff --git a/spec/factories/sessions.rb b/spec/factories/sessions.rb new file mode 100644 index 000000000..10ed43f99 --- /dev/null +++ b/spec/factories/sessions.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :session do + user + ip_address { "127.0.0.1" } + user_agent { "RSpec" } + end +end diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 054b21791..dd00759db 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -1,6 +1,25 @@ FactoryBot.define do factory :user do - email_address { "user@example.com" } + full_name { "Ada Lovelace" } + sequence(:email_address) { |n| "user#{n}@example.com" } password { "password" } + + trait :admin do + role { :admin } + end + + trait :with_avatar_image do + after(:build) do |user| + user.avatar_image.attach( + io: Rails.root.join("spec/fixtures/files/avatar.png").open, + filename: "avatar.png", + content_type: "image/png" + ) + end + end + + trait :with_avatar_url do + avatar_url { "https://cdn.example.com/avatars/ada.png" } + end end end diff --git a/spec/fixtures/files/avatar.png b/spec/fixtures/files/avatar.png new file mode 100644 index 0000000000000000000000000000000000000000..ff7e0e8ae314593716330b973802d9bb07c800d7 GIT binary patch literal 71 zcmeAS@N?(olHy`uVBq!ia0vp^Od!kwBL7~QRScx~JY5_^D&{2rIM2Wmz{DW(gX!Px S$_H#f83s>RKbLh*2~7YBtr5lm literal 0 HcmV?d00001 diff --git a/spec/fixtures/files/document.txt b/spec/fixtures/files/document.txt new file mode 100644 index 000000000..d36d2bdb9 --- /dev/null +++ b/spec/fixtures/files/document.txt @@ -0,0 +1 @@ +plain text, not an image \ No newline at end of file diff --git a/spec/mailers/passwords_mailer_spec.rb b/spec/mailers/passwords_mailer_spec.rb new file mode 100644 index 000000000..909bbedb7 --- /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, email_address: "ada@example.com") } + let(:mail) { described_class.reset(user) } + + it "addresses the reset to the user" do + expect(mail.to).to eq([ "ada@example.com" ]) + expect(mail.subject).to eq("Reset your password") + expect(mail.from).to eq([ "from@example.com" ]) + end + + it "renders both a html and a text part" do + expect(mail.body.parts.map(&:content_type)).to include( + a_string_starting_with("text/html"), + a_string_starting_with("text/plain") + ) + end + + it "includes a password reset link" do + body = mail.body.encoded + + expect(body).to include("/passwords/") + expect(body).to match(/edit/) + end + + it "delivers" do + expect { described_class.reset(user).deliver_now } + .to change { ActionMailer::Base.deliveries.count }.by(1) + end +end diff --git a/spec/models/session_spec.rb b/spec/models/session_spec.rb new file mode 100644 index 000000000..70814d277 --- /dev/null +++ b/spec/models/session_spec.rb @@ -0,0 +1,29 @@ +require "rails_helper" + +RSpec.describe Session, type: :model do + it "has a valid factory" do + expect(build(:session)).to be_valid + end + + it { is_expected.to belong_to(:user) } + + it "requires a user" do + session = build(:session, user: nil) + + expect(session).to be_invalid + expect(session.errors[:user]).to include(/must exist/) + end + + it "records the originating request metadata" do + session = create(:session, ip_address: "203.0.113.7", user_agent: "Mozilla/5.0") + + expect(session.ip_address).to eq("203.0.113.7") + expect(session.user_agent).to eq("Mozilla/5.0") + end + + it "is removed together with its user" do + session = create(:session) + + expect { session.user.destroy }.to change(described_class, :count).by(-1) + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 47a31bb43..e9c4417a8 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -1,5 +1,280 @@ -require 'rails_helper' +require "rails_helper" RSpec.describe User, type: :model do - pending "add some examples to (or delete) #{__FILE__}" + subject(:user) { build(:user) } + + def raw_email_column(record) + described_class.connection.select_value( + described_class.sanitize_sql_array([ "SELECT email_address FROM users WHERE id = ?", record.id ]) + ) + end + + it "has a valid factory" do + expect(user).to be_valid + end + + describe "associations and attachments" do + it { is_expected.to have_many(:sessions).dependent(:destroy) } + + it "destroys dependent sessions when the user is destroyed" do + user = create(:user) + create(:session, user: user) + + expect { user.destroy }.to change(Session, :count).by(-1) + end + + it "exposes an avatar_image attachment" do + expect(build(:user, :with_avatar_image).avatar_image).to be_attached + end + end + + describe "full_name" do + it { is_expected.to validate_presence_of(:full_name) } + + it "rejects a name shorter than 2 characters" do + user.full_name = "A" + expect(user).to be_invalid + expect(user.errors[:full_name]).to include(/too short/) + end + + it "rejects a name longer than 120 characters" do + user.full_name = "a" * 121 + expect(user).to be_invalid + expect(user.errors[:full_name]).to include(/too long/) + end + + it "accepts a name at both ends of the allowed range" do + expect(build(:user, full_name: "Ad")).to be_valid + expect(build(:user, full_name: "a" * 120)).to be_valid + end + + it "squishes surrounding and repeated whitespace" do + user.full_name = " Ada Lovelace \n" + expect(user.full_name).to eq("Ada Lovelace") + end + + it "leaves nil alone (Rails skips normalization for nil unless apply_to_nil) and rejects it" do + user.full_name = nil + + expect(user.full_name).to be_nil + expect(user).to be_invalid + expect(user.errors[:full_name]).to include(/can't be blank/) + end + + it "normalizes an all-whitespace name to blank and rejects it" do + user.full_name = " \n " + + expect(user.full_name).to eq("") + expect(user).to be_invalid + end + end + + describe "email_address" do + it "strips and downcases on assignment" do + user.email_address = " ADA@Example.COM " + expect(user.email_address).to eq("ada@example.com") + end + + it "requires presence" do + user.email_address = nil + expect(user).to be_invalid + expect(user.errors[:email_address]).to include(/can't be blank/) + end + + it { is_expected.to allow_value("ada@example.com").for(:email_address) } + it { is_expected.to allow_value("ada+tag@sub.example.co.uk").for(:email_address) } + it { is_expected.not_to allow_value("not-an-email").for(:email_address) } + it { is_expected.not_to allow_value("ada@").for(:email_address) } + + it "rejects a duplicate address" do + create(:user, email_address: "ada@example.com") + duplicate = build(:user, email_address: "ada@example.com") + + expect(duplicate).to be_invalid + expect(duplicate.errors[:email_address]).to include(/has already been taken/) + end + + it "treats addresses differing only by case or padding as duplicates" do + create(:user, email_address: "ada@example.com") + + expect(build(:user, email_address: " ADA@Example.COM ")).to be_invalid + end + + describe "encryption" do + let!(:persisted) { create(:user, email_address: "ada@example.com") } + + it "stores ciphertext rather than the plaintext address" do + raw = raw_email_column(persisted) + + expect(raw).not_to include("ada@example.com") + expect(raw).to include('"p"') # Active Record encryption envelope + end + + it "decrypts transparently on read" do + expect(described_class.find(persisted.id).email_address).to eq("ada@example.com") + end + + it "is deterministic, so exact lookups still work" do + expect(described_class.find_by(email_address: "ada@example.com")).to eq(persisted) + end + end + end + + describe "role" do + it "defaults to member" do + expect(described_class.new.role).to eq("member") + expect(described_class.new).to be_member + end + + it { is_expected.to define_enum_for(:role).with_values(member: 0, admin: 1) } + + it "exposes enum scopes" do + member = create(:user) + admin = create(:user, :admin) + + expect(described_class.member).to contain_exactly(member) + expect(described_class.admin).to contain_exactly(admin) + end + + it "rejects an unknown role through validation instead of raising" do + expect { user.role = :wizard }.not_to raise_error + expect(user).to be_invalid + expect(user.errors[:role]).to include(/is not included in the list/) + end + end + + describe "password" do + it "authenticates with the correct password" do + create(:user, email_address: "ada@example.com", password: "secret123") + + authenticated = described_class.authenticate_by( + email_address: "ada@example.com", password: "secret123" + ) + expect(authenticated).to be_present + end + + it "does not authenticate with a wrong password" do + create(:user, email_address: "ada@example.com", password: "secret123") + + expect( + described_class.authenticate_by(email_address: "ada@example.com", password: "nope") + ).to be_nil + end + + it "stores a digest rather than the password" do + record = create(:user, password: "secret123") + + expect(record.password_digest).to be_present + expect(record.password_digest).not_to include("secret123") + end + end + + describe "avatar_url" do + it { is_expected.to allow_value("https://cdn.example.com/a.png").for(:avatar_url) } + it { is_expected.to allow_value("").for(:avatar_url) } + it { is_expected.to allow_value(nil).for(:avatar_url) } + it { is_expected.not_to allow_value("http://insecure.example.com/a.png").for(:avatar_url) } + it { is_expected.not_to allow_value("ftp://example.com/a.png").for(:avatar_url) } + it { is_expected.not_to allow_value("https://has space.com/a.png").for(:avatar_url) } + end + + describe "avatar_image" do + it "accepts an allowed image type" do + expect(build(:user, :with_avatar_image)).to be_valid + end + + it "rejects a disallowed content type" do + user.avatar_image.attach( + io: Rails.root.join("spec/fixtures/files/document.txt").open, + filename: "document.txt", + content_type: "text/plain" + ) + + expect(user).to be_invalid + expect(user.errors[:avatar_image].join).to match(/content type/i) + end + + it "rejects a file larger than 5 megabytes" do + user.avatar_image.attach( + io: StringIO.new("0" * 6.megabytes), + filename: "huge.png", + content_type: "image/png" + ) + + expect(user).to be_invalid + expect(user.errors[:avatar_image].join).to match(/size|large/i) + end + + it "skips attachment validation entirely when nothing is attached" do + expect(user.avatar_image).not_to be_attached + expect(user).to be_valid + end + end + + describe "#avatar_source" do + it "prefers the attachment when one is present" do + record = build(:user, :with_avatar_image, :with_avatar_url) + + expect(record.avatar_source).to be_a(ActiveStorage::Attached::One) + expect(record.avatar_source).to be_attached + end + + it "falls back to avatar_url when no file is attached" do + record = build(:user, :with_avatar_url) + + expect(record.avatar_source).to eq("https://cdn.example.com/avatars/ada.png") + end + + it "returns nil when neither is set" do + expect(user.avatar_source).to be_nil + end + + it "returns nil when avatar_url is blank" do + expect(build(:user, avatar_url: "").avatar_source).to be_nil + end + end + + describe "protecting the last administrator" do + it "refuses to destroy the only admin and reports why" do + admin = create(:user, :admin) + + expect { admin.destroy }.not_to change(described_class, :count) + expect(admin.errors[:base]).to include("Cannot remove the last administrator") + end + + it "returns false from destroy when it aborts" do + admin = create(:user, :admin) + + expect(admin.destroy).to be(false) + expect(admin).to be_persisted + end + + it "runs before dependent: :destroy, so the aborted user keeps their sessions" do + admin = create(:user, :admin) + create(:session, user: admin) + + expect { admin.destroy }.not_to change(Session, :count) + expect(admin.sessions.count).to eq(1) + end + + it "allows destroying an admin once another admin exists" do + admin = create(:user, :admin) + create(:user, :admin) + + expect { admin.destroy }.to change(described_class.admin, :count).from(2).to(1) + end + + it "never blocks destroying a member" do + create(:user, :admin) + member = create(:user) + + expect { member.destroy }.to change(described_class, :count).by(-1) + end + + it "allows destroying a member even when they are the only user" do + member = create(:user) + + expect { member.destroy }.to change(described_class, :count).by(-1) + end + end end diff --git a/spec/policies/application_policy_spec.rb b/spec/policies/application_policy_spec.rb new file mode 100644 index 000000000..ccf2b1521 --- /dev/null +++ b/spec/policies/application_policy_spec.rb @@ -0,0 +1,57 @@ +require "rails_helper" + +RSpec.describe ApplicationPolicy do + subject(:policy) { described_class.new(user, record) } + + let(:user) { build_stubbed(:user) } + let(:record) { user } + + describe "readers" do + it "exposes the user and record it was built with" do + other = build_stubbed(:user) + policy = described_class.new(user, other) + + expect(policy.user).to eq(user) + expect(policy.record).to eq(other) + end + end + + describe "defaults" do + it "denies every action so subclasses must opt in" do + expect(policy.index?).to be(false) + expect(policy.show?).to be(false) + expect(policy.create?).to be(false) + expect(policy.update?).to be(false) + expect(policy.destroy?).to be(false) + end + + it "permits no attributes" do + expect(policy.permitted_attributes).to eq([]) + end + end + + describe "#owner?" do + it "is private, so it cannot be used as an action predicate" do + expect(described_class.private_method_defined?(:owner?)).to be(true) + expect(policy).not_to respond_to(:owner?) + end + + it "is true when the record is the user themselves" do + expect(policy.send(:owner?)).to be(true) + end + + it "is false for a different user" do + expect(described_class.new(user, build_stubbed(:user)).send(:owner?)).to be(false) + end + + it "is false when there is no user" do + expect(described_class.new(nil, user).send(:owner?)).to be(false) + end + + it "is false for a record that merely belongs to the user" do + session = build_stubbed(:session, user: user) + + expect(described_class.new(user, session).send(:owner?)).to be(false) + end + end +end diff --git a/spec/policies/user_policy_spec.rb b/spec/policies/user_policy_spec.rb new file mode 100644 index 000000000..aa12e6952 --- /dev/null +++ b/spec/policies/user_policy_spec.rb @@ -0,0 +1,126 @@ +require "rails_helper" + +RSpec.describe UserPolicy do + let(:admin) { create(:user, :admin) } + let(:member) { create(:user) } + let(:other) { create(:user) } + + def policy_for(actor, record) = described_class.new(actor, record) + + describe "an admin acting on someone else" do + subject(:policy) { policy_for(admin, member) } + + it { expect(policy.index?).to be(true) } + it { expect(policy.show?).to be(true) } + it { expect(policy.create?).to be(true) } + it { expect(policy.update?).to be(true) } + it { expect(policy.destroy?).to be(true) } + it { expect(policy.toggle_role?).to be(true) } + end + + describe "an admin acting on themselves" do + subject(:policy) { policy_for(admin, admin) } + + it { expect(policy.index?).to be(true) } + it { expect(policy.show?).to be(true) } + it { expect(policy.update?).to be(true) } + + it "cannot change their own role, which is the guard against self-demotion" do + expect(policy.toggle_role?).to be(false) + end + + # NOTE: the comment above `toggle_role?` says an admin must not be able to + # "demote or delete themselves out of access", but `destroy?` is + # `user.admin? || owner?` and so is true here. This example pins the + # behaviour as it actually is; see also the model-level last-admin guard, + # which only stops the *final* admin from being removed. + it "can currently still destroy their own account" do + expect(policy.destroy?).to be(true) + end + end + + describe "a member acting on themselves" do + subject(:policy) { policy_for(member, member) } + + it { expect(policy.index?).to be(false) } + it { expect(policy.create?).to be(false) } + it { expect(policy.toggle_role?).to be(false) } + + it "can view, edit and delete their own account" do + expect(policy.show?).to be(true) + expect(policy.update?).to be(true) + expect(policy.destroy?).to be(true) + end + end + + describe "a member acting on another user" do + subject(:policy) { policy_for(member, other) } + + it "is denied every action" do + expect(policy.index?).to be(false) + expect(policy.show?).to be(false) + expect(policy.create?).to be(false) + expect(policy.update?).to be(false) + expect(policy.destroy?).to be(false) + expect(policy.toggle_role?).to be(false) + end + end + + describe "#permitted_attributes" do + it "lets an admin assign role on top of the base attributes" do + expect(policy_for(admin, member).permitted_attributes).to eq(described_class::ADMIN_ATTRIBUTES) + expect(policy_for(admin, member).permitted_attributes).to include(:role) + end + + it "withholds role from a member editing themselves" do + attributes = policy_for(member, member).permitted_attributes + + expect(attributes).to eq(described_class::BASE_ATTRIBUTES) + expect(attributes).not_to include(:role) + end + + it "covers the writable profile fields" do + expect(described_class::BASE_ATTRIBUTES).to contain_exactly( + :full_name, :email_address, :password, :password_confirmation, + :avatar_url, :avatar_image + ) + end + + it "exposes frozen constants so callers cannot mutate them" do + expect(described_class::BASE_ATTRIBUTES).to be_frozen + expect(described_class::ADMIN_ATTRIBUTES).to be_frozen + end + end + + describe "#scope" do + before do + admin + member + other + end + + it "returns every user for an admin" do + expect(policy_for(admin, admin).scope).to match_array(User.all) + end + + it "returns only themselves for a member" do + expect(policy_for(member, member).scope).to contain_exactly(member) + end + + it "still returns only the actor when a member targets someone else" do + expect(policy_for(member, other).scope).to contain_exactly(member) + end + end + + # The policy calls `user.admin?` unguarded, so it assumes an authenticated + # actor. These examples document that a guest reaches a NoMethodError rather + # than a denial -- relevant because PasswordsController and + # SessionsController#new/#create allow unauthenticated access. + describe "with no authenticated user" do + it "raises instead of denying" do + expect { policy_for(nil, member).show? }.to raise_error(NoMethodError, /admin\?/) + expect { policy_for(nil, member).permitted_attributes }.to raise_error(NoMethodError, /admin\?/) + expect { policy_for(nil, member).scope }.to raise_error(NoMethodError, /admin\?/) + end + end +end diff --git a/spec/requests/passwords_spec.rb b/spec/requests/passwords_spec.rb new file mode 100644 index 000000000..40fbcdbc8 --- /dev/null +++ b/spec/requests/passwords_spec.rb @@ -0,0 +1,102 @@ +require "rails_helper" + +RSpec.describe "Passwords", type: :request do + let!(:user) { create(:user, email_address: "ada@example.com", password: "password") } + + describe "GET /passwords/new" do + it "is reachable without authentication" do + get new_password_url + + expect(response).to have_http_status(:ok) + end + end + + describe "POST /passwords" do + it "sends reset instructions to a known address" do + expect { + post passwords_url, params: { email_address: "ada@example.com" } + }.to have_enqueued_mail(PasswordsMailer, :reset) + + expect(response).to redirect_to(new_session_url) + expect(flash[:notice]).to match(/Password reset instructions sent/) + end + + it "finds the user even when the address needs normalizing" do + expect { + post passwords_url, params: { email_address: " ADA@Example.COM " } + }.to have_enqueued_mail(PasswordsMailer, :reset) + end + + it "sends nothing for an unknown address" do + expect { + post passwords_url, params: { email_address: "nobody@example.com" } + }.not_to have_enqueued_mail(PasswordsMailer, :reset) + end + + it "gives an identical response for unknown addresses, so accounts cannot be enumerated" do + post passwords_url, params: { email_address: "nobody@example.com" } + unknown_flash = flash[:notice] + + post passwords_url, params: { email_address: "ada@example.com" } + + expect(unknown_flash).to eq(flash[:notice]) + expect(response).to redirect_to(new_session_url) + end + end + + describe "GET /passwords/:token/edit" do + it "accepts a freshly generated token" do + get edit_password_url(user.password_reset_token) + + expect(response).to have_http_status(:ok) + end + + it "rejects a malformed token" do + get edit_password_url("not-a-real-token") + + expect(response).to redirect_to(new_password_url) + expect(flash[:alert]).to match(/invalid or has expired/) + end + end + + describe "PATCH /passwords/:token" do + it "changes the password when the confirmation matches" do + patch password_url(user.password_reset_token), + params: { password: "new-password", password_confirmation: "new-password" } + + expect(response).to redirect_to(new_session_url) + expect(flash[:notice]).to match(/Password has been reset/) + expect(user.reload.authenticate("new-password")).to be_truthy + end + + it "signs out every existing session after a successful reset" do + create(:session, user: user) + create(:session, user: user) + + expect { + patch password_url(user.password_reset_token), + params: { password: "new-password", password_confirmation: "new-password" } + }.to change { user.sessions.count }.from(2).to(0) + end + + it "refuses a mismatched confirmation and leaves the password alone" do + # `password_reset_token` mints a new token (with a fresh expiry) on every + # call, so hold on to the one we actually submit. + token = user.password_reset_token + + patch password_url(token), params: { password: "new-password", password_confirmation: "different" } + + expect(response).to redirect_to(edit_password_url(token)) + expect(flash[:alert]).to match(/Passwords did not match/) + expect(user.reload.authenticate("password")).to be_truthy + end + + it "rejects a malformed token" do + patch password_url("not-a-real-token"), + params: { password: "new-password", password_confirmation: "new-password" } + + expect(response).to redirect_to(new_password_url) + expect(user.reload.authenticate("password")).to be_truthy + end + end +end diff --git a/spec/requests/sessions_spec.rb b/spec/requests/sessions_spec.rb new file mode 100644 index 000000000..122bdbda7 --- /dev/null +++ b/spec/requests/sessions_spec.rb @@ -0,0 +1,98 @@ +require "rails_helper" + +RSpec.describe "Sessions", type: :request do + let(:password) { "password" } + let!(:user) { create(:user, email_address: "ada@example.com", password: password) } + + describe "GET /session/new" do + it "is reachable without authentication" do + get new_session_url + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Sign in") + end + end + + describe "POST /session" do + it "signs the user in and redirects to the post-login destination" do + post session_url, params: { email_address: "ada@example.com", password: password } + + expect(response).to redirect_to(root_url) + expect(user.sessions.count).to eq(1) + end + + it "accepts an address that needs normalizing" do + post session_url, params: { email_address: " ADA@Example.COM ", password: password } + + expect(response).to redirect_to(root_url) + expect(user.sessions.count).to eq(1) + end + + it "records the request metadata on the new session" do + post session_url, + params: { email_address: "ada@example.com", password: password }, + headers: { "HTTP_USER_AGENT" => "RSpec UA" } + + session = user.sessions.sole + expect(session.user_agent).to eq("RSpec UA") + expect(session.ip_address).to be_present + end + + it "rejects a wrong password without creating a session" do + post session_url, params: { email_address: "ada@example.com", password: "wrong" } + + expect(response).to redirect_to(new_session_url) + expect(flash[:alert]).to match(/Try another email address or password/) + expect(user.sessions).to be_empty + end + + it "rejects an unknown address without revealing that it is unknown" do + post session_url, params: { email_address: "nobody@example.com", password: password } + + expect(response).to redirect_to(new_session_url) + expect(flash[:alert]).to match(/Try another email address or password/) + end + + it "returns the user to the page they originally requested" do + get root_url # bounced to sign-in, stashing the destination + post session_url, params: { email_address: "ada@example.com", password: password } + + expect(response).to redirect_to(root_url) + end + end + + describe "DELETE /session" do + it "signs the user out and drops the session record" do + sign_in_as(user) + expect(user.sessions.count).to eq(1) + + delete session_url + + expect(response).to have_http_status(:see_other) + expect(response).to redirect_to(new_session_url) + expect(user.sessions).to be_empty + end + + it "requires authentication" do + delete session_url + + expect(response).to redirect_to(new_session_url) + end + end + + describe "authentication guard" do + it "redirects a signed-out visitor away from a protected page" do + get root_url + + expect(response).to redirect_to(new_session_url) + end + + it "lets a signed-in user through" do + sign_in_as(user) + + get root_url + + expect(response).to have_http_status(:ok) + end + end +end diff --git a/spec/support/authentication_helpers.rb b/spec/support/authentication_helpers.rb new file mode 100644 index 000000000..52a3946e0 --- /dev/null +++ b/spec/support/authentication_helpers.rb @@ -0,0 +1,12 @@ +module AuthenticationHelpers + # Signs in through the real session endpoint so the signed cookie the + # Authentication concern looks for is set the same way it is in production. + def sign_in_as(user, password: "password") + post session_url, params: { email_address: user.email_address, password: password } + user + end +end + +RSpec.configure do |config| + config.include AuthenticationHelpers, type: :request +end diff --git a/spec/support/capybara.rb b/spec/support/capybara.rb index 588e3e902..5773d0da9 100644 --- a/spec/support/capybara.rb +++ b/spec/support/capybara.rb @@ -4,6 +4,11 @@ Capybara::Playwright::Driver.new(app, browser_type: :chromium, headless: true) end +# config/routes.rb redirects any GET on host 127.0.0.1 to localhost. Capybara +# serves on 127.0.0.1 by default, so a browser test would follow that redirect +# to a different host and drop the session cookie set on the first one. +Capybara.server_host = "localhost" + Capybara.default_driver = :rack_test Capybara.javascript_driver = :playwright Capybara.server = :puma, { Silent: true } diff --git a/spec/system/authentication_spec.rb b/spec/system/authentication_spec.rb new file mode 100644 index 000000000..73742a7a5 --- /dev/null +++ b/spec/system/authentication_spec.rb @@ -0,0 +1,46 @@ +require "rails_helper" + +RSpec.describe "Signing in", type: :system do + let!(:user) { create(:user, email_address: "ada@example.com", password: "password") } + + it "rejects a bad password and keeps the visitor on the sign-in page" do + visit new_session_path + + fill_in "email_address", with: "ada@example.com" + fill_in "password", with: "wrong-password" + click_on "Sign in" + + expect(page).to have_css("#alert", text: "Try another email address or password.") + expect(user.sessions).to be_empty + end + + it "signs a registered user in" do + visit new_session_path + + fill_in "email_address", with: "ada@example.com" + fill_in "password", with: "password" + click_on "Sign in" + + expect(user.sessions.count).to eq(1) + end + + it "offers a route to password recovery" do + visit new_session_path + click_on "Forgot password?" + + expect(page).to have_current_path(new_password_path) + end + + # Runs through the Playwright driver registered in spec/support/capybara.rb, + # exercising the real browser rather than rack_test. + it "signs in through a real browser", :js do + visit new_session_path + + fill_in "email_address", with: "ada@example.com" + fill_in "password", with: "password" + click_on "Sign in" + + expect(page).to have_current_path(root_path) + expect(user.sessions.count).to eq(1) + end +end From 15d6c8b77fd6fbb374fe237a3f89fe146b583aa8 Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Wed, 9 Sep 2026 09:33:30 -0300 Subject: [PATCH 15/82] feat: update authentication error messages and implement authorization concern with tests --- .../admin/dashboards_controller.rb | 9 + app/controllers/concerns/authentication.rb | 6 +- app/controllers/concerns/authorization.rb | 30 +++ app/controllers/profiles_controller.rb | 5 + app/controllers/sessions_controller.rb | 4 +- app/views/admin/dashboards/show.html.erb | 13 ++ app/views/profiles/show.html.erb | 16 ++ config/routes.rb | 4 + db/seeds.rb | 23 ++- spec/controllers/authorization_spec.rb | 195 ++++++++++++++++++ spec/requests/admin/dashboards_spec.rb | 52 +++++ spec/requests/profiles_spec.rb | 36 ++++ spec/requests/sessions_spec.rb | 21 +- spec/system/authentication_spec.rb | 4 +- 14 files changed, 398 insertions(+), 20 deletions(-) create mode 100644 app/controllers/admin/dashboards_controller.rb create mode 100644 app/controllers/concerns/authorization.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 create mode 100644 spec/controllers/authorization_spec.rb create mode 100644 spec/requests/admin/dashboards_spec.rb create mode 100644 spec/requests/profiles_spec.rb diff --git a/app/controllers/admin/dashboards_controller.rb b/app/controllers/admin/dashboards_controller.rb new file mode 100644 index 000000000..ea41a5159 --- /dev/null +++ b/app/controllers/admin/dashboards_controller.rb @@ -0,0 +1,9 @@ +class Admin::DashboardsController < ApplicationController + include Authorization + + before_action -> { authorize!(User, "index?") } + + def show + @users = UserPolicy.new(Current.user, User).scope + end +end diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 3538f485c..e2e27c04d 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -35,7 +35,11 @@ def request_authentication end def after_authentication_url - session.delete(:return_to_after_authenticating) || root_url + session.delete(:return_to_after_authenticating) || default_landing_url + end + + def default_landing_url + Current.user.admin? ? admin_dashboard_path : profile_path end def start_new_session_for(user) diff --git a/app/controllers/concerns/authorization.rb b/app/controllers/concerns/authorization.rb new file mode 100644 index 000000000..f02a7a20e --- /dev/null +++ b/app/controllers/concerns/authorization.rb @@ -0,0 +1,30 @@ +module Authorization + extend ActiveSupport::Concern + + class NotAuthorizedError < StandardError; end + + included do + rescue_from NotAuthorizedError, with: :deny_access + end + + private + + def authorize!(record, action = "#{action_name}?") + policy = policy_for(record) + raise NotAuthorizedError unless policy.public_send(action) + policy + end + + def policy_for(record) + klass = record.is_a?(Class) ? record : record.class + "#{klass.name}Policy".constantize.new(Current.user, record) + end + + def permitted_params(record, key) + params.expect(key => policy_for(record).permitted_attributes) + end + + def deny_access + redirect_back fallback_location: root_path, alert: "You are not authorized to do that." + end +end diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb new file mode 100644 index 000000000..fb7e8f4dc --- /dev/null +++ b/app/controllers/profiles_controller.rb @@ -0,0 +1,5 @@ +class ProfilesController < ApplicationController + def show + @user = Current.user + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index cf7fccd12..c525cd575 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -6,11 +6,11 @@ def new end def create - if user = User.authenticate_by(params.permit(:email_address, :password)) + if (user = User.authenticate_by(params.permit(:email_address, :password))) start_new_session_for user redirect_to after_authentication_url else - redirect_to new_session_path, alert: "Try another email address or password." + redirect_to new_session_path, alert: "Invalid email or password." 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..54edd385c --- /dev/null +++ b/app/views/admin/dashboards/show.html.erb @@ -0,0 +1,13 @@ +
+

Admin dashboard

+ +

<%= pluralize(@users.count, "user") %>

+ +
    + <% @users.each do |user| %> +
  • <%= user.full_name %> — <%= user.role %>
  • + <% end %> +
+ + <%= button_to "Sign out", session_path, method: :delete, class: "mt-6 underline" %> +
diff --git a/app/views/profiles/show.html.erb b/app/views/profiles/show.html.erb new file mode 100644 index 000000000..d63670c4d --- /dev/null +++ b/app/views/profiles/show.html.erb @@ -0,0 +1,16 @@ +
+

Profile

+ +
+
Name
+
<%= @user.full_name %>
+ +
Email
+
<%= @user.email_address %>
+ +
Role
+
<%= @user.role %>
+
+ + <%= button_to "Sign out", session_path, method: :delete, class: "mt-6 underline" %> +
diff --git a/config/routes.rb b/config/routes.rb index fb916a719..5b2c631b3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -2,6 +2,10 @@ resource :session resources :passwords, param: :token + # Post-sign-in landing pages (see Authentication#default_landing_url). + get "profile" => "profiles#show" + get "admin/dashboard" => "admin/dashboards#show", as: :admin_dashboard + # Redirect to localhost from 127.0.0.1 to use same IP address with Vite server constraints(host: "127.0.0.1") do get "(*path)", to: redirect { |params, req| "#{req.protocol}localhost:#{req.port}/#{params[:path]}" } diff --git a/db/seeds.rb b/db/seeds.rb index 4fbd6ed97..443a6ff08 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,9 +1,14 @@ -# 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 +admin = User.find_or_create_by!(email_address: "admin@umanni.test") do |user| + user.full_name = "Umanni Admin" + user.password = ENV.fetch("SEED_ADMIN_PASSWORD", "password123") + user.role = :admin +end + +25.times do + User.find_or_create_by!(email_address: Faker::Internet.unique.email) do |user| + user.full_name = Faker::Name.name + user.password = "password123" + user.role = :member + user.avatar_url = "https://i.pravatar.cc/300?u=#{SecureRandom.hex(4)}" + end +end diff --git a/spec/controllers/authorization_spec.rb b/spec/controllers/authorization_spec.rb new file mode 100644 index 000000000..9844839ad --- /dev/null +++ b/spec/controllers/authorization_spec.rb @@ -0,0 +1,195 @@ +require "rails_helper" + +RSpec.describe Authorization, type: :controller do + controller(ActionController::Base) do + include Authorization + + def index + policy = authorize!(User) + render plain: policy.class.name + end + + def show + authorize!(User.find(params[:id])) + head :ok + end + + def edit + authorize!(User.find(params[:id]), "toggle_role?") + head :ok + end + + def update + permitted = permitted_params(User.find(params[:id]), :user) + render plain: permitted.keys.sort.join(",") + end + end + + let(:current_user) { create(:user) } + + before do + routes.draw do + root to: "anonymous#index" + get "index" => "anonymous#index" + get "show/:id" => "anonymous#show" + get "edit/:id" => "anonymous#edit" + patch "update/:id" => "anonymous#update" + end + allow(Current).to receive(:user).and_return(current_user) + end + + describe "#authorize!" do + context "when the policy permits the action" do + let(:current_user) { create(:user, :admin) } + + it "lets the action run" do + get :index + + expect(response).to have_http_status(:ok) + end + + it "returns the policy so callers can reuse it" do + get :index + + expect(response.body).to eq("UserPolicy") + end + end + + context "when the policy denies the action" do + it "does not run the action body" do + get :index + + expect(response).not_to have_http_status(:ok) + end + + it "redirects with an explanatory alert instead of raising" do + get :index + + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to eq("You are not authorized to do that.") + end + + it "returns the visitor to where they came from when there is a referer" do + request.env["HTTP_REFERER"] = "/index" + + get :index + + expect(response).to redirect_to("/index") + end + end + + it "derives the predicate from the current action name" do + # `show?` is true for the owner, whereas `index?` is not. + get :show, params: { id: current_user.id } + + expect(response).to have_http_status(:ok) + end + + it "denies show? for someone else's record" do + get :show, params: { id: create(:user).id } + + expect(response).to redirect_to(root_path) + end + + it "accepts an explicit action to check instead" do + admin = create(:user, :admin) + allow(Current).to receive(:user).and_return(admin) + + get :edit, params: { id: create(:user).id } + + expect(response).to have_http_status(:ok) + end + + it "denies the explicit action when the policy says no" do + admin = create(:user, :admin) + allow(Current).to receive(:user).and_return(admin) + + # toggle_role? is false when an admin targets themselves. + get :edit, params: { id: admin.id } + + expect(response).to redirect_to(root_path) + end + end + + describe "#policy_for" do + it "builds the policy named after the record's class" do + policy = controller.send(:policy_for, current_user) + + expect(policy).to be_a(UserPolicy) + expect(policy.record).to eq(current_user) + expect(policy.user).to eq(current_user) + end + + it "accepts a class and keeps it as the record" do + policy = controller.send(:policy_for, User) + + expect(policy).to be_a(UserPolicy) + expect(policy.record).to eq(User) + end + + it "raises when no policy is defined for the record's class" do + expect { controller.send(:policy_for, Session.new) } + .to raise_error(NameError, /SessionPolicy/) + end + end + + describe "#permitted_params" do + it "filters to the attributes the policy allows" do + patch :update, params: { + id: current_user.id, + user: { full_name: "Ada", avatar_url: "https://x.test/a.png" } + } + + expect(response.body).to eq("avatar_url,full_name") + end + + it "drops attributes the policy withholds from a member" do + patch :update, params: { + id: current_user.id, + user: { full_name: "Ada", role: "admin" } + } + + expect(response.body).to eq("full_name") + end + + it "keeps role for an admin" do + admin = create(:user, :admin) + allow(Current).to receive(:user).and_return(admin) + + patch :update, params: { id: admin.id, user: { full_name: "Ada", role: "admin" } } + + expect(response.body).to eq("full_name,role") + end + + it "raises when the expected key is missing entirely" do + expect { + patch :update, params: { id: current_user.id } + }.to raise_error(ActionController::ParameterMissing) + end + end + + # UserPolicy calls `user.admin?` without a nil guard, so an unauthenticated + # visitor reaching `authorize!` raises NoMethodError rather than being denied. + # `rescue_from NotAuthorizedError` does not catch it, so this surfaces as a 500 + # instead of the "not authorized" redirect. Pinned here so the behaviour is + # visible; adding a nil guard to the policy will flip this example. + describe "with no authenticated user" do + let(:current_user) { nil } + + it "raises NoMethodError instead of denying access" do + expect { get :index }.to raise_error(NoMethodError, /admin\?/) + end + end + + describe "NotAuthorizedError" do + it "is a StandardError so it can be rescued normally" do + expect(described_class::NotAuthorizedError.new).to be_a(StandardError) + end + + it "is registered as a rescuable on the including controller" do + handlers = self.class.controller_class.rescue_handlers.map(&:first) + + expect(handlers).to include("Authorization::NotAuthorizedError") + end + end +end diff --git a/spec/requests/admin/dashboards_spec.rb b/spec/requests/admin/dashboards_spec.rb new file mode 100644 index 000000000..2af559197 --- /dev/null +++ b/spec/requests/admin/dashboards_spec.rb @@ -0,0 +1,52 @@ +require "rails_helper" + +RSpec.describe "Admin::Dashboards", type: :request do + let!(:admin) { create(:user, :admin, email_address: "boss@example.com", password: "password") } + let!(:member) { create(:user, full_name: "Regular Member", email_address: "member@example.com", password: "password") } + + it "requires authentication" do + get admin_dashboard_path + + expect(response).to redirect_to(new_session_url) + end + + it "lets an admin in" do + sign_in_as(admin) + + get admin_dashboard_path + + expect(response).to have_http_status(:ok) + end + + it "lists every user for an admin" do + sign_in_as(admin) + + get admin_dashboard_path + + expect(response.body).to include("Regular Member") + expect(response.body).to include("2 users") + end + + it "turns a member away with the authorization alert" do + sign_in_as(member) + + get admin_dashboard_path + + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to eq("You are not authorized to do that.") + end + + it "sends a member back where they came from" do + sign_in_as(member) + + get admin_dashboard_path, headers: { "HTTP_REFERER" => profile_path } + + expect(response).to redirect_to(profile_path) + end + + it "is where an admin lands straight after signing in" do + sign_in_as(admin) + + expect(response).to redirect_to(admin_dashboard_path) + end +end diff --git a/spec/requests/profiles_spec.rb b/spec/requests/profiles_spec.rb new file mode 100644 index 000000000..47938f155 --- /dev/null +++ b/spec/requests/profiles_spec.rb @@ -0,0 +1,36 @@ +require "rails_helper" + +RSpec.describe "Profiles", type: :request do + let!(:user) { create(:user, full_name: "Ada Lovelace", email_address: "ada@example.com", password: "password") } + + it "requires authentication" do + get profile_path + + expect(response).to redirect_to(new_session_url) + end + + it "shows the signed-in user their own details" do + sign_in_as(user) + + get profile_path + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Ada Lovelace") + expect(response.body).to include("ada@example.com") + end + + it "is where a member lands straight after signing in" do + sign_in_as(user) + + expect(response).to redirect_to(profile_path) + end + + it "shows the viewer's own record rather than anyone else's" do + create(:user, full_name: "Someone Else") + sign_in_as(user) + + get profile_path + + expect(response.body).not_to include("Someone Else") + end +end diff --git a/spec/requests/sessions_spec.rb b/spec/requests/sessions_spec.rb index 122bdbda7..105750041 100644 --- a/spec/requests/sessions_spec.rb +++ b/spec/requests/sessions_spec.rb @@ -14,17 +14,26 @@ end describe "POST /session" do - it "signs the user in and redirects to the post-login destination" do + it "signs a member in and lands them on their profile" do post session_url, params: { email_address: "ada@example.com", password: password } - expect(response).to redirect_to(root_url) + expect(response).to redirect_to(profile_path) expect(user.sessions.count).to eq(1) end + it "lands an admin on the admin dashboard instead" do + admin = create(:user, :admin, email_address: "boss@example.com", password: password) + + post session_url, params: { email_address: "boss@example.com", password: password } + + expect(response).to redirect_to(admin_dashboard_path) + expect(admin.sessions.count).to eq(1) + end + it "accepts an address that needs normalizing" do post session_url, params: { email_address: " ADA@Example.COM ", password: password } - expect(response).to redirect_to(root_url) + expect(response).to redirect_to(profile_path) expect(user.sessions.count).to eq(1) end @@ -42,7 +51,7 @@ post session_url, params: { email_address: "ada@example.com", password: "wrong" } expect(response).to redirect_to(new_session_url) - expect(flash[:alert]).to match(/Try another email address or password/) + expect(flash[:alert]).to match(/Invalid email or password/) expect(user.sessions).to be_empty end @@ -50,10 +59,10 @@ post session_url, params: { email_address: "nobody@example.com", password: password } expect(response).to redirect_to(new_session_url) - expect(flash[:alert]).to match(/Try another email address or password/) + expect(flash[:alert]).to match(/Invalid email or password/) end - it "returns the user to the page they originally requested" do + it "returns the user to the page they originally requested, ahead of the default landing page" do get root_url # bounced to sign-in, stashing the destination post session_url, params: { email_address: "ada@example.com", password: password } diff --git a/spec/system/authentication_spec.rb b/spec/system/authentication_spec.rb index 73742a7a5..a2f9a1b5c 100644 --- a/spec/system/authentication_spec.rb +++ b/spec/system/authentication_spec.rb @@ -10,7 +10,7 @@ fill_in "password", with: "wrong-password" click_on "Sign in" - expect(page).to have_css("#alert", text: "Try another email address or password.") + expect(page).to have_css("#alert", text: "Invalid email or password.") expect(user.sessions).to be_empty end @@ -40,7 +40,7 @@ fill_in "password", with: "password" click_on "Sign in" - expect(page).to have_current_path(root_path) + expect(page).to have_current_path(profile_path) expect(user.sessions.count).to eq(1) end end From e7d065de0751f8aad23937f40fb6f6b19cd0ce3c Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Wed, 9 Sep 2026 09:39:30 -0300 Subject: [PATCH 16/82] feat: implement role validation to prevent demotion of the last admin and add corresponding tests --- app/models/user.rb | 12 ++++++++++++ spec/models/user_spec.rb | 41 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/app/models/user.rb b/app/models/user.rb index 92678dc9a..2f392dedc 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -27,6 +27,7 @@ class User < ApplicationRecord if: -> { avatar_image.attached? } before_destroy :ensure_not_last_admin, prepend: true + validate :admin_headcount_preserved, on: :update def avatar_source return avatar_image if avatar_image.attached? @@ -41,4 +42,15 @@ def ensure_not_last_admin errors.add(:base, "Cannot remove the last administrator") throw :abort end + + def admin_headcount_preserved + return unless role_previously_was_admin_and_now_member? + return if User.admin.where.not(id: id).exists? + + errors.add(:role, "cannot change: at least one administrator is required") + end + + def role_previously_was_admin_and_now_member? + role_changed? && role_was == "admin" && member? + end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index e9c4417a8..b18d3fcaa 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -276,5 +276,46 @@ def raw_email_column(record) expect { member.destroy }.to change(described_class, :count).by(-1) end + + it "refuses to demote the only admin" do + admin = create(:user, :admin) + + expect(admin.update(role: :member)).to be(false) + expect(admin.errors[:role]) + .to include("cannot change: at least one administrator is required") + expect(admin.reload).to be_admin + end + + it "allows demoting an admin once another admin exists" do + admin = create(:user, :admin) + create(:user, :admin) + + expect(admin.update(role: :member)).to be(true) + expect(admin.reload).to be_member + end + + it "does not block an unrelated update to the only admin" do + admin = create(:user, :admin) + + expect(admin.update(full_name: "Ada Byron")).to be(true) + end + + it "does not block promoting a member to admin" do + create(:user, :admin) + member = create(:user) + + expect(member.update(role: :admin)).to be(true) + end + + it "never blocks demoting when the record was already a member" do + create(:user, :admin) + member = create(:user) + + expect(member.update(role: :member)).to be(true) + end + + it "does not run on create, so the first user can be a member" do + expect(build(:user).save).to be(true) + end end end From e041e05f500e006d476acee38f49e20d0a7d5160 Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Wed, 9 Sep 2026 09:48:19 -0300 Subject: [PATCH 17/82] feat: add pagy gem for pagination support and configure inertia_rails RSpec matchers --- Gemfile | 6 +++++- Gemfile.lock | 3 +++ spec/rails_helper.rb | 3 +++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/Gemfile b/Gemfile index 185e0a359..70cb1bc35 100644 --- a/Gemfile +++ b/Gemfile @@ -20,6 +20,9 @@ gem "tailwindcss-rails" gem "inertia_rails" +# Pagination [https://github.com/ddnexus/pagy] +gem "pagy", "~> 9.0" + # Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] gem "bcrypt", "~> 3.1.7" @@ -70,7 +73,8 @@ group :development, :test do gem "dotenv-rails" end -group :test do gem "capybara" +group :test do + gem "capybara" gem "capybara-playwright-driver" gem "selenium-webdriver" gem "shoulda-matchers", "~> 6.0" diff --git a/Gemfile.lock b/Gemfile.lock index 4e4e7fb5e..3b03fae7c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -234,6 +234,7 @@ GEM nokogiri (1.19.4-x86_64-linux-musl) racc (~> 1.4) ostruct (0.6.3) + pagy (9.4.0) parallel (2.2.0) parser (3.3.12.0) ast (~> 2.4.1) @@ -481,6 +482,7 @@ DEPENDENCIES inertia_rails json (~> 2.9) kamal + pagy (~> 9.0) pg (~> 1.1) propshaft puma (>= 5.0) @@ -594,6 +596,7 @@ CHECKSUMS nokogiri (1.19.4-x86_64-linux-gnu) sha256=379fae440b28915e3f19d752ce2dcf8465ed2b2fbefd2a7ca0dd497bc981a06a nokogiri (1.19.4-x86_64-linux-musl) sha256=17dfb7c1fa194ae02fbf7c51a7afc8d278045ab3fdacfd86f91d02d7b274470b ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 + pagy (9.4.0) sha256=db3f2e043f684155f18f78be62a81e8d033e39b9f97b1e1a8d12ad38d7bce738 parallel (2.2.0) sha256=e1059c5fd7b649558a0aec38a769f06a42942bdb40503d005a59c352fe011cd8 parser (3.3.12.0) sha256=21a6d7f755d5a24dfbdc6e6b772e4e879a52e7631a88bc5a3a134606052c9828 pg (1.6.3) sha256=1388d0563e13d2758c1089e35e973a3249e955c659592d10e5b77c468f628a99 diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 6bbff4e07..cfe69268a 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -23,6 +23,9 @@ require 'rspec/rails' # Add additional requires below this line. Rails is not loaded until this point! require 'shoulda/matchers' +# Inertia's RSpec matchers (`render_component`, `have_props`, ...) ship inside +# inertia_rails itself; requiring this self-configures RSpec. +require 'inertia_rails/rspec' # Requires supporting ruby files with custom matchers and macros, etc, in # spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are From bcdf60499bd9a0c568e3c191d8c777683a10a4ba Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Wed, 9 Sep 2026 09:53:37 -0300 Subject: [PATCH 18/82] feat: set up home page with Inertia and create corresponding routes --- app/controllers/home_controller.rb | 7 +++++++ app/javascript/pages/home/index.tsx | 10 ++++++++++ config/routes.rb | 26 +++++++++++++++----------- 3 files changed, 32 insertions(+), 11 deletions(-) create mode 100644 app/controllers/home_controller.rb create mode 100644 app/javascript/pages/home/index.tsx diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb new file mode 100644 index 000000000..44dcf010d --- /dev/null +++ b/app/controllers/home_controller.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class HomeController < InertiaController + def index + render inertia: "home/index" + end +end diff --git a/app/javascript/pages/home/index.tsx b/app/javascript/pages/home/index.tsx new file mode 100644 index 000000000..6db0b0db7 --- /dev/null +++ b/app/javascript/pages/home/index.tsx @@ -0,0 +1,10 @@ +import { Head } from '@inertiajs/react' + +export default function Home() { + return ( + <> + +

Home

+ + ) +} diff --git a/config/routes.rb b/config/routes.rb index 5b2c631b3..b07063596 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,18 +1,25 @@ Rails.application.routes.draw do - resource :session - resources :passwords, param: :token + resource :session, only: %i[new create destroy] + resources :passwords, param: :token, only: %i[new create edit update] + resource :registration, only: %i[new create] + resource :profile, only: %i[show edit update destroy] - # Post-sign-in landing pages (see Authentication#default_landing_url). - get "profile" => "profiles#show" - get "admin/dashboard" => "admin/dashboards#show", as: :admin_dashboard + namespace :admin do + # Generates `admin_dashboard_path` => /admin + root "dashboards#show", as: :dashboard + + resources :users do + resource :role, only: :update, controller: "user_roles" + end + end # Redirect to localhost from 127.0.0.1 to use same IP address with Vite server constraints(host: "127.0.0.1") do get "(*path)", to: redirect { |params, req| "#{req.protocol}localhost:#{req.port}/#{params[:path]}" } end - root 'inertia_example#index' - get 'inertia-example', to: 'inertia_example#index' - # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html + + root "home#index" + get "inertia-example", to: "inertia_example#index" # 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. @@ -21,7 +28,4 @@ # 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 From 42e9be70c88d2bd92f3befeb69f31fe966ab59d7 Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Wed, 9 Sep 2026 11:15:38 -0300 Subject: [PATCH 19/82] feat: add UserSerializer and corresponding specs for user serialization --- app/serializers/user_serializer.rb | 33 ++++++++++ spec/serializers/user_serializer_spec.rb | 81 ++++++++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 app/serializers/user_serializer.rb create mode 100644 spec/serializers/user_serializer_spec.rb diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb new file mode 100644 index 000000000..74c37a446 --- /dev/null +++ b/app/serializers/user_serializer.rb @@ -0,0 +1,33 @@ +class UserSerializer + include Rails.application.routes.url_helpers + + VARIANT = { resize_to_fill: [ 160, 160 ], format: :webp }.freeze + + def self.collection(users) = users.map { new(_1).as_json } + + def initialize(user) + @user = user + end + + def as_json(*) + { + id: user.id, + full_name: user.full_name, + email_address: user.email_address, + role: user.role, + admin: user.admin?, + avatar_url: avatar_url, + created_at: user.created_at.iso8601 + } + end + + private + + attr_reader :user + + def avatar_url + return user.avatar_url.presence unless user.avatar_image.attached? + + rails_representation_url(user.avatar_image.variant(**VARIANT), only_path: true) + end +end diff --git a/spec/serializers/user_serializer_spec.rb b/spec/serializers/user_serializer_spec.rb new file mode 100644 index 000000000..7120bb286 --- /dev/null +++ b/spec/serializers/user_serializer_spec.rb @@ -0,0 +1,81 @@ +require "rails_helper" + +RSpec.describe UserSerializer do + describe "#as_json" do + it "exposes the public attributes" do + user = create(:user, full_name: "Ada Lovelace", email_address: "ada@example.com") + + expect(described_class.new(user).as_json).to include( + id: user.id, + full_name: "Ada Lovelace", + email_address: "ada@example.com", + role: "member", + admin: false + ) + end + + it "flags an admin" do + expect(described_class.new(create(:user, :admin)).as_json) + .to include(role: "admin", admin: true) + end + + it "formats created_at as iso8601" do + user = create(:user) + + expect(described_class.new(user).as_json[:created_at]).to eq(user.created_at.iso8601) + end + + it "never leaks the password digest" do + json = described_class.new(create(:user)).as_json + + expect(json).not_to include(:password_digest) + expect(json.values.join).not_to include("password") + end + end + + describe "avatar_url" do + it "falls back to the remote avatar_url when no image is attached" do + user = create(:user, :with_avatar_url) + + expect(described_class.new(user).as_json[:avatar_url]) + .to eq("https://cdn.example.com/avatars/ada.png") + end + + it "is nil when there is neither an attachment nor a url" do + expect(described_class.new(create(:user)).as_json[:avatar_url]).to be_nil + end + + it "is nil when avatar_url is blank rather than an empty string" do + expect(described_class.new(create(:user, avatar_url: "")).as_json[:avatar_url]).to be_nil + end + + it "prefers the attached image, served as a variant path" do + user = create(:user, :with_avatar_image, avatar_url: "https://cdn.example.com/ignored.png") + + url = described_class.new(user).as_json[:avatar_url] + + expect(url).to start_with("/rails/active_storage/representations/") + expect(url).not_to include("cdn.example.com") + end + + it "returns a path, not a host-qualified url" do + user = create(:user, :with_avatar_image) + + expect(described_class.new(user).as_json[:avatar_url]).not_to match(%r{\Ahttps?://}) + end + end + + describe ".collection" do + it "serializes each user" do + create(:user, full_name: "Ada Lovelace") + create(:user, full_name: "Grace Hopper") + + expect(described_class.collection(User.order(:full_name)).pluck(:full_name)) + .to eq([ "Ada Lovelace", "Grace Hopper" ]) + end + + it "returns an empty array for no users" do + expect(described_class.collection(User.none)).to eq([]) + end + end +end From 77ecfac2293310648b853f12b51b53cfd5244039 Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Wed, 9 Sep 2026 12:53:05 -0300 Subject: [PATCH 20/82] feat: implement UserSearch query class with filtering, sorting, and pagination --- app/controllers/application_controller.rb | 11 +- app/queries/user_search.rb | 40 +++ spec/controllers/shared_data_spec.rb | 26 ++ spec/queries/user_search_spec.rb | 281 ++++++++++++++++++++++ spec/requests/shared_data_spec.rb | 58 +++++ 5 files changed, 413 insertions(+), 3 deletions(-) create mode 100644 app/queries/user_search.rb create mode 100644 spec/controllers/shared_data_spec.rb create mode 100644 spec/queries/user_search_spec.rb create mode 100644 spec/requests/shared_data_spec.rb diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 5f38f02f3..2fadd0aa3 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,8 +1,13 @@ class ApplicationController < ActionController::Base include Authentication - # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. + include Authorization + allow_browser versions: :modern - # Changes to the importmap will invalidate the etag for HTML responses - stale_when_importmap_changes + inertia_share do + { + auth: { user: Current.user && UserSerializer.new(Current.user).as_json }, + flash: { notice: flash.notice, alert: flash.alert } + } + end end diff --git a/app/queries/user_search.rb b/app/queries/user_search.rb new file mode 100644 index 000000000..3e6b68abb --- /dev/null +++ b/app/queries/user_search.rb @@ -0,0 +1,40 @@ +# app/queries/user_search.rb +class UserSearch + PER_PAGE = 20 + SORTABLE = %w[full_name role created_at].freeze + DIRECTIONS = %w[asc desc].freeze + + attr_reader :page + + def initialize(scope, params) + @scope = scope + @query = params[:query].to_s.strip + @sort = SORTABLE.include?(params[:sort].to_s) ? params[:sort].to_s : "created_at" + @dir = DIRECTIONS.include?(params[:direction].to_s) ? params[:direction].to_s : "desc" + @role = User.roles.key?(params[:role].to_s) ? params[:role].to_s : nil + @page = [ params[:page].to_i, 1 ].max + end + + def records + @records ||= filtered.order(@sort => @dir).offset((page - 1) * PER_PAGE).limit(PER_PAGE) + end + + def total = @total ||= filtered.count + def total_pages = [ (total.to_f / PER_PAGE).ceil, 1 ].max + + def to_props + { query: @query, sort: @sort, direction: @dir, role: @role, + page: page, total_pages: total_pages, total: total } + end + + private + + def filtered + result = @scope + result = result.where(role: @role) if @role + result = result.where("full_name ILIKE ?", "%#{sanitize(@query)}%") if @query.present? + result + end + + def sanitize(term) = ActiveRecord::Base.sanitize_sql_like(term) +end diff --git a/spec/controllers/shared_data_spec.rb b/spec/controllers/shared_data_spec.rb new file mode 100644 index 000000000..bde1ad504 --- /dev/null +++ b/spec/controllers/shared_data_spec.rb @@ -0,0 +1,26 @@ +require "rails_helper" + +# Every Inertia page in the app currently sits behind `require_authentication`, +# so the signed-out branch of `auth.user` has no reachable route to exercise it. +# This anonymous controller renders Inertia without authentication to pin it. +RSpec.describe ApplicationController, type: :controller do + controller(ApplicationController) do + allow_unauthenticated_access + + def index = render(inertia: "home/index") + end + + before { routes.draw { get "index" => "anonymous#index" } } + + it "shares a nil user rather than dropping the key when nobody is signed in" do + get :index + + expect(inertia.props.deep_symbolize_keys[:auth]).to eq(user: nil) + end + + it "still shares the flash keys when nobody is signed in" do + get :index + + expect(inertia.props.deep_symbolize_keys[:flash]).to eq(notice: nil, alert: nil) + end +end diff --git a/spec/queries/user_search_spec.rb b/spec/queries/user_search_spec.rb new file mode 100644 index 000000000..6ed8bcfd7 --- /dev/null +++ b/spec/queries/user_search_spec.rb @@ -0,0 +1,281 @@ +require "rails_helper" + +RSpec.describe UserSearch do + def search(scope = User.all, **params) = described_class.new(scope, params) + + describe "defaults" do + it "falls back to the newest-first ordering with no filters" do + expect(search.to_props).to include( + query: "", sort: "created_at", direction: "desc", role: nil, page: 1 + ) + end + + it "orders by created_at desc when no sort is given" do + older = create(:user, created_at: 2.days.ago) + newer = create(:user, created_at: 1.day.ago) + + expect(search.records).to eq([ newer, older ]) + end + end + + describe "sorting" do + before do + create(:user, full_name: "Grace Hopper", created_at: 3.days.ago) + create(:user, full_name: "Ada Lovelace", created_at: 2.days.ago) + create(:user, full_name: "Barbara Liskov", created_at: 1.day.ago) + end + + it "sorts by an allowlisted column ascending" do + expect(search(sort: "full_name", direction: "asc").records.pluck(:full_name)) + .to eq([ "Ada Lovelace", "Barbara Liskov", "Grace Hopper" ]) + end + + it "sorts by an allowlisted column descending" do + expect(search(sort: "full_name", direction: "desc").records.pluck(:full_name)) + .to eq([ "Grace Hopper", "Barbara Liskov", "Ada Lovelace" ]) + end + + it "sorts by created_at ascending" do + expect(search(sort: "created_at", direction: "asc").records.pluck(:full_name)) + .to eq([ "Grace Hopper", "Ada Lovelace", "Barbara Liskov" ]) + end + + it "accepts symbols as well as strings" do + expect(search(sort: :full_name, direction: :asc).to_props) + .to include(sort: "full_name", direction: "asc") + end + + it "ignores a column outside the allowlist" do + expect(search(sort: "password_digest").to_props).to include(sort: "created_at") + end + + it "ignores an unknown direction" do + expect(search(direction: "sideways").to_props).to include(direction: "desc") + end + + it "refuses a SQL injection payload in sort rather than interpolating it" do + finder = search(sort: "created_at; DROP TABLE users --") + + expect(finder.to_props).to include(sort: "created_at") + expect { finder.records.load }.not_to raise_error + expect(User.count).to eq(3) + end + + it "refuses a SQL injection payload in direction" do + finder = search(sort: "full_name", direction: "asc, (SELECT 1)") + + expect(finder.to_props).to include(direction: "desc") + expect { finder.records.load }.not_to raise_error + end + + it "sorts by role" do + roles = search(sort: "role", direction: "asc").records.pluck(:role) + + expect(roles).to eq(roles.sort) + end + end + + describe "role filtering" do + let!(:admin) { create(:user, :admin, full_name: "Ada Lovelace") } + let!(:member) { create(:user, full_name: "Grace Hopper") } + + it "keeps only admins" do + expect(search(role: "admin").records).to eq([ admin ]) + end + + it "keeps only members" do + expect(search(role: "member").records).to eq([ member ]) + end + + it "accepts a symbol role" do + expect(search(role: :admin).records).to eq([ admin ]) + end + + it "ignores a role that is not part of the enum" do + finder = search(role: "superuser") + + expect(finder.to_props).to include(role: nil) + expect(finder.records).to match_array([ admin, member ]) + end + + it "ignores a case-mismatched role rather than guessing" do + expect(search(role: "ADMIN").to_props).to include(role: nil) + end + + it "ignores a blank role" do + expect(search(role: "").to_props).to include(role: nil) + end + end + + describe "name query" do + let!(:ada) { create(:user, full_name: "Ada Lovelace") } + let!(:grace) { create(:user, full_name: "Grace Hopper") } + + it "matches a substring case-insensitively" do + expect(search(query: "lovel").records).to eq([ ada ]) + end + + it "matches regardless of the casing of the stored name" do + expect(search(query: "GRACE").records).to eq([ grace ]) + end + + it "strips surrounding whitespace before matching" do + finder = search(query: " Ada ") + + expect(finder.records).to eq([ ada ]) + expect(finder.to_props).to include(query: "Ada") + end + + it "treats a whitespace-only query as no query at all" do + expect(search(query: " ").records).to match_array([ ada, grace ]) + end + + it "returns nothing when nothing matches" do + expect(search(query: "Margaret").records).to be_empty + end + + it "escapes % so it is matched literally instead of matching everyone" do + percent = create(:user, full_name: "100% Cotton") + + expect(search(query: "100%").records).to eq([ percent ]) + end + + it "escapes _ so it is matched literally instead of matching any character" do + underscore = create(:user, full_name: "snake_case") + create(:user, full_name: "snakeXcase") + + expect(search(query: "snake_case").records).to eq([ underscore ]) + end + + it "combines the query with the role filter" do + create(:user, :admin, full_name: "Ada Byron") + + expect(search(query: "Ada", role: "member").records).to eq([ ada ]) + end + end + + describe "the scope it is given" do + it "never reaches outside it" do + create(:user, full_name: "Ada Lovelace") + grace = create(:user, full_name: "Grace Hopper") + + expect(search(User.where(full_name: "Grace Hopper")).records).to eq([ grace ]) + end + + it "counts within it" do + create_list(:user, 2) + + expect(search(User.none).total).to eq(0) + end + end + + describe "pagination" do + before { stub_const("#{described_class}::PER_PAGE", 2) } + + let!(:users) do + 3.times.map { |i| create(:user, full_name: "User #{i}", created_at: i.days.ago) } + end + + it "returns at most one page of records" do + expect(search(sort: "full_name", direction: "asc").records.size).to eq(2) + end + + it "offsets to the requested page" do + expect(search(sort: "full_name", direction: "asc", page: 2).records.pluck(:full_name)) + .to eq([ "User 2" ]) + end + + it "returns nothing past the last page" do + expect(search(page: 99).records).to be_empty + end + + it "counts every match, not just the current page" do + expect(search.total).to eq(3) + end + + it "rounds total_pages up for a partial last page" do + expect(search.total_pages).to eq(2) + end + + it "does not add an empty page when the total divides evenly" do + users.last.destroy + + expect(search.total_pages).to eq(1) + end + + it "reports one page when there are no matches" do + finder = search(query: "nobody") + + expect(finder.total).to eq(0) + expect(finder.total_pages).to eq(1) + end + + describe "the page parameter" do + it "defaults to 1" do + expect(search.page).to eq(1) + end + + it "clamps zero to 1" do + expect(search(page: 0).page).to eq(1) + end + + it "clamps a negative page to 1, so the offset can never go negative" do + finder = search(page: -5) + + expect(finder.page).to eq(1) + expect { finder.records.load }.not_to raise_error + end + + it "coerces a numeric string" do + expect(search(page: "2").page).to eq(2) + end + + it "treats a non-numeric page as 1" do + expect(search(page: "abc").page).to eq(1) + end + end + end + + describe "memoization" do + it "runs the count query once" do + create(:user) + finder = search + + expect(finder.total).to eq(1) + + create(:user) + + expect(finder.total).to eq(1) + end + + it "returns the same relation object for repeated calls" do + finder = search + + expect(finder.records).to equal(finder.records) + end + end + + describe "#to_props" do + it "exposes everything the front end needs to render the current state" do + create_list(:user, 2, :admin, full_name: "Ada Lovelace") + + props = search(query: " Ada ", sort: "full_name", direction: "asc", + role: "admin", page: "1").to_props + + expect(props).to eq( + query: "Ada", sort: "full_name", direction: "asc", role: "admin", + page: 1, total_pages: 1, total: 2 + ) + end + + it "works with ActionController::Parameters" do + params = ActionController::Parameters.new( + query: "Ada", sort: "full_name", direction: "asc", role: "admin", page: "2" + ) + + expect(described_class.new(User.all, params).to_props).to include( + query: "Ada", sort: "full_name", direction: "asc", role: "admin", page: 2 + ) + end + end +end diff --git a/spec/requests/shared_data_spec.rb b/spec/requests/shared_data_spec.rb new file mode 100644 index 000000000..b202591d8 --- /dev/null +++ b/spec/requests/shared_data_spec.rb @@ -0,0 +1,58 @@ +require "rails_helper" + +RSpec.describe "Inertia shared data", type: :request do + # `inertia.props` symbolizes only the top level, so nested props are compared + # after a deep symbolize to keep the expectations readable. + def props = inertia.props.deep_symbolize_keys + + let(:user) { create(:user, full_name: "Ada Lovelace", email_address: "ada@example.com") } + + describe "auth.user" do + it "shares the signed-in user, serialized" do + sign_in_as(user) + + get root_path + + expect(props[:auth][:user]).to include( + id: user.id, full_name: "Ada Lovelace", email_address: "ada@example.com", + role: "member", admin: false + ) + end + + it "flags an admin so the front end can gate admin-only UI" do + sign_in_as(create(:user, :admin)) + + get root_path + + expect(props[:auth][:user]).to include(role: "admin", admin: true) + end + + it "never leaks the password digest" do + sign_in_as(user) + + get root_path + + expect(props[:auth][:user]).not_to include(:password_digest) + expect(response.body).not_to include(user.password_digest) + end + end + + describe "flash" do + it "shares an alert set by a redirect" do + sign_in_as(user) + + get admin_dashboard_path # a member is turned away with an alert + follow_redirect! + + expect(props[:flash]).to eq(notice: nil, alert: "You are not authorized to do that.") + end + + it "shares both keys, nil-valued, when nothing was flashed" do + sign_in_as(user) + + get root_path + + expect(props[:flash]).to eq(notice: nil, alert: nil) + end + end +end From 5481f95631c2478fc184043b09249d6dd100a485 Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Wed, 9 Sep 2026 12:58:01 -0300 Subject: [PATCH 21/82] feat: implement UsersController with CRUD actions and authorization for admin users --- app/controllers/admin/users_controller.rb | 68 +++++ app/policies/application_policy.rb | 6 + spec/policies/application_policy_spec.rb | 9 + spec/policies/user_policy_spec.rb | 6 + spec/requests/admin/users_spec.rb | 320 ++++++++++++++++++++++ 5 files changed, 409 insertions(+) create mode 100644 app/controllers/admin/users_controller.rb create mode 100644 spec/requests/admin/users_spec.rb diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb new file mode 100644 index 000000000..7ea35ffc5 --- /dev/null +++ b/app/controllers/admin/users_controller.rb @@ -0,0 +1,68 @@ +module Admin + class UsersController < ApplicationController + before_action :set_user, only: %i[show edit update destroy] + + def index + authorize! User + search = UserSearch.new(scope.with_attached_avatar_image, params) + + render inertia: "Admin/Users/Index", props: { + users: UserSerializer.collection(search.records), + filters: search.to_props + } + end + + def show + authorize! @user + render inertia: "Admin/Users/Show", props: { user: UserSerializer.new(@user).as_json } + end + + def new + authorize! User + render inertia: "Admin/Users/New", props: { roles: User.roles.keys } + end + + def create + authorize! User + user = User.new(permitted_params(User.new, :user)) + + if user.save + redirect_to admin_users_path, notice: "#{user.full_name} was created." + else + redirect_to new_admin_user_path, inertia: { errors: user.errors } + end + end + + def edit + authorize! @user + render inertia: "Admin/Users/Edit", props: { + user: UserSerializer.new(@user).as_json, + roles: User.roles.keys + } + end + + def update + authorize! @user + + if @user.update(permitted_params(@user, :user)) + redirect_to admin_users_path, notice: "#{@user.full_name} was updated." + else + redirect_to edit_admin_user_path(@user), inertia: { errors: @user.errors } + end + end + + def destroy + authorize! @user + @user.destroy! + redirect_to admin_users_path, notice: "User deleted." + rescue ActiveRecord::RecordNotDestroyed + redirect_to admin_users_path, alert: @user.errors.full_messages.to_sentence + end + + private + + def scope = policy_for(User).scope + + def set_user = @user = scope.find(params[:id]) + end +end diff --git a/app/policies/application_policy.rb b/app/policies/application_policy.rb index 2c9af7095..7975699be 100644 --- a/app/policies/application_policy.rb +++ b/app/policies/application_policy.rb @@ -12,6 +12,12 @@ def create? = false def update? = false def destroy? = false + # `authorize!` derives the predicate from `action_name`, so the two actions + # that only render a form need predicates of their own. They mirror the write + # they lead to: seeing the form is exactly as privileged as submitting it. + def new? = create? + def edit? = update? + def permitted_attributes = [] private diff --git a/spec/policies/application_policy_spec.rb b/spec/policies/application_policy_spec.rb index ccf2b1521..79499007f 100644 --- a/spec/policies/application_policy_spec.rb +++ b/spec/policies/application_policy_spec.rb @@ -23,6 +23,15 @@ expect(policy.create?).to be(false) expect(policy.update?).to be(false) expect(policy.destroy?).to be(false) + expect(policy.new?).to be(false) + expect(policy.edit?).to be(false) + end + + it "ties the form predicates to the write they lead to" do + allow(policy).to receive_messages(create?: true, update?: true) + + expect(policy.new?).to be(true) + expect(policy.edit?).to be(true) end it "permits no attributes" do diff --git a/spec/policies/user_policy_spec.rb b/spec/policies/user_policy_spec.rb index aa12e6952..d4d366bbc 100644 --- a/spec/policies/user_policy_spec.rb +++ b/spec/policies/user_policy_spec.rb @@ -13,6 +13,8 @@ def policy_for(actor, record) = described_class.new(actor, record) it { expect(policy.index?).to be(true) } it { expect(policy.show?).to be(true) } it { expect(policy.create?).to be(true) } + it { expect(policy.new?).to be(true) } + it { expect(policy.edit?).to be(true) } it { expect(policy.update?).to be(true) } it { expect(policy.destroy?).to be(true) } it { expect(policy.toggle_role?).to be(true) } @@ -44,12 +46,14 @@ def policy_for(actor, record) = described_class.new(actor, record) it { expect(policy.index?).to be(false) } it { expect(policy.create?).to be(false) } + it { expect(policy.new?).to be(false) } it { expect(policy.toggle_role?).to be(false) } it "can view, edit and delete their own account" do expect(policy.show?).to be(true) expect(policy.update?).to be(true) expect(policy.destroy?).to be(true) + expect(policy.edit?).to be(true) end end @@ -62,6 +66,8 @@ def policy_for(actor, record) = described_class.new(actor, record) expect(policy.create?).to be(false) expect(policy.update?).to be(false) expect(policy.destroy?).to be(false) + expect(policy.new?).to be(false) + expect(policy.edit?).to be(false) expect(policy.toggle_role?).to be(false) end end diff --git a/spec/requests/admin/users_spec.rb b/spec/requests/admin/users_spec.rb new file mode 100644 index 000000000..6330c2ed1 --- /dev/null +++ b/spec/requests/admin/users_spec.rb @@ -0,0 +1,320 @@ +require "rails_helper" + +RSpec.describe "Admin::Users", type: :request do + def props = inertia.props.deep_symbolize_keys + + let(:admin) { create(:user, :admin, full_name: "Ada Lovelace", email_address: "ada@example.com") } + let(:member) { create(:user, full_name: "Grace Hopper", email_address: "grace@example.com") } + + describe "GET /admin/users" do + it "turns away a visitor who is not signed in" do + get admin_users_path + + expect(response).to redirect_to(new_session_url) + end + + it "turns away a member with the authorization alert" do + sign_in_as(member) + + get admin_users_path + + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to eq("You are not authorized to do that.") + end + + it "renders the index component for an admin" do + sign_in_as(admin) + + get admin_users_path + + expect(response).to have_http_status(:ok) + expect(inertia).to render_component("Admin/Users/Index") + end + + it "serializes every user" do + member + sign_in_as(admin) + + get admin_users_path + + expect(props[:users].pluck(:full_name)).to match_array([ "Ada Lovelace", "Grace Hopper" ]) + end + + it "exposes the search state as filters" do + sign_in_as(admin) + + get admin_users_path + + expect(props[:filters]).to include( + query: "", sort: "created_at", direction: "desc", role: nil, page: 1, total_pages: 1 + ) + end + + it "narrows the list by the name query" do + member + sign_in_as(admin) + + get admin_users_path, params: { query: "Hopper" } + + expect(props[:users].pluck(:full_name)).to eq([ "Grace Hopper" ]) + expect(props[:filters]).to include(query: "Hopper", total: 1) + end + + it "narrows the list by role" do + member + sign_in_as(admin) + + get admin_users_path, params: { role: "admin" } + + expect(props[:users].pluck(:full_name)).to eq([ "Ada Lovelace" ]) + end + + it "sorts by an allowlisted column" do + member + sign_in_as(admin) + + get admin_users_path, params: { sort: "full_name", direction: "asc" } + + expect(props[:users].pluck(:full_name)).to eq([ "Ada Lovelace", "Grace Hopper" ]) + end + + it "paginates, reporting the page count alongside the page" do + stub_const("UserSearch::PER_PAGE", 1) + member + sign_in_as(admin) + + get admin_users_path, params: { sort: "full_name", direction: "asc", page: 2 } + + expect(props[:users].pluck(:full_name)).to eq([ "Grace Hopper" ]) + expect(props[:filters]).to include(page: 2, total_pages: 2, total: 2) + end + + it "never leaks a password digest into the page payload" do + sign_in_as(admin) + + get admin_users_path + + expect(response.body).not_to include(admin.password_digest) + end + end + + describe "GET /admin/users/:id" do + it "shows any user to an admin" do + sign_in_as(admin) + + get admin_user_path(member) + + expect(inertia).to render_component("Admin/Users/Show") + expect(props[:user]).to include(id: member.id, full_name: "Grace Hopper") + end + + it "hides other users from a member behind a 404, not a 403" do + other = create(:user) + sign_in_as(member) + + get admin_user_path(other) + + expect(response).to have_http_status(:not_found) + end + + # The policy grants `show?` to the record's owner, and the scope narrows to + # the member themselves, so a member reaches their own row through the admin + # namespace. See the note in the summary: index? is admin-only, but the + # member-facing actions are not. + it "lets a member reach their own row" do + sign_in_as(member) + + get admin_user_path(member) + + expect(response).to have_http_status(:ok) + expect(props[:user]).to include(id: member.id) + end + end + + describe "GET /admin/users/new" do + it "renders the form with the assignable roles" do + sign_in_as(admin) + + get new_admin_user_path + + expect(response).to have_http_status(:ok) + expect(inertia).to render_component("Admin/Users/New") + expect(props[:roles]).to eq(%w[member admin]) + end + + it "is closed to members, since only an admin may create" do + sign_in_as(member) + + get new_admin_user_path + + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to eq("You are not authorized to do that.") + end + end + + describe "POST /admin/users" do + let(:valid_params) do + { user: { full_name: "Margaret Hamilton", email_address: "margaret@example.com", + password: "password", password_confirmation: "password" } } + end + + it "creates the user and announces it" do + sign_in_as(admin) + + expect { post admin_users_path, params: valid_params }.to change(User, :count).by(1) + + expect(response).to redirect_to(admin_users_path) + expect(flash[:notice]).to eq("Margaret Hamilton was created.") + end + + it "lets an admin set the role on creation" do + sign_in_as(admin) + + post admin_users_path, params: valid_params.deep_merge(user: { role: "admin" }) + + expect(User.find_by(email_address: "margaret@example.com")).to be_admin + end + + it "sends validation errors back to the form" do + sign_in_as(admin) + + expect { post admin_users_path, params: { user: valid_params[:user].merge(full_name: "") } } + .not_to change(User, :count) + + expect(response).to redirect_to(new_admin_user_path) + expect(session[:inertia_errors][:full_name]).to include("can't be blank") + end + + it "rejects a duplicate email address" do + create(:user, email_address: "margaret@example.com") + sign_in_as(admin) + + expect { post admin_users_path, params: valid_params }.not_to change(User, :count) + + expect(session[:inertia_errors][:email_address]).to eq([ "has already been taken" ]) + end + + it "is closed to members" do + sign_in_as(member) + + expect { post admin_users_path, params: valid_params }.not_to change(User, :count) + + expect(response).to redirect_to(root_path) + end + end + + describe "GET /admin/users/:id/edit" do + it "renders the form with the user and the roles" do + sign_in_as(admin) + + get edit_admin_user_path(member) + + expect(inertia).to render_component("Admin/Users/Edit") + expect(props[:user]).to include(id: member.id) + expect(props[:roles]).to eq(%w[member admin]) + end + + it "404s for a member editing someone else" do + other = create(:user) + sign_in_as(member) + + get edit_admin_user_path(other) + + expect(response).to have_http_status(:not_found) + end + end + + describe "PATCH /admin/users/:id" do + it "updates the user and announces it" do + sign_in_as(admin) + + patch admin_user_path(member), params: { user: { full_name: "Grace M. Hopper" } } + + expect(response).to redirect_to(admin_users_path) + expect(flash[:notice]).to eq("Grace M. Hopper was updated.") + expect(member.reload.full_name).to eq("Grace M. Hopper") + end + + it "sends validation errors back to the edit form" do + sign_in_as(admin) + + patch admin_user_path(member), params: { user: { full_name: "" } } + + expect(response).to redirect_to(edit_admin_user_path(member)) + expect(session[:inertia_errors][:full_name]).to include("can't be blank") + expect(member.reload.full_name).to eq("Grace Hopper") + end + + it "lets an admin promote a member" do + sign_in_as(admin) + + patch admin_user_path(member), params: { user: { role: "admin" } } + + expect(member.reload).to be_admin + end + + it "refuses to demote the last admin, which the model guards" do + sign_in_as(admin) + + patch admin_user_path(admin), params: { user: { role: "member" } } + + expect(admin.reload).to be_admin + expect(session[:inertia_errors][:role]) + .to eq([ "cannot change: at least one administrator is required" ]) + end + + # NOTE: `toggle_role?` exists so an admin cannot demote themselves out of + # access, but `update` never consults it -- it authorizes with `update?` and + # then permits `:role` because the actor is an admin. So self-demotion goes + # through whenever a second admin exists to satisfy the model's headcount + # validation. This pins the behaviour as it actually is; see the summary. + it "currently lets an admin demote themselves, bypassing toggle_role?" do + create(:user, :admin, email_address: "second-admin@example.com") + sign_in_as(admin) + + expect(UserPolicy.new(admin, admin).toggle_role?).to be(false) + + patch admin_user_path(admin), params: { user: { role: "member" } } + + expect(admin.reload).to be_member + end + + it "ignores a role a member tries to give themselves" do + sign_in_as(member) + + patch admin_user_path(member), params: { user: { full_name: "Grace", role: "admin" } } + + expect(member.reload).to be_member + end + end + + describe "DELETE /admin/users/:id" do + it "deletes the user" do + member + sign_in_as(admin) + + expect { delete admin_user_path(member) }.to change(User, :count).by(-1) + + expect(response).to redirect_to(admin_users_path) + expect(flash[:notice]).to eq("User deleted.") + end + + it "refuses to delete the last admin and reports why" do + sign_in_as(admin) + + expect { delete admin_user_path(admin) }.not_to change(User, :count) + + expect(response).to redirect_to(admin_users_path) + expect(flash[:alert]).to eq("Cannot remove the last administrator") + end + + it "404s for a member deleting someone else" do + other = create(:user) + sign_in_as(member) + + expect { delete admin_user_path(other) }.not_to change(User, :count) + + expect(response).to have_http_status(:not_found) + end + end +end From 1a1c8be6fcab1573ecb0a97635b28a5b96661095 Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Wed, 9 Sep 2026 13:00:48 -0300 Subject: [PATCH 22/82] feat: add UserRolesController with role update functionality and corresponding request specs --- .../admin/user_roles_controller.rb | 17 +++ spec/requests/admin/user_roles_spec.rb | 128 ++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 app/controllers/admin/user_roles_controller.rb create mode 100644 spec/requests/admin/user_roles_spec.rb diff --git a/app/controllers/admin/user_roles_controller.rb b/app/controllers/admin/user_roles_controller.rb new file mode 100644 index 000000000..34642fa41 --- /dev/null +++ b/app/controllers/admin/user_roles_controller.rb @@ -0,0 +1,17 @@ +# app/controllers/admin/user_roles_controller.rb +module Admin + class UserRolesController < ApplicationController + def update + user = policy_for(User).scope.find(params[:user_id]) + authorize! user, "toggle_role?" + + if user.update(role: user.admin? ? :member : :admin) + redirect_back fallback_location: admin_users_path, + notice: "#{user.full_name} is now #{user.role}." + else + redirect_back fallback_location: admin_users_path, + alert: user.errors.full_messages.to_sentence + end + end + end +end diff --git a/spec/requests/admin/user_roles_spec.rb b/spec/requests/admin/user_roles_spec.rb new file mode 100644 index 000000000..8421fc0b8 --- /dev/null +++ b/spec/requests/admin/user_roles_spec.rb @@ -0,0 +1,128 @@ +require "rails_helper" + +RSpec.describe "Admin::UserRoles", type: :request do + let(:admin) { create(:user, :admin, full_name: "Ada Lovelace", email_address: "ada@example.com") } + let(:member) { create(:user, full_name: "Grace Hopper", email_address: "grace@example.com") } + + describe "PATCH /admin/users/:user_id/role" do + it "turns away a visitor who is not signed in" do + patch admin_user_role_path(member) + + expect(response).to redirect_to(new_session_url) + expect(member.reload).to be_member + end + + it "promotes a member" do + sign_in_as(admin) + + patch admin_user_role_path(member) + + expect(member.reload).to be_admin + expect(flash[:notice]).to eq("Grace Hopper is now admin.") + end + + it "demotes another admin" do + second = create(:user, :admin, full_name: "Grace Hopper", email_address: "grace@example.com") + sign_in_as(admin) + + patch admin_user_role_path(second) + + expect(second.reload).to be_member + expect(flash[:notice]).to eq("Grace Hopper is now member.") + end + + it "reads the role before the write, so the toggle never reports the old value" do + sign_in_as(admin) + + patch admin_user_role_path(member) + + expect(flash[:notice]).to include("now admin") + expect(member.reload.role).to eq("admin") + end + end + + describe "the self-demotion guard" do + it "stops an admin from toggling their own role" do + sign_in_as(admin) + + patch admin_user_role_path(admin) + + expect(admin.reload).to be_admin + expect(flash[:alert]).to eq("You are not authorized to do that.") + end + + it "stops the last admin even when they are the only user" do + sign_in_as(admin) + expect(User.admin.count).to eq(1) + + patch admin_user_role_path(admin) + + expect(admin.reload).to be_admin + expect(User.admin.count).to eq(1) + end + + it "stops a member from promoting themselves" do + sign_in_as(member) + + patch admin_user_role_path(member) + + expect(member.reload).to be_member + expect(flash[:alert]).to eq("You are not authorized to do that.") + end + + it "hides other users from a member behind a 404, not a 403" do + other = create(:user) + sign_in_as(member) + + patch admin_user_role_path(other) + + expect(response).to have_http_status(:not_found) + expect(other.reload).to be_member + end + end + + describe "where it redirects" do + it "goes back where the toggle was clicked from" do + sign_in_as(admin) + + patch admin_user_role_path(member), headers: { "HTTP_REFERER" => admin_user_path(member) } + + expect(response).to redirect_to(admin_user_path(member)) + end + + it "falls back to the index when there is no referer" do + sign_in_as(admin) + + patch admin_user_role_path(member) + + expect(response).to redirect_to(admin_users_path) + end + + it "falls back to the index when a denied toggle has no referer" do + sign_in_as(admin) + + patch admin_user_role_path(admin) + + expect(response).to redirect_to(root_path) # Authorization#deny_access owns this fallback + end + end + + describe "when the write fails" do + # The model's last-admin validation cannot fire here: `toggle_role?` already + # requires the actor to be a *different* admin, so that actor is always the + # second admin the validation looks for. The reachable failure is a record + # that is already invalid on another attribute -- data predating a + # validation, say -- which the toggle then has to save. + it "reports the errors instead of announcing a change" do + member.update_column(:full_name, "") + sign_in_as(admin) + + patch admin_user_role_path(member) + + expect(member.reload).to be_member + expect(response).to redirect_to(admin_users_path) + expect(flash[:notice]).to be_nil + expect(flash[:alert]).to include("Full name can't be blank") + end + end +end From daa0c1307d4b17bb063d81bc1709760f3a2fe278 Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Wed, 9 Sep 2026 13:03:36 -0300 Subject: [PATCH 23/82] feat: enhance ProfilesController with complete CRUD actions and corresponding request specs --- app/controllers/profiles_controller.rb | 35 +++- spec/requests/profiles_spec.rb | 246 +++++++++++++++++++++++-- 2 files changed, 262 insertions(+), 19 deletions(-) diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index fb7e8f4dc..da1bdf190 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -1,5 +1,38 @@ +# app/controllers/profiles_controller.rb + class ProfilesController < ApplicationController + before_action :set_profile + def show - @user = Current.user + authorize! @profile + render inertia: "Profile/Show", props: { user: UserSerializer.new(@profile).as_json } end + + def edit + authorize! @profile + render inertia: "Profile/Edit", props: { user: UserSerializer.new(@profile).as_json } + end + + def update + authorize! @profile + + if @profile.update(permitted_params(@profile, :user)) + redirect_to profile_path, notice: "Profile updated." + else + redirect_to edit_profile_path, inertia: { errors: @profile.errors } + end + end + + def destroy + authorize! @profile + @profile.destroy! + terminate_session + redirect_to root_path, notice: "Your account has been deleted." + rescue ActiveRecord::RecordNotDestroyed + redirect_to profile_path, alert: @profile.errors.full_messages.to_sentence + end + + private + + def set_profile = @profile = Current.user end diff --git a/spec/requests/profiles_spec.rb b/spec/requests/profiles_spec.rb index 47938f155..846bf1b1a 100644 --- a/spec/requests/profiles_spec.rb +++ b/spec/requests/profiles_spec.rb @@ -1,36 +1,246 @@ require "rails_helper" RSpec.describe "Profiles", type: :request do - let!(:user) { create(:user, full_name: "Ada Lovelace", email_address: "ada@example.com", password: "password") } + def props = inertia.props.deep_symbolize_keys - it "requires authentication" do - get profile_path + let!(:user) do + create(:user, full_name: "Ada Lovelace", email_address: "ada@example.com", password: "password") + end + + describe "GET /profile" do + it "requires authentication" do + get profile_path + + expect(response).to redirect_to(new_session_url) + end + + it "shows the signed-in user their own details" do + sign_in_as(user) + + get profile_path + + expect(response).to have_http_status(:ok) + expect(inertia).to render_component("Profile/Show") + expect(props[:user]).to include( + id: user.id, full_name: "Ada Lovelace", email_address: "ada@example.com", + role: "member", admin: false + ) + end + + it "shows the viewer's own record rather than anyone else's" do + create(:user, full_name: "Someone Else") + sign_in_as(user) + + get profile_path + + expect(props[:user][:id]).to eq(user.id) + expect(response.body).not_to include("Someone Else") + end + + it "is where a member lands straight after signing in" do + sign_in_as(user) + + expect(response).to redirect_to(profile_path) + end + + it "serves an admin their own profile, flagged as such" do + admin = create(:user, :admin, email_address: "boss@example.com", password: "password") + sign_in_as(admin) + + get profile_path + + expect(props[:user]).to include(id: admin.id, role: "admin", admin: true) + end + + it "never leaks the password digest" do + sign_in_as(user) + + get profile_path - expect(response).to redirect_to(new_session_url) + expect(response.body).not_to include(user.password_digest) + end end - it "shows the signed-in user their own details" do - sign_in_as(user) + describe "GET /profile/edit" do + it "requires authentication" do + get edit_profile_path - get profile_path + expect(response).to redirect_to(new_session_url) + end - expect(response).to have_http_status(:ok) - expect(response.body).to include("Ada Lovelace") - expect(response.body).to include("ada@example.com") + it "renders the edit form for the signed-in user" do + sign_in_as(user) + + get edit_profile_path + + expect(response).to have_http_status(:ok) + expect(inertia).to render_component("Profile/Edit") + expect(props[:user]).to include(id: user.id, full_name: "Ada Lovelace") + end end - it "is where a member lands straight after signing in" do - sign_in_as(user) + describe "PATCH /profile" do + it "requires authentication" do + patch profile_path, params: { user: { full_name: "Changed" } } + + expect(response).to redirect_to(new_session_url) + expect(user.reload.full_name).to eq("Ada Lovelace") + end + + it "updates the profile and announces it" do + sign_in_as(user) + + patch profile_path, params: { user: { full_name: "Ada King" } } + + expect(response).to redirect_to(profile_path) + expect(flash[:notice]).to eq("Profile updated.") + expect(user.reload.full_name).to eq("Ada King") + end + + it "changes the password so the new one signs in" do + sign_in_as(user) + + patch profile_path, params: { + user: { password: "new-password", password_confirmation: "new-password" } + } + + expect(response).to redirect_to(profile_path) + expect(User.authenticate_by(email_address: "ada@example.com", password: "new-password")) + .to eq(user) + end + + it "sends validation errors back to the edit form" do + sign_in_as(user) + + patch profile_path, params: { user: { full_name: "" } } + + expect(response).to redirect_to(edit_profile_path) + expect(session[:inertia_errors][:full_name]).to include("can't be blank") + expect(user.reload.full_name).to eq("Ada Lovelace") + end + + it "rejects an email address already taken by someone else" do + create(:user, email_address: "taken@example.com") + sign_in_as(user) + + patch profile_path, params: { user: { email_address: "taken@example.com" } } + + expect(session[:inertia_errors][:email_address]).to eq([ "has already been taken" ]) + expect(user.reload.email_address).to eq("ada@example.com") + end - expect(response).to redirect_to(profile_path) + it "edits the viewer, never anyone else, since there is no id to target" do + other = create(:user, full_name: "Someone Else") + sign_in_as(user) + + patch profile_path, params: { id: other.id, user: { full_name: "Hijacked" } } + + expect(other.reload.full_name).to eq("Someone Else") + expect(user.reload.full_name).to eq("Hijacked") + end + + it "ignores a role a member tries to give themselves" do + sign_in_as(user) + + patch profile_path, params: { user: { full_name: "Ada King", role: "admin" } } + + expect(user.reload).to be_member + end + + # NOTE: `permitted_attributes` keys off whether the *actor* is an admin, and + # the actor here is always the record's owner. So an admin editing their own + # profile may assign `:role` -- self-demotion, which `toggle_role?` exists to + # prevent, goes through as long as a second admin satisfies the model's + # headcount validation. Pinned as it actually is; see the summary. + it "currently lets an admin demote themselves through their own profile" do + admin = create(:user, :admin, email_address: "boss@example.com", password: "password") + create(:user, :admin, email_address: "second@example.com") + sign_in_as(admin) + + patch profile_path, params: { user: { role: "member" } } + + expect(admin.reload).to be_member + end + + it "still refuses to demote the last admin, which the model guards" do + admin = create(:user, :admin, email_address: "boss@example.com", password: "password") + sign_in_as(admin) + + patch profile_path, params: { user: { role: "member" } } + + expect(admin.reload).to be_admin + expect(session[:inertia_errors][:role]) + .to eq([ "cannot change: at least one administrator is required" ]) + end end - it "shows the viewer's own record rather than anyone else's" do - create(:user, full_name: "Someone Else") - sign_in_as(user) + describe "DELETE /profile" do + it "requires authentication" do + expect { delete profile_path }.not_to change(User, :count) + + expect(response).to redirect_to(new_session_url) + end + + it "deletes the account" do + sign_in_as(user) + + expect { delete profile_path }.to change(User, :count).by(-1) + + expect(User.find_by(id: user.id)).to be_nil + end + + it "takes the sessions with it and signs the visitor out" do + sign_in_as(user) + + delete profile_path + + expect(Session.count).to eq(0) + + get profile_path + + expect(response).to redirect_to(new_session_url) + end + + it "carries the confirmation through to the sign-in page" do + sign_in_as(user) + + delete profile_path + follow_redirect! # root, which now bounces a signed-out visitor + follow_redirect! + + expect(response).to have_http_status(:ok) + expect(flash[:notice]).to eq("Your account has been deleted.") + end + + it "refuses to delete the last admin and reports why" do + admin = create(:user, :admin, email_address: "boss@example.com", password: "password") + sign_in_as(admin) + + expect { delete profile_path }.not_to change(User, :count) + + expect(response).to redirect_to(profile_path) + expect(flash[:alert]).to eq("Cannot remove the last administrator") + end + + it "leaves a refused deletion signed in" do + admin = create(:user, :admin, email_address: "boss@example.com", password: "password") + sign_in_as(admin) + + delete profile_path + get profile_path + + expect(response).to have_http_status(:ok) + expect(props[:user]).to include(id: admin.id) + end + + it "lets an admin delete themselves when another admin remains" do + admin = create(:user, :admin, email_address: "boss@example.com", password: "password") + create(:user, :admin, email_address: "second@example.com") + sign_in_as(admin) - get profile_path + expect { delete profile_path }.to change(User, :count).by(-1) - expect(response.body).not_to include("Someone Else") + expect(flash[:notice]).to eq("Your account has been deleted.") + end end end From 31136939ea9b92a713277c57b17530fccdb2472c Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Wed, 9 Sep 2026 13:05:30 -0300 Subject: [PATCH 24/82] feat: implement RegistrationsController with user registration functionality and corresponding request specs --- app/controllers/registrations_controller.rb | 30 +++++ spec/requests/registrations_spec.rb | 141 ++++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 app/controllers/registrations_controller.rb create mode 100644 spec/requests/registrations_spec.rb diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb new file mode 100644 index 000000000..70902e201 --- /dev/null +++ b/app/controllers/registrations_controller.rb @@ -0,0 +1,30 @@ +# app/controllers/registrations_controller.rb +class RegistrationsController < ApplicationController + allow_unauthenticated_access + before_action :redirect_if_authenticated + + def new + render inertia: "Auth/Register" + end + + def create + user = User.new(registration_params.merge(role: :member)) + + if user.save + start_new_session_for user + redirect_to profile_path, notice: "Welcome, #{user.full_name}." + else + redirect_to new_registration_path, inertia: { errors: user.errors } + end + end + + private + + def registration_params + params.expect(user: %i[full_name email_address password password_confirmation]) + end + + def redirect_if_authenticated + redirect_to root_path if authenticated? + end +end diff --git a/spec/requests/registrations_spec.rb b/spec/requests/registrations_spec.rb new file mode 100644 index 000000000..7bccd443b --- /dev/null +++ b/spec/requests/registrations_spec.rb @@ -0,0 +1,141 @@ +require "rails_helper" + +RSpec.describe "Registrations", type: :request do + let(:valid_params) do + { user: { full_name: "Ada Lovelace", email_address: "ada@example.com", + password: "password", password_confirmation: "password" } } + end + + describe "GET /registration/new" do + it "is open to a visitor who is not signed in" do + get new_registration_path + + expect(response).to have_http_status(:ok) + expect(inertia).to render_component("Auth/Register") + end + + it "sends a signed-in visitor away, since they already have an account" do + user = create(:user, email_address: "someone@example.com", password: "password") + sign_in_as(user) + + get new_registration_path + + expect(response).to redirect_to(root_path) + end + end + + describe "POST /registration" do + it "creates the account" do + expect { post registration_path, params: valid_params }.to change(User, :count).by(1) + + expect(response).to redirect_to(profile_path) + expect(flash[:notice]).to eq("Welcome, Ada Lovelace.") + end + + it "signs the new user straight in" do + post registration_path, params: valid_params + + expect(Session.count).to eq(1) + + get profile_path + + expect(response).to have_http_status(:ok) + expect(inertia.props.deep_symbolize_keys[:user]) + .to include(email_address: "ada@example.com") + end + + it "registers a member" do + post registration_path, params: valid_params + + expect(User.find_by(email_address: "ada@example.com")).to be_member + end + + it "refuses to let a registrant make themselves an admin" do + post registration_path, params: valid_params.deep_merge(user: { role: "admin" }) + + expect(User.find_by(email_address: "ada@example.com")).to be_member + expect(User.admin.count).to eq(0) + end + + it "normalizes the email address before storing it" do + post registration_path, params: valid_params.deep_merge( + user: { email_address: " ADA@Example.COM " } + ) + + expect(User.find_by(email_address: "ada@example.com")).to be_present + end + + it "squishes the name before storing it" do + post registration_path, params: valid_params.deep_merge(user: { full_name: " Ada Lovelace " }) + + expect(User.last.full_name).to eq("Ada Lovelace") + end + + it "sends validation errors back to the form" do + expect { post registration_path, params: valid_params.deep_merge(user: { full_name: "" }) } + .not_to change(User, :count) + + expect(response).to redirect_to(new_registration_path) + expect(session[:inertia_errors][:full_name]).to include("can't be blank") + end + + it "rejects an email address that is already registered" do + create(:user, email_address: "ada@example.com") + + expect { post registration_path, params: valid_params }.not_to change(User, :count) + + expect(session[:inertia_errors][:email_address]).to eq([ "has already been taken" ]) + end + + it "rejects a malformed email address" do + expect { post registration_path, params: valid_params.deep_merge(user: { email_address: "nope" }) } + .not_to change(User, :count) + + expect(session[:inertia_errors][:email_address]).to be_present + end + + it "rejects a password that does not match its confirmation" do + expect { + post registration_path, params: valid_params.deep_merge(user: { password_confirmation: "other" }) + }.not_to change(User, :count) + + expect(session[:inertia_errors][:password_confirmation]).to include("doesn't match Password") + end + + it "rejects a blank password" do + expect { + post registration_path, + params: valid_params.deep_merge(user: { password: "", password_confirmation: "" }) + }.not_to change(User, :count) + + expect(session[:inertia_errors][:password]).to include("can't be blank") + end + + # NOTE: `has_secure_password` only caps length at 72 bytes -- it has no + # minimum -- and the model adds no password length validation of its own, so + # a one-character password registers. Pinned as it actually is; see the + # summary. If a minimum is added, this example should flip to a rejection. + it "currently accepts a one-character password" do + post registration_path, params: valid_params.deep_merge( + user: { password: "a", password_confirmation: "a" } + ) + + expect(User.find_by(email_address: "ada@example.com")).to be_present + end + + it "rejects a request with no user params at all" do + expect { post registration_path, params: {} }.not_to change(User, :count) + + expect(response).to have_http_status(:bad_request) + end + + it "sends a signed-in visitor away without creating a second account" do + user = create(:user, email_address: "someone@example.com", password: "password") + sign_in_as(user) + + expect { post registration_path, params: valid_params }.not_to change(User, :count) + + expect(response).to redirect_to(root_path) + end + end +end From 70220842091fab33b4c0180176a649d51fc0c792 Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Wed, 9 Sep 2026 13:13:44 -0300 Subject: [PATCH 25/82] feat: remove unused profile view template --- app/views/profiles/show.html.erb | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 app/views/profiles/show.html.erb diff --git a/app/views/profiles/show.html.erb b/app/views/profiles/show.html.erb deleted file mode 100644 index d63670c4d..000000000 --- a/app/views/profiles/show.html.erb +++ /dev/null @@ -1,16 +0,0 @@ -
-

Profile

- -
-
Name
-
<%= @user.full_name %>
- -
Email
-
<%= @user.email_address %>
- -
Role
-
<%= @user.role %>
-
- - <%= button_to "Sign out", session_path, method: :delete, class: "mt-6 underline" %> -
From a2aee6da51e0f2ea39ba3a77d6dfe2373a9964a8 Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Wed, 9 Sep 2026 13:13:50 -0300 Subject: [PATCH 26/82] feat: add password length validation and rate limiting for user registration --- app/controllers/registrations_controller.rb | 1 + app/models/user.rb | 5 ++-- app/policies/user_policy.rb | 3 +- spec/controllers/authorization_spec.rb | 13 +++++++-- spec/models/user_spec.rb | 17 +++++++++++ spec/policies/user_policy_spec.rb | 12 ++++++++ spec/requests/admin/users_spec.rb | 21 +++++++------- spec/requests/profiles_spec.rb | 21 +++++++------- spec/requests/registrations_spec.rb | 31 +++++++++++++++++---- 9 files changed, 89 insertions(+), 35 deletions(-) diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 70902e201..10925f93f 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -1,6 +1,7 @@ # app/controllers/registrations_controller.rb class RegistrationsController < ApplicationController allow_unauthenticated_access + rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_registration_path, alert: "Try again later." } before_action :redirect_if_authenticated def new diff --git a/app/models/user.rb b/app/models/user.rb index 2f392dedc..2faaf4039 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -11,13 +11,12 @@ class User < ApplicationRecord normalizes :full_name, with: ->(value) { value.to_s.squish } validates :full_name, presence: true, length: { in: 2..120 } - # `case_sensitive: false` would wrap the column in SQL LOWER(), which here applies - # to the *ciphertext* -- bypassing the unique index and comparing base64 case-blind. - # `normalizes` already downcases, so an exact match is both correct and index-backed. validates :email_address, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP } + + validates :password, length: { minimum: 8 }, allow_nil: true validates :avatar_url, format: { with: %r{\Ahttps://\S+\z} }, allow_blank: true diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index 7ff9123ec..09afd0181 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -9,11 +9,10 @@ def create? = user.admin? def update? = user.admin? || owner? def destroy? = user.admin? || owner? - # An admin must not be able to demote or delete themselves out of access. def toggle_role? = user.admin? && !owner? def permitted_attributes - user.admin? ? ADMIN_ATTRIBUTES : BASE_ATTRIBUTES + toggle_role? ? ADMIN_ATTRIBUTES : BASE_ATTRIBUTES end def scope diff --git a/spec/controllers/authorization_spec.rb b/spec/controllers/authorization_spec.rb index 9844839ad..da52970c1 100644 --- a/spec/controllers/authorization_spec.rb +++ b/spec/controllers/authorization_spec.rb @@ -152,15 +152,24 @@ def update expect(response.body).to eq("full_name") end - it "keeps role for an admin" do + it "keeps role for an admin acting on someone else" do admin = create(:user, :admin) allow(Current).to receive(:user).and_return(admin) - patch :update, params: { id: admin.id, user: { full_name: "Ada", role: "admin" } } + patch :update, params: { id: current_user.id, user: { full_name: "Ada", role: "admin" } } expect(response.body).to eq("full_name,role") end + it "drops role for an admin acting on themselves, so they cannot self-demote" do + admin = create(:user, :admin) + allow(Current).to receive(:user).and_return(admin) + + patch :update, params: { id: admin.id, user: { full_name: "Ada", role: "member" } } + + expect(response.body).to eq("full_name") + end + it "raises when the expected key is missing entirely" do expect { patch :update, params: { id: current_user.id } diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index b18d3fcaa..3e87767a5 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -161,6 +161,23 @@ def raw_email_column(record) ).to be_nil end + it "requires at least eight characters" do + record = build(:user, password: "short", password_confirmation: "short") + + expect(record).not_to be_valid + expect(record.errors[:password]).to include("is too short (minimum is 8 characters)") + end + + it "accepts exactly eight characters" do + expect(build(:user, password: "12345678", password_confirmation: "12345678")).to be_valid + end + + it "does not re-validate the password on an update that leaves it alone" do + record = create(:user, password: "secret123") + + expect(record.update(full_name: "Ada King")).to be(true) + end + it "stores a digest rather than the password" do record = create(:user, password: "secret123") diff --git a/spec/policies/user_policy_spec.rb b/spec/policies/user_policy_spec.rb index d4d366bbc..ac744d525 100644 --- a/spec/policies/user_policy_spec.rb +++ b/spec/policies/user_policy_spec.rb @@ -78,6 +78,18 @@ def policy_for(actor, record) = described_class.new(actor, record) expect(policy_for(admin, member).permitted_attributes).to include(:role) end + it "withholds role from an admin editing themselves, matching toggle_role?" do + attributes = policy_for(admin, admin).permitted_attributes + + expect(attributes).to eq(described_class::BASE_ATTRIBUTES) + expect(attributes).not_to include(:role) + expect(policy_for(admin, admin).toggle_role?).to be(false) + end + + it "grants role to an admin creating a user, who cannot be its owner" do + expect(policy_for(admin, User.new).permitted_attributes).to include(:role) + end + it "withholds role from a member editing themselves" do attributes = policy_for(member, member).permitted_attributes diff --git a/spec/requests/admin/users_spec.rb b/spec/requests/admin/users_spec.rb index 6330c2ed1..a54bf75f6 100644 --- a/spec/requests/admin/users_spec.rb +++ b/spec/requests/admin/users_spec.rb @@ -253,30 +253,29 @@ def props = inertia.props.deep_symbolize_keys expect(member.reload).to be_admin end - it "refuses to demote the last admin, which the model guards" do + it "rejects a request whose only field is one the policy withholds" do sign_in_as(admin) patch admin_user_path(admin), params: { user: { role: "member" } } + expect(response).to have_http_status(:bad_request) expect(admin.reload).to be_admin - expect(session[:inertia_errors][:role]) - .to eq([ "cannot change: at least one administrator is required" ]) end - # NOTE: `toggle_role?` exists so an admin cannot demote themselves out of - # access, but `update` never consults it -- it authorizes with `update?` and - # then permits `:role` because the actor is an admin. So self-demotion goes - # through whenever a second admin exists to satisfy the model's headcount - # validation. This pins the behaviour as it actually is; see the summary. - it "currently lets an admin demote themselves, bypassing toggle_role?" do + # `permitted_attributes` is keyed to `toggle_role?`, so an admin editing + # their own row never gets `:role` -- the self-demotion guard holds here as + # well as on the dedicated toggle route, even with a second admin present to + # satisfy the model's headcount validation. + it "ignores a role an admin tries to give themselves" do create(:user, :admin, email_address: "second-admin@example.com") sign_in_as(admin) expect(UserPolicy.new(admin, admin).toggle_role?).to be(false) - patch admin_user_path(admin), params: { user: { role: "member" } } + patch admin_user_path(admin), params: { user: { full_name: "Ada L.", role: "member" } } - expect(admin.reload).to be_member + expect(admin.reload).to be_admin + expect(admin.full_name).to eq("Ada L.") end it "ignores a role a member tries to give themselves" do diff --git a/spec/requests/profiles_spec.rb b/spec/requests/profiles_spec.rb index 846bf1b1a..dd0c95738 100644 --- a/spec/requests/profiles_spec.rb +++ b/spec/requests/profiles_spec.rb @@ -147,30 +147,29 @@ def props = inertia.props.deep_symbolize_keys expect(user.reload).to be_member end - # NOTE: `permitted_attributes` keys off whether the *actor* is an admin, and - # the actor here is always the record's owner. So an admin editing their own - # profile may assign `:role` -- self-demotion, which `toggle_role?` exists to - # prevent, goes through as long as a second admin satisfies the model's - # headcount validation. Pinned as it actually is; see the summary. - it "currently lets an admin demote themselves through their own profile" do + # The actor here is always the record's owner, so `toggle_role?` is false and + # `permitted_attributes` withholds `:role`. An admin cannot demote themselves + # through the member-facing profile route either, even with a second admin + # present to satisfy the model's headcount validation. + it "ignores a role an admin tries to give themselves" do admin = create(:user, :admin, email_address: "boss@example.com", password: "password") create(:user, :admin, email_address: "second@example.com") sign_in_as(admin) - patch profile_path, params: { user: { role: "member" } } + patch profile_path, params: { user: { full_name: "Ada King", role: "member" } } - expect(admin.reload).to be_member + expect(admin.reload).to be_admin + expect(admin.full_name).to eq("Ada King") end - it "still refuses to demote the last admin, which the model guards" do + it "rejects a request whose only field is one the policy withholds" do admin = create(:user, :admin, email_address: "boss@example.com", password: "password") sign_in_as(admin) patch profile_path, params: { user: { role: "member" } } + expect(response).to have_http_status(:bad_request) expect(admin.reload).to be_admin - expect(session[:inertia_errors][:role]) - .to eq([ "cannot change: at least one administrator is required" ]) end end diff --git a/spec/requests/registrations_spec.rb b/spec/requests/registrations_spec.rb index 7bccd443b..279426b90 100644 --- a/spec/requests/registrations_spec.rb +++ b/spec/requests/registrations_spec.rb @@ -111,18 +111,37 @@ expect(session[:inertia_errors][:password]).to include("can't be blank") end - # NOTE: `has_secure_password` only caps length at 72 bytes -- it has no - # minimum -- and the model adds no password length validation of its own, so - # a one-character password registers. Pinned as it actually is; see the - # summary. If a minimum is added, this example should flip to a rejection. - it "currently accepts a one-character password" do + it "rejects a password below the minimum length" do + expect { + post registration_path, params: valid_params.deep_merge( + user: { password: "short", password_confirmation: "short" } + ) + }.not_to change(User, :count) + + expect(session[:inertia_errors][:password]) + .to include("is too short (minimum is 8 characters)") + end + + it "accepts a password exactly at the minimum length" do post registration_path, params: valid_params.deep_merge( - user: { password: "a", password_confirmation: "a" } + user: { password: "12345678", password_confirmation: "12345678" } ) expect(User.find_by(email_address: "ada@example.com")).to be_present end + # The test environment runs a :null_store, whose `increment` always returns + # nil, so the throttle can never trip on its own here. Stubbing the count + # exercises the wiring: the configured limit and the `with:` handler. + it "turns away a flood of signups with the throttle message" do + allow(ActionController::Base.cache_store).to receive(:increment).and_return(11) + + expect { post registration_path, params: valid_params }.not_to change(User, :count) + + expect(response).to redirect_to(new_registration_path) + expect(flash[:alert]).to eq("Try again later.") + end + it "rejects a request with no user params at all" do expect { post registration_path, params: {} }.not_to change(User, :count) From 4e1e69f6038875429b7a01235cf9371c0222d837 Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Wed, 9 Sep 2026 13:27:20 -0300 Subject: [PATCH 27/82] feat: implement AppLayout with navigation and flash message handling --- app/javascript/layouts/AppLayout.tsx | 51 +++++++++++++++ app/javascript/pages/home/index.tsx | 5 +- app/javascript/types/index.ts | 23 ++++++- spec/system/app_layout_spec.rb | 98 ++++++++++++++++++++++++++++ 4 files changed, 173 insertions(+), 4 deletions(-) create mode 100644 app/javascript/layouts/AppLayout.tsx create mode 100644 spec/system/app_layout_spec.rb diff --git a/app/javascript/layouts/AppLayout.tsx b/app/javascript/layouts/AppLayout.tsx new file mode 100644 index 000000000..f7084dbfa --- /dev/null +++ b/app/javascript/layouts/AppLayout.tsx @@ -0,0 +1,51 @@ +import { Link, usePage } from '@inertiajs/react' +import { PropsWithChildren, useEffect, useState } from 'react' +import type { SharedProps } from '@/types' + +export default function AppLayout({ children }: PropsWithChildren) { + const { auth, flash } = usePage().props + const [banner, setBanner] = useState(flash.notice || flash.alert) + + useEffect(() => setBanner(flash.notice || flash.alert), [flash]) + + return ( +
+
+ +
+ + {banner && ( +
+ {banner} +
+ )} + +
{children}
+
+ ) +} diff --git a/app/javascript/pages/home/index.tsx b/app/javascript/pages/home/index.tsx index 6db0b0db7..5671d8c52 100644 --- a/app/javascript/pages/home/index.tsx +++ b/app/javascript/pages/home/index.tsx @@ -1,10 +1,13 @@ import { Head } from '@inertiajs/react' +import AppLayout from '@/layouts/AppLayout' export default function Home() { return ( <> -

Home

+

Home

) } + +Home.layout = AppLayout diff --git a/app/javascript/types/index.ts b/app/javascript/types/index.ts index 4a1370430..c5925da42 100644 --- a/app/javascript/types/index.ts +++ b/app/javascript/types/index.ts @@ -1,6 +1,23 @@ export type FlashData = { - notice?: string - alert?: string + notice: string | null + alert: string | null } -export type SharedProps = {} +export type UserRole = 'member' | 'admin' + +/** Mirrors the payload of UserSerializer#as_json. */ +export type User = { + id: number + full_name: string + email_address: string + role: UserRole + admin: boolean + avatar_url: string | null + created_at: string +} + +/** Shared with every Inertia response by ApplicationController#inertia_share. */ +export type SharedProps = { + auth: { user: User | null } + flash: FlashData +} diff --git a/spec/system/app_layout_spec.rb b/spec/system/app_layout_spec.rb new file mode 100644 index 000000000..b86821d20 --- /dev/null +++ b/spec/system/app_layout_spec.rb @@ -0,0 +1,98 @@ +require "rails_helper" + +# The layout only renders inside an Inertia page, so these run through the real +# browser. `home/index` is the one Inertia page that currently has a component +# to resolve, so it stands in for every screen that will inherit the layout. +RSpec.describe "The application layout", type: :system, js: true do + let(:member) { create(:user, full_name: "Grace Hopper", email_address: "grace@example.com") } + let(:admin) { create(:user, :admin, full_name: "Ada Lovelace", email_address: "ada@example.com") } + + # Polls a server-side condition the DOM gives no signal for. + def wait_until(timeout: 5) + deadline = Time.current + timeout + sleep(0.05) until yield || Time.current > deadline + yield + end + + def sign_in_through_the_form(user) + visit new_session_path + fill_in "email_address", with: user.email_address + fill_in "password", with: "password" + click_on "Sign in" + # `click_on` returns before the redirect lands, so wait it out rather than + # asserting against the sign-in page we are still standing on. + expect(page).to have_no_current_path(new_session_path, wait: 5) + end + + it "gives a member their own name and no admin links" do + sign_in_through_the_form(member) + visit root_path + + expect(page).to have_link("Grace Hopper", href: "/profile") + expect(page).to have_no_link("Dashboard") + expect(page).to have_no_link("Users") + end + + it "gives an admin the dashboard and users links" do + sign_in_through_the_form(admin) + visit root_path + + expect(page).to have_link("Dashboard", href: "/admin") + expect(page).to have_link("Users", href: "/admin/users") + expect(page).to have_link("Ada Lovelace", href: "/profile") + end + + # NOTE: `sessions#destroy` redirects to /session/new, which is an ERB page and + # carries no X-Inertia header. The nav's Link issues an Inertia XHR, and + # Inertia cannot swap in a non-Inertia response, so the request lands -- the + # session really is destroyed -- but the page never changes. To the user the + # button appears to do nothing, and the nav keeps showing them as signed in + # until they navigate. Pinned as it actually is; see the summary. + it "destroys the session when Sign out is clicked" do + sign_in_through_the_form(member) + visit root_path + + click_on "Sign out" + + expect(wait_until { member.sessions.reload.none? }).to be(true) + end + + it "leaves the nav showing the signed-in state until the next navigation" do + sign_in_through_the_form(member) + visit root_path + + click_on "Sign out" + wait_until { member.sessions.reload.none? } + + expect(page).to have_current_path(root_path) + expect(page).to have_link("Grace Hopper") + + visit root_path # only now does the browser learn it is signed out + + expect(page).to have_current_path(new_session_path) + end + + it "renders a flash alert in the banner" do + sign_in_through_the_form(member) + visit admin_users_path # denied, and bounced back to the home page + + expect(page).to have_current_path(root_path) + expect(page).to have_css("[role=status]", text: "You are not authorized to do that.") + end + + it "keeps the banner styled for an alert rather than a notice" do + sign_in_through_the_form(member) + visit admin_users_path + + expect(page).to have_css("[role=status].bg-red-50") + expect(page).to have_no_css("[role=status].bg-emerald-50") + end + + it "shows no banner on a plain page load" do + sign_in_through_the_form(member) + visit root_path + + expect(page).to have_css("h1", text: "Home") + expect(page).to have_no_css("[role=status]") + end +end From fcec61aec8c4619f39f8c51eaab4299d2717e091 Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Wed, 9 Sep 2026 13:33:58 -0300 Subject: [PATCH 28/82] feat: add user management components and types for filtering and sorting --- app/javascript/components/Avatar.tsx | 37 ++++ app/javascript/components/RoleBadge.tsx | 16 ++ app/javascript/pages/Admin/Users/Index.tsx | 197 +++++++++++++++++++++ app/javascript/types/index.ts | 17 ++ 4 files changed, 267 insertions(+) create mode 100644 app/javascript/components/Avatar.tsx create mode 100644 app/javascript/components/RoleBadge.tsx create mode 100644 app/javascript/pages/Admin/Users/Index.tsx diff --git a/app/javascript/components/Avatar.tsx b/app/javascript/components/Avatar.tsx new file mode 100644 index 000000000..6568b99b8 --- /dev/null +++ b/app/javascript/components/Avatar.tsx @@ -0,0 +1,37 @@ +import type { User } from '@/types' + +function initials(fullName: string) { + return fullName + .split(' ') + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase()) + .join('') +} + +type Props = { + user: User + size?: 'sm' | 'md' +} + +const SIZES = { + sm: 'h-8 w-8 text-xs', + md: 'h-10 w-10 text-sm', +} as const + +export default function Avatar({ user, size = 'sm' }: Props) { + const base = `${SIZES[size]} shrink-0 rounded-full object-cover` + + if (user.avatar_url) { + return + } + + return ( + + {initials(user.full_name)} + + ) +} diff --git a/app/javascript/components/RoleBadge.tsx b/app/javascript/components/RoleBadge.tsx new file mode 100644 index 000000000..1ed31ba37 --- /dev/null +++ b/app/javascript/components/RoleBadge.tsx @@ -0,0 +1,16 @@ +import type { UserRole } from '@/types' + +const STYLES: Record = { + admin: 'bg-indigo-50 text-indigo-700 ring-indigo-200', + member: 'bg-slate-100 text-slate-600 ring-slate-200', +} + +export default function RoleBadge({ role }: { role: UserRole }) { + return ( + + {role} + + ) +} diff --git a/app/javascript/pages/Admin/Users/Index.tsx b/app/javascript/pages/Admin/Users/Index.tsx new file mode 100644 index 000000000..8ed7fa6c9 --- /dev/null +++ b/app/javascript/pages/Admin/Users/Index.tsx @@ -0,0 +1,197 @@ +import { Head, Link, router, usePage } from '@inertiajs/react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import AppLayout from '@/layouts/AppLayout' +import Avatar from '@/components/Avatar' +import RoleBadge from '@/components/RoleBadge' +import type { Filters, SearchParams, SortKey, User, UserRole } from '@/types' + +const ONLY = ['users', 'filters'] as const + +const COLUMNS: { key: SortKey; label: string }[] = [ + { key: 'full_name', label: 'User' }, + { key: 'role', label: 'Role' }, + { key: 'created_at', label: 'Joined' }, +] + +const joinedAt = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }) + +/** Drops the read-only half of `filters` so pagination links carry only real query params. */ +function searchParams(filters: Filters): SearchParams { + const { query, sort, direction, role, page } = filters + return { query, sort, direction, role, page } +} + +export default function Index() { + const { users, filters } = usePage<{ users: User[]; filters: Filters }>().props + const { auth } = usePage().props + + const [query, setQuery] = useState(filters.query) + const params = useMemo(() => searchParams(filters), [filters]) + const isFirstRender = useRef(true) + + const visit = useCallback((overrides: Partial) => { + router.get('/admin/users', { ...params, page: 1, ...overrides }, { + only: [...ONLY], + preserveState: true, + preserveScroll: true, + replace: true, + }) + }, [params]) + + // Debounce the search box so typing doesn't fire a request per keystroke. + useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false + return + } + if (query === filters.query) return + + const timer = setTimeout(() => visit({ query }), 300) + return () => clearTimeout(timer) + }, [query, filters.query, visit]) + + const sortBy = useCallback((key: SortKey) => { + const direction = filters.sort === key && filters.direction === 'asc' ? 'desc' : 'asc' + visit({ sort: key, direction }) + }, [filters.sort, filters.direction, visit]) + + const toggleRole = useCallback((user: User) => { + router.patch(`/admin/users/${user.id}/role`, {}, { preserveScroll: true }) + }, []) + + const destroy = useCallback((user: User) => { + if (!window.confirm(`Delete ${user.full_name}? This cannot be undone.`)) return + router.delete(`/admin/users/${user.id}`, { preserveScroll: true }) + }, []) + + const ariaSort = (key: SortKey) => { + if (filters.sort !== key) return undefined + return filters.direction === 'asc' ? ('ascending' as const) : ('descending' as const) + } + + return ( + <> + + +
+

+ Users ({filters.total}) +

+ + New user + +
+ +
+ setQuery(event.target.value)} + placeholder="Search by name" + aria-label="Search users by name" + className="w-64 rounded-md border border-slate-300 px-3 py-2 text-sm" + /> + +
+ +
+ + + + {COLUMNS.map(({ key, label }) => ( + + ))} + + + + + + {users.map((user) => ( + + + + + + + + ))} + {users.length === 0 && ( + + + + )} + +
+ + EmailActions
+
+ + + {user.full_name} + +
+
+ + {user.email_address} +
+ {user.id !== auth.user?.id && ( + + )} + + Edit + + +
+
+ No users match those filters. +
+
+ + {filters.total_pages > 1 && ( + + )} + + ) +} + +Index.layout = AppLayout diff --git a/app/javascript/types/index.ts b/app/javascript/types/index.ts index c5925da42..e4e6803cf 100644 --- a/app/javascript/types/index.ts +++ b/app/javascript/types/index.ts @@ -21,3 +21,20 @@ export type SharedProps = { auth: { user: User | null } flash: FlashData } + +export type SortKey = 'full_name' | 'role' | 'created_at' +export type SortDirection = 'asc' | 'desc' + +/** Mirrors UserSearch#to_props. */ +export type Filters = { + query: string + sort: SortKey + direction: SortDirection + role: UserRole | null + page: number + total_pages: number + total: number +} + +/** The subset of Filters that UserSearch actually reads back off the query string. */ +export type SearchParams = Pick From 1eeb3841039a5379994b046b459269c7c13a2e4f Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Wed, 9 Sep 2026 13:44:41 -0300 Subject: [PATCH 29/82] feat: add Field and UserForm components for user input handling --- app/javascript/components/Field.tsx | 50 +++++++++ app/javascript/components/UserForm.tsx | 122 +++++++++++++++++++++ app/javascript/entrypoints/application.css | 6 + app/javascript/pages/Admin/Users/New.tsx | 28 +++++ 4 files changed, 206 insertions(+) create mode 100644 app/javascript/components/Field.tsx create mode 100644 app/javascript/components/UserForm.tsx create mode 100644 app/javascript/pages/Admin/Users/New.tsx diff --git a/app/javascript/components/Field.tsx b/app/javascript/components/Field.tsx new file mode 100644 index 000000000..245e54bfb --- /dev/null +++ b/app/javascript/components/Field.tsx @@ -0,0 +1,50 @@ +import { Children, cloneElement, useId, type ReactElement, type ReactNode } from 'react' + +type Props = { + label: string + /** Rails sends every error as an array of messages (see `errorValueType` in types/globals.d.ts). */ + error?: string[] | string + hint?: string + children: ReactNode +} + +type Control = ReactElement<{ + id?: string + 'aria-invalid'?: boolean + 'aria-describedby'?: string +}> + +export default function Field({ label, error, hint, children }: Props) { + const id = useId() + const messages = error === undefined ? [] : [error].flat() + const describedBy = [hint && `${id}-hint`, messages.length && `${id}-error`] + .filter(Boolean) + .join(' ') + + const control = cloneElement(Children.only(children) as Control, { + id, + 'aria-invalid': messages.length > 0 || undefined, + 'aria-describedby': describedBy || undefined, + }) + + return ( +
+ +
{control}
+ + {hint && ( +

+ {hint} +

+ )} + + {messages.length > 0 && ( +

+ {messages.join(', ')} +

+ )} +
+ ) +} diff --git a/app/javascript/components/UserForm.tsx b/app/javascript/components/UserForm.tsx new file mode 100644 index 000000000..b95253da1 --- /dev/null +++ b/app/javascript/components/UserForm.tsx @@ -0,0 +1,122 @@ +import { useForm } from '@inertiajs/react' +import { FormEvent } from 'react' +import Field from '@/components/Field' +import type { User, UserRole } from '@/types' + +type Props = { + user?: User + roles?: UserRole[] + action: string + method: 'post' | 'patch' + submitLabel: string +} + +export default function UserForm({ user, roles, action, method, submitLabel }: Props) { + const form = useForm({ + full_name: user?.full_name ?? '', + email_address: user?.email_address ?? '', + password: '', + password_confirmation: '', + avatar_url: user?.avatar_url ?? '', + avatar_image: null as File | null, + role: user?.role ?? ('member' as UserRole), + }) + const { data, setData, errors, processing, progress } = form + + const submit = (event: FormEvent) => { + event.preventDefault() + + // Inertia cannot send multipart over PATCH. Spoof the verb and force FormData. + form.transform((current) => (method === 'patch' ? { ...current, _method: 'patch' } : current)) + form.post(action, { forceFormData: true, preserveScroll: true }) + } + + return ( +
+ + setData('full_name', e.target.value)} + required + minLength={2} + maxLength={120} + className="input" + /> + + + + setData('email_address', e.target.value)} + required + className="input" + /> + + + + setData('password', e.target.value)} + autoComplete="new-password" + className="input" + /> + + + + setData('password_confirmation', e.target.value)} + autoComplete="new-password" + className="input" + /> + + + + setData('avatar_image', e.target.files?.[0] ?? null)} + className="text-sm" + /> + + + + setData('avatar_url', e.target.value)} + className="input" + /> + + + {roles && ( + + + + )} + + {progress && ( + + {progress.percentage}% + + )} + + +
+ ) +} diff --git a/app/javascript/entrypoints/application.css b/app/javascript/entrypoints/application.css index d93dbc6fa..062feb3f7 100644 --- a/app/javascript/entrypoints/application.css +++ b/app/javascript/entrypoints/application.css @@ -2,3 +2,9 @@ @plugin '@tailwindcss/typography'; @plugin '@tailwindcss/forms'; + +@utility input { + @apply w-full rounded-md border border-slate-300 px-3 py-2 text-sm + focus:border-slate-500 focus:ring-1 focus:ring-slate-500 + disabled:cursor-not-allowed disabled:bg-slate-50; +} diff --git a/app/javascript/pages/Admin/Users/New.tsx b/app/javascript/pages/Admin/Users/New.tsx new file mode 100644 index 000000000..2eb1c5120 --- /dev/null +++ b/app/javascript/pages/Admin/Users/New.tsx @@ -0,0 +1,28 @@ +import { Head, Link } from '@inertiajs/react' +import AppLayout from '@/layouts/AppLayout' +import UserForm from '@/components/UserForm' +import type { UserRole } from '@/types' + +/** Props from Admin::UsersController#new. */ +type Props = { roles: UserRole[] } + +export default function New({ roles }: Props) { + return ( + <> + + +
+

New user

+ + Back to users + +
+ +
+ +
+ + ) +} + +New.layout = AppLayout From ae772fffc659fb55a3657e1e48eb2f71a917b686 Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Wed, 9 Sep 2026 13:47:44 -0300 Subject: [PATCH 30/82] feat: add Edit page for user management with UserForm integration --- app/javascript/pages/Admin/Users/Edit.tsx | 34 +++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 app/javascript/pages/Admin/Users/Edit.tsx diff --git a/app/javascript/pages/Admin/Users/Edit.tsx b/app/javascript/pages/Admin/Users/Edit.tsx new file mode 100644 index 000000000..8ab535183 --- /dev/null +++ b/app/javascript/pages/Admin/Users/Edit.tsx @@ -0,0 +1,34 @@ +import { Head, Link } from '@inertiajs/react' +import AppLayout from '@/layouts/AppLayout' +import UserForm from '@/components/UserForm' +import type { User, UserRole } from '@/types' + +/** Props from Admin::UsersController#edit. */ +type Props = { user: User; roles: UserRole[] } + +export default function Edit({ user, roles }: Props) { + return ( + <> + + +
+

Edit {user.full_name}

+ + Back to users + +
+ +
+ +
+ + ) +} + +Edit.layout = AppLayout From b2b7846de43bb2db2eb8fab36c0491ec0f22ac62 Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Wed, 9 Sep 2026 13:49:09 -0300 Subject: [PATCH 31/82] feat: add ProfileEdit page with user account management features --- app/javascript/pages/Profile/Edit.tsx | 41 +++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 app/javascript/pages/Profile/Edit.tsx diff --git a/app/javascript/pages/Profile/Edit.tsx b/app/javascript/pages/Profile/Edit.tsx new file mode 100644 index 000000000..6f6071fbc --- /dev/null +++ b/app/javascript/pages/Profile/Edit.tsx @@ -0,0 +1,41 @@ +import { Head, Link, router } from '@inertiajs/react' +import AppLayout from '@/layouts/AppLayout' +import UserForm from '@/components/UserForm' +import type { User } from '@/types' + +/** Props from ProfilesController#edit. */ +type Props = { user: User } + +export default function ProfileEdit({ user }: Props) { + const destroy = () => { + if (!window.confirm('Delete your account permanently? This cannot be undone.')) return + router.delete('/profile') + } + + return ( + <> + + +
+

Your profile

+ + Back to profile + +
+ + {/* + No `roles` prop: members cannot see or submit a role field. UserPolicy#permitted_attributes + drops `role` for owners regardless, so this is presentation, not the enforcement point. + */} +
+ +
+ + + + ) +} + +ProfileEdit.layout = AppLayout From 285b0e0ec920ad104122a29f8f0a7a54a00d88a9 Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Wed, 9 Sep 2026 13:58:03 -0300 Subject: [PATCH 32/82] feat: specify Ruby version 4.0.6 in Gemfile and Gemfile.lock --- Gemfile | 2 ++ Gemfile.lock | 3 +++ 2 files changed, 5 insertions(+) diff --git a/Gemfile b/Gemfile index 70cb1bc35..816324f2e 100644 --- a/Gemfile +++ b/Gemfile @@ -1,5 +1,7 @@ source "https://rubygems.org" +ruby "4.0.6" + # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" gem "rails", "~> 8.1.3", ">= 8.1.3.1" gem "json", "~> 2.9" diff --git a/Gemfile.lock b/Gemfile.lock index 3b03fae7c..114e9a21f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -682,5 +682,8 @@ CHECKSUMS xpath (3.2.0) sha256=6dfda79d91bb3b949b947ecc5919f042ef2f399b904013eb3ef6d20dd3a4082e zeitwerk (2.8.3) sha256=2c85125a8467ce069e20123d1e709a08955c9d29c118c25b46b7b7fafdbb92e5 +RUBY VERSION + ruby 4.0.6 + BUNDLED WITH 4.0.16 From 757307860f6e0786313106c76e46095062286356 Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Wed, 9 Sep 2026 13:59:19 -0300 Subject: [PATCH 33/82] feat: add user detail pages with profile and admin views --- app/controllers/sessions_controller.rb | 3 ++ app/javascript/pages/Admin/Users/Show.tsx | 65 +++++++++++++++++++++++ app/javascript/pages/Profile/Show.tsx | 51 ++++++++++++++++++ spec/requests/sessions_spec.rb | 10 ++++ spec/system/app_layout_spec.rb | 28 ++-------- spec/system/user_pages_spec.rb | 58 ++++++++++++++++++++ 6 files changed, 191 insertions(+), 24 deletions(-) create mode 100644 app/javascript/pages/Admin/Users/Show.tsx create mode 100644 app/javascript/pages/Profile/Show.tsx create mode 100644 spec/system/user_pages_spec.rb diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index c525cd575..43fa7040b 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -16,6 +16,9 @@ def create def destroy terminate_session + + return inertia_location(new_session_path) if request.inertia? + redirect_to new_session_path, status: :see_other end end diff --git a/app/javascript/pages/Admin/Users/Show.tsx b/app/javascript/pages/Admin/Users/Show.tsx new file mode 100644 index 000000000..404690563 --- /dev/null +++ b/app/javascript/pages/Admin/Users/Show.tsx @@ -0,0 +1,65 @@ +import { Head, Link, router } from '@inertiajs/react' +import AppLayout from '@/layouts/AppLayout' +import Avatar from '@/components/Avatar' +import RoleBadge from '@/components/RoleBadge' +import type { User } from '@/types' + +/** Props from Admin::UsersController#show. */ +type Props = { user: User } + +const joinedAt = new Intl.DateTimeFormat(undefined, { dateStyle: 'long' }) + +export default function Show({ user }: Props) { + const destroy = () => { + if (!window.confirm(`Delete ${user.full_name}? This cannot be undone.`)) return + router.delete(`/admin/users/${user.id}`) + } + + return ( + <> + + +
+
+ +

{user.full_name}

+ +
+ + Back to users + +
+ +
+
+
Email
+
{user.email_address}
+
+
+
Role
+
{user.role}
+
+
+
Joined
+
+ +
+
+
+ +
+ + Edit user + + +
+ + ) +} + +Show.layout = AppLayout diff --git a/app/javascript/pages/Profile/Show.tsx b/app/javascript/pages/Profile/Show.tsx new file mode 100644 index 000000000..28265a278 --- /dev/null +++ b/app/javascript/pages/Profile/Show.tsx @@ -0,0 +1,51 @@ +import { Head, Link } from '@inertiajs/react' +import AppLayout from '@/layouts/AppLayout' +import Avatar from '@/components/Avatar' +import RoleBadge from '@/components/RoleBadge' +import type { User } from '@/types' + +/** Props from ProfilesController#show. */ +type Props = { user: User } + +const joinedAt = new Intl.DateTimeFormat(undefined, { dateStyle: 'long' }) + +export default function ProfileShow({ user }: Props) { + return ( + <> + + +
+
+ +

{user.full_name}

+ +
+ + Edit profile + +
+ +
+
+
Email
+
{user.email_address}
+
+
+
Role
+
{user.role}
+
+
+
Joined
+
+ +
+
+
+ + ) +} + +ProfileShow.layout = AppLayout diff --git a/spec/requests/sessions_spec.rb b/spec/requests/sessions_spec.rb index 105750041..5f9d5e376 100644 --- a/spec/requests/sessions_spec.rb +++ b/spec/requests/sessions_spec.rb @@ -82,6 +82,16 @@ expect(user.sessions).to be_empty end + it "answers an Inertia request with a location visit rather than a redirect" do + sign_in_as(user) + + delete session_url, headers: { "X-Inertia" => "true" } + + expect(response).to have_http_status(:conflict) + expect(response.headers["X-Inertia-Location"]).to eq(new_session_path) + expect(user.sessions).to be_empty + end + it "requires authentication" do delete session_url diff --git a/spec/system/app_layout_spec.rb b/spec/system/app_layout_spec.rb index b86821d20..4e6f1b2ba 100644 --- a/spec/system/app_layout_spec.rb +++ b/spec/system/app_layout_spec.rb @@ -1,19 +1,9 @@ require "rails_helper" -# The layout only renders inside an Inertia page, so these run through the real -# browser. `home/index` is the one Inertia page that currently has a component -# to resolve, so it stands in for every screen that will inherit the layout. RSpec.describe "The application layout", type: :system, js: true do let(:member) { create(:user, full_name: "Grace Hopper", email_address: "grace@example.com") } let(:admin) { create(:user, :admin, full_name: "Ada Lovelace", email_address: "ada@example.com") } - # Polls a server-side condition the DOM gives no signal for. - def wait_until(timeout: 5) - deadline = Time.current + timeout - sleep(0.05) until yield || Time.current > deadline - yield - end - def sign_in_through_the_form(user) visit new_session_path fill_in "email_address", with: user.email_address @@ -42,34 +32,24 @@ def sign_in_through_the_form(user) expect(page).to have_link("Ada Lovelace", href: "/profile") end - # NOTE: `sessions#destroy` redirects to /session/new, which is an ERB page and - # carries no X-Inertia header. The nav's Link issues an Inertia XHR, and - # Inertia cannot swap in a non-Inertia response, so the request lands -- the - # session really is destroyed -- but the page never changes. To the user the - # button appears to do nothing, and the nav keeps showing them as signed in - # until they navigate. Pinned as it actually is; see the summary. it "destroys the session when Sign out is clicked" do sign_in_through_the_form(member) visit root_path click_on "Sign out" - expect(wait_until { member.sessions.reload.none? }).to be(true) + expect(page).to have_current_path(new_session_path) + expect(member.sessions.reload).to be_empty end - it "leaves the nav showing the signed-in state until the next navigation" do + it "leaves the nav showing no signed-in user after signing out" do sign_in_through_the_form(member) visit root_path click_on "Sign out" - wait_until { member.sessions.reload.none? } - - expect(page).to have_current_path(root_path) - expect(page).to have_link("Grace Hopper") - - visit root_path # only now does the browser learn it is signed out expect(page).to have_current_path(new_session_path) + expect(page).to have_no_link("Grace Hopper") end it "renders a flash alert in the banner" do diff --git a/spec/system/user_pages_spec.rb b/spec/system/user_pages_spec.rb new file mode 100644 index 000000000..11586ad35 --- /dev/null +++ b/spec/system/user_pages_spec.rb @@ -0,0 +1,58 @@ +require "rails_helper" + +RSpec.describe "The user detail pages", type: :system, js: true do + let(:admin) { create(:user, :admin, full_name: "Ada Lovelace", email_address: "ada@example.com") } + let(:member) { create(:user, full_name: "Grace Hopper", email_address: "grace@example.com") } + + def sign_in_through_the_form(user) + visit new_session_path + fill_in "email_address", with: user.email_address + fill_in "password", with: "password" + click_on "Sign in" + expect(page).to have_no_current_path(new_session_path, wait: 5) + end + + describe "an admin looking at a user" do + it "renders the user's details" do + target = member + sign_in_through_the_form(admin) + visit admin_user_path(target) + + expect(page).to have_css("h1", text: "Grace Hopper") + expect(page).to have_text("grace@example.com") + expect(page).to have_link("Edit user", href: "/admin/users/#{target.id}/edit") + end + + it "is reachable by clicking a name in the users table" do + target = member + sign_in_through_the_form(admin) + visit admin_users_path + + click_on "Grace Hopper" + + expect(page).to have_current_path(admin_user_path(target)) + expect(page).to have_css("h1", text: "Grace Hopper") + end + end + + describe "a member looking at their own profile" do + it "renders their details and a route to editing them" do + sign_in_through_the_form(member) + visit profile_path + + expect(page).to have_css("h1", text: "Grace Hopper") + expect(page).to have_text("grace@example.com") + expect(page).to have_link("Edit profile", href: "/profile/edit") + end + + it "is reachable from the nav" do + sign_in_through_the_form(member) + visit root_path + + click_on "Grace Hopper" + + expect(page).to have_current_path(profile_path) + expect(page).to have_css("h1", text: "Grace Hopper") + end + end +end From c57529978acd44880f99be2851eedaf1e37f2809 Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Wed, 9 Sep 2026 14:08:34 -0300 Subject: [PATCH 34/82] feat: update CI configuration, enhance test setup, and upgrade dependencies --- .github/workflows/ci.yml | 73 ++++++++++++++++++- Gemfile | 2 +- Gemfile.lock | 15 ++-- app/controllers/inertia_example_controller.rb | 2 +- config/ci.rb | 2 + config/environments/test.rb | 12 +++ .../initializers/content_security_policy.rb | 6 +- package-lock.json | 48 ++++++++++++ package.json | 1 + 9 files changed, 148 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d58c2aa4c..045a951a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -39,6 +39,75 @@ jobs: - name: Scan for security vulnerabilities in JavaScript dependencies run: bin/importmap audit + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + env: + RAILS_ENV: test + DATABASE_URL: postgres://postgres:postgres@localhost:5432 + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + # ruby-vips loads libvips through FFI at boot, so without it the whole + # suite dies on require rather than on the first image assertion. + - name: Install libvips + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libvips + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Set up Node + uses: actions/setup-node@v6 + with: + node-version: 20 + cache: npm + + - name: Install JavaScript dependencies + run: npm ci + + # The system specs drive a real browser through capybara-playwright-driver. + # The version comes from package.json so it stays in step with the + # playwright-ruby-client gem, which pins the protocol it speaks. + - name: Install Playwright browser + run: npx playwright install --with-deps chromium + + - name: Prepare the test database + run: bin/rails db:test:prepare + + # Vite would build on demand inside the first system spec; doing it here + # keeps that cost out of the example and off the Capybara wait budget. + - name: Build frontend assets + run: bin/vite build + + - name: Run tests + run: bundle exec rspec + + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage + path: coverage + if-no-files-found: ignore + lint: runs-on: ubuntu-latest env: diff --git a/Gemfile b/Gemfile index 816324f2e..0e88031e2 100644 --- a/Gemfile +++ b/Gemfile @@ -46,7 +46,7 @@ gem "kamal", require: false gem "thruster", require: false # Spreadsheet parsing (xlsx) [https://github.com/roo-rb/roo] -gem "roo", "~> 2.10" +gem "roo", "~> 3.0" # roo requires csv at runtime but does not declare it; no longer a default gem on Ruby 4.0 gem "csv" diff --git a/Gemfile.lock b/Gemfile.lock index 114e9a21f..003bd8656 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -317,9 +317,12 @@ GEM reline (0.7.0) io-console (~> 0.5) rexml (3.4.4) - roo (2.10.1) + roo (3.0.0) + base64 (~> 0.2) + csv (~> 3) + logger (~> 1) nokogiri (~> 1) - rubyzip (>= 1.3.0, < 3.0.0) + rubyzip (>= 3.0.0, < 4.0.0) rspec-core (3.13.6) rspec-support (~> 3.13.0) rspec-expectations (3.13.5) @@ -369,7 +372,7 @@ GEM ruby-vips (2.3.0) ffi (~> 1.12) logger - rubyzip (2.4.1) + rubyzip (3.6.0) securerandom (0.4.1) selenium-webdriver (4.48.0) base64 (~> 0.2) @@ -487,7 +490,7 @@ DEPENDENCIES propshaft puma (>= 5.0) rails (~> 8.1.3, >= 8.1.3.1) - roo (~> 2.10) + roo (~> 3.0) rspec-rails (~> 8.0) rubocop-rails-omakase selenium-webdriver @@ -630,7 +633,7 @@ CHECKSUMS regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 - roo (2.10.1) sha256=cbb43bc955f9c110e74b721c835fb9bd3515b63af88ec709ac87fbf30f8be70e + 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 @@ -643,7 +646,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 (2.4.1) sha256=8577c88edc1fde8935eb91064c5cb1aef9ad5494b940cf19c775ee833e075615 + rubyzip (3.6.0) sha256=268994d44d62282d1cfd99bf10eae48d7267199158ad7ea3e1fee2da9458b695 securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 selenium-webdriver (4.48.0) sha256=0c8376ebc8a0a4879343fe6fe6eccdcea76748611cd25de370b33eded2077a94 shoulda-matchers (6.5.0) sha256=ef6b572b2bed1ac4aba6ab2c5ff345a24b6d055a93a3d1c3bfc86d9d499e3f44 diff --git a/app/controllers/inertia_example_controller.rb b/app/controllers/inertia_example_controller.rb index 3792b4df1..7b72c3a51 100644 --- a/app/controllers/inertia_example_controller.rb +++ b/app/controllers/inertia_example_controller.rb @@ -6,7 +6,7 @@ def index rails_version: Rails.version, ruby_version: RUBY_DESCRIPTION, rack_version: Rack.release, - inertia_rails_version: InertiaRails::VERSION, + inertia_rails_version: InertiaRails::VERSION } end end diff --git a/config/ci.rb b/config/ci.rb index 239b34398..8db391e2a 100644 --- a/config/ci.rb +++ b/config/ci.rb @@ -5,6 +5,8 @@ step "Style: Ruby", "bin/rubocop" + step "Tests: RSpec", "bin/rails db:test:prepare && bundle exec rspec" + 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" diff --git a/config/environments/test.rb b/config/environments/test.rb index c2095b117..dbc9d83b9 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -50,4 +50,16 @@ # Raise error when a before_action's only/except options reference missing actions. config.action_controller.raise_on_missing_callback_actions = true + + # `User#email_address` is encrypted, so the test environment needs encryption keys + # before any user can be built. Reading them from credentials would make the suite + # depend on config/master.key, which is gitignored -- CI and a fresh clone have no + # way to supply it. These keys guard nothing: the data they encrypt is generated + # and dropped by the suite itself. Never reuse them outside the test environment. + config.active_record.encryption.primary_key = + ENV.fetch("AR_ENCRYPTION_PRIMARY_KEY", "test_primary_key_not_for_real_data_00") + config.active_record.encryption.deterministic_key = + ENV.fetch("AR_ENCRYPTION_DETERMINISTIC_KEY", "test_deterministic_key_not_for_real_0") + config.active_record.encryption.key_derivation_salt = + ENV.fetch("AR_ENCRYPTION_SALT", "test_key_derivation_salt_not_for_real") end diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index 94221e688..b058ea6e7 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -11,14 +11,14 @@ # policy.img_src :self, :https, :data # policy.object_src :none # policy.script_src :self, :https - # Allow @vite/client to hot reload javascript changes in development +# Allow @vite/client to hot reload javascript changes in development # policy.script_src *policy.script_src, :unsafe_eval, "http://#{ ViteRuby.config.host_with_port }" if Rails.env.development? - # You may need to enable this in production as well depending on your setup. +# You may need to enable this in production as well depending on your setup. # policy.script_src *policy.script_src, :blob if Rails.env.test? # policy.style_src :self, :https - # Allow @vite/client to hot reload style changes in development +# Allow @vite/client to hot reload style changes in development # policy.style_src *policy.style_src, :unsafe_inline if Rails.env.development? # # Specify URI for violation reports diff --git a/package-lock.json b/package-lock.json index b16b004b2..af036a65e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@inertiajs/core": "^3.7.0", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.7", + "playwright": "1.62.1", "typescript": "^7.0.2", "vite": "^8.2.2", "vite-plugin-ruby": "^5.2.3" @@ -1723,6 +1724,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.28", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", diff --git a/package.json b/package.json index f81693c8f..b22741a88 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "@inertiajs/core": "^3.7.0", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.7", + "playwright": "1.62.1", "typescript": "^7.0.2", "vite": "^8.2.2", "vite-plugin-ruby": "^5.2.3" From 4a23c2ad44660798dd42e72de8bab31ad47a1e44 Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Wed, 9 Sep 2026 14:12:34 -0300 Subject: [PATCH 35/82] feat: implement dashboard stats query and corresponding tests --- app/queries/dashboard/stats.rb | 28 ++++++++ spec/queries/dashboard/stats_spec.rb | 96 ++++++++++++++++++++++++++++ spec/rails_helper.rb | 4 ++ 3 files changed, 128 insertions(+) create mode 100644 app/queries/dashboard/stats.rb create mode 100644 spec/queries/dashboard/stats_spec.rb diff --git a/app/queries/dashboard/stats.rb b/app/queries/dashboard/stats.rb new file mode 100644 index 000000000..006681b9c --- /dev/null +++ b/app/queries/dashboard/stats.rb @@ -0,0 +1,28 @@ +# app/queries/dashboard/stats.rb +module Dashboard + class Stats + CACHE_KEY = "dashboard/stats" + CACHE_TTL = 5.seconds + + def self.current = new.to_h + def self.expire = Rails.cache.delete(CACHE_KEY) + + def to_h + Rails.cache.fetch(CACHE_KEY, expires_in: CACHE_TTL) { compute } + end + + private + + # `group(:role).count` keys on the enum label, so the keys line up with + # `User.roles.keys` without any casting. + def compute + counts = User.group(:role).count + + { + total: counts.values.sum, + by_role: User.roles.keys.index_with { |role| counts.fetch(role, 0) }, + generated_at: Time.current.iso8601 + } + end + end +end diff --git a/spec/queries/dashboard/stats_spec.rb b/spec/queries/dashboard/stats_spec.rb new file mode 100644 index 000000000..1f82e4021 --- /dev/null +++ b/spec/queries/dashboard/stats_spec.rb @@ -0,0 +1,96 @@ +require "rails_helper" + +RSpec.describe Dashboard::Stats do + describe "the computed figures" do + it "counts every user regardless of role" do + create_list(:user, 2) + create(:user, :admin) + + expect(described_class.current[:total]).to eq(3) + end + + it "breaks the count down by role" do + create_list(:user, 2) + create(:user, :admin) + + expect(described_class.current[:by_role]).to eq("member" => 2, "admin" => 1) + end + + # `group(:role).count` omits roles nobody holds, so a dashboard reading + # `by_role["admin"]` would get nil rather than 0 without the `index_with`. + it "reports a role with no users as zero rather than omitting it" do + create(:user) + + expect(described_class.current[:by_role]).to eq("member" => 1, "admin" => 0) + end + + it "still answers with every role when there are no users at all" do + expect(described_class.current).to include(total: 0, by_role: { "member" => 0, "admin" => 0 }) + end + + it "stamps the result with an iso8601 time" do + generated_at = described_class.current[:generated_at] + + expect { Time.iso8601(generated_at) }.not_to raise_error + end + end + + # The test environment runs on :null_store, which never retains anything -- + # `fetch` would yield on every call and the caching would look broken. These + # examples swap in a real store so the caching itself is what is under test. + describe "caching" do + around do |example| + original = Rails.cache + Rails.cache = ActiveSupport::Cache::MemoryStore.new + example.run + ensure + Rails.cache = original + end + + it "serves a cached copy rather than recounting" do + create(:user) + described_class.current + + create(:user, :admin) + + expect(described_class.current[:total]).to eq(1) + end + + # Advancing the clock is what gives this teeth: `generated_at` is only + # second-precise, so two back-to-back calls match whether or not anything + # was cached. Two seconds in, still inside the TTL, a recompute would show. + it "hands back the same generated_at while the entry is warm" do + first = described_class.current[:generated_at] + + travel(2.seconds) do + expect(described_class.current[:generated_at]).to eq(first) + end + end + + it "recounts once the entry has expired" do + create(:user) + described_class.current + + create(:user, :admin) + travel(described_class::CACHE_TTL + 1.second) do + expect(described_class.current[:total]).to eq(2) + end + end + + describe ".expire" do + it "drops the entry so the next read recounts" do + create(:user) + described_class.current + + create(:user, :admin) + described_class.expire + + expect(described_class.current[:total]).to eq(2) + end + + it "is harmless when nothing has been cached yet" do + expect { described_class.expire }.not_to raise_error + end + end + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index cfe69268a..eab5f8fb4 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -60,6 +60,10 @@ # Use `create(...)` / `build(...)` directly instead of `FactoryBot.create(...)`. config.include FactoryBot::Syntax::Methods + # `travel`, `travel_to` and `freeze_time`, for specs that turn on cache expiry + # or timestamps. Each example's clock is unstubbed again on the way out. + config.include ActiveSupport::Testing::TimeHelpers + # 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. From 043c25a8a7fcaf30e3f6e704e465e649e9f9e089 Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Wed, 9 Sep 2026 19:17:24 -0300 Subject: [PATCH 36/82] feat: implement dashboard stats broadcasting and related tests --- app/broadcasters/dashboard/broadcaster.rb | 34 +++++++++ app/channels/application_cable/channel.rb | 4 ++ app/channels/dashboard/stats_channel.rb | 10 +++ app/jobs/dashboard/broadcast_job.rb | 6 ++ app/models/user.rb | 11 +++ .../dashboard/broadcaster_spec.rb | 72 +++++++++++++++++++ spec/channels/dashboard/stats_channel_spec.rb | 29 ++++++++ spec/jobs/dashboard/broadcast_job_spec.rb | 9 +++ spec/models/user_spec.rb | 35 +++++++++ spec/queries/dashboard/stats_spec.rb | 18 ++--- spec/support/cache_helpers.rb | 17 +++++ 11 files changed, 234 insertions(+), 11 deletions(-) create mode 100644 app/broadcasters/dashboard/broadcaster.rb create mode 100644 app/channels/application_cable/channel.rb create mode 100644 app/channels/dashboard/stats_channel.rb create mode 100644 app/jobs/dashboard/broadcast_job.rb create mode 100644 spec/broadcasters/dashboard/broadcaster_spec.rb create mode 100644 spec/channels/dashboard/stats_channel_spec.rb create mode 100644 spec/jobs/dashboard/broadcast_job_spec.rb create mode 100644 spec/support/cache_helpers.rb diff --git a/app/broadcasters/dashboard/broadcaster.rb b/app/broadcasters/dashboard/broadcaster.rb new file mode 100644 index 000000000..dfa1ec84b --- /dev/null +++ b/app/broadcasters/dashboard/broadcaster.rb @@ -0,0 +1,34 @@ +# app/broadcasters/dashboard/broadcaster.rb +module Dashboard + class Broadcaster + STREAM = "dashboard:stats" + LEADING_KEY = "dashboard/stats/leading" + TRAILING_KEY = "dashboard/stats/trailing" + WINDOW = 1.second + + class << self + def call + Stats.expire + claim(LEADING_KEY) ? broadcast : schedule_trailing + end + + def broadcast + ActionCable.server.broadcast(STREAM, { type: "stats.changed" }) + end + + private + + def schedule_trailing + return unless claim(TRAILING_KEY) + + Dashboard::BroadcastJob.set(wait: WINDOW).perform_later + end + + # `unless_exist` makes this an atomic claim: the first caller in the window + # gets true, everyone after it gets false until the key expires. + def claim(key) + Rails.cache.write(key, true, expires_in: WINDOW, unless_exist: true) + end + end + end +end diff --git a/app/channels/application_cable/channel.rb b/app/channels/application_cable/channel.rb new file mode 100644 index 000000000..d67269728 --- /dev/null +++ b/app/channels/application_cable/channel.rb @@ -0,0 +1,4 @@ +module ApplicationCable + class Channel < ActionCable::Channel::Base + end +end diff --git a/app/channels/dashboard/stats_channel.rb b/app/channels/dashboard/stats_channel.rb new file mode 100644 index 000000000..2102b0e5f --- /dev/null +++ b/app/channels/dashboard/stats_channel.rb @@ -0,0 +1,10 @@ +# app/channels/dashboard/stats_channel.rb +module Dashboard + class StatsChannel < ApplicationCable::Channel + def subscribed + return reject unless current_user.admin? + + stream_from Broadcaster::STREAM + end + end +end diff --git a/app/jobs/dashboard/broadcast_job.rb b/app/jobs/dashboard/broadcast_job.rb new file mode 100644 index 000000000..0246c6797 --- /dev/null +++ b/app/jobs/dashboard/broadcast_job.rb @@ -0,0 +1,6 @@ +# app/jobs/dashboard/broadcast_job.rb +module Dashboard + class BroadcastJob < ApplicationJob + def perform = Broadcaster.broadcast + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 2faaf4039..bd442ba07 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -28,6 +28,9 @@ class User < ApplicationRecord before_destroy :ensure_not_last_admin, prepend: true validate :admin_headcount_preserved, on: :update + after_commit :refresh_dashboard_stats, on: %i[ create destroy ] + after_commit :refresh_dashboard_stats_on_role_change, on: :update + def avatar_source return avatar_image if avatar_image.attached? avatar_url.presence @@ -35,6 +38,14 @@ def avatar_source private + def refresh_dashboard_stats + Dashboard::Broadcaster.call + end + + def refresh_dashboard_stats_on_role_change + refresh_dashboard_stats if saved_change_to_role? + end + def ensure_not_last_admin return unless admin? && User.admin.count <= 1 diff --git a/spec/broadcasters/dashboard/broadcaster_spec.rb b/spec/broadcasters/dashboard/broadcaster_spec.rb new file mode 100644 index 000000000..b58e06e4e --- /dev/null +++ b/spec/broadcasters/dashboard/broadcaster_spec.rb @@ -0,0 +1,72 @@ +require "rails_helper" + +# Every example needs a real cache store: the leading/trailing claims are +# `unless_exist` writes, and :null_store reports every write as a fresh claim, +# which would make the debounce look like it works while doing nothing. +RSpec.describe Dashboard::Broadcaster, :cache do + describe ".call" do + # Asserting on the cache entry rather than on a recount: creating a user + # would expire the entry through User's own after_commit hook, so a count + # that came out fresh would prove nothing about this call. + it "drops the cached stats so the next read recounts" do + Dashboard::Stats.current + + described_class.call + + expect(Rails.cache.read(Dashboard::Stats::CACHE_KEY)).to be_nil + end + + it "broadcasts straight away on the leading edge" do + expect { described_class.call } + .to have_broadcasted_to(described_class::STREAM) + .with(type: "stats.changed") + end + + it "does not enqueue a trailing job for the leading call" do + expect { described_class.call }.not_to have_enqueued_job(Dashboard::BroadcastJob) + end + + context "when a second change lands inside the window" do + before { described_class.call } + + it "does not broadcast again immediately" do + expect { described_class.call }.not_to have_broadcasted_to(described_class::STREAM) + end + + it "schedules the trailing broadcast instead" do + expect { described_class.call } + .to have_enqueued_job(Dashboard::BroadcastJob) + .at(a_value_within(1.second).of(described_class::WINDOW.from_now)) + end + + # The point of the trailing key: a burst of changes collapses into one + # scheduled broadcast rather than one per change. + it "schedules only one trailing job however many changes arrive" do + expect { 5.times { described_class.call } } + .to have_enqueued_job(Dashboard::BroadcastJob).exactly(:once) + end + end + + it "broadcasts on the leading edge again once the window has passed" do + described_class.call + + travel(described_class::WINDOW + 1.second) do + expect { described_class.call }.to have_broadcasted_to(described_class::STREAM) + end + end + end + + describe ".broadcast" do + it "publishes the stats.changed payload on the dashboard stream" do + expect { described_class.broadcast } + .to have_broadcasted_to(described_class::STREAM) + .with(type: "stats.changed") + end + + it "does not claim the window, so it can be called by the trailing job" do + described_class.broadcast + + expect { described_class.call }.to have_broadcasted_to(described_class::STREAM) + end + end +end diff --git a/spec/channels/dashboard/stats_channel_spec.rb b/spec/channels/dashboard/stats_channel_spec.rb new file mode 100644 index 000000000..a2dd1fb64 --- /dev/null +++ b/spec/channels/dashboard/stats_channel_spec.rb @@ -0,0 +1,29 @@ +require "rails_helper" + +RSpec.describe Dashboard::StatsChannel, type: :channel do + it "subscribes an admin to the dashboard stream" do + stub_connection current_user: create(:user, :admin) + + subscribe + + expect(subscription).to be_confirmed + expect(subscription).to have_stream_from(Dashboard::Broadcaster::STREAM) + end + + it "turns a member away rather than leaking the counts" do + stub_connection current_user: create(:user) + + subscribe + + expect(subscription).to be_rejected + end + + it "delivers the broadcaster's payload to a subscribed admin" do + stub_connection current_user: create(:user, :admin) + subscribe + + expect { Dashboard::Broadcaster.broadcast } + .to have_broadcasted_to(Dashboard::Broadcaster::STREAM) + .with(type: "stats.changed") + end +end diff --git a/spec/jobs/dashboard/broadcast_job_spec.rb b/spec/jobs/dashboard/broadcast_job_spec.rb new file mode 100644 index 000000000..a8c9ff2b6 --- /dev/null +++ b/spec/jobs/dashboard/broadcast_job_spec.rb @@ -0,0 +1,9 @@ +require "rails_helper" + +RSpec.describe Dashboard::BroadcastJob do + it "broadcasts the stats.changed payload when performed" do + expect { described_class.perform_now } + .to have_broadcasted_to(Dashboard::Broadcaster::STREAM) + .with(type: "stats.changed") + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 3e87767a5..df5303e57 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -335,4 +335,39 @@ def raw_email_column(record) expect(build(:user).save).to be(true) end end + + # The dashboard's counts come straight from these records, so anything that + # moves them has to reach Dashboard::Broadcaster. + describe "notifying the dashboard" do + let(:stream) { Dashboard::Broadcaster::STREAM } + + it "broadcasts when a user is created" do + expect { create(:user) }.to have_broadcasted_to(stream).with(type: "stats.changed") + end + + it "broadcasts when a user is destroyed" do + user = create(:user) + + expect { user.destroy }.to have_broadcasted_to(stream) + end + + it "broadcasts when a role changes" do + create(:user, :admin) + member = create(:user) + + expect { member.update!(role: :admin) }.to have_broadcasted_to(stream) + end + + it "stays quiet for an update that leaves the counts alone" do + user = create(:user) + + expect { user.update!(full_name: "Ada Byron") }.not_to have_broadcasted_to(stream) + end + + it "stays quiet when a save is rolled back" do + user = build(:user, full_name: "") + + expect { user.save }.not_to have_broadcasted_to(stream) + end + end end diff --git a/spec/queries/dashboard/stats_spec.rb b/spec/queries/dashboard/stats_spec.rb index 1f82e4021..4e7d5bb1e 100644 --- a/spec/queries/dashboard/stats_spec.rb +++ b/spec/queries/dashboard/stats_spec.rb @@ -35,17 +35,13 @@ end end - # The test environment runs on :null_store, which never retains anything -- - # `fetch` would yield on every call and the caching would look broken. These - # examples swap in a real store so the caching itself is what is under test. - describe "caching" do - around do |example| - original = Rails.cache - Rails.cache = ActiveSupport::Cache::MemoryStore.new - example.run - ensure - Rails.cache = original - end + # `:cache` swaps :null_store for a real store; see spec/support/cache_helpers.rb. + describe "caching", :cache do + # User's after_commit hook expires this cache on every create, which would + # mask what these examples are checking: creating a user is the only way to + # move the counts, so the invalidation has to be held back to see the cache + # do its job. Broadcaster's own spec covers the hook firing for real. + before { allow(Dashboard::Broadcaster).to receive(:call) } it "serves a cached copy rather than recounting" do create(:user) diff --git a/spec/support/cache_helpers.rb b/spec/support/cache_helpers.rb new file mode 100644 index 000000000..aa7f85c71 --- /dev/null +++ b/spec/support/cache_helpers.rb @@ -0,0 +1,17 @@ +# The test environment runs on :null_store, which retains nothing and reports +# every write as a success. Anything built on the cache is invisible under it -- +# a `fetch` never hits, and an `unless_exist` claim always looks unclaimed -- so +# specs exercising cache behaviour itself swap in a real store for the duration. +RSpec.shared_context "with a real cache store" do + around do |example| + original = Rails.cache + Rails.cache = ActiveSupport::Cache::MemoryStore.new + example.run + ensure + Rails.cache = original + end +end + +RSpec.configure do |config| + config.include_context "with a real cache store", :cache +end From 86dc29ea183b615bfa7ab76c64cc6d22e3b086bf Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Wed, 9 Sep 2026 19:19:41 -0300 Subject: [PATCH 37/82] feat: refactor BroadcastJob to include stats expiration before broadcasting --- app/jobs/dashboard/broadcast_job.rb | 7 ++++++- spec/jobs/dashboard/broadcast_job_spec.rb | 12 ++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/app/jobs/dashboard/broadcast_job.rb b/app/jobs/dashboard/broadcast_job.rb index 0246c6797..54054ebbe 100644 --- a/app/jobs/dashboard/broadcast_job.rb +++ b/app/jobs/dashboard/broadcast_job.rb @@ -1,6 +1,11 @@ # app/jobs/dashboard/broadcast_job.rb module Dashboard class BroadcastJob < ApplicationJob - def perform = Broadcaster.broadcast + queue_as :default + + def perform + Dashboard::Stats.expire + Dashboard::Broadcaster.broadcast + end end end diff --git a/spec/jobs/dashboard/broadcast_job_spec.rb b/spec/jobs/dashboard/broadcast_job_spec.rb index a8c9ff2b6..937058917 100644 --- a/spec/jobs/dashboard/broadcast_job_spec.rb +++ b/spec/jobs/dashboard/broadcast_job_spec.rb @@ -1,9 +1,21 @@ require "rails_helper" RSpec.describe Dashboard::BroadcastJob do + it "runs on the default queue" do + expect(described_class.new.queue_name).to eq("default") + end + it "broadcasts the stats.changed payload when performed" do expect { described_class.perform_now } .to have_broadcasted_to(Dashboard::Broadcaster::STREAM) .with(type: "stats.changed") end + + it "drops the cached stats before broadcasting", :cache do + Dashboard::Stats.current + + described_class.perform_now + + expect(Rails.cache.read(Dashboard::Stats::CACHE_KEY)).to be_nil + end end From c93edea35388b562436064592e2cf106cb164ae8 Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Wed, 9 Sep 2026 19:20:35 -0300 Subject: [PATCH 38/82] feat: streamline dashboard stats broadcasting logic in User model --- app/models/user.rb | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/app/models/user.rb b/app/models/user.rb index bd442ba07..248c25650 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -28,8 +28,10 @@ class User < ApplicationRecord before_destroy :ensure_not_last_admin, prepend: true validate :admin_headcount_preserved, on: :update - after_commit :refresh_dashboard_stats, on: %i[ create destroy ] - after_commit :refresh_dashboard_stats_on_role_change, on: :update + after_commit :broadcast_dashboard_stats, on: %i[create destroy] + # Block form on purpose: `after_commit` dedupes by filter, so the same symbol + # registered twice would drop the declaration above. + after_commit(on: :update, if: :saved_change_to_role?) { broadcast_dashboard_stats } def avatar_source return avatar_image if avatar_image.attached? @@ -38,14 +40,10 @@ def avatar_source private - def refresh_dashboard_stats + def broadcast_dashboard_stats Dashboard::Broadcaster.call end - def refresh_dashboard_stats_on_role_change - refresh_dashboard_stats if saved_change_to_role? - end - def ensure_not_last_admin return unless admin? && User.admin.count <= 1 From 5e5d87ef475f8d505a43607f41d461e1af58e007 Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Wed, 9 Sep 2026 19:21:39 -0300 Subject: [PATCH 39/82] feat: refactor connection logic to streamline user verification and add connection specs --- app/channels/application_cable/connection.rb | 12 ++++---- .../application_cable/connection_spec.rb | 30 +++++++++++++++++++ 2 files changed, 36 insertions(+), 6 deletions(-) create mode 100644 spec/channels/application_cable/connection_spec.rb diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb index 4264c745c..1a5986f95 100644 --- a/app/channels/application_cable/connection.rb +++ b/app/channels/application_cable/connection.rb @@ -3,14 +3,14 @@ class Connection < ActionCable::Connection::Base identified_by :current_user def connect - set_current_user || reject_unauthorized_connection + self.current_user = find_verified_user end private - def set_current_user - if session = Session.find_by(id: cookies.signed[:session_id]) - self.current_user = session.user - end - end + + def find_verified_user + session = Session.find_by(id: cookies.signed[:session_id]) + session&.user || reject_unauthorized_connection + end end end diff --git a/spec/channels/application_cable/connection_spec.rb b/spec/channels/application_cable/connection_spec.rb new file mode 100644 index 000000000..e30488239 --- /dev/null +++ b/spec/channels/application_cable/connection_spec.rb @@ -0,0 +1,30 @@ +require "rails_helper" + +RSpec.describe ApplicationCable::Connection, type: :channel do + it "identifies the user behind a signed session cookie" do + session = create(:session) + cookies.signed[:session_id] = session.id + + connect "/cable" + + expect(connection.current_user).to eq(session.user) + end + + it "rejects a connection without a session cookie" do + expect { connect "/cable" }.to have_rejected_connection + end + + it "rejects a session cookie that no longer resolves" do + session = create(:session) + cookies.signed[:session_id] = session.id + session.destroy + + expect { connect "/cable" }.to have_rejected_connection + end + + it "rejects an unsigned session cookie" do + cookies[:session_id] = create(:session).id + + expect { connect "/cable" }.to have_rejected_connection + end +end From 1a137f3e1f6ec72dca8eb3b780f97bec5af323db Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Wed, 9 Sep 2026 19:22:44 -0300 Subject: [PATCH 40/82] feat: remove StatsChannel and add @rails/actioncable dependency --- app/channels/dashboard/stats_channel.rb | 10 ---------- package-lock.json | 7 +++++++ package.json | 1 + 3 files changed, 8 insertions(+), 10 deletions(-) delete mode 100644 app/channels/dashboard/stats_channel.rb diff --git a/app/channels/dashboard/stats_channel.rb b/app/channels/dashboard/stats_channel.rb deleted file mode 100644 index 2102b0e5f..000000000 --- a/app/channels/dashboard/stats_channel.rb +++ /dev/null @@ -1,10 +0,0 @@ -# app/channels/dashboard/stats_channel.rb -module Dashboard - class StatsChannel < ApplicationCable::Channel - def subscribed - return reject unless current_user.admin? - - stream_from Broadcaster::STREAM - end - end -end diff --git a/package-lock.json b/package-lock.json index af036a65e..dd31e6f4f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "dependencies": { "@inertiajs/react": "^3.7.0", "@inertiajs/vite": "^3.7.0", + "@rails/actioncable": "^7.2.302", "@tailwindcss/forms": "^0.5.11", "@tailwindcss/typography": "^0.5.20", "@tailwindcss/vite": "^4.3.3", @@ -126,6 +127,12 @@ "url": "https://github.com/sponsors/oxc-project" } }, + "node_modules/@rails/actioncable": { + "version": "7.2.302", + "resolved": "https://registry.npmjs.org/@rails/actioncable/-/actioncable-7.2.302.tgz", + "integrity": "sha512-9JOPzUb7RCqIEWeoE78mPFd71fzyJ25LjeMzD45zQ75f41ca7BsgQVqiyrIuxDZ1yyZ4PdyxCMZF0KJlJ9qX0g==", + "license": "MIT" + }, "node_modules/@rolldown/binding-android-arm-eabi": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz", diff --git a/package.json b/package.json index b22741a88..223d397b5 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "dependencies": { "@inertiajs/react": "^3.7.0", "@inertiajs/vite": "^3.7.0", + "@rails/actioncable": "^7.2.302", "@tailwindcss/forms": "^0.5.11", "@tailwindcss/typography": "^0.5.20", "@tailwindcss/vite": "^4.3.3", From 802c9f3fa188e9c0fbad11ef0dad120367c91dd6 Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Wed, 9 Sep 2026 19:23:01 -0300 Subject: [PATCH 41/82] feat: add DashboardChannel with subscription logic and related specs --- app/channels/dashboard_channel.rb | 7 +++++++ ...stats_channel_spec.rb => dashboard_channel_spec.rb} | 10 +++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 app/channels/dashboard_channel.rb rename spec/channels/{dashboard/stats_channel_spec.rb => dashboard_channel_spec.rb} (77%) diff --git a/app/channels/dashboard_channel.rb b/app/channels/dashboard_channel.rb new file mode 100644 index 000000000..88fad536c --- /dev/null +++ b/app/channels/dashboard_channel.rb @@ -0,0 +1,7 @@ +class DashboardChannel < ApplicationCable::Channel + def subscribed + return reject unless current_user&.admin? + + stream_from Dashboard::Broadcaster::STREAM + end +end diff --git a/spec/channels/dashboard/stats_channel_spec.rb b/spec/channels/dashboard_channel_spec.rb similarity index 77% rename from spec/channels/dashboard/stats_channel_spec.rb rename to spec/channels/dashboard_channel_spec.rb index a2dd1fb64..b7710f771 100644 --- a/spec/channels/dashboard/stats_channel_spec.rb +++ b/spec/channels/dashboard_channel_spec.rb @@ -1,6 +1,6 @@ require "rails_helper" -RSpec.describe Dashboard::StatsChannel, type: :channel do +RSpec.describe DashboardChannel, type: :channel do it "subscribes an admin to the dashboard stream" do stub_connection current_user: create(:user, :admin) @@ -18,6 +18,14 @@ expect(subscription).to be_rejected end + it "turns away a connection with no identified user" do + stub_connection current_user: nil + + subscribe + + expect(subscription).to be_rejected + end + it "delivers the broadcaster's payload to a subscribed admin" do stub_connection current_user: create(:user, :admin) subscribe From 3c7696d79d2167016e1eb3d036bebfdfca84cc1d Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Wed, 9 Sep 2026 19:28:51 -0300 Subject: [PATCH 42/82] feat: implement admin dashboard with stats display and related specs --- .../admin/dashboards_controller.rb | 14 ++--- app/javascript/pages/Admin/Dashboard.tsx | 51 +++++++++++++++++++ app/javascript/types/index.ts | 7 +++ app/views/admin/dashboards/show.html.erb | 13 ----- app/views/sessions/new.html.erb | 3 +- spec/requests/admin/dashboards_spec.rb | 15 ++++-- spec/system/admin_dashboard_spec.rb | 33 ++++++++++++ 7 files changed, 112 insertions(+), 24 deletions(-) create mode 100644 app/javascript/pages/Admin/Dashboard.tsx delete mode 100644 app/views/admin/dashboards/show.html.erb create mode 100644 spec/system/admin_dashboard_spec.rb diff --git a/app/controllers/admin/dashboards_controller.rb b/app/controllers/admin/dashboards_controller.rb index ea41a5159..7c03f0144 100644 --- a/app/controllers/admin/dashboards_controller.rb +++ b/app/controllers/admin/dashboards_controller.rb @@ -1,9 +1,11 @@ -class Admin::DashboardsController < ApplicationController - include Authorization +module Admin + class DashboardsController < ApplicationController + def show + authorize! User, "index?" - before_action -> { authorize!(User, "index?") } - - def show - @users = UserPolicy.new(Current.user, User).scope + render inertia: "Admin/Dashboard", props: { + stats: -> { Dashboard::Stats.current } + } + end end end diff --git a/app/javascript/pages/Admin/Dashboard.tsx b/app/javascript/pages/Admin/Dashboard.tsx new file mode 100644 index 000000000..c0bdfdc33 --- /dev/null +++ b/app/javascript/pages/Admin/Dashboard.tsx @@ -0,0 +1,51 @@ +import { Head, Link } from '@inertiajs/react' +import AppLayout from '@/layouts/AppLayout' +import type { DashboardStats, UserRole } from '@/types' + +/** Props from Admin::DashboardsController#show. */ +type Props = { stats: DashboardStats } + +const ROLES: UserRole[] = ['admin', 'member'] + +const generatedAt = new Intl.DateTimeFormat(undefined, { timeStyle: 'medium' }) + +function Tile({ label, value }: { label: string; value: number }) { + return ( +
+
{label}
+
{value}
+
+ ) +} + +export default function Dashboard({ stats }: Props) { + return ( + <> + + +
+

Admin dashboard

+ + Manage users + +
+ +
+ + {ROLES.map((role) => ( + + ))} +
+ +

+ Counted at{' '} + +

+ + ) +} + +Dashboard.layout = AppLayout diff --git a/app/javascript/types/index.ts b/app/javascript/types/index.ts index e4e6803cf..9d4bd23a6 100644 --- a/app/javascript/types/index.ts +++ b/app/javascript/types/index.ts @@ -38,3 +38,10 @@ export type Filters = { /** The subset of Filters that UserSearch actually reads back off the query string. */ export type SearchParams = Pick + +/** Mirrors Dashboard::Stats#to_h. */ +export type DashboardStats = { + total: number + by_role: Record + generated_at: string +} diff --git a/app/views/admin/dashboards/show.html.erb b/app/views/admin/dashboards/show.html.erb deleted file mode 100644 index 54edd385c..000000000 --- a/app/views/admin/dashboards/show.html.erb +++ /dev/null @@ -1,13 +0,0 @@ -
-

Admin dashboard

- -

<%= pluralize(@users.count, "user") %>

- -
    - <% @users.each do |user| %> -
  • <%= user.full_name %> — <%= user.role %>
  • - <% end %> -
- - <%= button_to "Sign out", session_path, method: :delete, class: "mt-6 underline" %> -
diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index 308b04b37..70bf8683f 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -8,8 +8,7 @@ <% end %>

Sign in

- - <%= form_with url: session_url, class: "contents" do |form| %> + <%= form_with url: session_url, class: "contents", data: { turbo: false } do |form| %>
<%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address], class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
diff --git a/spec/requests/admin/dashboards_spec.rb b/spec/requests/admin/dashboards_spec.rb index 2af559197..45cabf0b0 100644 --- a/spec/requests/admin/dashboards_spec.rb +++ b/spec/requests/admin/dashboards_spec.rb @@ -1,6 +1,8 @@ require "rails_helper" RSpec.describe "Admin::Dashboards", type: :request do + def props = inertia.props.deep_symbolize_keys + let!(:admin) { create(:user, :admin, email_address: "boss@example.com", password: "password") } let!(:member) { create(:user, full_name: "Regular Member", email_address: "member@example.com", password: "password") } @@ -18,13 +20,20 @@ expect(response).to have_http_status(:ok) end - it "lists every user for an admin" do + it "renders the dashboard component for an admin" do + sign_in_as(admin) + + get admin_dashboard_path + + expect(inertia).to render_component("Admin/Dashboard") + end + + it "hands the component the current stats" do sign_in_as(admin) get admin_dashboard_path - expect(response.body).to include("Regular Member") - expect(response.body).to include("2 users") + expect(props[:stats]).to include(total: 2, by_role: { member: 1, admin: 1 }) end it "turns a member away with the authorization alert" do diff --git a/spec/system/admin_dashboard_spec.rb b/spec/system/admin_dashboard_spec.rb new file mode 100644 index 000000000..f06a63e1b --- /dev/null +++ b/spec/system/admin_dashboard_spec.rb @@ -0,0 +1,33 @@ +require "rails_helper" + +RSpec.describe "The admin dashboard", type: :system, js: true do + let(:admin) { create(:user, :admin, full_name: "Ada Lovelace", email_address: "ada@example.com") } + + def sign_in_through_the_form(user) + visit new_session_path + fill_in "email_address", with: user.email_address + fill_in "password", with: "password" + click_on "Sign in" + expect(page).to have_no_current_path(new_session_path, wait: 5) + end + + it "shows the counts an admin lands on after signing in" do + create_list(:user, 2) + sign_in_through_the_form(admin) + + expect(page).to have_current_path(admin_dashboard_path) + expect(page).to have_css("h1", text: "Admin dashboard") + expect(page).to have_css("dt", text: "Total users") + expect(page).to have_css("dd", text: "3") + expect(page).to have_css("dt", text: "Admins") + expect(page).to have_css("dt", text: "Members") + end + + it "links through to the users table" do + sign_in_through_the_form(admin) + + click_on "Manage users" + + expect(page).to have_current_path(admin_users_path) + end +end From 14133c79cc562ec52aa7007e38f42d799663f904 Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Wed, 9 Sep 2026 19:31:45 -0300 Subject: [PATCH 43/82] feat: implement useDashboardStream hook for real-time stats updates and add related tests --- app/javascript/hooks/useDashboardStream.ts | 36 ++++++++++++++++++++++ app/javascript/pages/Admin/Dashboard.tsx | 3 ++ package-lock.json | 8 +++++ package.json | 1 + spec/system/admin_dashboard_spec.rb | 11 +++++++ 5 files changed, 59 insertions(+) create mode 100644 app/javascript/hooks/useDashboardStream.ts diff --git a/app/javascript/hooks/useDashboardStream.ts b/app/javascript/hooks/useDashboardStream.ts new file mode 100644 index 000000000..aeeb4d467 --- /dev/null +++ b/app/javascript/hooks/useDashboardStream.ts @@ -0,0 +1,36 @@ +import { createConsumer, type Consumer } from '@rails/actioncable' +import { router } from '@inertiajs/react' +import { useEffect, useRef } from 'react' + +let consumer: Consumer | null = null +const getConsumer = () => (consumer ??= createConsumer()) + +export function useDashboardStream() { + const pending = useRef(null) + + useEffect(() => { + const refresh = () => router.reload({ only: ['stats'] }) + + const subscription = getConsumer().subscriptions.create( + { channel: 'DashboardChannel' }, + { + // Reconnects after a network drop land here. Refetch so we + // do not sit on counts that went stale while offline. + connected: refresh, + + received() { + if (pending.current) return + pending.current = window.setTimeout(() => { + pending.current = null + refresh() + }, 250) + }, + }, + ) + + return () => { + if (pending.current) clearTimeout(pending.current) + subscription.unsubscribe() + } + }, []) +} diff --git a/app/javascript/pages/Admin/Dashboard.tsx b/app/javascript/pages/Admin/Dashboard.tsx index c0bdfdc33..ce791cfed 100644 --- a/app/javascript/pages/Admin/Dashboard.tsx +++ b/app/javascript/pages/Admin/Dashboard.tsx @@ -1,5 +1,6 @@ import { Head, Link } from '@inertiajs/react' import AppLayout from '@/layouts/AppLayout' +import { useDashboardStream } from '@/hooks/useDashboardStream' import type { DashboardStats, UserRole } from '@/types' /** Props from Admin::DashboardsController#show. */ @@ -19,6 +20,8 @@ function Tile({ label, value }: { label: string; value: number }) { } export default function Dashboard({ stats }: Props) { + useDashboardStream() + return ( <> diff --git a/package-lock.json b/package-lock.json index dd31e6f4f..a0b3d5170 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ }, "devDependencies": { "@inertiajs/core": "^3.7.0", + "@types/rails__actioncable": "^8.0.3", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.7", "playwright": "1.62.1", @@ -909,6 +910,13 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@types/rails__actioncable": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@types/rails__actioncable/-/rails__actioncable-8.0.3.tgz", + "integrity": "sha512-y46MOTYorVQVwlHUyaZYbrh3nIkXsRYNuPna32lb3RngLVBlndNbIPvAUywFfhivftNhYg+vW5sZKWYCVIX2lA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", diff --git a/package.json b/package.json index 223d397b5..89ffa6bec 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "type": "module", "devDependencies": { "@inertiajs/core": "^3.7.0", + "@types/rails__actioncable": "^8.0.3", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.7", "playwright": "1.62.1", diff --git a/spec/system/admin_dashboard_spec.rb b/spec/system/admin_dashboard_spec.rb index f06a63e1b..52a17ec8d 100644 --- a/spec/system/admin_dashboard_spec.rb +++ b/spec/system/admin_dashboard_spec.rb @@ -23,6 +23,17 @@ def sign_in_through_the_form(user) expect(page).to have_css("dt", text: "Members") end + it "updates the counts when a user is created elsewhere" do + sign_in_through_the_form(admin) + total = -> { find("dt", text: "Total users").sibling("dd") } + + expect(total.call).to have_text("1") + + create(:user) + + expect(total.call).to have_text("2", wait: 5) + end + it "links through to the users table" do sign_in_through_the_form(admin) From bccd15c09a263872f203f958e14a8a3b32d2d653 Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Wed, 9 Sep 2026 19:34:17 -0300 Subject: [PATCH 44/82] feat: add StatCard component for displaying statistics and update Dashboard to use it --- app/javascript/components/StatCard.tsx | 56 ++++++++++++++++++++++++ app/javascript/pages/Admin/Dashboard.tsx | 14 ++---- spec/system/admin_dashboard_spec.rb | 8 ++-- 3 files changed, 63 insertions(+), 15 deletions(-) create mode 100644 app/javascript/components/StatCard.tsx diff --git a/app/javascript/components/StatCard.tsx b/app/javascript/components/StatCard.tsx new file mode 100644 index 000000000..db7ef4268 --- /dev/null +++ b/app/javascript/components/StatCard.tsx @@ -0,0 +1,56 @@ +import { useEffect, useRef, useState } from 'react' + +const DURATION = 400 + +const prefersReducedMotion = () => + window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false + +export default function StatCard({ label, value }: { label: string; value: number }) { + const [shown, setShown] = useState(value) + const from = useRef(value) + + useEffect(() => { + if (from.current === value) return + + if (prefersReducedMotion()) { + from.current = value + setShown(value) + return + } + + const start = performance.now() + const origin = from.current + let frame: number + + const tick = (now: number) => { + const progress = Math.min((now - start) / DURATION, 1) + const eased = 1 - (1 - progress) ** 3 + const current = Math.round(origin + (value - origin) * eased) + + // Broadcasts land every few hundred ms, so a new value often arrives + // mid-flight. Park the on-screen count here and the next run picks it + // up as its origin instead of snapping back to the last settled one. + from.current = current + setShown(current) + + if (progress < 1) frame = requestAnimationFrame(tick) + else from.current = value + } + + frame = requestAnimationFrame(tick) + return () => cancelAnimationFrame(frame) + }, [value]) + + return ( +
+
{label}
+
+ {shown.toLocaleString()} +
+ {/* Announce the settled count once, not once per frame. */} + + {label}: {value.toLocaleString()} + +
+ ) +} diff --git a/app/javascript/pages/Admin/Dashboard.tsx b/app/javascript/pages/Admin/Dashboard.tsx index ce791cfed..577aa922c 100644 --- a/app/javascript/pages/Admin/Dashboard.tsx +++ b/app/javascript/pages/Admin/Dashboard.tsx @@ -1,5 +1,6 @@ import { Head, Link } from '@inertiajs/react' import AppLayout from '@/layouts/AppLayout' +import StatCard from '@/components/StatCard' import { useDashboardStream } from '@/hooks/useDashboardStream' import type { DashboardStats, UserRole } from '@/types' @@ -10,15 +11,6 @@ const ROLES: UserRole[] = ['admin', 'member'] const generatedAt = new Intl.DateTimeFormat(undefined, { timeStyle: 'medium' }) -function Tile({ label, value }: { label: string; value: number }) { - return ( -
-
{label}
-
{value}
-
- ) -} - export default function Dashboard({ stats }: Props) { useDashboardStream() @@ -37,9 +29,9 @@ export default function Dashboard({ stats }: Props) {
- + {ROLES.map((role) => ( - + ))}
diff --git a/spec/system/admin_dashboard_spec.rb b/spec/system/admin_dashboard_spec.rb index 52a17ec8d..f41fa2d27 100644 --- a/spec/system/admin_dashboard_spec.rb +++ b/spec/system/admin_dashboard_spec.rb @@ -17,15 +17,15 @@ def sign_in_through_the_form(user) expect(page).to have_current_path(admin_dashboard_path) expect(page).to have_css("h1", text: "Admin dashboard") - expect(page).to have_css("dt", text: "Total users") + expect(page).to have_css("dt", text: /Total users/i) expect(page).to have_css("dd", text: "3") - expect(page).to have_css("dt", text: "Admins") - expect(page).to have_css("dt", text: "Members") + expect(page).to have_css("dt", text: /Admins/i) + expect(page).to have_css("dt", text: /Members/i) end it "updates the counts when a user is created elsewhere" do sign_in_through_the_form(admin) - total = -> { find("dt", text: "Total users").sibling("dd") } + total = -> { find("dt", text: /Total users/i).sibling("dd") } expect(total.call).to have_text("1") From 63a126915c8a460c7b694a455c44b5c6ab6b3367 Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Wed, 9 Sep 2026 19:38:45 -0300 Subject: [PATCH 45/82] feat: update Dashboard component layout and enhance stats display with timestamps --- app/javascript/pages/Admin/Dashboard.tsx | 28 +++++++++++++----------- config/environments/development.rb | 2 ++ config/environments/production.rb | 2 ++ 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/app/javascript/pages/Admin/Dashboard.tsx b/app/javascript/pages/Admin/Dashboard.tsx index 577aa922c..b0dad1ca7 100644 --- a/app/javascript/pages/Admin/Dashboard.tsx +++ b/app/javascript/pages/Admin/Dashboard.tsx @@ -2,13 +2,11 @@ import { Head, Link } from '@inertiajs/react' import AppLayout from '@/layouts/AppLayout' import StatCard from '@/components/StatCard' import { useDashboardStream } from '@/hooks/useDashboardStream' -import type { DashboardStats, UserRole } from '@/types' +import type { DashboardStats } from '@/types' /** Props from Admin::DashboardsController#show. */ type Props = { stats: DashboardStats } -const ROLES: UserRole[] = ['admin', 'member'] - const generatedAt = new Intl.DateTimeFormat(undefined, { timeStyle: 'medium' }) export default function Dashboard({ stats }: Props) { @@ -18,8 +16,16 @@ export default function Dashboard({ stats }: Props) { <> -
-

Admin dashboard

+
+
+

Admin dashboard

+ + Updated{' '} + + +
-
+ {/* A dl, not a div: StatCard renders a dt/dd pair. */} +
- {ROLES.map((role) => ( - + {Object.entries(stats.by_role).map(([role, count]) => ( + ))}
- -

- Counted at{' '} - -

) } diff --git a/config/environments/development.rb b/config/environments/development.rb index 3e185031a..0c413d1c3 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -78,4 +78,6 @@ config.active_job.queue_adapter = :solid_queue config.solid_queue.connects_to = { database: { writing: :queue } } + config.action_cable.allowed_request_origins = [ENV.fetch("APP_ORIGIN")] + config.action_cable.mount_path = "/cable" end diff --git a/config/environments/production.rb b/config/environments/production.rb index f5763e04e..8779a1302 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -87,4 +87,6 @@ # # Skip DNS rebinding protection for the default health check endpoint. # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } + config.action_cable.allowed_request_origins = [ENV.fetch("APP_ORIGIN")] + config.action_cable.mount_path = "/cable" end From b0f0f6ea11f80bdfc98c8d70f815856b46c72062 Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Thu, 10 Sep 2026 10:45:11 -0300 Subject: [PATCH 46/82] feat: add imports table with relevant fields and indexes for import tracking --- db/migrate/20260910134246_create_imports.rb | 21 ++++++++++++++++++++ db/schema.rb | 22 ++++++++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20260910134246_create_imports.rb diff --git a/db/migrate/20260910134246_create_imports.rb b/db/migrate/20260910134246_create_imports.rb new file mode 100644 index 000000000..eeb9dbe9a --- /dev/null +++ b/db/migrate/20260910134246_create_imports.rb @@ -0,0 +1,21 @@ +class CreateImports < ActiveRecord::Migration[8.1] + def change + create_table :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.integer :created_count, null: false, default: 0 + t.integer :skipped_count, null: false, default: 0 + t.integer :failed_count, null: false, default: 0 + t.jsonb :error_report, null: false, default: [] + t.string :failure_reason + t.datetime :started_at + t.datetime :finished_at + t.timestamps + end + + add_index :imports, %i[user_id created_at] + add_index :imports, :status + end +end diff --git a/db/schema.rb b/db/schema.rb index 3f916be19..3ca85f8a6 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_09_120845) do +ActiveRecord::Schema[8.1].define(version: 2026_09_10_134246) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -42,6 +42,25 @@ t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true end + create_table "imports", force: :cascade do |t| + t.datetime "created_at", null: false + t.integer "created_count", default: 0, null: false + t.jsonb "error_report", default: [], null: false + t.integer "failed_count", default: 0, null: false + t.string "failure_reason" + t.datetime "finished_at" + t.integer "processed_rows", default: 0, null: false + t.integer "skipped_count", default: 0, null: false + t.datetime "started_at" + t.integer "status", default: 0, null: false + t.integer "total_rows", default: 0, null: false + t.datetime "updated_at", null: false + t.bigint "user_id", null: false + t.index ["status"], name: "index_imports_on_status" + t.index ["user_id", "created_at"], name: "index_imports_on_user_id_and_created_at" + t.index ["user_id"], name: "index_imports_on_user_id" + end + create_table "sessions", force: :cascade do |t| t.datetime "created_at", null: false t.string "ip_address" @@ -65,5 +84,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 "imports", "users" add_foreign_key "sessions", "users" end From 9c506119c426f88610960f12ac02bc6f2f82bb1d Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Thu, 10 Sep 2026 10:48:01 -0300 Subject: [PATCH 47/82] feat: implement Import model with validations, associations, and file handling --- app/models/import.rb | 48 +++++++++++ app/models/user.rb | 1 + spec/factories/imports.rb | 13 +++ spec/fixtures/files/users.csv | 2 + spec/models/import_spec.rb | 154 ++++++++++++++++++++++++++++++++++ spec/models/user_spec.rb | 1 + 6 files changed, 219 insertions(+) create mode 100644 app/models/import.rb create mode 100644 spec/factories/imports.rb create mode 100644 spec/fixtures/files/users.csv create mode 100644 spec/models/import_spec.rb diff --git a/app/models/import.rb b/app/models/import.rb new file mode 100644 index 000000000..fd6fd5e70 --- /dev/null +++ b/app/models/import.rb @@ -0,0 +1,48 @@ +class Import < ApplicationRecord + MAX_FILE_SIZE = 10.megabytes + MAX_REPORTED_ROWS = 500 + CONTENT_TYPES = { + "text/csv" => :csv, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => :xlsx + }.freeze + + belongs_to :user + has_one_attached :file + + enum :status, { + pending: 0, parsing: 1, processing: 2, + completed: 3, failed: 4, cancelled: 5 + }, default: :pending, validate: true + + validates :file, presence: true + validate :acceptable_file + + scope :recent, -> { order(created_at: :desc) } + + def format + CONTENT_TYPES.fetch(file.content_type, :csv) + end + + def progress + return 0 if total_rows.zero? + + ((processed_rows.to_f / total_rows) * 100).round + end + + def finished? = completed? || failed? || cancelled? + + def record_error(index, identifier, messages) + return if error_report.size >= MAX_REPORTED_ROWS + + error_report << { row: index, identifier: identifier, errors: Array(messages) } + end + + private + + def acceptable_file + return unless file.attached? + + errors.add(:file, "must be a .csv or .xlsx file") unless CONTENT_TYPES.key?(file.content_type) + errors.add(:file, "must be smaller than 10 MB") if file.byte_size > MAX_FILE_SIZE + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 248c25650..a73c2582d 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,6 +1,7 @@ class User < ApplicationRecord has_secure_password has_many :sessions, dependent: :destroy + has_many :imports, dependent: :destroy has_one_attached :avatar_image enum :role, { member: 0, admin: 1 }, default: :member, validate: true diff --git a/spec/factories/imports.rb b/spec/factories/imports.rb new file mode 100644 index 000000000..833e9fcb0 --- /dev/null +++ b/spec/factories/imports.rb @@ -0,0 +1,13 @@ +FactoryBot.define do + factory :import do + user + + after(:build) do |import| + import.file.attach( + io: Rails.root.join("spec/fixtures/files/users.csv").open, + filename: "users.csv", + content_type: "text/csv" + ) + end + end +end diff --git a/spec/fixtures/files/users.csv b/spec/fixtures/files/users.csv new file mode 100644 index 000000000..724ee5694 --- /dev/null +++ b/spec/fixtures/files/users.csv @@ -0,0 +1,2 @@ +full_name,email_address +Grace Hopper,grace@example.com diff --git a/spec/models/import_spec.rb b/spec/models/import_spec.rb new file mode 100644 index 000000000..b64e53f16 --- /dev/null +++ b/spec/models/import_spec.rb @@ -0,0 +1,154 @@ +require "rails_helper" + +RSpec.describe Import, type: :model do + subject(:import) { build(:import) } + + def attach_file(record, content: "full_name\n", filename: "users.csv", content_type: "text/csv") + record.file.attach(io: StringIO.new(content), filename: filename, content_type: content_type, identify: false) + end + + it "has a valid factory" do + expect(import).to be_valid + end + + describe "associations" do + it { is_expected.to belong_to(:user) } + + it "is destroyed along with its user" do + import = create(:import) + + expect { import.user.destroy }.to change(described_class, :count).by(-1) + end + end + + describe "status" do + it do + is_expected.to define_enum_for(:status) + .with_values(pending: 0, parsing: 1, processing: 2, completed: 3, failed: 4, cancelled: 5) + .validating + end + + it "starts out pending" do + expect(described_class.new).to be_pending + end + end + + describe "file" do + it "is required" do + import = described_class.new(user: build(:user)) + + expect(import).to be_invalid + expect(import.errors[:file]).to include("can't be blank") + end + + it "accepts an xlsx workbook" do + attach_file(import, filename: "users.xlsx", content_type: Import::CONTENT_TYPES.key(:xlsx)) + + expect(import).to be_valid + end + + it "rejects any other content type" do + attach_file(import, filename: "notes.txt", content_type: "text/plain") + + expect(import).to be_invalid + expect(import.errors[:file]).to include("must be a .csv or .xlsx file") + end + + it "accepts a file of exactly the size limit" do + attach_file(import, content: "a" * Import::MAX_FILE_SIZE) + + expect(import).to be_valid + end + + it "rejects a file over the size limit" do + attach_file(import, content: "a" * (Import::MAX_FILE_SIZE + 1)) + + expect(import).to be_invalid + expect(import.errors[:file]).to include("must be smaller than 10 MB") + end + end + + describe ".recent" do + it "orders newest first" do + older = create(:import, created_at: 2.days.ago) + newer = create(:import, created_at: 1.hour.ago) + + expect(described_class.recent).to eq([ newer, older ]) + end + end + + describe "#format" do + it "is :csv for a CSV upload" do + expect(import.format).to eq(:csv) + end + + it "is :xlsx for a workbook upload" do + attach_file(import, filename: "users.xlsx", content_type: Import::CONTENT_TYPES.key(:xlsx)) + + expect(import.format).to eq(:xlsx) + end + + it "falls back to :csv for an unrecognised content type" do + attach_file(import, filename: "notes.txt", content_type: "text/plain") + + expect(import.format).to eq(:csv) + end + end + + describe "#progress" do + it "is 0 before any rows have been counted" do + expect(build(:import, total_rows: 0, processed_rows: 0).progress).to eq(0) + end + + it "rounds to a whole percentage" do + expect(build(:import, total_rows: 3, processed_rows: 1).progress).to eq(33) + expect(build(:import, total_rows: 3, processed_rows: 2).progress).to eq(67) + end + + it "is 100 once every row is processed" do + expect(build(:import, total_rows: 40, processed_rows: 40).progress).to eq(100) + end + end + + describe "#finished?" do + %i[completed failed cancelled].each do |status| + it "is true when #{status}" do + expect(build(:import, status: status)).to be_finished + end + end + + %i[pending parsing processing].each do |status| + it "is false when #{status}" do + expect(build(:import, status: status)).not_to be_finished + end + end + end + + describe "#record_error" do + # In-place `<<` on a jsonb column only persists if Active Record notices the + # mutation, so these go through a save and reload rather than trusting memory. + it "appends a row entry that survives a save" do + import = create(:import) + + import.record_error(2, "grace@example.com", "Email address has already been taken") + import.save! + + expect(import.reload.error_report).to eq([ + { "row" => 2, "identifier" => "grace@example.com", "errors" => [ "Email address has already been taken" ] } + ]) + end + + it "keeps a list of messages as a list" do + import.record_error(3, nil, [ "Full name can't be blank", "Email address is invalid" ]) + + expect(import.error_report.last[:errors]).to eq([ "Full name can't be blank", "Email address is invalid" ]) + end + + it "stops recording once the report is full" do + (Import::MAX_REPORTED_ROWS + 5).times { |i| import.record_error(i, "row#{i}", "bad") } + + expect(import.error_report.size).to eq(Import::MAX_REPORTED_ROWS) + expect(import.error_report.last[:row]).to eq(Import::MAX_REPORTED_ROWS - 1) + end + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index df5303e57..10d9928ca 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -15,6 +15,7 @@ def raw_email_column(record) describe "associations and attachments" do it { is_expected.to have_many(:sessions).dependent(:destroy) } + it { is_expected.to have_many(:imports).dependent(:destroy) } it "destroys dependent sessions when the user is destroyed" do user = create(:user) From cc618b601b56e186d407e9144bbb8fb47f86c534 Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Thu, 10 Sep 2026 10:52:10 -0300 Subject: [PATCH 48/82] feat: add UserRow model with header normalization, validations, and sanitization methods --- app/imports/imports/user_row.rb | 49 +++++++++ spec/imports/imports/user_row_spec.rb | 139 ++++++++++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 app/imports/imports/user_row.rb create mode 100644 spec/imports/imports/user_row_spec.rb diff --git a/app/imports/imports/user_row.rb b/app/imports/imports/user_row.rb new file mode 100644 index 000000000..e7167b99b --- /dev/null +++ b/app/imports/imports/user_row.rb @@ -0,0 +1,49 @@ +module Imports + class UserRow + include ActiveModel::Model + include ActiveModel::Attributes + + HEADER_ALIASES = { + "full_name" => :full_name, "name" => :full_name, "fullname" => :full_name, "nome" => :full_name, + "email" => :email_address, "email_address" => :email_address, "e_mail" => :email_address, + "role" => :role, "perfil" => :role, + "avatar" => :avatar_url, "avatar_url" => :avatar_url, "photo" => :avatar_url + }.freeze + + attribute :full_name, :string + attribute :email_address, :string + attribute :role, :string, default: "member" + attribute :avatar_url, :string + + validates :full_name, presence: true, length: { in: 2..120 } + validates :email_address, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } + validates :role, inclusion: { in: User.roles.keys, message: "must be admin or member" } + validates :avatar_url, format: { with: %r{\Ahttps://\S+\z} }, allow_blank: true + + def self.normalize_headers(headers) + headers.map do |header| + key = header.to_s.strip.downcase.gsub(/[^a-z0-9]+/, "_").delete_prefix("_").delete_suffix("_") + HEADER_ALIASES[key] + end + end + + def self.from(headers, values) + attributes = headers.zip(values).to_h.compact.except(nil) + new(attributes.transform_values { sanitize(_1) }) + end + + def self.sanitize(value) + text = value.is_a?(String) ? value : value.to_s + # Strip leading =, +, -, @ so a cell like "=cmd|..." cannot become a live + # formula if this data is ever re-exported to a spreadsheet. + text.squish.sub(/\A[=+\-@\t\r]+/, "") + end + + def normalized_email = email_address.to_s.strip.downcase + + def to_user_attributes + { full_name:, email_address: normalized_email, role:, avatar_url: avatar_url.presence, + password: SecureRandom.base58(24) } + end + end +end diff --git a/spec/imports/imports/user_row_spec.rb b/spec/imports/imports/user_row_spec.rb new file mode 100644 index 000000000..fceded2d8 --- /dev/null +++ b/spec/imports/imports/user_row_spec.rb @@ -0,0 +1,139 @@ +require "rails_helper" + +RSpec.describe Imports::UserRow, type: :model do + def row(**attributes) + described_class.new(full_name: "Grace Hopper", email_address: "grace@example.com", **attributes) + end + + describe ".normalize_headers" do + it "maps canonical names and their aliases onto attributes" do + expect(described_class.normalize_headers(%w[full_name email role avatar_url])) + .to eq(%i[full_name email_address role avatar_url]) + end + + it "accepts the Portuguese headers" do + expect(described_class.normalize_headers(%w[nome perfil])).to eq(%i[full_name role]) + end + + it "ignores case, surrounding space and punctuation" do + expect(described_class.normalize_headers([ " Full Name ", "E-mail", "AVATAR" ])) + .to eq(%i[full_name email_address avatar_url]) + end + + it "sees past the byte-order mark Excel writes before the first header" do + expect(described_class.normalize_headers([ "\uFEFFname" ])).to eq([ :full_name ]) + end + + it "keeps unknown and blank headers as nil so column positions still line up" do + expect(described_class.normalize_headers([ "name", "department", nil, "email" ])) + .to eq([ :full_name, nil, nil, :email_address ]) + end + end + + describe ".from" do + let(:headers) { %i[full_name email_address role] } + + it "pairs each value with the header in the same column" do + user_row = described_class.from(headers, [ "Grace Hopper", "grace@example.com", "admin" ]) + + expect(user_row).to have_attributes(full_name: "Grace Hopper", email_address: "grace@example.com", role: "admin") + end + + it "drops columns whose header was not recognised" do + user_row = described_class.from([ :full_name, nil, :email_address ], [ "Grace Hopper", "Engineering", "grace@example.com" ]) + + expect(user_row).to have_attributes(full_name: "Grace Hopper", email_address: "grace@example.com") + end + + it "falls back to member when the role cell is empty" do + expect(described_class.from(headers, [ "Grace Hopper", "grace@example.com", nil ]).role).to eq("member") + end + + it "falls back to member when the file has no role column" do + expect(described_class.from(%i[full_name email_address], [ "Grace Hopper", "grace@example.com" ]).role).to eq("member") + end + + it "tolerates a row shorter than the header" do + expect(described_class.from(headers, [ "Grace Hopper" ])) + .to have_attributes(full_name: "Grace Hopper", email_address: nil, role: "member") + end + + it "sanitizes every value" do + expect(described_class.from([ :full_name ], [ " =Grace Hopper " ]).full_name).to eq("Grace Hopper") + end + end + + describe ".sanitize" do + it "squishes whitespace" do + expect(described_class.sanitize(" Grace \n Hopper ")).to eq("Grace Hopper") + end + + it "strips each leading character that would start a spreadsheet formula" do + %w[=SUM(A1) +SUM(A1) -SUM(A1) @SUM(A1)].each do |cell| + expect(described_class.sanitize(cell)).to eq("SUM(A1)") + end + end + + it "strips a whole run of them, not just the first" do + expect(described_class.sanitize("=+-@cmd|' /C calc'!A0")).to eq("cmd|' /C calc'!A0") + end + + it "leaves those characters alone past the start of the value" do + expect(described_class.sanitize("Mary-Jane O'Neil")).to eq("Mary-Jane O'Neil") + expect(described_class.sanitize("grace+navy@example.com")).to eq("grace+navy@example.com") + end + + it "turns spreadsheet numbers into text" do + expect(described_class.sanitize(1.5)).to eq("1.5") + end + end + + describe "validations" do + subject(:user_row) { row } + + it { is_expected.to be_valid } + + it { is_expected.to validate_presence_of(:full_name) } + it { is_expected.to validate_length_of(:full_name).is_at_least(2).is_at_most(120) } + + it { is_expected.to validate_presence_of(:email_address) } + it { is_expected.to allow_value("grace.hopper+navy@example.com").for(:email_address) } + it { is_expected.not_to allow_value("grace@", "not an email").for(:email_address) } + + it { is_expected.to validate_inclusion_of(:role).in_array(%w[member admin]).with_message("must be admin or member") } + + it { is_expected.to allow_value(nil, "", "https://cdn.example.com/grace.png").for(:avatar_url) } + it { is_expected.not_to allow_value("http://cdn.example.com/grace.png", "javascript:alert(1)").for(:avatar_url) } + end + + describe "#normalized_email" do + it "trims and lowercases" do + expect(row(email_address: " Grace@Example.COM ").normalized_email).to eq("grace@example.com") + end + + it "is an empty string when there is no email" do + expect(row(email_address: nil).normalized_email).to eq("") + end + end + + describe "#to_user_attributes" do + it "is enough to create a User" do + attributes = row(email_address: "Grace@Example.com", role: "admin").to_user_attributes + + expect(User.create!(attributes)) + .to have_attributes(full_name: "Grace Hopper", email_address: "grace@example.com", role: "admin") + end + + it "turns a blank avatar URL into nil" do + expect(row(avatar_url: "").to_user_attributes[:avatar_url]).to be_nil + end + + it "generates a different 24-character password each time" do + user_row = row + first, second = 2.times.map { user_row.to_user_attributes[:password] } + + expect(first.length).to eq(24) + expect(first).not_to eq(second) + end + end +end From 8744acbc06b3057b0d8264c6c88f4d2392c8937e Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Thu, 10 Sep 2026 10:57:40 -0300 Subject: [PATCH 49/82] feat: implement CsvRowSet and SpreadsheetRowSet classes for handling CSV and XLSX imports --- app/imports/imports/csv_row_set.rb | 25 ++++ app/imports/imports/row_set.rb | 20 ++++ app/imports/imports/spreadsheet_row_set.rb | 28 +++++ spec/imports/imports/csv_row_set_spec.rb | 59 ++++++++++ spec/imports/imports/row_set_spec.rb | 32 +++++ .../imports/spreadsheet_row_set_spec.rb | 60 ++++++++++ spec/support/import_file_helpers.rb | 109 ++++++++++++++++++ 7 files changed, 333 insertions(+) create mode 100644 app/imports/imports/csv_row_set.rb create mode 100644 app/imports/imports/row_set.rb create mode 100644 app/imports/imports/spreadsheet_row_set.rb create mode 100644 spec/imports/imports/csv_row_set_spec.rb create mode 100644 spec/imports/imports/row_set_spec.rb create mode 100644 spec/imports/imports/spreadsheet_row_set_spec.rb create mode 100644 spec/support/import_file_helpers.rb diff --git a/app/imports/imports/csv_row_set.rb b/app/imports/imports/csv_row_set.rb new file mode 100644 index 000000000..03a522a73 --- /dev/null +++ b/app/imports/imports/csv_row_set.rb @@ -0,0 +1,25 @@ +module Imports + class CsvRowSet < RowSet + def each + return enum_for(:each) unless block_given? + + headers = nil + index = 0 + + CSV.foreach(@path, encoding: "bom|utf-8", liberal_parsing: true) do |values| + if headers.nil? + headers = UserRow.normalize_headers(values) + raise MalformedFile, "No recognisable columns found" if headers.compact.empty? + next + end + + next if values.all?(&:blank?) + + index += 1 + yield index, UserRow.from(headers, values) + end + rescue CSV::MalformedCSVError => error + raise MalformedFile, error.message + end + end +end diff --git a/app/imports/imports/row_set.rb b/app/imports/imports/row_set.rb new file mode 100644 index 000000000..63a8ec456 --- /dev/null +++ b/app/imports/imports/row_set.rb @@ -0,0 +1,20 @@ +module Imports + class RowSet + include Enumerable + + class MalformedFile < StandardError; end + + def self.for(import, path) + case import.format + when :xlsx then SpreadsheetRowSet.new(path) + else CsvRowSet.new(path) + end + end + + def initialize(path) + @path = path + end + + def count = @count ||= each.count + end +end diff --git a/app/imports/imports/spreadsheet_row_set.rb b/app/imports/imports/spreadsheet_row_set.rb new file mode 100644 index 000000000..41eeca789 --- /dev/null +++ b/app/imports/imports/spreadsheet_row_set.rb @@ -0,0 +1,28 @@ +module Imports + class SpreadsheetRowSet < RowSet + def each + return enum_for(:each) unless block_given? + + sheet = Roo::Excelx.new(@path) + headers = nil + index = 0 + + sheet.each_row_streaming(pad_cells: true) do |row| + values = row.map { _1&.value } + + if headers.nil? + headers = UserRow.normalize_headers(values) + raise MalformedFile, "No recognisable columns found" if headers.compact.empty? + next + end + + next if values.all?(&:blank?) + + index += 1 + yield index, UserRow.from(headers, values) + end + rescue Roo::Error, Zip::Error => error + raise MalformedFile, error.message + end + end +end diff --git a/spec/imports/imports/csv_row_set_spec.rb b/spec/imports/imports/csv_row_set_spec.rb new file mode 100644 index 000000000..388257404 --- /dev/null +++ b/spec/imports/imports/csv_row_set_spec.rb @@ -0,0 +1,59 @@ +require "rails_helper" + +RSpec.describe Imports::CsvRowSet do + def rows_from(content) + described_class.new(csv_file(content)).map { |index, row| [ index, row.full_name, row.email_address ] } + end + + it "yields each data row with a 1-based index" do + expect(rows_from("name,email\nGrace Hopper,grace@example.com\nAda Lovelace,ada@example.com\n")) + .to eq([ [ 1, "Grace Hopper", "grace@example.com" ], [ 2, "Ada Lovelace", "ada@example.com" ] ]) + end + + it "builds each row through the header aliases" do + _index, row = described_class.new(csv_file("Nome,E-mail,Perfil\nGrace Hopper,grace@example.com,admin\n")).first + + expect(row).to be_a(Imports::UserRow) + .and have_attributes(full_name: "Grace Hopper", email_address: "grace@example.com", role: "admin") + end + + it "skips blank lines without spending an index on them" do + rows = rows_from("name,email\n\nGrace Hopper,grace@example.com\n,\nAda Lovelace,ada@example.com\n") + + expect(rows.map(&:first)).to eq([ 1, 2 ]) + end + + it "reads a UTF-8 export that starts with a byte-order mark" do + expect(rows_from("\uFEFFname,email\nGrace Hopper,grace@example.com\n")) + .to eq([ [ 1, "Grace Hopper", "grace@example.com" ] ]) + end + + it "tolerates stray quotes inside an unquoted field" do + expect(rows_from(%(name,email\nGrace "Amazing" Hopper,grace@example.com\n))) + .to eq([ [ 1, %(Grace "Amazing" Hopper), "grace@example.com" ] ]) + end + + it "returns an enumerator when no block is given" do + expect(described_class.new(csv_file("name\nGrace Hopper\n")).each).to be_a(Enumerator) + end + + describe "files it cannot read" do + it "rejects a header with no recognisable columns" do + row_set = described_class.new(csv_file("department,office\nEngineering,London\n")) + + expect { row_set.to_a }.to raise_error(Imports::RowSet::MalformedFile, "No recognisable columns found") + end + + it "reports unparseable CSV as a malformed file" do + row_set = described_class.new(csv_file(%(name,email\n"Grace Hopper,grace@example.com\n))) + + expect { row_set.to_a }.to raise_error(Imports::RowSet::MalformedFile, /Unclosed quoted field/) + end + + it "reports text that is not UTF-8 as a malformed file rather than crashing" do + row_set = described_class.new(csv_file("name,email\nJosé Silva,jose@example.com\n".encode("Windows-1252"))) + + expect { row_set.to_a }.to raise_error(Imports::RowSet::MalformedFile, /Invalid byte sequence in UTF-8/) + end + end +end diff --git a/spec/imports/imports/row_set_spec.rb b/spec/imports/imports/row_set_spec.rb new file mode 100644 index 000000000..61322a70e --- /dev/null +++ b/spec/imports/imports/row_set_spec.rb @@ -0,0 +1,32 @@ +require "rails_helper" + +RSpec.describe Imports::RowSet do + describe ".for" do + it "reads a workbook upload as a spreadsheet" do + import = build(:import) + import.file.attach(io: StringIO.new(""), filename: "users.xlsx", + content_type: Import::CONTENT_TYPES.key(:xlsx), identify: false) + + expect(described_class.for(import, "users.xlsx")).to be_a(Imports::SpreadsheetRowSet) + end + + it "reads a CSV upload as CSV" do + expect(described_class.for(build(:import), "users.csv")).to be_a(Imports::CsvRowSet) + end + end + + describe "#count" do + it "counts data rows, leaving out the header and blank lines" do + path = csv_file("name,email\nGrace Hopper,grace@example.com\n\nAda Lovelace,ada@example.com\n") + + expect(Imports::CsvRowSet.new(path).count).to eq(2) + end + + it "reads the file once however often it is asked" do + row_set = Imports::CsvRowSet.new(csv_file("name\nGrace Hopper\n")) + + expect(CSV).to receive(:foreach).once.and_call_original + expect(2.times.map { row_set.count }).to eq([ 1, 1 ]) + end + end +end diff --git a/spec/imports/imports/spreadsheet_row_set_spec.rb b/spec/imports/imports/spreadsheet_row_set_spec.rb new file mode 100644 index 000000000..537768e91 --- /dev/null +++ b/spec/imports/imports/spreadsheet_row_set_spec.rb @@ -0,0 +1,60 @@ +require "rails_helper" + +RSpec.describe Imports::SpreadsheetRowSet do + def rows_from(rows) + described_class.new(xlsx_file(rows)).map { |index, row| [ index, row.full_name, row.email_address, row.role ] } + end + + it "yields each data row with a 1-based index, built through the header aliases" do + rows = rows_from([ + [ "Nome", "E-mail", "Perfil" ], + [ "Grace Hopper", "grace@example.com", "admin" ], + [ "Ada Lovelace", "ada@example.com", "member" ] + ]) + + expect(rows).to eq([ + [ 1, "Grace Hopper", "grace@example.com", "admin" ], + [ 2, "Ada Lovelace", "ada@example.com", "member" ] + ]) + end + + it "skips blank rows without spending an index on them" do + rows = rows_from([ + %w[name email], + [ "Grace Hopper", "grace@example.com" ], + [ nil, nil ], + [ "", "" ], + [ "Ada Lovelace", "ada@example.com" ] + ]) + + expect(rows.map(&:first)).to eq([ 1, 2 ]) + end + + it "keeps later cells in their own column when a row leaves one out" do + expect(rows_from([ %w[name email role], [ "Ada Lovelace", nil, "admin" ] ])) + .to eq([ [ 1, "Ada Lovelace", nil, "admin" ] ]) + end + + it "turns numeric cells into text" do + expect(rows_from([ %w[name email], [ 42, "answer@example.com" ] ])) + .to eq([ [ 1, "42", "answer@example.com", "member" ] ]) + end + + it "returns an enumerator when no block is given" do + expect(described_class.new(xlsx_file([ %w[name] ])).each).to be_a(Enumerator) + end + + describe "files it cannot read" do + it "rejects a header with no recognisable columns" do + row_set = described_class.new(xlsx_file([ %w[department office], %w[Engineering London] ])) + + expect { row_set.to_a }.to raise_error(Imports::RowSet::MalformedFile, "No recognisable columns found") + end + + it "reports a file that is not really a workbook as a malformed file" do + row_set = described_class.new(csv_file("name,email\n", name: "users.xlsx")) + + expect { row_set.to_a }.to raise_error(Imports::RowSet::MalformedFile, /end of central directory/) + end + end +end diff --git a/spec/support/import_file_helpers.rb b/spec/support/import_file_helpers.rb new file mode 100644 index 000000000..5ee8d4994 --- /dev/null +++ b/spec/support/import_file_helpers.rb @@ -0,0 +1,109 @@ +require "zip" + +# Throwaway CSV and xlsx files for the import row sets. The bundle has no xlsx +# writer, and a checked-in binary fixture would hide what each example feeds +# the parser, so workbooks are assembled from the minimum OOXML parts Roo needs +# to read a single sheet. +module ImportFileHelpers + SPREADSHEETML = "http://schemas.openxmlformats.org/spreadsheetml/2006/main".freeze + RELATIONSHIPS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships".freeze + PACKAGE = "http://schemas.openxmlformats.org/package/2006/relationships".freeze + CONTENT_TYPES = "http://schemas.openxmlformats.org/package/2006/content-types".freeze + OOXML_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml".freeze + + # Written as raw bytes so examples can hand in text in any encoding. + def csv_file(content, name: "users.csv") + import_file_path(name).tap { |path| File.binwrite(path, content) } + end + + # Each row is an array of cell values: strings, numbers, or nil for a cell + # that is absent from the sheet entirely. + def xlsx_file(rows, name: "users.xlsx") + import_file_path(name).tap do |path| + Zip::OutputStream.open(path) do |zip| + xlsx_parts(rows).each do |entry, xml| + zip.put_next_entry(entry) + zip.write(xml) + end + end + end + end + + private + + def import_file_path(name) + @import_file_dir ||= Dir.mktmpdir("import-files") + File.join(@import_file_dir, name) + end + + def xlsx_parts(rows) + { + "[Content_Types].xml" => <<~XML, + + + + + + + + + XML + "_rels/.rels" => <<~XML, + + + + + XML + "xl/workbook.xml" => <<~XML, + + + + + XML + "xl/_rels/workbook.xml.rels" => <<~XML, + + + + + + XML + "xl/styles.xml" => <<~XML, + + + XML + "xl/worksheets/sheet1.xml" => <<~XML + + #{xlsx_rows(rows)} + XML + } + end + + def xlsx_rows(rows) + rows.each_with_index.map do |values, row_index| + cells = values.each_with_index.map { |value, column| xlsx_cell("#{xlsx_column(column)}#{row_index + 1}", value) } + %(#{cells.join}) + end.join + end + + def xlsx_cell(ref, value) + case value + when nil then "" + when Numeric then %(#{value}) + else %(#{value.to_s.encode(xml: :text)}) + end + end + + def xlsx_column(index) + return ("A".ord + index).chr if index < 26 + + xlsx_column(index / 26 - 1) + xlsx_column(index % 26) + end +end + +RSpec.configure do |config| + config.include ImportFileHelpers, file_path: %r{spec/imports/} + + config.after(file_path: %r{spec/imports/}) do + FileUtils.remove_entry(@import_file_dir) if @import_file_dir + end +end From 3ec333a783de28b8a8ce3fb9c47e67e71cf5d4b5 Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Thu, 10 Sep 2026 11:04:45 -0300 Subject: [PATCH 50/82] feat: update UserForm to use remote_avatar_url and enhance form submission handling; add remote_avatar_url to UserSerializer and corresponding tests --- app/javascript/components/UserForm.tsx | 9 ++- app/javascript/types/index.ts | 1 + app/serializers/user_serializer.rb | 1 + spec/serializers/user_serializer_spec.rb | 12 ++++ spec/system/user_form_spec.rb | 85 ++++++++++++++++++++++++ 5 files changed, 105 insertions(+), 3 deletions(-) create mode 100644 spec/system/user_form_spec.rb diff --git a/app/javascript/components/UserForm.tsx b/app/javascript/components/UserForm.tsx index b95253da1..e070014ab 100644 --- a/app/javascript/components/UserForm.tsx +++ b/app/javascript/components/UserForm.tsx @@ -17,7 +17,7 @@ export default function UserForm({ user, roles, action, method, submitLabel }: P email_address: user?.email_address ?? '', password: '', password_confirmation: '', - avatar_url: user?.avatar_url ?? '', + avatar_url: user?.remote_avatar_url ?? '', avatar_image: null as File | null, role: user?.role ?? ('member' as UserRole), }) @@ -26,8 +26,11 @@ export default function UserForm({ user, roles, action, method, submitLabel }: P const submit = (event: FormEvent) => { event.preventDefault() - // Inertia cannot send multipart over PATCH. Spoof the verb and force FormData. - form.transform((current) => (method === 'patch' ? { ...current, _method: 'patch' } : current)) + + form.transform(({ avatar_image, ...fields }) => ({ + user: avatar_image ? { ...fields, avatar_image } : fields, + ...(method === 'patch' ? { _method: 'patch' } : {}), + })) form.post(action, { forceFormData: true, preserveScroll: true }) } diff --git a/app/javascript/types/index.ts b/app/javascript/types/index.ts index 9d4bd23a6..29843f772 100644 --- a/app/javascript/types/index.ts +++ b/app/javascript/types/index.ts @@ -13,6 +13,7 @@ export type User = { role: UserRole admin: boolean avatar_url: string | null + remote_avatar_url: string | null created_at: string } diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb index 74c37a446..b2bbfc86e 100644 --- a/app/serializers/user_serializer.rb +++ b/app/serializers/user_serializer.rb @@ -17,6 +17,7 @@ def as_json(*) role: user.role, admin: user.admin?, avatar_url: avatar_url, + remote_avatar_url: user.avatar_url.presence, created_at: user.created_at.iso8601 } end diff --git a/spec/serializers/user_serializer_spec.rb b/spec/serializers/user_serializer_spec.rb index 7120bb286..de6766b88 100644 --- a/spec/serializers/user_serializer_spec.rb +++ b/spec/serializers/user_serializer_spec.rb @@ -78,4 +78,16 @@ expect(described_class.collection(User.none)).to eq([]) end end + + describe "remote_avatar_url" do + it "is the stored remote URL even when an uploaded image is what gets displayed" do + user = create(:user, :with_avatar_image, avatar_url: "https://cdn.example.com/kept.png") + + expect(described_class.new(user).as_json[:remote_avatar_url]).to eq("https://cdn.example.com/kept.png") + end + + it "is nil when only an image was uploaded, so the form never posts a storage path back" do + expect(described_class.new(create(:user, :with_avatar_image)).as_json[:remote_avatar_url]).to be_nil + end + end end diff --git a/spec/system/user_form_spec.rb b/spec/system/user_form_spec.rb new file mode 100644 index 000000000..dba1fd8b6 --- /dev/null +++ b/spec/system/user_form_spec.rb @@ -0,0 +1,85 @@ +require "rails_helper" + +RSpec.describe "The user form", type: :system, js: true do + let(:admin) { create(:user, :admin, full_name: "Ada Lovelace", email_address: "ada@example.com") } + let(:member) { create(:user, full_name: "Grace Hopper", email_address: "grace@example.com") } + + def sign_in_through_the_form(user) + visit new_session_path + fill_in "email_address", with: user.email_address + fill_in "password", with: "password" + click_on "Sign in" + expect(page).to have_no_current_path(new_session_path, wait: 5) + end + + def fill_in_new_user + fill_in "Full name", with: "Margaret Hamilton" + fill_in "Email", with: "margaret@example.com" + fill_in "Password", with: "password" + fill_in "Confirm password", with: "password" + end + + describe "an admin creating a user" do + it "creates the user with the chosen role" do + sign_in_through_the_form(admin) + visit new_admin_user_path + + fill_in_new_user + select "admin", from: "Role" + click_on "Create user" + + expect(page).to have_css("[role=status]", text: "Margaret Hamilton was created.") + expect(User.find_by(email_address: "margaret@example.com")).to be_admin + end + + it "attaches an uploaded avatar" do + sign_in_through_the_form(admin) + visit new_admin_user_path + + fill_in_new_user + attach_file "Avatar upload", Rails.root.join("spec/fixtures/files/avatar.png") + click_on "Create user" + + expect(page).to have_css("[role=status]", text: "Margaret Hamilton was created.") + expect(User.find_by(email_address: "margaret@example.com").avatar_image).to be_attached + end + + it "shows validation errors on the form" do + sign_in_through_the_form(admin) + visit new_admin_user_path + + fill_in "Email", with: "margaret@example.com" + click_on "Create user" + + expect(page).to have_text("can't be blank") + expect(User.find_by(email_address: "margaret@example.com")).to be_nil + end + end + + describe "an admin editing a user" do + it "keeps the current avatar when no new file is picked" do + target = create(:user, :with_avatar_image, full_name: "Grace Hopper", email_address: "grace@example.com") + sign_in_through_the_form(admin) + visit edit_admin_user_path(target) + + fill_in "Full name", with: "Grace M. Hopper" + click_on "Save changes" + + expect(page).to have_css("[role=status]", text: "Grace M. Hopper was updated.") + expect(target.reload.avatar_image).to be_attached + end + end + + describe "a member editing their profile" do + it "saves the change" do + sign_in_through_the_form(member) + visit edit_profile_path + + fill_in "Full name", with: "Grace M. Hopper" + click_on "Save changes" + + expect(page).to have_css("[role=status]", text: "Profile updated.") + expect(member.reload.full_name).to eq("Grace M. Hopper") + end + end +end From 2181528afa73bd32e2b2c70e40fa9429eb6b56c8 Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Thu, 10 Sep 2026 11:07:49 -0300 Subject: [PATCH 51/82] feat: add initializer for RubyZip configuration to manage date warnings and entry size validation --- app/imports/imports/user_importer.rb | 27 +++++++++++++++++++++++++++ config/initializers/rubyzip.rb | 4 ++++ 2 files changed, 31 insertions(+) create mode 100644 app/imports/imports/user_importer.rb create mode 100644 config/initializers/rubyzip.rb diff --git a/app/imports/imports/user_importer.rb b/app/imports/imports/user_importer.rb new file mode 100644 index 000000000..a1c6482a7 --- /dev/null +++ b/app/imports/imports/user_importer.rb @@ -0,0 +1,27 @@ +module Imports + class UserImporter + Result = Data.define(:outcome, :errors) do + def created? = outcome == :created + def skipped? = outcome == :skipped + def failed? = outcome == :failed + end + + def call(row) + return Result.new(outcome: :failed, errors: row.errors.full_messages) if row.invalid? + + user = User.find_or_initialize_by(email_address: row.normalized_email) + return Result.new(outcome: :skipped, errors: []) if user.persisted? + + user.assign_attributes(row.to_user_attributes) + + if user.save + Result.new(outcome: :created, errors: []) + else + Result.new(outcome: :failed, errors: user.errors.full_messages) + end + rescue ActiveRecord::RecordNotUnique + # Lost a race with a concurrent import or signup on the unique index. + Result.new(outcome: :skipped, errors: []) + end + end +end diff --git a/config/initializers/rubyzip.rb b/config/initializers/rubyzip.rb new file mode 100644 index 000000000..0271ef9e5 --- /dev/null +++ b/config/initializers/rubyzip.rb @@ -0,0 +1,4 @@ +Zip.setup do |config| + config.warn_invalid_date = false + config.validate_entry_sizes = true +end From 6d6d04cf9f1057f9511ae217063c8ba0221f9b19 Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Thu, 10 Sep 2026 11:09:52 -0300 Subject: [PATCH 52/82] feat: add tests for Imports::UserImporter to validate user creation, skipping, and error handling --- spec/imports/imports/user_importer_spec.rb | 98 ++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 spec/imports/imports/user_importer_spec.rb diff --git a/spec/imports/imports/user_importer_spec.rb b/spec/imports/imports/user_importer_spec.rb new file mode 100644 index 000000000..4eda19fc8 --- /dev/null +++ b/spec/imports/imports/user_importer_spec.rb @@ -0,0 +1,98 @@ +require "rails_helper" + +RSpec.describe Imports::UserImporter do + subject(:importer) { described_class.new } + + def row(**attributes) + Imports::UserRow.new(full_name: "Grace Hopper", email_address: "grace@example.com", **attributes) + end + + describe "a row for a new email address" do + it "creates the user" do + result = nil + + expect { result = importer.call(row(role: "admin", avatar_url: "https://cdn.example.com/grace.png")) } + .to change(User, :count).by(1) + + expect(result).to be_created + expect(result.errors).to eq([]) + expect(User.find_by(email_address: "grace@example.com")) + .to have_attributes(full_name: "Grace Hopper", role: "admin", avatar_url: "https://cdn.example.com/grace.png") + end + end + + describe "a row for an email address that already has an account" do + let!(:existing) { create(:user, full_name: "Grace Brewster Hopper", email_address: "grace@example.com") } + + it "skips the row and leaves the account as it was" do + result = nil + + expect { result = importer.call(row(full_name: "Someone Else", role: "admin")) }.not_to change(User, :count) + + expect(result).to be_skipped + expect(result.errors).to eq([]) + expect(existing.reload).to have_attributes(full_name: "Grace Brewster Hopper", role: "member") + end + + it "matches the account regardless of case" do + expect(importer.call(row(email_address: "GRACE@Example.COM"))).to be_skipped + end + end + + describe "a row that fails its own validation" do + it "reports the row's errors without creating anyone" do + result = nil + + expect { result = importer.call(row(full_name: "", role: "owner")) }.not_to change(User, :count) + + expect(result).to be_failed + expect(result.errors).to include("Full name can't be blank", "Role must be admin or member") + end + + it "never looks the email address up" do + expect(User).not_to receive(:find_or_initialize_by) + + importer.call(row(email_address: "not an email")) + end + end + + describe "a row the User model rejects" do + it "reports the model's errors" do + user = User.new + allow(User).to receive(:find_or_initialize_by).and_return(user) + allow(user).to receive(:save) { user.errors.add(:avatar_image, "is too big") && false } + + result = importer.call(row) + + expect(result).to be_failed + expect(result.errors).to eq([ "Avatar image is too big" ]) + end + end + + describe "losing a race on the unique index" do + it "treats the row as skipped" do + create(:user, email_address: "grace@example.com") + fresh = User.new + allow(User).to receive(:find_or_initialize_by).and_return(fresh) + # The other writer committed after both our lookup and our uniqueness + # check, so only the database index is left to stop the insert. + allow(fresh).to receive(:save).and_wrap_original { |original| original.call(validate: false) } + + result = nil + + expect { result = importer.call(row) }.not_to change(User, :count) + expect(result).to be_skipped + end + end + + describe Imports::UserImporter::Result do + it "answers true to exactly one outcome predicate" do + %i[created skipped failed].each do |outcome| + result = described_class.new(outcome: outcome, errors: []) + + expect([ result.created?, result.skipped?, result.failed? ]) + .to eq(%i[created skipped failed].map { _1 == outcome }) + end + end + end +end From c83459d217650d45fbfd87a2c0fab2d74ba5d81d Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Thu, 10 Sep 2026 11:41:28 -0300 Subject: [PATCH 53/82] feat: implement suppression mechanism for dashboard broadcasts and add corresponding tests --- app/jobs/process_import_job.rb | 99 +++++++++++++ app/models/concerns/dashboard_broadcasts.rb | 15 ++ app/models/user.rb | 7 +- spec/jobs/process_import_job_spec.rb | 134 ++++++++++++++++++ .../concerns/dashboard_broadcasts_spec.rb | 46 ++++++ spec/models/user_spec.rb | 25 ++++ 6 files changed, 324 insertions(+), 2 deletions(-) create mode 100644 app/jobs/process_import_job.rb create mode 100644 app/models/concerns/dashboard_broadcasts.rb create mode 100644 spec/jobs/process_import_job_spec.rb create mode 100644 spec/models/concerns/dashboard_broadcasts_spec.rb diff --git a/app/jobs/process_import_job.rb b/app/jobs/process_import_job.rb new file mode 100644 index 000000000..222d6d4a0 --- /dev/null +++ b/app/jobs/process_import_job.rb @@ -0,0 +1,99 @@ +class ProcessImportJob < ApplicationJob + include ActiveJob::Continuable + + BATCH_SIZE = 100 + + queue_as :imports + retry_on Imports::RowSet::MalformedFile, attempts: 1 + discard_on ActiveJob::DeserializationError + + def perform(import) + @import = import + return if @import.finished? + + step :count_rows + step :import_rows, start: 0 + step :finalize + end + + private + + attr_reader :import + + def count_rows + import.update!(status: :parsing, started_at: Time.current) + + download { |path| import.update!(total_rows: Imports::RowSet.for(import, path).count) } + + import.update!(status: :processing) + Imports::ProgressBroadcaster.call(import) + rescue Imports::RowSet::MalformedFile => error + fail_with(error.message) + raise + end + + def import_rows(step) + importer = Imports::UserImporter.new + tally = Hash.new(0) + + download do |path| + DashboardBroadcasts.suppress do + Imports::RowSet.for(import, path).each do |index, row| + next if index <= step.cursor + + apply(importer, index, row, tally) + + if index % BATCH_SIZE == 0 + flush(tally, index) + step.set! index + end + end + end + end + + flush(tally, import.total_rows) + step.set! import.total_rows + end + + def finalize + import.update!(status: :completed, finished_at: Time.current) + Dashboard::Broadcaster.call + Imports::ProgressBroadcaster.call(import) + end + + def apply(importer, index, row, tally) + result = importer.call(row) + + case result.outcome + when :created then tally[:created_count] += 1 + when :skipped then tally[:skipped_count] += 1 + when :failed + tally[:failed_count] += 1 + import.record_error(index, row.email_address, result.errors) + end + end + + def flush(tally, processed) + return if tally.empty? && import.processed_rows == processed + + import.update_columns( + processed_rows: processed, + created_count: import.created_count + tally[:created_count], + skipped_count: import.skipped_count + tally[:skipped_count], + failed_count: import.failed_count + tally[:failed_count], + error_report: import.error_report, + updated_at: Time.current + ) + tally.clear + Imports::ProgressBroadcaster.call(import) + end + + def download(&) + import.file.open(tmpdir: Dir.tmpdir, &) + end + + def fail_with(reason) + import.update!(status: :failed, failure_reason: reason, finished_at: Time.current) + Imports::ProgressBroadcaster.call(import) + end +end diff --git a/app/models/concerns/dashboard_broadcasts.rb b/app/models/concerns/dashboard_broadcasts.rb new file mode 100644 index 000000000..5f673df83 --- /dev/null +++ b/app/models/concerns/dashboard_broadcasts.rb @@ -0,0 +1,15 @@ +module DashboardBroadcasts + KEY = :suppress_dashboard_broadcasts + + def self.suppressed? + ActiveSupport::IsolatedExecutionState[KEY].present? + end + + def self.suppress + previous = ActiveSupport::IsolatedExecutionState[KEY] + ActiveSupport::IsolatedExecutionState[KEY] = true + yield + ensure + ActiveSupport::IsolatedExecutionState[KEY] = previous + end +end diff --git a/app/models/user.rb b/app/models/user.rb index a73c2582d..d7d759d45 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -29,10 +29,13 @@ class User < ApplicationRecord before_destroy :ensure_not_last_admin, prepend: true validate :admin_headcount_preserved, on: :update - after_commit :broadcast_dashboard_stats, on: %i[create destroy] + after_commit :broadcast_dashboard_stats, on: %i[create destroy], + unless: -> { DashboardBroadcasts.suppressed? } # Block form on purpose: `after_commit` dedupes by filter, so the same symbol # registered twice would drop the declaration above. - after_commit(on: :update, if: :saved_change_to_role?) { broadcast_dashboard_stats } + after_commit(on: :update, if: -> { saved_change_to_role? && !DashboardBroadcasts.suppressed? }) do + broadcast_dashboard_stats + end def avatar_source return avatar_image if avatar_image.attached? diff --git a/spec/jobs/process_import_job_spec.rb b/spec/jobs/process_import_job_spec.rb new file mode 100644 index 000000000..6b8d929fb --- /dev/null +++ b/spec/jobs/process_import_job_spec.rb @@ -0,0 +1,134 @@ +require "rails_helper" +require "active_job/continuation/test_helper" + +RSpec.describe ProcessImportJob, type: :job do + include ActiveJob::Continuation::TestHelper + + let(:progress) { [] } + + # Imports::ProgressBroadcaster does not exist yet. A recorder stands in for it + # so the job can run, and so each broadcast's view of the import can be checked. + before do + snapshots = progress + stub_const("Imports::ProgressBroadcaster", Module.new do + define_singleton_method(:call) { |import| snapshots << [ import.status, import.processed_rows ] } + end) + end + + def csv(*emails) + lines = emails.map { |email| "#{email.split("@").first.capitalize} Person,#{email}" } + ([ "name,email" ] + lines).join("\n") + "\n" + end + + def emails(count) = (1..count).map { "row#{_1}@example.com" } + + def import_with(content) + create(:import).tap do |import| + import.file.attach(io: StringIO.new(content), filename: "users.csv", content_type: "text/csv") + end + end + + it "runs on the imports queue" do + expect(described_class.new.queue_name).to eq("imports") + end + + describe "a clean run" do + before { create(:user, email_address: "taken@example.com") } + + let!(:import) { import_with(csv("new1@example.com", "taken@example.com", "not-an-email", "new2@example.com")) } + + it "creates, skips and fails rows and keeps the tallies" do + expect { described_class.perform_now(import) }.to change(User, :count).by(2) + + expect(import.reload).to have_attributes( + status: "completed", total_rows: 4, processed_rows: 4, + created_count: 2, skipped_count: 1, failed_count: 1 + ) + expect(import.started_at).to be_present + expect(import.finished_at).to be >= import.started_at + end + + it "reports each failed row with its index and email" do + described_class.perform_now(import) + + expect(import.reload.error_report).to contain_exactly( + a_hash_including("row" => 3, "identifier" => "not-an-email", "errors" => include("Email address is invalid")) + ) + end + + it "broadcasts progress once counted, after the rows, and when done" do + described_class.perform_now(import) + + expect(progress).to eq([ [ "processing", 0 ], [ "processing", 4 ], [ "completed", 4 ] ]) + end + + it "notifies the dashboard once rather than once per created user" do + expect { described_class.perform_now(import) } + .to have_broadcasted_to(Dashboard::Broadcaster::STREAM).exactly(:once) + end + end + + describe "working in batches" do + before { stub_const("ProcessImportJob::BATCH_SIZE", 2) } + + let!(:import) { import_with(csv(*emails(5))) } + + it "saves progress after every full batch and after the last partial one" do + described_class.perform_now(import) + + expect(progress).to eq([ + [ "processing", 0 ], [ "processing", 2 ], [ "processing", 4 ], [ "processing", 5 ], [ "completed", 5 ] + ]) + end + + it "resumes after the last saved batch when interrupted, without redoing rows" do + described_class.perform_later(import) + + interrupt_job_during_step(described_class, :import_rows, cursor: 2) { perform_enqueued_jobs } + + expect(import.reload).to have_attributes(status: "processing", processed_rows: 2, created_count: 2) + + perform_enqueued_jobs + + expect(import.reload).to have_attributes( + status: "completed", processed_rows: 5, created_count: 5, skipped_count: 0 + ) + end + end + + it "leaves an import that has already finished alone" do + import = import_with(csv("new1@example.com")) + import.update!(status: :completed) + + expect { described_class.perform_now(import) }.not_to change(User, :count) + expect(progress).to be_empty + end + + it "completes a file that has a header and no rows" do + import = import_with("name,email\n") + + described_class.perform_now(import) + + expect(import.reload).to have_attributes(status: "completed", total_rows: 0, processed_rows: 0) + end + + describe "a file it cannot read" do + let!(:import) { import_with("department,office\nEngineering,London\n") } + + it "marks the import failed with the reason, then lets the error through" do + expect { described_class.perform_now(import) }.to raise_error(Imports::RowSet::MalformedFile) + + expect(import.reload).to have_attributes(status: "failed", failure_reason: "No recognisable columns found") + expect(import.finished_at).to be_present + expect(progress.last).to eq([ "failed", 0 ]) + end + end + + it "discards the job when the import has since been deleted" do + import = import_with(csv("new1@example.com")) + described_class.perform_later(import) + import.delete + + expect { perform_enqueued_jobs }.not_to raise_error + end +end diff --git a/spec/models/concerns/dashboard_broadcasts_spec.rb b/spec/models/concerns/dashboard_broadcasts_spec.rb new file mode 100644 index 000000000..995c868f1 --- /dev/null +++ b/spec/models/concerns/dashboard_broadcasts_spec.rb @@ -0,0 +1,46 @@ +require "rails_helper" + +RSpec.describe DashboardBroadcasts do + it "is not suppressed by default" do + expect(described_class).not_to be_suppressed + end + + it "is suppressed inside the block and not after it" do + inside = nil + + described_class.suppress { inside = described_class.suppressed? } + + expect(inside).to be(true) + expect(described_class).not_to be_suppressed + end + + it "returns the block's value" do + expect(described_class.suppress { :done }).to eq(:done) + end + + it "lifts suppression even when the block raises" do + expect { described_class.suppress { raise "boom" } }.to raise_error("boom") + + expect(described_class).not_to be_suppressed + end + + it "stays suppressed after a nested block finishes inside an outer one" do + still_suppressed = nil + + described_class.suppress do + described_class.suppress { nil } + still_suppressed = described_class.suppressed? + end + + expect(still_suppressed).to be(true) + expect(described_class).not_to be_suppressed + end + + it "does not reach other threads" do + elsewhere = nil + + described_class.suppress { elsewhere = Thread.new { described_class.suppressed? }.value } + + expect(elsewhere).to be(false) + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 10d9928ca..4a007e085 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -370,5 +370,30 @@ def raw_email_column(record) expect { user.save }.not_to have_broadcasted_to(stream) end + + describe "while broadcasts are suppressed" do + it "stays quiet for a create" do + expect { DashboardBroadcasts.suppress { create(:user) } }.not_to have_broadcasted_to(stream) + end + + it "stays quiet for a destroy" do + user = create(:user) + + expect { DashboardBroadcasts.suppress { user.destroy } }.not_to have_broadcasted_to(stream) + end + + it "stays quiet for a role change" do + create(:user, :admin) + member = create(:user) + + expect { DashboardBroadcasts.suppress { member.update!(role: :admin) } }.not_to have_broadcasted_to(stream) + end + + it "broadcasts again once the block is done" do + DashboardBroadcasts.suppress { create(:user) } + + expect { create(:user) }.to have_broadcasted_to(stream) + end + end end end From 9a3039aed2f11b1f1e00388d081cc3ee52577f7e Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Thu, 10 Sep 2026 11:42:55 -0300 Subject: [PATCH 54/82] feat: update production queue configuration to define dispatchers and workers for improved job handling --- config/queue.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/config/queue.yml b/config/queue.yml index 6b1436086..aacfe751d 100644 --- a/config/queue.yml +++ b/config/queue.yml @@ -15,4 +15,12 @@ test: <<: *default production: - <<: *default + dispatchers: + - polling_interval: 1 + batch_size: 500 + workers: + - queues: [default] + threads: 3 + - queues: [imports] + threads: 1 + processes: 1 From ac73f3f22c23016aae4d1f51ed5249e89c22b54a Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Thu, 10 Sep 2026 11:45:30 -0300 Subject: [PATCH 55/82] feat: implement ImportChannel and ProgressBroadcaster for real-time import updates; add corresponding tests --- app/channels/import_channel.rb | 8 +++ app/imports/imports/progress_broadcaster.rb | 9 +++ spec/channels/import_channel_spec.rb | 56 +++++++++++++++++++ spec/factories/imports.rb | 17 ++++-- .../imports/progress_broadcaster_spec.rb | 25 +++++++++ spec/jobs/process_import_job_spec.rb | 4 +- 6 files changed, 111 insertions(+), 8 deletions(-) create mode 100644 app/channels/import_channel.rb create mode 100644 app/imports/imports/progress_broadcaster.rb create mode 100644 spec/channels/import_channel_spec.rb create mode 100644 spec/imports/imports/progress_broadcaster_spec.rb diff --git a/app/channels/import_channel.rb b/app/channels/import_channel.rb new file mode 100644 index 000000000..5e9ca8727 --- /dev/null +++ b/app/channels/import_channel.rb @@ -0,0 +1,8 @@ +class ImportChannel < ApplicationCable::Channel + def subscribed + import = Import.find_by(id: params[:id]) + return reject unless import && current_user&.admin? + + stream_from Imports::ProgressBroadcaster.stream_for(import) + end +end diff --git a/app/imports/imports/progress_broadcaster.rb b/app/imports/imports/progress_broadcaster.rb new file mode 100644 index 000000000..bb83bba38 --- /dev/null +++ b/app/imports/imports/progress_broadcaster.rb @@ -0,0 +1,9 @@ +module Imports + class ProgressBroadcaster + def self.stream_for(import) = "import:#{import.id}" + + def self.call(import) + ActionCable.server.broadcast(stream_for(import), { type: "import.changed", id: import.id }) + end + end +end diff --git a/spec/channels/import_channel_spec.rb b/spec/channels/import_channel_spec.rb new file mode 100644 index 000000000..ca54a18fb --- /dev/null +++ b/spec/channels/import_channel_spec.rb @@ -0,0 +1,56 @@ +require "rails_helper" + +RSpec.describe ImportChannel, type: :channel do + let(:import) { create(:import) } + + it "subscribes an admin to that import's stream" do + stub_connection current_user: create(:user, :admin) + + subscribe id: import.id + + expect(subscription).to be_confirmed + expect(subscription).to have_stream_from(Imports::ProgressBroadcaster.stream_for(import)) + end + + it "does not stream any other import to that subscriber" do + other = create(:import) + stub_connection current_user: create(:user, :admin) + + subscribe id: import.id + + expect(subscription).not_to have_stream_from(Imports::ProgressBroadcaster.stream_for(other)) + end + + it "turns a member away, even from an import in their own name" do + member = create(:user) + stub_connection current_user: member + + subscribe id: create(:import, user: member).id + + expect(subscription).to be_rejected + end + + it "turns away a connection with no identified user" do + stub_connection current_user: nil + + subscribe id: import.id + + expect(subscription).to be_rejected + end + + it "rejects an id that matches no import" do + stub_connection current_user: create(:user, :admin) + + subscribe id: 0 + + expect(subscription).to be_rejected + end + + it "rejects a subscription that names no import" do + stub_connection current_user: create(:user, :admin) + + subscribe + + expect(subscription).to be_rejected + end +end diff --git a/spec/factories/imports.rb b/spec/factories/imports.rb index 833e9fcb0..573ff0aef 100644 --- a/spec/factories/imports.rb +++ b/spec/factories/imports.rb @@ -1,13 +1,18 @@ FactoryBot.define do factory :import do - user + user { association :user, :admin } + + transient { rows { [["Ada Lovelace", "ada@example.test", "admin"]] } } + + after(:build) do |import, evaluator| + csv = CSV.generate do |out| + out << %w[full_name email role] + evaluator.rows.each { out << _1 } + end - after(:build) do |import| import.file.attach( - io: Rails.root.join("spec/fixtures/files/users.csv").open, - filename: "users.csv", - content_type: "text/csv" + io: StringIO.new(csv), filename: "users.csv", content_type: "text/csv" ) end end -end +end \ No newline at end of file diff --git a/spec/imports/imports/progress_broadcaster_spec.rb b/spec/imports/imports/progress_broadcaster_spec.rb new file mode 100644 index 000000000..7fe7b4822 --- /dev/null +++ b/spec/imports/imports/progress_broadcaster_spec.rb @@ -0,0 +1,25 @@ +require "rails_helper" + +RSpec.describe Imports::ProgressBroadcaster do + let!(:import) { create(:import) } + + describe ".stream_for" do + it "names one stream per import" do + expect(described_class.stream_for(import)).to eq("import:#{import.id}") + end + end + + describe ".call" do + it "tells that import's subscribers it changed" do + expect { described_class.call(import) } + .to have_broadcasted_to("import:#{import.id}") + .with(type: "import.changed", id: import.id) + end + + it "leaves other imports' streams quiet" do + other = create(:import) + + expect { described_class.call(import) }.not_to have_broadcasted_to("import:#{other.id}") + end + end +end diff --git a/spec/jobs/process_import_job_spec.rb b/spec/jobs/process_import_job_spec.rb index 6b8d929fb..e18390cbe 100644 --- a/spec/jobs/process_import_job_spec.rb +++ b/spec/jobs/process_import_job_spec.rb @@ -6,8 +6,8 @@ let(:progress) { [] } - # Imports::ProgressBroadcaster does not exist yet. A recorder stands in for it - # so the job can run, and so each broadcast's view of the import can be checked. + # A recorder stands in for Imports::ProgressBroadcaster so the status and row + # count each broadcast would have announced can be checked, in order. before do snapshots = progress stub_const("Imports::ProgressBroadcaster", Module.new do From e2ff952fe8cb2035b31050a748ed138df3eaa5be Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Thu, 10 Sep 2026 11:53:44 -0300 Subject: [PATCH 56/82] feat: add admin imports management with CRUD actions, serializers, and real-time progress updates --- app/controllers/admin/imports_controller.rb | 44 +++++++ app/javascript/hooks/useDashboardStream.ts | 5 +- app/javascript/lib/cable.ts | 10 ++ app/javascript/pages/Admin/Imports/Show.tsx | 110 ++++++++++++++++ app/javascript/types/index.ts | 26 ++++ app/serializers/import_serializer.rb | 23 ++++ config/routes.rb | 2 + spec/models/import_spec.rb | 4 +- spec/requests/admin/imports_spec.rb | 137 ++++++++++++++++++++ spec/serializers/import_serializer_spec.rb | 42 ++++++ spec/system/import_page_spec.rb | 53 ++++++++ 11 files changed, 451 insertions(+), 5 deletions(-) create mode 100644 app/controllers/admin/imports_controller.rb create mode 100644 app/javascript/lib/cable.ts create mode 100644 app/javascript/pages/Admin/Imports/Show.tsx create mode 100644 app/serializers/import_serializer.rb create mode 100644 spec/requests/admin/imports_spec.rb create mode 100644 spec/serializers/import_serializer_spec.rb create mode 100644 spec/system/import_page_spec.rb diff --git a/app/controllers/admin/imports_controller.rb b/app/controllers/admin/imports_controller.rb new file mode 100644 index 000000000..ea628db34 --- /dev/null +++ b/app/controllers/admin/imports_controller.rb @@ -0,0 +1,44 @@ +module Admin + class ImportsController < ApplicationController + before_action :authorize_admin! + + def index + render inertia: "Admin/Imports/Index", props: { + imports: -> { ImportSerializer.collection(scope.recent.limit(25)) } + } + end + + def new + render inertia: "Admin/Imports/New" + end + + def show + import = scope.find(params[:id]) + + render inertia: "Admin/Imports/Show", props: { + import: -> { ImportSerializer.new(import).as_json } + } + end + + def create + import = Current.user.imports.new(import_params) + + if import.save + ProcessImportJob.perform_later(import) + redirect_to admin_import_path(import), notice: "Import queued." + else + redirect_to new_admin_import_path, inertia: { errors: import.errors } + end + end + + private + + def scope = Import.all + + def import_params = params.expect(import: [ :file ]) + + def authorize_admin! + raise Authorization::NotAuthorizedError unless Current.user&.admin? + end + end +end diff --git a/app/javascript/hooks/useDashboardStream.ts b/app/javascript/hooks/useDashboardStream.ts index aeeb4d467..1983f0315 100644 --- a/app/javascript/hooks/useDashboardStream.ts +++ b/app/javascript/hooks/useDashboardStream.ts @@ -1,9 +1,6 @@ -import { createConsumer, type Consumer } from '@rails/actioncable' import { router } from '@inertiajs/react' import { useEffect, useRef } from 'react' - -let consumer: Consumer | null = null -const getConsumer = () => (consumer ??= createConsumer()) +import { getConsumer } from '@/lib/cable' export function useDashboardStream() { const pending = useRef(null) diff --git a/app/javascript/lib/cable.ts b/app/javascript/lib/cable.ts new file mode 100644 index 000000000..63a248923 --- /dev/null +++ b/app/javascript/lib/cable.ts @@ -0,0 +1,10 @@ +import { createConsumer, type Consumer } from '@rails/actioncable' + +let consumer: Consumer | null = null + +/** + * One cable connection for the whole app. Every createConsumer() opens its own + * WebSocket and unsubscribing never closes it, so a consumer per component + * leaves a socket behind each time that component's effect runs. + */ +export const getConsumer = () => (consumer ??= createConsumer()) diff --git a/app/javascript/pages/Admin/Imports/Show.tsx b/app/javascript/pages/Admin/Imports/Show.tsx new file mode 100644 index 000000000..15bb7d89f --- /dev/null +++ b/app/javascript/pages/Admin/Imports/Show.tsx @@ -0,0 +1,110 @@ +import { useEffect, useRef } from 'react' +import { Head, router } from '@inertiajs/react' +import AppLayout from '@/layouts/AppLayout' +import { getConsumer } from '@/lib/cable' +import type { Import } from '@/types' + +/** Props from Admin::ImportsController#show. */ +type Props = { import: Import } + +export default function Show({ import: record }: Props) { + const finished = record.finished + const pending = useRef(null) + + useEffect(() => { + if (finished) return + + const refresh = () => router.reload({ only: ['import'] }) + + const subscription = getConsumer().subscriptions.create( + { channel: 'ImportChannel', id: record.id }, + { + connected: refresh, + received() { + if (pending.current) return + pending.current = window.setTimeout(() => { + pending.current = null + refresh() + }, 300) + }, + }, + ) + + return () => { + if (pending.current) clearTimeout(pending.current) + // Reset too: a stale id left here would make the next subscription's + // received() bail out on every message. + pending.current = null + subscription.unsubscribe() + } + }, [record.id, finished]) + + return ( + <> + + +

{record.filename}

+

Status: {record.status}

+ +
+
+
+
+

+ {record.processed_rows.toLocaleString()} of {record.total_rows.toLocaleString()} rows + {' '}({record.progress}%) +

+
+ +
+ {[ + ['Created', record.created_count], + ['Skipped', record.skipped_count], + ['Failed', record.failed_count], + ].map(([label, count]) => ( +
+
{label}
+
{count as number}
+
+ ))} +
+ + {record.failure_reason && ( +

+ {record.failure_reason} +

+ )} + + {record.error_report.length > 0 && ( +
+

+ Rejected rows + {record.failed_count > record.error_report.length && + ` (showing ${record.error_report.length} of ${record.failed_count})`} +

+
    + {record.error_report.map((entry) => ( +
  • + Row {entry.row} + {entry.identifier || '(no email)'} + {entry.errors.join(', ')} +
  • + ))} +
+
+ )} + + ) +} + +Show.layout = AppLayout diff --git a/app/javascript/types/index.ts b/app/javascript/types/index.ts index 29843f772..f823453b8 100644 --- a/app/javascript/types/index.ts +++ b/app/javascript/types/index.ts @@ -46,3 +46,29 @@ export type DashboardStats = { by_role: Record generated_at: string } + +export type ImportStatus = 'pending' | 'parsing' | 'processing' | 'completed' | 'failed' | 'cancelled' + +/** One entry of Import#error_report, as written by Import#record_error. */ +export type ImportRowError = { + row: number + identifier: string | null + errors: string[] +} + +/** Mirrors ImportSerializer#as_json. */ +export type Import = { + id: number + status: ImportStatus + filename: string + progress: number + total_rows: number + processed_rows: number + created_count: number + skipped_count: number + failed_count: number + failure_reason: string | null + finished: boolean + error_report: ImportRowError[] + created_at: string +} diff --git a/app/serializers/import_serializer.rb b/app/serializers/import_serializer.rb new file mode 100644 index 000000000..ed26264f5 --- /dev/null +++ b/app/serializers/import_serializer.rb @@ -0,0 +1,23 @@ +class ImportSerializer + def self.collection(imports) = imports.map { new(_1).as_json } + + def initialize(import) = @import = import + + def as_json(*) + { + id: @import.id, + status: @import.status, + filename: @import.file.filename.to_s, + progress: @import.progress, + total_rows: @import.total_rows, + processed_rows: @import.processed_rows, + created_count: @import.created_count, + skipped_count: @import.skipped_count, + failed_count: @import.failed_count, + failure_reason: @import.failure_reason, + finished: @import.finished?, + error_report: @import.error_report.first(50), + created_at: @import.created_at.iso8601 + } + end +end diff --git a/config/routes.rb b/config/routes.rb index b07063596..afa818a9d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -11,6 +11,8 @@ resources :users do resource :role, only: :update, controller: "user_roles" end + + resources :imports, only: %i[index new create show] end # Redirect to localhost from 127.0.0.1 to use same IP address with Vite server diff --git a/spec/models/import_spec.rb b/spec/models/import_spec.rb index b64e53f16..ebf6bd338 100644 --- a/spec/models/import_spec.rb +++ b/spec/models/import_spec.rb @@ -15,7 +15,9 @@ def attach_file(record, content: "full_name\n", filename: "users.csv", content_t it { is_expected.to belong_to(:user) } it "is destroyed along with its user" do - import = create(:import) + # A member owner on purpose: the factory's default owner is an admin, and + # the last admin cannot be destroyed at all. + import = create(:import, user: create(:user)) expect { import.user.destroy }.to change(described_class, :count).by(-1) end diff --git a/spec/requests/admin/imports_spec.rb b/spec/requests/admin/imports_spec.rb new file mode 100644 index 000000000..52128ad84 --- /dev/null +++ b/spec/requests/admin/imports_spec.rb @@ -0,0 +1,137 @@ +require "rails_helper" + +RSpec.describe "Admin::Imports", type: :request do + def props = inertia.props.deep_symbolize_keys + + def upload(name = "users.csv", type = "text/csv") = fixture_file_upload(name, type) + + let(:admin) { create(:user, :admin, full_name: "Ada Lovelace", email_address: "ada@example.com") } + let(:member) { create(:user, full_name: "Grace Hopper", email_address: "grace@example.com") } + + describe "GET /admin/imports" do + it "turns away a visitor who is not signed in" do + get admin_imports_path + + expect(response).to redirect_to(new_session_url) + end + + it "turns away a member with the authorization alert" do + sign_in_as(member) + + get admin_imports_path + + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to eq("You are not authorized to do that.") + end + + it "lists imports newest first for an admin" do + older = create(:import, created_at: 2.days.ago) + newer = create(:import, created_at: 1.hour.ago) + sign_in_as(admin) + + get admin_imports_path + + expect(inertia).to render_component("Admin/Imports/Index") + expect(props[:imports].pluck(:id)).to eq([ newer.id, older.id ]) + end + + it "lists no more than the 25 most recent" do + create_list(:import, 26) + sign_in_as(admin) + + get admin_imports_path + + expect(props[:imports].size).to eq(25) + end + end + + describe "GET /admin/imports/new" do + it "renders the upload form for an admin" do + sign_in_as(admin) + + get new_admin_import_path + + expect(inertia).to render_component("Admin/Imports/New") + end + + it "is closed to members" do + sign_in_as(member) + + get new_admin_import_path + + expect(response).to redirect_to(root_path) + end + end + + describe "GET /admin/imports/:id" do + it "renders the import for an admin" do + import = create(:import, status: :processing, total_rows: 10, processed_rows: 5) + sign_in_as(admin) + + get admin_import_path(import) + + expect(inertia).to render_component("Admin/Imports/Show") + expect(props[:import]).to include(id: import.id, status: "processing", progress: 50) + end + + it "is closed to members" do + import = create(:import) + sign_in_as(member) + + get admin_import_path(import) + + expect(response).to redirect_to(root_path) + end + + it "responds not found for an import that does not exist" do + sign_in_as(admin) + + get admin_import_path(0) + + expect(response).to have_http_status(:not_found) + end + end + + describe "POST /admin/imports" do + it "saves the upload against the admin, queues it and shows its page" do + sign_in_as(admin) + + expect { post admin_imports_path, params: { import: { file: upload } } } + .to change(admin.imports, :count).by(1) + + import = admin.imports.last + expect(ProcessImportJob).to have_been_enqueued.with(import) + expect(import.file.filename.to_s).to eq("users.csv") + expect(response).to redirect_to(admin_import_path(import)) + expect(flash[:notice]).to eq("Import queued.") + end + + it "sends a file of the wrong type back to the form with the reason" do + sign_in_as(admin) + + expect { post admin_imports_path, params: { import: { file: upload("document.txt", "text/plain") } } } + .not_to change(Import, :count) + + expect(response).to redirect_to(new_admin_import_path) + expect(session[:inertia_errors][:file]).to include("must be a .csv or .xlsx file") + expect(ProcessImportJob).not_to have_been_enqueued + end + + it "sends a submission without a file back to the form" do + sign_in_as(admin) + + expect { post admin_imports_path, params: { import: { file: "" } } }.not_to change(Import, :count) + + expect(session[:inertia_errors][:file]).to include("can't be blank") + end + + it "is closed to members and queues nothing" do + sign_in_as(member) + + expect { post admin_imports_path, params: { import: { file: upload } } }.not_to change(Import, :count) + + expect(response).to redirect_to(root_path) + expect(ProcessImportJob).not_to have_been_enqueued + end + end +end diff --git a/spec/serializers/import_serializer_spec.rb b/spec/serializers/import_serializer_spec.rb new file mode 100644 index 000000000..8bef75e5e --- /dev/null +++ b/spec/serializers/import_serializer_spec.rb @@ -0,0 +1,42 @@ +require "rails_helper" + +RSpec.describe ImportSerializer do + describe "#as_json" do + it "exposes the import's status, progress and tallies" do + import = create(:import, status: :processing, total_rows: 4, processed_rows: 3, + created_count: 2, skipped_count: 1, failed_count: 0) + + expect(described_class.new(import).as_json).to include( + id: import.id, status: "processing", filename: "users.csv", progress: 75, + total_rows: 4, processed_rows: 3, created_count: 2, skipped_count: 1, failed_count: 0, + failure_reason: nil, finished: false, created_at: import.created_at.iso8601 + ) + end + + it "marks a failed import as finished and carries the reason" do + import = create(:import, status: :failed, failure_reason: "No recognisable columns found") + + expect(described_class.new(import).as_json) + .to include(finished: true, failure_reason: "No recognisable columns found") + end + + it "sends only the first 50 rejected rows" do + import = build(:import) + 60.times { |i| import.record_error(i + 1, "row#{i + 1}@example.com", "Email address is invalid") } + import.save! + + report = described_class.new(import.reload).as_json[:error_report] + + expect(report.size).to eq(50) + expect(report.first).to eq("row" => 1, "identifier" => "row1@example.com", "errors" => [ "Email address is invalid" ]) + end + end + + describe ".collection" do + it "serializes each import in order" do + imports = create_list(:import, 2) + + expect(described_class.collection(imports).pluck(:id)).to eq(imports.map(&:id)) + end + end +end diff --git a/spec/system/import_page_spec.rb b/spec/system/import_page_spec.rb new file mode 100644 index 000000000..6caa16163 --- /dev/null +++ b/spec/system/import_page_spec.rb @@ -0,0 +1,53 @@ +require "rails_helper" + +RSpec.describe "The import page", type: :system, js: true do + let(:admin) { create(:user, :admin, full_name: "Ada Lovelace", email_address: "ada@example.com") } + + def sign_in_through_the_form(user) + visit new_session_path + fill_in "email_address", with: user.email_address + fill_in "password", with: "password" + click_on "Sign in" + expect(page).to have_no_current_path(new_session_path, wait: 5) + end + + it "shows the import's progress and tallies" do + import = create(:import, status: :processing, total_rows: 4, processed_rows: 1, created_count: 1) + sign_in_through_the_form(admin) + + visit admin_import_path(import) + + expect(page).to have_css("h1", text: "users.csv") + expect(page).to have_text("1 of 4 rows (25%)") + expect(page).to have_css("[role=progressbar][aria-valuenow='25']") + end + + # ProcessImportJob pushes through Imports::ProgressBroadcaster; the page has to + # pick that up over ImportChannel rather than waiting for someone to reload. + it "follows the job's progress without a reload" do + import = create(:import, status: :processing, total_rows: 4, processed_rows: 0) + sign_in_through_the_form(admin) + visit admin_import_path(import) + expect(page).to have_text("0 of 4 rows") + + import.update_columns(status: :completed, processed_rows: 4, created_count: 3, failed_count: 1) + Imports::ProgressBroadcaster.call(import) + + expect(page).to have_text("4 of 4 rows (100%)", wait: 5) + expect(page).to have_text("Status: completed") + end + + it "lists the rows that were rejected" do + import = create(:import, status: :completed, total_rows: 2, processed_rows: 2, failed_count: 1, + error_report: [ { row: 2, identifier: "not-an-email", errors: [ "Email address is invalid" ] } ]) + sign_in_through_the_form(admin) + + visit admin_import_path(import) + + within("section", text: "Rejected rows") do + expect(page).to have_text("Row 2") + expect(page).to have_text("not-an-email") + expect(page).to have_text("Email address is invalid") + end + end +end From 62af1ed3e62ddbce9cfb87565a2dd72b46b0295c Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Thu, 10 Sep 2026 12:26:53 -0300 Subject: [PATCH 57/82] feat: implement user import functionality with UI components, status badges, and system tests --- app/controllers/admin/imports_controller.rb | 2 +- .../components/ImportStatusBadge.tsx | 20 ++++ app/javascript/pages/Admin/Imports/Index.tsx | 86 +++++++++++++++ app/javascript/pages/Admin/Imports/New.tsx | 102 ++++++++++++++++++ app/javascript/pages/Admin/Imports/Show.tsx | 18 +++- app/javascript/pages/Admin/Users/Index.tsx | 14 ++- spec/system/import_flow_spec.rb | 76 +++++++++++++ 7 files changed, 311 insertions(+), 7 deletions(-) create mode 100644 app/javascript/components/ImportStatusBadge.tsx create mode 100644 app/javascript/pages/Admin/Imports/Index.tsx create mode 100644 app/javascript/pages/Admin/Imports/New.tsx create mode 100644 spec/system/import_flow_spec.rb diff --git a/app/controllers/admin/imports_controller.rb b/app/controllers/admin/imports_controller.rb index ea628db34..18bd521d0 100644 --- a/app/controllers/admin/imports_controller.rb +++ b/app/controllers/admin/imports_controller.rb @@ -4,7 +4,7 @@ class ImportsController < ApplicationController def index render inertia: "Admin/Imports/Index", props: { - imports: -> { ImportSerializer.collection(scope.recent.limit(25)) } + imports: -> { ImportSerializer.collection(scope.recent.with_attached_file.limit(25)) } } end diff --git a/app/javascript/components/ImportStatusBadge.tsx b/app/javascript/components/ImportStatusBadge.tsx new file mode 100644 index 000000000..ddcfbefd3 --- /dev/null +++ b/app/javascript/components/ImportStatusBadge.tsx @@ -0,0 +1,20 @@ +import type { ImportStatus } from '@/types' + +const STYLES: Record = { + pending: 'bg-slate-100 text-slate-600 ring-slate-200', + parsing: 'bg-amber-50 text-amber-700 ring-amber-200', + processing: 'bg-amber-50 text-amber-700 ring-amber-200', + completed: 'bg-emerald-50 text-emerald-700 ring-emerald-200', + failed: 'bg-red-50 text-red-700 ring-red-200', + cancelled: 'bg-slate-100 text-slate-500 ring-slate-200', +} + +export default function ImportStatusBadge({ status }: { status: ImportStatus }) { + return ( + + {status} + + ) +} diff --git a/app/javascript/pages/Admin/Imports/Index.tsx b/app/javascript/pages/Admin/Imports/Index.tsx new file mode 100644 index 000000000..da4794764 --- /dev/null +++ b/app/javascript/pages/Admin/Imports/Index.tsx @@ -0,0 +1,86 @@ +import { Head, Link } from '@inertiajs/react' +import AppLayout from '@/layouts/AppLayout' +import ImportStatusBadge from '@/components/ImportStatusBadge' +import type { Import } from '@/types' + +/** Props from Admin::ImportsController#index. */ +type Props = { imports: Import[] } + +const uploadedAt = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }) + +export default function Index({ imports }: Props) { + return ( + <> + + +
+

Imports

+
+ + Back to users + + + New import + +
+
+

The 25 most recent uploads.

+ +
+ + + + + + + + + + + + + + {imports.map((record) => ( + + + + + + + + + + ))} + {imports.length === 0 && ( + + + + )} + +
FileStatusRowsCreatedSkippedFailedUploaded
+ + {record.filename} + + + {record.processed_rows.toLocaleString()} of {record.total_rows.toLocaleString()} + ({record.progress}%) + {record.created_count.toLocaleString()}{record.skipped_count.toLocaleString()} 0 ? 'text-red-600' : ''}`}> + {record.failed_count.toLocaleString()} + + +
+ No imports yet.{' '} + + Upload a spreadsheet + {' '} + to add users in bulk. +
+
+ + ) +} + +Index.layout = AppLayout diff --git a/app/javascript/pages/Admin/Imports/New.tsx b/app/javascript/pages/Admin/Imports/New.tsx new file mode 100644 index 000000000..9d8ea3ec5 --- /dev/null +++ b/app/javascript/pages/Admin/Imports/New.tsx @@ -0,0 +1,102 @@ +import { Head, Link, useForm } from '@inertiajs/react' +import { FormEvent } from 'react' +import AppLayout from '@/layouts/AppLayout' +import Field from '@/components/Field' + +/** Mirrors Imports::UserRow::HEADER_ALIASES and the row validations. */ +const COLUMNS = [ + { name: 'full_name', aliases: 'name, fullname, nome', notes: 'Required, 2 to 120 characters.' }, + { name: 'email', aliases: 'email_address, e-mail', notes: 'Required. Rows for an email that already has an account are skipped.' }, + { name: 'role', aliases: 'perfil', notes: 'admin or member, in lowercase. Blank means member.' }, + { name: 'avatar_url', aliases: 'avatar, photo', notes: 'Optional https:// link.' }, +] + +export default function New() { + const form = useForm({ file: null as File | null }) + const { setData, errors, processing, progress } = form + + const submit = (event: FormEvent) => { + event.preventDefault() + + // Rails only wraps JSON bodies under the model key, so the multipart upload has + // to arrive already nested to satisfy `params.expect(import: [ :file ])`. + form.transform(({ file }) => ({ import: { file } })) + form.post('/admin/imports', { forceFormData: true }) + } + + return ( + <> + + +
+

Import users

+
+ + Past imports + + + Back to users + +
+
+ +
+ + setData('file', e.target.files?.[0] ?? null)} + className="text-sm" + /> + + + {progress && ( + + {progress.percentage}% + + )} + + +
+ +
+

Columns

+

+ Column names are matched regardless of case, spacing or punctuation. Any other column is ignored. +

+
+ + + + + + + + + + {COLUMNS.map((column) => ( + + + + + + ))} + +
ColumnAlso acceptedNotes
{column.name}{column.aliases}{column.notes}
+
+
+ + ) +} + +New.layout = AppLayout diff --git a/app/javascript/pages/Admin/Imports/Show.tsx b/app/javascript/pages/Admin/Imports/Show.tsx index 15bb7d89f..62675f9c8 100644 --- a/app/javascript/pages/Admin/Imports/Show.tsx +++ b/app/javascript/pages/Admin/Imports/Show.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef } from 'react' -import { Head, router } from '@inertiajs/react' +import { Head, Link, router } from '@inertiajs/react' import AppLayout from '@/layouts/AppLayout' import { getConsumer } from '@/lib/cable' import type { Import } from '@/types' @@ -43,8 +43,20 @@ export default function Show({ import: record }: Props) { <> -

{record.filename}

-

Status: {record.status}

+
+
+

{record.filename}

+

Status: {record.status}

+
+
+ + All imports + + + Back to users + +
+
Users ({filters.total}) - - New user - +
+ + Import users + + + New user + +
diff --git a/spec/system/import_flow_spec.rb b/spec/system/import_flow_spec.rb new file mode 100644 index 000000000..3e6e4b9b1 --- /dev/null +++ b/spec/system/import_flow_spec.rb @@ -0,0 +1,76 @@ +require "rails_helper" + +RSpec.describe "Importing users", type: :system, js: true do + include ActiveJob::TestHelper + + let(:admin) { create(:user, :admin, full_name: "Ada Lovelace", email_address: "ada@example.com") } + let(:folder) { Dir.mktmpdir("import-flow") } + + after { FileUtils.remove_entry(folder) } + + def sign_in_through_the_form(user) + visit new_session_path + fill_in "email_address", with: user.email_address + fill_in "password", with: "password" + click_on "Sign in" + expect(page).to have_no_current_path(new_session_path, wait: 5) + end + + def spreadsheet(name, content) + File.join(folder, name).tap { |path| File.write(path, content) } + end + + it "uploads a spreadsheet from the users page and follows it to the end" do + create(:user, full_name: "Grace Hopper", email_address: "grace@example.com") + file = spreadsheet("team.csv", <<~CSV) + Nome,E-mail,Perfil + Margaret Hamilton,margaret@example.com,admin + Grace Hopper,grace@example.com,member + Nobody,not-an-email,member + CSV + sign_in_through_the_form(admin) + visit admin_users_path + + click_on "Import users" + attach_file "Spreadsheet", file + click_on "Start import" + + expect(page).to have_css("h1", text: "team.csv") + expect(page).to have_css("[role=status]", text: "Import queued.") + + # The job runs here in the test process; the page has to hear about it over + # ImportChannel, exactly as it would from a Solid Queue worker. + perform_enqueued_jobs(only: ProcessImportJob) + + expect(page).to have_text("Status: completed", wait: 5) + expect(page).to have_text("3 of 3 rows (100%)") + within("section", text: "Rejected rows") { expect(page).to have_text("not-an-email") } + expect(User.find_by(email_address: "margaret@example.com")).to be_admin + + click_on "All imports" + + expect(page).to have_current_path(admin_imports_path) + expect(page).to have_link("team.csv") + end + + it "sends the form back when no file was chosen" do + sign_in_through_the_form(admin) + visit new_admin_import_path + + click_on "Start import" + + expect(page).to have_text("can't be blank") + expect(Import.count).to eq(0) + end + + it "lists past imports and links through to each" do + import = create(:import, status: :completed, total_rows: 1, processed_rows: 1, created_count: 1) + sign_in_through_the_form(admin) + visit admin_imports_path + + within("tbody tr", text: "users.csv") { expect(page).to have_text(/completed/i) } + click_on "users.csv" + + expect(page).to have_current_path(admin_import_path(import)) + end +end From 6ad5d6384500c7ec2c5fc994b491ed7d196415b3 Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Thu, 10 Sep 2026 12:36:56 -0300 Subject: [PATCH 58/82] feat: enhance responsive design and PWA support with layout adjustments, new icons, and manifest updates --- app/controllers/application_controller.rb | 5 +- app/javascript/entrypoints/application.css | 26 ++++- app/javascript/layouts/AppLayout.tsx | 99 ++++++++++++++------ app/javascript/pages/Admin/Imports/Show.tsx | 6 +- app/javascript/pages/Admin/Users/Index.tsx | 4 +- app/javascript/pages/Admin/Users/Show.tsx | 6 +- app/javascript/pages/Profile/Show.tsx | 6 +- app/views/layouts/application.html.erb | 39 ++++---- app/views/passwords/edit.html.erb | 36 +++---- app/views/passwords/new.html.erb | 30 +++--- app/views/pwa/manifest.json.erb | 28 ++++-- app/views/sessions/new.html.erb | 50 +++++----- config/routes.rb | 5 +- public/apple-touch-icon.png | Bin 0 -> 4359 bytes public/icon-192.png | Bin 0 -> 4716 bytes spec/requests/pwa_spec.rb | 96 +++++++++++++++++++ spec/system/responsive_layout_spec.rb | 56 +++++++++++ 17 files changed, 361 insertions(+), 131 deletions(-) create mode 100644 public/apple-touch-icon.png create mode 100644 public/icon-192.png create mode 100644 spec/requests/pwa_spec.rb create mode 100644 spec/system/responsive_layout_spec.rb diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 2fadd0aa3..f6a2b6014 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -2,7 +2,10 @@ class ApplicationController < ActionController::Base include Authentication include Authorization - allow_browser versions: :modern + # The floor is what the built stylesheet needs: Tailwind v4 relies on @property, + # color-mix() and oklch(). Rails' :modern set (Safari 17.2, Chrome 120) would also + # turn away iPhones on iOS 16.4 to 17.1 that render the app fine. + allow_browser versions: { safari: 16.4, chrome: 111, firefox: 128, opera: 97, ie: false } inertia_share do { diff --git a/app/javascript/entrypoints/application.css b/app/javascript/entrypoints/application.css index 062feb3f7..e5366fa60 100644 --- a/app/javascript/entrypoints/application.css +++ b/app/javascript/entrypoints/application.css @@ -3,8 +3,32 @@ @plugin '@tailwindcss/typography'; @plugin '@tailwindcss/forms'; +/* 16px on phones: iOS Safari zooms the page into any field smaller than that. */ @utility input { - @apply w-full rounded-md border border-slate-300 px-3 py-2 text-sm + @apply w-full rounded-md border border-slate-300 px-3 py-2 text-base sm:text-sm focus:border-slate-500 focus:ring-1 focus:ring-slate-500 disabled:cursor-not-allowed disabled:bg-slate-50; } + +/* + * Keep content clear of the notch, rounded corners and home indicator. The page + * opts into drawing there with viewport-fit=cover; env() is 0 everywhere else, so + * these fall back to ordinary gutters on desktops and older phones. + */ +@utility safe-px { + padding-left: max(1rem, env(safe-area-inset-left)); + padding-right: max(1rem, env(safe-area-inset-right)); + + @media (width >= 40rem) { + padding-left: max(1.5rem, env(safe-area-inset-left)); + padding-right: max(1.5rem, env(safe-area-inset-right)); + } +} + +@utility safe-pt { + padding-top: env(safe-area-inset-top); +} + +@utility safe-pb { + padding-bottom: max(2rem, env(safe-area-inset-bottom)); +} diff --git a/app/javascript/layouts/AppLayout.tsx b/app/javascript/layouts/AppLayout.tsx index f7084dbfa..bfad4f5fb 100644 --- a/app/javascript/layouts/AppLayout.tsx +++ b/app/javascript/layouts/AppLayout.tsx @@ -1,51 +1,92 @@ -import { Link, usePage } from '@inertiajs/react' -import { PropsWithChildren, useEffect, useState } from 'react' +import { Link, router, usePage } from '@inertiajs/react' +import { PropsWithChildren, useEffect, useId, useState } from 'react' import type { SharedProps } from '@/types' +type NavLink = { href: string; label: string } + export default function AppLayout({ children }: PropsWithChildren) { const { auth, flash } = usePage().props const [banner, setBanner] = useState(flash.notice || flash.alert) + const [menuOpen, setMenuOpen] = useState(false) + const menuId = useId() useEffect(() => setBanner(flash.notice || flash.alert), [flash]) + // Close the phone menu once a visit lands, so it never sits over the next page. + useEffect(() => router.on('navigate', () => setMenuOpen(false)), []) + + const links: NavLink[] = auth.user + ? [ + ...(auth.user.admin + ? [{ href: '/admin', label: 'Dashboard' }, { href: '/admin/users', label: 'Users' }] + : []), + { href: '/profile', label: auth.user.full_name }, + ] + : [{ href: '/session/new', label: 'Sign in' }] + return ( -
-
- + +
{banner && ( -
- {banner} +
+
+ {banner} +
)} -
{children}
+
{children}
) } diff --git a/app/javascript/pages/Admin/Imports/Show.tsx b/app/javascript/pages/Admin/Imports/Show.tsx index 62675f9c8..0d687f8a0 100644 --- a/app/javascript/pages/Admin/Imports/Show.tsx +++ b/app/javascript/pages/Admin/Imports/Show.tsx @@ -106,10 +106,10 @@ export default function Show({ import: record }: Props) {
    {record.error_report.map((entry) => ( -
  • +
  • Row {entry.row} - {entry.identifier || '(no email)'} - {entry.errors.join(', ')} + {entry.identifier || '(no email)'} + {entry.errors.join(', ')}
  • ))}
diff --git a/app/javascript/pages/Admin/Users/Index.tsx b/app/javascript/pages/Admin/Users/Index.tsx index 2c1b1c36d..3088d5202 100644 --- a/app/javascript/pages/Admin/Users/Index.tsx +++ b/app/javascript/pages/Admin/Users/Index.tsx @@ -97,13 +97,13 @@ export default function Index() { onChange={(event) => setQuery(event.target.value)} placeholder="Search by name" aria-label="Search users by name" - className="w-64 rounded-md border border-slate-300 px-3 py-2 text-sm" + className="w-full rounded-md border border-slate-300 px-3 py-2 text-base sm:w-64 sm:text-sm" /> setData('full_name', e.target.value)} + required + minLength={2} + maxLength={120} + autoComplete="name" + autoFocus + className="input" + /> + + + + setData('email_address', e.target.value)} + required + autoComplete="email" + className="input" + /> + + + + setData('password', e.target.value)} + required + minLength={8} + maxLength={72} + autoComplete="new-password" + className="input" + /> + + + + setData('password_confirmation', e.target.value)} + required + autoComplete="new-password" + className="input" + /> + + + + + +

+ Back to home +

+
+ + ) +} + +Register.layout = AppLayout diff --git a/app/javascript/pages/home/index.tsx b/app/javascript/pages/home/index.tsx index 5671d8c52..76fb08a4e 100644 --- a/app/javascript/pages/home/index.tsx +++ b/app/javascript/pages/home/index.tsx @@ -1,11 +1,33 @@ -import { Head } from '@inertiajs/react' +import { Head, Link } from '@inertiajs/react' import AppLayout from '@/layouts/AppLayout' export default function Home() { return ( <> - -

Home

+ + +
+

Welcome to Umanni

+

+ Create an account to set up your profile, or sign in if you already have one. +

+ +
+ + Create account + + {/* The sign-in page is a plain Rails view, not an Inertia page, so it needs a full page load. */} + + Sign in + +
+
) } diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index c6171801d..af48e5409 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -28,5 +28,9 @@
<% end %> + +

+ New here? <%= link_to "Create an account", new_registration_path, data: { turbo: false }, class: "text-gray-700 underline hover:no-underline" %> +

diff --git a/spec/controllers/shared_data_spec.rb b/spec/controllers/shared_data_spec.rb index bde1ad504..5eb554d37 100644 --- a/spec/controllers/shared_data_spec.rb +++ b/spec/controllers/shared_data_spec.rb @@ -1,8 +1,7 @@ require "rails_helper" -# Every Inertia page in the app currently sits behind `require_authentication`, -# so the signed-out branch of `auth.user` has no reachable route to exercise it. -# This anonymous controller renders Inertia without authentication to pin it. +# Pins the signed-out branch of `auth.user` on ApplicationController itself, so it +# holds regardless of which real pages happen to allow unauthenticated access. RSpec.describe ApplicationController, type: :controller do controller(ApplicationController) do allow_unauthenticated_access diff --git a/spec/requests/home_spec.rb b/spec/requests/home_spec.rb new file mode 100644 index 000000000..6577335e9 --- /dev/null +++ b/spec/requests/home_spec.rb @@ -0,0 +1,59 @@ +require "rails_helper" + +RSpec.describe "Home", type: :request do + let(:member) { create(:user, email_address: "grace@example.com", password: "password") } + let(:admin) { create(:user, :admin, email_address: "boss@example.com", password: "password") } + + describe "GET /" do + it "shows a visitor the landing page rather than bouncing them to sign in" do + get root_path + + expect(response).to have_http_status(:ok) + expect(inertia).to render_component("home/index") + expect(inertia.props.deep_symbolize_keys[:auth]).to eq(user: nil) + end + + it "sends a signed-in member to their profile" do + sign_in_as(member) + + get root_path + + expect(response).to redirect_to(profile_path) + end + + it "sends a signed-in admin to the user admin dashboard" do + sign_in_as(admin) + + get root_path + + expect(response).to redirect_to(admin_dashboard_path) + end + + it "treats a visitor whose session was revoked as signed out" do + sign_in_as(member) + member.sessions.destroy_all + + get root_path + + expect(inertia).to render_component("home/index") + end + + it "carries a flash through to the page it forwards to" do + sign_in_as(member) + + get admin_dashboard_path # denied, and bounced to the root + follow_redirect! # which forwards the member on to their profile + follow_redirect! + + expect(request.path).to eq(profile_path) + expect(inertia.props.deep_symbolize_keys[:flash]).to include(alert: "You are not authorized to do that.") + end + + it "is not remembered as the page to return to, so signing in still lands by role" do + get root_path + post session_url, params: { email_address: admin.email_address, password: "password" } + + expect(response).to redirect_to(admin_dashboard_path) + end + end +end diff --git a/spec/requests/profiles_spec.rb b/spec/requests/profiles_spec.rb index dd0c95738..bfeb7f3e1 100644 --- a/spec/requests/profiles_spec.rb +++ b/spec/requests/profiles_spec.rb @@ -200,15 +200,15 @@ def props = inertia.props.deep_symbolize_keys expect(response).to redirect_to(new_session_url) end - it "carries the confirmation through to the sign-in page" do + it "carries the confirmation through to the landing page" do sign_in_as(user) delete profile_path - follow_redirect! # root, which now bounces a signed-out visitor - follow_redirect! + follow_redirect! # root, which shows the now signed-out visitor the landing page expect(response).to have_http_status(:ok) - expect(flash[:notice]).to eq("Your account has been deleted.") + expect(inertia).to render_component("home/index") + expect(inertia.props.deep_symbolize_keys[:flash]).to include(notice: "Your account has been deleted.") end it "refuses to delete the last admin and reports why" do diff --git a/spec/requests/sessions_spec.rb b/spec/requests/sessions_spec.rb index 5f9d5e376..8409d736b 100644 --- a/spec/requests/sessions_spec.rb +++ b/spec/requests/sessions_spec.rb @@ -63,10 +63,10 @@ end it "returns the user to the page they originally requested, ahead of the default landing page" do - get root_url # bounced to sign-in, stashing the destination + get edit_profile_url # bounced to sign-in, stashing the destination post session_url, params: { email_address: "ada@example.com", password: password } - expect(response).to redirect_to(root_url) + expect(response).to redirect_to(edit_profile_url) end end @@ -101,7 +101,7 @@ describe "authentication guard" do it "redirects a signed-out visitor away from a protected page" do - get root_url + get profile_url expect(response).to redirect_to(new_session_url) end @@ -109,7 +109,7 @@ it "lets a signed-in user through" do sign_in_as(user) - get root_url + get profile_url expect(response).to have_http_status(:ok) end diff --git a/spec/requests/shared_data_spec.rb b/spec/requests/shared_data_spec.rb index b202591d8..0e96d2bfb 100644 --- a/spec/requests/shared_data_spec.rb +++ b/spec/requests/shared_data_spec.rb @@ -11,7 +11,7 @@ def props = inertia.props.deep_symbolize_keys it "shares the signed-in user, serialized" do sign_in_as(user) - get root_path + get profile_path expect(props[:auth][:user]).to include( id: user.id, full_name: "Ada Lovelace", email_address: "ada@example.com", @@ -22,7 +22,7 @@ def props = inertia.props.deep_symbolize_keys it "flags an admin so the front end can gate admin-only UI" do sign_in_as(create(:user, :admin)) - get root_path + get admin_dashboard_path expect(props[:auth][:user]).to include(role: "admin", admin: true) end @@ -30,7 +30,7 @@ def props = inertia.props.deep_symbolize_keys it "never leaks the password digest" do sign_in_as(user) - get root_path + get profile_path expect(props[:auth][:user]).not_to include(:password_digest) expect(response.body).not_to include(user.password_digest) @@ -41,7 +41,8 @@ def props = inertia.props.deep_symbolize_keys it "shares an alert set by a redirect" do sign_in_as(user) - get admin_dashboard_path # a member is turned away with an alert + get admin_dashboard_path # a member is turned away with an alert, to the root + follow_redirect! # which forwards them on to their profile follow_redirect! expect(props[:flash]).to eq(notice: nil, alert: "You are not authorized to do that.") @@ -50,7 +51,7 @@ def props = inertia.props.deep_symbolize_keys it "shares both keys, nil-valued, when nothing was flashed" do sign_in_as(user) - get root_path + get profile_path expect(props[:flash]).to eq(notice: nil, alert: nil) end diff --git a/spec/system/app_layout_spec.rb b/spec/system/app_layout_spec.rb index 4e6f1b2ba..febd4c2d1 100644 --- a/spec/system/app_layout_spec.rb +++ b/spec/system/app_layout_spec.rb @@ -14,9 +14,17 @@ def sign_in_through_the_form(user) expect(page).to have_no_current_path(new_session_path, wait: 5) end + it "gives a visitor ways to sign in and register, and no signed-in links" do + visit root_path + + expect(page).to have_link("Sign in", href: "/session/new") + expect(page).to have_link("Create account", href: "/registration/new") + expect(page).to have_no_button("Sign out") + end + it "gives a member their own name and no admin links" do sign_in_through_the_form(member) - visit root_path + visit profile_path expect(page).to have_link("Grace Hopper", href: "/profile") expect(page).to have_no_link("Dashboard") @@ -25,17 +33,32 @@ def sign_in_through_the_form(user) it "gives an admin the dashboard and users links" do sign_in_through_the_form(admin) - visit root_path + visit admin_dashboard_path expect(page).to have_link("Dashboard", href: "/admin") expect(page).to have_link("Users", href: "/admin/users") expect(page).to have_link("Ada Lovelace", href: "/profile") end - it "destroys the session when Sign out is clicked" do + it "sends a member who opens the root to their profile" do sign_in_through_the_form(member) visit root_path + expect(page).to have_current_path(profile_path) + expect(page).to have_css("h1", text: "Grace Hopper") + end + + it "sends an admin who opens the root to the dashboard" do + sign_in_through_the_form(admin) + visit root_path + + expect(page).to have_current_path(admin_dashboard_path) + end + + it "destroys the session when Sign out is clicked" do + sign_in_through_the_form(member) + visit profile_path + click_on "Sign out" expect(page).to have_current_path(new_session_path) @@ -44,7 +67,7 @@ def sign_in_through_the_form(user) it "leaves the nav showing no signed-in user after signing out" do sign_in_through_the_form(member) - visit root_path + visit profile_path click_on "Sign out" @@ -54,9 +77,9 @@ def sign_in_through_the_form(user) it "renders a flash alert in the banner" do sign_in_through_the_form(member) - visit admin_users_path # denied, and bounced back to the home page + visit admin_users_path # denied, bounced to the root, and on to the member's profile - expect(page).to have_current_path(root_path) + expect(page).to have_current_path(profile_path) expect(page).to have_css("[role=status]", text: "You are not authorized to do that.") end @@ -70,9 +93,9 @@ def sign_in_through_the_form(user) it "shows no banner on a plain page load" do sign_in_through_the_form(member) - visit root_path + visit profile_path - expect(page).to have_css("h1", text: "Home") + expect(page).to have_css("h1", text: "Grace Hopper") expect(page).to have_no_css("[role=status]") end end diff --git a/spec/system/authentication_spec.rb b/spec/system/authentication_spec.rb index a2f9a1b5c..ec7573811 100644 --- a/spec/system/authentication_spec.rb +++ b/spec/system/authentication_spec.rb @@ -43,4 +43,16 @@ expect(page).to have_current_path(profile_path) expect(user.sessions.count).to eq(1) end + + it "lands an admin on the user admin dashboard", :js do + admin = create(:user, :admin, email_address: "boss@example.com", password: "password") + visit new_session_path + + fill_in "email_address", with: "boss@example.com" + fill_in "password", with: "password" + click_on "Sign in" + + expect(page).to have_current_path(admin_dashboard_path) + expect(admin.sessions.count).to eq(1) + end end diff --git a/spec/system/registration_spec.rb b/spec/system/registration_spec.rb new file mode 100644 index 000000000..22a09c2c3 --- /dev/null +++ b/spec/system/registration_spec.rb @@ -0,0 +1,50 @@ +require "rails_helper" + +RSpec.describe "Registering", type: :system, js: true do + def fill_in_registration(password_confirmation: "password") + fill_in "Full name", with: "Ada Lovelace" + fill_in "Email", with: "ada@example.com" + fill_in "Password", with: "password" + fill_in "Confirm password", with: password_confirmation + end + + it "takes a visitor from the landing page to their new profile" do + visit root_path + within("main") { click_on "Create account" } + + expect(page).to have_css("h1", text: "Create your account") + fill_in_registration + click_button "Create account" + + expect(page).to have_current_path(profile_path) + expect(page).to have_css("h1", text: "Ada Lovelace") + expect(User.find_by(email_address: "ada@example.com")).to be_member + end + + it "keeps the visitor on the form with the errors shown when the details are invalid" do + visit new_registration_path + + fill_in_registration(password_confirmation: "something-else") + click_button "Create account" + + expect(page).to have_text("doesn't match Password") + expect(page).to have_current_path(new_registration_path) + expect(User.count).to eq(0) + end + + it "is reachable from the sign-in page" do + visit new_session_path + click_on "Create an account" + + expect(page).to have_current_path(new_registration_path) + expect(page).to have_css("h1", text: "Create your account") + end + + it "reaches the sign-in page from the nav with a full page load" do + visit root_path + within("nav") { click_on "Sign in" } + + expect(page).to have_current_path(new_session_path) + expect(page).to have_field("email_address") + end +end diff --git a/spec/system/user_pages_spec.rb b/spec/system/user_pages_spec.rb index 11586ad35..bda7b833b 100644 --- a/spec/system/user_pages_spec.rb +++ b/spec/system/user_pages_spec.rb @@ -47,7 +47,7 @@ def sign_in_through_the_form(user) it "is reachable from the nav" do sign_in_through_the_form(member) - visit root_path + visit edit_profile_path click_on "Grace Hopper" From d0a5fc5ca3bd0ae2693d0c1460813cf4944a5a9f Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Thu, 10 Sep 2026 13:56:29 -0300 Subject: [PATCH 61/82] feat: update Dockerfile and .dockerignore for improved build process and add entrypoint script --- .dockerignore | 14 +++++++++ .entrypoint | 7 +++++ Dockerfile | 83 ++++++++++++++++++++++++++++----------------------- 3 files changed, 66 insertions(+), 38 deletions(-) create mode 100644 .entrypoint diff --git a/.dockerignore b/.dockerignore index 325bfc036..80b62c9f4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,6 +6,7 @@ # Ignore bundler config. /.bundle +/vendor/bundle # Ignore all environment files. /.env* @@ -35,6 +36,14 @@ /app/assets/builds/* !/app/assets/builds/.keep /public/assets +/public/vite +/public/vite-ssr + +# Ignore test suite and its artifacts. +/spec +/coverage +/test-results +/playwright-report # Ignore CI service files. /.github @@ -45,7 +54,12 @@ # Ignore development files /.devcontainer +/.vscode +/.idea # Ignore Docker-related files /.dockerignore /Dockerfile* + +# Ignore OS cruft. +.DS_Store \ No newline at end of file diff --git a/.entrypoint b/.entrypoint new file mode 100644 index 000000000..aa9bc365c --- /dev/null +++ b/.entrypoint @@ -0,0 +1,7 @@ +#!/bin/bash -e + +if [ "${@: -1:1}" == "./bin/rails" ] || [ "${1}" == "./bin/thrust" ]; then + ./bin/rails db:prepare +fi + +exec "${@}" \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 6afd1d3de..5f14ca3b6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,77 +1,84 @@ # syntax=docker/dockerfile:1 # check=error=true -# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand: +# Production image, meant for Kamal or a manual build'n'run: # 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 RAILS_MASTER_KEY= -e APP_ORIGIN=https://example.com --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 +# Must match .ruby-version and the `ruby` line in the Gemfile. ARG RUBY_VERSION=4.0.6 +ARG NODE_VERSION=22.14.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 postgresql-client && \ + apt-get install --no-install-recommends -y \ + curl libjemalloc2 libvips postgresql-client && \ ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \ rm -rf /var/lib/apt/lists /var/cache/apt/archives -# 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" +# jemalloc is only used if preloaded. +ENV RAILS_ENV=production \ + BUNDLE_DEPLOYMENT=1 \ + BUNDLE_PATH=/usr/local/bundle \ + BUNDLE_WITHOUT=development:test \ + LD_PRELOAD=/usr/local/lib/libjemalloc.so -# Throw-away build stage to reduce size of final image +# ---------- build ---------- FROM base AS build -# Install packages needed to build gems +ARG NODE_VERSION +ENV PATH=/usr/local/node/bin:$PATH + RUN apt-get update -qq && \ - apt-get install --no-install-recommends -y build-essential git libpq-dev libvips libyaml-dev pkg-config && \ + apt-get install --no-install-recommends -y \ + build-essential git libpq-dev libyaml-dev node-gyp pkg-config python-is-python3 && \ rm -rf /var/lib/apt/lists /var/cache/apt/archives -# Install application gems -COPY vendor/* ./vendor/ -COPY Gemfile Gemfile.lock ./ +RUN curl -sL https://github.com/nodenv/node-build/archive/master.tar.gz | tar xz -C /tmp/ && \ + /tmp/node-build-master/bin/node-build "${NODE_VERSION}" /usr/local/node && \ + rm -rf /tmp/node-build-master +# -j 1 avoids a QEMU bug when cross-building amd64 on Apple Silicon: https://github.com/rails/bootsnap/issues/495 +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 + rm -rf ~/.bundle "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \ bundle exec bootsnap precompile -j 1 --gemfile -# Copy application code +COPY package.json package-lock.json ./ +RUN npm ci + 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 - - +# Build-time placeholders only: production.rb fetches APP_ORIGIN at boot, and +# assets:precompile boots the app (tailwindcss:build). npm ci already ran above, +# so vite_ruby must not reinstall. +RUN SECRET_KEY_BASE_DUMMY=1 \ + APP_ORIGIN=http://localhost \ + VITE_RUBY_SKIP_ASSETS_PRECOMPILE_INSTALL=true \ + ./bin/rails assets:precompile +# Vite output is fully bundled into public/vite; no SSR, so Node isn't needed at runtime. +RUN rm -rf node_modules -# Final stage for app image +# ---------- final ---------- FROM base -# Run and own only the runtime files as a non-root user for security +COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" +COPY --from=build /rails /rails + +# Code stays root-owned; the app user can only write where Rails needs to. RUN groupadd --system --gid 1000 rails && \ - useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash + useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \ + mkdir -p tmp/storage && \ + chown -R rails:rails db log storage tmp 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"] From da34bbcff11ae6f777816f13277bbd8d0c43884f Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Thu, 10 Sep 2026 14:07:50 -0300 Subject: [PATCH 62/82] feat: refactor deployment configuration and add local development setup with Docker --- .gitignore | 3 + .kamal/local/Dockerfile | 13 +++ .kamal/local/compose.yml | 24 +++++ .kamal/local/entrypoint.sh | 9 ++ .kamal/secrets | 23 +---- Gemfile | 4 + Gemfile.lock | 54 ++++++++++ config/database.yml | 13 +-- config/deploy.local.yml | 52 ++++++++++ config/deploy.yml | 159 +++++++++++------------------- config/environments/production.rb | 15 ++- config/postgres/init.sql | 4 + config/puma.rb | 2 +- config/storage.yml | 14 +-- 14 files changed, 244 insertions(+), 145 deletions(-) create mode 100644 .kamal/local/Dockerfile create mode 100644 .kamal/local/compose.yml create mode 100644 .kamal/local/entrypoint.sh create mode 100644 config/deploy.local.yml create mode 100644 config/postgres/init.sql diff --git a/.gitignore b/.gitignore index b7c4599a9..1691b1141 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,6 @@ node_modules # Ignore SimpleCov coverage reports. /coverage/ + +# SSH key for the local Kamal test server (.kamal/local). +/.kamal/local/ssh/ diff --git a/.kamal/local/Dockerfile b/.kamal/local/Dockerfile new file mode 100644 index 000000000..a5dc783ae --- /dev/null +++ b/.kamal/local/Dockerfile @@ -0,0 +1,13 @@ +# Stand-in for a production host: root SSH access plus its own Docker daemon. +FROM docker:29-dind + +# Kamal's local registry reaches this host through an SSH remote port forward, +# which Alpine's sshd disables by default. +RUN apk add --no-cache openssh-server && \ + ssh-keygen -A && \ + sed -i 's/^AllowTcpForwarding no/AllowTcpForwarding yes/' /etc/ssh/sshd_config + +COPY --chmod=755 entrypoint.sh /usr/local/bin/server-entrypoint.sh + +EXPOSE 22 80 +ENTRYPOINT ["server-entrypoint.sh"] diff --git a/.kamal/local/compose.yml b/.kamal/local/compose.yml new file mode 100644 index 000000000..90d169100 --- /dev/null +++ b/.kamal/local/compose.yml @@ -0,0 +1,24 @@ +# Fake server for `bin/kamal -d local` (see config/deploy.local.yml). +# +# ssh-keygen -t ed25519 -N "" -f .kamal/local/ssh/id_ed25519 # once +# docker compose -f .kamal/local/compose.yml up -d --build +# docker compose -f .kamal/local/compose.yml down -v # wipe the server +name: umanni-users-kamal-local + +services: + server: + build: . + privileged: true + hostname: kamal-local + ports: + - "127.0.0.1:2222:22" + - "127.0.0.1:8080:80" + volumes: + - ./ssh/id_ed25519.pub:/keys/authorized_keys:ro + # Persist containers and Kamal's host directories (e.g. postgres data) across restarts. + - docker-data:/var/lib/docker + - root-home:/root + +volumes: + docker-data: + root-home: diff --git a/.kamal/local/entrypoint.sh b/.kamal/local/entrypoint.sh new file mode 100644 index 000000000..aa7ba3e8d --- /dev/null +++ b/.kamal/local/entrypoint.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -e + +install -d -m 700 /root/.ssh +install -m 600 /keys/authorized_keys /root/.ssh/authorized_keys +/usr/sbin/sshd -e + +# Unix socket only: skips the TLS cert generation and TCP listener of the default dind setup. +exec dockerd-entrypoint.sh dockerd --host=unix:///var/run/docker.sock diff --git a/.kamal/secrets b/.kamal/secrets index b3089d6f5..ae23b1c5e 100644 --- a/.kamal/secrets +++ b/.kamal/secrets @@ -1,20 +1,5 @@ -# 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) +KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD +POSTGRES_PASSWORD=$POSTGRES_PASSWORD +AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID +AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY diff --git a/Gemfile b/Gemfile index 0e88031e2..946abb09d 100644 --- a/Gemfile +++ b/Gemfile @@ -73,6 +73,8 @@ group :development, :test do gem "factory_bot_rails" gem "faker" gem "dotenv-rails" + gem "rubocop-rspec", require: false + gem "erb_lint", require: false end group :test do @@ -89,3 +91,5 @@ group :development do end gem "vite_rails", "~> 3.11" + +gem "aws-sdk-s3", "~> 1.231", require: false diff --git a/Gemfile.lock b/Gemfile.lock index 003bd8656..0fdce657e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -84,9 +84,36 @@ GEM addressable (2.9.0) public_suffix (>= 2.0.2, < 8.0) ast (2.4.3) + aws-eventstream (1.4.0) + aws-partitions (1.1284.0) + aws-sdk-core (3.255.0) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + bigdecimal + jmespath (~> 1, >= 1.6.1) + logger + rexml (~> 3.4, >= 3.4.2) + aws-sdk-kms (1.131.0) + aws-sdk-core (~> 3, >= 3.255.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.231.0) + aws-sdk-core (~> 3, >= 3.255.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.12.1) + aws-eventstream (~> 1, >= 1.0.2) base64 (0.3.0) bcrypt (3.1.22) bcrypt_pbkdf (1.1.2) + better_html (2.2.0) + actionview (>= 7.0) + activesupport (>= 7.0) + ast (~> 2.0) + erubi (~> 1.4) + parser (>= 2.4) + smart_properties bigdecimal (4.1.2) bindex (0.8.1) bootsnap (1.26.0) @@ -127,6 +154,13 @@ GEM dry-cli (1.4.1) ed25519 (1.4.0) erb (6.0.7) + erb_lint (0.9.0) + activesupport + better_html (>= 2.0.1) + parser (>= 2.7.1.4) + rainbow + rubocop (>= 1) + smart_properties erubi (1.13.1) et-orbi (1.4.2) tzinfo @@ -166,6 +200,7 @@ GEM prism (>= 1.3.0) rdoc (>= 4.0.0) reline (>= 0.4.2) + jmespath (1.6.2) json (2.21.2) kamal (2.12.0) activesupport (>= 7.0) @@ -368,6 +403,10 @@ GEM rubocop (>= 1.72) rubocop-performance (>= 1.24) rubocop-rails (>= 2.30) + rubocop-rspec (3.10.2) + lint_roller (~> 1.1) + regexp_parser (>= 2.0) + rubocop (~> 1.86, >= 1.86.2) ruby-progressbar (1.13.0) ruby-vips (2.3.0) ffi (~> 1.12) @@ -383,6 +422,7 @@ GEM shoulda-matchers (6.5.0) activesupport (>= 5.2.0) simplecov (1.2.0) + smart_properties (1.17.0) solid_cable (4.0.2) actioncable (>= 7.2) activejob (>= 7.2) @@ -469,6 +509,7 @@ PLATFORMS DEPENDENCIES active_storage_validations + aws-sdk-s3 (~> 1.231) bcrypt (~> 3.1.7) bootsnap brakeman @@ -478,6 +519,7 @@ DEPENDENCIES csv debug dotenv-rails + erb_lint factory_bot_rails faker image_processing (~> 1.2) @@ -493,6 +535,7 @@ DEPENDENCIES roo (~> 3.0) rspec-rails (~> 8.0) rubocop-rails-omakase + rubocop-rspec selenium-webdriver shoulda-matchers (~> 6.0) simplecov @@ -523,9 +566,16 @@ CHECKSUMS activesupport (8.1.3.1) sha256=85458765f25ea48b9019c46b6bb3fa5683197bf4280d9f06710a6e8d7a831376 addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 + aws-eventstream (1.4.0) sha256=116bf85c436200d1060811e6f5d2d40c88f65448f2125bc77ffce5121e6e183b + aws-partitions (1.1284.0) sha256=026432da13da430a31ba7c30c0c210b35fa7d738399977f033d4a5a354de58dc + aws-sdk-core (3.255.0) sha256=2bac7fbc8796e4e2eb8e6a6edebcb880d7023a922af97b15d2a8a26c9283343f + aws-sdk-kms (1.131.0) sha256=b60d28045cd93c604142cb691b15c7ddc1e6738c4ee5a90db4f2b91f0ada1d15 + aws-sdk-s3 (1.231.0) sha256=a9fc98c6f03f0e71c7215d48ff8844d436421df7f9486301d56bdf1f368a3364 + aws-sigv4 (1.12.1) sha256=6973ff95cb0fd0dc58ba26e90e9510a2219525d07620c8babeb70ef831826c00 base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b bcrypt (3.1.22) sha256=1f0072e88c2d705d94aff7f2c5cb02eb3f1ec4b8368671e19112527489f29032 bcrypt_pbkdf (1.1.2) sha256=c2414c23ce66869b3eb9f643d6a3374d8322dfb5078125c82792304c10b94cf6 + better_html (2.2.0) sha256=e68ab66ab09696b708333bbf35e8aa3c107500ba7892f528e2111624bdd8cf76 bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e bootsnap (1.26.0) sha256=ca96237015e6cd74a02963d5821cf00ac5ea134653b323e8cd6d702a7718bf1b @@ -547,6 +597,7 @@ CHECKSUMS dry-cli (1.4.1) sha256=b8015bb76c708aa8705a36faf694973e75eeeffca39b89c8e172dc6f66a7d874 ed25519 (1.4.0) sha256=16e97f5198689a154247169f3453ef4cfd3f7a47481fde0ae33206cdfdcac506 erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92 + erb_lint (0.9.0) sha256=dfb5e40ad839e8d1f0d56ca85ec9a7ac4c9cd966ec281138282f35b323ca7c31 erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 et-orbi (1.4.2) sha256=bb555dae668419cb24caa2a293a170e58be6d4df1e017c51f5030bdc133cd20c factory_bot (6.6.0) sha256=1fc1b3b5620ec980a6a27aec1b6ec8c250ca82962e970e8a40f93e8d388d4b89 @@ -567,6 +618,7 @@ CHECKSUMS inertia_rails (3.22.0) sha256=39c20120de472015d2831fa461f8a09672c68e91c41d3d660e0b1d16b787b7b1 io-console (0.9.2) sha256=efa74f891dd03c0939a931dfc6e74c2813d904763d456ea9762b0525e748db08 irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 + jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1 json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a kamal (2.12.0) sha256=c51d1ab085e515470f98d0c0f043637122b5ebf76e8b610cb1fbbed0b7f9b8fa language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0 @@ -644,6 +696,7 @@ CHECKSUMS rubocop-performance (1.27.0) sha256=eeeb1374d062a368ee1c787b70eb0b0cc4b184cb1f8565f424760946146d61ce rubocop-rails (2.37.0) sha256=6e1645add5060e0328f8ddda0d820f55697c591394398bf14bb9dccb62f14b7e rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d + rubocop-rspec (3.10.2) sha256=0b3e2ecc592cd10ecbf0095bb58d1e357905276e069643523cc19eb7495f65e2 ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 ruby-vips (2.3.0) sha256=e685ec02c13969912debbd98019e50492e12989282da5f37d05f5471442f5374 rubyzip (3.6.0) sha256=268994d44d62282d1cfd99bf10eae48d7267199158ad7ea3e1fee2da9458b695 @@ -651,6 +704,7 @@ CHECKSUMS selenium-webdriver (4.48.0) sha256=0c8376ebc8a0a4879343fe6fe6eccdcea76748611cd25de370b33eded2077a94 shoulda-matchers (6.5.0) sha256=ef6b572b2bed1ac4aba6ab2c5ff345a24b6d055a93a3d1c3bfc86d9d499e3f44 simplecov (1.2.0) sha256=ea6acd05eece5a41990e2a5171c57d15700d329326c7666c85ee8c6a0dd0977e + smart_properties (1.17.0) sha256=f9323f8122e932341756ddec8e0ac9ec6e238408a7661508be99439ca6d6384b solid_cable (4.0.2) sha256=084636a67679ad00d23088b33c84047e614bcf41ee559db24b414d83cdc42d03 solid_cache (1.0.10) sha256=bc05a2fb3ac78a6f43cbb5946679cf9db67dd30d22939ededc385cb93e120d41 solid_queue (1.7.0) sha256=6566b70b801d1c317c81bba7bcdd5677c019afac584a30374b4164002ca356d3 diff --git a/config/database.yml b/config/database.yml index f4e41f541..48f8c2385 100644 --- a/config/database.yml +++ b/config/database.yml @@ -57,18 +57,19 @@ test: production: primary: &primary_production <<: *default - database: fullstack_developer_production - username: fullstack_developer - password: <%= ENV["FULLSTACK_DEVELOPER_DATABASE_PASSWORD"] %> + host: <%= ENV["DB_HOST"] %> + database: umanni_users_production + username: <%= ENV["POSTGRES_USER"] %> + password: <%= ENV["POSTGRES_PASSWORD"] %> cache: <<: *primary_production - database: fullstack_developer_production_cache + database: umanni_users_production_cache migrations_paths: db/cache_migrate queue: <<: *primary_production - database: fullstack_developer_production_queue + database: umanni_users_production_queue migrations_paths: db/queue_migrate cable: <<: *primary_production - database: fullstack_developer_production_cable + database: umanni_users_production_cable migrations_paths: db/cable_migrate diff --git a/config/deploy.local.yml b/config/deploy.local.yml new file mode 100644 index 000000000..71acc231f --- /dev/null +++ b/config/deploy.local.yml @@ -0,0 +1,52 @@ +# Local rehearsal of the production deploy, merged over config/deploy.yml. +# The "server" is the SSH + Docker container in .kamal/local; secrets come from .kamal/secrets.local. +# +# docker compose -f .kamal/local/compose.yml up -d --build +# bin/kamal setup -d local # first time: boots postgres, then deploys +# bin/kamal deploy -d local # afterwards +# open http://localhost:8080 +image: umanni-users + +servers: + web: + hosts: + - 127.0.0.1 + job: + hosts: + - 127.0.0.1 + +ssh: + port: 2222 + keys: [ ".kamal/local/ssh/id_ed25519" ] + keys_only: true + config: false + +registry: + server: localhost:5555 + +# Native build on Apple Silicon; production stays amd64. +# Build the working tree (uncommitted changes included) instead of a git clone of HEAD. +builder: + arch: arm64 + context: . + +proxy: + ssl: false + host: localhost + +env: + clear: + APP_ORIGIN: http://localhost:8080 + RAILS_FORCE_SSL: false + ACTIVE_STORAGE_SERVICE: local + WEB_CONCURRENCY: 1 + secret: + - RAILS_MASTER_KEY + - POSTGRES_PASSWORD + +volumes: + - "umanni_users_storage:/rails/storage" + +accessories: + postgres: + host: 127.0.0.1 diff --git a/config/deploy.yml b/config/deploy.yml index af06c2a9e..961a47457 100644 --- a/config/deploy.yml +++ b/config/deploy.yml @@ -1,119 +1,70 @@ -# Name of your application. Used to uniquely configure containers. -service: fullstack_developer +# config/deploy.yml +service: umanni-users +image: /umanni-users -# 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 + hosts: + - 192.168.0.1 + job: + hosts: + - 192.168.0.1 + cmd: bundle exec rake solid_queue:start + +proxy: + ssl: true + host: users.example.com + app_port: 80 + healthcheck: + path: /up + interval: 3 + timeout: 30 -# 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 + username: + password: + - KAMAL_REGISTRY_PASSWORD - # Always use an access token rather than real password when possible. - # password: - # - KAMAL_REGISTRY_PASSWORD +# RUBY_VERSION / NODE_VERSION defaults live in the Dockerfile, so they can't drift from .ruby-version. +builder: + arch: amd64 -# Inject ENV variables into containers (secrets come from .kamal/secrets). env: + clear: + APP_ORIGIN: https://users.example.com + RAILS_MAX_THREADS: 5 + WEB_CONCURRENCY: 2 + SOLID_QUEUE_IN_PUMA: false + INERTIA_SSR_ENABLED: false + ACTIVE_STORAGE_SERVICE: amazon + AWS_REGION: us-east-1 + AWS_S3_BUCKET: umanni-users-production + DB_HOST: umanni-users-postgres + POSTGRES_USER: umanni 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 + - POSTGRES_PASSWORD + - AWS_ACCESS_KEY_ID + - AWS_SECRET_ACCESS_KEY + +accessories: + postgres: + image: postgres:17 + host: 192.168.0.1 + port: "127.0.0.1:5432:5432" + env: + clear: + POSTGRES_USER: umanni + POSTGRES_DB: umanni_users_production + secret: + - POSTGRES_PASSWORD + files: + - config/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql + directories: + - data:/var/lib/postgresql/data - # 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: 4.0.6 - # secrets: - # - GITHUB_TOKEN - # - RAILS_MASTER_KEY - -# Use a different ssh user than root -# ssh: -# user: app - -# Use accessory services (secrets come from .kamal/secrets). -# accessories: -# db: -# image: mysql:8.0 -# host: 192.168.0.2 -# # Change to 3306 to expose port to the world instead of just local network. -# port: "127.0.0.1:3306:3306" -# env: -# clear: -# MYSQL_ROOT_HOST: '%' -# secret: -# - MYSQL_ROOT_PASSWORD -# files: -# - config/mysql/production.cnf:/etc/mysql/my.cnf -# - db/production.sql:/docker-entrypoint-initdb.d/setup.sql -# directories: -# - data:/var/lib/mysql -# redis: -# image: valkey/valkey:8 -# host: 192.168.0.2 -# port: 6379 -# directories: -# - data:/data + jobs: app logs -f --roles job diff --git a/config/environments/production.rb b/config/environments/production.rb index 8779a1302..4ffe29d21 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -21,17 +21,16 @@ # 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 + # Store uploaded files per ACTIVE_STORAGE_SERVICE (see config/storage.yml): amazon in production, local disk otherwise. + config.active_storage.service = ENV.fetch("ACTIVE_STORAGE_SERVICE", :amazon).to_sym - # 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 + # kamal-proxy terminates SSL and doesn't forward X-Forwarded-Proto, so assume SSL and force it. + # The local Kamal destination serves plain HTTP and sets RAILS_FORCE_SSL=false. + config.assume_ssl = ENV.fetch("RAILS_FORCE_SSL", "true") == "true" + config.force_ssl = config.assume_ssl # Skip http-to-https redirect for the default health check endpoint. - # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } + config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } # Log to STDOUT with the current request id as a default log tag. config.log_tags = [ :request_id ] diff --git a/config/postgres/init.sql b/config/postgres/init.sql new file mode 100644 index 000000000..aa12d1906 --- /dev/null +++ b/config/postgres/init.sql @@ -0,0 +1,4 @@ +-- Runs once, when the postgres accessory starts with an empty data directory. +CREATE DATABASE umanni_users_production_queue OWNER umanni; +CREATE DATABASE umanni_users_production_cache OWNER umanni; +CREATE DATABASE umanni_users_production_cable OWNER umanni; diff --git a/config/puma.rb b/config/puma.rb index 38c4b8659..0f3acecf9 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -35,7 +35,7 @@ plugin :tmp_restart # Run the Solid Queue supervisor inside of Puma for single-server deployments. -plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"] +plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"] == "true" # Specify the PID file. Defaults to tmp/pids/server.pid in development. # In other environments, only set the PID file if requested. diff --git a/config/storage.yml b/config/storage.yml index 927dc537c..67316b3b2 100644 --- a/config/storage.yml +++ b/config/storage.yml @@ -6,13 +6,13 @@ 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 %> +# Credentials come from Kamal secrets (.kamal/secrets). +amazon: + service: S3 + access_key_id: <%= ENV["AWS_ACCESS_KEY_ID"] %> + secret_access_key: <%= ENV["AWS_SECRET_ACCESS_KEY"] %> + region: <%= ENV["AWS_REGION"] %> + bucket: <%= ENV["AWS_S3_BUCKET"] %> # Remember not to checkin your GCS keyfile to a repository # google: From 7aeb5266a7d4c36c3078d5a4306d71c24615bcbf Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Thu, 10 Sep 2026 14:24:23 -0300 Subject: [PATCH 63/82] feat: update CI configuration, enhance RuboCop settings, and add Prettier for code formatting --- .github/workflows/ci.yml | 126 +++++------------------------- .prettierrc.json | 5 ++ .rubocop.yml | 36 +++++++-- config/ci.rb | 26 ++---- config/environments/production.rb | 2 +- package-lock.json | 17 ++++ package.json | 4 +- 7 files changed, 82 insertions(+), 134 deletions(-) create mode 100644 .prettierrc.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 045a951a1..a924e3562 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,45 +1,9 @@ +# .github/workflows/ci.yml name: CI - -on: - pull_request: - push: - branches: [ main ] +on: [push, pull_request] 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 - - test: + ci: runs-on: ubuntu-latest services: @@ -48,89 +12,41 @@ jobs: env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres - ports: - - 5432:5432 + ports: ["5432:5432"] options: >- - --health-cmd="pg_isready" - --health-interval=10s - --health-timeout=5s - --health-retries=5 + --health-cmd pg_isready --health-interval 10s + --health-timeout 5s --health-retries 5 env: RAILS_ENV: test DATABASE_URL: postgres://postgres:postgres@localhost:5432 steps: - - name: Checkout code - uses: actions/checkout@v6 - - # ruby-vips loads libvips through FFI at boot, so without it the whole - # suite dies on require rather than on the first image assertion. - - name: Install libvips - run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libvips + - uses: actions/checkout@v4 - - name: Set up Ruby - uses: ruby/setup-ruby@v1 + - uses: ruby/setup-ruby@v1 with: + ruby-version: 4.0.4 bundler-cache: true - - name: Set up Node - uses: actions/setup-node@v6 + - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: npm - - name: Install JavaScript dependencies - run: npm ci - - # The system specs drive a real browser through capybara-playwright-driver. - # The version comes from package.json so it stays in step with the - # playwright-ruby-client gem, which pins the protocol it speaks. - - name: Install Playwright browser - run: npx playwright install --with-deps chromium - - - name: Prepare the test database - run: bin/rails db:test:prepare + - run: npm ci + - run: npx playwright install --with-deps chromium - # Vite would build on demand inside the first system spec; doing it here - # keeps that cost out of the example and off the Capybara wait budget. - - name: Build frontend assets - run: bin/vite build + - run: bin/rails db:create db:schema:load - - name: Run tests - run: bundle exec rspec + - run: bundle exec rubocop + - run: bundle exec brakeman --quiet --no-pager --exit-on-warn + - run: bundle exec bundler-audit check --update + - run: npm run typecheck + - run: bundle exec rspec - - name: Upload coverage report + - uses: actions/upload-artifact@v4 if: always() - uses: actions/upload-artifact@v4 with: name: coverage - path: coverage - if-no-files-found: ignore - - 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 - + path: coverage/ \ No newline at end of file diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 000000000..75a894a27 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "semi": false, + "singleQuote": true, + "printWidth": 100 +} diff --git a/.rubocop.yml b/.rubocop.yml index f9d86d4a5..a5655506e 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,8 +1,28 @@ -# 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 +inherit_gem: + rubocop-rails-omakase: rubocop.yml + +plugins: + - rubocop-rspec + +AllCops: + TargetRubyVersion: 4.0 + NewCops: enable + Exclude: + - "db/**/*" + - "bin/**/*" + - "vendor/**/*" + - "node_modules/**/*" + +# Omakase disables the whole Layout department, so this cop must be enabled explicitly. +Layout/LineLength: + Enabled: true + Max: 120 + +RSpec/ExampleLength: + Max: 15 + +RSpec/MultipleExpectations: + Max: 4 + +RSpec/NestedGroups: + Max: 4 diff --git a/config/ci.rb b/config/ci.rb index 8db391e2a..7c727680f 100644 --- a/config/ci.rb +++ b/config/ci.rb @@ -1,22 +1,10 @@ -# Run using bin/ci - -CI.run do +# config/ci.rb +ActiveSupport::ContinuousIntegration.run do step "Setup", "bin/setup --skip-server" - step "Style: Ruby", "bin/rubocop" - - step "Tests: RSpec", "bin/rails db:test:prepare && bundle exec rspec" - 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 + step "Security: Brakeman", "bin/brakeman --quiet --no-pager --exit-on-warn" + step "Types: TypeScript", "npm run typecheck" + step "Style: JavaScript", "npm run lint" + step "Tests", "bin/rails spec" +end \ No newline at end of file diff --git a/config/environments/production.rb b/config/environments/production.rb index 4ffe29d21..2dd4ffef5 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -21,7 +21,7 @@ # Enable serving of images, stylesheets, and JavaScripts from an asset server. # config.asset_host = "http://assets.example.com" - # Store uploaded files per ACTIVE_STORAGE_SERVICE (see config/storage.yml): amazon in production, local disk otherwise. + # Store uploads in the ACTIVE_STORAGE_SERVICE from config/storage.yml (amazon unless overridden). config.active_storage.service = ENV.fetch("ACTIVE_STORAGE_SERVICE", :amazon).to_sym # kamal-proxy terminates SSL and doesn't forward X-Forwarded-Proto, so assume SSL and force it. diff --git a/package-lock.json b/package-lock.json index a0b3d5170..1f5d911c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@types/react": "^19.2.18", "@types/react-dom": "^19.2.7", "playwright": "1.62.1", + "prettier": "^3.9.6", "typescript": "^7.0.2", "vite": "^8.2.2", "vite-plugin-ruby": "^5.2.3" @@ -1827,6 +1828,22 @@ "node": ">=4" } }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", diff --git a/package.json b/package.json index 89ffa6bec..a4539f4b1 100644 --- a/package.json +++ b/package.json @@ -7,12 +7,14 @@ "@types/react": "^19.2.18", "@types/react-dom": "^19.2.7", "playwright": "1.62.1", + "prettier": "^3.9.6", "typescript": "^7.0.2", "vite": "^8.2.2", "vite-plugin-ruby": "^5.2.3" }, "scripts": { - "check": "tsc -p tsconfig.app.json && tsc -p tsconfig.node.json" + "typecheck": "tsc -b", + "format": "prettier --check app/javascript" }, "dependencies": { "@inertiajs/react": "^3.7.0", From db165c20b12e2f3b4e92b3a84e6f2d92f8d3cb75 Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Thu, 10 Sep 2026 15:10:44 -0300 Subject: [PATCH 64/82] feat: update database configuration, adjust Puma port, and add Docker Compose setup for local development --- config/database.yml | 3 +- config/environments/production.rb | 2 +- config/puma.rb | 2 +- docker-compose.yml | 68 +++++++++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 docker-compose.yml diff --git a/config/database.yml b/config/database.yml index 48f8c2385..7fddc0481 100644 --- a/config/database.yml +++ b/config/database.yml @@ -1,7 +1,8 @@ default: &default adapter: postgresql encoding: unicode - username: postgres + username: umanni + password: secret host: localhost max_connections: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> diff --git a/config/environments/production.rb b/config/environments/production.rb index 2dd4ffef5..5709f0a25 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -22,7 +22,7 @@ # config.asset_host = "http://assets.example.com" # Store uploads in the ACTIVE_STORAGE_SERVICE from config/storage.yml (amazon unless overridden). - config.active_storage.service = ENV.fetch("ACTIVE_STORAGE_SERVICE", :amazon).to_sym + config.active_storage.service = ENV.fetch("ACTIVE_STORAGE_SERVICE", :local).to_sym # kamal-proxy terminates SSL and doesn't forward X-Forwarded-Proto, so assume SSL and force it. # The local Kamal destination serves plain HTTP and sets RAILS_FORCE_SSL=false. diff --git a/config/puma.rb b/config/puma.rb index 0f3acecf9..c4727feb7 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -29,7 +29,7 @@ threads threads_count, threads_count # Specifies the `port` that Puma will listen on to receive requests; default is 3000. -port ENV.fetch("PORT", 3000) +port ENV.fetch("PORT", 3001) # Allow puma to be restarted by `bin/rails restart` command. plugin :tmp_restart diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..14bac04d4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,68 @@ +# docker-compose.yml +services: + db: + container_name: umanni-pg + image: postgres:17 + environment: + POSTGRES_USER: umanni + POSTGRES_PASSWORD: secret + POSTGRES_DB: umanni_users_production + volumes: + - ./config/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql + - pg_data:/var/lib/postgresql/data + networks: + - umanni-test + healthcheck: + test: ["CMD-SHELL", "pg_isready -U umanni -d umanni_users_production"] + interval: 5s + timeout: 5s + retries: 5 + + redis: + container_name: umanni-redis + image: redis:7-alpine + volumes: + - redis_data:/data + networks: + - umanni-test + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + + web: + container_name: umanni-users + build: . + ports: + - "3001:80" + environment: + - RAILS_MASTER_KEY=${RAILS_MASTER_KEY} + - DB_HOST=umanni-pg + - POSTGRES_USER=umanni + - POSTGRES_PASSWORD=secret + - APP_ORIGIN=http://localhost:3001 + - REDIS_URL=redis://umanni-redis:6379/1 + - SECRET_KEY_BASE=${SECRET_KEY_BASE} + - AWS_REGION=${AWS_REGION} + - AWS_BUCKET=${AWS_BUCKET} + - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} + - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + networks: + - umanni-test + +volumes: + pg_data: + redis_data: + +networks: + umanni-test: + # If you created the network manually earlier (docker network create umanni-test), + # uncomment the line below and delete 'driver: bridge' + # external: true + driver: bridge \ No newline at end of file From ce9a68860689ed51e5a6fd9df2558ad0cfe7e97a Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Thu, 10 Sep 2026 15:19:59 -0300 Subject: [PATCH 65/82] feat: update Puma port and Docker Compose configuration for consistent development environment --- Gemfile | 2 -- Gemfile.lock | 5 ----- config/puma.rb | 2 +- docker-compose.yml | 6 ++++-- 4 files changed, 5 insertions(+), 10 deletions(-) diff --git a/Gemfile b/Gemfile index 946abb09d..ccc346914 100644 --- a/Gemfile +++ b/Gemfile @@ -57,8 +57,6 @@ gem "image_processing", "~> 1.2" gem "active_storage_validations" 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 diff --git a/Gemfile.lock b/Gemfile.lock index 0fdce657e..c16449bce 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -142,9 +142,6 @@ GEM crass (1.0.7) csv (3.3.6) date (3.5.1) - debug (1.11.1) - irb (~> 1.10) - reline (>= 0.3.8) diff-lcs (1.6.2) dotenv (3.2.0) dotenv-rails (3.2.0) @@ -517,7 +514,6 @@ DEPENDENCIES capybara capybara-playwright-driver csv - debug dotenv-rails erb_lint factory_bot_rails @@ -589,7 +585,6 @@ CHECKSUMS 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 dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d dotenv-rails (3.2.0) sha256=657e25554ba622ffc95d8c4f1670286510f47f2edda9f68293c3f661b303beab diff --git a/config/puma.rb b/config/puma.rb index c4727feb7..0f3acecf9 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -29,7 +29,7 @@ threads threads_count, threads_count # Specifies the `port` that Puma will listen on to receive requests; default is 3000. -port ENV.fetch("PORT", 3001) +port ENV.fetch("PORT", 3000) # Allow puma to be restarted by `bin/rails restart` command. plugin :tmp_restart diff --git a/docker-compose.yml b/docker-compose.yml index 14bac04d4..c46258837 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,19 +35,21 @@ services: container_name: umanni-users build: . ports: - - "3001:80" + - "3000:80" environment: - RAILS_MASTER_KEY=${RAILS_MASTER_KEY} - DB_HOST=umanni-pg - POSTGRES_USER=umanni - POSTGRES_PASSWORD=secret - - APP_ORIGIN=http://localhost:3001 + - APP_ORIGIN=http://localhost:3000 - REDIS_URL=redis://umanni-redis:6379/1 - SECRET_KEY_BASE=${SECRET_KEY_BASE} - AWS_REGION=${AWS_REGION} - AWS_BUCKET=${AWS_BUCKET} - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} + - RAILS_ENV=development + - RACK_ENV=development depends_on: db: condition: service_healthy From 089b17c0a0af486442aad17c41112eda217cc195 Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Thu, 10 Sep 2026 15:48:24 -0300 Subject: [PATCH 66/82] feat: update environment settings for development and adjust database configuration in Docker setup --- Dockerfile | 2 +- Gemfile | 5 ++--- Gemfile.lock | 6 +----- config/database.yml | 6 +++--- docker-compose.yml | 8 ++++---- 5 files changed, 11 insertions(+), 16 deletions(-) diff --git a/Dockerfile b/Dockerfile index 5f14ca3b6..b288c5150 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,7 +20,7 @@ RUN apt-get update -qq && \ rm -rf /var/lib/apt/lists /var/cache/apt/archives # jemalloc is only used if preloaded. -ENV RAILS_ENV=production \ +ENV RAILS_ENV=development \ BUNDLE_DEPLOYMENT=1 \ BUNDLE_PATH=/usr/local/bundle \ BUNDLE_WITHOUT=development:test \ diff --git a/Gemfile b/Gemfile index ccc346914..c8dca8134 100644 --- a/Gemfile +++ b/Gemfile @@ -57,7 +57,7 @@ gem "image_processing", "~> 1.2" gem "active_storage_validations" group :development, :test do - + gem "faker" # Audits gems for known security defects (use config/bundler-audit.yml to ignore issues) gem "bundler-audit", require: false @@ -69,8 +69,7 @@ group :development, :test do gem "rspec-rails", "~> 8.0" gem "factory_bot_rails" - gem "faker" - gem "dotenv-rails" + gem 'dotenv' gem "rubocop-rspec", require: false gem "erb_lint", require: false end diff --git a/Gemfile.lock b/Gemfile.lock index c16449bce..c3492fc7a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -144,9 +144,6 @@ GEM date (3.5.1) diff-lcs (1.6.2) dotenv (3.2.0) - dotenv-rails (3.2.0) - dotenv (= 3.2.0) - railties (>= 6.1) drb (2.2.3) dry-cli (1.4.1) ed25519 (1.4.0) @@ -514,7 +511,7 @@ DEPENDENCIES capybara capybara-playwright-driver csv - dotenv-rails + dotenv erb_lint factory_bot_rails faker @@ -587,7 +584,6 @@ CHECKSUMS date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d - dotenv-rails (3.2.0) sha256=657e25554ba622ffc95d8c4f1670286510f47f2edda9f68293c3f661b303beab drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 dry-cli (1.4.1) sha256=b8015bb76c708aa8705a36faf694973e75eeeffca39b89c8e172dc6f66a7d874 ed25519 (1.4.0) sha256=16e97f5198689a154247169f3453ef4cfd3f7a47481fde0ae33206cdfdcac506 diff --git a/config/database.yml b/config/database.yml index 7fddc0481..706a6f218 100644 --- a/config/database.yml +++ b/config/database.yml @@ -1,9 +1,9 @@ default: &default adapter: postgresql encoding: unicode - username: umanni - password: secret - host: localhost + host: <%= ENV.fetch("DB_HOST", "localhost") %> + username: <%= ENV.fetch("POSTGRES_USER", "umanni") %> + password: <%= ENV.fetch("POSTGRES_PASSWORD", "") %> max_connections: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> diff --git a/docker-compose.yml b/docker-compose.yml index c46258837..8f050d068 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,14 +6,14 @@ services: environment: POSTGRES_USER: umanni POSTGRES_PASSWORD: secret - POSTGRES_DB: umanni_users_production + POSTGRES_DB: umanni_users_development volumes: - ./config/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql - pg_data:/var/lib/postgresql/data networks: - umanni-test healthcheck: - test: ["CMD-SHELL", "pg_isready -U umanni -d umanni_users_production"] + test: ["CMD-SHELL", "pg_isready -U umanni -d umanni_users_development"] interval: 5s timeout: 5s retries: 5 @@ -48,8 +48,8 @@ services: - AWS_BUCKET=${AWS_BUCKET} - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} - - RAILS_ENV=development - - RACK_ENV=development + - RAILS_ENV=${RAILS_ENV} + - RACK_ENV=${RACK_ENV} depends_on: db: condition: service_healthy From c2a5133fa8c679c2270afab0f37f99e4a42b49aa Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Thu, 10 Sep 2026 15:57:15 -0300 Subject: [PATCH 67/82] feat: add faker gem for development and testing --- Gemfile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Gemfile b/Gemfile index c8dca8134..73f0fc5ca 100644 --- a/Gemfile +++ b/Gemfile @@ -56,8 +56,10 @@ gem "image_processing", "~> 1.2" # Adds `content_type` / `size` validators for Active Storage attachments (not in Rails core) gem "active_storage_validations" +gem "faker" + group :development, :test do - gem "faker" + # Audits gems for known security defects (use config/bundler-audit.yml to ignore issues) gem "bundler-audit", require: false From 036527398b04adc4ac3c52716a4926c972210c08 Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Thu, 10 Sep 2026 18:39:39 -0300 Subject: [PATCH 68/82] feat: update SSH configuration in local deployment settings --- config/deploy.local.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/config/deploy.local.yml b/config/deploy.local.yml index 71acc231f..98e9f2e8b 100644 --- a/config/deploy.local.yml +++ b/config/deploy.local.yml @@ -16,10 +16,9 @@ servers: - 127.0.0.1 ssh: - port: 2222 - keys: [ ".kamal/local/ssh/id_ed25519" ] - keys_only: true - config: false + user: wilbert + keys: + - ~/.ssh/id_rsa registry: server: localhost:5555 From 34d24d7db0a0477e40f1436242cd848350f3d94f Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Thu, 10 Sep 2026 18:41:09 -0300 Subject: [PATCH 69/82] feat: add comprehensive development setup documentation with Docker Compose --- DEVELOPMENT_SETUP.md | 422 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 422 insertions(+) create mode 100644 DEVELOPMENT_SETUP.md diff --git a/DEVELOPMENT_SETUP.md b/DEVELOPMENT_SETUP.md new file mode 100644 index 000000000..13e0b12f2 --- /dev/null +++ b/DEVELOPMENT_SETUP.md @@ -0,0 +1,422 @@ +# Development Setup with Docker Compose + +This guide explains how to build and run the Umanni Users app locally with Docker Compose, and what happens inside the containers when you do. + +- [1. What runs](#1-what-runs) +- [2. Prerequisites](#2-prerequisites) +- [3. Configure `.env`](#3-configure-env) +- [4. Build the image](#4-build-the-image--docker-compose-build) +- [5. Start the stack](#5-start-the-stack--docker-compose-up) +- [6. How a request flows through the system](#6-how-a-request-flows-through-the-system) +- [7. Background jobs (Solid Queue)](#7-background-jobs-solid-queue) +- [8. Seed the database](#8-seed-the-database) +- [9. Day-to-day development workflow](#9-day-to-day-development-workflow) +- [10. Command reference](#10-command-reference) +- [11. Data and persistence](#11-data-and-persistence) +- [12. Troubleshooting](#12-troubleshooting) +- [13. Known gaps in the current setup](#13-known-gaps-in-the-current-setup) + +--- + +## 1. What runs + +[docker-compose.yml](docker-compose.yml) defines three services on a private bridge network called `umanni-test`: + +| Service | Container name | Image | Purpose | Reachable from your machine | +|---|---|---|---|---| +| `db` | `umanni-pg` | `postgres:17` | Primary database, plus the Solid Queue, Solid Cable, and Solid Cache databases | No (only inside the network, port 5432) | +| `redis` | `umanni-redis` | `redis:7-alpine` | Started, but not used by the app (see [Known gaps](#13-known-gaps-in-the-current-setup)) | No | +| `web` | `umanni-users` | Built from [Dockerfile](Dockerfile) | Rails 8.1 + Puma, fronted by Thruster | **Yes: http://localhost:3000** | + +Containers find each other by container name, so Rails connects to Postgres at `DB_HOST=umanni-pg`. + +--- + +## 2. Prerequisites + +- **Docker Desktop** (or another engine) with **Compose v2**. This guide uses `docker compose ...`. The legacy `docker-compose ...` binary accepts the same commands. +- **The Rails master key.** Ask a teammate for it, or copy it from `config/master.key` if you already have one. You need it because: + - `User#email_address` is encrypted with Active Record Encryption, and its keys live in `config/credentials.yml.enc`. + - `config/master.key` is excluded from the image by [.dockerignore](.dockerignore), so the key has to come in through the `RAILS_MASTER_KEY` environment variable. +- Port **3000** free on your machine. + +> Docker Desktop on macOS installs its CLI in `~/.docker/bin`. If your shell prints `docker: command not found`, add `export PATH="$HOME/.docker/bin:$PATH"` to your shell profile. + +--- + +## 3. Configure `.env` + +Compose automatically reads a `.env` file next to `docker-compose.yml` and substitutes its values into every `${VAR}` in the file. `.env` is ignored by git ([.gitignore](.gitignore)) and never copied into the image ([.dockerignore](.dockerignore)), so secrets stay on your machine. + +Create `.env` in the project root: + +```dotenv +# Required +RAILS_MASTER_KEY= +SECRET_KEY_BASE= +RAILS_ENV=development +RACK_ENV=development + +# Passed through to the container but not used in development +# (development stores uploads on local disk; see section 11) +AWS_REGION=us-east-1 +AWS_BUCKET= +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +``` + +The `web` service gets the following environment. Some values are hardcoded in `docker-compose.yml`, so setting them in `.env` has **no effect**: + +| Variable | Value / source | Used by | +|---|---|---| +| `RAILS_MASTER_KEY` | `.env` | Decrypting credentials, including the Active Record Encryption keys | +| `SECRET_KEY_BASE` | `.env` | Signing sessions and cookies | +| `RAILS_ENV`, `RACK_ENV` | `.env` (the image also defaults `RAILS_ENV=development`) | Rails environment selection | +| `DB_HOST` | hardcoded `umanni-pg` | [config/database.yml](config/database.yml) | +| `POSTGRES_USER` / `POSTGRES_PASSWORD` | hardcoded `umanni` / `secret` | [config/database.yml](config/database.yml) | +| `APP_ORIGIN` | hardcoded `http://localhost:3000` | Allowed Action Cable origins ([development.rb](config/environments/development.rb)). Boot fails without it. | +| `REDIS_URL` | hardcoded | Nothing (unused) | +| `AWS_*` | `.env` | Only the `amazon` storage service, which development does not use | + +--- + +## 4. Build the image — `docker compose build` + +```bash +docker compose build # builds the `web` image (db and redis are pulled, not built) +``` + +The first build downloads Ruby, Node, and all gems and npm packages, so it takes several minutes. Later builds reuse cached layers and take about 15–20 seconds when only application code changed. + +The resulting image is named `fullstack-developer-web:latest`. [Dockerfile](Dockerfile) is a **multi-stage** build: + +``` +┌──────────────── base ────────────────┐ +│ ruby:4.0.6-slim │ +│ + curl, libjemalloc2, libvips, │ +│ postgresql-client │ +│ ENV RAILS_ENV=development │ +│ BUNDLE_WITHOUT=development:test │ +└───────────────┬──────────────────────┘ + │ + ┌──────────▼─────────── build ────────────────────────────────┐ + │ + build-essential, libpq-dev, git, Node 22.14.0 │ + │ 1. bundle install (Gemfile / Gemfile.lock layer — cached) │ + │ 2. npm ci (package-lock.json layer — cached) │ + │ 3. COPY . . (application code) │ + │ 4. bootsnap precompile │ + │ 5. bin/rails assets:precompile │ + │ → Tailwind build + Vite build into public/vite-dev/ │ + │ 6. rm -rf node_modules │ + └──────────┬──────────────────────────────────────────────────┘ + │ copy /usr/local/bundle and /rails only + ┌──────────▼─────────── final ───────────┐ + │ base + gems + app + compiled assets │ + │ runs as non-root user `rails` (1000) │ + │ ENTRYPOINT bin/docker-entrypoint │ + │ CMD ./bin/thrust ./bin/rails server │ + │ EXPOSE 80 │ + └────────────────────────────────────────┘ +``` + +Things to know about the build: + +- **Layer order matters for speed.** Gems and npm packages are installed *before* `COPY . .`. Editing app code reuses those layers. Changing `Gemfile.lock` or `package-lock.json` triggers a full reinstall. +- **Frontend assets are compiled at build time.** Because `RAILS_ENV=development`, Vite writes to `public/vite-dev/` (see [config/vite.json](config/vite.json)). Node is removed from the final image, which works because the assets are already built. At runtime, vite_ruby logs `Skipping vite build. Watched files have not changed since the last build`. +- **Development and test gems are not installed** (`BUNDLE_WITHOUT=development:test`). `web-console`, `rspec`, `rubocop`, `brakeman`, and `dotenv` are therefore **not** in the container. Run tests and linters on your host (or in CI), not in this image. `faker` is a top-level gem, so seeds do work. +- **Secrets are never baked in.** `.env*`, `config/master.key`, `spec/`, `.git/`, `node_modules/`, and `log/`/`tmp/` contents are all in [.dockerignore](.dockerignore). + +Force a clean rebuild with no cache: + +```bash +docker compose build --no-cache web +``` + +--- + +## 5. Start the stack — `docker compose up` + +```bash +docker compose up -d # start in the background +docker compose logs -f web # follow the Rails/Thruster logs (Ctrl-C stops following, not the app) +``` + +Or run in the foreground (logs in your terminal, Ctrl-C stops everything): + +```bash +docker compose up +``` + +Build and start in one step: + +```bash +docker compose up -d --build +``` + +Then open **http://localhost:3000**. Health check: `curl http://localhost:3000/up` returns `200`. + +### Startup sequence + +``` +docker compose up +│ +├─ db (umanni-pg) +│ ├─ empty pg_data volume? → create user `umanni`, database `umanni_users_development`, +│ │ run config/postgres/init.sql (first boot only) +│ └─ healthcheck: pg_isready every 5s ───────────────┐ +│ │ +├─ redis (umanni-redis) │ +│ └─ healthcheck: redis-cli ping every 5s ───────────┤ +│ │ depends_on: service_healthy +└─ web (umanni-users) ◄───────────────────────────────┘ + └─ bin/docker-entrypoint ./bin/thrust ./bin/rails server + ├─ args end in "./bin/rails server" → ./bin/rails db:prepare + │ creates any missing databases, loads schema or runs pending migrations for + │ primary, queue, cache, and cable + └─ exec ./bin/thrust ./bin/rails server + ├─ Thruster listens on :80 (container) ← published as localhost:3000 + └─ Puma listens on 127.0.0.1:3000 (inside the container only) +``` + +Key points: + +1. **`web` waits until Postgres and Redis report healthy** (`depends_on: condition: service_healthy`). It won't start before the database accepts connections. +2. **Migrations run automatically on every boot.** [bin/docker-entrypoint](bin/docker-entrypoint) runs `db:prepare` whenever the command ends in `./bin/rails server`. On a fresh volume it creates and loads all four development databases. On an existing one it only applies pending migrations. Commands like `docker compose exec web ./bin/rails console` skip this step. +3. **The `3000:80` port mapping is intentional.** Thruster (an HTTP/2 proxy that handles gzip, asset caching, and X-Sendfile) listens on port 80 and forwards to Puma on port 3000 *inside* the container. Browser traffic always goes through Thruster. +4. During the first second or two you may see `Unable to proxy request ... connection refused`. Thruster starts before Puma finishes booting, and the message stops once Puma is listening. + +### Databases + +All four logical databases live in the single `umanni-pg` server: + +| Rails role | Database | Purpose in development | +|---|---|---| +| `primary` | `umanni_users_development` | Users, sessions, imports, Active Storage records | +| `queue` | `umanni_users_development_queue` | Solid Queue job tables | +| `cable` | `umanni_users_development_cable` | Solid Cable pub/sub messages (real-time dashboard and import progress) | +| `cache` | `umanni_users_development_cache` | Created by `db:prepare`, but development uses `:memory_store` | + +[config/postgres/init.sql](config/postgres/init.sql) also creates `umanni_users_production_{queue,cache,cable}`. That script is shared with the Kamal Postgres accessory. The production databases are unused in development and harmless. + +--- + +## 6. How a request flows through the system + +``` +Browser ──http://localhost:3000──► Docker port map ──► Thruster :80 ──► Puma 127.0.0.1:3000 ──► Rails + (gzip, asset │ + caching) ├─► Postgres primary (umanni-pg) + │ +Browser ──ws://localhost:3000/cable──────────────────────────────────────────► Action Cable ─────┤ + │ │ + polls every 0.1s ◄────────────┘ │ + Postgres cable DB (Solid Cable) │ + │ +Admin uploads spreadsheet ──► ProcessImportJob.perform_later ──► Postgres queue DB ──► bin/jobs worker + (must be started — §7) +``` + +- **Pages** are Rails controllers rendering Inertia.js responses. React components come from the prebuilt bundle in `public/vite-dev/`, served by Thruster. +- **Real-time updates** (dashboard counters, import progress) use Action Cable at `/cable` on the Solid Cable adapter. Solid Cable stores messages in the `cable` database and polls it. No Redis is involved. +- **Background work** (spreadsheet imports on the `imports` queue, debounced dashboard broadcasts on `default`) is enqueued into the `queue` database through Solid Queue. It only runs when a worker process is running (next section). + +--- + +## 7. Background jobs (Solid Queue) + +> ⚠️ `docker compose up` does **not** start a job worker. `web` runs only Puma, and `SOLID_QUEUE_IN_PUMA` is not set. Until you start a worker, spreadsheet imports stay queued and never make progress. + +Start a worker inside the running `web` container: + +```bash +# in the background +docker compose exec -d web ./bin/jobs + +# or in the foreground, to watch job logs (Ctrl-C stops the worker) +docker compose exec web ./bin/jobs +``` + +This starts a Solid Queue supervisor with one dispatcher and one worker. The worker has 3 threads and listens on all queues (see [config/queue.yml](config/queue.yml)). Check that it registered: + +```bash +docker compose exec db psql -U umanni -d umanni_users_development_queue \ + -c "select kind, hostname, pid from solid_queue_processes;" +``` + +The worker lives inside the `web` container, so it stops when `web` stops or is recreated. Start it again after every `up --build`. + +**Alternative:** run the worker inside Puma. Add this line to the `web` service's `environment:` list in `docker-compose.yml`: + +```yaml + - SOLID_QUEUE_IN_PUMA=true +``` + +[config/puma.rb](config/puma.rb) then loads the `solid_queue` plugin, and the worker starts and stops together with the web server. + +--- + +## 8. Seed the database + +```bash +docker compose exec web ./bin/rails db:seed +``` + +[db/seeds.rb](db/seeds.rb) creates: + +- **Admin:** `admin@umanni.test` / `password123`. To choose the password, pass `-e SEED_ADMIN_PASSWORD=...` to `docker compose exec`. The variable isn't forwarded from `.env`. +- **25 regular members** with Faker names and pravatar avatars, password `password123`. + +The admin is created only once, but **every run adds 25 more members**, because Faker generates new unique emails each time. + +Reset everything (drop, recreate, load schema, seed): + +```bash +docker compose exec web ./bin/rails db:reset +``` + +--- + +## 9. Day-to-day development workflow + +**The source code is not mounted into the container.** `web` runs the snapshot of the code that was copied in at build time. Rails' code reloading is enabled, but it only sees files inside the image. Edits on your host do **not** show up until you rebuild. + +The loop for picking up changes: + +```bash +# edit code on your host, then: +docker compose up -d --build web # rebuild the image (≈15–20s with cache) and recreate the container +docker compose exec -d web ./bin/jobs # restart the worker if you need jobs +docker compose logs -f web +``` + +`db` and `redis` keep running, and their data persists in named volumes. Pending migrations run automatically when the new `web` container boots. + +| You changed... | What to run | +|---|---| +| Ruby, views, React/TS components, CSS | `docker compose up -d --build web` | +| A new migration | `docker compose up -d --build web` (the entrypoint migrates on boot) | +| `Gemfile` / `package.json` | Update the lockfile on the host first (`bundle install` / `npm install`), then `docker compose up -d --build web` (slower: gem/npm layers rebuild) | +| `docker-compose.yml` environment | `docker compose up -d` (Compose recreates the containers that changed) | +| `.env` values | `docker compose up -d` | + +### Hot reload (hybrid mode) + +For fast feedback (Vite HMR, instant Ruby reloads, tests, linters), run the Rails processes on your host with `bin/dev` ([Procfile.dev](Procfile.dev) starts Puma, the Tailwind watcher, and the Vite dev server), and use Compose only for Postgres. This mode isn't configured out of the box: + +1. Publish the Postgres port. Add this to the `db` service: + ```yaml + ports: + - "5432:5432" + ``` +2. `docker compose up -d db` +3. On your host (Ruby 4.0.6 and Node 22 installed, after `bundle install && npm install`), export the variables Rails expects. `.env` is not loaded automatically outside Compose: + ```bash + export DB_HOST=localhost POSTGRES_USER=umanni POSTGRES_PASSWORD=secret \ + APP_ORIGIN=http://localhost:3000 + bin/dev + ``` + +Don't run the hybrid `bin/dev` and the `web` container at the same time. Both want port 3000. + +--- + +## 10. Command reference + +| Task | Command | +|---|---| +| Build the image | `docker compose build` | +| Start everything (background) | `docker compose up -d` | +| Rebuild and restart the app | `docker compose up -d --build web` | +| Service status and health | `docker compose ps` | +| Follow app logs | `docker compose logs -f web` | +| Start the job worker | `docker compose exec -d web ./bin/jobs` | +| Rails console | `docker compose exec web ./bin/rails console` | +| Shell in the app container | `docker compose exec web bash` | +| Run migrations manually | `docker compose exec web ./bin/rails db:migrate` | +| Seed | `docker compose exec web ./bin/rails db:seed` | +| Routes | `docker compose exec web ./bin/rails routes` | +| psql (primary DB) | `docker compose exec db psql -U umanni -d umanni_users_development` | +| Stop (keep containers and data) | `docker compose stop` | +| Stop and remove containers (keep data) | `docker compose down` | +| **Wipe everything, including the database** | `docker compose down -v` ⚠️ irreversible | +| Clean image rebuild | `docker compose build --no-cache web` | + +Commands run through `docker compose exec` execute as the non-root `rails` user (UID 1000) in `/rails`. + +--- + +## 11. Data and persistence + +| Data | Where it lives | Survives `down` / `up --build`? | +|---|---|---| +| Postgres (all four databases) | Named volume `fullstack-developer_pg_data` | ✅ Yes. Lost only with `down -v` | +| Redis | Named volume `fullstack-developer_redis_data` | ✅ Yes (unused) | +| Uploaded avatars (Active Storage, `:local` service) | `/rails/storage` **inside the container** | ❌ **No.** Lost whenever the `web` container is recreated | +| Logs | Container stdout (`docker compose logs`) and `/rails/log` | ❌ No | + +After a rebuild, users with *uploaded* avatars keep their database records, but the files are gone, so their images break. Avatars set by remote URL are unaffected. To keep uploads, add a volume to `web`: + +```yaml + volumes: + - storage_data:/rails/storage +``` + +Then declare `storage_data:` under the top-level `volumes:` key. + +`config/postgres/init.sql` runs **only when `pg_data` is empty**. Editing it has no effect on an existing database unless you wipe the volume. + +--- + +## 12. Troubleshooting + +**`web` exits immediately, or errors mention credentials or encryption.** +`RAILS_MASTER_KEY` is missing or wrong. Rails can't decrypt `config/credentials.yml.enc`, and so has no Active Record Encryption keys for `User#email_address`. Check `.env`, then `docker compose up -d`. + +**`Bind for 0.0.0.0:3000 failed: port is already allocated`** +Something else is using port 3000, often a host `bin/dev`. Stop it, or change the mapping to `"3001:80"`. If you change the port, also update `APP_ORIGIN` to `http://localhost:3001`, or Action Cable will reject WebSocket connections. + +**`Conflict. The container name "/umanni-pg" is already in use`** +The containers have fixed names, so only one copy of this stack can exist per Docker engine. Remove the old one (`docker rm -f umanni-pg umanni-redis umanni-users`), or run `docker compose down` from the other checkout. + +**Code changes don't appear.** +Expected: the code is baked into the image. Run `docker compose up -d --build web` ([section 9](#9-day-to-day-development-workflow)). + +**Imports stuck at "pending" / dashboard counters don't update after changes.** +No job worker is running. See [section 7](#7-background-jobs-solid-queue). + +**Real-time updates don't arrive (WebSocket fails).** +`APP_ORIGIN` must exactly match the URL in your browser, including scheme and port (`http://localhost:3000`). Using `127.0.0.1:3000` instead of `localhost:3000` fails the origin check. + +**`ActiveRecord::PendingMigrationError` in the browser.** +Migrations normally run on boot. If you ran `db:rollback` or similar by hand, run `docker compose exec web ./bin/rails db:migrate`. + +**`Unable to proxy request ... connection refused` in the logs right after start.** +Harmless: Thruster started before Puma. It stops once Puma prints `Listening on http://127.0.0.1:3000`. + +**`No route matches [GET] "/.well-known/appspecific/com.chrome.devtools.json"`** +Harmless: Chrome DevTools probes for this file. + +**Start completely fresh.** +```bash +docker compose down -v # ⚠️ deletes the database volume +docker compose build --no-cache +docker compose up -d +docker compose exec web ./bin/rails db:seed +``` + +--- + +## 13. Known gaps in the current setup + +Configuration quirks you may run into: + +- **No job worker service.** Imports need `bin/jobs` started by hand, or `SOLID_QUEUE_IN_PUMA=true` ([section 7](#7-background-jobs-solid-queue)). +- **Redis is unused.** Nothing reads `REDIS_URL`: cache, queue, and cable all run on Postgres through the Solid adapters. The `redis` service can be removed. +- **The image is built with `RAILS_ENV=development`.** The Dockerfile header describes it as a production image for Kamal, but [Dockerfile](Dockerfile) line 23 hardcodes `RAILS_ENV=development`, and [config/deploy.yml](config/deploy.yml) doesn't override it. A Kamal deploy would boot in development mode unless `RAILS_ENV: production` is added to `env.clear`. +- **Uploads aren't persisted** across container recreation ([section 11](#11-data-and-persistence)). +- **`.entrypoint` in the project root is not used.** The image's entrypoint is [bin/docker-entrypoint](bin/docker-entrypoint). +- **AWS variable names don't match.** Compose passes `AWS_BUCKET`, but [config/storage.yml](config/storage.yml) reads `AWS_S3_BUCKET`. Development uses local disk, so it only matters if you switch to the `amazon` service. + +### Not to be confused with: the Kamal local rehearsal + +[.kamal/local/compose.yml](.kamal/local/compose.yml) is a **separate** Compose file. It starts a fake "production server" (SSH + Docker-in-Docker) for rehearsing `bin/kamal deploy -d local`, served on http://localhost:8080. It has nothing to do with the development stack described here. See the comments in [config/deploy.local.yml](config/deploy.local.yml). From 1f7011ed55fbdf7bd371303763a8e1e707b07da6 Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Thu, 10 Sep 2026 18:45:31 -0300 Subject: [PATCH 70/82] feat: add AI usage disclosure document outlining AI tools and their applications in project development --- AI_DISCLOSURE.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 AI_DISCLOSURE.md diff --git a/AI_DISCLOSURE.md b/AI_DISCLOSURE.md new file mode 100644 index 000000000..2e5b4be53 --- /dev/null +++ b/AI_DISCLOSURE.md @@ -0,0 +1,40 @@ +# AI Usage Disclosure + +This document describes how AI assistants were used while building this project, in line with Umanni's AI policy. + +## Models used + +| Tool / Model | Primary use | +|---|---| +| **GitHub Copilot** | Inline code completion while writing code | +| **Claude Opus 5** (Anthropic) | Planning, bug fixing, and code / Pull Request reviews | + +Copilot was used for code completion. Claude Opus 5 was used for planning, debugging, and reviews. + +## Scope of use + +### 1. Planning the first steps +AI helped break the task into initial steps before implementation started: project setup, domain model (`User` with `full_name`, `email_address`, `avatar_image`, `role`), authentication, the admin dashboard, and the spreadsheet import flow. + +### 2. Understanding the requirements +AI was used to go through the test requirements in more depth. For example: +- what the Rails 8 native stack requires (Solid Queue for background imports, Solid Cable for real-time updates, built-in authentication instead of Devise); +- how the admin, user, and visitor use cases map to routes, controllers, and authorization rules. + +### 3. Choosing the frontend stack +The requirements offer two frontend options: +- **Option A:** Hotwire (Turbo / Stimulus) +- **Option B:** React integrated via Inertia.js + +AI helped compare the trade-offs between them. **Inertia.js + React with Vite Rails** was chosen instead of pure Rails with Stimulus. + +### 4. Learning technologies not used before +AI served as a learning aid for technologies I had not used before, mainly **Inertia.js** and its integration with Rails (`inertia_rails`), React, and Vite (`vite_rails`). This covered explaining concepts, setup, and how the pieces connect, and helped get them working in this project. + +### 5. Bug fixing +Claude Opus 5 helped investigate and fix bugs found during development. + +### 6. Pull Request reviews and descriptions +AI was used to: +- review Pull Requests before merging, pointing out correctness issues and possible improvements; +- write Pull Request descriptions summarizing the changes in each PR. From 3fba879f5288016bad1b9b84659ff552ad66e41b Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Thu, 10 Sep 2026 18:48:49 -0300 Subject: [PATCH 71/82] feat: update Ruby version to 4.0.6 in CI configuration --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a924e3562..423347a24 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: - uses: ruby/setup-ruby@v1 with: - ruby-version: 4.0.4 + ruby-version: 4.0.6 bundler-cache: true - uses: actions/setup-node@v4 From 98e50b326e0df1c83d7a81a9e77e2cbef94556b2 Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Thu, 10 Sep 2026 18:48:55 -0300 Subject: [PATCH 72/82] feat: add Kamal deployment status documentation outlining deployment challenges and repository contents --- KAMAL_DISCLOUSURE.md | 72 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 KAMAL_DISCLOUSURE.md diff --git a/KAMAL_DISCLOUSURE.md b/KAMAL_DISCLOUSURE.md new file mode 100644 index 000000000..aea7f79c5 --- /dev/null +++ b/KAMAL_DISCLOUSURE.md @@ -0,0 +1,72 @@ +# Kamal Deployment Status + +## Summary + +I intended to finish the Kamal 2 deployment and run it against a real server, but it was **not completed**. The repository contains Kamal configuration that is close to what a real VPS deploy needs, but that configuration has **not been verified with an actual `kamal deploy`**. + +For development, Kamal is not needed. Docker Compose is enough (see [Development](#development)). + +## Why the deploy was not completed + +- **No VPS available.** I don't have a VPS to deploy to at the moment. +- **The local rehearsal didn't fit on my machine.** I tried simulating a VPS locally with **Multipass** (an Ubuntu VM acting as the server). A VM plus Docker, the built images, and the Postgres accessory need a lot of SSD space. My Mac has a 256 GB SSD with only about 20 GB free, which wasn't enough, so I couldn't get Kamal running. + +## What is in the repository + +| File | Purpose | +|---|---| +| [config/deploy.yml](config/deploy.yml) | Production deploy config (close to final) | +| [.kamal/secrets](.kamal/secrets) | Maps secrets from the local environment and `config/master.key`. No secret values are committed. | +| [config/deploy.local.yml](config/deploy.local.yml) + [.kamal/local/](.kamal/local/) | Local rehearsal destination (`-d local`): an SSH + Docker-in-Docker container standing in for a server. Not verified. | +| [Dockerfile](Dockerfile) | Multi-stage image served through Thruster on port 80 | + +What `config/deploy.yml` defines: + +- **Roles:** `web` (Puma behind Thruster) and `job` (runs `bundle exec rake solid_queue:start` for background imports). +- **Proxy:** kamal-proxy with SSL (Let's Encrypt), `app_port: 80`, and a health check on `/up`. +- **Builder:** `amd64`, the typical VPS architecture. +- **Accessory:** `postgres:17`, bound to `127.0.0.1:5432`, with persistent data and [config/postgres/init.sql](config/postgres/init.sql) creating the queue, cache, and cable databases. +- **Environment:** `APP_ORIGIN`, `WEB_CONCURRENCY`, `RAILS_MAX_THREADS`, S3 storage for Active Storage, `DB_HOST` pointing to the Postgres accessory. +- **Secrets:** `RAILS_MASTER_KEY`, `POSTGRES_PASSWORD`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `KAMAL_REGISTRY_PASSWORD`. +- **Aliases:** `console`, `shell`, `logs`, `jobs`. + +## Remaining steps for a real VPS + +1. **Replace the placeholders** in `config/deploy.yml`: + + | Placeholder | Replace with | + |---|---| + | `192.168.0.1` (`web`, `job`, `postgres` hosts) | The VPS public IP | + | `` (`image`, `registry.username`) | Container registry account (for example Docker Hub or GHCR) | + | `users.example.com` (`proxy.host`, `APP_ORIGIN`) | The real domain | + | `umanni-users-production` (`AWS_S3_BUCKET`) | The real S3 bucket | + +2. **Set the Rails environment.** The Dockerfile sets `RAILS_ENV=development` by default, so add `RAILS_ENV: production` under `env.clear` in `config/deploy.yml`. +3. **Point DNS** for the domain (an A record) to the VPS IP. Let's Encrypt needs this to issue the SSL certificate. +4. **Provide the secrets** on the machine running Kamal: + ```bash + export KAMAL_REGISTRY_PASSWORD=... + export POSTGRES_PASSWORD=... + export AWS_ACCESS_KEY_ID=... + export AWS_SECRET_ACCESS_KEY=... + # RAILS_MASTER_KEY is read from config/master.key + ``` +5. **Deploy:** + ```bash + bin/kamal setup # first time: installs Docker on the host, boots Postgres, deploys the app + bin/kamal deploy # later deploys + bin/kamal logs # follow logs (alias) + ``` + +## Development + +For development, you only need Docker Compose: + +```bash +docker compose build +docker compose up +``` + +The app is then available at http://localhost:3000. + +Spreadsheet imports also need a job worker, started with `docker compose exec -d web ./bin/jobs`. See [DEVELOPMENT_SETUP.md](DEVELOPMENT_SETUP.md) for the full guide: environment variables, seeding, background jobs, and troubleshooting. From b1b04f8969cdddd85c9bfd97508eda67534bde1a Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Thu, 10 Sep 2026 21:59:53 -0300 Subject: [PATCH 73/82] feat: remove unused files and clean up project structure --- .entrypoint | 7 -- README.md => CHALLENGE.md | 0 app/assets/tailwind/application.css | 1 - app/controllers/inertia_example_controller.rb | 12 --- app/javascript/application.js | 3 - app/javascript/controllers/application.js | 9 -- .../controllers/hello_controller.js | 7 -- app/javascript/controllers/index.js | 4 - app/javascript/entrypoints/application.ts | 28 ----- .../pages/inertia_example/index.module.css | 102 ------------------ .../pages/inertia_example/index.tsx | 59 ---------- bin/importmap | 4 - config/importmap.rb | 7 -- vendor/javascript/.keep | 0 14 files changed, 243 deletions(-) delete mode 100644 .entrypoint rename README.md => CHALLENGE.md (100%) delete mode 100644 app/assets/tailwind/application.css delete mode 100644 app/controllers/inertia_example_controller.rb delete mode 100644 app/javascript/application.js delete mode 100644 app/javascript/controllers/application.js delete mode 100644 app/javascript/controllers/hello_controller.js delete mode 100644 app/javascript/controllers/index.js delete mode 100644 app/javascript/entrypoints/application.ts delete mode 100644 app/javascript/pages/inertia_example/index.module.css delete mode 100644 app/javascript/pages/inertia_example/index.tsx delete mode 100755 bin/importmap delete mode 100644 config/importmap.rb delete mode 100644 vendor/javascript/.keep diff --git a/.entrypoint b/.entrypoint deleted file mode 100644 index aa9bc365c..000000000 --- a/.entrypoint +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash -e - -if [ "${@: -1:1}" == "./bin/rails" ] || [ "${1}" == "./bin/thrust" ]; then - ./bin/rails db:prepare -fi - -exec "${@}" \ No newline at end of file diff --git a/README.md b/CHALLENGE.md similarity index 100% rename from README.md rename to CHALLENGE.md diff --git a/app/assets/tailwind/application.css b/app/assets/tailwind/application.css deleted file mode 100644 index f1d8c73cd..000000000 --- a/app/assets/tailwind/application.css +++ /dev/null @@ -1 +0,0 @@ -@import "tailwindcss"; diff --git a/app/controllers/inertia_example_controller.rb b/app/controllers/inertia_example_controller.rb deleted file mode 100644 index 7b72c3a51..000000000 --- a/app/controllers/inertia_example_controller.rb +++ /dev/null @@ -1,12 +0,0 @@ -# frozen_string_literal: true - -class InertiaExampleController < InertiaController - def index - render inertia: { - rails_version: Rails.version, - ruby_version: RUBY_DESCRIPTION, - rack_version: Rack.release, - inertia_rails_version: InertiaRails::VERSION - } - end -end diff --git a/app/javascript/application.js b/app/javascript/application.js deleted file mode 100644 index 0d7b49404..000000000 --- a/app/javascript/application.js +++ /dev/null @@ -1,3 +0,0 @@ -// 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 deleted file mode 100644 index 1213e85c7..000000000 --- a/app/javascript/controllers/application.js +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index 5975c0789..000000000 --- a/app/javascript/controllers/hello_controller.js +++ /dev/null @@ -1,7 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -export default class extends Controller { - connect() { - this.element.textContent = "Hello World!" - } -} diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js deleted file mode 100644 index 1156bf836..000000000 --- a/app/javascript/controllers/index.js +++ /dev/null @@ -1,4 +0,0 @@ -// 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/javascript/entrypoints/application.ts b/app/javascript/entrypoints/application.ts deleted file mode 100644 index ff27427fd..000000000 --- a/app/javascript/entrypoints/application.ts +++ /dev/null @@ -1,28 +0,0 @@ -// To see this message, add the following to the `` section in your -// views/layouts/application.html.erb -// -// <%= vite_client_tag %> -// <%= vite_javascript_tag 'application' %> -console.log('Vite ⚡️ Rails') - -// If using a TypeScript entrypoint file: -// <%= vite_typescript_tag 'application' %> -// -// If you want to use .jsx or .tsx, add the extension: -// <%= vite_javascript_tag 'application.jsx' %> - -console.log('Visit the guide for more information: ', 'https://vite-ruby.netlify.app/guide/rails') - -// Example: Load Rails libraries in Vite. -// -// import * as Turbo from '@hotwired/turbo' -// Turbo.start() -// -// import ActiveStorage from '@rails/activestorage' -// ActiveStorage.start() -// -// // Import all channels. -// const channels = import.meta.glob('./**/*_channel.js', { eager: true }) - -// Example: Import a stylesheet in app/frontend/index.css -// import '~/index.css' diff --git a/app/javascript/pages/inertia_example/index.module.css b/app/javascript/pages/inertia_example/index.module.css deleted file mode 100644 index 1aae5e40a..000000000 --- a/app/javascript/pages/inertia_example/index.module.css +++ /dev/null @@ -1,102 +0,0 @@ -.root { - box-sizing: border-box; - margin: 0; - padding: 0; - align-items: center; - background-color: #F0E7E9; - background-image: url(data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjEwMjQiIHZpZXdCb3g9IjAgMCAxNDQwIDEwMjQiIHdpZHRoPSIxNDQwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxwYXRoIGQ9Im0xNDQwIDUxMC4wMDA2NDh2LTUxMC4wMDA2NDhoLTE0NDB2Mzg0LjAwMDY0OGM0MTcuMzExOTM5IDEzMS4xNDIxNzkgODkxIDE3MS41MTMgMTQ0MCAxMjZ6IiBmaWxsPSIjZmZmIi8+PC9zdmc+); - background-position: center center; - background-repeat: no-repeat; - background-size: cover; - color: #261B23; - display: flex; - flex-direction: column; - font-family: Sans-Serif; - font-size: calc(0.9em + 0.5vw); - font-style: normal; - font-weight: 400; - justify-content: center; - line-height: 1.25; - min-height: 100vh; - text-align: center; -} - -@media (prefers-color-scheme: dark) { - .root { - background-color: #1a1a1a; - background-image: url(data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjEwMjQiIHZpZXdCb3g9IjAgMCAxNDQwIDEwMjQiIHdpZHRoPSIxNDQwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxwYXRoIGQ9Im0xNDQwIDUxMC4wMDA2NDh2LTUxMC4wMDA2NDhoLTE0NDB2Mzg0LjAwMDY0OGM0MTcuMzExOTM5IDEzMS4xNDIxNzkgODkxIDE3MS41MTMgMTQ0MCAxMjZ6IiBmaWxsPSIjMzMzIi8+PC9zdmc+); - color: #e0e0e0; - } -} - -.logo { - display: inline-block; - height: 9.8vw; - min-height: 130px; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; - filter: drop-shadow(0 20px 13px rgb(0 0 0 / 0.03)) drop-shadow(0 8px 5px rgb(0 0 0 / 0.08)); -} -.logo.inertia:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} -.logo.rails:hover { - filter: drop-shadow(0 0 2em rgb(211 0 1 / 0.6)); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - .logo.react { - animation: logo-spin infinite 20s linear; - } -} - -@media (prefers-color-scheme: dark) { - .logo { - filter: drop-shadow(0 20px 13px rgb(255 255 255 / 0.03)) drop-shadow(0 8px 5px rgb(255 255 255 / 0.08)); - } -} - -.card { - padding: 2em; - font-size: 0.7em; - color: #948e90; -} - -.footer { - bottom: 0; - left: 0; - margin: 0 2rem 2rem 2rem; - position: absolute; - right: 0; -} - -.footer ul { - list-style: none; -} - -.footer ul li { - display: inline; -} - -.footer ul ul li:after { - content: " | "; - font-weight: 300; - color: #948e90; -} - -.footer ul ul li:last-child:after { - content: ""; -} diff --git a/app/javascript/pages/inertia_example/index.tsx b/app/javascript/pages/inertia_example/index.tsx deleted file mode 100644 index 4518ab79e..000000000 --- a/app/javascript/pages/inertia_example/index.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { Head } from '@inertiajs/react' -import { version as react_version } from 'react' - -import railsSvg from '/assets/rails.svg' -import inertiaSvg from '/assets/inertia.svg' -import reactSvg from '/assets/react.svg' - -import cs from './index.module.css' - -export default function InertiaExample( - { rails_version, ruby_version, rack_version, inertia_rails_version }: - { rails_version: string, ruby_version: string, rack_version: string, inertia_rails_version: string } -) { - return ( -
- - - - -
-
-

- Edit app/javascript/pages/inertia_example/index.tsx and save to test HMR. -

-
- -
    -
  • -
      -
    • Rails version: {rails_version}
    • -
    • Rack version: {rack_version}
    • -
    -
  • -
  • Ruby version: {ruby_version}
  • -
  • -
      -
    • Inertia Rails version: {inertia_rails_version}
    • -
    • React version: {react_version}
    • -
    -
  • -
-
-
- ) -} diff --git a/bin/importmap b/bin/importmap deleted file mode 100755 index 36502ab16..000000000 --- a/bin/importmap +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env ruby - -require_relative "../config/application" -require "importmap/commands" diff --git a/config/importmap.rb b/config/importmap.rb deleted file mode 100644 index 909dfc542..000000000 --- a/config/importmap.rb +++ /dev/null @@ -1,7 +0,0 @@ -# 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/vendor/javascript/.keep b/vendor/javascript/.keep deleted file mode 100644 index e69de29bb..000000000 From 158d282f30ae7d42995b7646733c305f5309e37c Mon Sep 17 00:00:00 2001 From: Wilbert Ribeiro Date: Thu, 10 Sep 2026 22:06:32 -0300 Subject: [PATCH 74/82] Implement client-side form validation and improve UI consistency - Added `useLiveValidation` hook for real-time form validation in various forms. - Introduced validation rules for user registration and import forms. - Enhanced error handling and user feedback for form fields. - Refactored components to improve readability and maintainability. - Updated application layout for better structure and styling. - Removed unused Redis service from Docker configuration. - Improved CI configuration for better test coverage and performance. - Added system tests for client-side validation to ensure functionality. --- .github/workflows/ci.yml | 8 +- .gitignore | 5 + Dockerfile | 3 - Gemfile | 12 +- Gemfile.lock | 36 +--- Procfile.dev | 2 +- README.md | 195 +++++++++++++++++++ app/javascript/components/UserForm.tsx | 60 ++++-- app/javascript/entrypoints/inertia.tsx | 12 +- app/javascript/hooks/useLiveValidation.ts | 66 +++++++ app/javascript/layouts/AppLayout.tsx | 60 ++++-- app/javascript/lib/validation.ts | 69 +++++++ app/javascript/pages/Admin/Imports/Index.tsx | 53 +++-- app/javascript/pages/Admin/Imports/New.tsx | 31 ++- app/javascript/pages/Admin/Imports/Show.tsx | 8 +- app/javascript/pages/Admin/Users/Index.tsx | 76 ++++++-- app/javascript/pages/Auth/Register.tsx | 35 +++- app/javascript/pages/Profile/Edit.tsx | 6 +- app/javascript/types/index.ts | 3 +- app/views/layouts/application.html.erb | 5 +- app/views/sessions/new.html.erb | 4 +- config/ci.rb | 8 +- config/database.yml | 3 +- config/routes.rb | 1 - docker-compose.yml | 35 +--- spec/support/capybara.rb | 3 +- spec/system/client_validation_spec.rb | 81 ++++++++ 27 files changed, 710 insertions(+), 170 deletions(-) create mode 100644 README.md create mode 100644 app/javascript/hooks/useLiveValidation.ts create mode 100644 app/javascript/lib/validation.ts create mode 100644 spec/system/client_validation_spec.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 423347a24..3eca0e03f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,16 +37,18 @@ jobs: - run: npm ci - run: npx playwright install --with-deps chromium - - run: bin/rails db:create db:schema:load + - run: bin/rails parallel:create parallel:load_schema + - run: bin/vite build - run: bundle exec rubocop - run: bundle exec brakeman --quiet --no-pager --exit-on-warn - run: bundle exec bundler-audit check --update - run: npm run typecheck - - run: bundle exec rspec + - run: npm run format + - run: bundle exec parallel_rspec - uses: actions/upload-artifact@v4 if: always() with: name: coverage - path: coverage/ \ No newline at end of file + path: coverage/ diff --git a/.gitignore b/.gitignore index 1691b1141..2efddeb3f 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ # Ignore key files for decrypting credentials and more. /config/*.key +/config/credentials/*.key /app/assets/builds/* @@ -47,5 +48,9 @@ node_modules # Ignore SimpleCov coverage reports. /coverage/ +# Ignore Playwright traces and reports from browser specs. +/test-results/ +/playwright-report/ + # SSH key for the local Kamal test server (.kamal/local). /.kamal/local/ssh/ diff --git a/Dockerfile b/Dockerfile index b288c5150..cfca639c7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -54,9 +54,6 @@ COPY . . RUN bundle exec bootsnap precompile -j 1 app/ lib/ -# Build-time placeholders only: production.rb fetches APP_ORIGIN at boot, and -# assets:precompile boots the app (tailwindcss:build). npm ci already ran above, -# so vite_ruby must not reinstall. RUN SECRET_KEY_BASE_DUMMY=1 \ APP_ORIGIN=http://localhost \ VITE_RUBY_SKIP_ASSETS_PRECOMPILE_INSTALL=true \ diff --git a/Gemfile b/Gemfile index 73f0fc5ca..27b81651a 100644 --- a/Gemfile +++ b/Gemfile @@ -11,14 +11,6 @@ gem "propshaft" gem "pg", "~> 1.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" gem "inertia_rails" @@ -59,7 +51,7 @@ gem "active_storage_validations" gem "faker" group :development, :test do - + # Audits gems for known security defects (use config/bundler-audit.yml to ignore issues) gem "bundler-audit", require: false @@ -70,6 +62,8 @@ group :development, :test do gem "rubocop-rails-omakase", require: false gem "rspec-rails", "~> 8.0" + # Runs the suite across CPU cores: `bundle exec parallel_rspec` [https://github.com/grosser/parallel_tests] + gem "parallel_tests" gem "factory_bot_rails" gem 'dotenv' gem "rubocop-rspec", require: false diff --git a/Gemfile.lock b/Gemfile.lock index c3492fc7a..5e459da77 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -182,10 +182,6 @@ GEM 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) inertia_rails (3.22.0) railties (>= 6) io-console (0.9.2) @@ -265,6 +261,8 @@ GEM ostruct (0.6.3) pagy (9.4.0) parallel (2.2.0) + parallel_tests (5.7.0) + parallel parser (3.3.12.0) ast (~> 2.4.1) racc @@ -440,17 +438,6 @@ GEM 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-arm64-darwin) - 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) @@ -458,9 +445,6 @@ GEM 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) @@ -516,11 +500,11 @@ DEPENDENCIES factory_bot_rails faker image_processing (~> 1.2) - importmap-rails inertia_rails json (~> 2.9) kamal pagy (~> 9.0) + parallel_tests pg (~> 1.1) propshaft puma (>= 5.0) @@ -535,10 +519,7 @@ DEPENDENCIES solid_cable solid_cache solid_queue - stimulus-rails - tailwindcss-rails thruster - turbo-rails tzinfo-data vite_rails (~> 3.11) web-console @@ -605,7 +586,6 @@ CHECKSUMS 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 inertia_rails (3.22.0) sha256=39c20120de472015d2831fa461f8a09672c68e91c41d3d660e0b1d16b787b7b1 io-console (0.9.2) sha256=efa74f891dd03c0939a931dfc6e74c2813d904763d456ea9762b0525e748db08 irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 @@ -644,6 +624,7 @@ CHECKSUMS ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 pagy (9.4.0) sha256=db3f2e043f684155f18f78be62a81e8d033e39b9f97b1e1a8d12ad38d7bce738 parallel (2.2.0) sha256=e1059c5fd7b649558a0aec38a769f06a42942bdb40503d005a59c352fe011cd8 + parallel_tests (5.7.0) sha256=3f1762c46ca2c223b8af8ef877217f9d76974e191bfa934f2580b58bcf1d005c parser (3.3.12.0) sha256=21a6d7f755d5a24dfbdc6e6b772e4e879a52e7631a88bc5a3a134606052c9828 pg (1.6.3) sha256=1388d0563e13d2758c1089e35e973a3249e955c659592d10e5b77c468f628a99 pg (1.6.3-aarch64-linux) sha256=0698ad563e02383c27510b76bf7d4cd2de19cd1d16a5013f375dd473e4be72ea @@ -700,14 +681,6 @@ CHECKSUMS solid_cache (1.0.10) sha256=bc05a2fb3ac78a6f43cbb5946679cf9db67dd30d22939ededc385cb93e120d41 solid_queue (1.7.0) sha256=6566b70b801d1c317c81bba7bcdd5677c019afac584a30374b4164002ca356d3 sshkit (1.25.1) sha256=be3f10b9d6eb0b44d5eaba3f7cbe41bc6bb894bce4339688ac20124391455b78 - stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06 - tailwindcss-rails (4.6.0) sha256=d99512867173d55c5ef8890427682299d8539f550cec1408b3d8667a538bd365 - tailwindcss-ruby (4.3.3) sha256=ee0a64030749862deb501acab4c4aaf5adbee13865746a33299d46d7b5d0952a - tailwindcss-ruby (4.3.3-aarch64-linux-gnu) sha256=c86d6dd3eccc85fe0d792a832b06f2bf3c0a7a83b399308aeb9d8f5725f42a6a - tailwindcss-ruby (4.3.3-aarch64-linux-musl) sha256=72b77ca9edea82383dd09510ab520a122e3cb9f9864ca5b38e27698098d2b899 - tailwindcss-ruby (4.3.3-arm64-darwin) sha256=776c51fc734aac64bde0c253eadd2ed2e610b2ba0d8e760501fe7cbec55fd6cf - 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 @@ -715,7 +688,6 @@ CHECKSUMS 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 diff --git a/Procfile.dev b/Procfile.dev index e6ad037be..b885d75fa 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -1,3 +1,3 @@ web: bin/rails server -css: bin/rails tailwindcss:watch vite: bin/vite dev +jobs: bin/jobs diff --git a/README.md b/README.md new file mode 100644 index 000000000..7a8320324 --- /dev/null +++ b/README.md @@ -0,0 +1,195 @@ +# Umanni Users + +### AI Usage Disclosure + +This project was built with the help of AI assistants: + +- **GitHub Copilot**: inline code completion. +- **Claude Opus 5** (Anthropic): planning, understanding the requirements and choosing the stack (Inertia.js + React instead of Hotwire), learning Inertia.js/Vite, bug fixing, Pull Request reviews and descriptions, writing documentation, and a requirements-compliance pass (client-side form validation, parallel test setup, configuration cleanup). + +Details: [AI_DISCLOSURE.md](AI_DISCLOSURE.md). + +--- + +A responsive user-management app built on Rails 8 and Ruby 4. Admins manage users, toggle roles, import spreadsheets in the background, and watch live counters and import progress. Members manage their own profile, and visitors can sign up. + +The original challenge statement is kept in [CHALLENGE.md](CHALLENGE.md). + +## Contents + +- [Stack](#stack) +- [Features](#features) +- [Quick start with Docker Compose](#quick-start-with-docker-compose) +- [Running on your machine without Docker](#running-on-your-machine-without-docker) +- [Seeding](#seeding) +- [Tests](#tests) +- [Architecture notes](#architecture-notes) +- [Configuration and credentials](#configuration-and-credentials) +- [Deployment](#deployment) +- [More documentation](#more-documentation) + +## Stack + +| Layer | Choice | +|---|---| +| Language / framework | Ruby 4.0.6, Rails 8.1 | +| Database | PostgreSQL 17 (primary, plus separate queue, cache and cable databases) | +| Frontend | **Option B:** React 19 through Inertia.js, TypeScript, bundled by **Vite Rails** | +| Styling | Tailwind CSS v4 (`@tailwindcss/vite`, forms and typography plugins) | +| Real-time | Action Cable on **Solid Cable** | +| Background jobs | Active Job on **Solid Queue** | +| Authentication | Rails 8 built-in authentication generator, extended with roles and policies (no Devise) | +| File uploads | Active Storage (local disk in development, S3 in production) | +| Serving | Puma behind **Thruster** (compression, asset caching) | +| Deploy | Multi-stage Dockerfile, **Kamal 2** configuration | +| Tests | RSpec, Capybara + Playwright, SimpleCov, run in parallel with `parallel_tests` | + +No Redis is needed: cache, queue, and cable all run on PostgreSQL. + +## Features + +**Visitor** +- Registers as a regular member (the role is always forced to `member` on the server). + +**Member** +- Lands on their profile after signing in. +- Can view, edit, and delete only their own profile. Other users' records return not-found. + +**Admin** +- Lands on the admin dashboard after signing in. +- Dashboard shows total users and users per role, updated live over Solid Cable when users are created, deleted, or change role. +- Lists users with search, role filter, sorting, and pagination. Creates, edits, and deletes users, and toggles roles. The last admin can't be deleted or demoted, and admins can't toggle their own role. +- Imports `.csv` / `.xlsx` spreadsheets (up to 10 MB). The import runs as a Solid Queue job, and the import page shows live progress, created/skipped/failed counts, and the rejected rows with their errors. + +**User fields:** `full_name`, `email_address` (encrypted), `avatar_image` (Active Storage upload) or `avatar_url` (remote https link), `role` (`admin` / `member`). + +## Quick start with Docker Compose + +Requirements: Docker with Compose v2, and the Rails master key. + +1. **Create `.env`** in the project root: + + ```dotenv + RAILS_MASTER_KEY= + SECRET_KEY_BASE= + RAILS_ENV=development + RACK_ENV=development + ``` + +2. **Build and start:** + + ```bash + docker compose build + docker compose up + ``` + + On boot the `web` container creates and migrates all databases (`db:prepare`), then starts Thruster on port 80, published as **http://localhost:3000**. The Solid Queue worker runs inside Puma (`SOLID_QUEUE_IN_PUMA=true`), so imports are processed without any extra step. + +3. **Seed** (in another terminal): + + ```bash + docker compose exec web ./bin/rails db:seed + ``` + +4. Open http://localhost:3000 and sign in as `admin@umanni.test` / `password123`. + +The code is copied into the image, not mounted: after changing code, run `docker compose up -d --build web`. The full guide, including troubleshooting, is in [DEVELOPMENT_SETUP.md](DEVELOPMENT_SETUP.md). + +## Running on your machine without Docker + +Requirements: Ruby 4.0.6, Node 22+, PostgreSQL 17, and `config/master.key`. + +```bash +# config/database.yml defaults to user "umanni" with no password on localhost +export POSTGRES_USER= POSTGRES_PASSWORD= +export APP_ORIGIN=http://localhost:3000 + +bin/setup # bundle install, npm install, db:prepare, then starts bin/dev +``` + +`bin/dev` runs [Procfile.dev](Procfile.dev): the Rails server, the Vite dev server (hot reload), and a Solid Queue worker (`bin/jobs`). Seed with `bin/rails db:seed`. + +## Seeding + +```bash +bin/rails db:seed # or: docker compose exec web ./bin/rails db:seed +SEED_ADMIN_PASSWORD=something-secret bin/rails db:seed +``` + +[db/seeds.rb](db/seeds.rb) creates: +- the admin `admin@umanni.test` with password `password123` (or `SEED_ADMIN_PASSWORD`), created only once; +- 25 members with Faker names, pravatar avatars, and password `password123`. Every run adds 25 more. + +To try the importer, upload a CSV such as [spec/fixtures/files/users.csv](spec/fixtures/files/users.csv) from **Users → Import users**. The accepted columns are listed on that page. + +## Tests + +The suite uses RSpec: model, policy, query, job, channel, request, and serializer specs, plus Capybara system specs driven by Playwright. It runs in parallel across CPU cores with [`parallel_tests`](https://github.com/grosser/parallel_tests). SimpleCov merges the workers' results and **fails the run below 90% line / 80% branch coverage**. The last local run measured 99.45% line and 98.38% branch. + +```bash +# once: one test database per worker, and the browser used by system specs +RAILS_ENV=test bin/rails parallel:create parallel:load_schema +npx playwright install chromium + +# each run +RAILS_ENV=test bin/vite build # build the test bundle once, so workers don't race to build it +bundle exec parallel_rspec # whole suite, in parallel + +bundle exec rspec spec/requests # a single process, for a subset +bundle exec parallel_rspec -o "--tag '~js'" # skip the browser specs +``` + +Set `POSTGRES_USER` / `POSTGRES_PASSWORD` if your local PostgreSQL user isn't `umanni`. + +**Cross-browser:** system specs run in Chromium by default. To run the same specs in Firefox or WebKit (Safari's engine), install the engine and set `PLAYWRIGHT_BROWSER`: + +```bash +npx playwright install firefox webkit +PLAYWRIGHT_BROWSER=webkit bundle exec parallel_rspec spec/system +``` + +**All CI checks locally:** `bin/ci` ([config/ci.rb](config/ci.rb)) runs RuboCop, Prettier, bundler-audit, Brakeman, the TypeScript check, and the parallel suite. GitHub Actions runs the same checks ([.github/workflows/ci.yml](.github/workflows/ci.yml)). + +## Architecture notes + +**Authentication and authorization.** The Rails 8 authentication generator provides sessions, password reset, and the `Authentication` concern ([app/controllers/concerns/authentication.rb](app/controllers/concerns/authentication.rb)). On top of it: +- `default_landing_url` sends admins to the dashboard and members to their profile. +- Policy objects ([app/policies/user_policy.rb](app/policies/user_policy.rb)) decide actions, record scopes, and permitted attributes. Only an admin editing *someone else* may set `role`. +- Sign-in and registration are rate limited. + +**Real-time.** Model callbacks call `Dashboard::Broadcaster`, which throttles bursts and broadcasts on `DashboardChannel`. `ProcessImportJob` broadcasts progress on `ImportChannel`. Both channels only accept admins. The React pages listen with `@rails/actioncable` and re-request only the changed props through an Inertia partial reload. + +**Background imports.** `ProcessImportJob` runs on the `imports` queue. It uses `ActiveJob::Continuable` steps (count rows, import rows in batches of 100, finalize), so an interrupted import resumes from its last batch. Rows are parsed by `Imports::CsvRowSet` / `Imports::SpreadsheetRowSet` and validated by `Imports::UserRow`. Formula-like cell prefixes are stripped, and existing emails are skipped. + +**Validation, on both sides.** +- *Backend (authority):* strong parameters via `params.expect` with policy-defined attributes, model validations (presence, length, email format, uniqueness, https-only avatar URL, avatar content type and size, import file type and size), `has_secure_password` limits, and database constraints (unique email index, `NOT NULL` columns). +- *Frontend (interactive feedback):* [app/javascript/lib/validation.ts](app/javascript/lib/validation.ts) mirrors those rules with the same messages. [useLiveValidation](app/javascript/hooks/useLiveValidation.ts) checks a field when it loses focus, re-checks it as the user types, blocks submitting an invalid form, and focuses the first problem. Server errors appear in the same place. + +**Cross-browser support.** +- `allow_browser` in [ApplicationController](app/controllers/application_controller.rb) sets an explicit floor (Safari 16.4, Chrome 111, Firefox 128), chosen from what Tailwind v4's CSS needs (`@property`, `color-mix()`, `oklch()`) rather than Rails' stricter `:modern` preset, so iOS 16.4–17.1 still gets in. Request specs pin that floor with real user agents. +- Forms use `noValidate` with custom inline messages, so every engine shows the same feedback instead of its own bubbles. +- Inputs use 16px text on phones to prevent iOS zoom, and safe-area padding keeps content clear of the notch. +- System specs can run in Chromium, Firefox, or WebKit. + +**Security.** +- `email_address` is encrypted with Active Record Encryption (deterministic, so it can be looked up). +- React escapes output. CSRF protection is on, and Inertia sends the token. +- Brakeman and bundler-audit run in CI. + +## Configuration and credentials + +- `config/credentials.yml.enc` holds `secret_key_base` and the Active Record Encryption keys. `config/master.key` is gitignored and never copied into the image. Provide it as `RAILS_MASTER_KEY` in Docker and Kamal. +- Environment variables cover deploy-specific settings: `DB_HOST`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, `APP_ORIGIN` (allowed Action Cable origin), `SOLID_QUEUE_IN_PUMA`, and `ACTIVE_STORAGE_SERVICE` / `AWS_*` for S3 in production. In production, Kamal injects secrets from [.kamal/secrets](.kamal/secrets), which only references environment variables. +- The test environment uses fixed, clearly-labelled encryption keys, so CI and fresh clones don't need the master key. +- [.gitignore](.gitignore) and [.dockerignore](.dockerignore) keep keys, `.env*`, logs, uploads, build output, coverage, and test artifacts out of git and out of the image. + +## Deployment + +The [Dockerfile](Dockerfile) is multi-stage: gems and npm packages build in one stage, the final image carries no Node and no build tools, runs as a non-root user, and serves through Thruster on port 80. [config/deploy.yml](config/deploy.yml) defines a Kamal 2 deployment with `web` and `job` roles, kamal-proxy with SSL, and a PostgreSQL accessory. It hasn't been run against a real server yet: see [KAMAL_DISCLOUSURE.md](KAMAL_DISCLOUSURE.md) for status and the remaining steps. + +## More documentation + +- [DEVELOPMENT_SETUP.md](DEVELOPMENT_SETUP.md): Docker Compose development guide +- [AI_DISCLOSURE.md](AI_DISCLOSURE.md): how AI assistants were used +- [KAMAL_DISCLOUSURE.md](KAMAL_DISCLOUSURE.md): deployment status +- [CHALLENGE.md](CHALLENGE.md): original challenge statement diff --git a/app/javascript/components/UserForm.tsx b/app/javascript/components/UserForm.tsx index e070014ab..7095f67c0 100644 --- a/app/javascript/components/UserForm.tsx +++ b/app/javascript/components/UserForm.tsx @@ -1,6 +1,15 @@ import { useForm } from '@inertiajs/react' import { FormEvent } from 'react' import Field from '@/components/Field' +import { useLiveValidation } from '@/hooks/useLiveValidation' +import { + avatarImage, + avatarUrl, + emailAddress, + fullName, + password, + passwordConfirmation, +} from '@/lib/validation' import type { User, UserRole } from '@/types' type Props = { @@ -22,10 +31,19 @@ export default function UserForm({ user, roles, action, method, submitLabel }: P role: user?.role ?? ('member' as UserRole), }) const { data, setData, errors, processing, progress } = form + const validation = useLiveValidation(form, { + full_name: fullName, + email_address: emailAddress, + // Editing keeps the current password when the field is left blank. + password: password({ required: !user }), + password_confirmation: passwordConfirmation, + avatar_url: avatarUrl, + avatar_image: avatarImage, + }) - const submit = (event: FormEvent) => { + const submit = (event: FormEvent) => { event.preventDefault() - + if (!validation.validate(event.currentTarget)) return form.transform(({ avatar_image, ...fields }) => ({ user: avatar_image ? { ...fields, avatar_image } : fields, @@ -39,7 +57,8 @@ export default function UserForm({ user, roles, action, method, submitLabel }: P setData('full_name', e.target.value)} + onChange={(e) => validation.update('full_name', e.target.value)} + onBlur={() => validation.touch('full_name')} required minLength={2} maxLength={120} @@ -51,17 +70,24 @@ export default function UserForm({ user, roles, action, method, submitLabel }: P setData('email_address', e.target.value)} + onChange={(e) => validation.update('email_address', e.target.value)} + onBlur={() => validation.touch('email_address')} required className="input" /> - + setData('password', e.target.value)} + onChange={(e) => validation.update('password', e.target.value)} + onBlur={() => validation.touch('password')} + required={!user} autoComplete="new-password" className="input" /> @@ -71,26 +97,32 @@ export default function UserForm({ user, roles, action, method, submitLabel }: P setData('password_confirmation', e.target.value)} + onChange={(e) => validation.update('password_confirmation', e.target.value)} + onBlur={() => validation.touch('password_confirmation')} autoComplete="new-password" className="input" /> - + setData('avatar_image', e.target.files?.[0] ?? null)} + onChange={(e) => validation.update('avatar_image', e.target.files?.[0] ?? null, true)} className="text-sm" /> - + setData('avatar_url', e.target.value)} + onChange={(e) => validation.update('avatar_url', e.target.value)} + onBlur={() => validation.touch('avatar_url')} className="input" /> @@ -102,7 +134,11 @@ export default function UserForm({ user, roles, action, method, submitLabel }: P onChange={(e) => setData('role', e.target.value as UserRole)} className="input" > - {roles.map((role) => )} + {roles.map((role) => ( + + ))} )} diff --git a/app/javascript/entrypoints/inertia.tsx b/app/javascript/entrypoints/inertia.tsx index 3da2cca0e..36cf1cdb3 100644 --- a/app/javascript/entrypoints/inertia.tsx +++ b/app/javascript/entrypoints/inertia.tsx @@ -1,7 +1,7 @@ import { createInertiaApp } from '@inertiajs/react' void createInertiaApp({ - pages: "../pages", + pages: '../pages', strictMode: true, @@ -11,20 +11,20 @@ void createInertiaApp({ withAllErrors: true, }, visitOptions: () => { - return { queryStringArrayFormat: "brackets" } + return { queryStringArrayFormat: 'brackets' } }, }, }).catch((error) => { // This ensures this entrypoint is only loaded on Inertia pages // by checking for the presence of the root element (#app by default). // Feel free to remove this `catch` if you don't need it. - if (document.getElementById("app")) { + if (document.getElementById('app')) { throw error } else { console.error( - "Missing root element.\n\n" + - "If you see this error, it probably means you loaded Inertia.js on non-Inertia pages.\n" + - 'Consider moving <%= vite_typescript_tag "inertia.tsx" %> to the Inertia-specific layout instead.', + 'Missing root element.\n\n' + + 'If you see this error, it probably means you loaded Inertia.js on non-Inertia pages.\n' + + 'Consider moving <%= vite_typescript_tag "inertia.tsx" %> to the Inertia-specific layout instead.', ) } }) diff --git a/app/javascript/hooks/useLiveValidation.ts b/app/javascript/hooks/useLiveValidation.ts new file mode 100644 index 000000000..d4872b2a9 --- /dev/null +++ b/app/javascript/hooks/useLiveValidation.ts @@ -0,0 +1,66 @@ +import { useRef } from 'react' +import type { Rules, Validator } from '@/lib/validation' + +/** The part of Inertia's `useForm` this hook works through. */ +type Form = { + data: D + errors: Partial> + setData(field: K, value: D[K]): void + setError(field: keyof D, messages: string[]): void + clearErrors(...fields: (keyof D)[]): void +} + +/** + * Runs `rules` against an Inertia form while the user works through it: a field is checked + * when it loses focus, then on every change after that, and every rule runs on submit. + * Failures land in `form.errors`, where server errors land too, so shows both alike. + */ +export function useLiveValidation(form: Form, rules: NoInfer>) { + const touched = useRef(new Set()) + const fields = Object.keys(rules) as (keyof D)[] + + const check = (data: D, only: Iterable) => { + let valid = true + + for (const field of only) { + const message = (rules[field] as Validator | undefined)?.(data[field], data) ?? null + + if (message) { + valid = false + form.setError(field, [message]) + } else if (form.errors[field]) { + form.clearErrors(field) + } + } + + return valid + } + + /** Pass `commit` for controls finished in one step, such as file pickers. */ + const update = (field: K, value: D[K], commit = false) => { + form.setData(field, value) + // A server error on the field counts as touched: editing it should re-check, not leave it standing. + if (commit || form.errors[field]) touched.current.add(field) + check({ ...form.data, [field]: value } as D, touched.current) + } + + const touch = (field: keyof D) => { + touched.current.add(field) + check(form.data, [field]) + } + + /** Checks every rule; when one fails, moves focus to the first invalid control in `scope`. */ + const validate = (scope?: ParentNode | null) => { + fields.forEach((field) => touched.current.add(field)) + const valid = check(form.data, fields) + + if (!valid && scope) { + // Deferred until React has committed the aria-invalid attributes derives from errors. + setTimeout(() => scope.querySelector('[aria-invalid="true"]')?.focus()) + } + + return valid + } + + return { update, touch, validate } +} diff --git a/app/javascript/layouts/AppLayout.tsx b/app/javascript/layouts/AppLayout.tsx index 8418540a2..dbb1438fb 100644 --- a/app/javascript/layouts/AppLayout.tsx +++ b/app/javascript/layouts/AppLayout.tsx @@ -9,9 +9,15 @@ type NavLink = { } function NavItem({ link, className }: { link: NavLink; className: string }) { - return link.fullReload - ? {link.label} - : {link.label} + return link.fullReload ? ( + + {link.label} + + ) : ( + + {link.label} + + ) } export default function AppLayout({ children }: PropsWithChildren) { @@ -28,7 +34,10 @@ export default function AppLayout({ children }: PropsWithChildren) { const links: NavLink[] = auth.user ? [ ...(auth.user.admin - ? [{ href: '/admin', label: 'Dashboard' }, { href: '/admin/users', label: 'Users' }] + ? [ + { href: '/admin', label: 'Dashboard' }, + { href: '/admin/users', label: 'Users' }, + ] : []), { href: '/profile', label: auth.user.full_name }, ] @@ -40,15 +49,25 @@ export default function AppLayout({ children }: PropsWithChildren) { return (
-