From 27619f5d9ec938e28bbd6dfc880839dab0860c63 Mon Sep 17 00:00:00 2001 From: Bruno Schumacher Date: Thu, 10 Sep 2026 02:54:01 -0300 Subject: [PATCH 01/25] chore(docker): add development Docker environment --- .dockerignore | 9 +++++++++ Dockerfile.dev | 13 +++++++++++++ docker-compose.yml | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile.dev create mode 100644 docker-compose.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..87a0198cc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.gitignore +log/* +tmp/* +node_modules +storage/* +public/assets +public/vite-dev +public/vite \ No newline at end of file diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 000000000..8079ff889 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,13 @@ +FROM ruby:4.0-slim + +RUN apt-get update -qq && \ + apt-get install -y build-essential libpq-dev curl git nodejs npm postgresql-client && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /rails + +RUN gem install bundler + +EXPOSE 3000 5173 + +CMD ["bin/dev"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..af044e734 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,37 @@ +version: '3.8' + +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_DB: user_management_development + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + + app: + build: + context: . + dockerfile: Dockerfile.dev + command: bash -c "rm -f tmp/pids/server.pid && bin/dev" + volumes: + - .:/rails + - bundle_data:/usr/local/bundle + - node_modules_data:/rails/node_modules + ports: + - "3000:3000" + - "5173:5173" + environment: + DATABASE_URL: postgres://postgres:password@db:5432/user_management_development + RAILS_ENV: development + VITE_RUBY_HOST: 0.0.0.0 + depends_on: + - db + +volumes: + postgres_data: + bundle_data: + node_modules_data: \ No newline at end of file From 5b6f9b66a7961d23d95fce59d38c8ebd62223300 Mon Sep 17 00:00:00 2001 From: Bruno Schumacher Date: Thu, 10 Sep 2026 03:05:13 -0300 Subject: [PATCH 02/25] chore(rails): initialize Rails 8 application with PostgreSQL --- .dockerignore | 56 +- .gitattributes | 9 + .github/dependabot.yml | 12 + .github/workflows/ci.yml | 152 +++++ .gitignore | 35 ++ .rubocop.yml | 8 + .ruby-version | 1 + Dockerfile | 77 +++ Gemfile | 63 +++ Gemfile.lock | 535 ++++++++++++++++++ README.md | 89 +-- Rakefile | 6 + app/assets/images/.keep | 0 app/assets/stylesheets/application.css | 10 + 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 | 29 + 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 | 2 + bin/docker-entrypoint | 8 + bin/importmap | 4 + bin/jobs | 6 + bin/rails | 4 + bin/rake | 4 + bin/rubocop | 8 + bin/setup | 35 ++ bin/thrust | 5 + config.ru | 6 + config/application.rb | 27 + config/boot.rb | 4 + config/bundler-audit.yml | 5 + config/cable.yml | 17 + config/cache.yml | 16 + config/ci.rb | 24 + config/credentials.yml.enc | 1 + config/database.yml | 104 ++++ 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 test/controllers/.keep | 0 test/fixtures/files/.keep | 0 test/helpers/.keep | 0 test/integration/.keep | 0 test/mailers/.keep | 0 test/models/.keep | 0 test/test_helper.rb | 15 + tmp/.keep | 0 tmp/pids/.keep | 0 tmp/storage/.keep | 0 vendor/.keep | 0 vendor/javascript/.keep | 0 94 files changed, 2730 insertions(+), 85 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .rubocop.yml create mode 100644 .ruby-version create mode 100644 Dockerfile create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 Rakefile create mode 100644 app/assets/images/.keep create mode 100644 app/assets/stylesheets/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 100644 bin/brakeman create mode 100644 bin/bundler-audit create mode 100644 bin/ci create mode 100644 bin/dev create mode 100644 bin/docker-entrypoint create mode 100644 bin/importmap create mode 100644 bin/jobs create mode 100644 bin/rails create mode 100644 bin/rake create mode 100644 bin/rubocop create mode 100644 bin/setup create mode 100644 bin/thrust create mode 100644 config.ru create mode 100644 config/application.rb create mode 100644 config/boot.rb create mode 100644 config/bundler-audit.yml create mode 100644 config/cable.yml create mode 100644 config/cache.yml create mode 100644 config/ci.rb create mode 100644 config/credentials.yml.enc create mode 100644 config/database.yml create mode 100644 config/environment.rb create mode 100644 config/environments/development.rb create mode 100644 config/environments/production.rb create mode 100644 config/environments/test.rb create mode 100644 config/importmap.rb create mode 100644 config/initializers/assets.rb create mode 100644 config/initializers/content_security_policy.rb create mode 100644 config/initializers/filter_parameter_logging.rb create mode 100644 config/initializers/inflections.rb create mode 100644 config/locales/en.yml create mode 100644 config/puma.rb create mode 100644 config/queue.yml create mode 100644 config/recurring.yml create mode 100644 config/routes.rb create mode 100644 config/storage.yml create mode 100644 db/cable_schema.rb create mode 100644 db/cache_schema.rb create mode 100644 db/queue_schema.rb create mode 100644 db/seeds.rb create mode 100644 lib/tasks/.keep create mode 100644 log/.keep create mode 100644 public/400.html create mode 100644 public/404.html create mode 100644 public/406-unsupported-browser.html create mode 100644 public/422.html create mode 100644 public/500.html create mode 100644 public/icon.png create mode 100644 public/icon.svg create mode 100644 public/robots.txt create mode 100644 script/.keep create mode 100644 storage/.keep create mode 100644 test/controllers/.keep create mode 100644 test/fixtures/files/.keep create mode 100644 test/helpers/.keep create mode 100644 test/integration/.keep create mode 100644 test/mailers/.keep create mode 100644 test/models/.keep create mode 100644 test/test_helper.rb 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 index 87a0198cc..75405937b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,9 +1,47 @@ -.git -.gitignore -log/* -tmp/* -node_modules -storage/* -public/assets -public/vite-dev -public/vite \ No newline at end of file +# 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 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..8cfe0cd92 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,152 @@ +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 + + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3 + + # redis: + # image: valkey/valkey:8 + # ports: + # - 6379:6379 + # options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 + + steps: + - name: Install packages + run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libpq-dev libvips + + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Run tests + env: + RAILS_ENV: test + DATABASE_URL: postgres://postgres:postgres@localhost:5432 + # RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }} + # REDIS_URL: redis://localhost:6379/0 + run: bin/rails db:test:prepare test + + system-test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3 + + # redis: + # image: valkey/valkey:8 + # ports: + # - 6379:6379 + # options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 + + steps: + - name: Install packages + run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libpq-dev libvips + + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Run System Tests + env: + RAILS_ENV: test + DATABASE_URL: postgres://postgres:postgres@localhost:5432 + # RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }} + # REDIS_URL: redis://localhost:6379/0 + run: bin/rails db:test:prepare test:system + + - name: Keep screenshots from failed system tests + uses: actions/upload-artifact@v4 + if: failure() + with: + name: screenshots + path: ${{ github.workspace }}/tmp/screenshots + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..fbcab405e --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# 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 + diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 000000000..f9d86d4a5 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,8 @@ +# Omakase Ruby styling for Rails +inherit_gem: { rubocop-rails-omakase: rubocop.yml } + +# Overwrite or add rules to create your own house style +# +# # Use `[a, [b, c]]` not `[ a, [ b, c ] ]` +# Layout/SpaceInsideArrayLiteralBrackets: +# Enabled: false diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 000000000..1cf76f52b --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +ruby-4.0.6 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..5509f9e04 --- /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 user_management_app . +# docker run -d -p 80:80 -e RAILS_MASTER_KEY= --name user_management_app user_management_app + +# 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..36f943502 --- /dev/null +++ b/Gemfile @@ -0,0 +1,63 @@ +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" +# Build JSON APIs with ease [https://github.com/rails/jbuilder] +gem "jbuilder" + +# 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 + +# 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 + +group :test do + # Use system testing [https://guides.rubyonrails.org/testing.html#system-testing] + gem "capybara" + gem "selenium-webdriver" +end diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 000000000..1d249087a --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,535 @@ +GEM + remote: https://rubygems.org/ + specs: + action_text-trix (2.1.19) + railties + actioncable (8.1.3.1) + actionpack (= 8.1.3.1) + activesupport (= 8.1.3.1) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + zeitwerk (~> 2.6) + actionmailbox (8.1.3.1) + actionpack (= 8.1.3.1) + activejob (= 8.1.3.1) + activerecord (= 8.1.3.1) + activestorage (= 8.1.3.1) + activesupport (= 8.1.3.1) + mail (>= 2.8.0) + actionmailer (8.1.3.1) + actionpack (= 8.1.3.1) + actionview (= 8.1.3.1) + activejob (= 8.1.3.1) + activesupport (= 8.1.3.1) + mail (>= 2.8.0) + rails-dom-testing (~> 2.2) + actionpack (8.1.3.1) + actionview (= 8.1.3.1) + activesupport (= 8.1.3.1) + nokogiri (>= 1.8.5) + rack (>= 2.2.4) + rack-session (>= 1.0.1) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + useragent (~> 0.16) + actiontext (8.1.3.1) + action_text-trix (~> 2.1.15) + actionpack (= 8.1.3.1) + activerecord (= 8.1.3.1) + activestorage (= 8.1.3.1) + activesupport (= 8.1.3.1) + globalid (>= 0.6.0) + nokogiri (>= 1.8.5) + actionview (8.1.3.1) + activesupport (= 8.1.3.1) + builder (~> 3.1) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activejob (8.1.3.1) + activesupport (= 8.1.3.1) + globalid (>= 0.3.6) + activemodel (8.1.3.1) + activesupport (= 8.1.3.1) + activerecord (8.1.3.1) + activemodel (= 8.1.3.1) + activesupport (= 8.1.3.1) + timeout (>= 0.4.0) + activestorage (8.1.3.1) + actionpack (= 8.1.3.1) + activejob (= 8.1.3.1) + activerecord (= 8.1.3.1) + activesupport (= 8.1.3.1) + marcel (~> 1.0) + activesupport (8.1.3.1) + base64 + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + json + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) + ast (2.4.3) + base64 (0.3.0) + 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) + 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) + date (3.5.1) + debug (1.11.1) + irb (~> 1.10) + reline (>= 0.3.8) + drb (2.2.3) + 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-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) + jbuilder (2.15.1) + actionview (>= 7.0.0) + activesupport (>= 7.0.0) + json (3.0.2) + language_server-protocol (3.17.0.6) + lint_roller (1.1.0) + logger (1.7.0) + loofah (2.25.2) + crass (~> 1.0.2) + nokogiri (>= 1.12.0) + mail (2.9.1) + logger + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp + marcel (1.2.1) + matrix (0.4.3) + mini_magick (5.4.0) + logger + mini_mime (1.1.5) + minitest (6.0.6) + drb (~> 2.0) + prism (~> 1.5) + msgpack (1.8.4) + net-imap (0.6.7) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.3.0) + timeout + net-smtp (0.5.1) + net-protocol + 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-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) + 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-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 + public_suffix (7.0.5) + puma (8.0.2) + nio4r (~> 2.0) + raabro (1.5.0) + racc (1.8.1) + rack (3.2.7) + rack-session (2.1.2) + base64 (>= 0.1.0) + rack (>= 3.0.0) + rack-test (2.2.0) + rack (>= 1.3) + rackup (2.3.1) + rack (>= 3) + rails (8.1.3.1) + actioncable (= 8.1.3.1) + actionmailbox (= 8.1.3.1) + actionmailer (= 8.1.3.1) + actionpack (= 8.1.3.1) + actiontext (= 8.1.3.1) + actionview (= 8.1.3.1) + activejob (= 8.1.3.1) + activemodel (= 8.1.3.1) + activerecord (= 8.1.3.1) + activestorage (= 8.1.3.1) + activesupport (= 8.1.3.1) + bundler (>= 1.15.0) + railties (= 8.1.3.1) + rails-dom-testing (2.3.0) + activesupport (>= 5.0.0) + minitest + nokogiri (>= 1.6) + rails-html-sanitizer (1.7.1) + loofah (~> 2.25, >= 2.25.2) + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + railties (8.1.3.1) + actionpack (= 8.1.3.1) + activesupport (= 8.1.3.1) + irb (~> 1.13) + rackup (>= 1.0.0) + rake (>= 12.2) + thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) + zeitwerk (~> 2.6) + rainbow (3.1.1) + rake (13.4.2) + rbs (4.2.0) + logger + prism (>= 1.6.0) + tsort + rdoc (8.0.0) + erb + prism (>= 1.6.0) + rbs (>= 4.0.0) + tsort + regexp_parser (2.12.0) + reline (0.7.0) + io-console (~> 0.5) + rexml (3.4.4) + 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 + rubyzip (3.6.0) + securerandom (0.4.1) + selenium-webdriver (4.49.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) + 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) + stimulus-rails (1.3.4) + railties (>= 6.0.0) + 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-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 (1.2.11) + websocket-driver (0.8.2) + base64 + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + xpath (3.2.0) + nokogiri (~> 1.8) + zeitwerk (2.8.3) + +PLATFORMS + aarch64-linux + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + arm64-darwin + x86_64-darwin + x86_64-linux + x86_64-linux-gnu + x86_64-linux-musl + +DEPENDENCIES + bootsnap + brakeman + bundler-audit + capybara + debug + image_processing (~> 1.2) + importmap-rails + jbuilder + pg (~> 1.1) + propshaft + puma (>= 5.0) + rails (~> 8.1.3, >= 8.1.3.1) + rubocop-rails-omakase + selenium-webdriver + solid_cable + solid_cache + solid_queue + stimulus-rails + thruster + turbo-rails + tzinfo-data + web-console + +CHECKSUMS + action_text-trix (2.1.19) sha256=7012f59421009cf284aa651294896414d653a61a2417c9b8714c8476d2f74009 + actioncable (8.1.3.1) sha256=e318528295c878a3efdfe25f0f2267c80cb7a76eba41bb5f64d44aa380a3d91b + actionmailbox (8.1.3.1) sha256=5f704972097d843ade8e435e93694a1dac732b926df1717aceba1f3840082b1c + actionmailer (8.1.3.1) sha256=88ea441b28ff02a0c6c006468892642a3d9942affce9d294e81a74504aa5c43c + actionpack (8.1.3.1) sha256=974cb7154548e81f470b1b0f247b99cb38e87825899dca58610596e2817723d0 + actiontext (8.1.3.1) sha256=5da729d833d1a29cddb1eee938878e55e503d2613e00e735f5daf58c2ba98af2 + actionview (8.1.3.1) sha256=2da68b8414c47b43bfbed1ce69c5afe1c04f78c267aacb5660a4cab5ca12cfb6 + activejob (8.1.3.1) sha256=1c8dd275df930df40deecffec63d913a550a33fd94bd298f69721dd96939954a + activemodel (8.1.3.1) sha256=99cc02ce2faec371d14440949d85787ebd23a907c9baef0a9d4bcd4d21888f88 + activerecord (8.1.3.1) sha256=0a2fb6c28f4938f6b013a3a549bec0a7e37d535f3dc8990e804bcc3258c0403b + activestorage (8.1.3.1) sha256=f555254f387b1cffa499d2fd3115d12635eadc5b15206a8534316a67036163ef + activesupport (8.1.3.1) sha256=85458765f25ea48b9019c46b6bb3fa5683197bf4280d9f06710a6e8d7a831376 + addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af + ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 + base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + 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 + 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 + drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 + 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-darwin) sha256=aa70390523cf3235096cf64962b709b4cfbd5c082a2cb2ae714eb0fe2ccda496 + 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 + jbuilder (2.15.1) sha256=2430bec28fb0cebacb5875b1009cf9d8bc3c303ccb810c4c8b062a4b51457637 + json (3.0.2) sha256=8e6d7e7b11384c21230430cef90b71f14849a34a1f4452796670f7c981bd19df + language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0 + lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + loofah (2.25.2) sha256=2007f746959ac65552456e04b433e83deb22759ab38c838b4445c70e43425918 + mail (2.9.1) sha256=06574eca475253d6c18145dd70af80d0eb970182d55053497c5f4d797ea160e8 + marcel (1.2.1) sha256=1678e9360e32f9eafa917c80029e2f6d10b2715c66a4b87b6d0da9b9cd1f859f + matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b + mini_magick (5.4.0) sha256=f120af581d9ed4ec52c57f35a67a605d112bb1d8f582d1415b147fda42d11d78 + mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef + minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1 + msgpack (1.8.4) sha256=4411c22d350dd1c20250f7eada3cca2695438c2f769cf0782f0cd065d90a3e7b + net-imap (0.6.7) sha256=b5c9573be975d856de252ee851871724da66aa2d449b2482d5690bd12bc23660 + net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 + net-protocol (0.3.0) sha256=ba310c3d4f1cad46bb1ab20336b06669b1ff8f7c568d9cb9342b32a718547472 + net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736 + 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-darwin) sha256=7fd17057d3e1f00e9954a74b3cd76595d3d4a5ef233b7ed9599047c204f70551 + nokogiri (1.19.4-x86_64-linux-gnu) sha256=379fae440b28915e3f19d752ce2dcf8465ed2b2fbefd2a7ca0dd497bc981a06a + nokogiri (1.19.4-x86_64-linux-musl) sha256=17dfb7c1fa194ae02fbf7c51a7afc8d278045ab3fdacfd86f91d02d7b274470b + 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-darwin) sha256=ee2e04a17c0627225054ffeb43e31a95be9d7e93abda2737ea3ce4a62f2729d6 + pg (1.6.3-x86_64-linux) sha256=5d9e188c8f7a0295d162b7b88a768d8452a899977d44f3274d1946d67920ae8d + pg (1.6.3-x86_64-linux-musl) sha256=9c9c90d98c72f78eb04c0f55e9618fe55d1512128e411035fe229ff427864009 + pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570 + prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 + propshaft (1.3.2) sha256=1d56a3e56a92c21bfc29caf07406b5386b00d4c47ddf357cf989a5a234b1389e + public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 + puma (8.0.2) sha256=c8ed871dfbbe66448ea9ffd46692342d9804d4071522b52b5331b7b6e7b686fb + raabro (1.5.0) sha256=3f998a7bc84f9c84df3ab580634d2e0a5bda4f0841168d56035f529c9877440a + racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f + rack (3.2.7) sha256=93e13e1c24f93556671d85d2d79fa228c3485815c50d7e2f265b5330c6528fb7 + rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8 + rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463 + rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868 + rails (8.1.3.1) sha256=ccd11a36bfc171bf9c66d585d14c0ece91c0c9dde840aae60c0118d6f5c9c52a + rails-dom-testing (2.3.0) sha256=8acc7953a7b911ca44588bf08737bc16719f431a1cc3091a292bca7317925c1d + rails-html-sanitizer (1.7.1) sha256=e797a7c9b01e567307e317c576b49ab4168017e63eea4dba9ce3cb587e2f22c2 + railties (8.1.3.1) sha256=2388a232579a00cefea4487de66c8553c3408c1300abdc6cf1799d86ffb04487 + rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a + rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 + rbs (4.2.0) sha256=51f7b886dcc05bc09e10b901daa6a81829f6adc03101d6ca9ea4aac6103e0674 + rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469 + regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb + reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d + rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 + 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 + rubyzip (3.6.0) sha256=268994d44d62282d1cfd99bf10eae48d7267199158ad7ea3e1fee2da9458b695 + securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 + selenium-webdriver (4.49.0) sha256=b430f091a3cababb356b6f6132e0e4f385b017819355f563557e1537819edc89 + solid_cable (4.0.2) sha256=084636a67679ad00d23088b33c84047e614bcf41ee559db24b414d83cdc42d03 + solid_cache (1.0.10) sha256=bc05a2fb3ac78a6f43cbb5946679cf9db67dd30d22939ededc385cb93e120d41 + solid_queue (1.7.0) sha256=6566b70b801d1c317c81bba7bcdd5677c019afac584a30374b4164002ca356d3 + stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06 + 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-darwin) sha256=c7f7f0cbefd8030ea03bf88f6a988f595a0618a35a1a0d358e729c6898eb5b6a + thruster (0.1.26-x86_64-linux) sha256=3117a6ee430663f845a0457699fe9a05232dcc6c396e2cc83504de5a223c60e8 + timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb + tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f + turbo-rails (2.0.23) sha256=ee0d90733aafff056cf51ff11e803d65e43cae258cc55f6492020ec1f9f9315f + tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b + unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 + unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f + uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 + useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844 + web-console (4.3.0) sha256=e13b71301cdfc2093f155b5aa3a622db80b4672d1f2f713119cc7ec7ac6a6da4 + websocket (1.2.11) sha256=b7e7a74e2410b5e85c25858b26b3322f29161e300935f70a0e0d3c35e0462737 + websocket-driver (0.8.2) sha256=97c556b019bf3410b4961002ac501621e9322d3f8a7bc02161a09301cc4c4146 + websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241 + xpath (3.2.0) sha256=6dfda79d91bb3b949b947ecc5919f042ef2f399b904013eb3ef6d20dd3a4082e + zeitwerk (2.8.3) sha256=2c85125a8467ce069e20123d1e709a08955c9d29c118c25b46b7b7fafdbb92e5 + +BUNDLED WITH + 4.0.16 diff --git a/README.md b/README.md index 7829f14ff..7db80e4ca 100644 --- a/README.md +++ b/README.md @@ -1,87 +1,24 @@ -# Modern Fullstack Developer Test (Rails 8 / Ruby 4) +# README -- Check this readme.md -- Create a branch to develop your task -- Push to remote in 1 week (date will be checked from branch creation/assigned date) +This README would normally document whatever steps are necessary to get the +application up and running. -# Requirements: -- Target Stack: **Ruby 4.0+** and **Rails 8.0+** -- Database: PostgreSQL, MySQL, or SQLite (configured for production-ready WAL mode) -- Write robust unit, integration, and system tests using parallel testing features -- Deliver with a working multi-stage Dockerfile utilizing Thruster/Kamal-ready defaults -- Show senior best practices (e.g., proper design patterns, solid architecture, strict linter configuration) +Things you may want to cover: -# Our AI Policy -At Umanni, we value efficiency and the modern developer workflow. **You are allowed to use AI coding assistants (ChatGPT, Claude, Copilot, etc.) during this test.** However, transparency is part of our culture. If you use any LLM to generate, refactor, or structure your code, **you must explicitly state which model you used** in a dedicated section at the top of your submission's README.md. Failing to disclose AI usage while using it will invalidate your submission. +* Ruby version -# The Test -Here we'll try to simulate a "real sprint" that you'll probably be assigned while working as Fullstack at Umanni. +* System dependencies -# The Task -- Create a modern, responsive application to manage users. -- A user must have: - 1. full_name - 2. email - 3. avatar_image (ActiveStorage file upload or remote URL) - 4. role (admin/no-admin) +* Configuration -# The App -## Admin Use cases -- As an Admin, I must be able to access a User Admin Dashboard. -- As an Admin, I must be able to see on the Dashboard (updated via real-time streams/frontend state): - - Total number of Users - - Total number of Users grouped by Role -- As an Admin, I must be redirected to the User Admin Dashboard after login. -- As an Admin, I must be able to list, create, edit, and delete Users. -- As an Admin, I must be able to toggle the User Role. -- As an Admin, I must be able to import a Spreadsheet (.csv/.xlsx) into the system in order to asynchronously create new Users. -- As an Admin, I must be able to see the live progress/status of the spreadsheet import process. +* Database creation -## User Use Cases -- As a User, I must be redirected to my Profile after login. -- As a User, I must be able only to see my info, edit, and delete my profile. +* Database initialization -## Visitor Use Cases -- As a Visitor, I can register myself as a normal User. +* How to run the test suite - +* Services (job queues, cache servers, search engines, etc.) -# The Start. -- Your deadline is 1 week after accepting this test. +* Deployment instructions -# The Rules (Strict Compliance) -These are mandatory. Failing any of them will invalidate your submission. -- **Documentation**: You must write down a detailed README.md in English explaining how to build, seed, and run your app, including your AI disclosure if applicable. -- **Frontend Stack**: You have two choices for the modern monolithic approach: - - **Option A (Classic Modern):** Hotwire (Turbo 8+ / Stimulus) with smooth, reactive UI states. - - **Option B (Modern SPA Monolith):** **React integrated via Inertia.js** (using Vite or the official Rails 8 asset pipeline integration). -- **Styling**: The Frontend must use a modern CSS framework (Tailwind CSS, Bootstrap, or any utility-first library). Keep it beautiful, responsive, and clean. -- **Real-time & Background Processing**: You must leverage native Rails 8 tools (**Solid Cable** for live dashboard counters/import bars and **Solid Queue** for the background import processing). No Redis installation should be required. -- **Authentication**: You must use the new built-in Rails 8 Authentication system (`bin/rails generate authentication`), customized to fit the role constraints. Avoid legacy heavy gems (like Devise). -- **Git Hygiene**: Clean git history with atomic commits, proper descriptions, and a Pull Request-based workflow. - -# What we're expecting to see: -- Modern asset management using **Propshaft** or **Vite Rails** (if choosing Inertia/React). -- .gitignore, .dockerignore configured correctly. -- Clean application configuration using Rails credentials. -- Comprehensive cross-browser support considerations. -- Strict form validations (Frontend interactive feedback + Backend structural validation). -- Parallel testing with at least 90% coverage (using Minitest, RSpec, and Playwright/Capybara for frontend integration). - -# Extra points -- Delivery via a clean **Kamal 2** deployment configuration (`deploy.yml`). -- Advanced SSR (Server-Side Rendering) setup if using **Inertia.js + React**. -- Use of **Thruster** as a zero-config proxy for asset caching and compression in Docker. -- Advanced performance profiling leveraging Ruby 4's **ZJIT** compilation optimizations. - -# What will be assessed -- Code's Semantics, Cleanness, and Maintainability (Senior-level object-oriented design and clean React/Stimulus component lifecycle). -- Modern Rails 8 idiom usage (e.g., Strict structural params handling, Solid architecture separation). -- Basic Security testing against traditional vectors (SQLi, XSS, XSRF) and proper encryption of sensitive DB columns where applicable. +* ... 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/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/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..12f343a91 --- /dev/null +++ b/app/views/layouts/application.html.erb @@ -0,0 +1,29 @@ + + + + <%= content_for(:title) || "User Management App" %> + + + + + <%= 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..b47e0ae81 --- /dev/null +++ b/app/views/pwa/manifest.json.erb @@ -0,0 +1,22 @@ +{ + "name": "UserManagementApp", + "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": "UserManagementApp.", + "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 100644 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 100644 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 100644 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 100644 index 000000000..5f91c2054 --- /dev/null +++ b/bin/dev @@ -0,0 +1,2 @@ +#!/usr/bin/env ruby +exec "./bin/rails", "server", *ARGV diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint new file mode 100644 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 100644 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 100644 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/rails b/bin/rails new file mode 100644 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 100644 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 100644 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 100644 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 100644 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..376b40394 --- /dev/null +++ b/config/application.rb @@ -0,0 +1,27 @@ +require_relative "boot" + +require "rails/all" + +# Require the gems listed in Gemfile, including any gems +# you've limited to :test, :development, or :production. +Bundler.require(*Rails.groups) + +module UserManagementApp + class Application < Rails::Application + # Initialize configuration defaults for originally generated Rails version. + config.load_defaults 8.1 + + # Please, add to the `ignore` list any other `lib` subdirectories that do + # not contain `.rb` files, or that should not be reloaded or eager loaded. + # Common ones are `templates`, `generators`, or `middleware`, for example. + config.autoload_lib(ignore: %w[assets tasks]) + + # Configuration for the application, engines, and railties goes here. + # + # These settings can be overridden in specific environments using the files + # in config/environments, which are processed later. + # + # config.time_zone = "Central Time (US & Canada)" + # config.eager_load_paths << Rails.root.join("extras") + end +end diff --git a/config/boot.rb b/config/boot.rb new file mode 100644 index 000000000..988a5ddc4 --- /dev/null +++ b/config/boot.rb @@ -0,0 +1,4 @@ +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +require "bundler/setup" # Set up gems listed in the Gemfile. +require "bootsnap/setup" # Speed up boot time by caching expensive operations. diff --git a/config/bundler-audit.yml b/config/bundler-audit.yml new file mode 100644 index 000000000..e74b3af94 --- /dev/null +++ b/config/bundler-audit.yml @@ -0,0 +1,5 @@ +# Audit all gems listed in the Gemfile for known security problems by running bin/bundler-audit. +# CVEs that are not relevant to the application can be enumerated on the ignore list below. + +ignore: + - CVE-THAT-DOES-NOT-APPLY diff --git a/config/cable.yml b/config/cable.yml new file mode 100644 index 000000000..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..1712cc112 --- /dev/null +++ b/config/ci.rb @@ -0,0 +1,24 @@ +# Run using bin/ci + +CI.run do + step "Setup", "bin/setup --skip-server" + + step "Style: Ruby", "bin/rubocop" + + step "Security: Gem audit", "bin/bundler-audit" + step "Security: Importmap vulnerability audit", "bin/importmap audit" + step "Security: Brakeman code analysis", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error" + step "Tests: Rails", "bin/rails test" + step "Tests: Seeds", "env RAILS_ENV=test bin/rails db:seed:replant" + + # Optional: Run system tests + # step "Tests: System", "bin/rails test:system" + + # Optional: set a green GitHub commit status to unblock PR merge. + # Requires the `gh` CLI and `gh extension install basecamp/gh-signoff`. + # if success? + # step "Signoff: All systems go. Ready for merge and deploy.", "gh signoff" + # else + # failure "Signoff: CI failed. Do not merge or deploy.", "Fix the issues and try again." + # end +end diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc new file mode 100644 index 000000000..ac3c00a66 --- /dev/null +++ b/config/credentials.yml.enc @@ -0,0 +1 @@ +66PFycH9+ERGt1XY+4ALWA07qR/Wg21nMlA2TiPhry9T3Dk2YvVmU1n0FynlunmN35jEbHr8GrSjjJgEf1gm9eH/GWLVJweovouo/zokGtwuPqGCbCJ4TxsdW4XqdIROO6Rn5MUhWTrECyuwIWNhoYnzDHnYliouUXQ2/pBtj06o4VSHBZwnNVfj/ANIzAl4ye4/EJwe75MvRtDZwjpZVn9IJbl11agquZEuWDTrVIrdwqGLmXc+FLB0sCSv806SYc3Z6dqzOBDkwTqDj9K04lkP7p1fTHHQyzGV/Szx+7jmfUnsiXdwRwisNlJiaim5d6+cZcIKQuF7eBvtUgZDJcZ/M0VeMoESTuP+eDxaeiQo681W5mT10ulb1ViS9rR9h9W6DUoeHqHoSbOPjI4uUYXlXtSAkQBmA4yOBJJ3CmPGMSWeQh2ZEOv/EnJlQK6yb5vvLOT67hFqwMEuW3ou4Ug++5zDsbl/Hd84Km+vkIuX/6JeYewtGfim--DU+Zpa6GRN+fysc0--h5zEqlVcS5nUNXxgbEaseQ== \ No newline at end of file diff --git a/config/database.yml b/config/database.yml new file mode 100644 index 000000000..0d573e691 --- /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: user_management_app_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: user_management_app + + # 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: user_management_app_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: user_management_app_production + username: user_management_app + password: <%= ENV["USER_MANAGEMENT_APP_DATABASE_PASSWORD"] %> + cache: + <<: *primary_production + database: user_management_app_production_cache + migrations_paths: db/cache_migrate + queue: + <<: *primary_production + database: user_management_app_production_queue + migrations_paths: db/queue_migrate + cable: + <<: *primary_production + database: user_management_app_production_cable + migrations_paths: db/cable_migrate diff --git a/config/environment.rb b/config/environment.rb new file mode 100644 index 000000000..cac531577 --- /dev/null +++ b/config/environment.rb @@ -0,0 +1,5 @@ +# Load the Rails application. +require_relative "application" + +# Initialize the Rails application. +Rails.application.initialize! diff --git a/config/environments/development.rb b/config/environments/development.rb new file mode 100644 index 000000000..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..f893475da --- /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/test/controllers/.keep b/test/controllers/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/test/fixtures/files/.keep b/test/fixtures/files/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/test/helpers/.keep b/test/helpers/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/test/integration/.keep b/test/integration/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/test/mailers/.keep b/test/mailers/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/test/models/.keep b/test/models/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 000000000..0c22470ec --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,15 @@ +ENV["RAILS_ENV"] ||= "test" +require_relative "../config/environment" +require "rails/test_help" + +module ActiveSupport + class TestCase + # Run tests in parallel with specified workers + parallelize(workers: :number_of_processors) + + # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. + fixtures :all + + # Add more helper methods to be used by all tests here... + end +end 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 9425d7627d9970b7a7233c5f20f3cafbb3ff392f Mon Sep 17 00:00:00 2001 From: Bruno Schumacher Date: Thu, 10 Sep 2026 03:16:31 -0300 Subject: [PATCH 03/25] feat(app): setup authentication, Inertia, React, Vite and Tailwind --- .gitignore | 8 + Gemfile | 4 + Gemfile.lock | 33 + Procfile.dev | 3 + app/frontend/entrypoints/application.css | 1 + app/frontend/entrypoints/application.tsx | 14 + app/javascript/entrypoints/application.js | 28 + app/views/layouts/application.html.erb | 21 +- bin/vite | 16 + config/database.yml | 102 +- .../initializers/content_security_policy.rb | 9 + config/puma.rb | 2 +- config/vite.json | 17 + ...te_active_storage_tables.active_storage.rb | 57 + db/schema.rb | 47 + package-lock.json | 2016 +++++++++++++++++ package.json | 21 + tsconfig.json | 24 + vite.config.ts | 19 + 19 files changed, 2331 insertions(+), 111 deletions(-) create mode 100644 Procfile.dev create mode 100644 app/frontend/entrypoints/application.css create mode 100644 app/frontend/entrypoints/application.tsx create mode 100644 app/javascript/entrypoints/application.js create mode 100644 bin/vite create mode 100644 config/vite.json create mode 100644 db/migrate/20260910061238_create_active_storage_tables.active_storage.rb create mode 100644 db/schema.rb create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore index fbcab405e..63805955f 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,11 @@ # Ignore key files for decrypting credentials and more. /config/*.key + +# 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/Gemfile b/Gemfile index 36f943502..1afe06543 100644 --- a/Gemfile +++ b/Gemfile @@ -61,3 +61,7 @@ group :test do gem "capybara" gem "selenium-webdriver" end + +gem "inertia_rails", "~> 3.22" +gem "roo", "~> 3.0" +gem "vite_rails", "~> 3.11" diff --git a/Gemfile.lock b/Gemfile.lock index 1d249087a..5c99056c0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -101,11 +101,13 @@ GEM 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) drb (2.2.3) + dry-cli (1.4.1) erb (6.0.7) erubi (1.13.1) et-orbi (1.4.2) @@ -132,6 +134,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) @@ -163,6 +167,7 @@ GEM drb (~> 2.0) prism (~> 1.5) msgpack (1.8.4) + mutex_m (0.3.0) net-imap (0.6.7) date net-protocol @@ -214,6 +219,8 @@ GEM 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) @@ -266,6 +273,12 @@ GEM reline (0.7.0) io-console (~> 0.5) rexml (3.4.4) + roo (3.0.0) + base64 (~> 0.2) + csv (~> 3) + logger (~> 1) + nokogiri (~> 1) + rubyzip (>= 3.0.0, < 4.0.0) rubocop (1.90.0) json (>= 2.3) language_server-protocol (~> 3.17.0.2) @@ -342,6 +355,15 @@ 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) @@ -375,11 +397,13 @@ DEPENDENCIES debug image_processing (~> 1.2) importmap-rails + inertia_rails (~> 3.22) jbuilder pg (~> 1.1) propshaft puma (>= 5.0) rails (~> 8.1.3, >= 8.1.3.1) + roo (~> 3.0) rubocop-rails-omakase selenium-webdriver solid_cable @@ -389,6 +413,7 @@ DEPENDENCIES thruster turbo-rails tzinfo-data + vite_rails (~> 3.11) web-console CHECKSUMS @@ -417,9 +442,11 @@ CHECKSUMS 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 drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 + dry-cli (1.4.1) sha256=b8015bb76c708aa8705a36faf694973e75eeeffca39b89c8e172dc6f66a7d874 erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92 erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 et-orbi (1.4.2) sha256=bb555dae668419cb24caa2a293a170e58be6d4df1e017c51f5030bdc133cd20c @@ -436,6 +463,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 jbuilder (2.15.1) sha256=2430bec28fb0cebacb5875b1009cf9d8bc3c303ccb810c4c8b062a4b51457637 @@ -451,6 +479,7 @@ CHECKSUMS 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.7) sha256=b5c9573be975d856de252ee851871724da66aa2d449b2482d5690bd12bc23660 net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 net-protocol (0.3.0) sha256=ba310c3d4f1cad46bb1ab20336b06669b1ff8f7c568d9cb9342b32a718547472 @@ -482,6 +511,7 @@ CHECKSUMS 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 @@ -496,6 +526,7 @@ CHECKSUMS regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 + roo (3.0.0) sha256=6fdd7a9158d657c69768b4168754ff2110cc21fdc01a1bec1010820cb05c91b1 rubocop (1.90.0) sha256=9eb4c065b5c5154e4ef554c547972f3905a9eb6b53e657e580b6796b54bf8242 rubocop-ast (1.50.0) sha256=b9ca88300da0803ee222ad20cdb30494c0a784eed06fdc35d254b06d662788db rubocop-performance (1.27.0) sha256=eeeb1374d062a368ee1c787b70eb0b0cc4b184cb1f8565f424760946146d61ce @@ -524,6 +555,8 @@ 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 diff --git a/Procfile.dev b/Procfile.dev new file mode 100644 index 000000000..a8bb878e2 --- /dev/null +++ b/Procfile.dev @@ -0,0 +1,3 @@ +web: bin/rails server -b 0.0.0.0 -p 3000 +vite: bin/vite dev +worker: bin/rails solid_queue:start \ No newline at end of file diff --git a/app/frontend/entrypoints/application.css b/app/frontend/entrypoints/application.css new file mode 100644 index 000000000..a461c505f --- /dev/null +++ b/app/frontend/entrypoints/application.css @@ -0,0 +1 @@ +@import "tailwindcss"; \ No newline at end of file diff --git a/app/frontend/entrypoints/application.tsx b/app/frontend/entrypoints/application.tsx new file mode 100644 index 000000000..417b64585 --- /dev/null +++ b/app/frontend/entrypoints/application.tsx @@ -0,0 +1,14 @@ +import './application.css' +import React from 'react' +import { createRoot } from 'react-dom/client' +import { createInertiaApp } from '@inertiajs/react' + +createInertiaApp({ + resolve: (name: string) => { + const pages = import.meta.glob('../pages/**/*.tsx', { eager: true }) + return pages[`../pages/${name}.tsx`] + }, + setup({ el, App, props }) { + createRoot(el).render() + }, +}) \ No newline at end of file diff --git a/app/javascript/entrypoints/application.js b/app/javascript/entrypoints/application.js new file mode 100644 index 000000000..ff27427fd --- /dev/null +++ b/app/javascript/entrypoints/application.js @@ -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/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 12f343a91..1ade25623 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -1,29 +1,16 @@ - <%= content_for(:title) || "User Management App" %> + User Management System - - - <%= 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 %> + <%= vite_client_tag %> + <%= vite_typescript_tag 'application' %> <%= yield %> - + \ No newline at end of file diff --git a/bin/vite b/bin/vite new file mode 100644 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/database.yml b/config/database.yml index 0d573e691..5765fe202 100644 --- a/config/database.yml +++ b/config/database.yml @@ -1,104 +1,20 @@ -# 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 } %> - + host: <%= ENV.fetch("DB_HOST", "db") %> + username: <%= ENV.fetch("POSTGRES_USER", "postgres") %> + password: <%= ENV.fetch("POSTGRES_PASSWORD", "password") %> + port: 5432 + pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> development: <<: *default - database: user_management_app_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: user_management_app - - # 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 + database: user_management_development - # 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: user_management_app_test + database: user_management_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: user_management_app_production - username: user_management_app - password: <%= ENV["USER_MANAGEMENT_APP_DATABASE_PASSWORD"] %> - cache: - <<: *primary_production - database: user_management_app_production_cache - migrations_paths: db/cache_migrate - queue: - <<: *primary_production - database: user_management_app_production_queue - migrations_paths: db/queue_migrate - cable: - <<: *primary_production - database: user_management_app_production_cable - migrations_paths: db/cable_migrate + <<: *default + database: user_management_production \ No newline at end of file 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/puma.rb b/config/puma.rb index 38c4b8659..3ea6dd045 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", 3000), "0.0.0.0" # Allow puma to be restarted by `bin/rails restart` command. plugin :tmp_restart 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/db/migrate/20260910061238_create_active_storage_tables.active_storage.rb b/db/migrate/20260910061238_create_active_storage_tables.active_storage.rb new file mode 100644 index 000000000..6bd8bd082 --- /dev/null +++ b/db/migrate/20260910061238_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 new file mode 100644 index 000000000..515fb655c --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,47 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.1].define(version: 2026_09_10_061238) 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 + + 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" +end diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..062e39964 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2016 @@ +{ + "name": "rails", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@inertiajs/react": "^3.7.0", + "@vitejs/plugin-react": "^6.1.1", + "react": "^19.3.0", + "react-dom": "^19.3.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "autoprefixer": "^10.5.5", + "postcss": "^8.5.28", + "tailwindcss": "^4.3.3", + "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==", + "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==", + "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/@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==", + "dev": true, + "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==", + "dev": true, + "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==", + "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==" + }, + "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==", + "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==", + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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==" + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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==", + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "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==", + "dev": true, + "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.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "peerDependencies": { + "@types/react": "^19.3.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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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==", + "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/autoprefixer": { + "version": "10.5.5", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.5.tgz", + "integrity": "sha512-uiRYvQYe/nNSzBJ7OUnd2/TZVsAdob3blml44teEpee9Cc1f4rGZFewO+JT3Wo8mgFOSzNqes4FHZn/Qz8WOuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "browserslist": "^4.28.9", + "caniuse-lite": "^1.0.30001810", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", + "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==", + "dev": true, + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "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 + }, + "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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.425", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.425.tgz", + "integrity": "sha512-QvPtl41EUOnuT1HBvMKgxXRIaHNcagBPs50u7VULzhZXaGfqTbZyE16LQsctZ/RQHlGu+FOWeDTR4mY6YbeF1g==", + "dev": true + }, + "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==", + "dev": true, + "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==" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "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, + "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==", + "dev": true + }, + "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==", + "devOptional": true, + "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==", + "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==", + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "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" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.55", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz", + "integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "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" + ], + "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==" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "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" + } + ], + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz", + "integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==", + "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.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==" + }, + "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==", + "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==", + "dev": true + }, + "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==", + "dev": true, + "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==", + "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, + "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/update-browserslist-db": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "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==", + "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, + "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..c0f90faad --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "private": true, + "type": "module", + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "autoprefixer": "^10.5.5", + "postcss": "^8.5.28", + "tailwindcss": "^4.3.3", + "typescript": "^7.0.2", + "vite": "^8.2.2", + "vite-plugin-ruby": "^5.2.3" + }, + "dependencies": { + "@inertiajs/react": "^3.7.0", + "@vitejs/plugin-react": "^6.1.1", + "react": "^19.3.0", + "react-dom": "^19.3.0" + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..be3f5593c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["app/frontend/*"] + } + }, + "include": ["app/frontend/**/*"] +} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 000000000..33efafb24 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite' +import RubyPlugin from 'vite-plugin-ruby' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' + +export default defineConfig({ + plugins: [ + RubyPlugin(), + react(), + tailwindcss(), + ], + server: { + host: '0.0.0.0', + hmr: { + host: 'localhost', + port: 5173, + }, + }, +}) \ No newline at end of file From 43f2905a81bf6fb692299d12aa26bdf342b197a3 Mon Sep 17 00:00:00 2001 From: Bruno Schumacher Date: Thu, 10 Sep 2026 03:21:36 -0300 Subject: [PATCH 04/25] feat(db): add user model, imports and database seeds --- Gemfile | 2 ++ Gemfile.lock | 3 ++ app/models/user.rb | 32 +++++++++++++++++++ app/models/user_import.rb | 6 ++++ db/migrate/20260910061655_create_users.rb | 14 ++++++++ .../20260910061704_create_user_imports.rb | 15 +++++++++ db/schema.rb | 30 ++++++++++++++++- db/seeds.rb | 30 +++++++++++------ 8 files changed, 122 insertions(+), 10 deletions(-) create mode 100644 app/models/user.rb create mode 100644 app/models/user_import.rb create mode 100644 db/migrate/20260910061655_create_users.rb create mode 100644 db/migrate/20260910061704_create_user_imports.rb diff --git a/Gemfile b/Gemfile index 1afe06543..3711a8d91 100644 --- a/Gemfile +++ b/Gemfile @@ -65,3 +65,5 @@ end gem "inertia_rails", "~> 3.22" gem "roo", "~> 3.0" gem "vite_rails", "~> 3.11" + +gem "bcrypt", "~> 3.1" diff --git a/Gemfile.lock b/Gemfile.lock index 5c99056c0..db75d545b 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) bigdecimal (4.1.2) bindex (0.8.1) bootsnap (1.26.0) @@ -390,6 +391,7 @@ PLATFORMS x86_64-linux-musl DEPENDENCIES + bcrypt (~> 3.1) bootsnap brakeman bundler-audit @@ -432,6 +434,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 bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e bootsnap (1.26.0) sha256=ca96237015e6cd74a02963d5821cf00ac5ea134653b323e8cd6d702a7718bf1b diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 000000000..b5a9c33e2 --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,32 @@ +class User < ApplicationRecord + has_secure_password + has_one_attached :avatar + + enum :role, { member: "member", admin: "admin" }, default: :member + + has_many :sessions, dependent: :destroy + has_many :user_imports, dependent: :destroy + + validates :full_name, presence: true, length: { maximum: 100 } + validates :email_address, presence: true, uniqueness: { case_sensitive: false }, format: { with: URI::MailTo::EMAIL_REGEXP } + validates :password, presence: true, length: { minimum: 8 }, if: -> { new_record? || changes[:password_digest] } + + after_commit :broadcast_dashboard_stats, on: %i[create destroy update] + + def avatar_url + if avatar.attached? + Rails.application.routes.url_helpers.rails_blob_url(avatar, only_path: true) + else + "https://ui-avatars.com/api/?name=#{CGI.escape(full_name)}&background=random" + end + end + + private + + def broadcast_dashboard_stats + ActionCable.server.broadcast("admin_dashboard_channel", { + total_users: User.count, + role_counts: User.group(:role).count + }) + end +end \ No newline at end of file diff --git a/app/models/user_import.rb b/app/models/user_import.rb new file mode 100644 index 000000000..523fe76a4 --- /dev/null +++ b/app/models/user_import.rb @@ -0,0 +1,6 @@ +class UserImport < ApplicationRecord + belongs_to :user + has_one_attached :file + + validates :file, presence: true +end \ No newline at end of file diff --git a/db/migrate/20260910061655_create_users.rb b/db/migrate/20260910061655_create_users.rb new file mode 100644 index 000000000..ec5e978ce --- /dev/null +++ b/db/migrate/20260910061655_create_users.rb @@ -0,0 +1,14 @@ +class CreateUsers < ActiveRecord::Migration[8.0] + def change + create_enum :user_role, %w[member admin] + + create_table :users do |t| + t.string :full_name, null: false + t.string :email_address, null: false, index: { unique: true } + t.string :password_digest, null: false + t.enum :role, enum_type: :user_role, default: "member", null: false + + t.timestamps + end + end +end \ No newline at end of file diff --git a/db/migrate/20260910061704_create_user_imports.rb b/db/migrate/20260910061704_create_user_imports.rb new file mode 100644 index 000000000..43a720caa --- /dev/null +++ b/db/migrate/20260910061704_create_user_imports.rb @@ -0,0 +1,15 @@ +class CreateUserImports < ActiveRecord::Migration[8.1] + def change + create_table :user_imports do |t| + t.references :user, null: false, foreign_key: true + t.string :status + t.integer :total_rows + t.integer :processed_rows + t.integer :successful_rows + t.integer :failed_rows + t.jsonb :error_messages + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 515fb655c..e5f85cc56 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,10 +10,14 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_09_10_061238) do +ActiveRecord::Schema[8.1].define(version: 2026_09_10_061704) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" + # Custom types defined in this database. + # Note that some types may not work with other database engines. Be careful if changing database. + create_enum "user_role", ["member", "admin"] + create_table "active_storage_attachments", force: :cascade do |t| t.bigint "blob_id", null: false t.datetime "created_at", null: false @@ -42,6 +46,30 @@ t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true end + create_table "user_imports", force: :cascade do |t| + t.datetime "created_at", null: false + t.jsonb "error_messages" + t.integer "failed_rows" + t.integer "processed_rows" + t.string "status" + t.integer "successful_rows" + t.integer "total_rows" + t.datetime "updated_at", null: false + t.bigint "user_id", null: false + t.index ["user_id"], name: "index_user_imports_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 "full_name", null: false + t.string "password_digest", null: false + t.enum "role", default: "member", null: false, enum_type: "user_role" + t.datetime "updated_at", null: false + t.index ["email_address"], name: "index_users_on_email_address", unique: true + end + add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" + add_foreign_key "user_imports", "users" end diff --git a/db/seeds.rb b/db/seeds.rb index 4fbd6ed97..1927ea3a5 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,9 +1,21 @@ -# 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 +User.destroy_all + +admin = User.create!( + full_name: "Admin Master", + email_address: "admin@admin.com", + password: "password123", + password_confirmation: "password123", + role: :admin +) + +member = User.create!( + full_name: "Usuário Comum", + email_address: "user@user.com", + password: "password123", + password_confirmation: "password123", + role: :member +) + +puts "Seeds executados com sucesso!" +puts "Admin: admin@admin.com | Senha: password123" +puts "User: user@user.com | Senha: password123" \ No newline at end of file From 94da9f7cdde17d32034575a3679d4c0e3028b00f Mon Sep 17 00:00:00 2001 From: Bruno Schumacher Date: Thu, 10 Sep 2026 04:06:10 -0300 Subject: [PATCH 05/25] feat(auth): add Rails authentication and sessions --- app/controllers/concerns/authentication.rb | 52 ++++++++++++++++++++ app/controllers/sessions_controller.rb | 31 ++++++++++++ app/models/concerns/current.rb | 4 ++ app/models/session.rb | 3 ++ db/migrate/20260910063640_create_sessions.rb | 11 +++++ 5 files changed, 101 insertions(+) create mode 100644 app/controllers/concerns/authentication.rb create mode 100644 app/controllers/sessions_controller.rb create mode 100644 app/models/concerns/current.rb create mode 100644 app/models/session.rb create mode 100644 db/migrate/20260910063640_create_sessions.rb diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb new file mode 100644 index 000000000..80979819c --- /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(*args, **options) + skip_before_action :require_authentication, *args, **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 login_path + end + + def start_new_session_for(user) + user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |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 + + def current_user + Current.session&.user + end +end \ No newline at end of file diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb new file mode 100644 index 000000000..e1bbf889a --- /dev/null +++ b/app/controllers/sessions_controller.rb @@ -0,0 +1,31 @@ +class SessionsController < ApplicationController + allow_unauthenticated_access only: %i[new create] + + def new + render inertia: "Auth/Login" + end + + def create + if user = User.authenticate_by(email_address: params[:email_address], password: params[:password]) + start_new_session_for user + redirect_after_login(user) + else + redirect_to login_path, alert: "E-mail ou senha inválidos." + end + end + + def destroy + terminate_session + redirect_to login_path, notice: "Sessão encerrada com sucesso." + end + + private + + def redirect_after_login(user) + if user.admin? + redirect_to admin_dashboard_path + else + redirect_to profile_path + end + end +end \ No newline at end of file diff --git a/app/models/concerns/current.rb b/app/models/concerns/current.rb new file mode 100644 index 000000000..05e976afc --- /dev/null +++ b/app/models/concerns/current.rb @@ -0,0 +1,4 @@ +class Current < ActiveSupport::CurrentAttributes + attribute :session + delegate :user, to: :session, allow_nil: true +end \ No newline at end of file diff --git a/app/models/session.rb b/app/models/session.rb new file mode 100644 index 000000000..4825ef924 --- /dev/null +++ b/app/models/session.rb @@ -0,0 +1,3 @@ +class Session < ApplicationRecord + belongs_to :user +end \ No newline at end of file diff --git a/db/migrate/20260910063640_create_sessions.rb b/db/migrate/20260910063640_create_sessions.rb new file mode 100644 index 000000000..8e681fa30 --- /dev/null +++ b/db/migrate/20260910063640_create_sessions.rb @@ -0,0 +1,11 @@ +class CreateSessions < ActiveRecord::Migration[8.0] + 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 \ No newline at end of file From 1db658c1bbad798ddaf31ea9842b72d0ad8c32b9 Mon Sep 17 00:00:00 2001 From: Bruno Schumacher Date: Thu, 10 Sep 2026 04:06:17 -0300 Subject: [PATCH 06/25] feat(frontend): configure Inertia React and Vite --- app/controllers/inertia_controller.rb | 7 ++ app/controllers/inertia_example_controller.rb | 12 +++ app/frontend/pages/Auth/Login.tsx | 65 +++++++++++ app/frontend/pages/Profile/Show.tsx | 90 ++++++++++++++++ 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 + config/initializers/inertia_rails.rb | 9 ++ tsconfig.app.json | 33 ++++++ tsconfig.node.json | 13 +++ 13 files changed, 436 insertions(+) create mode 100644 app/controllers/inertia_controller.rb create mode 100644 app/controllers/inertia_example_controller.rb create mode 100644 app/frontend/pages/Auth/Login.tsx create mode 100644 app/frontend/pages/Profile/Show.tsx 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 100644 config/initializers/inertia_rails.rb create mode 100644 tsconfig.app.json create mode 100644 tsconfig.node.json 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/frontend/pages/Auth/Login.tsx b/app/frontend/pages/Auth/Login.tsx new file mode 100644 index 000000000..d5ec16dc5 --- /dev/null +++ b/app/frontend/pages/Auth/Login.tsx @@ -0,0 +1,65 @@ +import React from 'react' +import { useForm, Link } from '@inertiajs/react' + +export default function Login() { + const { data, setData, post, processing, errors } = useForm({ + email_address: '', + password: '', + }) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + post('/login') + } + + return ( +
+
+
+

+ Acesse sua conta +

+
+
+
+
+ + setData('email_address', e.target.value)} + /> +
+
+ + setData('password', e.target.value)} + /> +
+
+ + +
+ +
+ Não tem uma conta? + + Cadastre-se como visitante + +
+
+
+ ) +} \ No newline at end of file diff --git a/app/frontend/pages/Profile/Show.tsx b/app/frontend/pages/Profile/Show.tsx new file mode 100644 index 000000000..777fd6b4e --- /dev/null +++ b/app/frontend/pages/Profile/Show.tsx @@ -0,0 +1,90 @@ +import React from 'react' +import { useForm, router, Link } from '@inertiajs/react' + +interface UserProps { + id: number + full_name: string + email_address: string + role: string + avatar_url: string +} + +export default function Show({ user }: { user: UserProps }) { + const { data, setData, post, processing } = useForm({ + _method: 'patch', + full_name: user.full_name, + email_address: user.email_address, + avatar: null as File | null, + }) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + post('/profile') + } + + const handleDelete = () => { + if (confirm('Tem certeza que deseja excluir sua conta?')) { + router.delete('/profile') + } + } + + return ( +
+
+
+ {user.full_name} +
+

{user.full_name}

+

{user.email_address} • {user.role}

+
+
+ + Sair + +
+ +
+

Editar Meu Perfil

+
+
+ + setData('full_name', e.target.value)} + className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm" + /> +
+ +
+ + setData('avatar', e.target.files ? e.target.files[0] : null)} + className="mt-1 block w-full text-sm text-gray-500" + /> +
+ +
+ + + +
+
+
+
+ ) +} \ No newline at end of file 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/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/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.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"] +} From 13d068b626d09f48a8d72dada982869b9eed8d3c Mon Sep 17 00:00:00 2001 From: Bruno Schumacher Date: Thu, 10 Sep 2026 04:06:24 -0300 Subject: [PATCH 07/25] feat(frontend): configure Tailwind CSS and assets --- 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 ++++ 5 files changed, 16 insertions(+) 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 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'; From f69bd45db284e76fc34e1b7d0ea71c505ea77977 Mon Sep 17 00:00:00 2001 From: Bruno Schumacher Date: Thu, 10 Sep 2026 04:06:30 -0300 Subject: [PATCH 08/25] feat(auth): add profile and registration controllers --- app/controllers/profiles_controller.rb | 33 +++++++++++++++++++++ app/controllers/registrations_controller.rb | 25 ++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 app/controllers/profiles_controller.rb create mode 100644 app/controllers/registrations_controller.rb diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb new file mode 100644 index 000000000..b52c611ed --- /dev/null +++ b/app/controllers/profiles_controller.rb @@ -0,0 +1,33 @@ +class ProfilesController < ApplicationController + def show + render inertia: "Profile/Show", props: { + user: { + id: current_user.id, + full_name: current_user.full_name, + email_address: current_user.email_address, + role: current_user.role, + avatar_url: current_user.avatar_url + } + } + end + + def update + if current_user.update(profile_params) + redirect_to profile_path, notice: "Perfil atualizado com sucesso!" + else + redirect_to profile_path, alert: current_user.errors.full_messages.to_sentence + end + end + + def destroy + current_user.destroy + terminate_session + redirect_to register_path, notice: "Sua conta foi excluída permanentemente." + end + + private + + def profile_params + params.require(:user).permit(:full_name, :email_address, :password, :password_confirmation, :avatar) + end +end \ No newline at end of file diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb new file mode 100644 index 000000000..763d72faa --- /dev/null +++ b/app/controllers/registrations_controller.rb @@ -0,0 +1,25 @@ +class RegistrationsController < ApplicationController + allow_unauthenticated_access only: %i[new create] + + def new + render inertia: "Auth/Register" + end + + def create + user = User.new(user_params) + user.role = :member + + if user.save + start_new_session_for user + redirect_to profile_path, notice: "Conta criada com sucesso!" + else + redirect_to register_path, alert: user.errors.full_messages.to_sentence + end + end + + private + + def user_params + params.require(:user).permit(:full_name, :email_address, :password, :password_confirmation, :avatar) + end +end \ No newline at end of file From 0620952d1b8e8a4db2adc7d3db4eeb9d55f83bf1 Mon Sep 17 00:00:00 2001 From: Bruno Schumacher Date: Thu, 10 Sep 2026 04:13:25 -0300 Subject: [PATCH 09/25] chore: configure frontend dependencies and development tooling --- Gemfile | 1 + Gemfile.lock | 5 +-- bin/dev | 25 +++++++++++++-- package-lock.json | 77 +++++++++++++++++++++++++++++++++++++++++++++-- package.json | 7 +++++ tsconfig.json | 31 ++++++------------- vite.config.ts | 2 ++ 7 files changed, 120 insertions(+), 28 deletions(-) diff --git a/Gemfile b/Gemfile index 3711a8d91..83b59b880 100644 --- a/Gemfile +++ b/Gemfile @@ -67,3 +67,4 @@ gem "roo", "~> 3.0" gem "vite_rails", "~> 3.11" gem "bcrypt", "~> 3.1" +gem "json", "~> 2.21" \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock index db75d545b..b6bed59ea 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -146,7 +146,7 @@ GEM jbuilder (2.15.1) actionview (>= 7.0.0) activesupport (>= 7.0.0) - json (3.0.2) + json (2.21.2) language_server-protocol (3.17.0.6) lint_roller (1.1.0) logger (1.7.0) @@ -401,6 +401,7 @@ DEPENDENCIES importmap-rails inertia_rails (~> 3.22) jbuilder + json (~> 2.21) pg (~> 1.1) propshaft puma (>= 5.0) @@ -470,7 +471,7 @@ CHECKSUMS io-console (0.9.2) sha256=efa74f891dd03c0939a931dfc6e74c2813d904763d456ea9762b0525e748db08 irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 jbuilder (2.15.1) sha256=2430bec28fb0cebacb5875b1009cf9d8bc3c303ccb810c4c8b062a4b51457637 - json (3.0.2) sha256=8e6d7e7b11384c21230430cef90b71f14849a34a1f4452796670f7c981bd19df + json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0 lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 diff --git a/bin/dev b/bin/dev index 5f91c2054..ef33f02c7 100644 --- a/bin/dev +++ b/bin/dev @@ -1,2 +1,23 @@ -#!/usr/bin/env ruby -exec "./bin/rails", "server", *ARGV +#!/usr/bin/env sh + +export PORT="${PORT:-3000}" + +if command -v overmind 1> /dev/null 2>&1 +then + overmind start -f Procfile.dev "$@" + exit $? +fi + +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 + +if gem list --no-installed --exact --silent foreman; then + echo "Installing foreman..." + gem install foreman +fi + +foreman start -f Procfile.dev "$@" diff --git a/package-lock.json b/package-lock.json index 062e39964..bfed46487 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,11 +6,15 @@ "": { "dependencies": { "@inertiajs/react": "^3.7.0", + "@inertiajs/vite": "^3.7.0", + "@tailwindcss/forms": "^0.5.11", + "@tailwindcss/typography": "^0.5.20", "@vitejs/plugin-react": "^6.1.1", "react": "^19.3.0", "react-dom": "^19.3.0" }, "devDependencies": { + "@inertiajs/core": "^3.7.0", "@tailwindcss/vite": "^4.3.3", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", @@ -54,6 +58,18 @@ "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==", + "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", @@ -334,6 +350,17 @@ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==" }, + "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==", + "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", @@ -826,6 +853,17 @@ "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==", + "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", @@ -1307,6 +1345,17 @@ } ] }, + "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==", + "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", @@ -1673,6 +1722,14 @@ "@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==", + "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", @@ -1755,6 +1812,18 @@ "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==", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/postcss-value-parser": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", @@ -1828,8 +1897,7 @@ "node_modules/tailwindcss": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", - "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", - "dev": true + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==" }, "node_modules/tapable": { "version": "2.3.3", @@ -1923,6 +1991,11 @@ "browserslist": ">= 4.21.0" } }, + "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==" + }, "node_modules/vite": { "version": "8.2.2", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", diff --git a/package.json b/package.json index c0f90faad..3eb4f2c0a 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "private": true, "type": "module", "devDependencies": { + "@inertiajs/core": "^3.7.0", "@tailwindcss/vite": "^4.3.3", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", @@ -14,8 +15,14 @@ }, "dependencies": { "@inertiajs/react": "^3.7.0", + "@inertiajs/vite": "^3.7.0", + "@tailwindcss/forms": "^0.5.11", + "@tailwindcss/typography": "^0.5.20", "@vitejs/plugin-react": "^6.1.1", "react": "^19.3.0", "react-dom": "^19.3.0" + }, + "scripts": { + "check": "tsc -p tsconfig.app.json && tsc -p tsconfig.node.json" } } diff --git a/tsconfig.json b/tsconfig.json index be3f5593c..ea9d0cd82 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,24 +1,11 @@ { - "compilerOptions": { - "target": "ES2022", - "useDefineForClassFields": true, - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "module": "ESNext", - "skipLibCheck": true, - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "jsx": "react-jsx", - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - "baseUrl": ".", - "paths": { - "@/*": ["app/frontend/*"] + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.node.json" } - }, - "include": ["app/frontend/**/*"] -} \ No newline at end of file + ] +} diff --git a/vite.config.ts b/vite.config.ts index 33efafb24..87eda3ffe 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,3 +1,4 @@ +import inertia from '@inertiajs/vite' import { defineConfig } from 'vite' import RubyPlugin from 'vite-plugin-ruby' import react from '@vitejs/plugin-react' @@ -8,6 +9,7 @@ export default defineConfig({ RubyPlugin(), react(), tailwindcss(), + inertia(), ], server: { host: '0.0.0.0', From 5c94c0b6f72c3999e2a40ff443fcf203a4a3ac11 Mon Sep 17 00:00:00 2001 From: Bruno Schumacher Date: Thu, 10 Sep 2026 04:13:31 -0300 Subject: [PATCH 10/25] feat(frontend): integrate Inertia with Rails application --- app/controllers/application_controller.rb | 28 +++++++++++++++++---- app/frontend/entrypoints/application.tsx | 6 ++++- app/views/layouts/application.html.erb | 4 +-- config/routes.rb | 30 +++++++++++++++-------- 4 files changed, 50 insertions(+), 18 deletions(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index c3537563d..b79d76cf5 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,7 +1,25 @@ 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 + include Authentication - # Changes to the importmap will invalidate the etag for HTML responses - stale_when_importmap_changes -end + inertia_share do + { + auth: { + user: current_user ? { + id: current_user.id, + full_name: current_user.full_name, + email_address: current_user.email_address, + role: current_user.role, + avatar_url: current_user.avatar_url + } : nil + } + } + end + + private + + def require_admin + unless current_user&.admin? + redirect_to profile_path, alert: "Acesso restrito para administradores." + end + end +end \ No newline at end of file diff --git a/app/frontend/entrypoints/application.tsx b/app/frontend/entrypoints/application.tsx index 417b64585..77c568c12 100644 --- a/app/frontend/entrypoints/application.tsx +++ b/app/frontend/entrypoints/application.tsx @@ -6,7 +6,11 @@ import { createInertiaApp } from '@inertiajs/react' createInertiaApp({ resolve: (name: string) => { const pages = import.meta.glob('../pages/**/*.tsx', { eager: true }) - return pages[`../pages/${name}.tsx`] + const page = pages[`../pages/${name}.tsx`] + if (!page) { + throw new Error(`Página não encontrada: ${name}`) + } + return page }, setup({ el, App, props }) { createRoot(el).render() diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 1ade25623..856c68681 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -7,10 +7,10 @@ <%= csp_meta_tag %> <%= vite_client_tag %> - <%= vite_typescript_tag 'application' %> + <%= vite_typescript_tag 'application.tsx' %> - + <%= yield %> \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 48254e88e..828dd4211 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,14 +1,24 @@ 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 + # 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 + get 'inertia-example', to: 'inertia_example#index' + root to: redirect("/login") - # 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 + get "login", to: "sessions#new", as: :login + post "login", to: "sessions#create" + delete "logout", to: "sessions#destroy", as: :logout - # Defines the root path route ("/") - # root "posts#index" -end + get "register", to: "registrations#new", as: :register + post "register", to: "registrations#create" + + resource :profile, only: %i[show update destroy] + + namespace :admin do + get "dashboard", to: "dashboard#index" + resources :users + resources :user_imports, only: %i[create] + end +end \ No newline at end of file From 527865c6acb469e2cad6ca414f4ae2e18b77a6f3 Mon Sep 17 00:00:00 2001 From: Bruno Schumacher Date: Thu, 10 Sep 2026 04:13:37 -0300 Subject: [PATCH 11/25] feat(auth): add login page --- app/frontend/pages/Auth/Login.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/frontend/pages/Auth/Login.tsx b/app/frontend/pages/Auth/Login.tsx index d5ec16dc5..27ff33347 100644 --- a/app/frontend/pages/Auth/Login.tsx +++ b/app/frontend/pages/Auth/Login.tsx @@ -2,7 +2,7 @@ import React from 'react' import { useForm, Link } from '@inertiajs/react' export default function Login() { - const { data, setData, post, processing, errors } = useForm({ + const { data, setData, post, processing } = useForm({ email_address: '', password: '', }) @@ -14,20 +14,20 @@ export default function Login() { return (
-
+
-

+

Acesse sua conta

-
+
setData('email_address', e.target.value)} /> @@ -37,7 +37,7 @@ export default function Login() { setData('password', e.target.value)} /> @@ -47,7 +47,7 @@ export default function Login() { @@ -56,7 +56,7 @@ export default function Login() {
Não tem uma conta? - Cadastre-se como visitante + Cadastre-se
From 1a56d42bac19ca7e2fd3f87ee82be7f78ce48954 Mon Sep 17 00:00:00 2001 From: Bruno Schumacher Date: Thu, 10 Sep 2026 04:13:43 -0300 Subject: [PATCH 12/25] feat(admin): add admin dashboard --- app/controllers/admin/dashboard_controller.rb | 23 ++++ .../admin/user_imports_controller.rb | 17 +++ app/controllers/admin/users_controller.rb | 26 ++++ app/frontend/pages/Admin/Dashboard.tsx | 129 ++++++++++++++++++ 4 files changed, 195 insertions(+) create mode 100644 app/controllers/admin/dashboard_controller.rb create mode 100644 app/controllers/admin/user_imports_controller.rb create mode 100644 app/controllers/admin/users_controller.rb create mode 100644 app/frontend/pages/Admin/Dashboard.tsx diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb new file mode 100644 index 000000000..9ba864012 --- /dev/null +++ b/app/controllers/admin/dashboard_controller.rb @@ -0,0 +1,23 @@ +module Admin + class DashboardController < ApplicationController + before_action :require_admin + + def index + render inertia: "Admin/Dashboard", props: { + stats: { + total_users: User.count, + role_counts: User.group(:role).count + }, + users: User.all.map { |u| + { + id: u.id, + full_name: u.full_name, + email_address: u.email_address, + role: u.role, + avatar_url: u.avatar_url + } + } + } + end + end +end \ No newline at end of file diff --git a/app/controllers/admin/user_imports_controller.rb b/app/controllers/admin/user_imports_controller.rb new file mode 100644 index 000000000..0adc0ade4 --- /dev/null +++ b/app/controllers/admin/user_imports_controller.rb @@ -0,0 +1,17 @@ +module Admin + class UserImportsController < ApplicationController + before_action :require_admin + + def create + user_import = current_user.user_imports.build + user_import.file.attach(params[:file]) + + if user_import.save + ProcessUserImportJob.perform_later(user_import.id) + redirect_to admin_dashboard_path, notice: "Importação iniciada com sucesso!" + else + redirect_to admin_dashboard_path, alert: "Erro ao anexar arquivo de planilha." + end + end + end +end \ No newline at end of file diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb new file mode 100644 index 000000000..36701dc12 --- /dev/null +++ b/app/controllers/admin/users_controller.rb @@ -0,0 +1,26 @@ +module Admin + class UsersController < ApplicationController + before_action :require_admin + + def update + user = User.find(params[:id]) + if user.update(user_params) + redirect_to admin_dashboard_path, notice: "Usuário atualizado." + else + redirect_to admin_dashboard_path, alert: user.errors.full_messages.to_sentence + end + end + + def destroy + user = User.find(params[:id]) + user.destroy + redirect_to admin_dashboard_path, notice: "Usuário removido." + end + + private + + def user_params + params.require(:user).permit(:full_name, :email_address, :role) + end + end +end \ No newline at end of file diff --git a/app/frontend/pages/Admin/Dashboard.tsx b/app/frontend/pages/Admin/Dashboard.tsx new file mode 100644 index 000000000..5ea589f4d --- /dev/null +++ b/app/frontend/pages/Admin/Dashboard.tsx @@ -0,0 +1,129 @@ +import React, { useEffect, useState } from 'react' +import { useForm, router, Link } from '@inertiajs/react' + +interface UserProps { + id: number + full_name: string + email_address: string + role: string + avatar_url: string +} + +interface StatsProps { + total_users: number + role_counts: { + admin?: number + member?: number + } +} + +export default function Dashboard({ stats, users }: { stats: StatsProps, users: UserProps[] }) { + const { setData, post, processing } = useForm({ file: null as File | null }) + + const handleFileUpload = (e: React.FormEvent) => { + e.preventDefault() + post('/admin/user_imports') + } + + const handleRoleToggle = (userId: number, currentRole: string) => { + const newRole = currentRole === 'admin' ? 'member' : 'admin' + router.patch(`/admin/users/${userId}`, { user: { role: newRole } }) + } + + const handleDeleteUser = (userId: number) => { + if (confirm('Deseja realmente remover este usuário?')) { + router.delete(`/admin/users/${userId}`) + } + } + + return ( +
+
+

Painel Administrativo

+ + Sair + +
+ +
+
+

Total de Usuários

+

{stats.total_users}

+
+
+

Administradores

+

{stats.role_counts?.admin || 0}

+
+
+

Membros

+

{stats.role_counts?.member || 0}

+
+
+ +
+

Importar Usuários em Lote (.CSV / .XLSX)

+ + setData('file', e.target.files ? e.target.files[0] : null)} + className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100" + /> + + +
+ +
+
+

Lista de Usuários Cadastrados

+
+ + + + + + + + + + + {users.map((u) => ( + + + + + + + ))} + +
UsuárioE-mailFunção (Role)Ações
+ {u.full_name} + {u.full_name} + {u.email_address} + + {u.role} + + + + +
+
+
+ ) +} \ No newline at end of file From 9952b13a7afd4b3daed9117b7b75736d3b05eb4b Mon Sep 17 00:00:00 2001 From: Bruno Schumacher Date: Thu, 10 Sep 2026 04:13:49 -0300 Subject: [PATCH 13/25] feat(imports): add asynchronous user import job --- app/jobs/process_user_import_job.rb | 58 +++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 app/jobs/process_user_import_job.rb diff --git a/app/jobs/process_user_import_job.rb b/app/jobs/process_user_import_job.rb new file mode 100644 index 000000000..95da5ffe0 --- /dev/null +++ b/app/jobs/process_user_import_job.rb @@ -0,0 +1,58 @@ +require "roo" + +class ProcessUserImportJob < ApplicationJob + queue_as :default + + def perform(user_import_id) + import = UserImport.find(user_import_id) + return if import.status == "completed" + + import.update!(status: "processing") + file_path = ActiveStorage::Blob.service.path_for(import.file.key) + + spreadsheet = Roo::Spreadsheet.open(file_path) + sheet = spreadsheet.sheet(0) + + headers = sheet.row(1).map(&:to_s).map(&:downcase) + total_rows = sheet.last_row - 1 + import.update!(total_rows: total_rows) + + (2..sheet.last_row).each_with_index do |row_index, i| + row = Hash[[headers, sheet.row(row_index)].transpose] + + user = User.new( + full_name: row["full_name"] || row["nome"], + email_address: row["email"] || row["email_address"], + password: SecureRandom.hex(10), + role: (row["role"].to_s.downcase == "admin") ? :admin : :member + ) + + if user.save + import.increment!(:successful_rows) + else + import.increment!(:failed_rows) + import.error_messages << "Linha #{row_index}: #{user.errors.full_messages.join(', ')}" + end + + import.update!(processed_rows: i + 1) + + # Transmite atualização em tempo real a cada 5 linhas ou no final + if (i + 1) % 5 == 0 || (i + 1) == total_rows + percentage = ((import.processed_rows.to_f / total_rows) * 100).round + ActionCable.server.broadcast("import_progress_#{import.id}", { + id: import.id, + status: import.status, + total: import.total_rows, + processed: import.processed_rows, + percentage: percentage + }) + end + end + + import.update!(status: "completed") + ActionCable.server.broadcast("import_progress_#{import.id}", { + status: "completed", + errors: import.error_messages + }) + end +end \ No newline at end of file From 77f22593781f9a66431c6ade0d07756710556a5c Mon Sep 17 00:00:00 2001 From: Bruno Schumacher Date: Thu, 10 Sep 2026 04:13:55 -0300 Subject: [PATCH 14/25] chore(db): update database schema --- db/schema.rb | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/db/schema.rb b/db/schema.rb index e5f85cc56..e45ff3bc2 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_10_061704) do +ActiveRecord::Schema[8.1].define(version: 2026_09_10_063640) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -46,6 +46,15 @@ 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" + 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 "user_imports", force: :cascade do |t| t.datetime "created_at", null: false t.jsonb "error_messages" @@ -71,5 +80,6 @@ add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" + add_foreign_key "sessions", "users" add_foreign_key "user_imports", "users" end From 18340076ac48da33a46b2166d82e1e963e9d899a Mon Sep 17 00:00:00 2001 From: Bruno Schumacher Date: Thu, 10 Sep 2026 06:06:45 -0300 Subject: [PATCH 15/25] chore(infra): configure Docker and Kamal deployment --- .dockerignore | 6 +++-- .kamal/secrets.example | 4 +++ Dockerfile | 56 ++++++++++++++++----------------------- Dockerfile.dev | 27 ++++++++++++++----- bin/docker-dev-entrypoint | 12 +++++++++ bin/setup | 9 +++++-- config/deploy.yml | 54 +++++++++++++++++++++++++++++++++++++ docker-compose.yml | 15 +++++++---- 8 files changed, 135 insertions(+), 48 deletions(-) create mode 100644 .kamal/secrets.example create mode 100644 bin/docker-dev-entrypoint create mode 100644 config/deploy.yml diff --git a/.dockerignore b/.dockerignore index 75405937b..b7cffea15 100644 --- a/.dockerignore +++ b/.dockerignore @@ -36,12 +36,14 @@ !/app/assets/builds/.keep /public/assets +/coverage +/.kamal/secrets + # Ignore CI service files. /.github # Ignore development files /.devcontainer -# Ignore Docker-related files +# Ignore Docker metadata (the image build context still uses Dockerfile / Dockerfile.dev) /.dockerignore -/Dockerfile* diff --git a/.kamal/secrets.example b/.kamal/secrets.example new file mode 100644 index 000000000..816d74f55 --- /dev/null +++ b/.kamal/secrets.example @@ -0,0 +1,4 @@ +KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD +RAILS_MASTER_KEY=$(cat config/master.key) +DATABASE_URL=$DATABASE_URL +POSTGRES_PASSWORD=$POSTGRES_PASSWORD diff --git a/Dockerfile b/Dockerfile index 5509f9e04..468fa31d4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,77 +1,67 @@ # 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 user_management_app . -# docker run -d -p 80:80 -e RAILS_MASTER_KEY= --name user_management_app user_management_app +# Production image for Kamal/Thruster. +# docker build -t umanni-users . +# docker run -d -p 80:80 -e RAILS_MASTER_KEY= -e DATABASE_URL= --name umanni-users umanni-users -# 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 +ARG NODE_VERSION=22 + +FROM docker.io/library/node:${NODE_VERSION}-slim AS node + +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 libsqlite3-0 libvips postgresql-client sqlite3 && \ ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \ rm -rf /var/lib/apt/lists /var/cache/apt/archives -# Set production environment variables and enable jemalloc for reduced memory usage and latency. ENV RAILS_ENV="production" \ BUNDLE_DEPLOYMENT="1" \ BUNDLE_PATH="/usr/local/bundle" \ - BUNDLE_WITHOUT="development" \ - LD_PRELOAD="/usr/local/lib/libjemalloc.so" + BUNDLE_WITHOUT="development:test" \ + LD_PRELOAD="/usr/local/lib/libjemalloc.so" \ + RUBY_YJIT_ENABLE="0" \ + RUBY_ZJIT_ENABLE="1" +# OptimizationRef: RB4-RM80-Solid -# Throw-away build stage to reduce size of final image FROM base AS build -# Install packages needed to build gems +COPY --from=node /usr/local/bin/node /usr/local/bin/node +COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules +RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm + 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 libsqlite3-dev libvips libyaml-dev pkg-config python3 && \ 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 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 +RUN rm -rf node_modules tmp/cache test spec - - - -# 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/Dockerfile.dev b/Dockerfile.dev index 8079ff889..59ee286fe 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,13 +1,28 @@ -FROM ruby:4.0-slim +FROM ruby:4.0.6-slim RUN apt-get update -qq && \ - apt-get install -y build-essential libpq-dev curl git nodejs npm postgresql-client && \ - rm -rf /var/lib/apt/lists/* + apt-get install --no-install-recommends -y \ + build-essential \ + curl \ + git \ + libpq-dev \ + libsqlite3-dev \ + libvips \ + libyaml-dev \ + nodejs \ + npm \ + pkg-config \ + postgresql-client \ + sqlite3 && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives WORKDIR /rails -RUN gem install bundler +ENV BUNDLE_PATH="/usr/local/bundle" \ + RAILS_ENV="development" -EXPOSE 3000 5173 +RUN gem install bundler -v 4.0.16 -CMD ["bin/dev"] \ No newline at end of file +EXPOSE 3000 3036 + +CMD ["bin/dev"] diff --git a/bin/docker-dev-entrypoint b/bin/docker-dev-entrypoint new file mode 100644 index 000000000..1beb9befa --- /dev/null +++ b/bin/docker-dev-entrypoint @@ -0,0 +1,12 @@ +#!/bin/bash +set -euo pipefail + +bundle check || bundle install +if [ ! -d node_modules/@inertiajs ]; then + npm install +fi + +rm -f tmp/pids/server.pid +bin/rails db:prepare + +exec "$@" diff --git a/bin/setup b/bin/setup index 81be011e8..10cdc33df 100644 --- 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") if File.exist?("package.json") # puts "\n== Copying sample files ==" # unless File.exist?("config/database.yml") @@ -21,8 +22,12 @@ FileUtils.chdir APP_ROOT do # end puts "\n== Preparing database ==" - system! "bin/rails db:prepare" - system! "bin/rails db:reset" if ARGV.include?("--reset") + if ARGV.include?("--reset") + system! "bin/rails db:reset" + else + system! "bin/rails db:prepare" + system! "bin/rails db:seed" + end puts "\n== Removing old logs and tempfiles ==" system! "bin/rails log:clear tmp:clear" diff --git a/config/deploy.yml b/config/deploy.yml new file mode 100644 index 000000000..b2911e6f0 --- /dev/null +++ b/config/deploy.yml @@ -0,0 +1,54 @@ +# Kamal 2 production deploy. Replace hosts, image, and proxy host before shipping. +service: umanni-users +image: umanni/users + +servers: + web: + - 192.168.0.1 + +proxy: + ssl: true + host: users.example.com + +registry: + username: umanni + password: + - KAMAL_REGISTRY_PASSWORD + +env: + secret: + - RAILS_MASTER_KEY + - DATABASE_URL + clear: + SOLID_QUEUE_IN_PUMA: true + RUBY_YJIT_ENABLE: 0 + RUBY_ZJIT_ENABLE: 1 + PORT: 80 + +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" + +volumes: + - "umanni_users_storage:/rails/storage" + +asset_path: /rails/public + +builder: + arch: amd64 + +accessories: + db: + image: postgres:16 + host: 192.168.0.1 + port: 5432 + env: + clear: + POSTGRES_USER: umanni + POSTGRES_DB: user_management_production + secret: + - POSTGRES_PASSWORD + directories: + - data:/var/lib/postgresql/data diff --git a/docker-compose.yml b/docker-compose.yml index af044e734..f5dddbd70 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: db: image: postgres:16-alpine @@ -11,25 +9,32 @@ services: - postgres_data:/var/lib/postgresql/data ports: - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 10 app: build: context: . dockerfile: Dockerfile.dev - command: bash -c "rm -f tmp/pids/server.pid && bin/dev" + entrypoint: ["bash", "bin/docker-dev-entrypoint"] + command: ["bin/dev"] volumes: - .:/rails - bundle_data:/usr/local/bundle - node_modules_data:/rails/node_modules ports: - "3000:3000" - - "5173:5173" + - "3036:3036" environment: DATABASE_URL: postgres://postgres:password@db:5432/user_management_development RAILS_ENV: development VITE_RUBY_HOST: 0.0.0.0 depends_on: - - db + db: + condition: service_healthy volumes: postgres_data: From a5d223348bafe2702e309c39911e07885d537575 Mon Sep 17 00:00:00 2001 From: Bruno Schumacher Date: Thu, 10 Sep 2026 06:06:52 -0300 Subject: [PATCH 16/25] chore(rails): configure CI and application infrastructure --- .github/workflows/ci.yml | 42 +++++++++++++---- .rubocop.yml | 31 +++++++++---- config/application.rb | 25 +++++----- config/cable.yml | 12 +++-- config/cache.yml | 3 +- config/ci.rb | 6 +-- config/database.yml | 74 ++++++++++++++++++++++++++---- config/environments/development.rb | 9 +++- config/environments/production.rb | 3 +- config/environments/test.rb | 10 ++++ config/puma.rb | 2 +- test/test_helper.rb | 35 ++++++++++++-- 12 files changed, 192 insertions(+), 60 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8cfe0cd92..dfae91088 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,13 +31,17 @@ jobs: - name: Checkout code uses: actions/checkout@v6 - - name: Set up Ruby - uses: ruby/setup-ruby@v1 + - name: Set up Node + uses: actions/setup-node@v4 with: - bundler-cache: true + node-version: 22 + cache: npm + + - name: Install JavaScript dependencies + run: npm ci - name: Scan for security vulnerabilities in JavaScript dependencies - run: bin/importmap audit + run: npm audit --audit-level=high lint: runs-on: ubuntu-latest @@ -86,7 +90,7 @@ jobs: steps: - name: Install packages - run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libpq-dev libvips + run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libpq-dev libsqlite3-dev libvips - name: Checkout code uses: actions/checkout@v6 @@ -96,12 +100,21 @@ jobs: with: bundler-cache: true + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install JavaScript dependencies + run: npm ci + - name: Run tests env: RAILS_ENV: test DATABASE_URL: postgres://postgres:postgres@localhost:5432 - # RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }} - # REDIS_URL: redis://localhost:6379/0 + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres run: bin/rails db:test:prepare test system-test: @@ -125,7 +138,7 @@ jobs: steps: - name: Install packages - run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libpq-dev libvips + run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libpq-dev libsqlite3-dev libvips - name: Checkout code uses: actions/checkout@v6 @@ -135,12 +148,21 @@ jobs: with: bundler-cache: true + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install JavaScript dependencies + run: npm ci + - name: Run System Tests env: RAILS_ENV: test DATABASE_URL: postgres://postgres:postgres@localhost:5432 - # RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }} - # REDIS_URL: redis://localhost:6379/0 + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres run: bin/rails db:test:prepare test:system - name: Keep screenshots from failed system tests diff --git a/.rubocop.yml b/.rubocop.yml index f9d86d4a5..abf9989ac 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,8 +1,23 @@ -# 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 + +AllCops: + NewCops: enable + TargetRubyVersion: 4.0 + Exclude: + - "bin/**/*" + - "db/schema.rb" + - "db/*_schema.rb" + - "vendor/**/*" + - "node_modules/**/*" + - "storage/**/*" + - "tmp/**/*" + +Layout/LineLength: + Max: 120 + +Style/Documentation: + Enabled: false + +Style/StringLiterals: + EnforcedStyle: double_quotes diff --git a/config/application.rb b/config/application.rb index 376b40394..4e73486ba 100644 --- a/config/application.rb +++ b/config/application.rb @@ -2,26 +2,23 @@ require "rails/all" -# Require the gems listed in Gemfile, including any gems -# you've limited to :test, :development, or :production. Bundler.require(*Rails.groups) module UserManagementApp 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]) + config.i18n.available_locales = %i[pt-BR en] + config.i18n.default_locale = :"pt-BR" + config.i18n.fallbacks = [ :"pt-BR", :en ] + config.generators do |g| + g.test_framework :test_unit, fixture: true + g.system_tests :test_unit + end - # 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") + config.action_dispatch.default_headers.merge!( + "X-Content-Type-Options" => "nosniff", + "Referrer-Policy" => "strict-origin-when-cross-origin" + ) end end diff --git a/config/cable.yml b/config/cable.yml index b9adc5aa3..8ecabd6a6 100644 --- a/config/cable.yml +++ b/config/cable.yml @@ -1,9 +1,11 @@ -# 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. +# OptimizationRef: RB4-RM80-Solid 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/cache.yml b/config/cache.yml index 19d490843..e71067a7b 100644 --- a/config/cache.yml +++ b/config/cache.yml @@ -1,11 +1,10 @@ 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: + database: cache <<: *default test: diff --git a/config/ci.rb b/config/ci.rb index 1712cc112..0995bf6d5 100644 --- a/config/ci.rb +++ b/config/ci.rb @@ -6,14 +6,12 @@ step "Style: Ruby", "bin/rubocop" step "Security: Gem audit", "bin/bundler-audit" - step "Security: Importmap vulnerability audit", "bin/importmap audit" + step "Security: npm audit", "npm audit --audit-level=high" step "Security: Brakeman code analysis", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error" step "Tests: Rails", "bin/rails test" + step "Tests: System", "bin/rails test:system" step "Tests: Seeds", "env RAILS_ENV=test bin/rails db:seed:replant" - # Optional: Run system tests - # step "Tests: System", "bin/rails test:system" - # Optional: set a green GitHub commit status to unblock PR merge. # Requires the `gh` CLI and `gh extension install basecamp/gh-signoff`. # if success? diff --git a/config/database.yml b/config/database.yml index 5765fe202..ec31aa72e 100644 --- a/config/database.yml +++ b/config/database.yml @@ -1,20 +1,76 @@ -default: &default +# PostgreSQL stores application data. +# SQLite WAL powers Solid Cache, Solid Queue, and Solid Cable (no Redis). + +default: &postgres adapter: postgresql encoding: unicode - host: <%= ENV.fetch("DB_HOST", "db") %> + pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + host: <%= ENV.fetch("DB_HOST", "localhost") %> username: <%= ENV.fetch("POSTGRES_USER", "postgres") %> password: <%= ENV.fetch("POSTGRES_PASSWORD", "password") %> - port: 5432 + port: <%= ENV.fetch("DB_PORT", 5432) %> + +sqlite: &sqlite + adapter: sqlite3 pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + timeout: 5000 + pragmas: + journal_mode: WAL + synchronous: NORMAL + mmap_size: 134217728 + journal_size_limit: 67108864 + cache_size: 2000 development: - <<: *default - database: user_management_development + primary: + <<: *postgres + database: user_management_development + url: <%= ENV["DATABASE_URL"] %> + cache: + <<: *sqlite + database: storage/development_cache.sqlite3 + migrations_paths: db/cache_migrate + queue: + <<: *sqlite + database: storage/development_queue.sqlite3 + migrations_paths: db/queue_migrate + cable: + <<: *sqlite + database: storage/development_cable.sqlite3 + migrations_paths: db/cable_migrate test: - <<: *default - database: user_management_test + primary: + <<: *postgres + database: user_management_test<%= ENV["TEST_ENV_NUMBER"] %> + url: <%= ENV["DATABASE_URL"] %> + cache: + <<: *sqlite + database: storage/test_cache<%= ENV["TEST_ENV_NUMBER"] %>.sqlite3 + migrations_paths: db/cache_migrate + queue: + <<: *sqlite + database: storage/test_queue<%= ENV["TEST_ENV_NUMBER"] %>.sqlite3 + migrations_paths: db/queue_migrate + cable: + <<: *sqlite + database: storage/test_cable<%= ENV["TEST_ENV_NUMBER"] %>.sqlite3 + migrations_paths: db/cable_migrate production: - <<: *default - database: user_management_production \ No newline at end of file + primary: + <<: *postgres + database: user_management_production + url: <%= ENV["DATABASE_URL"] %> + cache: + <<: *sqlite + database: storage/production_cache.sqlite3 + migrations_paths: db/cache_migrate + queue: + <<: *sqlite + database: storage/production_queue.sqlite3 + migrations_paths: db/queue_migrate + cable: + <<: *sqlite + database: storage/production_cable.sqlite3 + migrations_paths: db/cable_migrate diff --git a/config/environments/development.rb b/config/environments/development.rb index 75243c3d0..3bd5cb1e6 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -25,8 +25,9 @@ config.action_controller.perform_caching = false end - # Change to :null_store to avoid any caching. - config.cache_store = :memory_store + config.cache_store = :solid_cache_store + config.active_job.queue_adapter = :solid_queue + config.solid_queue.connects_to = { database: { writing: :queue } } # Store uploaded files on the local file system (see config/storage.yml for options). config.active_storage.service = :local @@ -73,6 +74,10 @@ # Raise error when a before_action's only/except options reference missing actions. config.action_controller.raise_on_missing_callback_actions = true + config.active_record.encryption.primary_key = "development_primary_key_32_bytes_long!!" + config.active_record.encryption.deterministic_key = "development_deterministic_key_32_bytes!" + config.active_record.encryption.key_derivation_salt = "development_key_derivation_salt_32_bytes!" + # 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 index f893475da..3102c8955 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -30,8 +30,7 @@ # 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" } } } + 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/environments/test.rb b/config/environments/test.rb index c2095b117..11d2fb85c 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -21,12 +21,18 @@ # Show full error reports. config.consider_all_requests_local = true config.cache_store = :null_store + config.active_job.queue_adapter = :test + + config.active_record.encryption.primary_key = "testPrimaryKeytestPrimaryKey12" + config.active_record.encryption.deterministic_key = "testDeterministicKeytestDet12" + config.active_record.encryption.key_derivation_salt = "testKeyDerivationSalttestKey12" # 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 + config.action_controller.action_on_unpermitted_parameters = :raise # Store uploaded files on the local file system in a temporary directory. config.active_storage.service = :test @@ -42,6 +48,10 @@ # Print deprecation notices to the stderr. config.active_support.deprecation = :stderr + config.active_record.encryption.primary_key = "development_primary_key_32_bytes_long!!" + config.active_record.encryption.deterministic_key = "development_deterministic_key_32_bytes!" + config.active_record.encryption.key_derivation_salt = "development_key_derivation_salt_32_bytes!" + # Raises error for missing translations. # config.i18n.raise_on_missing_translations = true diff --git a/config/puma.rb b/config/puma.rb index 3ea6dd045..ea684d204 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"] != "false" && (Rails.env.development? || 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. diff --git a/test/test_helper.rb b/test/test_helper.rb index 0c22470ec..9f624cd58 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,15 +1,44 @@ ENV["RAILS_ENV"] ||= "test" +require "simplecov" + +SimpleCov.start "rails" do + enable_coverage :branch + add_filter %r{^/config/} + add_filter %r{^/db/} + add_filter %r{^/vendor/} + add_filter %r{^/bin/} + add_filter %r{^/test/} + add_filter %r{^/lib/tasks/} + add_group "Services", "app/services" + add_group "Channels", "app/channels" + minimum_coverage line: 90 +end + require_relative "../config/environment" require "rails/test_help" module ActiveSupport class TestCase - # Run tests in parallel with specified workers parallelize(workers: :number_of_processors) + parallelize_setup do |worker| + SimpleCov.command_name "#{SimpleCov.command_name}-#{worker}" + end + parallelize_teardown do |_worker| + SimpleCov.result + end - # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. fixtures :all - # Add more helper methods to be used by all tests here... + def sign_in_as(user, password: "password123") + post login_path, params: { email_address: user.email_address, password: password } + end + end +end + +module ActionDispatch + class IntegrationTest + def sign_in_as(user, password: "password123") + post login_path, params: { email_address: user.email_address, password: password } + end end end From abe7b56ddbab98e24211cebd9ab2cc144001ad2e Mon Sep 17 00:00:00 2001 From: Bruno Schumacher Date: Thu, 10 Sep 2026 06:06:58 -0300 Subject: [PATCH 17/25] feat(frontend): configure Inertia React and Vite --- Gemfile | 47 ++----- Gemfile.lock | 79 +++++++++++- app/controllers/application_controller.rb | 25 ++-- app/frontend/components/Flash.tsx | 23 ++++ app/frontend/components/UserForm.tsx | 150 ++++++++++++++++++++++ app/frontend/entrypoints/application.tsx | 36 ++++-- app/frontend/entrypoints/ssr.tsx | 34 +++++ app/frontend/i18n.ts | 26 ++++ app/frontend/layouts/AppLayout.tsx | 45 +++++++ app/frontend/layouts/GuestLayout.tsx | 19 +++ app/frontend/types/actioncable.d.ts | 10 ++ app/frontend/types/index.ts | 42 ++++++ app/views/layouts/application.html.erb | 2 + config/initializers/inertia_rails.rb | 4 +- config/vite.json | 12 +- package-lock.json | 6 + package.json | 1 + vite.config.ts | 19 +-- 18 files changed, 509 insertions(+), 71 deletions(-) create mode 100644 app/frontend/components/Flash.tsx create mode 100644 app/frontend/components/UserForm.tsx create mode 100644 app/frontend/entrypoints/ssr.tsx create mode 100644 app/frontend/i18n.ts create mode 100644 app/frontend/layouts/AppLayout.tsx create mode 100644 app/frontend/layouts/GuestLayout.tsx create mode 100644 app/frontend/types/actioncable.d.ts create mode 100644 app/frontend/types/index.ts diff --git a/Gemfile b/Gemfile index 83b59b880..9ba2fa5c6 100644 --- a/Gemfile +++ b/Gemfile @@ -1,70 +1,43 @@ source "https://rubygems.org" -# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" +ruby "4.0.6" + 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 "sqlite3", ">= 2.1" 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" -# Build JSON APIs with ease [https://github.com/rails/jbuilder] gem "jbuilder" - -# 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 "bcrypt", "~> 3.1" 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 - -# Add HTTP asset caching/compression and X-Sendfile acceleration to Puma [https://github.com/basecamp/thruster/] +gem "kamal", require: false gem "thruster", require: false - -# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] gem "image_processing", "~> 1.2" +gem "inertia_rails", "~> 3.22" +gem "vite_rails", "~> 3.11" +gem "roo", "~> 3.0" +gem "rails-i18n", "~> 8.0" 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 group :test do - # Use system testing [https://guides.rubyonrails.org/testing.html#system-testing] gem "capybara" gem "selenium-webdriver" + gem "simplecov", require: false end - -gem "inertia_rails", "~> 3.22" -gem "roo", "~> 3.0" -gem "vite_rails", "~> 3.11" - -gem "bcrypt", "~> 3.1" -gem "json", "~> 2.21" \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock index b6bed59ea..b39448f1c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -80,6 +80,7 @@ GEM 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) bootsnap (1.26.0) @@ -107,8 +108,10 @@ GEM debug (1.11.1) irb (~> 1.10) reline (>= 0.3.8) + 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) @@ -118,6 +121,7 @@ GEM ffi (1.17.4-arm-linux-gnu) ffi (1.17.4-arm-linux-musl) ffi (1.17.4-arm64-darwin) + ffi (1.17.4-x64-mingw-ucrt) ffi (1.17.4-x86_64-darwin) ffi (1.17.4-x86_64-linux-gnu) ffi (1.17.4-x86_64-linux-musl) @@ -147,6 +151,17 @@ GEM actionview (>= 7.0.0) activesupport (>= 7.0.0) json (2.21.2) + kamal (2.12.0) + activesupport (>= 7.0) + base64 (~> 0.2) + bcrypt_pbkdf (~> 1.0) + concurrent-ruby (~> 1.2) + dotenv (~> 3.1) + ed25519 (~> 1.4) + net-ssh (~> 7.3) + sshkit (>= 1.23.0, < 2.0) + thor (~> 1.3) + zeitwerk (>= 2.6.18, < 3.0) language_server-protocol (3.17.0.6) lint_roller (1.1.0) logger (1.7.0) @@ -176,8 +191,13 @@ GEM 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) @@ -189,12 +209,15 @@ GEM racc (~> 1.4) nokogiri (1.19.4-arm64-darwin) racc (~> 1.4) + nokogiri (1.19.4-x64-mingw-ucrt) + racc (~> 1.4) nokogiri (1.19.4-x86_64-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) @@ -203,6 +226,7 @@ GEM pg (1.6.3-aarch64-linux) pg (1.6.3-aarch64-linux-musl) pg (1.6.3-arm64-darwin) + pg (1.6.3-x64-mingw-ucrt) pg (1.6.3-x86_64-darwin) pg (1.6.3-x86_64-linux) pg (1.6.3-x86_64-linux-musl) @@ -250,6 +274,9 @@ GEM rails-html-sanitizer (1.7.1) loofah (~> 2.25, >= 2.25.2) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + rails-i18n (8.1.0) + i18n (>= 0.7, < 2) + railties (>= 8.0.0, < 9) railties (8.1.3.1) actionpack (= 8.1.3.1) activesupport (= 8.1.3.1) @@ -320,6 +347,7 @@ GEM rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 4.0) websocket (~> 1.0) + simplecov (1.2.0) solid_cable (4.0.2) actioncable (>= 7.2) activejob (>= 7.2) @@ -336,6 +364,22 @@ GEM fugit (~> 1.11) railties (>= 7.1) thor (>= 1.3.1) + sqlite3 (2.9.6-aarch64-linux-gnu) + sqlite3 (2.9.6-aarch64-linux-musl) + sqlite3 (2.9.6-arm-linux-gnu) + sqlite3 (2.9.6-arm-linux-musl) + sqlite3 (2.9.6-arm64-darwin) + sqlite3 (2.9.6-x64-mingw-ucrt) + sqlite3 (2.9.6-x86_64-darwin) + sqlite3 (2.9.6-x86_64-linux-gnu) + sqlite3 (2.9.6-x86_64-linux-musl) + sshkit (1.25.1) + base64 + logger + net-scp (>= 1.1.2) + net-sftp (>= 2.1.2) + net-ssh (>= 2.8.0) + ostruct stimulus-rails (1.3.4) railties (>= 6.0.0) thor (1.5.0) @@ -351,6 +395,8 @@ GEM railties (>= 7.1.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) + tzinfo-data (1.2026.3) + tzinfo (>= 1.0.0) unicode-display_width (3.2.0) unicode-emoji (~> 4.1) unicode-emoji (4.2.0) @@ -385,6 +431,7 @@ PLATFORMS arm-linux-gnu arm-linux-musl arm64-darwin + x64-mingw-ucrt x86_64-darwin x86_64-linux x86_64-linux-gnu @@ -401,17 +448,20 @@ DEPENDENCIES importmap-rails inertia_rails (~> 3.22) jbuilder - json (~> 2.21) + kamal pg (~> 1.1) propshaft puma (>= 5.0) rails (~> 8.1.3, >= 8.1.3.1) + rails-i18n (~> 8.0) roo (~> 3.0) rubocop-rails-omakase selenium-webdriver + simplecov solid_cable solid_cache solid_queue + sqlite3 (>= 2.1) stimulus-rails thruster turbo-rails @@ -436,6 +486,7 @@ CHECKSUMS 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 bootsnap (1.26.0) sha256=ca96237015e6cd74a02963d5821cf00ac5ea134653b323e8cd6d702a7718bf1b @@ -449,8 +500,10 @@ CHECKSUMS csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456 date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6 + dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 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 @@ -459,6 +512,7 @@ CHECKSUMS 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-x64-mingw-ucrt) sha256=f6ff9618cfccc494138bddade27aa06c74c6c7bc367a1ea1103d80c2fcb9ed35 ffi (1.17.4-x86_64-darwin) sha256=aa70390523cf3235096cf64962b709b4cfbd5c082a2cb2ae714eb0fe2ccda496 ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d ffi (1.17.4-x86_64-linux-musl) sha256=3fdf9888483de005f8ef8d1cf2d3b20d86626af206cbf780f6a6a12439a9c49e @@ -472,6 +526,7 @@ CHECKSUMS irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 jbuilder (2.15.1) sha256=2430bec28fb0cebacb5875b1009cf9d8bc3c303ccb810c4c8b062a4b51457637 json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a + kamal (2.12.0) sha256=c51d1ab085e515470f98d0c0f043637122b5ebf76e8b610cb1fbbed0b7f9b8fa language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0 lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 @@ -487,22 +542,28 @@ CHECKSUMS net-imap (0.6.7) sha256=b5c9573be975d856de252ee851871724da66aa2d449b2482d5690bd12bc23660 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-x64-mingw-ucrt) sha256=051da97b8eccfdb5444fed40246a35e10d7298b9efe759b4cd25455ea04c587e nokogiri (1.19.4-x86_64-darwin) sha256=7fd17057d3e1f00e9954a74b3cd76595d3d4a5ef233b7ed9599047c204f70551 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-x64-mingw-ucrt) sha256=cdff974cbde6935e07b8f5cc3c2af7d320bc5263bde06acaae08302194ebf5e1 pg (1.6.3-x86_64-darwin) sha256=ee2e04a17c0627225054ffeb43e31a95be9d7e93abda2737ea3ce4a62f2729d6 pg (1.6.3-x86_64-linux) sha256=5d9e188c8f7a0295d162b7b88a768d8452a899977d44f3274d1946d67920ae8d pg (1.6.3-x86_64-linux-musl) sha256=9c9c90d98c72f78eb04c0f55e9618fe55d1512128e411035fe229ff427864009 @@ -522,6 +583,7 @@ CHECKSUMS rails (8.1.3.1) sha256=ccd11a36bfc171bf9c66d585d14c0ece91c0c9dde840aae60c0118d6f5c9c52a rails-dom-testing (2.3.0) sha256=8acc7953a7b911ca44588bf08737bc16719f431a1cc3091a292bca7317925c1d rails-html-sanitizer (1.7.1) sha256=e797a7c9b01e567307e317c576b49ab4168017e63eea4dba9ce3cb587e2f22c2 + rails-i18n (8.1.0) sha256=52d5fd6c0abef28d84223cc05647f6ae0fd552637a1ede92deee9545755b6cf3 railties (8.1.3.1) sha256=2388a232579a00cefea4487de66c8553c3408c1300abdc6cf1799d86ffb04487 rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 @@ -541,9 +603,20 @@ CHECKSUMS rubyzip (3.6.0) sha256=268994d44d62282d1cfd99bf10eae48d7267199158ad7ea3e1fee2da9458b695 securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 selenium-webdriver (4.49.0) sha256=b430f091a3cababb356b6f6132e0e4f385b017819355f563557e1537819edc89 + 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 + sqlite3 (2.9.6-aarch64-linux-gnu) sha256=d8b1f7d23efd7abac285775a9566562fc7debfef79d594e3a20354406fb7907c + sqlite3 (2.9.6-aarch64-linux-musl) sha256=3579e1c98cdc7ff5c3722847bb63ed4e1efb7ff675cb5e1e48ef2d4da5fb3bc9 + sqlite3 (2.9.6-arm-linux-gnu) sha256=33541500e3615da02afe54a9cc38b17a6985d3cf9d8b76d6d0a83002f114e7ec + sqlite3 (2.9.6-arm-linux-musl) sha256=c5490af48bb228fefa54314e9541375c3907e70f8109f3881b5ff97e1c93ae33 + sqlite3 (2.9.6-arm64-darwin) sha256=849b5d7f795e60fe25076d62c72dd722beb45b3850b516ad978d60ee848ec15b + sqlite3 (2.9.6-x64-mingw-ucrt) sha256=1f2b88f417fd0a8c1d5ef19c7e817d8b9c61bee6e33b6b36255fb6e40148e6f8 + sqlite3 (2.9.6-x86_64-darwin) sha256=b5842fea77781c14da03fa7bc0feb82db03a69e135affcb6f5399cbd2797a5f3 + sqlite3 (2.9.6-x86_64-linux-gnu) sha256=613188ce02f614126ddbc38c5e217ccffd6306d0dcd9adca9764547aa890a634 + sqlite3 (2.9.6-x86_64-linux-musl) sha256=d493b11818a3573387a1d56e1ee8fa00da23a683a7a1cc063e7a0feeed843abf + sshkit (1.25.1) sha256=be3f10b9d6eb0b44d5eaba3f7cbe41bc6bb894bce4339688ac20124391455b78 stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06 thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 thruster (0.1.26) sha256=6e45e807086b29d51404841bd1ad493b67cd95892fd65dc5afcdd32e82e94ce8 @@ -555,6 +628,7 @@ CHECKSUMS tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f turbo-rails (2.0.23) sha256=ee0d90733aafff056cf51ff11e803d65e43cae258cc55f6492020ec1f9f9315f tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b + tzinfo-data (1.2026.3) sha256=478fbc5356f13c1004cf8372b1336f3dad4055c96340fc4c881a3738da8cf7f9 unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 @@ -568,5 +642,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 diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index b79d76cf5..60b614048 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,25 +1,26 @@ class ApplicationController < ActionController::Base include Authentication + allow_browser versions: :modern + inertia_share do { auth: { - user: current_user ? { - id: current_user.id, - full_name: current_user.full_name, - email_address: current_user.email_address, - role: current_user.role, - avatar_url: current_user.avatar_url - } : nil - } + user: current_user&.to_props + }, + flash: { + notice: flash[:notice], + alert: flash[:alert] + }, + i18n: I18n.t("frontend") } end private def require_admin - unless current_user&.admin? - redirect_to profile_path, alert: "Acesso restrito para administradores." - end + return if current_user&.admin? + + redirect_to profile_path, alert: I18n.t("flashes.auth.admin_required") end -end \ No newline at end of file +end diff --git a/app/frontend/components/Flash.tsx b/app/frontend/components/Flash.tsx new file mode 100644 index 000000000..72c9413f2 --- /dev/null +++ b/app/frontend/components/Flash.tsx @@ -0,0 +1,23 @@ +import { usePage } from "@inertiajs/react" +import type { SharedProps } from "../types" + +export default function Flash() { + const { flash } = usePage().props + + if (!flash?.notice && !flash?.alert) return null + + return ( +
+ {flash.notice && ( +
+ {flash.notice} +
+ )} + {flash.alert && ( +
+ {flash.alert} +
+ )} +
+ ) +} diff --git a/app/frontend/components/UserForm.tsx b/app/frontend/components/UserForm.tsx new file mode 100644 index 000000000..f8fc256d1 --- /dev/null +++ b/app/frontend/components/UserForm.tsx @@ -0,0 +1,150 @@ +import type { FormEvent } from "react" +import { useT } from "../i18n" + +type FieldErrors = Record + +type Props = { + data: { + full_name: string + email_address: string + password: string + password_confirmation: string + role?: string + avatar_url: string + avatar: File | null + } + setData: (field: string, value: string | File | null) => void + onSubmit: (event: FormEvent) => void + processing: boolean + errors: FieldErrors + submitLabel: string + showRole?: boolean + requirePassword?: boolean +} + +function errorText(value: string | string[] | undefined) { + if (!value) return null + return Array.isArray(value) ? value.join(", ") : value +} + +export default function UserForm({ + data, + setData, + onSubmit, + processing, + errors, + submitLabel, + showRole = false, + requirePassword = false, +}: Props) { + const t = useT() + + return ( +
+
+ + setData("full_name", event.target.value)} + className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-200" + /> + {errorText(errors.full_name) &&

{errorText(errors.full_name)}

} +
+ +
+ + setData("email_address", event.target.value)} + className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-200" + /> + {errorText(errors.email_address) &&

{errorText(errors.email_address)}

} +
+ +
+
+ + setData("password", event.target.value)} + className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-200" + /> + {errorText(errors.password) &&

{errorText(errors.password)}

} +
+
+ + setData("password_confirmation", event.target.value)} + className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-200" + /> +
+
+ + {showRole && ( +
+ + + {errorText(errors.role) &&

{errorText(errors.role)}

} +
+ )} + +
+ + setData("avatar_url", event.target.value)} + className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-200" + /> + {errorText(errors.avatar_url) &&

{errorText(errors.avatar_url)}

} +
+ +
+ + setData("avatar", event.target.files?.[0] ?? null)} + className="mt-1 block w-full text-sm text-slate-500 file:mr-4 file:rounded-lg file:border-0 file:bg-indigo-50 file:px-4 file:py-2 file:font-semibold file:text-indigo-700 hover:file:bg-indigo-100" + /> + {errorText(errors.avatar) &&

{errorText(errors.avatar)}

} +
+ + +
+ ) +} diff --git a/app/frontend/entrypoints/application.tsx b/app/frontend/entrypoints/application.tsx index 77c568c12..8ffcc7115 100644 --- a/app/frontend/entrypoints/application.tsx +++ b/app/frontend/entrypoints/application.tsx @@ -1,18 +1,38 @@ -import './application.css' -import React from 'react' -import { createRoot } from 'react-dom/client' -import { createInertiaApp } from '@inertiajs/react' +import "./application.css" + +import { createInertiaApp } from "@inertiajs/react" +import { createElement, type ReactNode } from "react" +import { createRoot, hydrateRoot } from "react-dom/client" +import AppLayout from "../layouts/AppLayout" +import GuestLayout from "../layouts/GuestLayout" + +type PageModule = { + default: React.ComponentType & { + layout?: (page: ReactNode) => ReactNode + } +} + +const guestPages = new Set(["Auth/Login", "Auth/Register"]) createInertiaApp({ resolve: (name: string) => { - const pages = import.meta.glob('../pages/**/*.tsx', { eager: true }) + const pages = import.meta.glob("../pages/**/*.tsx", { eager: true }) const page = pages[`../pages/${name}.tsx`] if (!page) { - throw new Error(`Página não encontrada: ${name}`) + throw new Error(`Missing Inertia page: ${name}`) } + + page.default.layout ??= (pageNode: ReactNode) => + createElement(guestPages.has(name) ? GuestLayout : AppLayout, null, pageNode) + return page }, setup({ el, App, props }) { - createRoot(el).render() + const app = createElement(App, props) + if (el.hasChildNodes()) { + hydrateRoot(el, app) + } else { + createRoot(el).render(app) + } }, -}) \ No newline at end of file +}) diff --git a/app/frontend/entrypoints/ssr.tsx b/app/frontend/entrypoints/ssr.tsx new file mode 100644 index 000000000..8245c37fb --- /dev/null +++ b/app/frontend/entrypoints/ssr.tsx @@ -0,0 +1,34 @@ +import { createInertiaApp } from "@inertiajs/react" +import createServer from "@inertiajs/react/server" +import { createElement, type ReactNode } from "react" +import ReactDOMServer from "react-dom/server" +import AppLayout from "../layouts/AppLayout" +import GuestLayout from "../layouts/GuestLayout" + +type PageModule = { + default: React.ComponentType & { + layout?: (page: ReactNode) => ReactNode + } +} + +const guestPages = new Set(["Auth/Login", "Auth/Register"]) + +createServer((page) => + createInertiaApp({ + page, + render: ReactDOMServer.renderToString, + resolve: (name) => { + const pages = import.meta.glob("../pages/**/*.tsx", { eager: true }) + const pageModule = pages[`../pages/${name}.tsx`] + if (!pageModule) { + throw new Error(`Missing Inertia page: ${name}`) + } + + pageModule.default.layout ??= (pageNode: ReactNode) => + createElement(guestPages.has(name) ? GuestLayout : AppLayout, null, pageNode) + + return pageModule + }, + setup: ({ App, props }) => createElement(App, props), + }), +) diff --git a/app/frontend/i18n.ts b/app/frontend/i18n.ts new file mode 100644 index 000000000..48a95f82c --- /dev/null +++ b/app/frontend/i18n.ts @@ -0,0 +1,26 @@ +import { usePage } from "@inertiajs/react" +import type { SharedProps } from "./types" + +type Vars = Record + +function lookup(source: unknown, path: string): string { + const value = path.split(".").reduce((acc, key) => { + if (acc && typeof acc === "object" && key in (acc as object)) { + return (acc as Record)[key] + } + return undefined + }, source) + + return typeof value === "string" ? value : path +} + +function interpolate(template: string, vars?: Vars) { + if (!vars) return template + return template.replace(/%\{(\w+)\}/g, (_, key) => String(vars[key] ?? "")) +} + +export function useT() { + const { i18n } = usePage().props + + return (path: string, vars?: Vars) => interpolate(lookup(i18n, path), vars) +} diff --git a/app/frontend/layouts/AppLayout.tsx b/app/frontend/layouts/AppLayout.tsx new file mode 100644 index 000000000..bf51e4e55 --- /dev/null +++ b/app/frontend/layouts/AppLayout.tsx @@ -0,0 +1,45 @@ +import { Link, usePage } from "@inertiajs/react" +import type { PropsWithChildren } from "react" +import Flash from "../components/Flash" +import { useT } from "../i18n" +import type { SharedProps } from "../types" + +export default function AppLayout({ children }: PropsWithChildren) { + const { auth } = usePage().props + const t = useT() + const isAdmin = auth.user?.role === "admin" + + return ( +
+
+
+ + {t("brand")} + + +
+
+
+ + {children} +
+
+ ) +} diff --git a/app/frontend/layouts/GuestLayout.tsx b/app/frontend/layouts/GuestLayout.tsx new file mode 100644 index 000000000..8281e9943 --- /dev/null +++ b/app/frontend/layouts/GuestLayout.tsx @@ -0,0 +1,19 @@ +import type { PropsWithChildren } from "react" +import Flash from "../components/Flash" +import { useT } from "../i18n" + +export default function GuestLayout({ children }: PropsWithChildren) { + const t = useT() + + return ( +
+
+

+ {t("brand")} +

+ + {children} +
+
+ ) +} diff --git a/app/frontend/types/actioncable.d.ts b/app/frontend/types/actioncable.d.ts new file mode 100644 index 000000000..450728465 --- /dev/null +++ b/app/frontend/types/actioncable.d.ts @@ -0,0 +1,10 @@ +declare module "@rails/actioncable" { + export function createConsumer(url?: string): { + subscriptions: { + create: ( + channel: string | Record, + mixin?: Record, + ) => { unsubscribe: () => void } + } + } +} diff --git a/app/frontend/types/index.ts b/app/frontend/types/index.ts new file mode 100644 index 000000000..314ce54a5 --- /dev/null +++ b/app/frontend/types/index.ts @@ -0,0 +1,42 @@ +export type UserProps = { + id: number + full_name: string + email_address: string + role: "admin" | "member" | string + avatar_url: string +} + +export type StatsProps = { + total_users: number + role_counts: { + admin?: number + member?: number + } +} + +export type ImportProps = { + id: number + status: string + total: number + processed: number + successful: number + failed: number + percentage: number + errors: string[] +} + +export type AuthProps = { + user: UserProps | null +} + +export type FlashProps = { + notice?: string | null + alert?: string | null +} + +export type SharedProps = { + auth: AuthProps + flash: FlashProps + i18n: Record + errors?: Record +} diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 856c68681..1b4f4bf25 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -6,6 +6,8 @@ <%= csrf_meta_tags %> <%= csp_meta_tag %> + <%# Helper correto do Vite Rails para React Fast Refresh %> + <%= vite_react_refresh_tag %> <%= vite_client_tag %> <%= vite_typescript_tag 'application.tsx' %> diff --git a/config/initializers/inertia_rails.rb b/config/initializers/inertia_rails.rb index 4349da9f7..bd57adda4 100644 --- a/config/initializers/inertia_rails.rb +++ b/config/initializers/inertia_rails.rb @@ -1,9 +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 + config.ssr_enabled = ENV["INERTIA_SSR"] == "true" + config.ssr_url = ENV.fetch("INERTIA_SSR_URL", "http://127.0.0.1:13714") end diff --git a/config/vite.json b/config/vite.json index 476dcf667..819dca3af 100644 --- a/config/vite.json +++ b/config/vite.json @@ -1,17 +1,23 @@ { "all": { - "sourceCodeDir": "app/javascript", + "sourceCodeDir": "app/frontend", "watchAdditionalPaths": [] }, "development": { "autoBuild": true, - "skipProxy": true, "publicOutputDir": "vite-dev", + "host": "0.0.0.0", "port": 3036 }, "test": { "autoBuild": true, "publicOutputDir": "vite-test", "port": 3037 + }, + "production": { + "autoBuild": false, + "publicOutputDir": "vite", + "ssrBuildEnabled": true, + "ssrOutputDir": "ssr" } -} +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index bfed46487..78ae9c987 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": "^8.1.301", "@tailwindcss/forms": "^0.5.11", "@tailwindcss/typography": "^0.5.20", "@vitejs/plugin-react": "^6.1.1", @@ -120,6 +121,11 @@ "url": "https://github.com/sponsors/oxc-project" } }, + "node_modules/@rails/actioncable": { + "version": "8.1.301", + "resolved": "https://registry.npmjs.org/@rails/actioncable/-/actioncable-8.1.301.tgz", + "integrity": "sha512-n4Q6DfnqnZ604xgqeqURNyUmk6JtPoKf++UeV9agvecIkV3AcpgJs5D08Kx8WnZNgM7u4av9ezfIXv+CgdV94A==" + }, "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 3eb4f2c0a..0882a77d5 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "dependencies": { "@inertiajs/react": "^3.7.0", "@inertiajs/vite": "^3.7.0", + "@rails/actioncable": "^8.1.301", "@tailwindcss/forms": "^0.5.11", "@tailwindcss/typography": "^0.5.20", "@vitejs/plugin-react": "^6.1.1", diff --git a/vite.config.ts b/vite.config.ts index 87eda3ffe..10e9b1a10 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,8 +1,8 @@ -import inertia from '@inertiajs/vite' -import { defineConfig } from 'vite' -import RubyPlugin from 'vite-plugin-ruby' -import react from '@vitejs/plugin-react' -import tailwindcss from '@tailwindcss/vite' +import inertia from "@inertiajs/vite" +import { defineConfig } from "vite" +import RubyPlugin from "vite-plugin-ruby" +import react from "@vitejs/plugin-react" +import tailwindcss from "@tailwindcss/vite" export default defineConfig({ plugins: [ @@ -11,11 +11,14 @@ export default defineConfig({ tailwindcss(), inertia(), ], + server: { - host: '0.0.0.0', + host: "0.0.0.0", + port: 3036, + hmr: { - host: 'localhost', - port: 5173, + host: "localhost", + port: 3036, }, }, }) \ No newline at end of file From 1d44d6cd6c2bf1d46ecde0784fb47d36a56b84b9 Mon Sep 17 00:00:00 2001 From: Bruno Schumacher Date: Thu, 10 Sep 2026 06:07:04 -0300 Subject: [PATCH 18/25] feat(auth): implement authentication and user registration --- app/controllers/concerns/authentication.rb | 39 +++++-- app/controllers/profiles_controller.rb | 30 +++-- app/controllers/registrations_controller.rb | 14 ++- app/controllers/sessions_controller.rb | 25 ++-- app/frontend/pages/Auth/Login.tsx | 105 ++++++++--------- app/frontend/pages/Auth/Register.tsx | 49 ++++++++ app/frontend/pages/Profile/Show.tsx | 121 ++++++++------------ app/models/current.rb | 4 + app/models/session.rb | 2 +- config/routes.rb | 8 +- 10 files changed, 219 insertions(+), 178 deletions(-) create mode 100644 app/frontend/pages/Auth/Register.tsx create mode 100644 app/models/current.rb diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 80979819c..e9d6ecf79 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -3,12 +3,12 @@ module Authentication included do before_action :require_authentication - helper_method :authenticated? + helper_method :authenticated?, :current_user end class_methods do - def allow_unauthenticated_access(*args, **options) - skip_before_action :require_authentication, *args, **options + def allow_unauthenticated_access(...) + skip_before_action(:require_authentication, ...) end end @@ -18,6 +18,10 @@ def authenticated? resume_session end + def current_user + Current.user + end + def require_authentication resume_session || request_authentication end @@ -35,18 +39,33 @@ def request_authentication redirect_to login_path end + def after_authentication_url + session.delete(:return_to_after_authenticating).presence || default_url_after_login + end + + def default_url_after_login + current_user&.admin? ? admin_dashboard_path : profile_path + end + def start_new_session_for(user) - user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session| - cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax } + user.sessions.create!( + user_agent: request.user_agent, + ip_address: request.remote_ip + ).tap do |new_session| + Current.session = new_session + + cookies.signed.permanent[:session_id] = { + value: new_session.id, + httponly: true, + same_site: :lax, + secure: Rails.env.production? + } end end def terminate_session Current.session&.destroy cookies.delete(:session_id) + Current.session = nil end - - def current_user - Current.session&.user - end -end \ No newline at end of file +end diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index b52c611ed..97a998f1a 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -1,33 +1,31 @@ class ProfilesController < ApplicationController def show - render inertia: "Profile/Show", props: { - user: { - id: current_user.id, - full_name: current_user.full_name, - email_address: current_user.email_address, - role: current_user.role, - avatar_url: current_user.avatar_url - } - } + render inertia: "Profile/Show", props: { user: current_user.to_props } end def update if current_user.update(profile_params) - redirect_to profile_path, notice: "Perfil atualizado com sucesso!" + redirect_to profile_path, notice: I18n.t("flashes.profiles.updated") else - redirect_to profile_path, alert: current_user.errors.full_messages.to_sentence + redirect_to profile_path, inertia: { errors: current_user.errors }, alert: current_user.errors.full_messages.to_sentence end end def destroy - current_user.destroy - terminate_session - redirect_to register_path, notice: "Sua conta foi excluída permanentemente." + if current_user.destroy + terminate_session + redirect_to register_path, notice: I18n.t("flashes.profiles.deleted") + else + redirect_to profile_path, alert: current_user.errors.full_messages.to_sentence + end end private def profile_params - params.require(:user).permit(:full_name, :email_address, :password, :password_confirmation, :avatar) + permitted = params.expect(user: [ :full_name, :email_address, :password, :password_confirmation, :avatar, :avatar_url ]) + permitted.delete(:password) if permitted[:password].blank? + permitted.delete(:password_confirmation) if permitted[:password_confirmation].blank? + permitted end -end \ No newline at end of file +end diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 763d72faa..5962e74b3 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -2,24 +2,26 @@ class RegistrationsController < ApplicationController allow_unauthenticated_access only: %i[new create] def new + return redirect_to after_authentication_url if authenticated? + render inertia: "Auth/Register" end def create - user = User.new(user_params) + user = User.new(registration_params) user.role = :member if user.save start_new_session_for user - redirect_to profile_path, notice: "Conta criada com sucesso!" + redirect_to profile_path, notice: I18n.t("flashes.registrations.created") else - redirect_to register_path, alert: user.errors.full_messages.to_sentence + redirect_to register_path, inertia: { errors: user.errors }, alert: user.errors.full_messages.to_sentence end end private - def user_params - params.require(:user).permit(:full_name, :email_address, :password, :password_confirmation, :avatar) + def registration_params + params.expect(user: [ :full_name, :email_address, :password, :password_confirmation, :avatar, :avatar_url ]) end -end \ No newline at end of file +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index e1bbf889a..59fe3596d 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,31 +1,26 @@ class SessionsController < ApplicationController allow_unauthenticated_access only: %i[new create] + rate_limit to: 10, within: 3.minutes, only: :create, with: -> { + redirect_to login_path, alert: I18n.t("flashes.sessions.throttled") + } def new + return redirect_to after_authentication_url if authenticated? + render inertia: "Auth/Login" end def create - if user = User.authenticate_by(email_address: params[:email_address], password: params[:password]) + if (user = User.authenticate_by(email_address: params.expect(:email_address), password: params.expect(:password))) start_new_session_for user - redirect_after_login(user) + redirect_to after_authentication_url, notice: I18n.t("flashes.sessions.created") else - redirect_to login_path, alert: "E-mail ou senha inválidos." + redirect_to login_path, alert: I18n.t("flashes.sessions.invalid") end end def destroy terminate_session - redirect_to login_path, notice: "Sessão encerrada com sucesso." - end - - private - - def redirect_after_login(user) - if user.admin? - redirect_to admin_dashboard_path - else - redirect_to profile_path - end + redirect_to login_path, notice: I18n.t("flashes.sessions.destroyed") end -end \ No newline at end of file +end diff --git a/app/frontend/pages/Auth/Login.tsx b/app/frontend/pages/Auth/Login.tsx index 27ff33347..727406834 100644 --- a/app/frontend/pages/Auth/Login.tsx +++ b/app/frontend/pages/Auth/Login.tsx @@ -1,65 +1,66 @@ -import React from 'react' -import { useForm, Link } from '@inertiajs/react' +import { Link, useForm, usePage } from "@inertiajs/react" +import type { FormEvent } from "react" +import { useT } from "../../i18n" +import type { SharedProps } from "../../types" export default function Login() { + const { errors } = usePage().props + const t = useT() const { data, setData, post, processing } = useForm({ - email_address: '', - password: '', + email_address: "", + password: "", }) - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault() - post('/login') + const submit = (event: FormEvent) => { + event.preventDefault() + post("/login") } return ( -
-
+
+

{t("login.title")}

+

{t("login.subtitle")}

+ +
-

- Acesse sua conta -

+ + setData("email_address", event.target.value)} + className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-200" + />
- -
-
- - setData('email_address', e.target.value)} - /> -
-
- - setData('password', e.target.value)} - /> -
-
- - -
- -
- Não tem uma conta? - - Cadastre-se - +
+ + setData("password", event.target.value)} + className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-200" + />
-
+ {errors?.email_address &&

{String(errors.email_address)}

} + + + +

+ {t("login.new_here")}{" "} + + {t("login.create_account")} + +

) -} \ No newline at end of file +} diff --git a/app/frontend/pages/Auth/Register.tsx b/app/frontend/pages/Auth/Register.tsx new file mode 100644 index 000000000..040642f33 --- /dev/null +++ b/app/frontend/pages/Auth/Register.tsx @@ -0,0 +1,49 @@ +import { Link, useForm, usePage } from "@inertiajs/react" +import type { FormEvent } from "react" +import UserForm from "../../components/UserForm" +import { useT } from "../../i18n" +import type { SharedProps } from "../../types" + +export default function Register() { + const { errors } = usePage().props + const t = useT() + const { data, setData, post, processing } = useForm({ + user: { + full_name: "", + email_address: "", + password: "", + password_confirmation: "", + avatar_url: "", + avatar: null as File | null, + }, + }) + + const submit = (event: FormEvent) => { + event.preventDefault() + post("/register", { forceFormData: true }) + } + + return ( +
+

{t("register.title")}

+

{t("register.subtitle")}

+
+ setData(`user.${field}` as never, value as never)} + onSubmit={submit} + processing={processing} + errors={errors ?? {}} + submitLabel={t("forms.create_account")} + requirePassword + /> +
+

+ {t("register.already")}{" "} + + {t("register.sign_in")} + +

+
+ ) +} diff --git a/app/frontend/pages/Profile/Show.tsx b/app/frontend/pages/Profile/Show.tsx index 777fd6b4e..5fd0729ab 100644 --- a/app/frontend/pages/Profile/Show.tsx +++ b/app/frontend/pages/Profile/Show.tsx @@ -1,90 +1,63 @@ -import React from 'react' -import { useForm, router, Link } from '@inertiajs/react' - -interface UserProps { - id: number - full_name: string - email_address: string - role: string - avatar_url: string -} +import { router, useForm, usePage } from "@inertiajs/react" +import type { FormEvent } from "react" +import UserForm from "../../components/UserForm" +import { useT } from "../../i18n" +import type { SharedProps, UserProps } from "../../types" export default function Show({ user }: { user: UserProps }) { + const { errors } = usePage().props + const t = useT() const { data, setData, post, processing } = useForm({ - _method: 'patch', - full_name: user.full_name, - email_address: user.email_address, - avatar: null as File | null, + _method: "patch", + user: { + full_name: user.full_name, + email_address: user.email_address, + password: "", + password_confirmation: "", + avatar_url: "", + avatar: null as File | null, + }, }) - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault() - post('/profile') + const submit = (event: FormEvent) => { + event.preventDefault() + post("/profile", { forceFormData: true }) } - const handleDelete = () => { - if (confirm('Tem certeza que deseja excluir sua conta?')) { - router.delete('/profile') + const destroyProfile = () => { + if (confirm(t("profile.delete_confirm"))) { + router.delete("/profile") } } return ( -
-
-
- {user.full_name} +
+
+
+
-

{user.full_name}

-

{user.email_address} • {user.role}

+

{user.full_name}

+

+ {user.email_address} · {t(`roles.${user.role}`)} +

- - Sair - -
- -
-

Editar Meu Perfil

-
-
- - setData('full_name', e.target.value)} - className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm" - /> -
- -
- - setData('avatar', e.target.files ? e.target.files[0] : null)} - className="mt-1 block w-full text-sm text-gray-500" - /> -
- -
- - - -
-
-
+ + +
+

{t("profile.edit_title")}

+ setData(`user.${field}` as never, value as never)} + onSubmit={submit} + processing={processing} + errors={errors ?? {}} + submitLabel={t("forms.save_changes")} + /> + +
) -} \ No newline at end of file +} 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 index 4825ef924..cf376fb28 100644 --- a/app/models/session.rb +++ b/app/models/session.rb @@ -1,3 +1,3 @@ class Session < ApplicationRecord belongs_to :user -end \ No newline at end of file +end diff --git a/config/routes.rb b/config/routes.rb index 828dd4211..bdcb2c17f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,10 +1,10 @@ Rails.application.routes.draw do + get "up" => "rails/health#show", as: :rails_health_check - # 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 - get 'inertia-example', to: 'inertia_example#index' + root to: redirect("/login") get "login", to: "sessions#new", as: :login @@ -19,6 +19,6 @@ namespace :admin do get "dashboard", to: "dashboard#index" resources :users - resources :user_imports, only: %i[create] + resources :user_imports, only: %i[create show] end -end \ No newline at end of file +end From 4dd02a9ee96e592b1b3c2252928c5a5a55f904ae Mon Sep 17 00:00:00 2001 From: Bruno Schumacher Date: Thu, 10 Sep 2026 06:07:10 -0300 Subject: [PATCH 19/25] feat(admin): implement user management dashboard --- app/controllers/admin/base_controller.rb | 5 + app/controllers/admin/dashboard_controller.rb | 22 +- .../admin/user_imports_controller.rb | 21 +- app/controllers/admin/users_controller.rb | 53 +++- app/frontend/pages/Admin/Dashboard.tsx | 262 +++++++++++------- app/frontend/pages/Admin/Users/Edit.tsx | 42 +++ app/frontend/pages/Admin/Users/New.tsx | 42 +++ 7 files changed, 316 insertions(+), 131 deletions(-) create mode 100644 app/controllers/admin/base_controller.rb create mode 100644 app/frontend/pages/Admin/Users/Edit.tsx create mode 100644 app/frontend/pages/Admin/Users/New.tsx diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb new file mode 100644 index 000000000..0e099c706 --- /dev/null +++ b/app/controllers/admin/base_controller.rb @@ -0,0 +1,5 @@ +module Admin + class BaseController < ApplicationController + before_action :require_admin + end +end diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb index 9ba864012..1c70faed6 100644 --- a/app/controllers/admin/dashboard_controller.rb +++ b/app/controllers/admin/dashboard_controller.rb @@ -1,23 +1,11 @@ module Admin - class DashboardController < ApplicationController - before_action :require_admin - + class DashboardController < BaseController def index render inertia: "Admin/Dashboard", props: { - stats: { - total_users: User.count, - role_counts: User.group(:role).count - }, - users: User.all.map { |u| - { - id: u.id, - full_name: u.full_name, - email_address: u.email_address, - role: u.role, - avatar_url: u.avatar_url - } - } + stats: Dashboard::Stats.call, + users: User.order(:full_name).map(&:to_props), + active_import: current_user.user_imports.where(status: %w[pending processing]).order(created_at: :desc).first&.to_props } end end -end \ No newline at end of file +end diff --git a/app/controllers/admin/user_imports_controller.rb b/app/controllers/admin/user_imports_controller.rb index 0adc0ade4..9db7a5364 100644 --- a/app/controllers/admin/user_imports_controller.rb +++ b/app/controllers/admin/user_imports_controller.rb @@ -1,17 +1,26 @@ module Admin - class UserImportsController < ApplicationController - before_action :require_admin + class UserImportsController < BaseController + def show + import = UserImport.find(params.expect(:id)) + render json: import.to_props + end def create user_import = current_user.user_imports.build - user_import.file.attach(params[:file]) + user_import.file.attach(file_param) if user_import.save ProcessUserImportJob.perform_later(user_import.id) - redirect_to admin_dashboard_path, notice: "Importação iniciada com sucesso!" + redirect_to admin_dashboard_path, notice: I18n.t("flashes.imports.started") else - redirect_to admin_dashboard_path, alert: "Erro ao anexar arquivo de planilha." + redirect_to admin_dashboard_path, alert: user_import.errors.full_messages.to_sentence end end + + private + + def file_param + params.expect(:file) + end end -end \ No newline at end of file +end diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 36701dc12..b5e66e69e 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -1,26 +1,57 @@ module Admin - class UsersController < ApplicationController - before_action :require_admin + class UsersController < BaseController + before_action :set_user, only: %i[edit update destroy] + + def index + redirect_to admin_dashboard_path + end + + def new + render inertia: "Admin/Users/New" + end + + def edit + render inertia: "Admin/Users/Edit", props: { user: @user.to_props } + end + + def create + user = User.new(user_params) + + if user.save + redirect_to admin_dashboard_path, notice: I18n.t("flashes.users.created") + else + redirect_to new_admin_user_path, inertia: { errors: user.errors }, alert: user.errors.full_messages.to_sentence + end + end def update - user = User.find(params[:id]) - if user.update(user_params) - redirect_to admin_dashboard_path, notice: "Usuário atualizado." + if @user.update(user_params) + redirect_to admin_dashboard_path, notice: I18n.t("flashes.users.updated") else - redirect_to admin_dashboard_path, alert: user.errors.full_messages.to_sentence + redirect_to edit_admin_user_path(@user), inertia: { errors: @user.errors }, + alert: @user.errors.full_messages.to_sentence end end def destroy - user = User.find(params[:id]) - user.destroy - redirect_to admin_dashboard_path, notice: "Usuário removido." + if @user.destroy + redirect_to admin_dashboard_path, notice: I18n.t("flashes.users.deleted") + else + redirect_to admin_dashboard_path, alert: @user.errors.full_messages.to_sentence + end end private + def set_user + @user = User.find(params.expect(:id)) + end + def user_params - params.require(:user).permit(:full_name, :email_address, :role) + permitted = params.expect(user: [ :full_name, :email_address, :password, :password_confirmation, :role, :avatar, :avatar_url ]) + permitted.delete(:password) if permitted[:password].blank? + permitted.delete(:password_confirmation) if permitted[:password_confirmation].blank? + permitted end end -end \ No newline at end of file +end diff --git a/app/frontend/pages/Admin/Dashboard.tsx b/app/frontend/pages/Admin/Dashboard.tsx index 5ea589f4d..5592b8b81 100644 --- a/app/frontend/pages/Admin/Dashboard.tsx +++ b/app/frontend/pages/Admin/Dashboard.tsx @@ -1,129 +1,197 @@ -import React, { useEffect, useState } from 'react' -import { useForm, router, Link } from '@inertiajs/react' - -interface UserProps { - id: number - full_name: string - email_address: string - role: string - avatar_url: string -} +import { Link, router, useForm } from "@inertiajs/react" +import { useEffect, useState, type FormEvent } from "react" +import consumer from "../../channels/consumer" +import { useT } from "../../i18n" +import type { ImportProps, StatsProps, UserProps } from "../../types" -interface StatsProps { - total_users: number - role_counts: { - admin?: number - member?: number - } +type Props = { + stats: StatsProps + users: UserProps[] + active_import: ImportProps | null } -export default function Dashboard({ stats, users }: { stats: StatsProps, users: UserProps[] }) { +export default function Dashboard({ stats, users, active_import }: Props) { + const t = useT() + const [liveStats, setLiveStats] = useState(stats) + const [importState, setImportState] = useState(active_import) const { setData, post, processing } = useForm({ file: null as File | null }) - const handleFileUpload = (e: React.FormEvent) => { - e.preventDefault() - post('/admin/user_imports') + useEffect(() => { + setLiveStats(stats) + }, [stats]) + + useEffect(() => { + const subscription = consumer.subscriptions.create("DashboardChannel", { + received(payload: StatsProps) { + setLiveStats(payload) + }, + }) + + return () => subscription.unsubscribe() + }, []) + + useEffect(() => { + if (!importState?.id || importState.status === "completed" || importState.status === "failed") return + + const subscription = consumer.subscriptions.create( + { channel: "ImportProgressChannel", id: importState.id }, + { + received(payload: ImportProps) { + setImportState(payload) + if (payload.status === "completed" || payload.status === "failed") { + router.reload({ only: ["users", "stats", "active_import"] }) + } + }, + }, + ) + + return () => subscription.unsubscribe() + }, [importState?.id, importState?.status]) + + const submitImport = (event: FormEvent) => { + event.preventDefault() + post("/admin/user_imports", { forceFormData: true }) } - const handleRoleToggle = (userId: number, currentRole: string) => { - const newRole = currentRole === 'admin' ? 'member' : 'admin' - router.patch(`/admin/users/${userId}`, { user: { role: newRole } }) + const toggleRole = (user: UserProps) => { + const role = user.role === "admin" ? "member" : "admin" + router.patch(`/admin/users/${user.id}`, { user: { role } }) } - const handleDeleteUser = (userId: number) => { - if (confirm('Deseja realmente remover este usuário?')) { - router.delete(`/admin/users/${userId}`) + const deleteUser = (user: UserProps) => { + if (confirm(t("dashboard.delete_confirm", { name: user.full_name }))) { + router.delete(`/admin/users/${user.id}`) } } + const importStatusLabel = (status: string) => t(`import_status.${status}`) + return ( -
-
-

Painel Administrativo

- - Sair +
+
+
+

{t("dashboard.title")}

+

{t("dashboard.subtitle")}

+
+ + {t("dashboard.create_user")}
-
-
-

Total de Usuários

-

{stats.total_users}

-
-
-

Administradores

-

{stats.role_counts?.admin || 0}

-
-
-

Membros

-

{stats.role_counts?.member || 0}

-
+
+ + +
-
-

Importar Usuários em Lote (.CSV / .XLSX)

-
+
+

{t("dashboard.import_title")}

+

{t("dashboard.import_help")}

+ setData('file', e.target.files ? e.target.files[0] : null)} - className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100" + accept=".csv,.xlsx" + required + onChange={(event) => setData("file", event.target.files?.[0] ?? null)} + className="block w-full text-sm text-slate-500 file:mr-4 file:rounded-lg file:border-0 file:bg-indigo-50 file:px-4 file:py-2 file:font-semibold file:text-indigo-700 hover:file:bg-indigo-100" /> -
-
-
-

Lista de Usuários Cadastrados

+ {importState && ( +
+
+ {t("dashboard.status", { status: importStatusLabel(importState.status) })} + {importState.percentage}% +
+
+
+
+

+ {t("dashboard.progress", { + processed: importState.processed, + total: importState.total, + successful: importState.successful, + failed: importState.failed, + })} +

+ {importState.errors?.length > 0 && ( +
    + {importState.errors.slice(0, 8).map((message) => ( +
  • {message}
  • + ))} +
+ )} +
+ )} + + +
+
+

{t("dashboard.users")}

- - - - - - - - - - - {users.map((u) => ( - - - - - +
+
UsuárioE-mailFunção (Role)Ações
- {u.full_name} - {u.full_name} - {u.email_address} - - {u.role} - - - - -
+ + + + + + - ))} - -
{t("dashboard.user")}{t("dashboard.email")}{t("dashboard.role")}{t("dashboard.actions")}
-
+ + + {users.map((user) => ( + + +
+ + {user.full_name} +
+ + {user.email_address} + + + {t(`roles.${user.role}`)} + + + +
+ + {t("dashboard.edit")} + + + +
+ + + ))} + + +
+
) -} \ No newline at end of file +} + +function StatCard({ label, value, accent }: { label: string; value: number; accent: string }) { + return ( +
+

{label}

+

{value}

+
+ ) +} diff --git a/app/frontend/pages/Admin/Users/Edit.tsx b/app/frontend/pages/Admin/Users/Edit.tsx new file mode 100644 index 000000000..b07930c1c --- /dev/null +++ b/app/frontend/pages/Admin/Users/Edit.tsx @@ -0,0 +1,42 @@ +import { useForm, usePage } from "@inertiajs/react" +import type { FormEvent } from "react" +import UserForm from "../../../components/UserForm" +import { useT } from "../../../i18n" +import type { SharedProps, UserProps } from "../../../types" + +export default function Edit({ user }: { user: UserProps }) { + const { errors } = usePage().props + const t = useT() + const { data, setData, post, processing } = useForm({ + _method: "patch", + user: { + full_name: user.full_name, + email_address: user.email_address, + password: "", + password_confirmation: "", + role: user.role, + avatar_url: "", + avatar: null as File | null, + }, + }) + + const submit = (event: FormEvent) => { + event.preventDefault() + post(`/admin/users/${user.id}`, { forceFormData: true }) + } + + return ( +
+

{t("users.edit_title", { name: user.full_name })}

+ setData(`user.${field}` as never, value as never)} + onSubmit={submit} + processing={processing} + errors={errors ?? {}} + submitLabel={t("forms.save_user")} + showRole + /> +
+ ) +} diff --git a/app/frontend/pages/Admin/Users/New.tsx b/app/frontend/pages/Admin/Users/New.tsx new file mode 100644 index 000000000..75c29b839 --- /dev/null +++ b/app/frontend/pages/Admin/Users/New.tsx @@ -0,0 +1,42 @@ +import { useForm, usePage } from "@inertiajs/react" +import type { FormEvent } from "react" +import UserForm from "../../../components/UserForm" +import { useT } from "../../../i18n" +import type { SharedProps } from "../../../types" + +export default function New() { + const { errors } = usePage().props + const t = useT() + const { data, setData, post, processing } = useForm({ + user: { + full_name: "", + email_address: "", + password: "", + password_confirmation: "", + role: "member", + avatar_url: "", + avatar: null as File | null, + }, + }) + + const submit = (event: FormEvent) => { + event.preventDefault() + post("/admin/users", { forceFormData: true }) + } + + return ( +
+

{t("users.new_title")}

+ setData(`user.${field}` as never, value as never)} + onSubmit={submit} + processing={processing} + errors={errors ?? {}} + submitLabel={t("forms.create_user")} + showRole + requirePassword + /> +
+ ) +} From 0ebb7c02d2209f1584a9e2e5fc87571a9ca7726f Mon Sep 17 00:00:00 2001 From: Bruno Schumacher Date: Thu, 10 Sep 2026 06:07:16 -0300 Subject: [PATCH 20/25] feat(imports): implement asynchronous user import --- app/jobs/process_user_import_job.rb | 56 +---------- app/models/user_import.rb | 43 ++++++++- app/services/dashboard/broadcaster.rb | 13 +++ app/services/dashboard/stats.rb | 14 +++ app/services/user_imports/processor.rb | 98 ++++++++++++++++++++ db/samples/users.csv | 3 + test/jobs/process_user_import_job_test.rb | 33 +++++++ test/models/user_import_test.rb | 31 +++++++ test/services/dashboard/stats_test.rb | 13 +++ test/services/user_imports/processor_test.rb | 50 ++++++++++ 10 files changed, 302 insertions(+), 52 deletions(-) create mode 100644 app/services/dashboard/broadcaster.rb create mode 100644 app/services/dashboard/stats.rb create mode 100644 app/services/user_imports/processor.rb create mode 100644 db/samples/users.csv create mode 100644 test/jobs/process_user_import_job_test.rb create mode 100644 test/models/user_import_test.rb create mode 100644 test/services/dashboard/stats_test.rb create mode 100644 test/services/user_imports/processor_test.rb diff --git a/app/jobs/process_user_import_job.rb b/app/jobs/process_user_import_job.rb index 95da5ffe0..f462df085 100644 --- a/app/jobs/process_user_import_job.rb +++ b/app/jobs/process_user_import_job.rb @@ -1,58 +1,12 @@ -require "roo" - class ProcessUserImportJob < ApplicationJob + # OptimizationRef: RB4-RM80-Solid queue_as :default + discard_on ActiveRecord::RecordNotFound def perform(user_import_id) import = UserImport.find(user_import_id) - return if import.status == "completed" - - import.update!(status: "processing") - file_path = ActiveStorage::Blob.service.path_for(import.file.key) - - spreadsheet = Roo::Spreadsheet.open(file_path) - sheet = spreadsheet.sheet(0) - - headers = sheet.row(1).map(&:to_s).map(&:downcase) - total_rows = sheet.last_row - 1 - import.update!(total_rows: total_rows) - - (2..sheet.last_row).each_with_index do |row_index, i| - row = Hash[[headers, sheet.row(row_index)].transpose] - - user = User.new( - full_name: row["full_name"] || row["nome"], - email_address: row["email"] || row["email_address"], - password: SecureRandom.hex(10), - role: (row["role"].to_s.downcase == "admin") ? :admin : :member - ) - - if user.save - import.increment!(:successful_rows) - else - import.increment!(:failed_rows) - import.error_messages << "Linha #{row_index}: #{user.errors.full_messages.join(', ')}" - end - - import.update!(processed_rows: i + 1) - - # Transmite atualização em tempo real a cada 5 linhas ou no final - if (i + 1) % 5 == 0 || (i + 1) == total_rows - percentage = ((import.processed_rows.to_f / total_rows) * 100).round - ActionCable.server.broadcast("import_progress_#{import.id}", { - id: import.id, - status: import.status, - total: import.total_rows, - processed: import.processed_rows, - percentage: percentage - }) - end - end + return if import.completed? || import.failed? - import.update!(status: "completed") - ActionCable.server.broadcast("import_progress_#{import.id}", { - status: "completed", - errors: import.error_messages - }) + UserImports::Processor.call(import) end -end \ No newline at end of file +end diff --git a/app/models/user_import.rb b/app/models/user_import.rb index 523fe76a4..3348c9dbe 100644 --- a/app/models/user_import.rb +++ b/app/models/user_import.rb @@ -2,5 +2,46 @@ class UserImport < ApplicationRecord belongs_to :user has_one_attached :file + enum :status, { pending: "pending", processing: "processing", completed: "completed", failed: "failed" }, default: :pending + validates :file, presence: true -end \ No newline at end of file + validate :acceptable_spreadsheet + + after_commit :broadcast_progress, on: %i[create update] + + def progress_percentage + return 0 if total_rows.to_i.zero? + + ((processed_rows.to_f / total_rows) * 100).round + end + + def to_props + { + id: id, + status: status, + total: total_rows.to_i, + processed: processed_rows.to_i, + successful: successful_rows.to_i, + failed: failed_rows.to_i, + percentage: progress_percentage, + errors: Array(error_messages) + } + end + + private + + def acceptable_spreadsheet + return unless file.attached? + + extension = File.extname(file.filename.to_s).downcase + unless extension.in?(%w[.csv .xlsx]) + errors.add(:file, :invalid_type) + end + + errors.add(:file, :too_large) if file.byte_size > 10.megabytes + end + + def broadcast_progress + Dashboard::Broadcaster.import_progress(self) + end +end diff --git a/app/services/dashboard/broadcaster.rb b/app/services/dashboard/broadcaster.rb new file mode 100644 index 000000000..3acf8dd17 --- /dev/null +++ b/app/services/dashboard/broadcaster.rb @@ -0,0 +1,13 @@ +module Dashboard + class Broadcaster + STATS_STREAM = "admin_dashboard" + + def self.stats + ActionCable.server.broadcast(STATS_STREAM, Dashboard::Stats.call) + end + + def self.import_progress(user_import) + ActionCable.server.broadcast("import_progress_#{user_import.id}", user_import.to_props) + end + end +end diff --git a/app/services/dashboard/stats.rb b/app/services/dashboard/stats.rb new file mode 100644 index 000000000..78bb22713 --- /dev/null +++ b/app/services/dashboard/stats.rb @@ -0,0 +1,14 @@ +module Dashboard + class Stats + def self.call + counts = User.group(:role).count + { + total_users: User.count, + role_counts: { + "admin" => counts["admin"].to_i, + "member" => counts["member"].to_i + } + } + end + end +end diff --git a/app/services/user_imports/processor.rb b/app/services/user_imports/processor.rb new file mode 100644 index 000000000..cd9c82bcc --- /dev/null +++ b/app/services/user_imports/processor.rb @@ -0,0 +1,98 @@ +require "csv" +require "roo" + +module UserImports + class Processor + def initialize(user_import) + @user_import = user_import + end + + def self.call(user_import) + new(user_import).call + end + + def call + @user_import.processing! + rows = spreadsheet_rows + @user_import.update!( + total_rows: rows.size, + processed_rows: 0, + successful_rows: 0, + failed_rows: 0, + error_messages: [] + ) + + rows.each_with_index do |row, index| + import_row(row, index + 2) + @user_import.increment!(:processed_rows) + end + + @user_import.completed! + rescue StandardError => error + @user_import.update!( + status: :failed, + error_messages: Array(@user_import.error_messages) + [error.message] + ) + end + + private + + def spreadsheet_rows + @user_import.file.open do |file| + extension = File.extname(@user_import.file.filename.to_s).delete(".").downcase + if extension == "csv" + csv_rows(file.path) + else + excel_rows(file.path, extension) + end + end + end + + def csv_rows(path) + table = CSV.read(path, headers: true, encoding: "bom|utf-8") + table.filter_map { |row| normalize_row(row.to_h) } + end + + def excel_rows(path, extension) + spreadsheet = Roo::Spreadsheet.open(path, extension: extension) + sheet = spreadsheet.sheet(0) + headers = sheet.row(1).map { |header| header.to_s.strip.downcase } + (2..sheet.last_row).filter_map do |row_index| + normalize_row(headers.zip(sheet.row(row_index)).to_h) + end + end + + def normalize_row(row) + normalized = row.stringify_keys.transform_keys { |key| key.to_s.strip.downcase } + return if normalized.values.all? { |value| value.blank? } + + normalized + end + + def import_row(row, line_number) + user = User.new( + full_name: row["full_name"] || row["name"] || row["nome"], + email_address: row["email"] || row["email_address"], + password: SecureRandom.hex(8), + role: normalized_role(row["role"] || row["perfil"]), + avatar_url: row["avatar_url"] || row["avatar"] + ) + + if user.save + @user_import.increment!(:successful_rows) + else + @user_import.increment!(:failed_rows) + append_error(I18n.t("imports.row_error", line: line_number, messages: user.errors.full_messages.to_sentence)) + end + end + + def normalized_role(value) + %w[admin administrador administradora].include?(value.to_s.strip.downcase) ? :admin : :member + end + + def append_error(message) + messages = Array(@user_import.error_messages) + [message] + @user_import.update!(error_messages: messages) + end + end +end diff --git a/db/samples/users.csv b/db/samples/users.csv new file mode 100644 index 000000000..7aed9bd0b --- /dev/null +++ b/db/samples/users.csv @@ -0,0 +1,3 @@ +full_name,email,role,avatar_url +Imported One,imported.one@example.com,member,https://example.com/one.png +Imported Admin,imported.admin@example.com,admin,https://example.com/admin.png diff --git a/test/jobs/process_user_import_job_test.rb b/test/jobs/process_user_import_job_test.rb new file mode 100644 index 000000000..633d00190 --- /dev/null +++ b/test/jobs/process_user_import_job_test.rb @@ -0,0 +1,33 @@ +require "test_helper" + +class ProcessUserImportJobTest < ActiveJob::TestCase + test "processes a pending import" do + import = users(:admin).user_imports.new + import.file.attach( + io: file_fixture("users.csv").open, + filename: "users.csv", + content_type: "text/csv" + ) + import.save! + + assert_difference("User.count", 2) do + ProcessUserImportJob.perform_now(import.id) + end + + assert import.reload.completed? + end + + test "skips imports that already finished" do + import = users(:admin).user_imports.new(status: :completed) + import.file.attach( + io: file_fixture("users.csv").open, + filename: "users.csv", + content_type: "text/csv" + ) + import.save! + + assert_no_difference("User.count") do + ProcessUserImportJob.perform_now(import.id) + end + end +end diff --git a/test/models/user_import_test.rb b/test/models/user_import_test.rb new file mode 100644 index 000000000..1f985c3b2 --- /dev/null +++ b/test/models/user_import_test.rb @@ -0,0 +1,31 @@ +require "test_helper" + +class UserImportTest < ActiveSupport::TestCase + test "requires a spreadsheet file" do + import = users(:admin).user_imports.new + assert_not import.valid? + assert_includes import.errors[:file], I18n.t("errors.messages.blank") + end + + test "rejects unsupported file types" do + import = users(:admin).user_imports.new + import.file.attach( + io: StringIO.new("not a spreadsheet"), + filename: "notes.txt", + content_type: "text/plain" + ) + + assert_not import.valid? + assert_includes import.errors[:file], I18n.t("activerecord.errors.models.user_import.attributes.file.invalid_type") + end + + test "computes progress percentage" do + import = users(:admin).user_imports.new(total_rows: 4, processed_rows: 1) + assert_equal 25, import.progress_percentage + end + + test "returns zero progress when there are no rows" do + import = users(:admin).user_imports.new(total_rows: 0, processed_rows: 0) + assert_equal 0, import.progress_percentage + end +end diff --git a/test/services/dashboard/stats_test.rb b/test/services/dashboard/stats_test.rb new file mode 100644 index 000000000..c57122e5e --- /dev/null +++ b/test/services/dashboard/stats_test.rb @@ -0,0 +1,13 @@ +require "test_helper" + +module Dashboard + class StatsTest < ActiveSupport::TestCase + test "counts users by role" do + stats = Dashboard::Stats.call + + assert_equal User.count, stats[:total_users] + assert_equal User.admin.count, stats[:role_counts]["admin"] + assert_equal User.member.count, stats[:role_counts]["member"] + end + end +end diff --git a/test/services/user_imports/processor_test.rb b/test/services/user_imports/processor_test.rb new file mode 100644 index 000000000..434f18844 --- /dev/null +++ b/test/services/user_imports/processor_test.rb @@ -0,0 +1,50 @@ +require "test_helper" + +module UserImports + class ProcessorTest < ActiveSupport::TestCase + test "imports valid rows and records validation failures" do + import = build_import("users.csv") + + assert_difference("User.count", 2) do + UserImports::Processor.call(import) + end + + import.reload + assert import.completed? + assert_equal 3, import.total_rows + assert_equal 2, import.successful_rows + assert_equal 1, import.failed_rows + assert User.exists?(email_address: "imported.one@example.com") + assert User.exists?(email_address: "imported.admin@example.com") + assert import.error_messages.any? { |message| message.include?("Linha 4") } + end + + test "marks the import as failed when the file cannot be parsed" do + import = users(:admin).user_imports.new + import.file.attach( + io: StringIO.new("\xFF\xD8not-csv"), + filename: "users.csv", + content_type: "text/csv" + ) + import.save! + + UserImports::Processor.call(import) + + assert import.reload.failed? + assert import.error_messages.present? + end + + private + + def build_import(filename) + import = users(:admin).user_imports.new + import.file.attach( + io: file_fixture(filename).open, + filename: filename, + content_type: "text/csv" + ) + import.save! + import + end + end +end From c22bb811b9970238a85e3f01af603ff4be99883f Mon Sep 17 00:00:00 2001 From: Bruno Schumacher Date: Thu, 10 Sep 2026 06:07:35 -0300 Subject: [PATCH 21/25] feat(db): update user model and database schema --- app/models/user.rb | 78 ++++++-- ...2000_add_avatar_url_and_import_defaults.rb | 23 +++ db/schema.rb | 173 +++++++++++++++++- db/seeds.rb | 28 +-- test/fixtures/users.yml | 19 ++ test/models/user_test.rb | 97 ++++++++++ 6 files changed, 386 insertions(+), 32 deletions(-) create mode 100644 db/migrate/20260910072000_add_avatar_url_and_import_defaults.rb create mode 100644 test/fixtures/users.yml create mode 100644 test/models/user_test.rb diff --git a/app/models/user.rb b/app/models/user.rb index b5a9c33e2..ffa40c82b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,32 +1,86 @@ class User < ApplicationRecord + EMAIL_FORMAT = URI::MailTo::EMAIL_REGEXP + REMOTE_URL_FORMAT = /\Ahttps?:\/\/.+\z/i + has_secure_password has_one_attached :avatar - enum :role, { member: "member", admin: "admin" }, default: :member + enum :role, { member: "member", admin: "admin" }, default: :member, validate: true has_many :sessions, dependent: :destroy has_many :user_imports, dependent: :destroy + encrypts :email_address, deterministic: true, downcase: true + + normalizes :email_address, with: ->(email) { email.strip.downcase } + normalizes :full_name, with: ->(name) { name.strip } + normalizes :avatar_url, with: ->(url) { url.presence&.strip } + validates :full_name, presence: true, length: { maximum: 100 } - validates :email_address, presence: true, uniqueness: { case_sensitive: false }, format: { with: URI::MailTo::EMAIL_REGEXP } - validates :password, presence: true, length: { minimum: 8 }, if: -> { new_record? || changes[:password_digest] } + validates :email_address, presence: true, uniqueness: true, format: { with: EMAIL_FORMAT } + validates :password, length: { minimum: 8 }, allow_nil: true + validates :avatar_url, format: { with: REMOTE_URL_FORMAT, allow_blank: true } + validate :acceptable_avatar + validate :must_keep_one_admin, on: :update - after_commit :broadcast_dashboard_stats, on: %i[create destroy update] + before_destroy :prevent_destroying_last_admin + + after_commit :broadcast_dashboard_stats, on: %i[create update destroy] + + def self.dashboard_stats + Dashboard::Stats.call + end - def avatar_url + def avatar_image_url if avatar.attached? - Rails.application.routes.url_helpers.rails_blob_url(avatar, only_path: true) + Rails.application.routes.url_helpers.rails_blob_path(avatar, only_path: true) + elsif avatar_url.present? + avatar_url else - "https://ui-avatars.com/api/?name=#{CGI.escape(full_name)}&background=random" + "https://ui-avatars.com/api/?name=#{CGI.escape(full_name.to_s)}&background=4f46e5&color=fff" end end + def to_props + { + id: id, + full_name: full_name, + email_address: email_address, + role: role, + avatar_url: avatar_image_url + } + end + private + def acceptable_avatar + return unless avatar.attached? + + unless avatar.content_type.in?(%w[image/png image/jpeg image/jpg image/gif image/webp]) + errors.add(:avatar, :invalid_type) + end + + errors.add(:avatar, :too_large) if avatar.byte_size > 5.megabytes + end + + def must_keep_one_admin + return unless role_changed? && role_was == "admin" && member? && last_admin? + + errors.add(:role, :last_admin) + end + + def prevent_destroying_last_admin + return unless admin? && last_admin? + + errors.add(:base, :last_admin) + throw :abort + end + + def last_admin? + User.admin.where.not(id: id).none? + end + def broadcast_dashboard_stats - ActionCable.server.broadcast("admin_dashboard_channel", { - total_users: User.count, - role_counts: User.group(:role).count - }) + Dashboard::Broadcaster.stats end -end \ No newline at end of file +end diff --git a/db/migrate/20260910072000_add_avatar_url_and_import_defaults.rb b/db/migrate/20260910072000_add_avatar_url_and_import_defaults.rb new file mode 100644 index 000000000..1a8fada18 --- /dev/null +++ b/db/migrate/20260910072000_add_avatar_url_and_import_defaults.rb @@ -0,0 +1,23 @@ +class AddAvatarUrlAndImportDefaults < ActiveRecord::Migration[8.1] + def change + add_column :users, :avatar_url, :string + + change_column_default :user_imports, :status, from: nil, to: "pending" + change_column_default :user_imports, :total_rows, from: nil, to: 0 + change_column_default :user_imports, :processed_rows, from: nil, to: 0 + change_column_default :user_imports, :successful_rows, from: nil, to: 0 + change_column_default :user_imports, :failed_rows, from: nil, to: 0 + change_column_default :user_imports, :error_messages, from: nil, to: [] + + reversible do |direction| + direction.up do + UserImport.where(status: nil).update_all(status: "pending") + UserImport.where(total_rows: nil).update_all(total_rows: 0) + UserImport.where(processed_rows: nil).update_all(processed_rows: 0) + UserImport.where(successful_rows: nil).update_all(successful_rows: 0) + UserImport.where(failed_rows: nil).update_all(failed_rows: 0) + UserImport.where(error_messages: nil).update_all(error_messages: []) + end + end + end +end diff --git a/db/schema.rb b/db/schema.rb index e45ff3bc2..26474e940 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_10_063640) do +ActiveRecord::Schema[8.1].define(version: 2026_09_10_072000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -55,20 +55,171 @@ t.index ["user_id"], name: "index_sessions_on_user_id" end + 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.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 "created_at", null: false + 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.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.datetime "created_at", null: false + 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 "active_job_id" + 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.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.datetime "created_at", null: false + 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.text "metadata" + t.string "name", null: false + 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.integer "priority", default: 0, null: false + 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.datetime "run_at", null: false + 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.text "arguments" + 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.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.datetime "created_at", null: false + t.bigint "job_id", null: false + t.integer "priority", default: 0, null: false + t.string "queue_name", null: false + t.datetime "scheduled_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.datetime "created_at", null: false + t.datetime "expires_at", null: false + t.string "key", null: false + t.datetime "updated_at", null: false + 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 + create_table "user_imports", force: :cascade do |t| t.datetime "created_at", null: false - t.jsonb "error_messages" - t.integer "failed_rows" - t.integer "processed_rows" - t.string "status" - t.integer "successful_rows" - t.integer "total_rows" + t.jsonb "error_messages", default: [] + t.integer "failed_rows", default: 0 + t.integer "processed_rows", default: 0 + t.string "status", default: "pending" + t.integer "successful_rows", default: 0 + t.integer "total_rows", default: 0 t.datetime "updated_at", null: false t.bigint "user_id", null: false t.index ["user_id"], name: "index_user_imports_on_user_id" end create_table "users", force: :cascade do |t| + t.string "avatar_url" t.datetime "created_at", null: false t.string "email_address", null: false t.string "full_name", null: false @@ -81,5 +232,13 @@ add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "sessions", "users" + add_foreign_key "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 add_foreign_key "user_imports", "users" end diff --git a/db/seeds.rb b/db/seeds.rb index 1927ea3a5..2300e84b0 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,21 +1,23 @@ -User.destroy_all - -admin = User.create!( - full_name: "Admin Master", - email_address: "admin@admin.com", +admin = User.find_or_initialize_by(email_address: "admin@example.com") +admin.assign_attributes( + full_name: "Ada Admin", password: "password123", password_confirmation: "password123", - role: :admin + role: :admin, + avatar_url: "https://ui-avatars.com/api/?name=Ada+Admin&background=4f46e5&color=fff" ) +admin.save! -member = User.create!( - full_name: "Usuário Comum", - email_address: "user@user.com", +member = User.find_or_initialize_by(email_address: "user@example.com") +member.assign_attributes( + full_name: "Morgan Member", password: "password123", password_confirmation: "password123", - role: :member + role: :member, + avatar_url: "https://ui-avatars.com/api/?name=Morgan+Member&background=0f766e&color=fff" ) +member.save! -puts "Seeds executados com sucesso!" -puts "Admin: admin@admin.com | Senha: password123" -puts "User: user@user.com | Senha: password123" \ No newline at end of file +puts "Contas criadas:" +puts " Admin admin@example.com / password123" +puts " Usuário user@example.com / password123" diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml new file mode 100644 index 000000000..556acdc54 --- /dev/null +++ b/test/fixtures/users.yml @@ -0,0 +1,19 @@ +admin: + full_name: Ada Admin + email_address: admin@example.com + password_digest: <%= BCrypt::Password.create("password123", cost: 4) %> + role: admin + avatar_url: https://example.com/ada.png + +member: + full_name: Morgan Member + email_address: user@example.com + password_digest: <%= BCrypt::Password.create("password123", cost: 4) %> + role: member + avatar_url: https://example.com/morgan.png + +second_admin: + full_name: Second Admin + email_address: second-admin@example.com + password_digest: <%= BCrypt::Password.create("password123", cost: 4) %> + role: admin diff --git a/test/models/user_test.rb b/test/models/user_test.rb new file mode 100644 index 000000000..882498cd5 --- /dev/null +++ b/test/models/user_test.rb @@ -0,0 +1,97 @@ +require "test_helper" + +class UserTest < ActiveSupport::TestCase + setup do + @admin = users(:admin) + @member = users(:member) + end + + test "accepts a valid user" do + user = User.new( + full_name: "Casey New", + email_address: "casey@example.com", + password: "password123", + password_confirmation: "password123", + role: :member + ) + + assert user.valid? + end + + test "requires full name, email, and password" do + user = User.new + assert_not user.valid? + assert_includes user.errors[:full_name], I18n.t("errors.messages.blank") + assert_includes user.errors[:email_address], I18n.t("errors.messages.blank") + assert_includes user.errors[:password], I18n.t("errors.messages.blank") + end + + test "rejects invalid emails" do + user = User.new(full_name: "Casey", email_address: "not-an-email", password: "password123") + assert_not user.valid? + assert user.errors[:email_address].present? + end + + test "enforces unique emails" do + user = User.new( + full_name: "Copy", + email_address: @member.email_address, + password: "password123" + ) + + assert_not user.valid? + assert_includes user.errors[:email_address], I18n.t("errors.messages.taken") + end + + test "defaults new users to member" do + user = User.create!( + full_name: "Default Role", + email_address: "default.role@example.com", + password: "password123" + ) + + assert user.member? + end + + test "rejects invalid avatar urls" do + @member.avatar_url = "ftp://example.com/avatar.png" + assert_not @member.valid? + end + + test "prevents demoting the last remaining admin" do + users(:second_admin).destroy! + @admin.role = :member + + assert_not @admin.valid? + assert_includes @admin.errors[:role], I18n.t("activerecord.errors.models.user.attributes.role.last_admin") + end + + test "prevents deleting the last remaining admin" do + users(:second_admin).destroy! + + assert_no_difference("User.count") do + @admin.destroy + end + assert_includes @admin.errors[:base], I18n.t("activerecord.errors.models.user.attributes.base.last_admin") + end + + test "serializes props with an avatar fallback" do + user = User.create!( + full_name: "No Avatar", + email_address: "no.avatar@example.com", + password: "password123", + avatar_url: nil + ) + + assert_equal user.id, user.to_props[:id] + assert_includes user.avatar_image_url, "ui-avatars.com" + end + + test "encrypts email addresses at rest" do + raw = User.connection.select_value( + User.sanitize_sql_array(["SELECT email_address FROM users WHERE id = ?", @member.id]) + ) + + assert_not_equal @member.email_address, raw + end +end From 78443fc445e99e5b6e9c48109f6ed8d3516d7de3 Mon Sep 17 00:00:00 2001 From: Bruno Schumacher Date: Thu, 10 Sep 2026 06:07:53 -0300 Subject: [PATCH 22/25] test: add application and security test coverage --- test/application_system_test_case.rb | 12 ++++ test/channels/connection_test.rb | 14 +++++ test/channels/dashboard_channel_test.rb | 16 +++++ test/channels/import_progress_channel_test.rb | 33 ++++++++++ .../admin/dashboard_controller_test.rb | 27 ++++++++ .../admin/user_imports_controller_test.rb | 36 +++++++++++ .../admin/users_controller_test.rb | 63 +++++++++++++++++++ test/controllers/profiles_controller_test.rb | 46 ++++++++++++++ .../registrations_controller_test.rb | 53 ++++++++++++++++ test/controllers/sessions_controller_test.rb | 38 +++++++++++ test/fixtures/files/users.csv | 4 ++ test/fixtures/sessions.yml | 9 +++ test/integration/security_test.rb | 49 +++++++++++++++ test/system/admin_users_test.rb | 27 ++++++++ test/system/authentication_test.rb | 29 +++++++++ test/system/profile_test.rb | 11 ++++ 16 files changed, 467 insertions(+) create mode 100644 test/application_system_test_case.rb create mode 100644 test/channels/connection_test.rb create mode 100644 test/channels/dashboard_channel_test.rb create mode 100644 test/channels/import_progress_channel_test.rb create mode 100644 test/controllers/admin/dashboard_controller_test.rb create mode 100644 test/controllers/admin/user_imports_controller_test.rb create mode 100644 test/controllers/admin/users_controller_test.rb create mode 100644 test/controllers/profiles_controller_test.rb create mode 100644 test/controllers/registrations_controller_test.rb create mode 100644 test/controllers/sessions_controller_test.rb create mode 100644 test/fixtures/files/users.csv create mode 100644 test/fixtures/sessions.yml create mode 100644 test/integration/security_test.rb create mode 100644 test/system/admin_users_test.rb create mode 100644 test/system/authentication_test.rb create mode 100644 test/system/profile_test.rb diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb new file mode 100644 index 000000000..411b912df --- /dev/null +++ b/test/application_system_test_case.rb @@ -0,0 +1,12 @@ +require "test_helper" + +class ApplicationSystemTestCase < ActionDispatch::SystemTestCase + driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 1400 ] + + def sign_in(email, password = "password123") + visit login_path + fill_in "email_address", with: email + fill_in "password", with: password + click_button I18n.t("frontend.login.submit") + end +end diff --git a/test/channels/connection_test.rb b/test/channels/connection_test.rb new file mode 100644 index 000000000..2aa344d3e --- /dev/null +++ b/test/channels/connection_test.rb @@ -0,0 +1,14 @@ +require "test_helper" + +class ApplicationCable::ConnectionTest < ActionCable::Connection::TestCase + test "connects with a signed session cookie" do + cookies.signed[:session_id] = sessions(:admin).id + + connect + assert_equal users(:admin), connection.current_user + end + + test "rejects connections without a session" do + assert_reject_connection { connect } + end +end diff --git a/test/channels/dashboard_channel_test.rb b/test/channels/dashboard_channel_test.rb new file mode 100644 index 000000000..20d5ad487 --- /dev/null +++ b/test/channels/dashboard_channel_test.rb @@ -0,0 +1,16 @@ +require "test_helper" + +class DashboardChannelTest < ActionCable::Channel::TestCase + test "admins can subscribe" do + stub_connection current_user: users(:admin) + subscribe + assert subscription.confirmed? + assert_has_stream Dashboard::Broadcaster::STATS_STREAM + end + + test "members are rejected" do + stub_connection current_user: users(:member) + subscribe + assert subscription.rejected? + end +end diff --git a/test/channels/import_progress_channel_test.rb b/test/channels/import_progress_channel_test.rb new file mode 100644 index 000000000..7ee54fd1d --- /dev/null +++ b/test/channels/import_progress_channel_test.rb @@ -0,0 +1,33 @@ +require "test_helper" + +class ImportProgressChannelTest < ActionCable::Channel::TestCase + setup do + @import = users(:admin).user_imports.new + @import.file.attach( + io: file_fixture("users.csv").open, + filename: "users.csv", + content_type: "text/csv" + ) + @import.save! + end + + test "admins can subscribe to an import stream" do + stub_connection current_user: users(:admin) + subscribe id: @import.id + + assert subscription.confirmed? + assert_has_stream "import_progress_#{@import.id}" + end + + test "members are rejected" do + stub_connection current_user: users(:member) + subscribe id: @import.id + assert subscription.rejected? + end + + test "unknown imports are rejected" do + stub_connection current_user: users(:admin) + subscribe id: 0 + assert subscription.rejected? + end +end diff --git a/test/controllers/admin/dashboard_controller_test.rb b/test/controllers/admin/dashboard_controller_test.rb new file mode 100644 index 000000000..72bfb1ed2 --- /dev/null +++ b/test/controllers/admin/dashboard_controller_test.rb @@ -0,0 +1,27 @@ +require "test_helper" + +module Admin + class DashboardControllerTest < ActionDispatch::IntegrationTest + test "admins can open the dashboard" do + sign_in_as users(:admin) + get admin_dashboard_path + + assert_response :success + assert_equal "Admin/Dashboard", inertia.component + assert inertia.props[:stats][:total_users] >= 3 + assert inertia.props[:users].is_a?(Array) + end + + test "members are blocked from the dashboard" do + sign_in_as users(:member) + get admin_dashboard_path + + assert_redirected_to profile_path + end + + test "visitors are sent to login" do + get admin_dashboard_path + assert_redirected_to login_path + end + end +end diff --git a/test/controllers/admin/user_imports_controller_test.rb b/test/controllers/admin/user_imports_controller_test.rb new file mode 100644 index 000000000..5b5e7047f --- /dev/null +++ b/test/controllers/admin/user_imports_controller_test.rb @@ -0,0 +1,36 @@ +require "test_helper" + +module Admin + class UserImportsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as users(:admin) + end + + test "enqueues an import job" do + assert_enqueued_with(job: ProcessUserImportJob) do + post admin_user_imports_path, params: { + file: fixture_file_upload("users.csv", "text/csv") + } + end + + assert_redirected_to admin_dashboard_path + assert UserImport.last.pending? + end + + test "returns import props as json" do + import = users(:admin).user_imports.new + import.file.attach( + io: file_fixture("users.csv").open, + filename: "users.csv", + content_type: "text/csv" + ) + import.save! + + get admin_user_import_path(import) + assert_response :success + body = JSON.parse(response.body) + assert_equal import.id, body["id"] + assert_equal "pending", body["status"] + end + end +end diff --git a/test/controllers/admin/users_controller_test.rb b/test/controllers/admin/users_controller_test.rb new file mode 100644 index 000000000..617185eeb --- /dev/null +++ b/test/controllers/admin/users_controller_test.rb @@ -0,0 +1,63 @@ +require "test_helper" + +module Admin + class UsersControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as users(:admin) + end + + test "lists users through the dashboard" do + get admin_users_path + assert_redirected_to admin_dashboard_path + end + + test "creates a user" do + assert_difference("User.count", 1) do + post admin_users_path, params: { + user: { + full_name: "Created User", + email_address: "created@example.com", + password: "password123", + password_confirmation: "password123", + role: "member" + } + } + end + + assert_redirected_to admin_dashboard_path + end + + test "updates a user" do + patch admin_user_path(users(:member)), params: { + user: { + full_name: "Morgan Edited", + email_address: users(:member).email_address, + role: "member" + } + } + + assert_redirected_to admin_dashboard_path + assert_equal "Morgan Edited", users(:member).reload.full_name + end + + test "toggles a user role" do + patch admin_user_path(users(:member)), params: { user: { role: "admin" } } + + assert users(:member).reload.admin? + end + + test "deletes a member" do + assert_difference("User.count", -1) do + delete admin_user_path(users(:member)) + end + end + + test "members cannot manage users" do + delete logout_path + sign_in_as users(:member) + + get new_admin_user_path + assert_redirected_to profile_path + end + end +end diff --git a/test/controllers/profiles_controller_test.rb b/test/controllers/profiles_controller_test.rb new file mode 100644 index 000000000..e58a114fc --- /dev/null +++ b/test/controllers/profiles_controller_test.rb @@ -0,0 +1,46 @@ +require "test_helper" + +class ProfilesControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as users(:member) + end + + test "shows the signed-in user's profile" do + get profile_path + assert_response :success + assert_equal "Profile/Show", inertia.component + assert_equal users(:member).id, inertia.props.dig(:user, :id) + end + + test "updates the signed-in user's profile" do + patch profile_path, params: { + user: { + full_name: "Morgan Updated", + email_address: users(:member).email_address + } + } + + assert_redirected_to profile_path + assert_equal "Morgan Updated", users(:member).reload.full_name + end + + test "does not allow members to change their role" do + assert_raises ActionController::UnpermittedParameters do + patch profile_path, params: { + user: { + full_name: users(:member).full_name, + email_address: users(:member).email_address, + role: "admin" + } + } + end + end + + test "deletes the signed-in user's account" do + assert_difference("User.count", -1) do + delete profile_path + end + + assert_redirected_to register_path + end +end diff --git a/test/controllers/registrations_controller_test.rb b/test/controllers/registrations_controller_test.rb new file mode 100644 index 000000000..c61c7bf1b --- /dev/null +++ b/test/controllers/registrations_controller_test.rb @@ -0,0 +1,53 @@ +require "test_helper" + +class RegistrationsControllerTest < ActionDispatch::IntegrationTest + test "renders registration for visitors" do + get register_path + assert_response :success + assert_equal "Auth/Register", inertia.component + end + + test "creates a member account and signs the visitor in" do + assert_difference("User.count", 1) do + post register_path, params: { + user: { + full_name: "Visitor Person", + email_address: "visitor@example.com", + password: "password123", + password_confirmation: "password123" + } + } + end + + user = User.find_by(email_address: "visitor@example.com") + assert user.member? + assert_redirected_to profile_path + end + + test "does not allow visitors to register as admin" do + assert_raises ActionController::UnpermittedParameters do + post register_path, params: { + user: { + full_name: "Evil Visitor", + email_address: "evil@example.com", + password: "password123", + password_confirmation: "password123", + role: "admin" + } + } + end + end + + test "returns validation errors for duplicate emails" do + post register_path, params: { + user: { + full_name: "Dup", + email_address: users(:member).email_address, + password: "password123", + password_confirmation: "password123" + } + } + + assert_redirected_to register_path + end +end diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb new file mode 100644 index 000000000..703518369 --- /dev/null +++ b/test/controllers/sessions_controller_test.rb @@ -0,0 +1,38 @@ +require "test_helper" + +class SessionsControllerTest < ActionDispatch::IntegrationTest + test "renders the login page for visitors" do + get login_path + assert_response :success + assert_equal "Auth/Login", inertia.component + end + + test "redirects admins to the dashboard after login" do + post login_path, params: { email_address: users(:admin).email_address, password: "password123" } + + assert_redirected_to admin_dashboard_path + end + + test "redirects members to their profile after login" do + post login_path, params: { email_address: users(:member).email_address, password: "password123" } + + assert_redirected_to profile_path + end + + test "rejects invalid credentials" do + post login_path, params: { email_address: users(:admin).email_address, password: "wrong-password" } + + assert_redirected_to login_path + follow_redirect! + assert_equal I18n.t("flashes.sessions.invalid"), flash[:alert] + end + + test "signs the user out" do + sign_in_as users(:member) + delete logout_path + + assert_redirected_to login_path + get profile_path + assert_redirected_to login_path + end +end diff --git a/test/fixtures/files/users.csv b/test/fixtures/files/users.csv new file mode 100644 index 000000000..9f44027ce --- /dev/null +++ b/test/fixtures/files/users.csv @@ -0,0 +1,4 @@ +full_name,email,role,avatar_url +Imported One,imported.one@example.com,member,https://example.com/one.png +Imported Admin,imported.admin@example.com,admin,https://example.com/admin.png +Invalid User,not-an-email,member, diff --git a/test/fixtures/sessions.yml b/test/fixtures/sessions.yml new file mode 100644 index 000000000..cfdc4a96c --- /dev/null +++ b/test/fixtures/sessions.yml @@ -0,0 +1,9 @@ +admin: + user: admin + ip_address: 127.0.0.1 + user_agent: Rails Testing + +member: + user: member + ip_address: 127.0.0.1 + user_agent: Rails Testing diff --git a/test/integration/security_test.rb b/test/integration/security_test.rb new file mode 100644 index 000000000..599b34529 --- /dev/null +++ b/test/integration/security_test.rb @@ -0,0 +1,49 @@ +require "test_helper" + +class SecurityTest < ActionDispatch::IntegrationTest + test "escapes stored xss payloads in inertia props" do + payload = "" + users(:member).update!(full_name: payload) + sign_in_as users(:member) + get profile_path + + assert_no_match(%r{}, response.body) + end + + test "does not interpolate sql from user identifiers" do + sign_in_as users(:admin) + + assert_raises ActiveRecord::RecordNotFound do + get edit_admin_user_path("1 OR 1=1") + end + end + + test "rejects state-changing login without a csrf token" do + with_forgery_protection do + assert_raises ActionController::InvalidAuthenticityToken do + post login_path, params: { + email_address: users(:admin).email_address, + password: "password123" + } + end + end + end + + test "does not expose encrypted emails as plaintext in sql" do + raw = User.connection.select_value( + User.sanitize_sql_array(["SELECT email_address FROM users WHERE id = ?", users(:admin).id]) + ) + + assert_not_includes raw.to_s, "admin@example.com" + end + + private + + def with_forgery_protection + old = ActionController::Base.allow_forgery_protection + ActionController::Base.allow_forgery_protection = true + yield + ensure + ActionController::Base.allow_forgery_protection = old + end +end diff --git a/test/system/admin_users_test.rb b/test/system/admin_users_test.rb new file mode 100644 index 000000000..37dae1e97 --- /dev/null +++ b/test/system/admin_users_test.rb @@ -0,0 +1,27 @@ +require "application_system_test_case" + +class AdminUsersSystemTest < ApplicationSystemTestCase + setup do + sign_in "admin@example.com" + end + + test "admin can create a user from the dashboard" do + click_link I18n.t("frontend.dashboard.create_user") + fill_in "full_name", with: "Dashboard User" + fill_in "email_address", with: "dashboard.user@example.com" + fill_in "password", with: "password123" + fill_in "password_confirmation", with: "password123" + click_button I18n.t("frontend.forms.create_user") + + assert_text "Dashboard User" + assert_text "dashboard.user@example.com" + end + + test "admin can toggle a member role" do + within(:xpath, "//tr[contains(., 'Morgan Member')]") do + click_button I18n.t("frontend.dashboard.toggle_role") + end + + assert_text I18n.t("frontend.roles.admin") + end +end diff --git a/test/system/authentication_test.rb b/test/system/authentication_test.rb new file mode 100644 index 000000000..d7b578b38 --- /dev/null +++ b/test/system/authentication_test.rb @@ -0,0 +1,29 @@ +require "application_system_test_case" + +class AuthenticationSystemTest < ApplicationSystemTestCase + test "admin lands on the dashboard after login" do + sign_in "admin@example.com" + assert_text I18n.t("frontend.dashboard.title") + assert_text I18n.t("frontend.dashboard.total_users") + assert_text "Ada Admin" + end + + test "member lands on their profile after login" do + sign_in "user@example.com" + assert_text "Morgan Member" + assert_text I18n.t("frontend.profile.edit_title") + assert_no_text I18n.t("frontend.dashboard.title") + end + + test "visitor can register as a member" do + visit register_path + fill_in "full_name", with: "New Visitor" + fill_in "email_address", with: "new.visitor@example.com" + fill_in "password", with: "password123" + fill_in "password_confirmation", with: "password123" + click_button I18n.t("frontend.forms.create_account") + + assert_text "New Visitor" + assert_text I18n.t("frontend.profile.edit_title") + end +end diff --git a/test/system/profile_test.rb b/test/system/profile_test.rb new file mode 100644 index 000000000..3b05e50d9 --- /dev/null +++ b/test/system/profile_test.rb @@ -0,0 +1,11 @@ +require "application_system_test_case" + +class ProfileSystemTest < ApplicationSystemTestCase + test "member can update their own profile" do + sign_in "user@example.com" + fill_in "full_name", with: "Morgan Profile" + click_button I18n.t("frontend.forms.save_changes") + + assert_text "Morgan Profile" + end +end From cb9446ca37f9c235a9d257222a9e9c5b8b92ec1a Mon Sep 17 00:00:00 2001 From: Bruno Schumacher Date: Thu, 10 Sep 2026 06:07:59 -0300 Subject: [PATCH 23/25] docs(i18n): add Portuguese translations and documentation --- README.md | 24 ------ app/views/pwa/manifest.json.erb | 8 +- config/locales/en.yml | 145 +++++++++++++++++++++++++------- config/locales/pt-BR.yml | 139 ++++++++++++++++++++++++++++++ 4 files changed, 258 insertions(+), 58 deletions(-) create mode 100644 config/locales/pt-BR.yml diff --git a/README.md b/README.md index 7db80e4ca..e69de29bb 100644 --- a/README.md +++ b/README.md @@ -1,24 +0,0 @@ -# README - -This README would normally document whatever steps are necessary to get the -application up and running. - -Things you may want to cover: - -* Ruby version - -* System dependencies - -* Configuration - -* Database creation - -* Database initialization - -* How to run the test suite - -* Services (job queues, cache servers, search engines, etc.) - -* Deployment instructions - -* ... diff --git a/app/views/pwa/manifest.json.erb b/app/views/pwa/manifest.json.erb index b47e0ae81..d46b345cc 100644 --- a/app/views/pwa/manifest.json.erb +++ b/app/views/pwa/manifest.json.erb @@ -1,5 +1,5 @@ { - "name": "UserManagementApp", + "name": "Umanni Usuários", "icons": [ { "src": "/icon.png", @@ -16,7 +16,7 @@ "start_url": "/", "display": "standalone", "scope": "/", - "description": "UserManagementApp.", - "theme_color": "red", - "background_color": "red" + "description": "Aplicação de gestão de usuários.", + "theme_color": "#0f172a", + "background_color": "#f8fafc" } diff --git a/config/locales/en.yml b/config/locales/en.yml index 6c349ae5e..ffdbc506b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1,31 +1,116 @@ -# 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" + flashes: + auth: + admin_required: "Admin access required." + sessions: + created: "Signed in successfully." + destroyed: "Signed out successfully." + invalid: "Invalid email or password." + throttled: "Too many attempts. Please try again later." + registrations: + created: "Account created successfully." + profiles: + updated: "Profile updated successfully." + deleted: "Your account has been deleted." + users: + created: "User created." + updated: "User updated." + deleted: "User deleted." + imports: + started: "Import started." + + imports: + row_error: "Line %{line}: %{messages}" + + activerecord: + errors: + models: + user: + attributes: + avatar: + invalid_type: must be a PNG, JPEG, GIF, or WEBP image + too_large: must be smaller than 5MB + avatar_url: + invalid: must be a valid http(s) URL + role: + last_admin: cannot demote the last admin + base: + last_admin: cannot delete the last admin + user_import: + attributes: + file: + invalid_type: must be a CSV or XLSX spreadsheet + too_large: must be smaller than 10MB + + frontend: + brand: Umanni Users + nav: + dashboard: Dashboard + new_user: New user + profile: Profile + sign_out: Sign out + roles: + admin: Admin + member: Member + login: + title: Sign in + subtitle: Admins land on the dashboard. Members land on their profile. + email: Email + password: Password + submit: Sign in + submitting: Signing in... + new_here: New here? + create_account: Create an account + register: + title: Create your account + subtitle: Visitors register as members. Admins are assigned later. + already: Already registered? + sign_in: Sign in + profile: + edit_title: Edit your profile + delete: Delete my account + delete_confirm: Delete your account permanently? + dashboard: + title: Admin dashboard + subtitle: Live user totals, roles, and spreadsheet imports. + create_user: Create user + total_users: Total users + admins: Admins + members: Members + import_title: Import spreadsheet + import_help: "CSV or XLSX with columns: full_name, email, role, avatar_url." + start_import: Start import + uploading: Uploading... + status: "Status: %{status}" + progress: "%{processed}/%{total} processed · %{successful} created · %{failed} failed" + users: Users + user: User + email: Email + role: Role + actions: Actions + edit: Edit + toggle_role: Toggle role + delete: Delete + delete_confirm: "Delete %{name}?" + import_status: + pending: pending + processing: processing + completed: completed + failed: failed + users: + new_title: Create user + edit_title: "Edit %{name}" + forms: + full_name: Full name + email: Email + password: Password + password_optional: Password (optional) + password_confirmation: Confirm password + role: Role + avatar_url: Avatar URL + avatar_upload: Or upload an image + saving: Saving... + create_account: Create account + create_user: Create user + save_changes: Save changes + save_user: Save user diff --git a/config/locales/pt-BR.yml b/config/locales/pt-BR.yml new file mode 100644 index 000000000..8b55870c7 --- /dev/null +++ b/config/locales/pt-BR.yml @@ -0,0 +1,139 @@ +pt-BR: + flashes: + auth: + admin_required: "Acesso de administrador necessário." + sessions: + created: "Login realizado com sucesso." + destroyed: "Você saiu da conta." + invalid: "E-mail ou senha inválidos." + throttled: "Muitas tentativas. Tente novamente em instantes." + registrations: + created: "Conta criada com sucesso." + profiles: + updated: "Perfil atualizado com sucesso." + deleted: "Sua conta foi excluída." + users: + created: "Usuário criado." + updated: "Usuário atualizado." + deleted: "Usuário excluído." + imports: + started: "Importação iniciada." + + imports: + row_error: "Linha %{line}: %{messages}" + + activerecord: + models: + user: Usuário + user_import: Importação + attributes: + user: + full_name: Nome completo + email_address: E-mail + password: Senha + password_confirmation: Confirmação de senha + role: Perfil + avatar: Avatar + avatar_url: URL do avatar + user_import: + file: Planilha + errors: + models: + user: + attributes: + avatar: + invalid_type: deve ser uma imagem PNG, JPEG, GIF ou WEBP + too_large: deve ter menos de 5 MB + avatar_url: + invalid: deve ser uma URL http(s) válida + role: + last_admin: não é possível rebaixar o último administrador + base: + last_admin: não é possível excluir o último administrador + user_import: + attributes: + file: + invalid_type: deve ser uma planilha CSV ou XLSX + too_large: deve ter menos de 10 MB + + errors: + messages: + blank: não pode ficar em branco + taken: já está em uso + invalid: não é válido + confirmation: não confere com %{attribute} + too_short: + one: "é muito curto (mínimo: %{count} caractere)" + other: "é muito curto (mínimo: %{count} caracteres)" + frontend: + brand: Umanni Usuários + nav: + dashboard: Painel + new_user: Novo usuário + profile: Perfil + sign_out: Sair + roles: + admin: Administrador + member: Membro + login: + title: Entrar + subtitle: Administradores vão para o painel. Membros vão para o próprio perfil. + email: E-mail + password: Senha + submit: Entrar + submitting: Entrando... + new_here: Novo por aqui? + create_account: Criar uma conta + register: + title: Crie sua conta + subtitle: Visitantes se cadastram como membros. Administradores são definidos depois. + already: Já tem conta? + sign_in: Entrar + profile: + edit_title: Editar seu perfil + delete: Excluir minha conta + delete_confirm: Excluir sua conta permanentemente? + dashboard: + title: Painel administrativo + subtitle: Totais ao vivo, perfis e importação de planilhas. + create_user: Criar usuário + total_users: Total de usuários + admins: Administradores + members: Membros + import_title: Importar planilha + import_help: "CSV ou XLSX com colunas: full_name, email, role, avatar_url (ou nome, e-mail, perfil)." + start_import: Iniciar importação + uploading: Enviando... + status: "Status: %{status}" + progress: "%{processed}/%{total} processados · %{successful} criados · %{failed} falharam" + users: Usuários + user: Usuário + email: E-mail + role: Perfil + actions: Ações + edit: Editar + toggle_role: Alternar perfil + delete: Excluir + delete_confirm: "Excluir %{name}?" + import_status: + pending: pendente + processing: processando + completed: concluída + failed: falhou + users: + new_title: Criar usuário + edit_title: "Editar %{name}" + forms: + full_name: Nome completo + email: E-mail + password: Senha + password_optional: Senha (opcional) + password_confirmation: Confirmar senha + role: Perfil + avatar_url: URL do avatar + avatar_upload: Ou envie uma imagem + saving: Salvando... + create_account: Criar conta + create_user: Criar usuário + save_changes: Salvar alterações + save_user: Salvar usuário \ No newline at end of file From fe46cfcd936d77adfc89d0790bfaf6c8451c5a53 Mon Sep 17 00:00:00 2001 From: Bruno Schumacher Date: Thu, 10 Sep 2026 06:08:20 -0300 Subject: [PATCH 24/25] refactor(inertia): remove legacy Inertia example files --- app/controllers/inertia_controller.rb | 7 -- app/controllers/inertia_example_controller.rb | 12 --- .../pages/inertia_example/index.module.css | 102 ------------------ .../pages/inertia_example/index.tsx | 59 ---------- app/models/concerns/current.rb | 4 - 5 files changed, 184 deletions(-) delete mode 100644 app/controllers/inertia_controller.rb delete mode 100644 app/controllers/inertia_example_controller.rb delete mode 100644 app/javascript/pages/inertia_example/index.module.css delete mode 100644 app/javascript/pages/inertia_example/index.tsx delete mode 100644 app/models/concerns/current.rb diff --git a/app/controllers/inertia_controller.rb b/app/controllers/inertia_controller.rb deleted file mode 100644 index 2d86313af..000000000 --- a/app/controllers/inertia_controller.rb +++ /dev/null @@ -1,7 +0,0 @@ -# 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 deleted file mode 100644 index 3792b4df1..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/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/app/models/concerns/current.rb b/app/models/concerns/current.rb deleted file mode 100644 index 05e976afc..000000000 --- a/app/models/concerns/current.rb +++ /dev/null @@ -1,4 +0,0 @@ -class Current < ActiveSupport::CurrentAttributes - attribute :session - delegate :user, to: :session, allow_nil: true -end \ No newline at end of file From b21849d29ec2ad4f1339874f1671e3b2ef5c07bb Mon Sep 17 00:00:00 2001 From: Bruno Schumacher Date: Thu, 10 Sep 2026 06:08:25 -0300 Subject: [PATCH 25/25] chore: configure application channels and security --- .../modern-fullstack-developer.agent.md | 48 +++++++++++++++++ .gitignore | 5 ++ app/channels/application_cable/channel.rb | 4 ++ app/channels/application_cable/connection.rb | 16 ++++++ app/channels/dashboard_channel.rb | 8 +++ app/channels/import_progress_channel.rb | 11 ++++ app/frontend/channels/consumer.ts | 5 ++ app/frontend/entrypoints/application.css | 10 +++- .../initializers/content_security_policy.rb | 52 ++++++------------- 9 files changed, 123 insertions(+), 36 deletions(-) create mode 100644 .github/agents/modern-fullstack-developer.agent.md create mode 100644 app/channels/application_cable/channel.rb create mode 100644 app/channels/application_cable/connection.rb create mode 100644 app/channels/dashboard_channel.rb create mode 100644 app/channels/import_progress_channel.rb create mode 100644 app/frontend/channels/consumer.ts diff --git a/.github/agents/modern-fullstack-developer.agent.md b/.github/agents/modern-fullstack-developer.agent.md new file mode 100644 index 000000000..8096e7c48 --- /dev/null +++ b/.github/agents/modern-fullstack-developer.agent.md @@ -0,0 +1,48 @@ +--- +name: Modern Fullstack Developer +description: "Use for the Rails 8+ full-stack developer test: user administration, Rails authentication, ActiveStorage avatars, React/Inertia or Hotwire UI, Solid Queue imports, Solid Cable dashboard updates, Docker/Kamal delivery, testing, security, and README compliance." +tools: [read, edit, search, execute, todo] +reasoning-effort: high +argument-hint: "Describe the user-management feature, bug, test, or delivery requirement to implement." +user-invocable: true +--- + +You are the senior full-stack engineer responsible for completing the Umanni Modern Fullstack Developer Test in this repository. + +## Mission + +Deliver a production-minded user-management application for Rails 8+ and Ruby 4+, using the repository's existing architecture wherever it is sound. The application must support: + +- Admin and normal-user authorization with Rails' built-in authentication generator, not Devise. +- User CRUD for admins, role toggling, profile self-service, strict validation, and secure access boundaries. +- Avatar uploads or remote avatar URLs through the existing ActiveStorage setup. +- Asynchronous CSV/XLSX imports through Solid Queue, with live progress and status updates through Solid Cable and frontend state or streams. +- A responsive, accessible frontend using the stack already selected by the repository, with a modern CSS framework and useful validation feedback. +- Production-minded PostgreSQL, MySQL, or SQLite configuration, including WAL mode where SQLite is used. +- Multi-stage Docker, Thruster/Kamal-ready defaults, credentials-based configuration, and clean deployment documentation. + +## Working Rules + +- Before changing code, inspect the owning implementation, nearby tests, routes, and configuration. State one local hypothesis and one focused validation check internally, then make the smallest coherent edit. +- If the repository already chooses React/Inertia, Hotwire, Vite, or a CSS framework, follow that choice. Do not introduce a competing frontend architecture without a concrete requirement. +- Prefer Rails conventions, service objects already present in the repository, policy/authorization boundaries, strong parameters, database constraints, and transactional writes over ad hoc controller logic. +- Keep imports idempotent where practical, validate rows before persistence, report failures clearly, and never expose another user's data through progress or profile endpoints. +- Treat authentication, authorization, file handling, XSS, CSRF, SQL injection, mass assignment, and unsafe spreadsheet input as first-class concerns. +- Add or update focused model, service, controller/integration, job, channel, and system tests. Use parallel testing consistently with the existing test setup and preserve a credible path to 90% coverage. +- Keep tests deterministic and avoid Redis. Use Solid Queue and Solid Cable as required by the brief. +- Preserve unrelated user changes. Do not reset, checkout, or rewrite history. Do not commit unless explicitly asked. +- At the beginning of implementation work, check the current branch and working tree. Create or use a dedicated task branch only when requested or when the repository workflow requires it; never silently discard existing work. +- Update the English README whenever setup, seed data, test commands, deployment, architecture, or user-facing behavior changes. Include a clearly labeled AI disclosure naming the model used: GitHub Copilot. + +## Delivery Loop + +1. Inspect the relevant code path and existing conventions. +2. Make a narrow implementation or test change. +3. Run the cheapest behavior-focused validation immediately. +4. Repair local failures before expanding scope. +5. Run the relevant full test, lint, security, and build checks when the slice is complete. +6. Review the diff for authorization gaps, regressions, missing documentation, and accidental metadata or generated-file churn. + +## Completion Criteria + +Do not call a task complete until the affected behavior is implemented, tested, documented when relevant, and validated with executable checks. Report commands run, relevant failures that predate the change, and any remaining risk. Keep the final response concise and link changed workspace files. \ No newline at end of file diff --git a/.gitignore b/.gitignore index 63805955f..9ecf90259 100644 --- a/.gitignore +++ b/.gitignore @@ -29,9 +29,14 @@ !/tmp/storage/.keep /public/assets +/coverage +/public/vite-test +/public/vite-ssr # Ignore key files for decrypting credentials and more. /config/*.key +/config/master.key +/.kamal/secrets # Vite Ruby 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/application_cable/connection.rb b/app/channels/application_cable/connection.rb new file mode 100644 index 000000000..0dce2156c --- /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 + session = Session.find_by(id: cookies.signed[:session_id]) + self.current_user = session&.user + end + end +end diff --git a/app/channels/dashboard_channel.rb b/app/channels/dashboard_channel.rb new file mode 100644 index 000000000..a7ad199d1 --- /dev/null +++ b/app/channels/dashboard_channel.rb @@ -0,0 +1,8 @@ +class DashboardChannel < ApplicationCable::Channel + # OptimizationRef: RB4-RM80-Solid + def subscribed + reject unless current_user&.admin? + + stream_from Dashboard::Broadcaster::STATS_STREAM + end +end diff --git a/app/channels/import_progress_channel.rb b/app/channels/import_progress_channel.rb new file mode 100644 index 000000000..02e47fc94 --- /dev/null +++ b/app/channels/import_progress_channel.rb @@ -0,0 +1,11 @@ +class ImportProgressChannel < ApplicationCable::Channel + # OptimizationRef: RB4-RM80-Solid + def subscribed + reject unless current_user&.admin? + + import = UserImport.find_by(id: params[:id]) + reject unless import + + stream_from "import_progress_#{import.id}" + end +end diff --git a/app/frontend/channels/consumer.ts b/app/frontend/channels/consumer.ts new file mode 100644 index 000000000..b1d32a105 --- /dev/null +++ b/app/frontend/channels/consumer.ts @@ -0,0 +1,5 @@ +import { createConsumer } from "@rails/actioncable" + +const consumer = createConsumer() + +export default consumer diff --git a/app/frontend/entrypoints/application.css b/app/frontend/entrypoints/application.css index a461c505f..ab5bb0886 100644 --- a/app/frontend/entrypoints/application.css +++ b/app/frontend/entrypoints/application.css @@ -1 +1,9 @@ -@import "tailwindcss"; \ No newline at end of file +@import "tailwindcss"; + +@theme { + --font-sans: "Segoe UI", "Helvetica Neue", ui-sans-serif, system-ui, sans-serif; +} + +body { + font-family: var(--font-sans); +} diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index 94221e688..aa08ec992 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -1,38 +1,20 @@ # 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 + policy.font_src :self, :https, :data + policy.img_src :self, :https, :data, :blob + policy.object_src :none + policy.script_src :self, :unsafe_inline + policy.style_src :self, :unsafe_inline + policy.connect_src :self, :https, :wss, :ws + policy.frame_ancestors :none -# Rails.application.configure do -# config.content_security_policy do |policy| -# policy.default_src :self, :https -# policy.font_src :self, :https, :data -# policy.img_src :self, :https, :data -# policy.object_src :none -# policy.script_src :self, :https - # 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 -# -# # 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 + if Rails.env.development? + vite = "http://#{ViteRuby.config.host_with_port}" + policy.script_src :self, :unsafe_inline, :unsafe_eval, vite + policy.connect_src :self, :https, :wss, :ws, vite, "ws://#{ViteRuby.config.host_with_port}" + end + end +end