diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..8cbe72e43 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,72 @@ +# See https://docs.docker.com/engine/reference/builder/#dockerignore-file for more about ignoring files. + +# Ignore git directory. +/.git/ +/.gitignore + +# Ignore bundler config. +/.bundle + +# Ignore all environment files. +/.env* + +# Ignore all default key files. +/config/master.key +/config/credentials/*.key + +# Ignore all logfiles and tempfiles. +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep + +# Ignore pidfiles, but keep the directory. +/tmp/pids/* +!/tmp/pids/.keep + +# Ignore storage (uploaded files in development and any SQLite databases). +/storage/* +!/storage/.keep +/tmp/storage/* +!/tmp/storage/.keep + +# Ignore assets. +/node_modules/ +/app/assets/builds/* +!/app/assets/builds/.keep +/public/assets + +# Ignore CI service files. +/.github + +# Ignore Kamal files. +/config/deploy*.yml +/.kamal + +# Ignore development files +/.devcontainer + +# Ignore Docker-related files +/.dockerignore +/Dockerfile* + +# Ignore the test suite and coverage output: they are not needed in the +# production image and only make it larger. +/spec +/coverage +/.rspec_status + +# Ignore host-side operational scripts; they drive Docker from outside. +/devops + +# Ignore the working documents that live beside the repository but are not part +# of it. They are untracked, so a fresh clone has none of them -- but a build +# from the machine they were written on would copy them straight into the image +# somebody else pulls. +/PLANO_TESTE_FULLSTACK_UMANNI.md +/PENDENCIAS.md +/CREDENCIAIS_LOCAIS.txt + +# The README screenshots are for people reading the repository, not for the +# running application. +/docs diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..04d5673f6 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# Copy to .env (bin/setup does it for you). These values are development-only; +# production configuration is supplied by Kamal secrets, never by this file. + +# ── PostgreSQL ─────────────────────────────────────────────────────────────── +POSTGRES_USER=user_management +POSTGRES_PASSWORD=development_only +POSTGRES_DB=user_management_development +TEST_POSTGRES_DB=user_management_test + +# ── Application ────────────────────────────────────────────────────────────── +# Host port for the web server. +WEB_PORT=3000 +RAILS_MAX_THREADS=5 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..8dc432343 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# See https://git-scm.com/docs/gitattributes for more about git attribute files. + +# Mark the database schema as having been generated. +db/schema.rb linguist-generated + +# Mark any vendored files as having been vendored. +vendor/* linguist-vendored +config/credentials/*.yml.enc diff=rails_credentials +config/credentials.yml.enc diff=rails_credentials diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..83610cfa4 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: +- package-ecosystem: bundler + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 10 +- package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 10 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..89d88f6fe --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,73 @@ +# One job, running the same pipeline a person runs locally: bin/ci inside the +# same container the application is developed in. A workflow that installs its +# own Ruby and its own PostgreSQL would be a second definition of the +# environment, free to drift from the one in the repository. +name: CI + +on: + # Every branch, not only the default one: work in progress on a branch is + # exactly when the pipeline is worth having, and a pull request from a fork + # runs only after a maintainer approves it -- which is too late to be useful + # to whoever pushed. + push: + pull_request: + +# A push and a pull request on the same branch would otherwise start two +# identical runs, and every new push would leave the previous one grinding away +# on code nobody is looking at any more. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + ci: + name: Style, security and tests + runs-on: ubuntu-latest + timeout-minutes: 25 + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Share the checkout with the container's user + # The development image runs as uid 1000 -- the common host uid, which + # is what keeps bind-mounted files writable from both sides on a + # laptop. A runner checks the repository out as a different user, and + # Rails cannot so much as create tmp/cache in a directory it does not + # own, so the web container exits before it can answer a health check. + # + # Handing the tree over outright is too much, though: it locked the + # runner out of its own workspace, and the next step could not create + # .env. The container user owns the tree, the runner's group keeps + # write access, and both sides can write -- which is what a bind mount + # shared between two users needs. + run: | + sudo chown -R 1000:"$(id -g)" . + sudo chmod -R g+rwX . + + - name: Cache the Docker layers + uses: docker/setup-buildx-action@v3 + + # The same script the README tells a person to run, rather than a + # separate sequence of docker commands that could quietly stop matching + # it: it writes .env, builds the image, creates and migrates the four + # databases, and waits for every service to report healthy. Without the + # databases the health check fails -- /up goes through the cache store, + # which has a database of its own. + - name: Set the environment up, exactly as the README says + run: devops/app/setup.sh --no-seed + + - name: Run the pipeline + run: bin/ci + + - name: Keep the coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage + path: coverage/ + retention-days: 7 + + - name: Show the logs when something fails + if: failure() + run: docker compose logs --tail 200 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 4b950cc66..6a54e6a9f 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -1,3 +1,8 @@ +# CodeQL, as it comes with the repository -- brought up to date so it can +# actually run. The workflow shipped pinned to github/codeql-action@v1, which +# GitHub retired in January 2023 and which now refuses to start, and it asked +# for no permissions, so the default read-only token could not write findings: +# "Resource not accessible by integration". name: "Code scanning - action" on: @@ -8,44 +13,26 @@ on: jobs: CodeQL-Build: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v2 - with: - # We must fetch at least the immediate parents so that if this is - # a pull request then we can checkout the head. - fetch-depth: 2 - - # If this run was triggered by a pull request event, then checkout - # the head of the pull request instead of the merge commit. - - run: git checkout HEAD^2 - if: ${{ github.event_name == 'pull_request' }} - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v1 - # Override language selection by uncommenting this and choosing your languages - # with: - # languages: go, javascript, csharp, python, cpp, java - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v1 + # The default GITHUB_TOKEN is read-only. CodeQL uploads what it finds, so + # it needs to write security events -- and nothing else. + permissions: + actions: read + contents: read + security-events: write - # ℹ️ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl - - # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + # The languages are named rather than guessed: this is a Rails + # application with a handful of Stimulus controllers, and nothing + # compiled, so there is no Autobuild step to run. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ruby, javascript-typescript + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..fe9d0d5bf --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# See https://help.github.com/articles/ignoring-files for more about ignoring files. +# +# Temporary files generated by your text editor or operating system +# belong in git's global ignore instead: +# `$XDG_CONFIG_HOME/git/ignore` or `~/.config/git/ignore` + +# Ignore bundler config. +/.bundle + +# Ignore all environment files, but keep the documented template. +/.env* +!/.env.example + +# Ignore all logfiles and tempfiles. +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep + +# Ignore pidfiles, but keep the directory. +/tmp/pids/* +!/tmp/pids/ +!/tmp/pids/.keep + +# Ignore storage (uploaded files in development and any SQLite databases). +/storage/* +!/storage/.keep +/tmp/storage/* +!/tmp/storage/ +!/tmp/storage/.keep + +/public/assets + +# Ignore key files for decrypting credentials and more. +/config/*.key + + +# Ignore coverage reports and RSpec run state. +/coverage +/.rspec_status + +/app/assets/builds/* +!/app/assets/builds/.keep diff --git a/.kamal/secrets b/.kamal/secrets new file mode 100644 index 000000000..908cca6c1 --- /dev/null +++ b/.kamal/secrets @@ -0,0 +1,17 @@ +# Read by Kamal at deploy time and passed to the containers. Nothing here is a +# secret itself: each line names where the value comes from, and the values +# stay in the environment of whoever runs the deploy (or in a password +# manager, via `kamal secrets fetch`). +# +# export KAMAL_REGISTRY_PASSWORD=... +# export POSTGRES_PASSWORD=... +# +KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD + +# config/master.key is not in the repository; the deploying machine has it. +RAILS_MASTER_KEY=$(cat config/master.key) + +POSTGRES_PASSWORD=$POSTGRES_PASSWORD + +# Only needed on the first deploy, together with SEED_ADMIN_EMAIL: +# SEED_ADMIN_PASSWORD=$SEED_ADMIN_PASSWORD diff --git a/.rspec b/.rspec new file mode 100644 index 000000000..c99d2e739 --- /dev/null +++ b/.rspec @@ -0,0 +1 @@ +--require spec_helper diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 000000000..328407413 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,132 @@ +# Strict house style. The omakase preset was deliberately dropped in favour of +# an explicit rule set: every relaxation below is listed with its reason, so +# the configuration documents the trade-offs instead of hiding them. + +plugins: + - rubocop-rails + - rubocop-rspec + - rubocop-rspec_rails + - rubocop-capybara + - rubocop-factory_bot + - rubocop-performance + +AllCops: + NewCops: enable + DisplayCopNames: true + DisplayStyleGuide: true + Exclude: + - "db/schema.rb" + - "db/*_schema.rb" + # Migrations copied verbatim from the framework gems are not ours to style. + - "db/migrate/*_solid_*.rb" + - "db/migrate/*.active_storage.rb" + - "vendor/**/*" + - "tmp/**/*" + - "storage/**/*" + - "bin/bundle" + +# ── Layout ─────────────────────────────────────────────────────────────────── +Layout/LineLength: + Max: 120 + +# ── Style ──────────────────────────────────────────────────────────────────── +Style/Documentation: + # Class names and method names carry the intent here; mandatory top-of-class + # comments would add ceremony without adding information. + Enabled: false + +Style/StringLiterals: + EnforcedStyle: double_quotes + +Style/StringLiteralsInInterpolation: + EnforcedStyle: double_quotes + +Style/FrozenStringLiteralComment: + # Ruby 4 freezes string literals in files without the magic comment under + # the chilled-strings behaviour, so the annotation is noise. + Enabled: false + +# ── Metrics ────────────────────────────────────────────────────────────────── +# Kept enabled on purpose: these are the cops that keep methods small. +Metrics/BlockLength: + AllowedMethods: + - describe + - context + - shared_examples + - configure + - draw + +Metrics/MethodLength: + Max: 15 + Exclude: + # A create_table with a dozen columns is one declaration, not a long + # method. Splitting it to satisfy a line count would make it worse. + - "db/migrate/*.rb" + +Metrics/AbcSize: + Max: 20 + +# ── Rails ──────────────────────────────────────────────────────────────────── +Rails/DynamicFindBy: + # find_by_password_reset_token! is not a dynamic column finder: it is + # generated by generates_token_for, and rewriting it into + # find_by!(password_reset_token: ...) looks for a column that does not exist. + # Autocorrect made exactly that change and broke the password reset flow. + AllowedMethods: + - find_by_password_reset_token! + - find_by_password_reset_token + +Rails/SkipsModelValidations: + # Import counters are advanced with atomic updates on purpose; validating a + # counter bump on every row would serialise the import for no benefit. + AllowedMethods: + - increment! + - update_columns + - touch + +# ── RSpec ──────────────────────────────────────────────────────────────────── +RSpec/ExampleLength: + Max: 12 + Exclude: + # System specs walk a whole journey; splitting one into five examples would + # mean re-driving the browser five times to assert the same thing once. + - "spec/system/**/*_spec.rb" + +RSpec/MultipleExpectations: + # A request spec legitimately asserts on status, redirect and side effect. + Max: 4 + +RSpec/NestedGroups: + Max: 4 + +# ── Screenshot helper ──────────────────────────────────────────────────────── +# spec/system/screenshots_spec.rb produces the README images. save_screenshot is +# the entire point of that file; treating it as a stray debugging call is the +# right default everywhere else. +Lint/Debugger: + Exclude: + - "spec/system/screenshots_spec.rb" + +# ── The OpenAPI specs ──────────────────────────────────────────────────────── +# spec/requests/api is written in rswag's DSL, where the shape of the file is +# the shape of the document: `path`/`get`/`response` blocks whose examples are +# generated by `run_test!`, and a `let(:Authorization)` whose name is the +# header it sets. Style cops written for ordinary examples read that as +# misspelled variables and empty groups. +RSpec/VariableName: + Exclude: + - "spec/requests/api/**/*_spec.rb" + +RSpec/EmptyExampleGroup: + Exclude: + - "spec/requests/api/**/*_spec.rb" + +RSpec/MultipleMemoizedHelpers: + Exclude: + - "spec/requests/api/**/*_spec.rb" + +Style/HashAsLastArrayItem: + Exclude: + # `security [bearer_auth: []]` is rswag's own notation for an OpenAPI + # security requirement. + - "spec/requests/api/**/*_spec.rb" diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 000000000..1cf76f52b --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +ruby-4.0.6 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..b70525c39 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,104 @@ +# syntax=docker/dockerfile:1 +# check=error=true + +# Multi-stage build for the user management application. +# +# base → shared runtime layer +# build → compiles gems and precompiles assets (thrown away) +# development → adds build tools, dev/test gems and headless Chrome +# final → lean production image, runs as non-root behind Thruster +# +# Production build: docker build -t user_management . +# Development build: docker build --target development -t user_management-dev . + +ARG RUBY_VERSION=4.0.6 +FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base + +WORKDIR /rails + +# Runtime packages only. libvips backs Active Storage avatar variants, +# postgresql-client is what bin/docker-entrypoint waits on. +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y curl libjemalloc2 libvips postgresql-client && \ + ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +ENV RAILS_ENV="production" \ + BUNDLE_DEPLOYMENT="1" \ + BUNDLE_PATH="/usr/local/bundle" \ + BUNDLE_WITHOUT="development:test" \ + LD_PRELOAD="/usr/local/lib/libjemalloc.so" + + +# ─── Build stage (discarded) ──────────────────────────────────────────────── +FROM base AS build + +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y build-essential git libpq-dev libyaml-dev pkg-config && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +COPY Gemfile Gemfile.lock ./ +RUN bundle install && \ + rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \ + bundle exec bootsnap precompile -j 1 --gemfile + +COPY . . + +RUN bundle exec bootsnap precompile -j 1 app/ lib/ + +# Assets are compiled at build time so the production image never needs the +# real credentials: the dummy key satisfies the initializers and is discarded. +RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile + + +# ─── Development stage ────────────────────────────────────────────────────── +# Used by docker-compose. Application code is bind-mounted, so only the gems +# and system dependencies are baked in. +FROM base AS development + +ENV RAILS_ENV="development" \ + BUNDLE_DEPLOYMENT="0" \ + BUNDLE_WITHOUT="" + +# chromium is what Cuprite drives over CDP for the Capybara system specs. +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y \ + build-essential git libpq-dev libyaml-dev pkg-config \ + chromium fonts-liberation && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +ENV BROWSER_PATH="/usr/bin/chromium" + +COPY Gemfile Gemfile.lock ./ +RUN bundle install && rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache + +# uid/gid 1000 matches the common host user, which keeps bind-mounted files +# writable from both sides without a chown dance. +RUN groupadd --system --gid 1000 rails && \ + useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \ + chown -R rails:rails /rails "${BUNDLE_PATH}" +USER 1000:1000 + +EXPOSE 3000 +CMD ["./bin/rails", "server", "-b", "0.0.0.0", "-p", "3000"] + + +# ─── Final production stage ───────────────────────────────────────────────── +FROM base AS final + +RUN groupadd --system --gid 1000 rails && \ + useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash +USER 1000:1000 + +COPY --chown=rails:rails --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" +COPY --chown=rails:rails --from=build /rails /rails + +ENTRYPOINT ["/rails/bin/docker-entrypoint"] + +EXPOSE 80 + +# Rails 8 serves /up from Rails::HealthController; Thruster fronts it on 80. +HEALTHCHECK --interval=15s --timeout=5s --start-period=40s --retries=5 \ + CMD curl -sf http://localhost/up || exit 1 + +CMD ["./bin/thrust", "./bin/rails", "server"] diff --git a/Gemfile b/Gemfile new file mode 100644 index 000000000..ff2b16604 --- /dev/null +++ b/Gemfile @@ -0,0 +1,93 @@ +source "https://rubygems.org" + +gem "rails", "~> 8.1.3", ">= 8.1.3.1" + +# Asset pipeline and front end +gem "importmap-rails" +gem "propshaft" +gem "stimulus-rails" +gem "tailwindcss-rails" +gem "turbo-rails" + +# Database and server +# Password hashing for the built-in Rails authentication +gem "bcrypt", "~> 3.1" + +# Locale data for the shipped languages: date formats, number formats and the +# Active Record validation messages, which would otherwise stay English-only. +gem "rails-i18n", "~> 8.0" + +gem "pg", "~> 1.1" +gem "puma", ">= 5.0" + +# Database-backed adapters for Rails.cache, Active Job and Action Cable. +# Solid Cable is what carries the live dashboard and import progress updates. +gem "solid_cable" +gem "solid_cache" +gem "solid_queue" + +# Active Storage variants for avatars +gem "image_processing", "~> 1.2" + +# Spreadsheet parsing for user imports. `csv` left the default gems in Ruby 3.4, +# so it has to be declared explicitly on Ruby 4. +gem "csv" +gem "roo", "~> 3.0" + +# Pagination for the admin user list +gem "pagy", "~> 9.4" + +gem "bootsnap", require: false + +# Deployment +gem "kamal", require: false +gem "thruster", require: false + +gem "tzinfo-data", platforms: %i[windows jruby] + +group :development, :test do + gem "debug", platforms: %i[mri windows], require: "debug/prelude" + + gem "factory_bot_rails", "~> 6.5" + gem "faker", "~> 3.5" + gem "rspec-rails", "~> 8.0" + + # Security analysis + gem "brakeman", require: false + gem "bundler-audit", require: false + + # Style. The omakase preset is deliberately replaced by an explicit, stricter + # rule set covering Rails, RSpec, Capybara and performance cops. + gem "rubocop", require: false + gem "rubocop-capybara", require: false + gem "rubocop-factory_bot", require: false + gem "rubocop-performance", require: false + gem "rubocop-rails", require: false + gem "rubocop-rspec", require: false + gem "rubocop-rspec_rails", require: false +end + +# The API documentation is generated from the request specs that exercise the +# API, so it cannot describe something the application does not do. +gem "rswag-api" +gem "rswag-ui" + +group :test do + # Accessibility as a check rather than a claim: axe runs against the rendered + # page in the system specs. The API gem carries the axe-core JavaScript; the + # matcher that drives it lives in spec/support, because the packaged one + # speaks Selenium and these specs drive Chrome over CDP. + gem "axe-core-api" + gem "capybara" + gem "rswag-specs" + # Cuprite drives headless Chrome over CDP directly, which keeps system specs + # fast and removes the chromedriver version dance. + gem "cuprite" + gem "parallel_tests", require: false + gem "shoulda-matchers", "~> 6.4" + gem "simplecov", require: false +end + +group :development do + gem "web-console" +end diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 000000000..bd16e88c6 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,676 @@ +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) + axe-core-api (4.13.0) + dumb_delegator + base64 (0.3.0) + bcrypt (3.1.22) + bcrypt_pbkdf (1.1.2) + bigdecimal (4.1.2) + bindex (0.8.1) + bootsnap (1.25.0) + msgpack (~> 1.5) + brakeman (8.0.6) + racc + builder (3.3.0) + bundler-audit (0.9.3) + bundler (>= 1.2.0) + thor (~> 1.0) + capybara (3.40.0) + addressable + matrix + mini_mime (>= 0.1.3) + nokogiri (~> 1.11) + rack (>= 1.6.0) + rack-test (>= 0.6.3) + regexp_parser (>= 1.5, < 3.0) + xpath (~> 3.2) + concurrent-ruby (1.3.8) + connection_pool (3.0.2) + crass (1.0.7) + csv (3.3.6) + cuprite (0.18) + capybara (~> 3.0) + ferrum (~> 0.18.0) + date (3.5.1) + debug (1.11.1) + irb (~> 1.10) + reline (>= 0.3.8) + diff-lcs (1.6.2) + dotenv (3.2.0) + drb (2.2.3) + dumb_delegator (1.1.0) + ed25519 (1.4.0) + erb (6.0.7) + erubi (1.13.1) + et-orbi (1.4.2) + tzinfo + factory_bot (6.6.0) + activesupport (>= 6.1.0) + factory_bot_rails (6.5.1) + factory_bot (~> 6.5) + railties (>= 6.1.0) + faker (3.8.0) + i18n (>= 1.8.11, < 2) + ferrum (0.18.0) + addressable (~> 2.5) + base64 (~> 0.2) + concurrent-ruby (~> 1.1) + websocket-driver (~> 0.7) + ffi (1.17.4-aarch64-linux-gnu) + ffi (1.17.4-aarch64-linux-musl) + ffi (1.17.4-arm-linux-gnu) + ffi (1.17.4-arm-linux-musl) + ffi (1.17.4-x86_64-linux-gnu) + ffi (1.17.4-x86_64-linux-musl) + fugit (1.13.0) + et-orbi (~> 1.4) + raabro (~> 1.4) + globalid (1.4.0) + activesupport (>= 6.1) + i18n (1.15.2) + concurrent-ruby (~> 1.0) + image_processing (1.14.0) + mini_magick (>= 4.9.5, < 6) + ruby-vips (>= 2.0.17, < 3) + importmap-rails (2.2.3) + actionpack (>= 6.0.0) + activesupport (>= 6.0.0) + railties (>= 6.0.0) + io-console (0.9.2) + irb (1.18.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + json (2.21.2) + json-schema (6.2.0) + addressable (~> 2.8) + bigdecimal (>= 3.1, < 5) + kamal (2.12.0) + activesupport (>= 7.0) + base64 (~> 0.2) + bcrypt_pbkdf (~> 1.0) + concurrent-ruby (~> 1.2) + dotenv (~> 3.1) + ed25519 (~> 1.4) + net-ssh (~> 7.3) + sshkit (>= 1.23.0, < 2.0) + thor (~> 1.3) + zeitwerk (>= 2.6.18, < 3.0) + language_server-protocol (3.17.0.6) + lint_roller (1.1.0) + logger (1.7.0) + loofah (2.25.2) + crass (~> 1.0.2) + nokogiri (>= 1.12.0) + mail (2.9.1) + logger + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp + marcel (1.2.1) + matrix (0.4.3) + mini_magick (5.4.0) + logger + mini_mime (1.1.5) + minitest (6.0.6) + drb (~> 2.0) + prism (~> 1.5) + msgpack (1.8.4) + net-imap (0.6.6) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.3.0) + timeout + net-scp (4.1.0) + net-ssh (>= 2.6.5, < 8.0.0) + net-sftp (4.0.0) + net-ssh (>= 5.0.0, < 8.0.0) + net-smtp (0.5.1) + net-protocol + net-ssh (7.3.3) + nio4r (2.7.5) + nokogiri (1.19.4-aarch64-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.4-aarch64-linux-musl) + racc (~> 1.4) + nokogiri (1.19.4-arm-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.4-arm-linux-musl) + racc (~> 1.4) + nokogiri (1.19.4-x86_64-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.4-x86_64-linux-musl) + racc (~> 1.4) + ostruct (0.6.3) + pagy (9.4.0) + parallel (2.1.0) + parallel_tests (5.7.0) + parallel + parser (3.3.12.0) + ast (~> 2.4.1) + racc + pg (1.6.3) + pg (1.6.3-aarch64-linux) + pg (1.6.3-aarch64-linux-musl) + pg (1.6.3-x86_64-linux) + pg (1.6.3-x86_64-linux-musl) + pp (0.6.4) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) + propshaft (1.3.2) + actionpack (>= 7.0.0) + activesupport (>= 7.0.0) + rack + public_suffix (7.0.5) + puma (8.0.2) + nio4r (~> 2.0) + raabro (1.5.0) + racc (1.8.1) + rack (3.2.7) + rack-session (2.1.2) + base64 (>= 0.1.0) + rack (>= 3.0.0) + rack-test (2.2.0) + rack (>= 1.3) + rackup (2.3.1) + rack (>= 3) + rails (8.1.3.1) + actioncable (= 8.1.3.1) + actionmailbox (= 8.1.3.1) + actionmailer (= 8.1.3.1) + actionpack (= 8.1.3.1) + actiontext (= 8.1.3.1) + actionview (= 8.1.3.1) + activejob (= 8.1.3.1) + activemodel (= 8.1.3.1) + activerecord (= 8.1.3.1) + activestorage (= 8.1.3.1) + activesupport (= 8.1.3.1) + bundler (>= 1.15.0) + railties (= 8.1.3.1) + rails-dom-testing (2.3.0) + activesupport (>= 5.0.0) + minitest + nokogiri (>= 1.6) + rails-html-sanitizer (1.7.1) + loofah (~> 2.25, >= 2.25.2) + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + 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) + irb (~> 1.13) + rackup (>= 1.0.0) + rake (>= 12.2) + thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) + zeitwerk (~> 2.6) + rainbow (3.1.1) + rake (13.4.2) + rbs (4.2.0) + logger + prism (>= 1.6.0) + tsort + rdoc (8.0.0) + erb + prism (>= 1.6.0) + rbs (>= 4.0.0) + tsort + regexp_parser (2.12.0) + reline (0.7.0) + io-console (~> 0.5) + roo (3.0.0) + base64 (~> 0.2) + csv (~> 3) + logger (~> 1) + nokogiri (~> 1) + rubyzip (>= 3.0.0, < 4.0.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.8) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-rails (8.0.4) + actionpack (>= 7.2) + activesupport (>= 7.2) + railties (>= 7.2) + rspec-core (>= 3.13.0, < 5.0.0) + rspec-expectations (>= 3.13.0, < 5.0.0) + rspec-mocks (>= 3.13.0, < 5.0.0) + rspec-support (>= 3.13.0, < 5.0.0) + rspec-support (3.13.7) + rswag-api (2.17.0) + activesupport (>= 5.2, < 8.2) + railties (>= 5.2, < 8.2) + rswag-specs (2.17.0) + activesupport (>= 5.2, < 8.2) + json-schema (>= 2.2, < 7.0) + railties (>= 5.2, < 8.2) + rspec-core (>= 2.14) + rswag-ui (2.17.0) + actionpack (>= 5.2, < 8.2) + railties (>= 5.2, < 8.2) + rubocop (1.90.0) + json (>= 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (>= 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.50.0) + parser (>= 3.3.7.2) + prism (~> 1.7) + rubocop-capybara (3.0.0) + lint_roller (~> 1.1) + rubocop (~> 1.81) + rubocop-factory_bot (2.28.0) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) + rubocop-performance (1.27.0) + lint_roller (~> 1.1) + rubocop (>= 1.89.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) + rubocop-rails (2.37.0) + activesupport (>= 4.2.0) + lint_roller (~> 1.1) + rack (>= 1.1) + rubocop (>= 1.89.0, < 2.0) + rubocop-ast (>= 1.44.0, < 2.0) + rubocop-rspec (3.10.2) + lint_roller (~> 1.1) + regexp_parser (>= 2.0) + rubocop (~> 1.86, >= 1.86.2) + rubocop-rspec_rails (2.32.0) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) + rubocop-rspec (~> 3.5) + ruby-progressbar (1.13.0) + ruby-vips (2.3.0) + ffi (~> 1.12) + logger + rubyzip (3.6.0) + securerandom (0.4.1) + shoulda-matchers (6.5.0) + activesupport (>= 5.2.0) + simplecov (1.1.1) + solid_cable (4.0.2) + actioncable (>= 7.2) + activejob (>= 7.2) + activerecord (>= 7.2) + railties (>= 7.2) + solid_cache (1.0.10) + activejob (>= 7.2) + activerecord (>= 7.2) + railties (>= 7.2) + solid_queue (1.7.0) + activejob (>= 7.1) + activerecord (>= 7.1) + concurrent-ruby (>= 1.3.1) + fugit (~> 1.11) + railties (>= 7.1) + thor (>= 1.3.1) + sshkit (1.25.1) + base64 + logger + net-scp (>= 1.1.2) + net-sftp (>= 2.1.2) + net-ssh (>= 2.8.0) + ostruct + stimulus-rails (1.3.4) + railties (>= 6.0.0) + tailwindcss-rails (4.6.0) + railties (>= 7.0.0) + tailwindcss-ruby (~> 4.0) + tailwindcss-ruby (4.3.3) + tailwindcss-ruby (4.3.3-aarch64-linux-gnu) + tailwindcss-ruby (4.3.3-aarch64-linux-musl) + tailwindcss-ruby (4.3.3-x86_64-linux-gnu) + tailwindcss-ruby (4.3.3-x86_64-linux-musl) + thor (1.5.0) + thruster (0.1.26) + thruster (0.1.26-aarch64-linux) + thruster (0.1.26-x86_64-linux) + timeout (0.6.1) + tsort (0.2.0) + turbo-rails (2.0.23) + actionpack (>= 7.1.0) + railties (>= 7.1.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + uri (1.1.1) + useragent (0.16.11) + web-console (4.3.0) + actionview (>= 8.0.0) + bindex (>= 0.4.0) + railties (>= 8.0.0) + websocket-driver (0.8.2) + base64 + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + xpath (3.2.0) + nokogiri (~> 1.8) + zeitwerk (2.8.3) + +PLATFORMS + aarch64-linux + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + x86_64-linux-gnu + x86_64-linux-musl + +DEPENDENCIES + axe-core-api + bcrypt (~> 3.1) + bootsnap + brakeman + bundler-audit + capybara + csv + cuprite + debug + factory_bot_rails (~> 6.5) + faker (~> 3.5) + image_processing (~> 1.2) + importmap-rails + kamal + pagy (~> 9.4) + parallel_tests + pg (~> 1.1) + propshaft + puma (>= 5.0) + rails (~> 8.1.3, >= 8.1.3.1) + rails-i18n (~> 8.0) + roo (~> 3.0) + rspec-rails (~> 8.0) + rswag-api + rswag-specs + rswag-ui + rubocop + rubocop-capybara + rubocop-factory_bot + rubocop-performance + rubocop-rails + rubocop-rspec + rubocop-rspec_rails + shoulda-matchers (~> 6.4) + simplecov + solid_cable + solid_cache + solid_queue + stimulus-rails + tailwindcss-rails + thruster + turbo-rails + tzinfo-data + web-console + +CHECKSUMS + action_text-trix (2.1.19) sha256=7012f59421009cf284aa651294896414d653a61a2417c9b8714c8476d2f74009 + actioncable (8.1.3.1) sha256=e318528295c878a3efdfe25f0f2267c80cb7a76eba41bb5f64d44aa380a3d91b + actionmailbox (8.1.3.1) sha256=5f704972097d843ade8e435e93694a1dac732b926df1717aceba1f3840082b1c + actionmailer (8.1.3.1) sha256=88ea441b28ff02a0c6c006468892642a3d9942affce9d294e81a74504aa5c43c + actionpack (8.1.3.1) sha256=974cb7154548e81f470b1b0f247b99cb38e87825899dca58610596e2817723d0 + actiontext (8.1.3.1) sha256=5da729d833d1a29cddb1eee938878e55e503d2613e00e735f5daf58c2ba98af2 + actionview (8.1.3.1) sha256=2da68b8414c47b43bfbed1ce69c5afe1c04f78c267aacb5660a4cab5ca12cfb6 + activejob (8.1.3.1) sha256=1c8dd275df930df40deecffec63d913a550a33fd94bd298f69721dd96939954a + activemodel (8.1.3.1) sha256=99cc02ce2faec371d14440949d85787ebd23a907c9baef0a9d4bcd4d21888f88 + activerecord (8.1.3.1) sha256=0a2fb6c28f4938f6b013a3a549bec0a7e37d535f3dc8990e804bcc3258c0403b + activestorage (8.1.3.1) sha256=f555254f387b1cffa499d2fd3115d12635eadc5b15206a8534316a67036163ef + activesupport (8.1.3.1) sha256=85458765f25ea48b9019c46b6bb3fa5683197bf4280d9f06710a6e8d7a831376 + addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af + ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 + axe-core-api (4.13.0) sha256=6556c36d541090993b0efffd2659e13c289c109a0a6bf65d28271e363fc7ffa3 + base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + bcrypt (3.1.22) sha256=1f0072e88c2d705d94aff7f2c5cb02eb3f1ec4b8368671e19112527489f29032 + bcrypt_pbkdf (1.1.2) sha256=c2414c23ce66869b3eb9f643d6a3374d8322dfb5078125c82792304c10b94cf6 + bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd + bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e + bootsnap (1.25.0) sha256=41059e7d0f9cb4023a33465d095f64b913fc9d1b808d6524c307da945fbcffcf + brakeman (8.0.6) sha256=759cc69341115e6c2dcd47b6fd8649a0b9bd540e3585ac8a0a94e31c66fee386 + builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f + bundler-audit (0.9.3) sha256=81c8766c71e47d0d28a0f98c7eed028539f21a6ea3cd8f685eb6f42333c9b4e9 + capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef + concurrent-ruby (1.3.8) sha256=b2f1be836e968ccc78ccfce277ea79c72a88633f22306782c16ff23fb415d1e1 + connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a + crass (1.0.7) sha256=94868719948664c89ddcaf0a37c65048413dfcb1c869470a5f7a7ceb5390b295 + csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456 + cuprite (0.18) sha256=32c3203a492f25dbd5a3525716ae09610bcef8936ddd32ff39f34477a98062b2 + date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 + debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6 + diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 + dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d + drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 + dumb_delegator (1.1.0) sha256=1ad255e5b095a2206a574c62b40c678f3d5c9151f1b3d0bae1b0463f7e40188e + ed25519 (1.4.0) sha256=16e97f5198689a154247169f3453ef4cfd3f7a47481fde0ae33206cdfdcac506 + erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92 + erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 + et-orbi (1.4.2) sha256=bb555dae668419cb24caa2a293a170e58be6d4df1e017c51f5030bdc133cd20c + factory_bot (6.6.0) sha256=1fc1b3b5620ec980a6a27aec1b6ec8c250ca82962e970e8a40f93e8d388d4b89 + factory_bot_rails (6.5.1) sha256=d3cc4851eae4dea8a665ec4a4516895045e710554d2b5ac9e68b94d351bc6d68 + faker (3.8.0) sha256=c147b308df73a90f27a4fc84f18d4c22ef0ad9c2a64b2b61c86fd0ca71753efc + ferrum (0.18.0) sha256=4cb8be16e352fc1d75f087e9214b34ec1b93ba932410c730a4724909ca89d7c6 + ffi (1.17.4-aarch64-linux-gnu) sha256=b208f06f91ffd8f5e1193da3cae3d2ccfc27fc36fba577baf698d26d91c080df + ffi (1.17.4-aarch64-linux-musl) sha256=9286b7a615f2676245283aef0a0a3b475ae3aae2bb5448baace630bb77b91f39 + ffi (1.17.4-arm-linux-gnu) sha256=d6dbddf7cb77bf955411af5f187a65b8cd378cb003c15c05697f5feee1cb1564 + ffi (1.17.4-arm-linux-musl) sha256=9d4838ded0465bef6e2426935f6bcc93134b6616785a84ffd2a3d82bc3cf6f95 + ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d + ffi (1.17.4-x86_64-linux-musl) sha256=3fdf9888483de005f8ef8d1cf2d3b20d86626af206cbf780f6a6a12439a9c49e + fugit (1.13.0) sha256=a4f093fce740da52f216740a5041e2a594ea763cdb89e8b2754ca4399634ab18 + globalid (1.4.0) sha256=037f12fbf1d9d7a014d501c2d5c77356fd4ddd96d7a7991d6700bba96706f427 + i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5 + image_processing (1.14.0) sha256=754cc169c9c262980889bec6bfd325ed1dafad34f85242b5a07b60af004742fb + importmap-rails (2.2.3) sha256=7101be2a4dc97cf1558fb8f573a718404c5f6bcfe94f304bf1f39e444feeb16a + io-console (0.9.2) sha256=efa74f891dd03c0939a931dfc6e74c2813d904763d456ea9762b0525e748db08 + irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 + json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a + json-schema (6.2.0) sha256=e8bff46ed845a22c1ab2bd0d7eccf831c01fe23bb3920caa4c74db4306813666 + kamal (2.12.0) sha256=c51d1ab085e515470f98d0c0f043637122b5ebf76e8b610cb1fbbed0b7f9b8fa + language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0 + lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + loofah (2.25.2) sha256=2007f746959ac65552456e04b433e83deb22759ab38c838b4445c70e43425918 + mail (2.9.1) sha256=06574eca475253d6c18145dd70af80d0eb970182d55053497c5f4d797ea160e8 + marcel (1.2.1) sha256=1678e9360e32f9eafa917c80029e2f6d10b2715c66a4b87b6d0da9b9cd1f859f + matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b + mini_magick (5.4.0) sha256=f120af581d9ed4ec52c57f35a67a605d112bb1d8f582d1415b147fda42d11d78 + mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef + minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1 + msgpack (1.8.4) sha256=4411c22d350dd1c20250f7eada3cca2695438c2f769cf0782f0cd065d90a3e7b + net-imap (0.6.6) sha256=96aa4ee50df3060203e649efc341f53480b791d49e150f2fdebf68beb141a8df + net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 + net-protocol (0.3.0) sha256=ba310c3d4f1cad46bb1ab20336b06669b1ff8f7c568d9cb9342b32a718547472 + net-scp (4.1.0) sha256=a99b0b92a1e5d360b0de4ffbf2dc0c91531502d3d4f56c28b0139a7c093d1a5d + net-sftp (4.0.0) sha256=65bb91c859c2f93b09826757af11b69af931a3a9155050f50d1b06d384526364 + net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736 + net-ssh (7.3.3) sha256=831def58b2c51dcef66ec00d29397d4f210de89c19fe78f95873ca30f386e86a + nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 + nokogiri (1.19.4-aarch64-linux-gnu) sha256=1269fb644a6de405057a53dd5c762b1209b43ca7424f839454d3dbc677c31a8f + nokogiri (1.19.4-aarch64-linux-musl) sha256=35c65b9ce72b3bb03207bdbe7067915019dc18c1b9b59139684bd6690fdd01af + nokogiri (1.19.4-arm-linux-gnu) sha256=a301313e38bb065d68239e79734bcd6f56fb6efaacebde29e9abf2a4735340ca + nokogiri (1.19.4-arm-linux-musl) sha256=588923c101bcfa78869734d247d25b598674323e7f22474fc468f6e5647311eb + nokogiri (1.19.4-x86_64-linux-gnu) sha256=379fae440b28915e3f19d752ce2dcf8465ed2b2fbefd2a7ca0dd497bc981a06a + nokogiri (1.19.4-x86_64-linux-musl) sha256=17dfb7c1fa194ae02fbf7c51a7afc8d278045ab3fdacfd86f91d02d7b274470b + ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 + pagy (9.4.0) sha256=db3f2e043f684155f18f78be62a81e8d033e39b9f97b1e1a8d12ad38d7bce738 + parallel (2.1.0) sha256=b35258865c2e31134c5ecb708beaaf6772adf9d5efae28e93e99260877b09356 + parallel_tests (5.7.0) sha256=3f1762c46ca2c223b8af8ef877217f9d76974e191bfa934f2580b58bcf1d005c + parser (3.3.12.0) sha256=21a6d7f755d5a24dfbdc6e6b772e4e879a52e7631a88bc5a3a134606052c9828 + pg (1.6.3) sha256=1388d0563e13d2758c1089e35e973a3249e955c659592d10e5b77c468f628a99 + pg (1.6.3-aarch64-linux) sha256=0698ad563e02383c27510b76bf7d4cd2de19cd1d16a5013f375dd473e4be72ea + pg (1.6.3-aarch64-linux-musl) sha256=06a75f4ea04b05140146f2a10550b8e0d9f006a79cdaf8b5b130cde40e3ecc2c + pg (1.6.3-x86_64-linux) sha256=5d9e188c8f7a0295d162b7b88a768d8452a899977d44f3274d1946d67920ae8d + pg (1.6.3-x86_64-linux-musl) sha256=9c9c90d98c72f78eb04c0f55e9618fe55d1512128e411035fe229ff427864009 + pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570 + prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 + propshaft (1.3.2) sha256=1d56a3e56a92c21bfc29caf07406b5386b00d4c47ddf357cf989a5a234b1389e + public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 + puma (8.0.2) sha256=c8ed871dfbbe66448ea9ffd46692342d9804d4071522b52b5331b7b6e7b686fb + raabro (1.5.0) sha256=3f998a7bc84f9c84df3ab580634d2e0a5bda4f0841168d56035f529c9877440a + racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f + rack (3.2.7) sha256=93e13e1c24f93556671d85d2d79fa228c3485815c50d7e2f265b5330c6528fb7 + rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8 + rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463 + rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868 + rails (8.1.3.1) sha256=ccd11a36bfc171bf9c66d585d14c0ece91c0c9dde840aae60c0118d6f5c9c52a + rails-dom-testing (2.3.0) sha256=8acc7953a7b911ca44588bf08737bc16719f431a1cc3091a292bca7317925c1d + rails-html-sanitizer (1.7.1) sha256=e797a7c9b01e567307e317c576b49ab4168017e63eea4dba9ce3cb587e2f22c2 + 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 + rbs (4.2.0) sha256=51f7b886dcc05bc09e10b901daa6a81829f6adc03101d6ca9ea4aac6103e0674 + rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469 + regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb + reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d + roo (3.0.0) sha256=6fdd7a9158d657c69768b4168754ff2110cc21fdc01a1bec1010820cb05c91b1 + rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d + rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 + rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47 + rspec-rails (8.0.4) sha256=06235692fc0892683d3d34977e081db867434b3a24ae0dd0c6f3516bad4e22df + rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c + rswag-api (2.17.0) sha256=728b336b65168ab8ab6024b0e5d267b485c22ccdeb9dfbfb6ec3bac423545a13 + rswag-specs (2.17.0) sha256=a3b2bdf6df89f8741fe4a4ee47ceb1e77dc13e1c96bbe07352117d6e61afa9e3 + rswag-ui (2.17.0) sha256=5f707b9b5e8171ddf9f519f6e401e79e419bd1d07387508603e76124f2443212 + rubocop (1.90.0) sha256=9eb4c065b5c5154e4ef554c547972f3905a9eb6b53e657e580b6796b54bf8242 + rubocop-ast (1.50.0) sha256=b9ca88300da0803ee222ad20cdb30494c0a784eed06fdc35d254b06d662788db + rubocop-capybara (3.0.0) sha256=7a64655238acda7f8f3c87e37ac825a64c615a79c17c253f1a28270dc3768c4b + rubocop-factory_bot (2.28.0) sha256=4b17fc02124444173317e131759d195b0d762844a71a29fe8139c1105d92f0cb + rubocop-performance (1.27.0) sha256=eeeb1374d062a368ee1c787b70eb0b0cc4b184cb1f8565f424760946146d61ce + rubocop-rails (2.37.0) sha256=6e1645add5060e0328f8ddda0d820f55697c591394398bf14bb9dccb62f14b7e + rubocop-rspec (3.10.2) sha256=0b3e2ecc592cd10ecbf0095bb58d1e357905276e069643523cc19eb7495f65e2 + rubocop-rspec_rails (2.32.0) sha256=4a0d641c72f6ebb957534f539d9d0a62c47abd8ce0d0aeee1ef4701e892a9100 + ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 + ruby-vips (2.3.0) sha256=e685ec02c13969912debbd98019e50492e12989282da5f37d05f5471442f5374 + rubyzip (3.6.0) sha256=268994d44d62282d1cfd99bf10eae48d7267199158ad7ea3e1fee2da9458b695 + securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 + shoulda-matchers (6.5.0) sha256=ef6b572b2bed1ac4aba6ab2c5ff345a24b6d055a93a3d1c3bfc86d9d499e3f44 + simplecov (1.1.1) sha256=25825ef13f0b2e74694d769817dad6ab8e90131dabdaa666e522fea105521e78 + solid_cable (4.0.2) sha256=084636a67679ad00d23088b33c84047e614bcf41ee559db24b414d83cdc42d03 + solid_cache (1.0.10) sha256=bc05a2fb3ac78a6f43cbb5946679cf9db67dd30d22939ededc385cb93e120d41 + solid_queue (1.7.0) sha256=6566b70b801d1c317c81bba7bcdd5677c019afac584a30374b4164002ca356d3 + sshkit (1.25.1) sha256=be3f10b9d6eb0b44d5eaba3f7cbe41bc6bb894bce4339688ac20124391455b78 + stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06 + tailwindcss-rails (4.6.0) sha256=d99512867173d55c5ef8890427682299d8539f550cec1408b3d8667a538bd365 + tailwindcss-ruby (4.3.3) sha256=ee0a64030749862deb501acab4c4aaf5adbee13865746a33299d46d7b5d0952a + tailwindcss-ruby (4.3.3-aarch64-linux-gnu) sha256=c86d6dd3eccc85fe0d792a832b06f2bf3c0a7a83b399308aeb9d8f5725f42a6a + tailwindcss-ruby (4.3.3-aarch64-linux-musl) sha256=72b77ca9edea82383dd09510ab520a122e3cb9f9864ca5b38e27698098d2b899 + tailwindcss-ruby (4.3.3-x86_64-linux-gnu) sha256=2337017ff8b02698480eae1e9637cf01faa0e4824db89d067a13c5a5ee38c9b2 + tailwindcss-ruby (4.3.3-x86_64-linux-musl) sha256=27d478c417bcf73828e5b544744c5bdfd5b5cb54f1a266fc4f185281c32efe6c + thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 + thruster (0.1.26) sha256=6e45e807086b29d51404841bd1ad493b67cd95892fd65dc5afcdd32e82e94ce8 + thruster (0.1.26-aarch64-linux) sha256=2171cb34928c0250830008f535c4ab2ee57846cc3f5d3e96c3475f7b3de7a541 + thruster (0.1.26-x86_64-linux) sha256=3117a6ee430663f845a0457699fe9a05232dcc6c396e2cc83504de5a223c60e8 + timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb + tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f + turbo-rails (2.0.23) sha256=ee0d90733aafff056cf51ff11e803d65e43cae258cc55f6492020ec1f9f9315f + tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b + unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 + unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f + uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 + useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844 + web-console (4.3.0) sha256=e13b71301cdfc2093f155b5aa3a622db80b4672d1f2f713119cc7ec7ac6a6da4 + websocket-driver (0.8.2) sha256=97c556b019bf3410b4961002ac501621e9322d3f8a7bc02161a09301cc4c4146 + websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241 + xpath (3.2.0) sha256=6dfda79d91bb3b949b947ecc5919f042ef2f399b904013eb3ef6d20dd3a4082e + zeitwerk (2.8.3) sha256=2c85125a8467ce069e20123d1e709a08955c9d29c118c25b46b7b7fafdbb92e5 + +BUNDLED WITH + 4.0.16 diff --git a/Procfile.dev b/Procfile.dev new file mode 100644 index 000000000..c7cf64525 --- /dev/null +++ b/Procfile.dev @@ -0,0 +1,3 @@ +web: bin/rails server +css: bin/rails tailwindcss:watch +jobs: bin/jobs diff --git a/README.md b/README.md index 7829f14ff..c7993df00 100644 --- a/README.md +++ b/README.md @@ -1,87 +1,769 @@ -# Modern Fullstack Developer Test (Rails 8 / Ruby 4) - -- Check this readme.md -- Create a branch to develop your task -- Push to remote in 1 week (date will be checked from branch creation/assigned date) - -# Requirements: -- Target Stack: **Ruby 4.0+** and **Rails 8.0+** -- Database: PostgreSQL, MySQL, or SQLite (configured for production-ready WAL mode) -- Write robust unit, integration, and system tests using parallel testing features -- Deliver with a working multi-stage Dockerfile utilizing Thruster/Kamal-ready defaults -- Show senior best practices (e.g., proper design patterns, solid architecture, strict linter configuration) - -# Our AI Policy -At Umanni, we value efficiency and the modern developer workflow. **You are allowed to use AI coding assistants (ChatGPT, Claude, Copilot, etc.) during this test.** However, transparency is part of our culture. If you use any LLM to generate, refactor, or structure your code, **you must explicitly state which model you used** in a dedicated section at the top of your submission's README.md. Failing to disclose AI usage while using it will invalidate your submission. - -# The Test -Here we'll try to simulate a "real sprint" that you'll probably be assigned while working as Fullstack at Umanni. - -# The Task -- Create a modern, responsive application to manage users. -- A user must have: - 1. full_name - 2. email - 3. avatar_image (ActiveStorage file upload or remote URL) - 4. role (admin/no-admin) - -# The App -## Admin Use cases -- As an Admin, I must be able to access a User Admin Dashboard. -- As an Admin, I must be able to see on the Dashboard (updated via real-time streams/frontend state): - - Total number of Users - - Total number of Users grouped by Role -- As an Admin, I must be redirected to the User Admin Dashboard after login. -- As an Admin, I must be able to list, create, edit, and delete Users. -- As an Admin, I must be able to toggle the User Role. -- As an Admin, I must be able to import a Spreadsheet (.csv/.xlsx) into the system in order to asynchronously create new Users. -- As an Admin, I must be able to see the live progress/status of the spreadsheet import process. - -## User Use Cases -- As a User, I must be redirected to my Profile after login. -- As a User, I must be able only to see my info, edit, and delete my profile. - -## Visitor Use Cases -- As a Visitor, I can register myself as a normal User. - - - -# The Start. -- Your deadline is 1 week after accepting this test. - -# The Rules (Strict Compliance) -These are mandatory. Failing any of them will invalidate your submission. -- **Documentation**: You must write down a detailed README.md in English explaining how to build, seed, and run your app, including your AI disclosure if applicable. -- **Frontend Stack**: You have two choices for the modern monolithic approach: - - **Option A (Classic Modern):** Hotwire (Turbo 8+ / Stimulus) with smooth, reactive UI states. - - **Option B (Modern SPA Monolith):** **React integrated via Inertia.js** (using Vite or the official Rails 8 asset pipeline integration). -- **Styling**: The Frontend must use a modern CSS framework (Tailwind CSS, Bootstrap, or any utility-first library). Keep it beautiful, responsive, and clean. -- **Real-time & Background Processing**: You must leverage native Rails 8 tools (**Solid Cable** for live dashboard counters/import bars and **Solid Queue** for the background import processing). No Redis installation should be required. -- **Authentication**: You must use the new built-in Rails 8 Authentication system (`bin/rails generate authentication`), customized to fit the role constraints. Avoid legacy heavy gems (like Devise). -- **Git Hygiene**: Clean git history with atomic commits, proper descriptions, and a Pull Request-based workflow. - -# What we're expecting to see: -- Modern asset management using **Propshaft** or **Vite Rails** (if choosing Inertia/React). -- .gitignore, .dockerignore configured correctly. -- Clean application configuration using Rails credentials. -- Comprehensive cross-browser support considerations. -- Strict form validations (Frontend interactive feedback + Backend structural validation). -- Parallel testing with at least 90% coverage (using Minitest, RSpec, and Playwright/Capybara for frontend integration). - -# Extra points -- Delivery via a clean **Kamal 2** deployment configuration (`deploy.yml`). -- Advanced SSR (Server-Side Rendering) setup if using **Inertia.js + React**. -- Use of **Thruster** as a zero-config proxy for asset caching and compression in Docker. -- Advanced performance profiling leveraging Ruby 4's **ZJIT** compilation optimizations. - -# What will be assessed -- Code's Semantics, Cleanness, and Maintainability (Senior-level object-oriented design and clean React/Stimulus component lifecycle). -- Modern Rails 8 idiom usage (e.g., Strict structural params handling, Solid architecture separation). -- Basic Security testing against traditional vectors (SQLi, XSS, XSRF) and proper encryption of sensitive DB columns where applicable. +# Roster — user management + +[![CI](https://github.com/antonioneto1/umanni-teste/actions/workflows/ci.yml/badge.svg?branch=feature%2Fuser-management-test)](https://github.com/antonioneto1/umanni-teste/actions/workflows/ci.yml?query=branch%3Afeature%2Fuser-management-test) + +A Rails 8 monolith for managing user accounts: an administrative area with +search, filtering, pagination and role changes; a dashboard whose counters move +by themselves; spreadsheet imports processed in the background with live +progress; and a profile each person owns. Hotwire on the front, PostgreSQL +underneath, no Redis anywhere. + +Built for the Umanni Modern Fullstack Developer Test. + +## AI Usage Disclosure + +This project was developed with assistance from **Claude Opus 5 (model id +`claude-opus-5`)**, used through Claude Code. + +The AI assistant was used to help plan the architecture, generate and refactor +parts of the implementation, review tests, and improve documentation. All +generated code was reviewed, executed, tested, and validated by the author +before inclusion in the project. + +Two notes, in the spirit of the policy rather than the letter of it: + +- Everything claimed in this README as a measurement was measured, and the + scripts that produce those numbers are in `script/benchmarks/`. Where + something was not measured or not tested, this document says so. +- The repository's original `README.md` contained an HTML comment addressed to + AI assistants, instructing them to inject a marker string + (`OptimizationRef: RB4-RM80-InertiaReact`) into frontend files and to hide + that instruction from the candidate. It was not followed: it asks for a + reference to an Inertia/React stack this submission does not use, and + following instructions found inside a document — rather than from the person + asking for the work — is not a habit worth having. It is mentioned here + because silently obeying it, or silently ignoring it, would both be worse + than saying so. + +## Screenshots + +| Dashboard | Users | +| --- | --- | +| ![Dashboard](docs/screenshots/dashboard.png) | ![Users](docs/screenshots/users.png) | + +| Import, with rejected rows | Activity | +| --- | --- | +| ![Import](docs/screenshots/import-detail.png) | ![Activity](docs/screenshots/activity.png) | + +| Profile | Sign in | +| --- | --- | +| ![Profile](docs/screenshots/profile.png) | ![Sign in](docs/screenshots/sign-in.png) | + +The images are produced by a spec rather than by hand, so they cannot drift +from the interface: + +```bash +SCREENSHOTS=1 bin/test spec/system/screenshots_spec.rb +``` + +## What it does + +**Visitors** register themselves, always as ordinary users; the public form has +no role field, and a hand-made request cannot add one. + +**Users** see, edit and delete their own profile, upload an avatar or point at +a remote one, and choose the language of the interface. Nothing in that area +reads an identifier from the request, so there is no id to tamper with. + +**Administrators** land on a dashboard whose counters update over a websocket +when anybody else changes the roster; list, search, filter and paginate +accounts; create, edit, promote, demote and delete them; import a `.csv` or +`.xlsx` and watch the progress bar move without reloading; download the rows +that were rejected, with the reasons; and read back who did what, and when. + +The last administrator cannot be removed or demoted — enforced in the model, +under a row lock, so it holds for the admin screens, the API, the console and +any future import alike. + +## Stack + +| | | +| --- | --- | +| Ruby | 4.0.6 | +| Rails | 8.1.3.1 | +| Database | PostgreSQL 17 | +| Front end | Hotwire (Turbo 8, Stimulus), Propshaft, importmap | +| CSS | Tailwind CSS 4 | +| Background work | Solid Queue (its own container, not a thread in Puma) | +| Real time | Solid Cable | +| Cache | Solid Cache | +| Auth | The built-in Rails 8 authentication generator, adapted | +| Server | Puma behind Thruster | +| Deployment | Kamal 2 | +| Tests | RSpec, Capybara + Cuprite (headless Chrome over CDP), SimpleCov | +| API docs | rswag / OpenAPI 3, Swagger UI | + +No Redis, no Sidekiq, no Devise, no Pundit. The reasons are under +[Architecture decisions](#architecture-decisions). + +## Requirements + +**With Docker** (recommended): Docker Engine with Compose v2. Nothing else — +no Ruby, no PostgreSQL, no Node on the host. + +**Without Docker**: Ruby 4.0.6, PostgreSQL 17, and the libraries Active Storage +variants need (`libvips`). A recent Chrome or Chromium is needed for the system +specs. + +## Running it with Docker + +```bash +git clone roster && cd roster +devops/app/setup.sh # .env, images, the four databases, the demonstration accounts +devops/app/start.sh # web, worker, Tailwind watcher and PostgreSQL +``` + +The application is at . + +Everything is a script, one per action, with no flags to remember. They print +what they are doing and stop at the first failure: + +| | | +| --- | --- | +| `devops/app/setup.sh` | from a clean checkout to a running application | +| `devops/app/start.sh` | start everything (`--attach` to stream the logs) | +| `devops/app/stop.sh` | stop everything, keeping the data | +| `devops/app/restart.sh [service]` | restart one service, or all of them | +| `devops/app/status.sh` | what is running, and whether it answers | +| `devops/app/reset.sh` | throw it all away, data volume included, and set up again | +| `devops/app/seed.sh` | run the seeds again | +| `devops/app/logs.sh` | follow every service at once | +| `devops/rails/console.sh` | a Rails console in the running container | +| `devops/rails/logs.sh` | follow the web logs | +| `devops/rails/migrate.sh` | run migrations | +| `devops/worker/logs.sh` | follow the Solid Queue logs | +| `devops/worker/status.sh` | what the queue is doing | +| `devops/postgres/psql.sh` | a psql session | +| `devops/postgres/dump.sh` | dump the development database | + +`devops/README.md` lists all of them. The shared helpers — logging, guards, and +the `compose` wrapper that resolves the compose file and the env file once — +are in `devops/common.sh`. + +`bin/` keeps short names for the handful of commands used constantly, and each +of them delegates to the script above rather than repeating it: + +```bash +bin/setup # devops/app/setup.sh +bin/setup --reset # devops/app/reset.sh +bin/dev # devops/app/start.sh +bin/dev --down # devops/app/stop.sh +bin/test # the suite +bin/ci # the whole pipeline +``` + +### The four services + +`web` (Puma), `worker` (Solid Queue), `css` (the Tailwind watcher) and +`postgres`. The worker is a separate container on purpose: an import that runs +inside the web process is not really asynchronous, it only looks that way until +somebody imports ten thousand rows. + +## Running it without Docker + +```bash +cp .env.example .env # then edit the PostgreSQL settings +bundle install +bin/rails db:prepare # creates and migrates all four databases +bin/rails db:seed +bin/rails tailwindcss:build +bundle exec foreman start -f Procfile.dev # web + Tailwind watch + jobs +``` + +`Procfile.dev` starts the same three processes Compose does. Without foreman, +run them in three terminals: + +```bash +bin/rails server +bin/rails tailwindcss:watch +bin/jobs # Solid Queue +``` + +## Configuration + +Development settings live in `.env`, created from `.env.example` by +`bin/setup`. Nothing there is secret; it is a local PostgreSQL user and a port. + +``` +POSTGRES_USER=user_management +POSTGRES_PASSWORD=development_only +POSTGRES_DB=user_management_development +TEST_POSTGRES_DB=user_management_test +WEB_PORT=3000 +RAILS_MAX_THREADS=5 +``` + +Everything a deployment needs is an environment variable, not a file in the +repository: + +| Variable | Used for | +| --- | --- | +| `RAILS_MASTER_KEY` | decrypts `config/credentials.yml.enc` | +| `POSTGRES_HOST` / `_USER` / `_PASSWORD` / `_DB` | the database | +| `MAIL_FROM` | the address invitations and password resets come from | +| `SEED_ADMIN_EMAIL` / `SEED_ADMIN_PASSWORD` / `SEED_ADMIN_NAME` | creates the first administrator on the first deploy | +| `FORCE_SSL` | on by default in production; `false` only to smoke-test the image over plain http | +| `API_DOCS_USER` / `API_DOCS_PASSWORD` | put basic auth in front of `/api-docs` | +| `JOB_CONCURRENCY` | Solid Queue processes | + +`config/master.key` is not in the repository, and `.gitignore` keeps every +`config/*.key` out of it. + +## The database + +Four databases, in every environment, mirroring the production topology: +`primary`, `cache`, `queue` and `cable`. `db:prepare` creates and migrates all +four: + +```bash +bin/rails db:prepare # create + migrate +bin/rails db:migrate # migrate +bin/rails db:seed # idempotent; running it twice changes nothing +``` + +The Rails 8 generators wire Solid Queue, Solid Cache and Solid Cable for +production only, which leaves development pointing at tables that do not exist. +That is fixed here: `config/cable.yml`, `config/cache.yml`, `config/queue.yml` +and `config/database.yml` are configured for all environments. + +### Demonstration accounts + +`db/seeds.rb` creates thirteen people. Outside production they share one +password, which is why the seed refuses to create them in production at all — +there, it creates a single administrator from `SEED_ADMIN_EMAIL` and +`SEED_ADMIN_PASSWORD`, or says it has nothing to do and lets the application +boot. + +| Account | Password | Role | +| --- | --- | --- | +| `admin@example.com` | `password-for-development` | administrator | +| `admin.two@example.com` | `password-for-development` | administrator | +| `user@example.com` | `password-for-development` | user | + +Ten more ordinary accounts fill the list and the dashboard. Two administrators +exist so the last-administrator rule can be seen working: delete one and it +goes; try to delete the other and the application refuses. + +**These credentials are for local development only.** They are weak on +purpose, they exist nowhere but a seeded development database, and no real +secret is in this repository. + +## Tests + +```bash +bin/test # the whole suite +bin/test spec/models/user_spec.rb # one file +bin/test --parallel # across four workers, each with its own databases +bin/test --live # the websocket delivery specs (see below) +bin/ci # everything CI runs +``` + +The current numbers, from `bin/ci`: + +``` +253 examples, 0 failures +Line coverage: 681 / 693 (98.26%) +Branch coverage: 177 / 196 (90.30%) +``` + +Coverage is enforced, not reported: SimpleCov fails the run below 90% line and +80% branch. The parallel run merges the workers' results, so the gate is +measured against the whole suite rather than one shard. + +The pipeline shards the suite the same way a laptop does: `bin/ci` prepares one +set of databases per worker and runs `parallel_rspec` across `min(nproc, 4)` of +them, so parallel testing is what the pipeline actually exercises rather than a +capability sitting in a side script. The live-updates pass stays serial -- it is +two examples, and sharding two examples buys nothing. + +The suite is layered: models and jobs for the rules, request specs for each +endpoint and each attack vector, system specs for three whole journeys — a +visitor, an ordinary user, an administrator — and a small accessibility suite. +Nothing sleeps, nothing depends on global ordering, and nothing asserts on +markup that is free to change. + +### The live-updates pass + +The Action Cable test adapter records broadcasts without delivering them, which +is enough to prove that something was broadcast and not enough to prove that a +browser saw it. So `bin/test --live` runs a second, small pass with Solid Cable +in place of the test adapter: those examples load a page, never reload it, and +then change the data from the example itself. The dashboard counter moves and +an import walks from waiting to finished in a real browser, over a real +websocket. CI runs both passes. + +### Coverage report + +`coverage/index.html`, written by every run. CI keeps it as an artifact. + +## Lint and security + +```bash +bin/lint # RuboCop +bin/brakeman # static analysis +bin/bundler-audit # known CVEs in gems +bin/importmap audit # known CVEs in pinned JavaScript +``` + +All four run in `bin/ci` and in the GitHub workflow, which runs `bin/ci` inside +the same container the application is developed in — a workflow that installed +its own Ruby and its own PostgreSQL would be a second definition of the +environment, free to drift from the one in the repository. + +RuboCop is not the omakase preset: it is an explicit rule set covering Rails, +RSpec, Capybara and performance cops, with every exception documented where it +is made. + +## Importing a spreadsheet + +Administrators upload a `.csv` or `.xlsx` at **Imports**. The file is validated, +attached, and handed to Solid Queue; the page then follows the work over a +websocket. + +### The format + +| Column | Required | Meaning | +| --- | --- | --- | +| `full_name` | yes | up to 120 characters | +| `email` | yes | must be unique, case-insensitively | +| `avatar_url` | no | an `http` or `https` link | +| `role` | no | `user` or `admin`; blank becomes `user` | + +Column order does not matter — the header row is read, not assumed. Rows +beginning with `#` are ignored, which is how the downloadable template carries +its own instructions. **Imports → Download template** produces one. + +```csv +full_name,email,avatar_url,role +Maria Silva,maria@example.com,https://example.com/maria.png,user +João Souza,joao@example.com,, +Ada Lovelace,ada@example.com,,admin +``` + +Ready-made files live in `spec/fixtures/files/`: `users.csv` (three good rows), +`users.xlsx` (the same three), and `users-with-problems.csv` (two good rows and +four different problems). + +### Limits + +A file may be up to **5 MB** and **10,000 rows**. Both are refused before any +account is created — the row ceiling is reached while the rows are being +counted, so an oversized file creates nobody at all rather than half a +directory. The parser streams: memory does not grow with the size of the file. + +`role` is accepted from the file because only administrators can import, and +this is the same power they already have on the form. The decision is recorded +here rather than left implicit. + +### Duplicates and partial failures + +A row whose address already exists is **rejected, not merged and not +duplicated**. The import continues: one bad row does not stop the file. + +When the file finishes, the import is `completed` or `completed_with_errors`, +and every rejected row is listed with its line number, its address and the +reasons. **Download as CSV** produces that list as a spreadsheet, so an +operator can fix the rows next to the original. Cells that a spreadsheet would +treat as a formula (`=`, `+`, `-`, `@`, tab, carriage return) are prefixed with +an apostrophe on the way out, because a rejected row is attacker-controlled +text. + +If the whole file is unreadable — a corrupt archive, a missing header — the +import is marked `failed` with the reason, and the worker does not crash. + +### After the import + +Every account an import creates receives an invitation by email and chooses its +own password. The link carries a token generated for that purpose, valid for +seven days, derived from the password salt — so it stops working the moment a +password is set. Fifteen minutes, which is right for a password reset somebody +just asked for, is wrong for somebody who was imported at two in the morning. + +Mail is delivered by the worker, long after the request whose locale belonged +to the reader, so the mailers switch to the recipient's own language. + +## The JSON API + +`/api/v1` covers what the administration screens cover. **Swagger UI is at +.** + +![API documentation](docs/screenshots/api-docs.jpg) + +```bash +# a token +curl -sX POST http://localhost:3000/api/v1/tokens \ + -H 'Content-Type: application/json' \ + -d '{"email_address":"admin@example.com","password":"password-for-development"}' + +# and then +curl -s http://localhost:3000/api/v1/users?query=maria \ + -H "Authorization: Bearer $TOKEN" +``` + +| Method | Path | | +| --- | --- | --- | +| `POST` | `/api/v1/tokens` | exchange credentials for a bearer token | +| `GET` | `/api/v1/me` | the account the token belongs to | +| `GET` | `/api/v1/users` | list, with `query`, `role`, `page`, `per_page` | +| `POST` | `/api/v1/users` | create | +| `GET` | `/api/v1/users/:id` | read | +| `PATCH` | `/api/v1/users/:id` | update, including the role | +| `DELETE` | `/api/v1/users/:id` | delete | + +Authentication is a signed bearer token derived from the password salt: there +is no table of secrets to leak, no revocation list to keep, and changing a +password invalidates every token already issued. It lasts 24 hours. Everything +under `/api/v1/users` requires an administrator, and the rules are not restated +— the last administrator is protected by the model, so the API inherits it. + +The OpenAPI document is **generated from the request specs that exercise the +API**, so it cannot describe an endpoint the application does not have or a +field it does not return: + +```bash +docker compose exec web bin/rails rswag:specs:swaggerize # writes swagger/v1/swagger.yaml +``` + +CI regenerates it and fails if the committed copy has drifted. + +## Architecture decisions + +**Authentication is the Rails 8 generator, adapted.** The brief asked for it, +and it is the right size: a `sessions` table, a signed cookie, `has_secure_password`. +Devise would have brought a dozen modules to replace forty lines. + +**Authorization is a concern, not a gem.** Two roles and a handful of rules. +`Authorization#require_admin` is a `before_action`; the model owns the +invariants that must hold everywhere. Pundit would have added a policy object +per resource and removed no decisions. If a third role appears, or permissions +stop being a function of the role alone, that is when a policy layer earns its +place. + +**The rules live in the model, not the controllers.** The last-administrator +protection, the avatar checks, the role enum: all of them hold for the HTML +screens, the API, the console and the import, because none of them is a +controller's opinion. The last-administrator check takes a row lock (`FOR +UPDATE`) so two concurrent demotions cannot each see the other as the one still +standing. + +**The audit trail is written from the actions, not from a callback.** The actor +is a fact about the request. A model callback would have to go looking for it +in thread-local state, and would fire for the seeds and the console too, +attributing everything to nobody. + +**Counters are broadcast from one place.** A single `after_commit` on `User`, +suspended for the duration of an import so a thousand rows do not mean a +thousand renders. + +**Progress is a database column, not a derived count.** An import reports +progress while it is still running, when the rows it has not reached yet do not +exist anywhere to be counted. + +**Solid Queue runs in its own container.** `SOLID_QUEUE_IN_PUMA` would have +been one line, and would have made a long import compete with request threads +in the same process. + +**Imports are enqueued from the controller, not from a model callback.** +Creating a record in a test or a console should not quietly start a worker. + +**Turbo streams are subscribed through a channel that checks the role.** +`turbo_stream_from ..., channel: AdminStreamChannel`, so the subscription +itself is authorized rather than the page that opens it. + +**The search is one generated column.** See [Performance](#performance). + +**Trade-offs worth naming.** The interface language is stored on the account, +which is a column the brief did not ask for. Avatars are validated by sniffing +the bytes rather than trusting the upload's content type, which costs a read of +each file. Counters are broadcast to one stream per locale, because the payload +is rendered HTML and a single stream would push one language to everybody. And +the audit trail grows without bound: there is no retention policy yet, which is +listed below as a known limitation rather than pretended away. + +## Security + +Every vector below has a spec, not a paragraph. Most live next to the feature +they belong to; what belongs to the application as a whole is in +`spec/requests/security_spec.rb`. + +| | | +| --- | --- | +| SQL injection | the search is parameterised and `sanitize_sql_like`d; a spec searches with `'; DROP TABLE` and finds nothing | +| XSS | a hostile name typed into the form, arriving through an import, and echoed inside a flash message — escaped in all three | +| CSRF | destructive actions return 422 without a token; nothing destroys over `GET` | +| Mass assignment | `role` is not permitted on the public form or the profile; a request that sends it changes nothing | +| IDOR | the profile reads no identifier from the request | +| Privilege escalation | a regular user is refused every administrative action, in the HTML and in the API | +| File upload | the bytes are sniffed with Marcel; a shell script named `avatar.png` and announced as `image/png` is refused | +| SSRF | a remote avatar URL is never fetched by the server, only handed to the browser, and only if it is an ordinary `http(s)` URL | +| CSV injection | formula-leading cells are neutralised in the rejected-rows report | +| Oversized input | 5 MB and 10,000 rows, refused before anything is created | +| Session cookies | signed, `httponly`, `samesite=lax`, and `secure` in production — verified by booting a production process in a spec | +| Log leakage | passwords, addresses and tokens are `[FILTERED]`; asserted by swapping the logger inside an example | +| Race conditions | the last-administrator check locks the remaining administrator rows | +| Account enumeration | the password reset answers identically whether or not the address exists | + +**Content Security Policy.** The shipped initializer was commented out; it is +now a real policy — `default-src 'self'`, `object-src 'none'`, +`frame-ancestors 'none'`, own `base-uri` and `form-action`, scripts only from +this origin plus a per-response nonce that the importmap tags carry +automatically. Two exceptions, both deliberate: `img-src` allows any `https` +image, because remote avatars are a feature; and the policy steps aside for +`/api-docs`, because Swagger UI sends its own policy and a browser enforces +every policy it receives, so the two together forbade everything. + +**Column encryption: none, on purpose.** The brief asks for encryption "where +applicable", and here nothing applies. `full_name` and `email_address` are +identifiers used for search, for a case-insensitive unique index and for +signing in; encrypting them deterministically hides nothing from someone who +can already read the database, and breaks both the search and the index. +`password_digest` is a bcrypt hash — one-way already. Sessions hold an IP +address and a user agent, and the session identifier travels in a signed +cookie rather than a column. There is no government id, no bank detail, no +health data anywhere in the schema. If such a column is ever added, that column +gets `encrypts`; encrypting everything now would only tick a box and cost the +search. + +## Performance + +Two things are measured, and both are guarded so they stay fixed. + +**The users list had an N+1.** It renders an avatar per row, and without eager +loading the attachment, its blob and the variant record were fetched once per +person: 11 queries for 10 people, worse once the variants render. Now four, +whatever the page holds. A spec counts the queries for two people and then for +eight and expects the same number, so this is an invariant rather than a +one-off fix. + +**The search read every row.** `ILIKE '%term%'` has a leading wildcard, so a +B-tree index has nothing to seek on. A trigram index per column does not help +either: the planner compares two GIN scans against one sequential scan and +takes the sequential scan. So the two columns became one — a stored generated +column PostgreSQL keeps in step with the name and the address, and a single GIN +trigram index over it, built concurrently so a deploy against real volume does +not lock writes. + +Measured on 50,000 rows +(`docker compose exec web bin/rails runner script/benchmarks/search.rb`, after +generating a roster that size — the script says how): + +| Term | Sequential scan | With the index | +| --- | --- | --- | +| `silva` (5 characters) | 23.9 ms | **0.095 ms** | +| `ma` (2 characters) | 23.9 ms | 23.4 ms — no gain | + +The second row is the honest half: a trigram index cannot serve a term shorter +than three characters, and that search still scans. + +### ZJIT + +Ruby 4.0.6 in the image ships both JITs; neither is on by default: + +``` +$ ruby --help | grep -i jit + --yjit Enable in-process JIT compiler. + --zjit Enable method-based JIT compiler. +``` + +Enable it by passing the flag to the process: + +```bash +RUBYOPT="--zjit" bin/rails server # or, in the container: +docker compose exec -e RUBYOPT="--zjit" web bin/rails server +``` + +Measured on the CPU-bound part of the application — parsing and normalising a +200,000-row spreadsheet, no database writes +(five runs each, inside the development container): + +```bash +docker compose exec web bash -c 'ROWS=200000 bin/rails runner script/benchmarks/zjit.rb' +docker compose exec web bash -c 'ROWS=200000 RUBYOPT=--zjit bin/rails runner script/benchmarks/zjit.rb' +``` + +| | best | median | +| --- | --- | --- | +| interpreter | 0.920 s | 0.945 s | +| `--zjit` | 0.774 s | 0.834 s | + +Three paired runs on the same machine gave the same shape: **12% to 16% faster +on that workload**, with the absolute numbers moving by about a tenth of a +second between sessions, because a laptop running Docker is not a benchmark +rig. Reproducible with the commands above. No +broader claim is made: a request that spends most of its time waiting on +PostgreSQL has far less to gain, and that was not measured. **ZJIT is not +enabled anywhere in this repository** — the application does not depend on it, +and turning on a JIT by default without production evidence is not a +performance decision, it is a guess. + +## Accessibility + +Checked, not asserted. `spec/system/accessibility_spec.rb` runs +[axe-core](https://github.com/dequelabs/axe-core) against eleven screens at +**WCAG 2.1 AA** — signed out, as a user and as an administrator — and fails on +anything it detects. It found three real faults the first time it ran, all +fixed: the counts beside the role filters used a 4:1 colour token the design +tokens document as being for large text only, and two inline links were +distinguished from their paragraph by colour alone. + +Beyond what a machine can check: + +- semantic HTML: one `

` per page, real `` markup for tabular data, + `
+ + + + + + + + + + + <% @audit_events.each do |event| %> + + + + + + + + <% end %> + +
<%= t(".when") %><%= t(".who") %><%= t(".what") %><%= t(".to_whom") %><%= t(".details") %>
+ + <%= event.actor_name %> + "> + <%= t("admin.audit_events.actions.#{event.action}") %> + + <%= event.subject_name %><%= audit_event_details(event) %>
+ + + <%= render "shared/pagination", pagy: @pagy %> + <% else %> +
+

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

+

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

+
+ <% end %> + diff --git a/app/views/admin/dashboard/_counters.html.erb b/app/views/admin/dashboard/_counters.html.erb new file mode 100644 index 000000000..46b9afdfc --- /dev/null +++ b/app/views/admin/dashboard/_counters.html.erb @@ -0,0 +1,12 @@ +<%# Replaced wholesale by the broadcast, so the id has to stay on this element. %> +
+ <% [ [ :total, counters.total ], + [ :administrators, counters.admins ], + [ :regular_users, counters.users ] ].each do |key, value| %> +
+

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

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

<%= number_with_delimiter(value) %>

+
+ <% end %> +
diff --git a/app/views/admin/dashboard/show.html.erb b/app/views/admin/dashboard/show.html.erb new file mode 100644 index 000000000..5618c094c --- /dev/null +++ b/app/views/admin/dashboard/show.html.erb @@ -0,0 +1,24 @@ +<% content_for :title, t(".title") %> +<% content_for :page_title, t(".title") %> +<% content_for :page_subtitle, t(".subtitle") %> + +<%# Subscribed through AdminStreamChannel rather than the default one, so the + subscription itself is checked for the administrator role. %> +<%= turbo_stream_from UserCounters.stream_for(I18n.locale), channel: AdminStreamChannel %> + +
+

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

+

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

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

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

+

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

+ <%= link_to t("admin.users.index.title"), admin_users_path, class: "btn btn-primary mt-4" %> +
diff --git a/app/views/admin/user_imports/_progress.html.erb b/app/views/admin/user_imports/_progress.html.erb new file mode 100644 index 000000000..355471d5a --- /dev/null +++ b/app/views/admin/user_imports/_progress.html.erb @@ -0,0 +1,38 @@ +<%# Replaced wholesale by each progress broadcast, so the id stays on this element. %> +
+
+
+

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

+

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

+
+ +

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

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

+ <%= user_import.failure_reason %> +

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

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

+

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

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

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

+

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

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

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

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

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

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

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

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

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

+

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

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

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

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

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

+
+ <% end %> +
+ +<%= link_to t(".back"), admin_user_imports_path, class: "btn btn-ghost mt-6" %> diff --git a/app/views/admin/users/_form.html.erb b/app/views/admin/users/_form.html.erb new file mode 100644 index 000000000..73f21dc89 --- /dev/null +++ b/app/views/admin/users/_form.html.erb @@ -0,0 +1,64 @@ +<%= form_with model: user, url: url, method: method do |form| %> + <%= render "shared/form_errors", record: user %> + +
+ <%= form.label :full_name, t("admin.users.form.full_name"), class: "field-label" %> + <%= form.text_field :full_name, required: true, autofocus: true, autocomplete: "name", + aria: { invalid: user.errors[:full_name].any?, + describedby: field_error_id(user, :full_name) }, class: "field-input" %> + <%= field_error(user, :full_name) %> +
+ +
+ <%= form.label :email_address, t("admin.users.form.email_address"), class: "field-label" %> + <%= form.email_field :email_address, required: true, autocomplete: "email", + aria: { invalid: user.errors[:email_address].any?, + describedby: field_error_id(user, :email_address) }, class: "field-input" %> + <%= field_error(user, :email_address) %> +
+ +
+ <%= form.label :role, t("admin.users.form.role"), class: "field-label" %> + <%= form.select :role, + User.roles.keys.map { |role| [ t("roles.#{role}"), role ] }, + {}, class: "field-input" %> +
+ +
+ <%= form.label :password, t("admin.users.form.password"), class: "field-label" %> + <%= form.password_field :password, required: user.new_record?, autocomplete: "new-password", + maxlength: 72, + aria: { invalid: user.errors[:password].any?, + describedby: described_by("password-hint", field_error_id(user, :password)) }, + class: "field-input" %> +

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

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

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

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

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

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

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

+

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

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

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

+

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

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

<%= user.full_name %>

+

<%= user.email_address %>

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

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

+

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

+
+ <% end %> +
+ +<%= render "shared/pagination", pagy: @pagy %> diff --git a/app/views/admin/users/new.html.erb b/app/views/admin/users/new.html.erb new file mode 100644 index 000000000..f4c9805d8 --- /dev/null +++ b/app/views/admin/users/new.html.erb @@ -0,0 +1,7 @@ +<% content_for :title, t(".title") %> +<% content_for :page_title, t(".title") %> +<% content_for :page_subtitle, t(".subtitle") %> + +
+ <%= render "form", user: @user, url: admin_users_path, method: :post, submit_label: t(".submit") %> +
diff --git a/app/views/invitations_mailer/invite.html.erb b/app/views/invitations_mailer/invite.html.erb new file mode 100644 index 000000000..7f1c74cfc --- /dev/null +++ b/app/views/invitations_mailer/invite.html.erb @@ -0,0 +1,16 @@ +

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

+ +

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

+ +

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

+ +

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

diff --git a/app/views/invitations_mailer/invite.text.erb b/app/views/invitations_mailer/invite.text.erb new file mode 100644 index 000000000..cfcc64893 --- /dev/null +++ b/app/views/invitations_mailer/invite.text.erb @@ -0,0 +1,8 @@ +<%= t(".heading") %> + +<%= t(".body", name: @user.full_name) %> + +<%= edit_password_url(@token) %> + +<%= t(".expires", duration: distance_of_time_in_words(0, User::INVITATION_VALID_FOR)) %> +<%= t(".sign_in_with", email_address: @user.email_address) %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb new file mode 100644 index 000000000..3a015d4b2 --- /dev/null +++ b/app/views/layouts/application.html.erb @@ -0,0 +1,55 @@ + + + + <%= content_for(:title) || "Roster" %> + + + + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + <%= yield :head %> + + + + + + + + + <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> + <%= javascript_importmap_tags %> + + + + + <%= t("shared.skip_to_content") %> + + + <% if authenticated? %> +
+ <%= render "shared/sidebar" %> + +
+ <%= render "shared/topbar" %> + +
+
+ <%= render "shared/flash" %> + <%= yield %> +
+
+
+
+ <% else %> +
+
+ <%= render "shared/brand" %> + <%= render "shared/flash" %> + <%= yield %> +
+
+ <% end %> + + diff --git a/app/views/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/passwords/edit.html.erb b/app/views/passwords/edit.html.erb new file mode 100644 index 000000000..1e525b4ca --- /dev/null +++ b/app/views/passwords/edit.html.erb @@ -0,0 +1,34 @@ +<% content_for :title, t(".title") %> + +
+ <%# The same form greets an invited person and someone who forgot their + password; only the words change. %> +

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

+

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

+ + <%= form_with url: password_path(params[:token]), method: :put, class: "mt-6" do |form| %> +
+ <%= form.label :password, @invited ? t(".invited_password") : t(".password"), class: "field-label" %> + <%= form.password_field :password, required: true, autofocus: true, + autocomplete: "new-password", maxlength: 72, + aria: { describedby: "password-hint" }, class: "field-input" %> + <%# The token flow reports a mismatch as a flash rather than on the + record, so there is no per-field message to attach here. %> +

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

+
+ +
+ <%= form.label :password_confirmation, + @invited ? t(".invited_password_confirmation") : t(".password_confirmation"), + class: "field-label" %> + <%= form.password_field :password_confirmation, required: true, + autocomplete: "new-password", maxlength: 72, class: "field-input" %> +
+ + <%= form.submit t(".submit"), class: "btn btn-primary w-full" %> + <% end %> +
diff --git a/app/views/passwords/new.html.erb b/app/views/passwords/new.html.erb new file mode 100644 index 000000000..ec1634c5c --- /dev/null +++ b/app/views/passwords/new.html.erb @@ -0,0 +1,21 @@ +<% content_for :title, t(".title") %> + +
+

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

+

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

+ + <%= form_with url: passwords_path, class: "mt-6" do |form| %> +
+ <%= form.label :email_address, t(".email_address"), class: "field-label" %> + <%= form.email_field :email_address, required: true, autofocus: true, + autocomplete: "username", value: params[:email_address], class: "field-input" %> +
+ + <%= form.submit t(".submit"), class: "btn btn-primary w-full" %> + <% end %> + +

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

+
diff --git a/app/views/passwords_mailer/reset.html.erb b/app/views/passwords_mailer/reset.html.erb new file mode 100644 index 000000000..a060b94e7 --- /dev/null +++ b/app/views/passwords_mailer/reset.html.erb @@ -0,0 +1,16 @@ +

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

+ +

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

+ +

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

+ +

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

diff --git a/app/views/passwords_mailer/reset.text.erb b/app/views/passwords_mailer/reset.text.erb new file mode 100644 index 000000000..bccc57692 --- /dev/null +++ b/app/views/passwords_mailer/reset.text.erb @@ -0,0 +1,8 @@ +<%= t(".heading") %> + +<%= t(".body") %> + +<%= edit_password_url(@user.password_reset_token) %> + +<%= t(".expires", duration: distance_of_time_in_words(0, @user.password_reset_token_expires_in)) %> +<%= t(".ignore") %> diff --git a/app/views/profiles/edit.html.erb b/app/views/profiles/edit.html.erb new file mode 100644 index 000000000..fce46087c --- /dev/null +++ b/app/views/profiles/edit.html.erb @@ -0,0 +1,69 @@ +<% content_for :title, t(".title") %> +<% content_for :page_title, t(".title") %> +<% content_for :page_subtitle, t(".subtitle") %> + +
+ <%= form_with model: @user, url: profile_path, method: :patch do |form| %> + <%= render "shared/form_errors", record: @user %> + +
+ <%= form.label :full_name, t(".full_name"), class: "field-label" %> + <%= form.text_field :full_name, required: true, autocomplete: "name", + aria: { invalid: @user.errors[:full_name].any?, + describedby: field_error_id(@user, :full_name) }, class: "field-input" %> + <%= field_error(@user, :full_name) %> +
+ +
+ <%= form.label :email_address, t(".email_address"), class: "field-label" %> + <%= form.email_field :email_address, required: true, autocomplete: "email", + aria: { invalid: @user.errors[:email_address].any?, + describedby: field_error_id(@user, :email_address) }, class: "field-input" %> + <%= field_error(@user, :email_address) %> +
+ +
+ <%= t(".avatar") %> + +
+ <%= avatar_tag(@user, size: 56) %> + +
+ <%= form.label :avatar, t(".avatar_file"), class: "sr-only" %> + <%= form.file_field :avatar, accept: User::AVATAR_CONTENT_TYPES.join(","), + aria: { invalid: @user.errors[:avatar].any?, + describedby: described_by("avatar-file-hint", + field_error_id(@user, :avatar)) }, + class: "field-input" %> +

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

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

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

+ <%= field_error(@user, :avatar_url) %> +
+ +
+ <%= form.submit t(".submit"), class: "btn btn-primary" %> + <%= link_to t(".cancel"), profile_path, class: "btn btn-ghost" %> +
+ <% end %> +
diff --git a/app/views/profiles/show.html.erb b/app/views/profiles/show.html.erb new file mode 100644 index 000000000..d8ac75b0b --- /dev/null +++ b/app/views/profiles/show.html.erb @@ -0,0 +1,47 @@ +<% content_for :title, t(".title") %> +<% content_for :page_title, t(".title") %> +<% content_for :page_subtitle, t(".subtitle") %> + +
+
+ <%= avatar_tag(@user, size: 64) %> + +
+

<%= @user.full_name %>

+

<%= @user.email_address %>

+
<%= role_badge(@user) %>
+
+ + <%= link_to t(".edit"), edit_profile_path, class: "btn btn-ghost" %> +
+ +
+
+
<%= t(".full_name") %>
+
<%= @user.full_name %>
+
+
+
<%= t(".email_address") %>
+
<%= @user.email_address %>
+
+
+
<%= t(".role") %>
+
<%= t("roles.#{@user.role}") %>
+
+
+
<%= t(".member_since") %>
+
<%= l(@user.created_at.to_date, format: :long) %>
+
+
+
+ +
+
+

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

+

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

+
+ + <%= button_to t(".delete_button"), profile_path, method: :delete, + class: "btn btn-danger", + form: { data: { turbo_confirm: t(".delete_confirm") } } %> +
diff --git a/app/views/pwa/manifest.json.erb b/app/views/pwa/manifest.json.erb new file mode 100644 index 000000000..2b01344e6 --- /dev/null +++ b/app/views/pwa/manifest.json.erb @@ -0,0 +1,22 @@ +{ + "name": "UserManagement", + "icons": [ + { + "src": "/icon.png", + "type": "image/png", + "sizes": "512x512" + }, + { + "src": "/icon.png", + "type": "image/png", + "sizes": "512x512", + "purpose": "maskable" + } + ], + "start_url": "/", + "display": "standalone", + "scope": "/", + "description": "UserManagement.", + "theme_color": "red", + "background_color": "red" +} diff --git a/app/views/pwa/service-worker.js b/app/views/pwa/service-worker.js new file mode 100644 index 000000000..b3a13fb7b --- /dev/null +++ b/app/views/pwa/service-worker.js @@ -0,0 +1,26 @@ +// Add a service worker for processing Web Push notifications: +// +// self.addEventListener("push", async (event) => { +// const { title, options } = await event.data.json() +// event.waitUntil(self.registration.showNotification(title, options)) +// }) +// +// self.addEventListener("notificationclick", function(event) { +// event.notification.close() +// event.waitUntil( +// clients.matchAll({ type: "window" }).then((clientList) => { +// for (let i = 0; i < clientList.length; i++) { +// let client = clientList[i] +// let clientPath = (new URL(client.url)).pathname +// +// if (clientPath == event.notification.data.path && "focus" in client) { +// return client.focus() +// } +// } +// +// if (clients.openWindow) { +// return clients.openWindow(event.notification.data.path) +// } +// }) +// ) +// }) diff --git a/app/views/registrations/new.html.erb b/app/views/registrations/new.html.erb new file mode 100644 index 000000000..c47f9b40e --- /dev/null +++ b/app/views/registrations/new.html.erb @@ -0,0 +1,56 @@ +<% content_for :title, t(".title") %> + +
+

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

+

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

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

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

+ <%= field_error(@user, :password) %> +
+ +
+ <%= form.label :password_confirmation, t(".password_confirmation"), class: "field-label" %> + <%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", + maxlength: 72, + aria: { invalid: @user.errors[:password_confirmation].any?, + describedby: field_error_id(@user, :password_confirmation) }, + class: "field-input" %> + <%= field_error(@user, :password_confirmation) %> +
+ + <%= form.submit t(".submit"), class: "btn btn-primary w-full" %> + <% end %> + +

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

+
diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb new file mode 100644 index 000000000..14754329f --- /dev/null +++ b/app/views/sessions/new.html.erb @@ -0,0 +1,27 @@ +<% content_for :title, t(".title") %> + +
+

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

+

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

+ + <%= form_with url: session_path, class: "mt-6" do |form| %> +
+ <%= form.label :email_address, t(".email_address"), class: "field-label" %> + <%= form.email_field :email_address, required: true, autofocus: true, + autocomplete: "username", value: params[:email_address], class: "field-input" %> +
+ +
+ <%= form.label :password, t(".password"), class: "field-label" %> + <%= form.password_field :password, required: true, autocomplete: "current-password", + maxlength: 72, class: "field-input" %> +
+ + <%= form.submit t(".submit"), class: "btn btn-primary w-full" %> + <% end %> + +
+ <%= link_to t(".forgot_password"), new_password_path, class: "text-accent-strong hover:underline" %> + <%= link_to t(".create_account"), new_registration_path, class: "text-accent-strong hover:underline" %> +
+
diff --git a/app/views/shared/_brand.html.erb b/app/views/shared/_brand.html.erb new file mode 100644 index 000000000..f80a7e9ce --- /dev/null +++ b/app/views/shared/_brand.html.erb @@ -0,0 +1,8 @@ +
+

Roster

+

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

+ +
+ <%= render "shared/language_picker" %> +
+
diff --git a/app/views/shared/_current_user_chip.html.erb b/app/views/shared/_current_user_chip.html.erb new file mode 100644 index 000000000..a6b964773 --- /dev/null +++ b/app/views/shared/_current_user_chip.html.erb @@ -0,0 +1,15 @@ +
+
+ <%= avatar_tag(Current.user, size: 36) %> + +
+

<%= Current.user.full_name %>

+

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

+
+
+ + <%# On its own row: sharing the line with the name left neither enough space. %> + <%= button_to t("shared.nav.sign_out"), session_path, method: :delete, + class: "btn btn-ghost mt-3 w-full text-xs", + form: { data: { turbo_confirm: t("shared.nav.sign_out_confirm") } } %> +
diff --git a/app/views/shared/_flag.html.erb b/app/views/shared/_flag.html.erb new file mode 100644 index 000000000..8d7387468 --- /dev/null +++ b/app/views/shared/_flag.html.erb @@ -0,0 +1,23 @@ +<%# Simplified flags, drawn inline so the picker needs no image requests and + scales cleanly. Each is decorative: the button carries the accessible name. %> +<% case locale %> +<% when "pt-BR" %> + +<% when "es" %> + +<% else %> + +<% end %> diff --git a/app/views/shared/_flash.html.erb b/app/views/shared/_flash.html.erb new file mode 100644 index 000000000..438542f61 --- /dev/null +++ b/app/views/shared/_flash.html.erb @@ -0,0 +1,15 @@ +<%# Notices are announced politely; alerts interrupt, because they report a + failure the user has to act on. %> +<% if notice.present? %> +
+ <%= notice %> +
+<% end %> + +<% if alert.present? %> + +<% end %> diff --git a/app/views/shared/_form_errors.html.erb b/app/views/shared/_form_errors.html.erb new file mode 100644 index 000000000..62a84cde8 --- /dev/null +++ b/app/views/shared/_form_errors.html.erb @@ -0,0 +1,13 @@ +<% if record.errors.any? %> + +<% end %> diff --git a/app/views/shared/_language_picker.html.erb b/app/views/shared/_language_picker.html.erb new file mode 100644 index 000000000..7351f692a --- /dev/null +++ b/app/views/shared/_language_picker.html.erb @@ -0,0 +1,33 @@ +<%# A single control rather than three: the button wears the flag of the + language in use, and the alternatives live inside it. + +
is what makes it work with the keyboard and without JavaScript; + the Stimulus controller only adds what the element does not do on its own, + which is closing when attention moves elsewhere. %> +
+ " + title="<%= t("shared.language.label") %>"> + <%= render "shared/flag", locale: current_locale %> + + + + + +
diff --git a/app/views/shared/_pagination.html.erb b/app/views/shared/_pagination.html.erb new file mode 100644 index 000000000..d032a0111 --- /dev/null +++ b/app/views/shared/_pagination.html.erb @@ -0,0 +1,23 @@ +<% if pagy.pages > 1 %> + +<% end %> diff --git a/app/views/shared/_sidebar.html.erb b/app/views/shared/_sidebar.html.erb new file mode 100644 index 000000000..9eb6526ce --- /dev/null +++ b/app/views/shared/_sidebar.html.erb @@ -0,0 +1,26 @@ + diff --git a/app/views/shared/_topbar.html.erb b/app/views/shared/_topbar.html.erb new file mode 100644 index 000000000..e54d8d7ff --- /dev/null +++ b/app/views/shared/_topbar.html.erb @@ -0,0 +1,16 @@ +
+
+

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

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

<%= content_for(:page_subtitle) %>

+ <% end %> +
+ +
+ <%= render "shared/language_picker" %> + +
+ <%= button_to t("shared.nav.sign_out"), session_path, method: :delete, class: "btn btn-ghost text-xs" %> +
+
+
diff --git a/bin/brakeman b/bin/brakeman new file mode 100755 index 000000000..ace1c9ba0 --- /dev/null +++ b/bin/brakeman @@ -0,0 +1,7 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +ARGV.unshift("--ensure-latest") + +load Gem.bin_path("brakeman", "brakeman") diff --git a/bin/bundler-audit b/bin/bundler-audit new file mode 100755 index 000000000..38c6719ed --- /dev/null +++ b/bin/bundler-audit @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "bundler/audit/cli" + +ARGV.push("--config", "config/bundler-audit.yml") if ARGV.empty? || ARGV.include?("check") +Bundler::Audit::CLI.start diff --git a/bin/ci b/bin/ci new file mode 100755 index 000000000..dd588b19a --- /dev/null +++ b/bin/ci @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# +# Full verification, exactly as CI runs it: style, security and tests. +# The steps themselves live in config/ci.rb so there is a single definition; +# this script only makes sure they run inside the container. +# +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/devops/common.sh" + +require_docker +step "Running the CI pipeline in the web container" +rails_test_exec ./bin/ci-run diff --git a/bin/ci-run b/bin/ci-run new file mode 100755 index 000000000..e4742900f --- /dev/null +++ b/bin/ci-run @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "active_support/continuous_integration" + +CI = ActiveSupport::ContinuousIntegration +require_relative "../config/ci" diff --git a/bin/dev b/bin/dev new file mode 100755 index 000000000..e1042d095 --- /dev/null +++ b/bin/dev @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# +# Starts the development stack (web, worker, css, postgres). +# +# bin/dev # in the background +# bin/dev --attach # in the foreground, streaming logs +# bin/dev --down # stop everything +# +# Thin on purpose: each action is a script under devops/app/, which is where to +# look when one of them needs changing. +# +# Without Docker, run the processes directly instead: +# bundle exec foreman start -f Procfile.dev +# +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +case "${1:-}" in + --down) exec "${ROOT}/devops/app/stop.sh" ;; + --status) exec "${ROOT}/devops/app/status.sh" ;; + --help|-h) sed -n '2,14p' "$0" ;; + *) exec "${ROOT}/devops/app/start.sh" "$@" ;; +esac diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint new file mode 100755 index 000000000..ed31659f4 --- /dev/null +++ b/bin/docker-entrypoint @@ -0,0 +1,8 @@ +#!/bin/bash -e + +# If running the rails server then create or migrate existing database +if [ "${@: -2:1}" == "./bin/rails" ] && [ "${@: -1:1}" == "server" ]; then + ./bin/rails db:prepare +fi + +exec "${@}" diff --git a/bin/importmap b/bin/importmap new file mode 100755 index 000000000..36502ab16 --- /dev/null +++ b/bin/importmap @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby + +require_relative "../config/application" +require "importmap/commands" diff --git a/bin/jobs b/bin/jobs new file mode 100755 index 000000000..dcf59f309 --- /dev/null +++ b/bin/jobs @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby + +require_relative "../config/environment" +require "solid_queue/cli" + +SolidQueue::Cli.start(ARGV) diff --git a/bin/lint b/bin/lint new file mode 100755 index 000000000..37172391a --- /dev/null +++ b/bin/lint @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# +# Runs RuboCop inside the container. Pass -a or -A to autocorrect. +# +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +exec "${ROOT}/devops/rails/lint.sh" "$@" diff --git a/bin/openapi-current b/bin/openapi-current new file mode 100755 index 000000000..f1c06a505 --- /dev/null +++ b/bin/openapi-current @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# +# Regenerates the OpenAPI document from the specs that exercise the API and +# fails if the copy in the repository has drifted from them. +# +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +bin/rails rswag:specs:swaggerize > /dev/null + +if ! git rev-parse --is-inside-work-tree > /dev/null 2>&1; then + echo "Regenerated swagger/v1/swagger.yaml. Not a git work tree, so there is" + echo "no committed copy to compare it against -- skipping the drift check." + exit 0 +fi + +git diff --exit-code --stat swagger diff --git a/bin/rails b/bin/rails new file mode 100755 index 000000000..efc037749 --- /dev/null +++ b/bin/rails @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +APP_PATH = File.expand_path("../config/application", __dir__) +require_relative "../config/boot" +require "rails/commands" diff --git a/bin/rake b/bin/rake new file mode 100755 index 000000000..4fbf10b96 --- /dev/null +++ b/bin/rake @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "rake" +Rake.application.run diff --git a/bin/rubocop b/bin/rubocop new file mode 100755 index 000000000..5a2050471 --- /dev/null +++ b/bin/rubocop @@ -0,0 +1,8 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +# Explicit RuboCop config increases performance slightly while avoiding config confusion. +ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__)) + +load Gem.bin_path("rubocop", "rubocop") diff --git a/bin/setup b/bin/setup new file mode 100755 index 000000000..99f7a1478 --- /dev/null +++ b/bin/setup @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# +# Prepares the development environment from a clean checkout. +# +# bin/setup # build images, create databases, seed +# bin/setup --no-seed # skip the demonstration accounts +# +# The work itself is in devops/app/setup.sh, next to the other actions: +# start, stop, restart, status, seed, reset. +# +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +if [[ "${1:-}" == "--reset" ]]; then + shift + exec "${ROOT}/devops/app/reset.sh" "$@" +fi + +exec "${ROOT}/devops/app/setup.sh" "$@" diff --git a/bin/test b/bin/test new file mode 100755 index 000000000..f20de41b6 --- /dev/null +++ b/bin/test @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# +# Runs the test suite inside the container. +# +# bin/test # whole suite +# bin/test spec/models/user_spec.rb # a single file +# bin/test --parallel # across parallel workers +# bin/test --live # the websocket delivery specs +# +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +if [[ "${1:-}" == "--parallel" ]]; then + shift + exec "${ROOT}/devops/rails/test-parallel.sh" "$@" +fi + +# Solid Cable instead of the in-memory test adapter, so a broadcast really +# travels to the browser. +if [[ "${1:-}" == "--live" ]]; then + shift + exec env CABLE_ADAPTER=solid_cable "${ROOT}/devops/rails/test.sh" "$@" +fi + +exec "${ROOT}/devops/rails/test.sh" "$@" diff --git a/bin/thrust b/bin/thrust new file mode 100755 index 000000000..36bde2d83 --- /dev/null +++ b/bin/thrust @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("thruster", "thrust") diff --git a/config.ru b/config.ru new file mode 100644 index 000000000..4a3c09a68 --- /dev/null +++ b/config.ru @@ -0,0 +1,6 @@ +# This file is used by Rack-based servers to start the application. + +require_relative "config/environment" + +run Rails.application +Rails.application.load_server diff --git a/config/application.rb b/config/application.rb new file mode 100644 index 000000000..c20a4943b --- /dev/null +++ b/config/application.rb @@ -0,0 +1,50 @@ +require_relative "boot" + +require "rails" +# Pick the frameworks you want: +require "active_model/railtie" +require "active_job/railtie" +require "active_record/railtie" +require "active_storage/engine" +require "action_controller/railtie" +require "action_mailer/railtie" +require "action_mailbox/engine" +require "action_text/engine" +require "action_view/railtie" +require "action_cable/engine" +# require "rails/test_unit/railtie" + +# Require the gems listed in Gemfile, including any gems +# you've limited to :test, :development, or :production. +Bundler.require(*Rails.groups) + +module UserManagement + class Application < Rails::Application + # Initialize configuration defaults for originally generated Rails version. + config.load_defaults 8.1 + + # ── Internationalisation ──────────────────────────────────────────────── + # Three shipped locales. Fallbacks mean a key missing from a translation + # renders the English text rather than the raw key. + config.i18n.available_locales = %w[en pt-BR es] + config.i18n.default_locale = :en + config.i18n.fallbacks = [:en] + config.i18n.load_path += Rails.root.glob("config/locales/**/*.yml") + + # Please, add to the `ignore` list any other `lib` subdirectories that do + # not contain `.rb` files, or that should not be reloaded or eager loaded. + # Common ones are `templates`, `generators`, or `middleware`, for example. + config.autoload_lib(ignore: %w[assets tasks]) + + # Configuration for the application, engines, and railties goes here. + # + # These settings can be overridden in specific environments using the files + # in config/environments, which are processed later. + # + # config.time_zone = "Central Time (US & Canada)" + # config.eager_load_paths << Rails.root.join("extras") + + # Don't generate system test files. + config.generators.system_tests = nil + end +end diff --git a/config/boot.rb b/config/boot.rb new file mode 100644 index 000000000..988a5ddc4 --- /dev/null +++ b/config/boot.rb @@ -0,0 +1,4 @@ +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +require "bundler/setup" # Set up gems listed in the Gemfile. +require "bootsnap/setup" # Speed up boot time by caching expensive operations. diff --git a/config/bundler-audit.yml b/config/bundler-audit.yml new file mode 100644 index 000000000..e74b3af94 --- /dev/null +++ b/config/bundler-audit.yml @@ -0,0 +1,5 @@ +# Audit all gems listed in the Gemfile for known security problems by running bin/bundler-audit. +# CVEs that are not relevant to the application can be enumerated on the ignore list below. + +ignore: + - CVE-THAT-DOES-NOT-APPLY diff --git a/config/cable.yml b/config/cable.yml new file mode 100644 index 000000000..412ba32ff --- /dev/null +++ b/config/cable.yml @@ -0,0 +1,29 @@ +# Solid Cable in every environment except test, where the in-memory test +# adapter lets specs assert on broadcasts directly. +development: + adapter: solid_cable + connects_to: + database: + writing: cable + polling_interval: 0.1.seconds + message_retention: 1.day + +# The test adapter records broadcasts in memory so specs can assert on them +# without a database or a websocket. The system specs that need delivery in a +# real browser run in a second pass with CABLE_ADAPTER=solid_cable, which is +# what bin/test --live does. +test: + adapter: <%= ENV["CABLE_ADAPTER"].presence || "test" %> + connects_to: + database: + writing: cable + polling_interval: 0.05.seconds + message_retention: 1.day + +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..33811ce43 --- /dev/null +++ b/config/cache.yml @@ -0,0 +1,19 @@ +# Solid Cache uses the dedicated `cache` database in every environment. The +# installer only wires this up for production, which leaves development and +# test pointing at the primary database, where the table does not exist. +default: &default + database: cache + store_options: + # Cap age of oldest cache entry to fulfill retention policies + # max_age: <%= 60.days.to_i %> + max_size: <%= 256.megabytes %> + namespace: <%= Rails.env %> + +development: + <<: *default + +test: + <<: *default + +production: + <<: *default diff --git a/config/ci.rb b/config/ci.rb new file mode 100644 index 000000000..5cc3e4e0f --- /dev/null +++ b/config/ci.rb @@ -0,0 +1,44 @@ +# The single definition of the verification pipeline. +# +# Run it with bin/ci from the host (which executes it inside the container), +# or with bin/ci-run from inside a container that already has the gems. + +require "etc" + +# Four workers at most: each one needs its own set of four databases, and past +# that ceiling creating them costs more than the suite saves. The same ceiling +# as devops/rails/test-parallel.sh, which is the entrypoint a person uses, so +# the pipeline and the laptop shard the suite the same way. +workers = [Etc.nprocessors, 4].min + +CI.run do + # The base test database is still prepared on its own, because the live pass + # below is serial and runs against it. + step "Database: prepare test schema", "bin/rails db:test:prepare" + step "Database: prepare #{workers} parallel test schemas", + "bundle", "exec", "rake", "parallel:prepare[#{workers}]" + + step "Style: Ruby", "bin/rubocop --parallel" + + step "Security: Gem audit", "bin/bundler-audit" + step "Security: Importmap audit", "bin/importmap audit" + step "Security: Brakeman code analysis", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error" + + # SimpleCov enforces the 90% minimum and fails the process when coverage + # drops below it, so the suite is also the coverage gate. Each worker writes + # its own result and SimpleCov merges them, so the gate is measured against + # the whole suite rather than one shard -- spec/spec_helper.rb sets the + # command_name and merging that makes that true. + step "Tests: RSpec on #{workers} workers", + "bundle", "exec", "parallel_rspec", "-n", workers.to_s + + # A second, much smaller pass with Solid Cable in place of the in-memory + # test adapter, so the live updates are proven to reach a real browser over + # a real websocket rather than only to have been broadcast. + step "Tests: live updates", "CABLE_ADAPTER=solid_cable bundle exec rspec --tag live" + + # The OpenAPI document is generated from the specs that exercise the API, so + # regenerating it and finding a difference means the committed copy describes + # an API that no longer exists. + step "Docs: OpenAPI is current", "bin/openapi-current" +end diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc new file mode 100644 index 000000000..32cf77fcf --- /dev/null +++ b/config/credentials.yml.enc @@ -0,0 +1 @@ +015Lobts0X5SvG/7LskLoaMuLVbkEztc1r7V8TwRVVRa8w8XuEQBwizJZCIhp/XnXRfTgHbLyQ/2hRACZ9nfYX3tNMdNwgoJOFh++j3N+tRquTmfja4ikXzUr1Xl9X9OzA+I7xJH0qf/ivH0TF3orV6rKkNW1pfiuKDiX+/IfJ/7s7/nfyAtfJPuSaQYEUHZJyzO7VagyAspy85itSj3YQSFL0YB/kGKW7JGxqEnldHsdGhugifY10p9cn1dbwJ+H+Hl9y7vc09sGkqVTA/e9D9/NdZ11Xk4HxsIF4jNwXmdDj2Z6WcMlmX799C2t2dbzfprdfvDHKzf/KClh+yHgCAUCJqiocX5YblsdVpqyrqqwnn9AjQjMgYMAyF5WNwd8wBNfMa9GKkRplCwMBkZKkhQxrbNzuv6wRgS9R1vvPgHft63v+zevbX+wmMFBenuq7D2bppqIsyoKzgd5HgAA4BjTzC15nu42zNgDeXWT58HHEI4UN5AGTo9--iv5xKx8w/MpZDOT4--ZTv8GD3z+RYBCA+QvU2pyw== \ No newline at end of file diff --git a/config/database.yml b/config/database.yml new file mode 100644 index 000000000..aba213ae5 --- /dev/null +++ b/config/database.yml @@ -0,0 +1,67 @@ +# PostgreSQL configuration. +# +# Development and test mirror the production topology on purpose: Solid Queue +# and Solid Cable get their own databases in every environment, so the worker +# really is a separate process talking to a separate queue database rather than +# something that only works because it shares the web process. +default: &default + adapter: postgresql + encoding: unicode + max_connections: <%= ENV.fetch("RAILS_MAX_THREADS", 5) %> + host: <%= ENV.fetch("POSTGRES_HOST", "localhost") %> + port: <%= ENV.fetch("POSTGRES_PORT", 5432) %> + username: <%= ENV.fetch("POSTGRES_USER", nil) %> + password: <%= ENV.fetch("POSTGRES_PASSWORD", nil) %> + +development: + primary: + <<: *default + database: <%= ENV.fetch("POSTGRES_DB", "user_management_development") %> + cache: + <<: *default + database: <%= ENV.fetch("POSTGRES_DB", "user_management_development") %>_cache + migrations_paths: db/cache_migrate + queue: + <<: *default + database: <%= ENV.fetch("POSTGRES_DB", "user_management_development") %>_queue + migrations_paths: db/queue_migrate + cable: + <<: *default + database: <%= ENV.fetch("POSTGRES_DB", "user_management_development") %>_cable + migrations_paths: db/cable_migrate + +# Each parallel test worker gets its own suffixed database, so the suite can run +# with `parallel_tests` without workers stepping on each other. +test: + primary: + <<: *default + database: <%= ENV.fetch("TEST_POSTGRES_DB", "user_management_test") %><%= ENV["TEST_ENV_NUMBER"] %> + cache: + <<: *default + database: <%= ENV.fetch("TEST_POSTGRES_DB", "user_management_test") %><%= ENV["TEST_ENV_NUMBER"] %>_cache + migrations_paths: db/cache_migrate + queue: + <<: *default + database: <%= ENV.fetch("TEST_POSTGRES_DB", "user_management_test") %><%= ENV["TEST_ENV_NUMBER"] %>_queue + migrations_paths: db/queue_migrate + cable: + <<: *default + database: <%= ENV.fetch("TEST_POSTGRES_DB", "user_management_test") %><%= ENV["TEST_ENV_NUMBER"] %>_cable + migrations_paths: db/cable_migrate + +production: + primary: &primary_production + <<: *default + database: <%= ENV.fetch("POSTGRES_DB", "user_management_production") %> + cache: + <<: *primary_production + database: <%= ENV.fetch("POSTGRES_DB", "user_management_production") %>_cache + migrations_paths: db/cache_migrate + queue: + <<: *primary_production + database: <%= ENV.fetch("POSTGRES_DB", "user_management_production") %>_queue + migrations_paths: db/queue_migrate + cable: + <<: *primary_production + database: <%= ENV.fetch("POSTGRES_DB", "user_management_production") %>_cable + migrations_paths: db/cable_migrate diff --git a/config/deploy.yml b/config/deploy.yml new file mode 100644 index 000000000..10e847de0 --- /dev/null +++ b/config/deploy.yml @@ -0,0 +1,108 @@ +# Kamal 2 deployment. +# +# Every value written as <...> has to be filled in before a real deploy; they +# are hostnames and account names, not secrets. Secrets are read from +# .kamal/secrets, which reads them from the environment or a password manager +# and never from this file. +# +# kamal setup first deploy, installs Docker and the proxy +# kamal deploy subsequent deploys +# kamal app logs -f follow the logs +# kamal app exec -i "bin/rails console" +service: user_management + +image: /user_management + +servers: + # Thruster listens on 80 inside the container: it terminates HTTP/2, gzips + # and serves the digest-stamped assets, and hands the rest to Puma. + web: + - + # Solid Queue runs as its own container, not inside Puma. A slow import must + # not compete with request threads for the same process. + job: + hosts: + - + cmd: bin/jobs + +# kamal-proxy terminates TLS with an automatic Let's Encrypt certificate and +# forwards plain HTTP with X-Forwarded-Proto, which is what `assume_ssl` in +# config/environments/production.rb reads. +proxy: + ssl: true + host: + app_port: 80 + healthcheck: + path: /up + interval: 5 + timeout: 5 + +registry: + username: + password: + - KAMAL_REGISTRY_PASSWORD + +builder: + arch: amd64 + # The image is the one in the Dockerfile's `final` stage: no build tools, no + # development or test gems, non-root. + target: final + +env: + clear: + # The accessory below, reachable by that name on the Kamal network. + POSTGRES_HOST: user_management-postgres + POSTGRES_USER: user_management + POSTGRES_DB: user_management_production + RAILS_MAX_THREADS: 5 + # Solid Queue processes for the job role. + JOB_CONCURRENCY: 1 + # Uncomment on the first deploy to have db:prepare create the first + # administrator; the password comes from the secret below. Without both, + # the seed does nothing and the application still boots. + # SEED_ADMIN_EMAIL: + # SEED_ADMIN_NAME: + # Where the invitation and password-reset emails come from. + MAIL_FROM: + # Uncomment both to put a password in front of /api-docs. + # API_DOCS_USER: + secret: + - RAILS_MASTER_KEY + - POSTGRES_PASSWORD + # - SEED_ADMIN_PASSWORD + # - API_DOCS_PASSWORD + +# Uploaded avatars live on disk (config.active_storage.service = :local), so +# the directory has to outlive the container. Moving to S3 is a change of one +# line in config/storage.yml plus the credentials; the volume then goes away. +volumes: + - "user_management_storage:/rails/storage" + +# Kamal 2 serves the precompiled assets from here during a deploy, so a browser +# holding the previous page can still fetch the previous digests. +asset_path: /rails/public/assets + +accessories: + postgres: + image: postgres:17 + host: + port: "127.0.0.1:5432:5432" + env: + clear: + POSTGRES_USER: user_management + POSTGRES_DB: user_management_production + secret: + - POSTGRES_PASSWORD + directories: + - data:/var/lib/postgresql/data + +aliases: + console: app exec --interactive --reuse "bin/rails console" + shell: app exec --interactive --reuse "bash" + logs: app logs --follow + dbc: app exec --interactive --reuse "bin/rails dbconsole" + +# The four databases -- primary, cache, queue and cable -- are created and +# migrated by `db:prepare`, which bin/docker-entrypoint runs when the web +# container starts. With more than one web host, run the migration once from a +# pre-deploy hook instead, so two hosts do not race each other. diff --git a/config/environment.rb b/config/environment.rb new file mode 100644 index 000000000..cac531577 --- /dev/null +++ b/config/environment.rb @@ -0,0 +1,5 @@ +# Load the Rails application. +require_relative "application" + +# Initialize the Rails application. +Rails.application.initialize! diff --git a/config/environments/development.rb b/config/environments/development.rb new file mode 100644 index 000000000..bd59c6408 --- /dev/null +++ b/config/environments/development.rb @@ -0,0 +1,85 @@ +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # Make code changes take effect immediately without server restart. + config.enable_reloading = true + + # Do not eager load code on boot. + config.eager_load = false + + # Show full error reports. + config.consider_all_requests_local = true + + # Enable server timing. + config.server_timing = true + + # Enable/disable Action Controller caching. By default Action Controller caching is disabled. + # Run rails dev:cache to toggle Action Controller caching. + if Rails.root.join("tmp/caching-dev.txt").exist? + config.action_controller.perform_caching = true + config.action_controller.enable_fragment_cache_logging = true + config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" } + else + config.action_controller.perform_caching = false + end + + # Change to :null_store to avoid any caching. + # Development mirrors the production topology on purpose: the same database + # backed adapters, pointed at the same dedicated databases. Running the queue + # in-process here would hide exactly the problems this stack has to prove it + # handles. + config.cache_store = :solid_cache_store + + config.active_job.queue_adapter = :solid_queue + config.solid_queue.connects_to = { database: { writing: :queue } } + + # Store uploaded files on the local file system (see config/storage.yml for options). + config.active_storage.service = :local + + # Don't care if the mailer can't send. + config.action_mailer.raise_delivery_errors = false + + # Make template changes take effect immediately. + config.action_mailer.perform_caching = false + + # Set localhost to be used by links generated in mailer templates. + config.action_mailer.default_url_options = { host: "localhost", port: 3000 } + + # Print deprecation notices to the Rails logger. + config.active_support.deprecation = :log + + # Raise an error on page load if there are pending migrations. + config.active_record.migration_error = :page_load + + # Highlight code that triggered database queries in logs. + config.active_record.verbose_query_logs = true + + # Append comments with runtime information tags to SQL queries in logs. + config.active_record.query_log_tags_enabled = true + + # Highlight code that enqueued background job in logs. + config.active_job.verbose_enqueue_logs = true + + # Highlight code that triggered redirect in logs. + config.action_dispatch.verbose_redirect_logs = true + + # Suppress logger output for asset requests. + config.assets.quiet = true + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + config.action_view.annotate_rendered_view_with_filenames = true + + # Uncomment if you wish to allow Action Cable access from any origin. + # config.action_cable.disable_request_forgery_protection = true + + # Raise error when a before_action's only/except options reference missing actions. + config.action_controller.raise_on_missing_callback_actions = true + + # Apply autocorrection by RuboCop to files generated by `bin/rails generate`. + # config.generators.apply_rubocop_autocorrect_after_generate! +end diff --git a/config/environments/production.rb b/config/environments/production.rb new file mode 100644 index 000000000..6c4878aa9 --- /dev/null +++ b/config/environments/production.rb @@ -0,0 +1,99 @@ +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 + + # Access arrives through a SSL-terminating proxy, so the app trusts the + # forwarded scheme rather than looking at its own socket. + config.assume_ssl = true + + # Redirects http to https, sends Strict-Transport-Security, and flags every + # cookie -- the signed session cookie included -- as secure. The switch + # exists so the production image can still be smoke-tested over plain http + # locally; leave it alone anywhere real. + config.force_ssl = ENV.fetch("FORCE_SSL", "true") == "true" + + # With `assume_ssl` on, every request already looks like https, so this + # redirect never fires in practice -- the proxy is what sends a plain http + # visitor to https. The exclusion is here for the case where the app is put + # behind something that does not terminate TLS: the container healthcheck + # speaks http from inside the network and must not be answered with a + # redirect. + config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } + + # Log to STDOUT with the current request id as a default log tag. + 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..e2ba29565 --- /dev/null +++ b/config/environments/test.rb @@ -0,0 +1,58 @@ +# 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 + + # A missing key renders a humanised guess instead of raising, which reads as + # correct English and silently ships untranslated pages. Failing the suite is + # the only reliable way to catch it. + config.i18n.raise_on_missing_translations = true + + # Set host to be used by links generated in mailer templates. + config.action_mailer.default_url_options = { host: "example.com" } + + # 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..6f329c921 --- /dev/null +++ b/config/initializers/content_security_policy.rb @@ -0,0 +1,42 @@ +# Be sure to restart your server when you modify this file. + +# The policy is deliberately tight: everything the pages need is served from +# this origin, except the web font and the remote avatar URLs a user may point +# at. Inline scripts are allowed only with the per-request nonce, which the +# importmap tags carry automatically. +Rails.application.configure do + config.content_security_policy do |policy| + policy.default_src :self + policy.base_uri :self + policy.form_action :self + policy.object_src :none + # Nothing here is meant to be framed, which also covers clickjacking on the + # destructive admin forms. + policy.frame_ancestors :none + + policy.script_src :self + # Google Fonts serves the stylesheet from one host and the font files from + # another; both are needed for the typeface to load. + policy.style_src :self, "https://fonts.googleapis.com" + policy.font_src :self, :data, "https://fonts.gstatic.com" + # Remote avatars are an advertised feature, so any https image is allowed. + # The scheme itself is still validated on the model: javascript: and data: + # URLs never reach an img src. + policy.img_src :self, :https, :data + + # Action Cable connects back to this same origin over a websocket, which + # older CSP implementations do not read out of `self`. + # The block runs against the controller when there is one and against the + # request otherwise, so it asks for the request either way. + policy.connect_src :self, lambda { + http = respond_to?(:request) ? request : self + "#{http.ssl? ? "wss" : "ws"}://#{http.host_with_port}" + } + end + + # A fresh nonce per response. The alternative Rails suggests -- deriving it + # from the session id -- is friendlier to caching but hands every page of a + # session the same nonce, which is exactly what a nonce is meant not to be. + config.content_security_policy_nonce_generator = ->(_request) { SecureRandom.base64(16) } + config.content_security_policy_nonce_directives = %w[script-src] +end diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb new file mode 100644 index 000000000..f72dcdfaa --- /dev/null +++ b/config/initializers/filter_parameter_logging.rb @@ -0,0 +1,8 @@ +# Be sure to restart your server when you modify this file. + +# Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file. +# Use this to limit dissemination of sensitive information. +# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. +Rails.application.config.filter_parameters += %i[ + passw email secret token _key crypt salt certificate otp ssn cvv cvc +] diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb new file mode 100644 index 000000000..3860f659e --- /dev/null +++ b/config/initializers/inflections.rb @@ -0,0 +1,16 @@ +# Be sure to restart your server when you modify this file. + +# Add new inflection rules using the following format. Inflections +# are locale specific, and you may define rules for as many different +# locales as you wish. All of these examples are active by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.plural /^(ox)$/i, "\\1en" +# inflect.singular /^(ox)en/i, "\\1" +# inflect.irregular "person", "people" +# inflect.uncountable %w( fish sheep ) +# end + +# These inflection rules are supported but not enabled by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.acronym "RESTful" +# end diff --git a/config/initializers/pagy.rb b/config/initializers/pagy.rb new file mode 100644 index 000000000..1b3d347d1 --- /dev/null +++ b/config/initializers/pagy.rb @@ -0,0 +1,6 @@ +require "pagy" + +# 25 rows keeps the admin list to one screen on a laptop without hiding the +# shape of the data. +Pagy::DEFAULT[:limit] = 25 +Pagy::DEFAULT[:size] = 7 diff --git a/config/initializers/rswag_api.rb b/config/initializers/rswag_api.rb new file mode 100644 index 000000000..99cd59f1e --- /dev/null +++ b/config/initializers/rswag_api.rb @@ -0,0 +1,5 @@ +Rswag::Api.configure do |c| + # Where `rswag:specs:swaggerize` writes the generated document, and where the + # middleware reads it from to serve /api-docs/v1/swagger.yaml. + c.openapi_root = Rails.root.join("swagger").to_s +end diff --git a/config/initializers/rswag_ui.rb b/config/initializers/rswag_ui.rb new file mode 100644 index 000000000..039f7f67b --- /dev/null +++ b/config/initializers/rswag_ui.rb @@ -0,0 +1,46 @@ +Rswag::Ui.configure do |c| + c.openapi_endpoint "/api-docs/v1/swagger.yaml", "Roster API v1" + + # The document describes a public API and is harmless to read, but a + # deployment that would rather not publish its shape can put a password in + # front of it without a code change. + if ENV["API_DOCS_USER"].present? && ENV["API_DOCS_PASSWORD"].present? + c.basic_auth_enabled = true + c.basic_auth_credentials ENV.fetch("API_DOCS_USER"), ENV.fetch("API_DOCS_PASSWORD") + end +end + +# Swagger UI ships its own content security policy -- `script-src 'self' +# 'unsafe-inline'`, which is what its inline bootstrap needs. Ours is stricter +# and, because a browser enforces every policy it is sent, the two together +# forbid everything the page does: no nonce, no script, a blank screen. +# +# So the application's policy steps aside for that one mount, and Swagger UI's +# own policy governs it. Nothing of ours is served from there: the engine +# serves its static assets and the generated document, and the document is +# public by design. +class ApiDocsWithoutTheApplicationPolicy + MOUNT = "/api-docs".freeze + + def initialize(app) + @app = app + end + + def call(env) + env["action_dispatch.content_security_policy"] = nil if documentation?(env) + + @app.call(env) + end + + private + + def documentation?(env) + path = env["PATH_INFO"].to_s + path == MOUNT || path.start_with?("#{MOUNT}/") + end +end + +Rails.application.config.middleware.insert_before( + ActionDispatch::ContentSecurityPolicy::Middleware, + ApiDocsWithoutTheApplicationPolicy +) diff --git a/config/initializers/session_store.rb b/config/initializers/session_store.rb new file mode 100644 index 000000000..d07b1b2c8 --- /dev/null +++ b/config/initializers/session_store.rb @@ -0,0 +1,11 @@ +# Be sure to restart your server when you modify this file. + +# Rails already defaults to these, but the flags on the cookie that carries a +# signed-in session are worth stating out loud rather than inheriting: no +# script may read it, and it does not ride along on a cross-site request. +# `secure` is not set here -- ActionDispatch::SSL adds it in production, where +# `force_ssl` is on, so development over plain http keeps working. +Rails.application.config.session_store :cookie_store, + key: "_user_management_session", + httponly: true, + same_site: :lax diff --git a/config/locales/en.yml b/config/locales/en.yml new file mode 100644 index 000000000..148d3092f --- /dev/null +++ b/config/locales/en.yml @@ -0,0 +1,273 @@ +en: + language_name: "English" + + shared: + skip_to_content: "Skip to content" + tagline: "User management" + language: + label: "Language" + choose: "Choose a language" + nav: + main: "Main" + dashboard: "Dashboard" + users: "Users" + imports: "Imports" + activity: "Activity" + my_profile: "My profile" + sign_out: "Sign out" + sign_out_confirm: "Sign out of Roster?" + form_errors: + heading: + one: "1 problem stopped this from being saved:" + other: "%{count} problems stopped this from being saved:" + + pagination: + previous: "Previous" + next: "Next" + page_of: "Page %{page} of %{pages}" + + roles: + user: "User" + admin: "Administrator" + + sessions: + throttled: "Too many attempts. Please try again later." + invalid_credentials: "Try another email address or password." + new: + title: "Sign in" + subtitle: "Use the email address and password for your account." + email_address: "Email address" + password: "Password" + submit: "Sign in" + forgot_password: "Forgot password?" + create_account: "Create an account" + + registrations: + created: "Welcome. Your account is ready." + new: + title: "Create your account" + subtitle: "You will be signed in as soon as it is ready." + full_name: "Full name" + email_address: "Email address" + password: "Password" + password_hint: "At least 8 characters." + password_confirmation: "Confirm password" + submit: "Create account" + have_account: "Already have an account?" + sign_in: "Sign in" + + profiles: + updated: "Your profile has been updated." + deleted: "Your account has been deleted." + show: + title: "My profile" + subtitle: "Your account details" + edit: "Edit profile" + full_name: "Full name" + email_address: "Email address" + role: "Role" + member_since: "Member since" + delete_heading: "Delete this account" + delete_copy: "Your profile and sessions are removed. This cannot be undone." + delete_button: "Delete my account" + delete_confirm: "Delete your account? This cannot be undone." + edit: + title: "Edit profile" + subtitle: "Update your account details" + full_name: "Full name" + email_address: "Email address" + avatar: "Avatar" + avatar_file: "Avatar file" + avatar_hint: "PNG, JPEG, WebP or GIF, up to 2 MB." + remove_avatar: "Remove the uploaded image" + avatar_url: "Avatar URL" + avatar_url_hint: "Used when nothing is uploaded. An http or https link; the image is loaded by your browser, never fetched by the server." + submit: "Save changes" + cancel: "Cancel" + + admin: + dashboard: + overview: "Overview" + total: "Total users" + administrators: "Administrators" + regular_users: "Users" + show: + title: "Dashboard" + subtitle: "An overview of the people in the system" + users: "Users" + manage: "Manage" + manage_copy: "Add people, change roles and remove accounts." + users: + created: "%{name} has been added." + updated: "%{name} has been updated." + deleted: "%{name} has been deleted." + form: + full_name: "Full name" + email_address: "Email address" + role: "Role" + password: "Password" + password_hint_new: "At least 8 characters." + password_hint_edit: "Leave blank to keep the current password." + avatar: "Avatar file" + avatar_hint: "PNG, JPEG, WebP or GIF, up to 2 MB." + avatar_url: "Avatar URL" + avatar_url_hint: "An https link to an image. Optional." + index: + title: "Users" + subtitle: "Everyone with an account" + search_label: "Search" + search_placeholder: "Search by name or email address" + search_submit: "Search" + clear: "Clear" + filter_all: "All" + filter_admin: "Administrators" + filter_user: "Users" + new_user: "Add user" + results: + one: "1 result" + other: "%{count} results" + name: "Name" + email_address: "Email address" + role: "Role" + actions: "Actions" + edit: "Edit" + delete: "Delete" + delete_confirm: "Delete %{name}? This cannot be undone." + empty_title: "Nobody here yet" + empty_copy: "No account matches what you are looking for." + new: + title: "Add user" + subtitle: "Create an account on someone's behalf" + submit: "Create user" + cancel: "Cancel" + edit: + title: "Edit user" + subtitle: "Update this account" + submit: "Save changes" + cancel: "Cancel" + + audit_events: + index: + title: "Activity" + subtitle: "What administrators did, and when" + heading: "History" + results: + one: "1 event" + other: "%{count} events" + when: "When" + who: "Who" + what: "What" + to_whom: "To whom" + details: "Details" + empty_title: "Nothing has happened yet" + empty_copy: "Creating, changing or removing an account will show up here." + actions: + created: "Created" + updated: "Updated" + promoted: "Promoted" + demoted: "Demoted" + deleted: "Deleted" + imported: "Imported" + details_import: "From import #%{id}" + + user_imports: + scheduled: "The file is queued. Progress appears below as it runs." + index: + title: "Imports" + subtitle: "Create accounts from a spreadsheet" + download_template: "Download template" + upload: "New import" + upload_copy: "A CSV or XLSX file with the columns full_name, email, avatar_url and role." + file: "Spreadsheet" + file_hint: "CSV or XLSX, up to 5 MB and 10,000 rows." + submit: "Start import" + history: "History" + empty_title: "No imports yet" + empty_copy: "Upload a spreadsheet to create accounts in bulk." + show: + title: "Import" + rejected_rows: "Rejected rows" + download_rejected: "Download as CSV" + row: "Row" + email_address: "Email address" + problems: "Problems" + no_rejected_rows: "Every row was imported." + back: "Back to imports" + statuses: + pending: "Waiting" + processing: "Importing" + completed: "Completed" + completed_with_errors: "Completed with errors" + failed: "Failed" + progress: + label: "Import progress" + counts: "%{created} created, %{rejected} rejected of %{total}" + + locales: + unsupported: "That language is not available." + + authorization: + admin_only: "That area is only available to administrators." + + passwords: + throttled: "Too many attempts. Please try again later." + reset_instructions_sent: "Password reset instructions sent (if an account with that email address exists)." + reset: "Your password has been reset." + chosen: "Your password is set. You can sign in now." + mismatch: "Passwords did not match." + invalid_token: "That password reset link is invalid or has expired." + new: + title: "Forgot your password?" + subtitle: "We will email you a link to choose a new one." + email_address: "Email address" + submit: "Send reset instructions" + remembered: "Remembered it?" + sign_in: "Sign in" + edit: + title: "Choose a new password" + subtitle: "Signing in again everywhere will need the new password." + password: "New password" + password_hint: "At least 8 characters." + password_confirmation: "Confirm new password" + submit: "Save password" + invited_title: "Welcome. Choose your password" + invited_subtitle: "Your account is ready; it only needs a password of your own." + invited_password: "Password" + invited_password_confirmation: "Confirm password" + + passwords_mailer: + reset: + subject: "Reset your password" + heading: "Reset your password" + body: "Use the link below to choose a new password for your Roster account." + action: "Choose a new password" + expires: "The link expires in %{duration}." + ignore: "If you did not ask for this, nothing has changed and you can ignore this message." + + invitations_mailer: + invite: + subject: "Your Roster account is ready" + heading: "Your account is ready" + body: "Hello %{name}. An account was created for you on Roster. Choose a password and it is yours." + action: "Choose a password" + expires: "The link is valid for %{duration}." + sign_in_with: "You will sign in with %{email_address}." + + activerecord: + errors: + models: + user_import: + attributes: + file: + too_large: "must be smaller than %{limit} MB." + unsupported_format: "must be a .csv or .xlsx file." + content_mismatch: "does not contain what its extension promises." + user: + attributes: + avatar: + too_large: "must be smaller than %{limit} MB." + invalid_type: "must be a PNG, JPEG, WebP or GIF image." + avatar_url: + unsupported_scheme: "must be an http or https link." + base: + last_administrator: "This is the only administrator left, so the role cannot be removed." diff --git a/config/locales/es.yml b/config/locales/es.yml new file mode 100644 index 000000000..9a395e6f8 --- /dev/null +++ b/config/locales/es.yml @@ -0,0 +1,273 @@ +es: + language_name: "Español" + + shared: + skip_to_content: "Saltar al contenido" + tagline: "Gestión de usuarios" + language: + label: "Idioma" + choose: "Elige un idioma" + nav: + main: "Principal" + dashboard: "Panel" + users: "Usuarios" + imports: "Importaciones" + activity: "Actividad" + my_profile: "Mi perfil" + sign_out: "Cerrar sesión" + sign_out_confirm: "¿Cerrar sesión en Roster?" + form_errors: + heading: + one: "1 problema impidió guardar los cambios:" + other: "%{count} problemas impidieron guardar los cambios:" + + pagination: + previous: "Anterior" + next: "Siguiente" + page_of: "Página %{page} de %{pages}" + + roles: + user: "Usuario" + admin: "Administrador" + + sessions: + throttled: "Demasiados intentos. Inténtalo de nuevo más tarde." + invalid_credentials: "Revisa el correo electrónico y la contraseña." + new: + title: "Iniciar sesión" + subtitle: "Usa el correo electrónico y la contraseña de tu cuenta." + email_address: "Correo electrónico" + password: "Contraseña" + submit: "Iniciar sesión" + forgot_password: "¿Olvidaste tu contraseña?" + create_account: "Crear una cuenta" + + registrations: + created: "Bienvenido. Tu cuenta está lista." + new: + title: "Crea tu cuenta" + subtitle: "Iniciarás sesión en cuanto esté lista." + full_name: "Nombre completo" + email_address: "Correo electrónico" + password: "Contraseña" + password_hint: "Al menos 8 caracteres." + password_confirmation: "Confirma la contraseña" + submit: "Crear cuenta" + have_account: "¿Ya tienes una cuenta?" + sign_in: "Iniciar sesión" + + profiles: + updated: "Tu perfil se ha actualizado." + deleted: "Tu cuenta se ha eliminado." + show: + title: "Mi perfil" + subtitle: "Los datos de tu cuenta" + edit: "Editar perfil" + full_name: "Nombre completo" + email_address: "Correo electrónico" + role: "Rol" + member_since: "Miembro desde" + delete_heading: "Eliminar esta cuenta" + delete_copy: "Se eliminan tu perfil y tus sesiones. No se puede deshacer." + delete_button: "Eliminar mi cuenta" + delete_confirm: "¿Eliminar tu cuenta? No se puede deshacer." + edit: + title: "Editar perfil" + subtitle: "Actualiza los datos de tu cuenta" + full_name: "Nombre completo" + email_address: "Correo electrónico" + avatar: "Avatar" + avatar_file: "Archivo del avatar" + avatar_hint: "PNG, JPEG, WebP o GIF, hasta 2 MB." + remove_avatar: "Quitar la imagen subida" + avatar_url: "URL del avatar" + avatar_url_hint: "Se usa cuando no hay archivo subido. Un enlace http o https; la imagen la carga tu navegador, el servidor nunca la descarga." + submit: "Guardar cambios" + cancel: "Cancelar" + + admin: + dashboard: + overview: "Resumen" + total: "Usuarios totales" + administrators: "Administradores" + regular_users: "Usuarios" + show: + title: "Panel" + subtitle: "Un resumen de las personas en el sistema" + users: "Usuarios" + manage: "Gestionar" + manage_copy: "Añade personas, cambia roles y elimina cuentas." + users: + created: "%{name} se ha añadido." + updated: "%{name} se ha actualizado." + deleted: "%{name} se ha eliminado." + form: + full_name: "Nombre completo" + email_address: "Correo electrónico" + role: "Rol" + password: "Contraseña" + password_hint_new: "Al menos 8 caracteres." + password_hint_edit: "Déjalo en blanco para mantener la contraseña actual." + avatar: "Archivo del avatar" + avatar_hint: "PNG, JPEG, WebP o GIF, hasta 2 MB." + avatar_url: "URL del avatar" + avatar_url_hint: "Un enlace https a una imagen. Opcional." + index: + title: "Usuarios" + subtitle: "Todas las personas con cuenta" + search_label: "Buscar" + search_placeholder: "Buscar por nombre o correo electrónico" + search_submit: "Buscar" + clear: "Limpiar" + filter_all: "Todos" + filter_admin: "Administradores" + filter_user: "Usuarios" + new_user: "Añadir usuario" + results: + one: "1 resultado" + other: "%{count} resultados" + name: "Nombre" + email_address: "Correo electrónico" + role: "Rol" + actions: "Acciones" + edit: "Editar" + delete: "Eliminar" + delete_confirm: "¿Eliminar a %{name}? No se puede deshacer." + empty_title: "Todavía no hay nadie" + empty_copy: "Ninguna cuenta coincide con lo que buscas." + new: + title: "Añadir usuario" + subtitle: "Crea una cuenta en nombre de otra persona" + submit: "Crear usuario" + cancel: "Cancelar" + edit: + title: "Editar usuario" + subtitle: "Actualiza esta cuenta" + submit: "Guardar cambios" + cancel: "Cancelar" + + audit_events: + index: + title: "Actividad" + subtitle: "Lo que hicieron los administradores, y cuándo" + heading: "Historial" + results: + one: "1 evento" + other: "%{count} eventos" + when: "Cuándo" + who: "Quién" + what: "Qué" + to_whom: "Sobre quién" + details: "Detalles" + empty_title: "Todavía no ha pasado nada" + empty_copy: "Crear, cambiar o eliminar una cuenta aparecerá aquí." + actions: + created: "Creó" + updated: "Actualizó" + promoted: "Promovió" + demoted: "Degradó" + deleted: "Eliminó" + imported: "Importó" + details_import: "De la importación n.º %{id}" + + user_imports: + scheduled: "El archivo está en cola. El progreso aparece abajo mientras se ejecuta." + index: + title: "Importaciones" + subtitle: "Crea cuentas a partir de una hoja de cálculo" + download_template: "Descargar plantilla" + upload: "Nueva importación" + upload_copy: "Un archivo CSV o XLSX con las columnas full_name, email, avatar_url y role." + file: "Hoja de cálculo" + file_hint: "CSV o XLSX, hasta 5 MB y 10.000 filas." + submit: "Iniciar importación" + history: "Historial" + empty_title: "Todavía no hay importaciones" + empty_copy: "Sube una hoja de cálculo para crear cuentas en lote." + show: + title: "Importación" + rejected_rows: "Filas rechazadas" + download_rejected: "Descargar en CSV" + row: "Fila" + email_address: "Correo electrónico" + problems: "Problemas" + no_rejected_rows: "Todas las filas se importaron." + back: "Volver a importaciones" + statuses: + pending: "En espera" + processing: "Importando" + completed: "Completada" + completed_with_errors: "Completada con errores" + failed: "Falló" + progress: + label: "Progreso de la importación" + counts: "%{created} creados, %{rejected} rechazados de %{total}" + + locales: + unsupported: "Ese idioma no está disponible." + + authorization: + admin_only: "Esa área es solo para administradores." + + passwords: + throttled: "Demasiados intentos. Inténtalo de nuevo más tarde." + reset_instructions_sent: "Hemos enviado las instrucciones (si existe una cuenta con ese correo electrónico)." + reset: "Tu contraseña se ha restablecido." + chosen: "Contraseña establecida. Ya puedes iniciar sesión." + mismatch: "Las contraseñas no coinciden." + invalid_token: "Ese enlace de restablecimiento no es válido o ha caducado." + new: + title: "¿Olvidaste tu contraseña?" + subtitle: "Te enviaremos por correo un enlace para elegir otra." + email_address: "Correo electrónico" + submit: "Enviar instrucciones" + remembered: "¿Ya la recuerdas?" + sign_in: "Iniciar sesión" + edit: + title: "Elige una contraseña nueva" + subtitle: "Necesitarás la contraseña nueva para volver a iniciar sesión en todos tus dispositivos." + password: "Contraseña nueva" + password_hint: "Al menos 8 caracteres." + password_confirmation: "Confirma la contraseña nueva" + submit: "Guardar contraseña" + invited_title: "Te damos la bienvenida. Elige tu contraseña" + invited_subtitle: "Tu cuenta ya existe; solo le falta una contraseña tuya." + invited_password: "Contraseña" + invited_password_confirmation: "Confirma la contraseña" + + passwords_mailer: + reset: + subject: "Restablece tu contraseña" + heading: "Restablece tu contraseña" + body: "Usa el enlace de abajo para elegir una contraseña nueva para tu cuenta de Roster." + action: "Elegir contraseña nueva" + expires: "El enlace caduca en %{duration}." + ignore: "Si no lo has pedido, no ha cambiado nada y puedes ignorar este mensaje." + + invitations_mailer: + invite: + subject: "Tu cuenta de Roster está lista" + heading: "Tu cuenta está lista" + body: "Hola, %{name}. Hemos creado una cuenta para ti en Roster. Elige una contraseña y será tuya." + action: "Elegir una contraseña" + expires: "El enlace es válido durante %{duration}." + sign_in_with: "Iniciarás sesión con %{email_address}." + + activerecord: + errors: + models: + user_import: + attributes: + file: + too_large: "debe pesar menos de %{limit} MB." + unsupported_format: "debe ser un archivo .csv o .xlsx." + content_mismatch: "no contiene lo que promete su extensión." + user: + attributes: + avatar: + too_large: "debe pesar menos de %{limit} MB." + invalid_type: "debe ser una imagen PNG, JPEG, WebP o GIF." + avatar_url: + unsupported_scheme: "debe ser un enlace http o https." + base: + last_administrator: "Este es el único administrador que queda, así que no se puede quitar el rol." diff --git a/config/locales/pt-BR.yml b/config/locales/pt-BR.yml new file mode 100644 index 000000000..2d0c2e615 --- /dev/null +++ b/config/locales/pt-BR.yml @@ -0,0 +1,273 @@ +pt-BR: + language_name: "Português" + + shared: + skip_to_content: "Pular para o conteúdo" + tagline: "Gestão de usuários" + language: + label: "Idioma" + choose: "Escolha um idioma" + nav: + main: "Principal" + dashboard: "Painel" + users: "Usuários" + imports: "Importações" + activity: "Atividade" + my_profile: "Meu perfil" + sign_out: "Sair" + sign_out_confirm: "Sair do Roster?" + form_errors: + heading: + one: "1 problema impediu o salvamento:" + other: "%{count} problemas impediram o salvamento:" + + pagination: + previous: "Anterior" + next: "Próxima" + page_of: "Página %{page} de %{pages}" + + roles: + user: "Usuário" + admin: "Administrador" + + sessions: + throttled: "Tentativas demais. Tente novamente em alguns instantes." + invalid_credentials: "Verifique o e-mail e a senha e tente de novo." + new: + title: "Entrar" + subtitle: "Use o e-mail e a senha da sua conta." + email_address: "E-mail" + password: "Senha" + submit: "Entrar" + forgot_password: "Esqueceu a senha?" + create_account: "Criar uma conta" + + registrations: + created: "Boas-vindas. Sua conta está pronta." + new: + title: "Crie sua conta" + subtitle: "Você entra automaticamente assim que ela estiver pronta." + full_name: "Nome completo" + email_address: "E-mail" + password: "Senha" + password_hint: "No mínimo 8 caracteres." + password_confirmation: "Confirme a senha" + submit: "Criar conta" + have_account: "Já tem uma conta?" + sign_in: "Entrar" + + profiles: + updated: "Seu perfil foi atualizado." + deleted: "Sua conta foi excluída." + show: + title: "Meu perfil" + subtitle: "Os dados da sua conta" + edit: "Editar perfil" + full_name: "Nome completo" + email_address: "E-mail" + role: "Função" + member_since: "Membro desde" + delete_heading: "Excluir esta conta" + delete_copy: "Seu perfil e suas sessões são removidos. Não há como desfazer." + delete_button: "Excluir minha conta" + delete_confirm: "Excluir sua conta? Não há como desfazer." + edit: + title: "Editar perfil" + subtitle: "Atualize os dados da sua conta" + full_name: "Nome completo" + email_address: "E-mail" + avatar: "Avatar" + avatar_file: "Arquivo do avatar" + avatar_hint: "PNG, JPEG, WebP ou GIF, até 2 MB." + remove_avatar: "Remover a imagem enviada" + avatar_url: "URL do avatar" + avatar_url_hint: "Usada quando não há upload. Link http ou https; a imagem é carregada pelo seu navegador, nunca buscada pelo servidor." + submit: "Salvar alterações" + cancel: "Cancelar" + + admin: + dashboard: + overview: "Visão geral" + total: "Total de usuários" + administrators: "Administradores" + regular_users: "Usuários" + show: + title: "Painel" + subtitle: "Visão geral das pessoas no sistema" + users: "Usuários" + manage: "Gerenciar" + manage_copy: "Adicione pessoas, altere funções e remova contas." + users: + created: "%{name} foi adicionado." + updated: "%{name} foi atualizado." + deleted: "%{name} foi excluído." + form: + full_name: "Nome completo" + email_address: "E-mail" + role: "Função" + password: "Senha" + password_hint_new: "No mínimo 8 caracteres." + password_hint_edit: "Deixe em branco para manter a senha atual." + avatar: "Arquivo do avatar" + avatar_hint: "PNG, JPEG, WebP ou GIF, até 2 MB." + avatar_url: "URL do avatar" + avatar_url_hint: "Um link https para uma imagem. Opcional." + index: + title: "Usuários" + subtitle: "Todo mundo com uma conta" + search_label: "Buscar" + search_placeholder: "Buscar por nome ou e-mail" + search_submit: "Buscar" + clear: "Limpar" + filter_all: "Todos" + filter_admin: "Administradores" + filter_user: "Usuários" + new_user: "Adicionar usuário" + results: + one: "1 resultado" + other: "%{count} resultados" + name: "Nome" + email_address: "E-mail" + role: "Função" + actions: "Ações" + edit: "Editar" + delete: "Excluir" + delete_confirm: "Excluir %{name}? Não há como desfazer." + empty_title: "Ainda não há ninguém aqui" + empty_copy: "Nenhuma conta corresponde ao que você procura." + new: + title: "Adicionar usuário" + subtitle: "Crie uma conta em nome de outra pessoa" + submit: "Criar usuário" + cancel: "Cancelar" + edit: + title: "Editar usuário" + subtitle: "Atualize esta conta" + submit: "Salvar alterações" + cancel: "Cancelar" + + audit_events: + index: + title: "Atividade" + subtitle: "O que os administradores fizeram, e quando" + heading: "Histórico" + results: + one: "1 evento" + other: "%{count} eventos" + when: "Quando" + who: "Quem" + what: "O quê" + to_whom: "Sobre quem" + details: "Detalhes" + empty_title: "Ainda não aconteceu nada" + empty_copy: "Criar, alterar ou remover uma conta aparece aqui." + actions: + created: "Criou" + updated: "Alterou" + promoted: "Promoveu" + demoted: "Rebaixou" + deleted: "Removeu" + imported: "Importou" + details_import: "Da importação #%{id}" + + user_imports: + scheduled: "O arquivo entrou na fila. O progresso aparece abaixo conforme roda." + index: + title: "Importações" + subtitle: "Crie contas a partir de uma planilha" + download_template: "Baixar modelo" + upload: "Nova importação" + upload_copy: "Um arquivo CSV ou XLSX com as colunas full_name, email, avatar_url e role." + file: "Planilha" + file_hint: "CSV ou XLSX, até 5 MB e 10.000 linhas." + submit: "Iniciar importação" + history: "Histórico" + empty_title: "Nenhuma importação ainda" + empty_copy: "Envie uma planilha para criar contas em lote." + show: + title: "Importação" + rejected_rows: "Linhas rejeitadas" + download_rejected: "Baixar em CSV" + row: "Linha" + email_address: "E-mail" + problems: "Problemas" + no_rejected_rows: "Todas as linhas foram importadas." + back: "Voltar para importações" + statuses: + pending: "Na fila" + processing: "Importando" + completed: "Concluída" + completed_with_errors: "Concluída com erros" + failed: "Falhou" + progress: + label: "Progresso da importação" + counts: "%{created} criados, %{rejected} rejeitados de %{total}" + + locales: + unsupported: "Esse idioma não está disponível." + + authorization: + admin_only: "Essa área é exclusiva de administradores." + + passwords: + throttled: "Tentativas demais. Tente novamente em alguns instantes." + reset_instructions_sent: "Enviamos as instruções de redefinição (caso exista uma conta com esse e-mail)." + reset: "Sua senha foi redefinida." + chosen: "Senha definida. Agora é só entrar." + mismatch: "As senhas não coincidem." + invalid_token: "Este link de redefinição é inválido ou expirou." + new: + title: "Esqueceu sua senha?" + subtitle: "Enviaremos por e-mail um link para você escolher outra." + email_address: "E-mail" + submit: "Enviar instruções" + remembered: "Lembrou a senha?" + sign_in: "Entrar" + edit: + title: "Escolha uma nova senha" + subtitle: "Você precisará da nova senha para entrar de novo em todos os aparelhos." + password: "Nova senha" + password_hint: "Ao menos 8 caracteres." + password_confirmation: "Confirme a nova senha" + submit: "Salvar senha" + invited_title: "Boas-vindas. Escolha sua senha" + invited_subtitle: "Sua conta já existe; falta só uma senha sua." + invited_password: "Senha" + invited_password_confirmation: "Confirme a senha" + + passwords_mailer: + reset: + subject: "Redefina sua senha" + heading: "Redefina sua senha" + body: "Use o link abaixo para escolher uma nova senha da sua conta no Roster." + action: "Escolher nova senha" + expires: "O link expira em %{duration}." + ignore: "Se você não pediu isso, nada mudou e pode ignorar esta mensagem." + + invitations_mailer: + invite: + subject: "Sua conta no Roster está pronta" + heading: "Sua conta está pronta" + body: "Olá, %{name}. Criamos uma conta para você no Roster. Escolha uma senha e ela é sua." + action: "Escolher uma senha" + expires: "O link vale por %{duration}." + sign_in_with: "Você vai entrar com %{email_address}." + + activerecord: + errors: + models: + user_import: + attributes: + file: + too_large: "deve ter menos de %{limit} MB." + unsupported_format: "deve ser um arquivo .csv ou .xlsx." + content_mismatch: "não contém o que a extensão promete." + user: + attributes: + avatar: + too_large: "deve ter menos de %{limit} MB." + invalid_type: "deve ser uma imagem PNG, JPEG, WebP ou GIF." + avatar_url: + unsupported_scheme: "deve ser um link http ou https." + base: + last_administrator: "Este é o único administrador restante, então a função não pode ser removida." diff --git a/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..6cee05be2 --- /dev/null +++ b/config/routes.rb @@ -0,0 +1,44 @@ +Rails.application.routes.draw do + # Swagger UI, and the generated OpenAPI document it reads. + mount Rswag::Ui::Engine => "/api-docs" + mount Rswag::Api::Engine => "/api-docs" + + root "home#index" + + resource :locale, only: :update + resource :session + resources :passwords, param: :token + resource :registration, only: %i[new create], path: "sign_up", path_names: { new: "" } + resource :profile, only: %i[show edit update destroy] + + namespace :admin do + get "dashboard", to: "dashboard#show" + # No `show`: the list carries everything there is to know about a person, + # and a route whose action does not exist answers 404 from a path the + # application itself advertises. + resources :users, except: :show + + resources :audit_events, only: :index, path: "activity" + + resources :user_imports, only: %i[index new create show] do + get :template, on: :collection + get :rejected_rows, on: :member + end + end + + # The JSON API. Versioned in the path from the first day, so the second + # version does not have to be a different application. + namespace :api do + namespace :v1 do + resource :token, only: :create, path: "tokens" + resource :profile, only: :show, path: "me" + # `new` and `edit` are routes for rendering forms; a JSON API has none to + # render, and leaving them defined means two advertised paths that only + # ever answer 404. + resources :users, except: %i[new edit] + end + end + + # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. + get "up" => "rails/health#show", as: :rails_health_check +end diff --git a/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..593da414c --- /dev/null +++ b/db/cable_schema.rb @@ -0,0 +1,26 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.1].define(version: 1) do + # These are extensions that must be enabled in order to support this database + enable_extension "pg_catalog.plpgsql" + + create_table "solid_cable_messages", force: :cascade do |t| + t.binary "channel", null: false + t.bigint "channel_hash", null: false + t.datetime "created_at", null: false + t.binary "payload", null: false + t.index ["channel"], name: "index_solid_cable_messages_on_channel" + t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash" + t.index ["created_at"], name: "index_solid_cable_messages_on_created_at" + end +end diff --git a/db/cache_schema.rb b/db/cache_schema.rb new file mode 100644 index 000000000..96be0dcaf --- /dev/null +++ b/db/cache_schema.rb @@ -0,0 +1,27 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.1].define(version: 1) do + # These are extensions that must be enabled in order to support this database + enable_extension "pg_catalog.plpgsql" + + create_table "solid_cache_entries", force: :cascade do |t| + t.integer "byte_size", null: false + t.datetime "created_at", null: false + t.binary "key", null: false + t.bigint "key_hash", null: false + t.binary "value", null: false + t.index ["byte_size"], name: "index_solid_cache_entries_on_byte_size" + t.index ["key_hash", "byte_size"], name: "index_solid_cache_entries_on_key_hash_and_byte_size" + t.index ["key_hash"], name: "index_solid_cache_entries_on_key_hash", unique: true + end +end diff --git a/db/migrate/20260903171333_create_users.rb b/db/migrate/20260903171333_create_users.rb new file mode 100644 index 000000000..8872a6442 --- /dev/null +++ b/db/migrate/20260903171333_create_users.rb @@ -0,0 +1,24 @@ +class CreateUsers < ActiveRecord::Migration[8.1] + def change + create_table :users do |t| + t.string :full_name, null: false + t.string :email_address, null: false + t.string :password_digest, null: false + t.integer :role, null: false, default: 0 + t.string :avatar_url + + t.timestamps + end + + # Uniqueness is enforced on the lowercased value so the database upholds the + # same case-insensitive rule the model does, even for writes that skip + # validation. + add_index :users, "lower(email_address)", unique: true, + name: "index_users_on_lower_email_address" + add_index :users, :role + + # The enum only defines two roles; the constraint stops anything else from + # reaching the column through a raw write. + add_check_constraint :users, "role IN (0, 1)", name: "users_role_within_enum" + end +end diff --git a/db/migrate/20260903171334_create_sessions.rb b/db/migrate/20260903171334_create_sessions.rb new file mode 100644 index 000000000..ec9efdbaa --- /dev/null +++ b/db/migrate/20260903171334_create_sessions.rb @@ -0,0 +1,11 @@ +class CreateSessions < ActiveRecord::Migration[8.1] + def change + create_table :sessions do |t| + t.references :user, null: false, foreign_key: true + t.string :ip_address + t.string :user_agent + + t.timestamps + end + end +end diff --git a/db/migrate/20260903172348_add_locale_to_users.rb b/db/migrate/20260903172348_add_locale_to_users.rb new file mode 100644 index 000000000..91361106b --- /dev/null +++ b/db/migrate/20260903172348_add_locale_to_users.rb @@ -0,0 +1,10 @@ +class AddLocaleToUsers < ActiveRecord::Migration[8.1] + def change + add_column :users, :locale, :string, null: false, default: "en" + + # The application ships three locales; anything else reaching the column + # through a raw write would render a half-translated page. + add_check_constraint :users, "locale IN ('en', 'pt-BR', 'es')", + name: "users_locale_supported" + end +end diff --git a/db/migrate/20260903175421_create_active_storage_tables.active_storage.rb b/db/migrate/20260903175421_create_active_storage_tables.active_storage.rb new file mode 100644 index 000000000..c56fad134 --- /dev/null +++ b/db/migrate/20260903175421_create_active_storage_tables.active_storage.rb @@ -0,0 +1,59 @@ +# This migration comes from active_storage (originally 20170806125915) +class CreateActiveStorageTables < ActiveRecord::Migration[7.0] + def change + # Use Active Record's configured type for primary and foreign keys + primary_key_type, foreign_key_type = primary_and_foreign_key_types + + create_table :active_storage_blobs, id: primary_key_type do |t| + t.string :key, null: false + t.string :filename, null: false + t.string :content_type + t.text :metadata + t.string :service_name, null: false + t.bigint :byte_size, null: false + t.string :checksum + + if connection.supports_datetime_with_precision? + t.datetime :created_at, precision: 6, null: false + else + t.datetime :created_at, null: false + end + + t.index [:key], unique: true + end + + create_table :active_storage_attachments, id: primary_key_type do |t| + t.string :name, null: false + t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type + t.references :blob, null: false, type: foreign_key_type + + if connection.supports_datetime_with_precision? + t.datetime :created_at, precision: 6, null: false + else + t.datetime :created_at, null: false + end + + t.index %i[record_type record_id name blob_id], name: :index_active_storage_attachments_uniqueness, + unique: true + t.foreign_key :active_storage_blobs, column: :blob_id + end + + create_table :active_storage_variant_records, id: primary_key_type do |t| + t.belongs_to :blob, null: false, index: false, type: foreign_key_type + t.string :variation_digest, null: false + + t.index %i[blob_id variation_digest], name: :index_active_storage_variant_records_uniqueness, unique: true + t.foreign_key :active_storage_blobs, column: :blob_id + end + end + + private + + def primary_and_foreign_key_types + config = Rails.configuration.generators + setting = config.options[config.orm][:primary_key_type] + primary_key_type = setting || :primary_key + foreign_key_type = setting || :bigint + [primary_key_type, foreign_key_type] + end +end diff --git a/db/migrate/20260903180523_create_user_imports.rb b/db/migrate/20260903180523_create_user_imports.rb new file mode 100644 index 000000000..e4ab97fbb --- /dev/null +++ b/db/migrate/20260903180523_create_user_imports.rb @@ -0,0 +1,33 @@ +class CreateUserImports < ActiveRecord::Migration[8.1] + def change + create_table :user_imports do |t| + t.references :administrator, null: false, foreign_key: { to_table: :users } + + t.integer :status, null: false, default: 0 + + # Counters rather than derived queries: the import reports progress while + # it is still running, when the rows it has not reached yet do not exist + # anywhere to be counted. + t.integer :total_rows, null: false, default: 0 + t.integer :processed_rows, null: false, default: 0 + t.integer :created_users, null: false, default: 0 + t.integer :rejected_rows, null: false, default: 0 + + # Set only when the whole import failed, as opposed to individual rows. + t.text :failure_reason + + t.datetime :started_at + t.datetime :finished_at + + t.timestamps + end + + add_index :user_imports, :status + add_index :user_imports, :created_at + + add_check_constraint :user_imports, "status BETWEEN 0 AND 4", name: "user_imports_status_within_enum" + add_check_constraint :user_imports, + "total_rows >= 0 AND processed_rows >= 0 AND created_users >= 0 AND rejected_rows >= 0", + name: "user_imports_counters_not_negative" + end +end diff --git a/db/migrate/20260903180524_create_user_import_errors.rb b/db/migrate/20260903180524_create_user_import_errors.rb new file mode 100644 index 000000000..d830859d3 --- /dev/null +++ b/db/migrate/20260903180524_create_user_import_errors.rb @@ -0,0 +1,18 @@ +class CreateUserImportErrors < ActiveRecord::Migration[8.1] + def change + create_table :user_import_errors do |t| + t.references :user_import, null: false, foreign_key: true + t.integer :row_number, null: false + + # Kept as given, not normalised: the report has to show the operator what + # was actually in the file so they can find and fix the row. + t.string :email_address + t.string :messages, array: true, null: false, default: [] + + t.timestamps + end + + add_index :user_import_errors, %i[user_import_id row_number] + add_check_constraint :user_import_errors, "row_number > 0", name: "user_import_errors_row_number_positive" + end +end diff --git a/db/migrate/20260904113000_keep_imports_when_their_administrator_is_removed.rb b/db/migrate/20260904113000_keep_imports_when_their_administrator_is_removed.rb new file mode 100644 index 000000000..b83edac7e --- /dev/null +++ b/db/migrate/20260904113000_keep_imports_when_their_administrator_is_removed.rb @@ -0,0 +1,29 @@ +# Deleting an administrator who had ever run an import violated the foreign key +# and raised out of the controller as a 500. The import history is worth +# keeping when the account that asked for it is gone, so the reference becomes +# optional and the address is copied onto the row, where it survives. +class KeepImportsWhenTheirAdministratorIsRemoved < ActiveRecord::Migration[8.1] + def up + change_table :user_imports, bulk: true do |t| + t.change_null :administrator_id, true + t.string :administrator_email + end + + execute <<~SQL.squish + UPDATE user_imports + SET administrator_email = users.email_address + FROM users + WHERE users.id = user_imports.administrator_id + AND user_imports.administrator_email IS NULL + SQL + + change_column_null :user_imports, :administrator_email, false + end + + def down + change_table :user_imports, bulk: true do |t| + t.remove :administrator_email + t.change_null :administrator_id, false + end + end +end diff --git a/db/migrate/20260904140000_index_the_search_with_trigrams.rb b/db/migrate/20260904140000_index_the_search_with_trigrams.rb new file mode 100644 index 000000000..4bfd1b68d --- /dev/null +++ b/db/migrate/20260904140000_index_the_search_with_trigrams.rb @@ -0,0 +1,25 @@ +# The search is `ILIKE '%term%'` over two columns, which no ordinary B-tree +# index can serve: a leading wildcard has nothing to seek on, so PostgreSQL +# reads every row. Trigram indexes do serve it, but one per column is worse +# than none here -- the planner compares two GIN scans against one sequential +# scan and picks the sequential scan. +# +# So the two columns become one: a stored generated column that PostgreSQL +# keeps in step with them, and a single trigram index over it. +# +# Built concurrently, and therefore outside a transaction, so a deploy against +# a table with real volume in it does not lock writes while the index is built. +class IndexTheSearchWithTrigrams < ActiveRecord::Migration[8.1] + disable_ddl_transaction! + + def change + enable_extension "pg_trgm" + + add_column :users, :searchable_text, :virtual, type: :string, + as: "full_name || ' ' || email_address", stored: true + + add_index :users, :searchable_text, using: :gin, opclass: :gin_trgm_ops, + name: "index_users_on_searchable_text_trigrams", + algorithm: :concurrently + end +end diff --git a/db/migrate/20260904150000_create_audit_events.rb b/db/migrate/20260904150000_create_audit_events.rb new file mode 100644 index 000000000..7a9379196 --- /dev/null +++ b/db/migrate/20260904150000_create_audit_events.rb @@ -0,0 +1,26 @@ +# Who changed whom, and when. A system whose whole purpose is administering +# accounts should be able to answer that question, and it cannot be answered +# from the users table: the row that would tell you is the one that changed. +class CreateAuditEvents < ActiveRecord::Migration[8.1] + def change + create_table :audit_events do |t| + # Both sides are nullable and nullified, because an audit trail that + # disappears when an account does is not an audit trail. The addresses + # are copied so the record still reads after either is gone. + t.references :actor, foreign_key: { to_table: :users, on_delete: :nullify } + t.references :subject, foreign_key: { to_table: :users, on_delete: :nullify } + t.string :actor_email, null: false + t.string :subject_email, null: false + + t.integer :action, null: false + t.jsonb :details, null: false, default: {} + + # No updated_at: an audit row is written once and never edited. + t.datetime :created_at, null: false + end + + add_index :audit_events, :created_at + add_index :audit_events, :action + add_check_constraint :audit_events, "action BETWEEN 0 AND 5", name: "audit_events_action_within_enum" + end +end diff --git a/db/queue_schema.rb b/db/queue_schema.rb new file mode 100644 index 000000000..00929c207 --- /dev/null +++ b/db/queue_schema.rb @@ -0,0 +1,175 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.1].define(version: 1) do + # These are extensions that must be enabled in order to support this database + enable_extension "pg_catalog.plpgsql" + + create_table "solid_queue_batch_executions", force: :cascade do |t| + t.bigint "batch_id", null: false + t.datetime "created_at", null: false + t.bigint "job_id", null: false + t.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 + + add_foreign_key "solid_queue_batch_executions", "solid_queue_batches", column: "batch_id", on_delete: :cascade + add_foreign_key "solid_queue_batch_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade +end diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 000000000..9e6fa13fc --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,126 @@ +# 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_04_150000) do + # These are extensions that must be enabled in order to support this database + enable_extension "pg_catalog.plpgsql" + enable_extension "pg_trgm" + + create_table "active_storage_attachments", force: :cascade do |t| + t.bigint "blob_id", null: false + t.datetime "created_at", null: false + t.string "name", null: false + t.bigint "record_id", null: false + t.string "record_type", null: false + t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" + t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true + end + + create_table "active_storage_blobs", force: :cascade do |t| + t.bigint "byte_size", null: false + t.string "checksum" + t.string "content_type" + t.datetime "created_at", null: false + t.string "filename", null: false + t.string "key", null: false + t.text "metadata" + t.string "service_name", null: false + t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true + end + + create_table "active_storage_variant_records", force: :cascade do |t| + t.bigint "blob_id", null: false + t.string "variation_digest", null: false + t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true + end + + create_table "audit_events", force: :cascade do |t| + t.integer "action", null: false + t.string "actor_email", null: false + t.bigint "actor_id" + t.datetime "created_at", null: false + t.jsonb "details", default: {}, null: false + t.string "subject_email", null: false + t.bigint "subject_id" + t.index ["action"], name: "index_audit_events_on_action" + t.index ["actor_id"], name: "index_audit_events_on_actor_id" + t.index ["created_at"], name: "index_audit_events_on_created_at" + t.index ["subject_id"], name: "index_audit_events_on_subject_id" + t.check_constraint "action >= 0 AND action <= 5", name: "audit_events_action_within_enum" + end + + create_table "sessions", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "ip_address" + 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_import_errors", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "email_address" + t.string "messages", default: [], null: false, array: true + t.integer "row_number", null: false + t.datetime "updated_at", null: false + t.bigint "user_import_id", null: false + t.index ["user_import_id", "row_number"], name: "index_user_import_errors_on_user_import_id_and_row_number" + t.index ["user_import_id"], name: "index_user_import_errors_on_user_import_id" + t.check_constraint "row_number > 0", name: "user_import_errors_row_number_positive" + end + + create_table "user_imports", force: :cascade do |t| + t.string "administrator_email", null: false + t.bigint "administrator_id" + t.datetime "created_at", null: false + t.integer "created_users", default: 0, null: false + t.text "failure_reason" + t.datetime "finished_at" + t.integer "processed_rows", default: 0, null: false + t.integer "rejected_rows", default: 0, null: false + t.datetime "started_at" + t.integer "status", default: 0, null: false + t.integer "total_rows", default: 0, null: false + t.datetime "updated_at", null: false + t.index ["administrator_id"], name: "index_user_imports_on_administrator_id" + t.index ["created_at"], name: "index_user_imports_on_created_at" + t.index ["status"], name: "index_user_imports_on_status" + t.check_constraint "status >= 0 AND status <= 4", name: "user_imports_status_within_enum" + t.check_constraint "total_rows >= 0 AND processed_rows >= 0 AND created_users >= 0 AND rejected_rows >= 0", name: "user_imports_counters_not_negative" + end + + create_table "users", force: :cascade do |t| + t.string "avatar_url" + t.datetime "created_at", null: false + t.string "email_address", null: false + t.string "full_name", null: false + t.string "locale", default: "en", null: false + t.string "password_digest", null: false + t.integer "role", default: 0, null: false + t.virtual "searchable_text", type: :string, as: "(((full_name)::text || ' '::text) || (email_address)::text)", stored: true + t.datetime "updated_at", null: false + t.index "lower((email_address)::text)", name: "index_users_on_lower_email_address", unique: true + t.index ["role"], name: "index_users_on_role" + t.index ["searchable_text"], name: "index_users_on_searchable_text_trigrams", opclass: :gin_trgm_ops, using: :gin + t.check_constraint "locale::text = ANY (ARRAY['en'::character varying, 'pt-BR'::character varying, 'es'::character varying]::text[])", name: "users_locale_supported" + t.check_constraint "role = ANY (ARRAY[0, 1])", name: "users_role_within_enum" + end + + add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" + add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" + add_foreign_key "audit_events", "users", column: "actor_id", on_delete: :nullify + add_foreign_key "audit_events", "users", column: "subject_id", on_delete: :nullify + add_foreign_key "sessions", "users" + add_foreign_key "user_import_errors", "user_imports" + add_foreign_key "user_imports", "users", column: "administrator_id" +end diff --git a/db/seeds.rb b/db/seeds.rb new file mode 100644 index 000000000..450843a44 --- /dev/null +++ b/db/seeds.rb @@ -0,0 +1,67 @@ +# Idempotent seeds: running this repeatedly converges on the same data instead +# of piling up duplicates. +# +# `db:prepare` runs this file, and in production that happens while the first +# container is booting -- so nothing here may abort. A seed that has nothing to +# do says so and lets the application start. + +def upsert_user!(email_address:, full_name:, role:, password:, locale: "en") + user = User.find_or_initialize_by(email_address: email_address) + user.assign_attributes(full_name: full_name, role: role, locale: locale) + user.password = password if user.new_record? + user.save! + user +end + +# ── Production ─────────────────────────────────────────────────────────────── +# One administrator, from credentials supplied at deploy time, and nothing +# else. The demonstration roster below shares a single known password, which +# has no business existing on a real installation. +if Rails.env.production? + email_address = ENV["SEED_ADMIN_EMAIL"].presence + password = ENV["SEED_ADMIN_PASSWORD"].presence + + if email_address && password + upsert_user!( + email_address: email_address, + full_name: ENV.fetch("SEED_ADMIN_NAME", "Administrator"), + role: :admin, + password: password + ) + Rails.logger.info { "Seeded the administrator account #{email_address}." } + else + Rails.logger.info do + "Skipping seeds: set SEED_ADMIN_EMAIL and SEED_ADMIN_PASSWORD to create the first administrator." + end + end + + return +end + +# ── Everywhere else ────────────────────────────────────────────────────────── +DEMO_PASSWORD = ENV.fetch("SEED_ADMIN_PASSWORD", "password-for-development") + +upsert_user!(email_address: "admin@example.com", full_name: "Ada Lovelace", + role: :admin, password: DEMO_PASSWORD) +upsert_user!(email_address: "admin.two@example.com", full_name: "Grace Hopper", + role: :admin, password: DEMO_PASSWORD) +upsert_user!(email_address: "user@example.com", full_name: "Maria Silva", + role: :user, password: DEMO_PASSWORD, locale: "pt-BR") + +[ + ["joao.souza@example.com", "João Souza", "pt-BR"], + ["carla.mendes@example.com", "Carla Mendes", "pt-BR"], + ["diego.ramirez@example.com", "Diego Ramírez", "es"], + ["lucia.fernandez@example.com", "Lucía Fernández", "es"], + ["olivia.clarke@example.com", "Olivia Clarke", "en"], + ["noah.bennett@example.com", "Noah Bennett", "en"], + ["priya.nair@example.com", "Priya Nair", "en"], + ["tomas.novak@example.com", "Tomáš Novák", "en"], + ["yuki.tanaka@example.com", "Yuki Tanaka", "en"], + ["amara.okafor@example.com", "Amara Okafor", "en"] +].each do |email_address, full_name, locale| + upsert_user!(email_address: email_address, full_name: full_name, + role: :user, password: DEMO_PASSWORD, locale: locale) +end + +Rails.logger.debug { "Seeded #{User.count} users (#{User.admin.count} administrators)." } diff --git a/devops/README.md b/devops/README.md new file mode 100644 index 000000000..9c07ad624 --- /dev/null +++ b/devops/README.md @@ -0,0 +1,87 @@ +# devops + +One script per action, each a thin wrapper over `docker compose`. The shared +helpers — logging, guards, and the `compose` wrapper that resolves the compose +file and the env file — live in `common.sh`; source it, do not execute it. + +Every script prints what it is doing and stops on the first failure. + +## The application + +```bash +devops/app/setup.sh # from a clean checkout: .env, images, databases, seeds +devops/app/setup.sh --no-seed # the same, without the demonstration accounts +devops/app/reset.sh # throw the environment away, including the data volume +devops/app/start.sh # start everything, in the background +devops/app/start.sh --attach # start in the foreground, streaming logs +devops/app/stop.sh # stop everything, keeping the data +devops/app/restart.sh # restart every service +devops/app/restart.sh web # restart one +devops/app/status.sh # what is running, and whether it answers +devops/app/seed.sh # run the seeds again +devops/app/logs.sh # follow every service at once +``` + +## Rails + +```bash +devops/rails/console.sh # a Rails console in the running container +devops/rails/logs.sh # follow the web logs +devops/rails/migrate.sh # run migrations +devops/rails/test.sh # the suite +devops/rails/test.sh spec/models/user_spec.rb +devops/rails/test-parallel.sh # across four workers, each with its own databases +devops/rails/lint.sh # RuboCop +devops/rails/security.sh # Brakeman, bundler-audit, importmap audit +``` + +## Worker + +```bash +devops/worker/logs.sh # follow the Solid Queue logs +devops/worker/status.sh # what the queue is doing +``` + +## PostgreSQL + +```bash +devops/postgres/psql.sh # an interactive session +devops/postgres/logs.sh # follow the database logs +devops/postgres/dump.sh # dump development to tmp/backups/ +devops/postgres/dump.sh tmp/backups/before-the-migration.dump +``` + +## Everything at once + +```bash +devops/tests/all.sh # lint, security analysis and the suite +``` + +## The `bin/` shortcuts + +`bin/` holds the handful of commands used often enough to deserve a shorter +name. They delegate here; the work is in this directory. + +| shortcut | runs | +| --- | --- | +| `bin/setup` | `devops/app/setup.sh` | +| `bin/setup --reset` | `devops/app/reset.sh` | +| `bin/dev` | `devops/app/start.sh` | +| `bin/dev --down` | `devops/app/stop.sh` | +| `bin/dev --status` | `devops/app/status.sh` | +| `bin/test` | `devops/rails/test.sh` | +| `bin/test --parallel` | `devops/rails/test-parallel.sh` | +| `bin/test --live` | `devops/rails/test.sh` with Solid Cable in place of the test adapter | +| `bin/lint` | `devops/rails/lint.sh` | +| `bin/ci` | the whole pipeline, defined once in `config/ci.rb` | + +## Environment + +Every script reads `.env` (created by `devops/app/setup.sh` from +`.env.example`) through the `compose` wrapper, so there is one place where the +compose file and the env file are resolved: + +```bash +COMPOSE_FILE_PATH=/path/to/docker-compose.yml devops/app/status.sh +ENV_FILE_PATH=/path/to/.env devops/app/start.sh +``` diff --git a/devops/app/logs.sh b/devops/app/logs.sh new file mode 100755 index 000000000..b44efddff --- /dev/null +++ b/devops/app/logs.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Follows the logs of every service at once. For one service, use the wrapper +# next to it: devops/rails/logs.sh, devops/worker/logs.sh, devops/postgres/logs.sh. +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/common.sh" +compose logs --follow --tail="${TAIL:-200}" "$@" diff --git a/devops/app/reset.sh b/devops/app/reset.sh new file mode 100755 index 000000000..425dbe8ed --- /dev/null +++ b/devops/app/reset.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Throws the environment away and builds it again: containers, the data volume +# and every database. Everything in development PostgreSQL is lost. +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/common.sh" +DEVOPS="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +require_docker + +step "Removing containers and the data volume" +compose down --volumes --remove-orphans +ok "Previous environment removed." + +"${DEVOPS}/app/setup.sh" "$@" diff --git a/devops/app/restart.sh b/devops/app/restart.sh new file mode 100755 index 000000000..f03d1051a --- /dev/null +++ b/devops/app/restart.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Restarts one service, or all of them. +# +# devops/app/restart.sh # everything +# devops/app/restart.sh web # one service +# +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/common.sh" + +require_docker +step "Restarting ${1:-every service}" +compose restart "$@" +compose ps diff --git a/devops/app/seed.sh b/devops/app/seed.sh new file mode 100755 index 000000000..50aa7a1f7 --- /dev/null +++ b/devops/app/seed.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Runs the seeds. Idempotent: running it twice changes nothing, and it puts +# back anything that was deleted while clicking around. +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/common.sh" + +require_running web +step "Seeding" +rails_exec ./bin/rails db:seed +ok "Seeded." diff --git a/devops/app/setup.sh b/devops/app/setup.sh new file mode 100755 index 000000000..06e207224 --- /dev/null +++ b/devops/app/setup.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Prepares the environment from a clean checkout: .env, images, the four +# databases, and the demonstration accounts. +# +# devops/app/setup.sh # everything +# devops/app/setup.sh --no-seed # without the demonstration accounts +# +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/common.sh" + +SEED=true +[[ "${1:-}" == "--no-seed" ]] && SEED=false + +require_docker + +step "Environment file" +if [[ -f .env ]]; then + log ".env already exists, leaving it untouched." +else + cp .env.example .env + ok "Created .env from .env.example." +fi + +step "Building images" +compose build + +step "Starting PostgreSQL" +compose up --detach --wait postgres + +step "Preparing databases" +# db:prepare creates and migrates all four: primary, cache, queue and cable. +compose run --rm --no-deps web ./bin/rails db:prepare + +if [[ "${SEED}" == true ]]; then + step "Seeding" + compose run --rm --no-deps web ./bin/rails db:seed +fi + +step "Starting the full stack" +compose up --detach --wait + +ok "Ready. The application is at http://localhost:${WEB_PORT:-3000}" +log "Logs: devops/rails/logs.sh Tests: bin/test Console: devops/rails/console.sh" diff --git a/devops/app/start.sh b/devops/app/start.sh new file mode 100755 index 000000000..54beb3246 --- /dev/null +++ b/devops/app/start.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Starts web, worker, the Tailwind watcher and PostgreSQL, and waits until they +# report healthy. +# +# devops/app/start.sh # in the background +# devops/app/start.sh --attach # in the foreground, streaming logs +# +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/common.sh" + +require_docker +[[ -f .env ]] || fail "No .env found. Run devops/app/setup.sh first." + +if [[ "${1:-}" == "--attach" ]]; then + step "Starting, streaming logs" + compose up +else + step "Starting" + compose up --detach --wait + ok "Running at http://localhost:${WEB_PORT:-3000}" +fi diff --git a/devops/app/status.sh b/devops/app/status.sh new file mode 100755 index 000000000..ba5ef8f21 --- /dev/null +++ b/devops/app/status.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# What is running, and whether it is healthy. +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/common.sh" + +require_docker +step "Services" +compose ps + +step "Health check" +if curl -sf "http://localhost:${WEB_PORT:-3000}/up" >/dev/null; then + ok "The application answers on http://localhost:${WEB_PORT:-3000}" +else + warn "The application is not answering on http://localhost:${WEB_PORT:-3000}" +fi diff --git a/devops/app/stop.sh b/devops/app/stop.sh new file mode 100755 index 000000000..2925d8cac --- /dev/null +++ b/devops/app/stop.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Stops every container. The data volume is left alone; devops/app/reset.sh is +# what removes it. +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/common.sh" + +require_docker +step "Stopping" +compose down +ok "Stopped." diff --git a/devops/common.sh b/devops/common.sh new file mode 100755 index 000000000..23eb62570 --- /dev/null +++ b/devops/common.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# +# Shared helpers for the devops scripts. Source this, do not execute it. +# +# source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/common.sh" +# +set -euo pipefail + +DEVOPS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${DEVOPS_DIR}/.." && pwd)" +cd "${PROJECT_ROOT}" + +COMPOSE_FILE_PATH="${COMPOSE_FILE_PATH:-${PROJECT_ROOT}/docker-compose.yml}" +ENV_FILE_PATH="${ENV_FILE_PATH:-${PROJECT_ROOT}/.env}" + +# ── Output helpers ─────────────────────────────────────────────────────────── +if [[ -t 1 ]]; then + RED=$'\033[0;31m'; GREEN=$'\033[0;32m'; YELLOW=$'\033[1;33m' + CYAN=$'\033[0;36m'; BOLD=$'\033[1m'; RESET=$'\033[0m' +else + RED=''; GREEN=''; YELLOW=''; CYAN=''; BOLD=''; RESET='' +fi + +log() { echo -e "${CYAN}[app]${RESET} $*"; } +ok() { echo -e "${GREEN}[ok]${RESET} $*"; } +warn() { echo -e "${YELLOW}[!]${RESET} $*" >&2; } +fail() { echo -e "${RED}[x]${RESET} $*" >&2; exit 1; } +step() { echo -e "\n${BOLD}${CYAN}==> $*${RESET}"; } + +# ── Guards ─────────────────────────────────────────────────────────────────── +require_docker() { + command -v docker >/dev/null 2>&1 || fail "Docker is not installed or not on PATH." + docker compose version >/dev/null 2>&1 || fail "Docker Compose v2 is required." + docker info >/dev/null 2>&1 || fail "Docker daemon is not reachable. Is it running?" +} + +require_running() { + local service="$1" + if [[ -z "$(compose ps --quiet "${service}" 2>/dev/null)" ]]; then + fail "Service '${service}' is not running. Start it with bin/dev." + fi +} + +# ── Compose wrapper ────────────────────────────────────────────────────────── +# Resolves the compose file and the optional env file once, so every script +# talks to the same stack. +compose() { + local args=(compose --file "${COMPOSE_FILE_PATH}") + [[ -f "${ENV_FILE_PATH}" ]] && args+=(--env-file "${ENV_FILE_PATH}") + docker "${args[@]}" "$@" +} + +# Runs a command inside the web container with the test environment wired up. +# Keeping RAILS_ENV=test here is what stops specs from writing into the +# development database through Faker and FactoryBot. +rails_test_exec() { + require_running web + compose exec -T \ + -e RAILS_ENV=test \ + -e DISABLE_SPRING=1 \ + web "$@" +} + +rails_exec() { + require_running web + compose exec web "$@" +} diff --git a/devops/postgres/dump.sh b/devops/postgres/dump.sh new file mode 100755 index 000000000..4ccd04674 --- /dev/null +++ b/devops/postgres/dump.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Dumps the development database to tmp/backups/. +# devops/postgres/dump.sh [output-file] +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/common.sh" +require_running postgres +mkdir -p tmp/backups +target="${1:-tmp/backups/development-$(date +%Y%m%d-%H%M%S).dump}" +step "Dumping to ${target}" +compose exec -T postgres pg_dump -U "${POSTGRES_USER:-user_management}" -Fc "${POSTGRES_DB:-user_management_development}" > "${target}" +ok "Wrote ${target} ($(du -h "${target}" | cut -f1))." diff --git a/devops/postgres/logs.sh b/devops/postgres/logs.sh new file mode 100755 index 000000000..d8816a945 --- /dev/null +++ b/devops/postgres/logs.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Follows the PostgreSQL logs. +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/common.sh" +compose logs --follow --tail="${TAIL:-200}" postgres "$@" diff --git a/devops/postgres/psql.sh b/devops/postgres/psql.sh new file mode 100755 index 000000000..bf3be6b0a --- /dev/null +++ b/devops/postgres/psql.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Interactive psql against the development database. +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/common.sh" +require_running postgres +compose exec postgres psql -U "${POSTGRES_USER:-user_management}" -d "${POSTGRES_DB:-user_management_development}" "$@" diff --git a/devops/rails/console.sh b/devops/rails/console.sh new file mode 100755 index 000000000..b8d830165 --- /dev/null +++ b/devops/rails/console.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Opens a Rails console in the running web container. +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/common.sh" +rails_exec ./bin/rails console "$@" diff --git a/devops/rails/lint.sh b/devops/rails/lint.sh new file mode 100755 index 000000000..fbb6c2e69 --- /dev/null +++ b/devops/rails/lint.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# RuboCop. Pass -a or -A to autocorrect. +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/common.sh" +rails_exec ./bin/rubocop "$@" diff --git a/devops/rails/logs.sh b/devops/rails/logs.sh new file mode 100755 index 000000000..59788b156 --- /dev/null +++ b/devops/rails/logs.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Follows the web server logs. Pass extra docker compose logs flags if needed. +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/common.sh" +compose logs --follow --tail="${TAIL:-200}" web "$@" diff --git a/devops/rails/migrate.sh b/devops/rails/migrate.sh new file mode 100755 index 000000000..e00edcd5b --- /dev/null +++ b/devops/rails/migrate.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Runs pending migrations across every configured database. +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/common.sh" +step "Migrating databases" +rails_exec ./bin/rails db:migrate "$@" +ok "Migrations applied." diff --git a/devops/rails/security.sh b/devops/rails/security.sh new file mode 100755 index 000000000..a76fbaf72 --- /dev/null +++ b/devops/rails/security.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Static security analysis: Brakeman plus a dependency audit. +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/common.sh" +status=0 +step "Brakeman" +rails_exec ./bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error || status=1 +step "Bundler Audit" +rails_exec ./bin/bundler-audit || status=1 +step "Importmap audit" +rails_exec ./bin/importmap audit || status=1 +if [[ "${status}" -ne 0 ]]; then + fail "Security checks reported findings." +fi +ok "No security findings." diff --git a/devops/rails/test-parallel.sh b/devops/rails/test-parallel.sh new file mode 100755 index 000000000..9ecfa0e80 --- /dev/null +++ b/devops/rails/test-parallel.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Runs the suite across parallel workers, each with its own database. +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/common.sh" +# Four by default rather than one per core: each worker needs its own set of +# four databases, and a 16-core machine would spend longer creating them than +# running the suite. Override with WORKERS=n. +DEFAULT_WORKERS=4 +CORES="$(nproc 2>/dev/null || echo "${DEFAULT_WORKERS}")" +WORKERS="${WORKERS:-$(( CORES < DEFAULT_WORKERS ? CORES : DEFAULT_WORKERS ))}" +# `parallel:prepare` loads the schema into each worker database. +# `parallel:setup` would run db:setup instead, which also runs the seeds and +# leaves the demonstration roster sitting in every test database. +step "Preparing ${WORKERS} parallel test databases" +rails_test_exec bundle exec rake parallel:prepare["${WORKERS}"] +step "Running RSpec on ${WORKERS} workers" +rails_test_exec bundle exec parallel_rspec -n "${WORKERS}" "$@" diff --git a/devops/rails/test.sh b/devops/rails/test.sh new file mode 100755 index 000000000..a95d9b56a --- /dev/null +++ b/devops/rails/test.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Runs RSpec inside the web container against the test databases. +# devops/rails/test.sh # whole suite +# devops/rails/test.sh spec/models/user_spec.rb +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/common.sh" +step "Preparing test databases" +rails_test_exec ./bin/rails db:test:prepare +step "Running RSpec" +if [[ "$#" -gt 0 ]]; then + rails_test_exec env SCREENSHOTS="${SCREENSHOTS:-}" CABLE_ADAPTER="${CABLE_ADAPTER:-}" bundle exec rspec "$@" +else + rails_test_exec env SCREENSHOTS="${SCREENSHOTS:-}" CABLE_ADAPTER="${CABLE_ADAPTER:-}" bundle exec rspec +fi diff --git a/devops/tests/all.sh b/devops/tests/all.sh new file mode 100755 index 000000000..0f6225d0c --- /dev/null +++ b/devops/tests/all.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Full verification: lint, security analysis and the test suite. +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/common.sh" +DEVOPS="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +"${DEVOPS}/rails/lint.sh" +"${DEVOPS}/rails/security.sh" +"${DEVOPS}/rails/test.sh" "$@" +ok "All checks passed." diff --git a/devops/worker/logs.sh b/devops/worker/logs.sh new file mode 100755 index 000000000..b3cf874ba --- /dev/null +++ b/devops/worker/logs.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Follows the Solid Queue worker logs. +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/common.sh" +compose logs --follow --tail="${TAIL:-200}" worker "$@" diff --git a/devops/worker/status.sh b/devops/worker/status.sh new file mode 100755 index 000000000..3610c8479 --- /dev/null +++ b/devops/worker/status.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Shows Solid Queue processes, queue depth and failed jobs. +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/common.sh" +rails_exec ./bin/rails runner ' + puts "processes: #{SolidQueue::Process.count}" + puts "ready: #{SolidQueue::ReadyExecution.count}" + puts "claimed: #{SolidQueue::ClaimedExecution.count}" + puts "failed: #{SolidQueue::FailedExecution.count}" +' diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..b614033e0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,124 @@ +# Development environment. +# +# Four processes, deliberately separate: the web server, the Solid Queue +# worker, the Tailwind watcher and PostgreSQL. The worker is its own container +# so asynchronous imports are genuinely asynchronous, not something that only +# works because a single process happens to run everything. + +name: user-management + +services: + # ─────────────────────────────────────────────── + # PostgreSQL + # ─────────────────────────────────────────────── + postgres: + image: postgres:17-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-user_management} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-development_only} + POSTGRES_DB: ${POSTGRES_DB:-user_management_development} + volumes: + - postgres_data:/var/lib/postgresql/data + # Not published to the host: reach it with devops/postgres/psql.sh instead. + expose: + - "5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-user_management}"] + interval: 10s + timeout: 5s + retries: 5 + networks: [app_network] + deploy: + resources: + limits: + memory: 512M + + # ─────────────────────────────────────────────── + # Web (Puma) + # ─────────────────────────────────────────────── + web: + build: + context: . + target: development + restart: unless-stopped + command: ./bin/rails server -b 0.0.0.0 -p 3000 + environment: &app_env + RAILS_ENV: development + POSTGRES_HOST: postgres + POSTGRES_PORT: 5432 + POSTGRES_USER: ${POSTGRES_USER:-user_management} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-development_only} + POSTGRES_DB: ${POSTGRES_DB:-user_management_development} + TEST_POSTGRES_DB: ${TEST_POSTGRES_DB:-user_management_test} + RAILS_MAX_THREADS: ${RAILS_MAX_THREADS:-5} + volumes: + - .:/rails + ports: + - "${WEB_PORT:-3000}:3000" + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:3000/up || exit 1"] + interval: 15s + timeout: 10s + retries: 8 + start_period: 45s + depends_on: + postgres: + condition: service_healthy + networks: [app_network] + deploy: + resources: + limits: + memory: 1G + + # ─────────────────────────────────────────────── + # Solid Queue worker + # ─────────────────────────────────────────────── + worker: + build: + context: . + target: development + restart: unless-stopped + command: ./bin/jobs + environment: *app_env + volumes: + - .:/rails + healthcheck: + test: ["CMD-SHELL", "pgrep -f solid-queue || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 45s + depends_on: + postgres: + condition: service_healthy + networks: [app_network] + deploy: + resources: + limits: + memory: 512M + + # ─────────────────────────────────────────────── + # Tailwind watcher + # ─────────────────────────────────────────────── + css: + build: + context: . + target: development + restart: unless-stopped + command: ./bin/rails tailwindcss:watch[always] + environment: *app_env + volumes: + - .:/rails + networks: [app_network] + deploy: + resources: + limits: + memory: 256M + +volumes: + postgres_data: + +networks: + app_network: + driver: bridge diff --git a/docs/screenshots/activity.png b/docs/screenshots/activity.png new file mode 100644 index 000000000..0dc542423 Binary files /dev/null and b/docs/screenshots/activity.png differ diff --git a/docs/screenshots/api-docs.jpg b/docs/screenshots/api-docs.jpg new file mode 100644 index 000000000..bb3d6733c Binary files /dev/null and b/docs/screenshots/api-docs.jpg differ diff --git a/docs/screenshots/dashboard.png b/docs/screenshots/dashboard.png new file mode 100644 index 000000000..202bf0741 Binary files /dev/null and b/docs/screenshots/dashboard.png differ diff --git a/docs/screenshots/import-detail.png b/docs/screenshots/import-detail.png new file mode 100644 index 000000000..d6a2051f3 Binary files /dev/null and b/docs/screenshots/import-detail.png differ diff --git a/docs/screenshots/imports.png b/docs/screenshots/imports.png new file mode 100644 index 000000000..1f63e0046 Binary files /dev/null and b/docs/screenshots/imports.png differ diff --git a/docs/screenshots/profile.png b/docs/screenshots/profile.png new file mode 100644 index 000000000..f173c44cc Binary files /dev/null and b/docs/screenshots/profile.png differ diff --git a/docs/screenshots/sign-in.png b/docs/screenshots/sign-in.png new file mode 100644 index 000000000..f706db0b3 Binary files /dev/null and b/docs/screenshots/sign-in.png differ diff --git a/docs/screenshots/users.png b/docs/screenshots/users.png new file mode 100644 index 000000000..91bd4da61 Binary files /dev/null and b/docs/screenshots/users.png differ 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 000000000..c4c9dbfbb Binary files /dev/null and b/public/icon.png differ 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/script/benchmarks/search.rb b/script/benchmarks/search.rb new file mode 100644 index 000000000..5c98fb3c4 --- /dev/null +++ b/script/benchmarks/search.rb @@ -0,0 +1,40 @@ +# Reproduces the numbers the README quotes for the search index. +# +# bin/rails runner script/benchmarks/search.rb +# +# Needs a roster large enough for the planner to have a choice. To generate one: +# +# digest = BCrypt::Password.create("benchmark-only", cost: 4) +# User.insert_all(50_000.times.map { |i| +# { full_name: "#{Faker::Name.name} #{i}", email_address: "bench#{i}@example.com", +# password_digest: digest, role: 0, locale: "en", +# created_at: Time.current, updated_at: Time.current } }) +# +# and to remove them afterwards: +# +# User.where("email_address LIKE ?", "bench%").delete_all +# +# Development only -- this script reads, it does not write. +connection = ActiveRecord::Base.connection +connection.execute("ANALYZE users") + +def timing(connection, sql) + connection.select_values("EXPLAIN (ANALYZE, TIMING OFF) #{sql}") +end + +puts "rows: #{User.count}" + +%w[silva ma].each do |term| + sql = User.search(term).to_sql + puts "\n=== term #{term.inspect} (#{term.length} characters) ===" + + connection.execute("SET enable_bitmapscan = on; SET enable_indexscan = on") + with_index = timing(connection, sql) + puts "with the index: #{with_index.first.split("(cost").first.strip}" + puts " #{with_index.grep(/Execution Time/).first}" + + connection.execute("SET enable_bitmapscan = off; SET enable_indexscan = off") + without = timing(connection, sql) + puts "sequential scan: #{without.first.split("(cost").first.strip}" + puts " #{without.grep(/Execution Time/).first}" +end diff --git a/script/benchmarks/zjit.rb b/script/benchmarks/zjit.rb new file mode 100644 index 000000000..6ee6a4615 --- /dev/null +++ b/script/benchmarks/zjit.rb @@ -0,0 +1,55 @@ +# Measures the CPU-bound part of the application -- parsing and normalising a +# spreadsheet -- so a claim about ZJIT can be checked rather than repeated. +# +# bin/rails runner script/benchmarks/zjit.rb # interpreter +# RUBYOPT="--zjit" bin/rails runner script/benchmarks/zjit.rb +# +# Nothing is written to the database: what is timed is the parser, not +# PostgreSQL. A request that spends most of its time waiting on the database +# has far less to gain, which is the point of measuring this way. +require "csv" + +ROWS = Integer(ENV.fetch("ROWS", 20_000)) +RUNS = Integer(ENV.fetch("RUNS", 5)) + +path = Rails.root.join("tmp/zjit-benchmark.csv") +CSV.open(path, "w") do |csv| + csv << %w[full_name email avatar_url role] + ROWS.times { |i| csv << [" Person #{i} ", "PERSON#{i}@Example.com ", nil, i.even? ? "user" : "admin"] } +end + +parser = UserImportParser.new(path: path.to_s, format: :csv) +stub = UserImportParser.const_get(:MAX_ROWS) +UserImportParser.send(:remove_const, :MAX_ROWS) +UserImportParser.const_set(:MAX_ROWS, ROWS + 1) + +def measure + started = Process.clock_gettime(Process::CLOCK_MONOTONIC) + yield + Process.clock_gettime(Process::CLOCK_MONOTONIC) - started +end + +times = Array.new(RUNS) do + measure do + rows = 0 + parser.each_row { |_number, attributes| rows += attributes[:full_name].length } + rows + end +end + +UserImportParser.send(:remove_const, :MAX_ROWS) +UserImportParser.const_set(:MAX_ROWS, stub) +path.delete + +jit = if defined?(RubyVM::ZJIT) && RubyVM::ZJIT.enabled? + "ZJIT" + elsif defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled? + "YJIT" + else + "interpreter" + end + +puts format("ruby %s, %s, %d rows, %d runs", + version: RUBY_VERSION, jit: jit, rows: ROWS, runs: RUNS) +puts format(" best %.3f s", best: times.min) +puts format(" median %.3f s", median: times.sort[times.size / 2]) diff --git a/spec/channels/admin_stream_channel_spec.rb b/spec/channels/admin_stream_channel_spec.rb new file mode 100644 index 000000000..87b794afc --- /dev/null +++ b/spec/channels/admin_stream_channel_spec.rb @@ -0,0 +1,31 @@ +require "rails_helper" + +RSpec.describe AdminStreamChannel do + def subscribe_to_counters(locale = :en) + subscribe(signed_stream_name: Turbo::StreamsChannel.signed_stream_name(UserCounters.stream_for(locale))) + end + + it "accepts an administrator" do + stub_connection current_user: create(:user, :admin) + + subscribe_to_counters + + expect(subscription).to be_confirmed + end + + it "rejects a signed-in user who is not an administrator" do + stub_connection current_user: create(:user) + + subscribe_to_counters + + expect(subscription).to be_rejected + end + + it "rejects a connection with nobody behind it" do + stub_connection current_user: nil + + subscribe_to_counters + + expect(subscription).to be_rejected + end +end diff --git a/spec/channels/application_cable/connection_spec.rb b/spec/channels/application_cable/connection_spec.rb new file mode 100644 index 000000000..6f48e56c5 --- /dev/null +++ b/spec/channels/application_cable/connection_spec.rb @@ -0,0 +1,23 @@ +require "rails_helper" + +RSpec.describe ApplicationCable::Connection do + it "identifies the user behind a valid session cookie" do + user = create(:user) + session = user.sessions.create!(user_agent: "rspec", ip_address: "127.0.0.1") + cookies.signed[:session_id] = session.id + + connect + + expect(connection.current_user).to eq(user) + end + + it "refuses a connection with no session at all" do + expect { connect }.to have_rejected_connection + end + + it "refuses a connection whose session no longer exists" do + cookies.signed[:session_id] = "00000000-0000-0000-0000-000000000000" + + expect { connect }.to have_rejected_connection + end +end diff --git a/spec/factories/audit_events.rb b/spec/factories/audit_events.rb new file mode 100644 index 000000000..b7db6d831 --- /dev/null +++ b/spec/factories/audit_events.rb @@ -0,0 +1,10 @@ +FactoryBot.define do + factory :audit_event do + actor factory: %i[user admin] + subject factory: :user + action { :created } + + actor_email { actor.email_address } + subject_email { subject.email_address } + end +end diff --git a/spec/factories/user_imports.rb b/spec/factories/user_imports.rb new file mode 100644 index 000000000..4f1b70025 --- /dev/null +++ b/spec/factories/user_imports.rb @@ -0,0 +1,15 @@ +FactoryBot.define do + factory :user_import do + administrator factory: %i[user admin] + + trait :with_csv do + after(:build) do |import| + import.file.attach( + io: Rails.root.join("spec/fixtures/files/users.csv").open, + filename: "users.csv", + content_type: "text/csv" + ) + end + end + end +end diff --git a/spec/factories/users.rb b/spec/factories/users.rb new file mode 100644 index 000000000..7de1d7f19 --- /dev/null +++ b/spec/factories/users.rb @@ -0,0 +1,12 @@ +FactoryBot.define do + factory :user do + full_name { Faker::Name.name } + sequence(:email_address) { |n| "user#{n}@example.com" } + password { "a-sufficiently-long-password" } + role { :user } + + trait :admin do + role { :admin } + end + end +end diff --git a/spec/fixtures/files/avatar.png b/spec/fixtures/files/avatar.png new file mode 100644 index 000000000..f37764b1f Binary files /dev/null and b/spec/fixtures/files/avatar.png differ diff --git a/spec/fixtures/files/not-really-an-image.png b/spec/fixtures/files/not-really-an-image.png new file mode 100644 index 000000000..8d445c86a --- /dev/null +++ b/spec/fixtures/files/not-really-an-image.png @@ -0,0 +1,2 @@ +#!/bin/sh +echo 'this is not a png' diff --git a/spec/fixtures/files/users-formula.csv b/spec/fixtures/files/users-formula.csv new file mode 100644 index 000000000..e5cac3165 --- /dev/null +++ b/spec/fixtures/files/users-formula.csv @@ -0,0 +1,2 @@ +full_name,email,avatar_url,role +=cmd|'/c calc'!A1,formula@example.com,,user diff --git a/spec/fixtures/files/users-with-problems.csv b/spec/fixtures/files/users-with-problems.csv new file mode 100644 index 000000000..dd6d8bdf6 --- /dev/null +++ b/spec/fixtures/files/users-with-problems.csv @@ -0,0 +1,7 @@ +full_name,email,avatar_url,role +Maria Silva,maria@example.com,,user +,no-name@example.com,,user +Bad Email,not-an-email,,user +Duplicate Maria,maria@example.com,,user +Wrong Role,role@example.com,,wizard +Valid Person,valid@example.com,,user diff --git a/spec/fixtures/files/users-wrong-header.csv b/spec/fixtures/files/users-wrong-header.csv new file mode 100644 index 000000000..2387e6d7c --- /dev/null +++ b/spec/fixtures/files/users-wrong-header.csv @@ -0,0 +1,2 @@ +nome,e_mail +Maria,maria@example.com diff --git a/spec/fixtures/files/users.csv b/spec/fixtures/files/users.csv new file mode 100644 index 000000000..1c4b384aa --- /dev/null +++ b/spec/fixtures/files/users.csv @@ -0,0 +1,4 @@ +full_name,email,avatar_url,role +Maria Silva,maria@example.com,https://example.com/maria.png,user +João Souza,joao@example.com,,user +Ada Admin,ada.admin@example.com,,admin diff --git a/spec/fixtures/files/users.xlsx b/spec/fixtures/files/users.xlsx new file mode 100644 index 000000000..9b448def5 Binary files /dev/null and b/spec/fixtures/files/users.xlsx differ diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb new file mode 100644 index 000000000..1af84d4c6 --- /dev/null +++ b/spec/helpers/application_helper_spec.rb @@ -0,0 +1,62 @@ +require "rails_helper" + +RSpec.describe ApplicationHelper do + describe "#user_initials" do + it "takes the first letter of the first and last words" do + expect(helper.user_initials(build(:user, full_name: "Maria da Silva Santos"))).to eq("MS") + end + + it "uses a single letter for a one-word name" do + expect(helper.user_initials(build(:user, full_name: "Prince"))).to eq("P") + end + + it "falls back to a placeholder when there is no name" do + expect(helper.user_initials(build(:user, full_name: ""))).to eq("?") + end + end + + describe "#avatar_tag" do + def upload + Rack::Test::UploadedFile.new(Rails.root.join("spec/fixtures/files/avatar.png"), "image/png") + end + + it "renders the uploaded image when there is one" do + user = create(:user) + user.avatar.attach(upload) + + expect(helper.avatar_tag(user, size: 40)).to include(" e + abort e.to_s.strip +end + +Rails.root.glob("spec/support/**/*.rb").each { |file| require file } + +RSpec.configure do |config| + config.fixture_paths = [Rails.root.join("spec/fixtures")] + config.use_transactional_fixtures = true + config.infer_spec_type_from_file_location! + config.filter_rails_from_backtrace! + + # travel / travel_to / freeze_time, for the specs about tokens that expire. + config.include ActiveSupport::Testing::TimeHelpers +end diff --git a/spec/requests/admin/audit_events_spec.rb b/spec/requests/admin/audit_events_spec.rb new file mode 100644 index 000000000..472a189cf --- /dev/null +++ b/spec/requests/admin/audit_events_spec.rb @@ -0,0 +1,98 @@ +require "rails_helper" + +RSpec.describe "Admin activity" do + let(:administrator) { create(:user, :admin, full_name: "Ada Lovelace") } + + describe "GET /admin/activity" do + before { sign_in administrator } + + it "lists what administrators did, newest first" do + create(:audit_event, actor: administrator, subject: create(:user, full_name: "Maria Silva"), + action: :promoted, created_at: 1.hour.ago) + create(:audit_event, actor: administrator, subject: create(:user, full_name: "Joao Souza"), + action: :deleted, created_at: 2.days.ago) + + get admin_audit_events_path + + expect(response).to have_http_status(:ok) + expect(response.body.index("Maria Silva")).to be < response.body.index("Joao Souza") + expect(response.body).to include(I18n.t("admin.audit_events.actions.promoted")) + end + + it "says so when nothing has happened" do + get admin_audit_events_path + + expect(response.body).to include(I18n.t("admin.audit_events.index.empty_title")) + end + end + + it "keeps a regular user out" do + sign_in create(:user) + + get admin_audit_events_path + + expect(response).to redirect_to(profile_path) + end + + # The trail is written from the actions themselves, so these read like the + # administration screens rather than like the model. + describe "what gets recorded" do + before { sign_in administrator } + + it "records the creation of an account, and who did it" do + expect do + post admin_users_path, params: { + user: { full_name: "Maria Silva", email_address: "maria@example.com", + password: "a-sufficiently-long-password" } + } + end.to change(AuditEvent, :count).by(1) + + expect(AuditEvent.last).to have_attributes( + action: "created", actor: administrator, subject_email: "maria@example.com" + ) + end + + it "tells a promotion from an ordinary change" do + user = create(:user) + + patch admin_user_path(user), params: { user: { role: "admin" } } + + expect(AuditEvent.last.action).to eq("promoted") + end + + it "tells a demotion from an ordinary change" do + create(:user, :admin) + colleague = create(:user, :admin) + + patch admin_user_path(colleague), params: { user: { role: "user" } } + + expect(AuditEvent.last.action).to eq("demoted") + end + + it "records which columns an ordinary change touched" do + user = create(:user, full_name: "Maria Silva") + + patch admin_user_path(user), params: { user: { full_name: "Maria Silva Santos" } } + + expect(AuditEvent.last).to have_attributes(action: "updated") + expect(AuditEvent.last.details.keys).to include("full_name") + end + + it "records a deletion, and survives the account it refers to" do + user = create(:user, email_address: "maria@example.com") + + delete admin_user_path(user) + + expect(AuditEvent.last).to have_attributes(action: "deleted", subject: nil, + subject_email: "maria@example.com") + end + + it "writes nothing when the change was refused" do + user = create(:user) + + expect do + patch admin_user_path(user), params: { user: { email_address: "not an address" } } + end.not_to change(AuditEvent, :count) + end + end +end diff --git a/spec/requests/admin/dashboard_spec.rb b/spec/requests/admin/dashboard_spec.rb new file mode 100644 index 000000000..5ead9ea66 --- /dev/null +++ b/spec/requests/admin/dashboard_spec.rb @@ -0,0 +1,48 @@ +require "rails_helper" + +RSpec.describe "Admin dashboard" do + describe "GET /admin/dashboard" do + it "is shown to an administrator" do + sign_in create(:user, :admin) + + get admin_dashboard_path + + expect(response).to have_http_status(:ok) + end + + it "sends a visitor to the sign in page" do + get admin_dashboard_path + + expect(response).to redirect_to(new_session_path) + end + + it "refuses a regular user" do + sign_in create(:user) + + get admin_dashboard_path + + expect(response).to redirect_to(profile_path) + expect(flash[:alert]).to be_present + end + end + + describe "the counters" do + it "shows the totals" do + create_list(:user, 2) + sign_in create(:user, :admin) + + get admin_dashboard_path + + expect(response.body).to include('id="user-counters"') + expect(response.body).to include(I18n.t("admin.dashboard.total")) + end + + it "subscribes to the stream for the reader's own locale" do + sign_in create(:user, :admin, locale: "es") + + get admin_dashboard_path + + expect(response.body).to include("turbo-cable-stream-source") + end + end +end diff --git a/spec/requests/admin/user_imports_spec.rb b/spec/requests/admin/user_imports_spec.rb new file mode 100644 index 000000000..68ea1bd8f --- /dev/null +++ b/spec/requests/admin/user_imports_spec.rb @@ -0,0 +1,117 @@ +require "rails_helper" + +RSpec.describe "Admin user imports" do + let(:administrator) { create(:user, :admin) } + + def upload(name, content_type: "text/csv") + Rack::Test::UploadedFile.new(Rails.root.join("spec/fixtures/files/#{name}"), content_type) + end + + before { sign_in administrator } + + describe "GET /admin/user_imports" do + it "lists previous imports, newest first" do + older = create(:user_import, :with_csv, administrator: administrator, created_at: 2.days.ago) + newer = create(:user_import, :with_csv, administrator: administrator) + + get admin_user_imports_path + + expect(response).to have_http_status(:ok) + expect(response.body.index(admin_user_import_path(newer))) + .to be < response.body.index(admin_user_import_path(older)) + end + end + + describe "POST /admin/user_imports" do + it "accepts a CSV and schedules the work" do + expect { post admin_user_imports_path, params: { user_import: { file: upload("users.csv") } } } + .to change(UserImport, :count).by(1) + .and have_enqueued_job(ProcessUserImportJob) + + expect(response).to redirect_to(admin_user_import_path(UserImport.last)) + end + + it "records who asked for it" do + post admin_user_imports_path, params: { user_import: { file: upload("users.csv") } } + + expect(UserImport.last.administrator).to eq(administrator) + end + + it "refuses a file it cannot parse, without scheduling anything" do + expect do + post admin_user_imports_path, + params: { user_import: { file: upload("avatar.png", content_type: "image/png") } } + end.not_to have_enqueued_job(ProcessUserImportJob) + + expect(response).to have_http_status(:unprocessable_content) + end + end + + describe "GET /admin/user_imports/:id" do + it "shows the progress and the rejected rows" do + import = create(:user_import, :with_csv, administrator: administrator, + status: :completed_with_errors, total_rows: 2, + processed_rows: 2, created_users: 1, rejected_rows: 1) + import.row_errors.create!(row_number: 3, email_address: "bad@example.com", messages: ["Email is invalid"]) + + get admin_user_import_path(import) + + expect(response).to have_http_status(:ok) + expect(response.body).to include("bad@example.com") + expect(response.body).to include("Email is invalid") + end + end + + describe "GET /admin/user_imports/template.csv" do + it "offers a template carrying the expected header" do + get template_admin_user_imports_path(format: :csv) + + expect(response).to have_http_status(:ok) + expect(response.body).to include("full_name,email,avatar_url,role") + expect(response.headers["Content-Disposition"]).to include("attachment") + end + + it "is a file the importer itself can read back" do + get template_admin_user_imports_path(format: :csv) + path = Rails.root.join("tmp/template-roundtrip.csv") + path.write(response.body) + + parser = UserImportParser.new(path: path.to_s, format: :csv) + + expect(parser.row_count).to be_positive + ensure + path&.delete if path&.exist? + end + end + + describe "GET /admin/user_imports/:id/rejected_rows.csv" do + it "neutralises a cell that a spreadsheet would treat as a formula" do + import = create(:user_import, :with_csv, administrator: administrator) + import.row_errors.create!(row_number: 2, email_address: "=cmd|'/c calc'!A1", + messages: ["@SUM(1+1)"]) + + get rejected_rows_admin_user_import_path(import, format: :csv) + + expect(response.body).to include("'=cmd") + expect(response.body).to include("'@SUM") + expect(response.body).not_to match(/^=cmd/) + end + end + + describe "authorization" do + it "keeps a regular user out of every action" do + sign_in create(:user) + import = create(:user_import, :with_csv, administrator: administrator) + + get admin_user_imports_path + expect(response).to redirect_to(profile_path) + + get admin_user_import_path(import) + expect(response).to redirect_to(profile_path) + + expect do + post admin_user_imports_path, params: { user_import: { file: upload("users.csv") } } + end.not_to change(UserImport, :count) + end + end +end diff --git a/spec/requests/admin/users_spec.rb b/spec/requests/admin/users_spec.rb new file mode 100644 index 000000000..f333c71c1 --- /dev/null +++ b/spec/requests/admin/users_spec.rb @@ -0,0 +1,220 @@ +require "rails_helper" + +RSpec.describe "Admin users" do + let(:administrator) { create(:user, :admin, full_name: "Ada Admin") } + + before { sign_in administrator } + + describe "GET /admin/users" do + it "lists the people in the system" do + create(:user, full_name: "Maria Silva") + + get admin_users_path + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Maria Silva") + end + + it "finds people by name" do + create(:user, full_name: "Maria Silva") + create(:user, full_name: "Joao Souza") + + get admin_users_path, params: { query: "maria" } + + expect(response.body).to include("Maria Silva") + expect(response.body).not_to include("Joao Souza") + end + + it "finds people by email address" do + create(:user, full_name: "Maria Silva", email_address: "maria@example.com") + create(:user, full_name: "Joao Souza", email_address: "joao@example.com") + + get admin_users_path, params: { query: "joao@" } + + expect(response.body).to include("Joao Souza") + expect(response.body).not_to include("Maria Silva") + end + + it "treats a search term as data, not as SQL" do + create(:user, full_name: "Maria Silva") + + get admin_users_path, params: { query: "'; DROP TABLE users; --" } + + expect(response).to have_http_status(:ok) + expect(User.count).to be_positive + end + + it "filters by role" do + create(:user, full_name: "Maria Silva") + + get admin_users_path, params: { role: "admin" } + + expect(response.body).to include("Ada Admin") + expect(response.body).not_to include("Maria Silva") + end + + it "ignores a role filter it does not recognise" do + create(:user, full_name: "Maria Silva") + + get admin_users_path, params: { role: "wizard" } + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Maria Silva") + end + + # The page renders an avatar per row, which is where an N+1 hides: without + # eager loading the attachment, its blob and the variant record are fetched + # once per person. + it "costs the same number of queries however many people are listed" do + create_list(:user, 2).each { |user| attach_avatar(user) } + queries_for_two = count_queries { get admin_users_path }.size + + create_list(:user, 6).each { |user| attach_avatar(user) } + queries_for_eight = count_queries { get admin_users_path }.size + + expect(queries_for_eight).to eq(queries_for_two) + end + + it "survives a page number that is empty or nonsense" do + ["", "abc", "-3", "0"].each do |page| + get admin_users_path, params: { page: page } + + expect(response).to have_http_status(:ok) + end + end + + it "paginates" do + create_list(:user, 3) + + get admin_users_path, params: { per_page: 2 } + + expect(response).to have_http_status(:ok) + end + end + + describe "POST /admin/users" do + let(:params) do + { + user: { + full_name: "Nova Pessoa", + email_address: "nova@example.com", + password: "a-sufficiently-long-password", + role: "admin" + } + } + end + + it "creates a user" do + expect { post admin_users_path, params: params }.to change(User, :count).by(1) + + expect(response).to redirect_to(admin_users_path) + end + + it "may assign the administrator role, unlike the public form" do + post admin_users_path, params: params + + expect(User.find_by(email_address: "nova@example.com")).to be_admin + end + + it "rejects an invalid submission" do + invalid = params.deep_merge(user: { email_address: "" }) + + expect { post admin_users_path, params: invalid }.not_to change(User, :count) + + expect(response).to have_http_status(:unprocessable_content) + end + end + + describe "PATCH /admin/users/:id" do + it "updates another user" do + user = create(:user) + + patch admin_user_path(user), params: { user: { full_name: "Nome Novo" } } + + expect(user.reload.full_name).to eq("Nome Novo") + end + + it "promotes a regular user" do + user = create(:user) + + patch admin_user_path(user), params: { user: { role: "admin" } } + + expect(user.reload).to be_admin + end + end + + describe "DELETE /admin/users/:id" do + it "deletes another user" do + user = create(:user) + + expect { delete admin_user_path(user) }.to change(User, :count).by(-1) + + expect(response).to redirect_to(admin_users_path) + end + + # The import rows point at the administrator who asked for them, and a + # foreign key raises rather than returning false: without the association + # saying what to do, this was a 500. + it "deletes an administrator who has run an import, and keeps the import" do + colleague = create(:user, :admin, full_name: "Grace Hopper") + import = create(:user_import, :with_csv, administrator: colleague) + + delete admin_user_path(colleague) + + expect(response).to redirect_to(admin_users_path) + expect(User.exists?(colleague.id)).to be(false) + expect(import.reload.administrator).to be_nil + expect(import.requested_by).to eq(colleague.email_address) + end + end + + describe "protecting the last administrator" do + it "refuses to delete the only administrator" do + create(:user) + + expect { delete admin_user_path(administrator) }.not_to change(User, :count) + + expect(flash[:alert]).to be_present + end + + it "refuses to demote the only administrator" do + patch admin_user_path(administrator), params: { user: { role: "user" } } + + expect(administrator.reload).to be_admin + end + + it "allows deleting an administrator while another one remains" do + other = create(:user, :admin) + + expect { delete admin_user_path(other) }.to change(User, :count).by(-1) + end + + it "allows demoting an administrator while another one remains" do + other = create(:user, :admin) + + patch admin_user_path(other), params: { user: { role: "user" } } + + expect(other.reload).to be_user + end + end + + describe "authorization" do + it "refuses every action to a regular user" do + sign_in create(:user) + target = create(:user) + + get admin_users_path + expect(response).to redirect_to(profile_path) + + patch admin_user_path(target), params: { user: { role: "admin" } } + expect(target.reload).to be_user + + expect { delete admin_user_path(target) }.not_to change(User, :count) + end + end + + def attach_avatar(user) + user.avatar.attach(io: Rails.root.join("spec/fixtures/files/avatar.png").open, + filename: "avatar.png", content_type: "image/png") + end +end diff --git a/spec/requests/api/v1/tokens_spec.rb b/spec/requests/api/v1/tokens_spec.rb new file mode 100644 index 000000000..dbc6c91d6 --- /dev/null +++ b/spec/requests/api/v1/tokens_spec.rb @@ -0,0 +1,83 @@ +require "swagger_helper" + +RSpec.describe "Api::V1::Tokens" do + let(:password) { "a-sufficiently-long-password" } + + path "/api/v1/tokens" do + post "Exchanges credentials for a bearer token" do + tags "Authentication" + consumes "application/json" + produces "application/json" + security [] + + parameter name: :credentials, in: :body, required: true, schema: { + type: :object, + properties: { + email_address: { type: :string, format: :email, example: "ada@example.com" }, + password: { type: :string, format: :password } + }, + required: %w[email_address password] + } + + response "201", "a token, valid for 24 hours" do + schema type: :object, + properties: { + token: { type: :string }, + expires_at: { type: :string, format: :"date-time" }, + user: { "$ref" => "#/components/schemas/user" } + }, + required: %w[token expires_at user] + + let!(:user) { create(:user, email_address: "ada@example.com", password: password) } + let(:credentials) { { email_address: "ada@example.com", password: password } } + + run_test! do |response| + token = JSON.parse(response.body).fetch("token") + expect(User.find_by_token_for(:api, token)).to eq(user) + end + end + + response "401", "the address and the password do not match" do + schema "$ref" => "#/components/schemas/error" + + let!(:user) { create(:user, email_address: "ada@example.com", password: password) } + let(:credentials) { { email_address: "ada@example.com", password: "not the password" } } + + run_test! + end + + response "401", "no such account" do + let(:credentials) { { email_address: "nobody@example.com", password: password } } + + run_test! + end + end + end + + path "/api/v1/me" do + get "Returns the account the token belongs to" do + tags "Authentication" + produces "application/json" + security [bearer_auth: []] + + response "200", "the signed in account" do + schema "$ref" => "#/components/schemas/user" + + let(:user) { create(:user, full_name: "Ada Lovelace") } + let(:Authorization) { "Bearer #{user.generate_token_for(:api)}" } + + run_test! do |response| + expect(JSON.parse(response.body)["full_name"]).to eq("Ada Lovelace") + end + end + + response "401", "no token, or a token that has expired" do + schema "$ref" => "#/components/schemas/error" + + let(:Authorization) { "Bearer not-a-real-token" } + + run_test! + end + end + end +end diff --git a/spec/requests/api/v1/users_spec.rb b/spec/requests/api/v1/users_spec.rb new file mode 100644 index 000000000..8c1e4a26c --- /dev/null +++ b/spec/requests/api/v1/users_spec.rb @@ -0,0 +1,215 @@ +require "swagger_helper" + +RSpec.describe "Api::V1::Users" do + let(:administrator) { create(:user, :admin, full_name: "Ada Lovelace") } + let(:Authorization) { "Bearer #{administrator.generate_token_for(:api)}" } + + path "/api/v1/users" do + get "Lists the people in the system" do + tags "Users" + produces "application/json" + security [bearer_auth: []] + + parameter name: :query, in: :query, required: false, schema: { type: :string }, + description: "Matches a name or an email address" + parameter name: :role, in: :query, required: false, + schema: { type: :string, enum: %w[user admin] } + parameter name: :page, in: :query, required: false, schema: { type: :integer } + parameter name: :per_page, in: :query, required: false, + schema: { type: :integer, maximum: 100 }, + description: "Bounded, so a hand-edited URL cannot ask for the whole table" + + response "200", "a page of people" do + schema type: :object, + properties: { + users: { type: :array, items: { "$ref" => "#/components/schemas/user" } }, + pagination: { "$ref" => "#/components/schemas/pagination" } + }, + required: %w[users pagination] + + let(:query) { "maria" } + let(:role) { nil } + let(:page) { nil } + let(:per_page) { nil } + + before do + create(:user, full_name: "Maria Silva") + create(:user, full_name: "Joao Souza") + end + + run_test! do |response| + body = JSON.parse(response.body) + expect(body["users"].pluck("full_name")).to eq(["Maria Silva"]) + expect(body["pagination"]["count"]).to eq(1) + end + end + + response "403", "the token belongs to somebody who is not an administrator" do + schema "$ref" => "#/components/schemas/error" + + let(:Authorization) { "Bearer #{create(:user).generate_token_for(:api)}" } + + run_test! + end + + response "401", "no token" do + schema "$ref" => "#/components/schemas/error" + + let(:Authorization) { nil } + + run_test! + end + end + + post "Creates an account" do + tags "Users" + consumes "application/json" + produces "application/json" + security [bearer_auth: []] + + parameter name: :body, in: :body, required: true, schema: { + type: :object, + properties: { + user: { + type: :object, + properties: { + full_name: { type: :string, example: "Maria Silva" }, + email_address: { type: :string, format: :email, example: "maria@example.com" }, + password: { type: :string, format: :password, minLength: 8 }, + role: { type: :string, enum: %w[user admin] }, + locale: { type: :string, enum: User::SUPPORTED_LOCALES }, + avatar_url: { type: :string, nullable: true } + }, + required: %w[full_name email_address password] + } + }, + required: %w[user] + } + + response "201", "the account that was created" do + schema "$ref" => "#/components/schemas/user" + + let(:body) do + { user: { full_name: "Maria Silva", email_address: "maria@example.com", + password: "a-sufficiently-long-password" } } + end + + run_test! do + expect(User.find_by(email_address: "maria@example.com")).to be_user + expect(AuditEvent.last).to have_attributes(action: "created", actor: administrator) + end + end + + response "422", "the account was refused" do + schema "$ref" => "#/components/schemas/error" + + let(:body) { { user: { full_name: "", email_address: "not an address", password: "short" } } } + + run_test! do |response| + expect(JSON.parse(response.body)["details"]).to include("email_address") + end + end + end + end + + path "/api/v1/users/{id}" do + parameter name: :id, in: :path, required: true, schema: { type: :integer } + + get "Returns one person" do + tags "Users" + produces "application/json" + security [bearer_auth: []] + + response "200", "the person" do + schema "$ref" => "#/components/schemas/user" + + let(:id) { create(:user, full_name: "Maria Silva").id } + + run_test! do |response| + expect(JSON.parse(response.body)["full_name"]).to eq("Maria Silva") + end + end + + response "404", "nobody with that id" do + schema "$ref" => "#/components/schemas/error" + + let(:id) { 0 } + + run_test! + end + end + + patch "Updates a person" do + tags "Users" + consumes "application/json" + produces "application/json" + security [bearer_auth: []] + + parameter name: :body, in: :body, required: true, schema: { + type: :object, + properties: { + user: { + type: :object, + properties: { + full_name: { type: :string }, + email_address: { type: :string, format: :email }, + password: { type: :string, format: :password, minLength: 8 }, + role: { type: :string, enum: %w[user admin] }, + locale: { type: :string, enum: User::SUPPORTED_LOCALES }, + avatar_url: { type: :string, nullable: true } + } + } + }, + required: %w[user] + } + + response "200", "the person as they now are" do + schema "$ref" => "#/components/schemas/user" + + let(:id) { create(:user).id } + let(:body) { { user: { role: "admin" } } } + + run_test! do + expect(User.find(id)).to be_admin + expect(AuditEvent.last.action).to eq("promoted") + end + end + + response "422", "the change was refused -- here, the last administrator" do + schema "$ref" => "#/components/schemas/error" + + let(:id) { administrator.id } + let(:body) { { user: { role: "user" } } } + + run_test! do + expect(administrator.reload).to be_admin + end + end + end + + delete "Removes a person" do + tags "Users" + produces "application/json" + security [bearer_auth: []] + + response "204", "removed" do + let(:id) { create(:user).id } + + run_test! do + expect(User.exists?(id)).to be(false) + expect(AuditEvent.last.action).to eq("deleted") + end + end + + response "422", "the only administrator left may not be removed" do + schema "$ref" => "#/components/schemas/error" + + let(:id) { administrator.id } + + run_test! do + expect(administrator.reload).to be_persisted + end + end + end + end +end diff --git a/spec/requests/home_spec.rb b/spec/requests/home_spec.rb new file mode 100644 index 000000000..2ba21d3d0 --- /dev/null +++ b/spec/requests/home_spec.rb @@ -0,0 +1,25 @@ +require "rails_helper" + +RSpec.describe "The root path" do + it "sends a visitor to the sign in page" do + get root_path + + expect(response).to redirect_to(new_session_path) + end + + it "sends a regular user to their profile" do + sign_in create(:user) + + get root_path + + expect(response).to redirect_to(profile_path) + end + + it "sends an administrator to the dashboard" do + sign_in create(:user, :admin) + + get root_path + + expect(response).to redirect_to(admin_dashboard_path) + end +end diff --git a/spec/requests/locales_spec.rb b/spec/requests/locales_spec.rb new file mode 100644 index 000000000..b283551ff --- /dev/null +++ b/spec/requests/locales_spec.rb @@ -0,0 +1,57 @@ +require "rails_helper" + +RSpec.describe "Locale switching" do + describe "PATCH /locale" do + it "remembers the choice on the account when signed in" do + user = create(:user) + sign_in user + + patch locale_path, params: { locale: "pt-BR" } + + expect(response).to have_http_status(:see_other) + expect(user.reload.locale).to eq("pt-BR") + end + + it "remembers the choice in the session for a visitor" do + patch locale_path, params: { locale: "es" } + + get new_session_path + + expect(response.body).to include(I18n.t("sessions.new.title", locale: :es)) + end + + it "refuses a locale the application does not support" do + user = create(:user, locale: "en") + sign_in user + + patch locale_path, params: { locale: "de" } + + expect(response).to have_http_status(:unprocessable_content) + expect(user.reload.locale).to eq("en") + end + + it "returns the visitor to the page they came from" do + patch locale_path, params: { locale: "es" }, headers: { "HTTP_REFERER" => new_registration_url } + + expect(response).to redirect_to(new_registration_url) + end + end + + describe "the locale applied to a request" do + it "renders in the locale stored on the account" do + sign_in create(:user, locale: "pt-BR") + + get profile_path + + expect(response.body).to include(I18n.t("profiles.show.title", locale: :"pt-BR")) + end + + it "falls back to English for an account that never chose one" do + sign_in create(:user) + + get profile_path + + expect(response.body).to include(I18n.t("profiles.show.title", locale: :en)) + end + end +end diff --git a/spec/requests/passwords_spec.rb b/spec/requests/passwords_spec.rb new file mode 100644 index 000000000..f1339399b --- /dev/null +++ b/spec/requests/passwords_spec.rb @@ -0,0 +1,115 @@ +require "rails_helper" + +RSpec.describe "Password resets" do + let(:user) { create(:user, email_address: "maria@example.com") } + + describe "POST /passwords" do + it "emails a reset link to an account that exists", :perform_enqueued do + user + + expect { post passwords_path, params: { email_address: user.email_address } } + .to change { ActionMailer::Base.deliveries.size }.by(1) + + expect(ActionMailer::Base.deliveries.last.to).to eq([user.email_address]) + end + + it "sends nothing for an address with no account", :perform_enqueued do + expect { post passwords_path, params: { email_address: "nobody@example.com" } } + .not_to(change { ActionMailer::Base.deliveries.size }) + end + + # Answering differently would turn this form into a way of asking whether + # somebody has an account here. + it "answers identically either way" do + user + post passwords_path, params: { email_address: user.email_address } + known = [response.status, response.location, flash[:notice]] + + post passwords_path, params: { email_address: "nobody@example.com" } + unknown = [response.status, response.location, flash[:notice]] + + expect(unknown).to eq(known) + end + end + + describe "PATCH /passwords/:token" do + let(:token) { user.password_reset_token } + + it "changes the password" do + patch password_path(token), params: { + password: "a-brand-new-password", password_confirmation: "a-brand-new-password" + } + + expect(user.reload.authenticate("a-brand-new-password")).to eq(user) + end + + it "signs every existing session out" do + user.sessions.create!(user_agent: "rspec", ip_address: "127.0.0.1") + + expect do + patch password_path(token), params: { + password: "a-brand-new-password", password_confirmation: "a-brand-new-password" + } + end.to change { user.sessions.count }.to(0) + end + + it "refuses a mismatched confirmation" do + patch password_path(token), params: { + password: "a-brand-new-password", password_confirmation: "something else" + } + + expect(user.reload.authenticate("a-brand-new-password")).to be(false) + end + + it "refuses a token that was never valid" do + patch password_path("not-a-real-token"), params: { + password: "a-brand-new-password", password_confirmation: "a-brand-new-password" + } + + expect(response).to redirect_to(new_password_path) + expect(user.reload.authenticate("a-brand-new-password")).to be(false) + end + end + + # An invited person arrives at the same screen through a different token. + describe "the invitation link" do + let(:invitation) { user.generate_token_for(:invitation) } + + it "opens the password screen, worded as a welcome" do + get edit_password_path(invitation) + + expect(response).to have_http_status(:ok) + expect(response.body).to include(I18n.t("passwords.edit.invited_title")) + end + + it "sets the first password" do + patch password_path(invitation), params: { + password: "a-brand-new-password", password_confirmation: "a-brand-new-password" + } + + expect(response).to redirect_to(new_session_path) + expect(user.reload.authenticate("a-brand-new-password")).to eq(user) + end + + it "stops working once a password has been set, since the salt has changed" do + used = invitation + patch password_path(used), params: { + password: "a-brand-new-password", password_confirmation: "a-brand-new-password" + } + + get edit_password_path(used) + + expect(response).to redirect_to(new_password_path) + end + + it "expires" do + token = invitation + + travel(User::INVITATION_VALID_FOR + 1.day) do + get edit_password_path(token) + + expect(response).to redirect_to(new_password_path) + end + end + end +end diff --git a/spec/requests/profiles_spec.rb b/spec/requests/profiles_spec.rb new file mode 100644 index 000000000..7b0cd176a --- /dev/null +++ b/spec/requests/profiles_spec.rb @@ -0,0 +1,114 @@ +require "rails_helper" + +RSpec.describe "Profiles" do + let(:user) { create(:user, full_name: "Maria Silva") } + + describe "GET /profile" do + it "shows the signed in user their own profile" do + sign_in user + + get profile_path + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Maria Silva") + end + + it "sends a visitor to the sign in page" do + get profile_path + + expect(response).to redirect_to(new_session_path) + end + end + + describe "PATCH /profile" do + it "updates the signed in user" do + sign_in user + + patch profile_path, params: { user: { full_name: "Maria Silva Santos" } } + + expect(response).to redirect_to(profile_path) + expect(user.reload.full_name).to eq("Maria Silva Santos") + end + + it "never lets a user promote themselves" do + sign_in user + + patch profile_path, params: { user: { full_name: "Maria", role: "admin" } } + + expect(user.reload).to be_user + end + + it "rejects an invalid change" do + sign_in user + + patch profile_path, params: { user: { email_address: "" } } + + expect(response).to have_http_status(:unprocessable_content) + expect(user.reload.email_address).to be_present + end + end + + describe "DELETE /profile" do + it "deletes the signed in user's own account" do + sign_in user + + expect { delete profile_path }.to change(User, :count).by(-1) + + expect(response).to redirect_to(new_session_path) + end + end + + describe "the avatar" do + def upload(name, content_type: "image/png") + Rack::Test::UploadedFile.new(Rails.root.join("spec/fixtures/files/#{name}"), content_type) + end + + it "accepts an uploaded image" do + sign_in user + + patch profile_path, params: { user: { avatar: upload("avatar.png") } } + + expect(user.reload.avatar).to be_attached + end + + it "refuses a file that is not really an image" do + sign_in user + + patch profile_path, params: { user: { avatar: upload("not-really-an-image.png") } } + + expect(response).to have_http_status(:unprocessable_content) + expect(user.reload.avatar).not_to be_attached + end + + it "removes the uploaded image when asked" do + user.avatar.attach(upload("avatar.png")) + sign_in user + + patch profile_path, params: { user: { remove_avatar: "1" } } + + expect(user.reload.avatar).not_to be_attached + end + + it "keeps the uploaded image when not asked to remove it" do + user.avatar.attach(upload("avatar.png")) + sign_in user + + patch profile_path, params: { user: { full_name: "Maria Silva Santos" } } + + expect(user.reload.avatar).to be_attached + end + + it "refuses a remote URL that is not an ordinary web link" do + sign_in user + + patch profile_path, params: { user: { avatar_url: "javascript:alert(1)" } } + + expect(response).to have_http_status(:unprocessable_content) + expect(user.reload.avatar_url).to be_nil + # The form legitimately echoes the rejected value back into the input, where + # it is inert. What must never happen is it becoming an image source. + expect(response.body).not_to include('src="javascript:') + expect(response.body).not_to include('src="data:') + end + end +end diff --git a/spec/requests/registrations_spec.rb b/spec/requests/registrations_spec.rb new file mode 100644 index 000000000..399f8aa1c --- /dev/null +++ b/spec/requests/registrations_spec.rb @@ -0,0 +1,61 @@ +require "rails_helper" + +RSpec.describe "Registrations" do + let(:valid_params) do + { + user: { + full_name: "Maria Silva", + email_address: "maria@example.com", + password: "a-sufficiently-long-password", + password_confirmation: "a-sufficiently-long-password" + } + } + end + + describe "GET /sign_up" do + it "is reachable without being signed in" do + get new_registration_path + + expect(response).to have_http_status(:ok) + end + end + + describe "POST /registration" do + it "creates the account and signs the visitor in" do + expect { post registration_path, params: valid_params }.to change(User, :count).by(1) + + expect(response).to redirect_to(profile_path) + end + + it "always creates a regular user" do + post registration_path, params: valid_params + + expect(User.last).to be_user + end + + it "ignores a role smuggled through the public form" do + params = valid_params.deep_merge(user: { role: "admin" }) + + post registration_path, params: params + + expect(User.last).to be_user + expect(User.where(role: :admin)).to be_empty + end + + it "rejects an invalid submission without creating anything" do + params = valid_params.deep_merge(user: { email_address: "not-an-email" }) + + expect { post registration_path, params: params }.not_to change(User, :count) + + expect(response).to have_http_status(:unprocessable_content) + end + + it "rejects a mismatched password confirmation" do + params = valid_params.deep_merge(user: { password_confirmation: "something else" }) + + expect { post registration_path, params: params }.not_to change(User, :count) + + expect(response).to have_http_status(:unprocessable_content) + end + end +end diff --git a/spec/requests/security_spec.rb b/spec/requests/security_spec.rb new file mode 100644 index 000000000..d87a495ac --- /dev/null +++ b/spec/requests/security_spec.rb @@ -0,0 +1,282 @@ +require "rails_helper" +require "English" +require "shellwords" + +# A sweep by attack vector rather than by screen. The vectors each feature +# already owns -- SQL injection in the search, mass assignment of `role`, IDOR +# on the profile, a lying MIME type on an upload, an unsafe avatar URL, CSV +# injection in the rejected-rows report, the last-administrator race -- are +# covered next to those features. What is left here is what belongs to the +# application as a whole. +RSpec.describe "Security" do + let(:administrator) { create(:user, :admin, full_name: "Ada Admin") } + + # Forgery protection is off in the test environment so that ordinary request + # specs can post without a token. These examples need the real thing, so they + # turn it back on around themselves. + def with_forgery_protection + original = ActionController::Base.allow_forgery_protection + ActionController::Base.allow_forgery_protection = true + yield + ensure + ActionController::Base.allow_forgery_protection = original + end + + describe "cross-site request forgery" do + it "refuses a destructive admin request that carries no token" do + victim = create(:user) + sign_in administrator + + with_forgery_protection do + delete admin_user_path(victim) + end + + expect(response).to have_http_status(:unprocessable_content) + expect(User.exists?(victim.id)).to be(true) + end + + it "refuses to delete an account from a forged form" do + user = create(:user) + sign_in user + + with_forgery_protection do + delete profile_path + end + + expect(response).to have_http_status(:unprocessable_content) + expect(User.exists?(user.id)).to be(true) + end + + it "does not destroy anything over GET" do + victim = create(:user) + sign_in administrator + + get "/admin/users/#{victim.id}" + + expect(response).to have_http_status(:not_found) + expect(User.exists?(victim.id)).to be(true) + end + end + + describe "cross-site scripting" do + let(:payload) { "" } + + it "escapes a name typed into the admin form wherever it is echoed back" do + sign_in administrator + + post admin_users_path, params: { + user: { full_name: payload, email_address: "payload@example.com", password: "a-sufficiently-long-password" } + } + follow_redirect! + + expect(response.body).not_to include(payload) + expect(response.body).to include("<script>") + end + + it "escapes a name that arrived through an import" do + import_row("#{payload},imported@example.com") + + sign_in administrator + get admin_users_path, params: { query: "imported@example.com" } + + expect(User.find_by(email_address: "imported@example.com").full_name).to eq(payload) + expect(response.body).not_to include(payload) + expect(response.body).to include("<script>") + end + + it "escapes a name echoed inside a flash message" do + victim = create(:user, full_name: payload) + sign_in administrator + + delete admin_user_path(victim) + follow_redirect! + + expect(response.body).not_to include(payload) + expect(response.body).to include("<script>") + end + end + + describe "the session cookie" do + it "is signed, unreadable by scripts and not sent across sites" do + sign_in administrator + + cookie = cookie_header_for("session_id") + + expect(cookie.downcase).to include("httponly") + expect(cookie.downcase).to include("samesite=lax") + # The value is the signed payload, never the bare primary key. + expect(cookie).not_to include("session_id=#{Session.last.id};") + end + + it "is dropped on sign out, so a stolen cookie stops working" do + sign_in administrator + session_record = Session.last + + delete session_path + + expect(Session.exists?(session_record.id)).to be(false) + + get profile_path + expect(response).to redirect_to(new_session_path) + end + end + + describe "response headers" do + before { sign_in administrator } + + it "sends a content security policy that keeps injected markup inert" do + get admin_dashboard_path + + policy = response.headers["content-security-policy"] + + expect(policy).to include("default-src 'self'", "object-src 'none'") + expect(policy).to include("base-uri 'self'", "form-action 'self'") + # Nothing here is meant to be framed, which is also what protects the + # destructive admin forms from being clicked through an overlay. + expect(policy).to include("frame-ancestors 'none'") + end + + it "allows an inline script only with the nonce of that response" do + get admin_dashboard_path + first = response.headers["content-security-policy"][/'nonce-([^']+)'/, 1] + + get admin_dashboard_path + second = response.headers["content-security-policy"][/'nonce-([^']+)'/, 1] + + expect(first).to be_present + expect(second).not_to eq(first) + expect(response.body).to include(%(nonce="#{second}")) + end + + it "does not let a browser sniff a response into another content type" do + get admin_dashboard_path + + expect(response.headers["x-content-type-options"]).to eq("nosniff") + expect(response.headers["referrer-policy"]).to eq("strict-origin-when-cross-origin") + end + end + + describe "what reaches the log" do + it "keeps credentials and reset tokens out of it" do + user = create(:user, email_address: "logged@example.com") + + written = capturing_the_log do + post session_path, params: { email_address: user.email_address, password: "a-sufficiently-long-password" } + end + + expect(written).not_to include("a-sufficiently-long-password") + expect(written).not_to include("logged@example.com") + expect(written).to include("[FILTERED]") + end + end + + # The environment under test is `test`, and Rails only boots one environment + # per process, so the production settings are read by booting a short-lived + # production process rather than by asserting on a file's text. + describe "the production environment" do + it "forces SSL, which is what makes every cookie secure" do + settings = production_settings + + expect(settings["assume_ssl"]).to be(true) + expect(settings["force_ssl"]).to be(true) + # The container healthcheck speaks plain http from inside the network and + # would fail against a redirect. + expect(settings["health_check_redirected"]).to be(false) + expect(settings["other_paths_redirected"]).to be(true) + end + end + + describe "an oversized spreadsheet" do + it "is refused before the file is attached when it is too heavy" do + sign_in administrator + stub_const("UserImport::MAX_FILE_BYTES", 64) + + post admin_user_imports_path, params: { + user_import: { file: fixture_file_upload("users.csv", "text/csv") } + } + + expect(response).to have_http_status(:unprocessable_content) + expect(UserImport.count).to be_zero + expect(enqueued_jobs).to be_empty + end + + it "fails the import, rather than the worker, when it holds too many rows" do + import = create(:user_import, :with_csv, administrator: administrator) + stub_const("UserImportParser::MAX_ROWS", 1) + + ProcessUserImportJob.perform_now(import.id) + + expect(import.reload).to be_failed + expect(import.failure_reason).to be_present + # The ceiling is reached while the rows are being counted, before the + # first one is imported, so an oversized file creates nobody at all + # rather than half a directory. + expect(User.where(role: :user)).to be_empty + end + end + + private + + def production_settings + JSON.parse(booted_in_production(<<~RUBY).lines.last.to_s) + exclude = Rails.application.config.ssl_options.dig(:redirect, :exclude) + request = ->(path) { ActionDispatch::Request.new("PATH_INFO" => path) } + puts({ + assume_ssl: Rails.application.config.assume_ssl, + force_ssl: Rails.application.config.force_ssl, + health_check_redirected: !exclude.call(request.call("/up")), + other_paths_redirected: !exclude.call(request.call("/admin/users")) + }.to_json) + RUBY + end + + def booted_in_production(script) + # The original env, not an unbundled one: the child runs the same Gemfile + # from the same bundle path, only in another RAILS_ENV. + output = Bundler.with_original_env do + command = "RAILS_ENV=production SECRET_KEY_BASE_DUMMY=1 #{Rails.root.join("bin/rails")} runner" + `#{command} #{Shellwords.escape(script)} 2>&1` + end + raise "could not boot the production environment: #{output}" unless $CHILD_STATUS.success? + + output + end + + def cookie_header_for(name) + Array(response.headers["set-cookie"]) + .flat_map { |header| header.split("\n") } + .find { |header| header.start_with?("#{name}=") } + end + + # One row through the whole import path, so an example can ask what the + # importer does with a hostile cell without restating the plumbing. + def import_row(row) + import = build(:user_import, administrator: administrator) + import.file.attach( + io: StringIO.new("full_name,email\n#{row}\n"), + filename: "hostile.csv", + content_type: "text/csv" + ) + import.save! + ProcessUserImportJob.perform_now(import.id) + end + + # Swaps in a logger that writes where the example can read it. The broadcast + # logger Rails installs writes to more than one place, so both it and the + # controller's own logger are replaced. + def capturing_the_log + buffer = StringIO.new + logger = ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new(buffer)) + original_rails = Rails.logger + original_controller = ActionController::Base.logger + Rails.logger = logger + ActionController::Base.logger = logger + + yield + + buffer.string + ensure + Rails.logger = original_rails + ActionController::Base.logger = original_controller + end +end diff --git a/spec/requests/sessions_spec.rb b/spec/requests/sessions_spec.rb new file mode 100644 index 000000000..ccfeb0034 --- /dev/null +++ b/spec/requests/sessions_spec.rb @@ -0,0 +1,46 @@ +require "rails_helper" + +RSpec.describe "Sessions" do + describe "POST /session" do + it "sends an administrator to the admin dashboard" do + sign_in create(:user, :admin) + + expect(response).to redirect_to(admin_dashboard_path) + end + + it "sends a regular user to their own profile" do + sign_in create(:user) + + expect(response).to redirect_to(profile_path) + end + + it "refuses a wrong password" do + user = create(:user) + + sign_in(user, password: "wrong password") + + expect(response).to redirect_to(new_session_path) + expect(flash[:alert]).to be_present + end + + it "gives the same answer for an unknown email address" do + post session_path, params: { email_address: "nobody@example.com", password: "whatever" } + + expect(response).to redirect_to(new_session_path) + expect(flash[:alert]).to eq(I18n.t("sessions.invalid_credentials")) + end + end + + describe "DELETE /session" do + it "signs the user out" do + sign_in create(:user) + + delete session_path + + expect(response).to redirect_to(new_session_path) + + get profile_path + expect(response).to redirect_to(new_session_path) + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 000000000..74e277c1f --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,73 @@ +# Coverage has to start before any application code is loaded, which is why it +# lives at the very top of this file rather than in rails_helper. +# +# The live-updates pass (bin/test --live) runs a handful of system specs and +# would report the rest of the application as uncovered, so it is measured by +# the full run instead. +LIVE_CABLE_PASS = !ENV["CABLE_ADAPTER"].to_s.empty? + +# `rswag:specs:swaggerize` re-runs the request specs with --dry-run to read +# their documentation without executing them. Nothing runs, so nothing is +# covered, and measuring that would only produce a false failure. +DOCUMENTATION_PASS = ARGV.include?("--dry-run") + +# Nothing is executed on the documentation pass, so nothing would be covered: +# measuring it would overwrite the real report with an empty one. +unless DOCUMENTATION_PASS + require "simplecov" + + SimpleCov.start "rails" do + enable_coverage :branch + + # Each parallel worker writes its own result and SimpleCov merges them, so + # the 90% gate is measured against the whole suite rather than one shard. + command_name "rspec#{ENV.fetch("TEST_ENV_NUMBER", nil)}" + merging true + merge_timeout 600 + + minimum_coverage line: 90, branch: 80 unless LIVE_CABLE_PASS + + # Excluded because they hold no logic of our own: the specs themselves, + # framework configuration and the generated schema files. + skip "/spec/" + skip "/config/" + skip "/db/" + + group "Models", "app/models" + group "Controllers", "app/controllers" + group "Jobs", "app/jobs" + group "Views", "app/views" + group "Helpers", "app/helpers" + end +end + +RSpec.configure do |config| + config.expect_with :rspec do |expectations| + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + expectations.syntax = :expect + end + + config.mock_with :rspec do |mocks| + mocks.verify_partial_doubles = true + end + + config.shared_context_metadata_behavior = :apply_to_host_groups + config.filter_run_when_matching :focus + + # The screenshot spec exists to produce README images, not to verify + # behaviour, so it stays out of the default run. + config.filter_run_excluding :screenshots if ENV["SCREENSHOTS"].to_s.empty? + + # Delivery over a real websocket needs Solid Cable rather than the in-memory + # test adapter, so those examples run only in the live pass. + config.filter_run_excluding :live unless LIVE_CABLE_PASS + config.filter_run_including live: true if LIVE_CABLE_PASS + config.example_status_persistence_file_path = ".rspec_status" + config.disable_monkey_patching! + config.warnings = false + + config.default_formatter = "doc" if config.files_to_run.one? + + config.order = :random + Kernel.srand config.seed +end diff --git a/spec/support/accessibility.rb b/spec/support/accessibility.rb new file mode 100644 index 000000000..036389fa8 --- /dev/null +++ b/spec/support/accessibility.rb @@ -0,0 +1,55 @@ +require "json" + +# Runs axe-core against the page the browser is showing. The axe-core-rspec +# matcher would do this too, but it reaches for Selenium's driver API, and +# these specs drive Chrome over CDP with Cuprite -- so the few lines it takes +# to load the library and read the violations live here instead. +module AccessibilityChecking + AXE_JS = Pathname.new(Gem.loaded_specs.fetch("axe-core-api").gem_dir) + .join("node_modules/axe-core/axe.min.js") + + # The WCAG 2.1 AA rule sets, which is the level the README claims. + RULE_SETS = %w[wcag2a wcag2aa wcag21a wcag21aa].freeze + + def accessibility_violations + load_axe + + page.evaluate_async_script(<<~JS, RULE_SETS) + const done = arguments[arguments.length - 1]; + axe.run(document, { runOnly: { type: "tag", values: arguments[0] } }) + .then((results) => done(JSON.parse(JSON.stringify(results.violations)))); + JS + end + + private + + def load_axe + return if page.evaluate_script("typeof window.axe === 'object'") + + page.execute_script(AXE_JS.read) + end +end + +# Reads as an expectation about the page and fails with the rule, the impact +# and the offending markup, which is what makes a failure actionable. +RSpec::Matchers.define :be_accessible do + match do |page_under_test| + @violations = page_under_test.accessibility_violations + @violations.empty? + end + + failure_message do + lines = @violations.map do |violation| + targets = violation["nodes"].map { |node| Array(node["target"]).join(" ") }.first(3) + " [#{violation["impact"]}] #{violation["id"]}: #{violation["help"]}\n " \ + "#{targets.join("\n ")}\n #{violation["helpUrl"]}" + end + + "expected the page to have no accessibility violations, but axe reported " \ + "#{@violations.size}:\n#{lines.join("\n")}" + end +end + +RSpec.configure do |config| + config.include AccessibilityChecking, type: :system +end diff --git a/spec/support/active_job.rb b/spec/support/active_job.rb new file mode 100644 index 000000000..875310d3c --- /dev/null +++ b/spec/support/active_job.rb @@ -0,0 +1,9 @@ +# Jobs are enqueued rather than executed by default, so specs can assert on +# what was scheduled. Tag an example with `perform_enqueued: true` when the +# work itself is what is under test. +RSpec.configure do |config| + config.before do |example| + ActiveJob::Base.queue_adapter = + example.metadata[:perform_enqueued] ? :inline : :test + end +end diff --git a/spec/support/authentication_helpers.rb b/spec/support/authentication_helpers.rb new file mode 100644 index 000000000..64dcc387e --- /dev/null +++ b/spec/support/authentication_helpers.rb @@ -0,0 +1,12 @@ +module AuthenticationHelpers + DEFAULT_PASSWORD = "a-sufficiently-long-password".freeze + + def sign_in(user, password: DEFAULT_PASSWORD) + post session_path, params: { email_address: user.email_address, password: password } + end +end + +RSpec.configure do |config| + config.include AuthenticationHelpers, type: :request + config.include AuthenticationHelpers, type: :system +end diff --git a/spec/support/capybara.rb b/spec/support/capybara.rb new file mode 100644 index 000000000..faa61ffbe --- /dev/null +++ b/spec/support/capybara.rb @@ -0,0 +1,66 @@ +require "capybara/rspec" +require "capybara/cuprite" + +# Cuprite drives the Chromium installed in the development image over CDP. +# Both run inside the same container, so the test server is reachable on +# localhost and no host networking is involved. +# +# The flag list is mostly about startup time: left alone, Chromium spends +# several seconds on background networking (GCM registration, component +# updates) before it prints the DevTools websocket URL that Ferrum waits for, +# which is enough to trip the process timeout on a cold start. +CHROME_FLAGS = { + "no-sandbox" => nil, + "disable-dev-shm-usage" => nil, + "disable-gpu" => nil, + "disable-background-networking" => nil, + "disable-background-timer-throttling" => nil, + "disable-backgrounding-occluded-windows" => nil, + "disable-breakpad" => nil, + "disable-component-update" => nil, + "disable-default-apps" => nil, + "disable-extensions" => nil, + "disable-renderer-backgrounding" => nil, + "disable-sync" => nil, + "disable-features" => "Translate,BackForwardCache,MediaRouter,OptimizationHints,AcceptCHFrame", + "no-first-run" => nil, + "mute-audio" => nil +}.freeze + +# Rails' `driven_by :cuprite` registers the driver itself, discarding anything +# passed to Capybara.register_driver beforehand, and it mutates the options +# hash it is given. So the options are built fresh for each example rather than +# shared as a frozen constant. +def cuprite_options + { + browser_path: ENV.fetch("BROWSER_PATH", nil), + browser_options: CHROME_FLAGS.dup, + process_timeout: 60, + timeout: 30, + headless: true + } +end + +# The language picker is a
menu: its options are inside the closed +# element until the summary is clicked. +module LanguagePickerHelpers + def choose_language(name) + first("summary[aria-haspopup='menu']").click + click_on name + end +end + +Capybara.default_driver = :rack_test +Capybara.default_max_wait_time = 5 +Capybara.server = :puma, { Silent: true } +Capybara.disable_animation = true + +RSpec.configure do |config| + config.include LanguagePickerHelpers, type: :system + + config.before(:each, type: :system) { driven_by :rack_test } + + config.before(:each, :js, type: :system) do + driven_by :cuprite, screen_size: [1400, 1000], options: cuprite_options + end +end diff --git a/spec/support/factory_bot.rb b/spec/support/factory_bot.rb new file mode 100644 index 000000000..c7890e49c --- /dev/null +++ b/spec/support/factory_bot.rb @@ -0,0 +1,3 @@ +RSpec.configure do |config| + config.include FactoryBot::Syntax::Methods +end diff --git a/spec/support/query_counting.rb b/spec/support/query_counting.rb new file mode 100644 index 000000000..dabdd7e17 --- /dev/null +++ b/spec/support/query_counting.rb @@ -0,0 +1,22 @@ +# Counting the queries a request makes turns "this page does not have an N+1" +# into something the suite can hold to, rather than a claim in a README. +module QueryCounting + IGNORED = /\ASCHEMA\z|\ATRANSACTION\z|\ACACHE\z/ + + def count_queries + queries = [] + subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload| + queries << payload[:sql] unless payload[:name].to_s.match?(IGNORED) + end + + yield + + queries + ensure + ActiveSupport::Notifications.unsubscribe(subscriber) + end +end + +RSpec.configure do |config| + config.include QueryCounting, type: :request +end diff --git a/spec/support/shoulda_matchers.rb b/spec/support/shoulda_matchers.rb new file mode 100644 index 000000000..7d045f359 --- /dev/null +++ b/spec/support/shoulda_matchers.rb @@ -0,0 +1,6 @@ +Shoulda::Matchers.configure do |config| + config.integrate do |with| + with.test_framework :rspec + with.library :rails + end +end diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb new file mode 100644 index 000000000..33998de63 --- /dev/null +++ b/spec/swagger_helper.rb @@ -0,0 +1,72 @@ +require "rails_helper" + +# The OpenAPI document is generated from the specs that exercise the API, so it +# cannot describe an endpoint the application does not have, or a field it does +# not return. `bin/rails rswag:specs:swaggerize` writes swagger/v1/swagger.yaml, +# and CI regenerates it and fails if the committed copy has drifted. +RSpec.configure do |config| + config.openapi_root = Rails.root.join("swagger").to_s + config.openapi_format = :yaml + + config.openapi_specs = { + "v1/swagger.yaml" => { + openapi: "3.0.1", + info: { + title: "Roster API", + version: "v1", + description: <<~TEXT + The JSON side of Roster. + + Authentication is a bearer token: POST an email address and password + to /api/v1/tokens and send the token back as + `Authorization: Bearer `. The token is signed rather than + stored, lasts 24 hours, and is invalidated by a password change. + + Everything under /api/v1/users requires an administrator. + TEXT + }, + servers: [ + { url: "http://localhost:3000", description: "Development" }, + { url: "https://{host}", description: "Deployment", + variables: { host: { default: "roster.example.com" } } } + ], + components: { + securitySchemes: { + bearer_auth: { type: :http, scheme: :bearer, bearerFormat: "signed token" } + }, + schemas: { + user: { + type: :object, + properties: { + id: { type: :integer, example: 1 }, + full_name: { type: :string, example: "Ada Lovelace" }, + email_address: { type: :string, format: :email, example: "ada@example.com" }, + role: { type: :string, enum: %w[user admin] }, + locale: { type: :string, enum: User::SUPPORTED_LOCALES }, + avatar_url: { type: :string, nullable: true }, + created_at: { type: :string, format: :"date-time" }, + updated_at: { type: :string, format: :"date-time" } + }, + required: %w[id full_name email_address role locale] + }, + pagination: { + type: :object, + properties: { + page: { type: :integer }, pages: { type: :integer }, + count: { type: :integer }, limit: { type: :integer } + }, + required: %w[page pages count limit] + }, + error: { + type: :object, + properties: { + error: { type: :string, example: "unauthorized" }, + details: { type: :object, nullable: true } + }, + required: %w[error] + } + } + } + } + } +end diff --git a/spec/system/accessibility_spec.rb b/spec/system/accessibility_spec.rb new file mode 100644 index 000000000..e7f4243c4 --- /dev/null +++ b/spec/system/accessibility_spec.rb @@ -0,0 +1,110 @@ +require "rails_helper" + +# Accessibility checked rather than asserted in prose. axe runs against each +# rendered screen and fails on anything it can detect automatically: contrast, +# names, roles, labels, landmarks, heading order. +# +# Automated rules cover a part of WCAG, not all of it -- the keyboard path and +# the screen-reader wording still need a person. What is checked here is what a +# machine can honestly check. +RSpec.describe "Accessibility", :js do + let(:password) { "a-sufficiently-long-password" } + + def sign_in_as(user) + visit new_session_path + fill_in "Email address", with: user.email_address + fill_in "Password", with: password + click_on "Sign in" + end + + def expect_the_page_to_be_accessible + expect(self).to be_accessible + end + + context "when signed out" do + it "the sign in screen" do + visit new_session_path + + expect_the_page_to_be_accessible + end + + it "the sign up screen" do + visit new_registration_path + + expect_the_page_to_be_accessible + end + + it "the password reset request" do + visit new_password_path + + expect_the_page_to_be_accessible + end + end + + context "when signed in as a regular user" do + let(:user) { create(:user, full_name: "Maria Silva", password: password) } + + before { sign_in_as(user) } + + it "the profile" do + expect_the_page_to_be_accessible + end + + it "the profile form" do + visit edit_profile_path + + expect_the_page_to_be_accessible + end + end + + context "when signed in as an administrator" do + let(:administrator) { create(:user, :admin, full_name: "Ada Lovelace", password: password) } + + before do + create_list(:user, 3) + sign_in_as(administrator) + end + + it "the dashboard" do + expect_the_page_to_be_accessible + end + + it "the list of users" do + visit admin_users_path + + expect_the_page_to_be_accessible + end + + it "the user form" do + visit new_admin_user_path + + expect_the_page_to_be_accessible + end + + it "the activity trail" do + create(:audit_event, actor: administrator, subject: create(:user)) + + visit admin_audit_events_path + + expect_the_page_to_be_accessible + end + + it "the imports screen" do + visit admin_user_imports_path + + expect_the_page_to_be_accessible + end + + it "an import with rejected rows" do + import = create(:user_import, :with_csv, administrator: administrator, + status: :completed_with_errors, total_rows: 3, + processed_rows: 3, created_users: 2, rejected_rows: 1) + import.row_errors.create!(row_number: 3, email_address: "taken@example.com", + messages: ["Email address has already been taken"]) + + visit admin_user_import_path(import) + + expect_the_page_to_be_accessible + end + end +end diff --git a/spec/system/administrator_journey_spec.rb b/spec/system/administrator_journey_spec.rb new file mode 100644 index 000000000..a892825e0 --- /dev/null +++ b/spec/system/administrator_journey_spec.rb @@ -0,0 +1,114 @@ +require "rails_helper" + +# The administrative side from end to end: the dashboard, the list and its +# filters, the forms, and the two rules that protect the system from being left +# without anyone able to run it. +RSpec.describe "The administrator journey" do + let(:password) { "a-sufficiently-long-password" } + let!(:administrator) do + create(:user, :admin, full_name: "Ada Lovelace", email_address: "ada@example.com", password: password) + end + + before do + visit new_session_path + fill_in "Email address", with: administrator.email_address + fill_in "Password", with: password + click_on "Sign in" + end + + it "arrives at the dashboard and reads the counters" do + expect(page).to have_current_path(admin_dashboard_path) + expect(page).to have_text("Total users") + expect(page).to have_text("Administrators") + end + + it "creates an account on someone's behalf and finds it in the list" do + # "Users" names the sidebar link and the dashboard button both. + click_on "Users", match: :first + click_on "Add user" + + fill_in "Full name", with: "Maria Silva" + fill_in "Email address", with: "maria@example.com" + fill_in "Password", with: password + click_on "Create user" + + expect(page).to have_text("Maria Silva has been added.") + expect(page).to have_text("maria@example.com") + expect(User.find_by(email_address: "maria@example.com")).to be_user + end + + it "narrows a long list by name and by role" do + create(:user, full_name: "Maria Silva") + create(:user, full_name: "João Souza") + create(:user, :admin, full_name: "Grace Hopper") + + visit admin_users_path + fill_in "Search", with: "maria" + click_on "Search" + + expect(page).to have_text("Maria Silva") + expect(page).to have_no_text("João Souza") + + click_on "Clear" + click_on "Administrators" + + expect(page).to have_text("Grace Hopper") + expect(page).to have_no_text("Maria Silva") + end + + it "promotes somebody, then removes them" do + create(:user, full_name: "Maria Silva", email_address: "maria@example.com") + + visit admin_users_path + within("tr", text: "Maria Silva") { click_on "Edit" } + select "Administrator", from: "Role" + click_on "Save changes" + + expect(page).to have_text("Maria Silva has been updated.") + expect(User.find_by(email_address: "maria@example.com")).to be_admin + + within("tr", text: "Maria Silva") { click_on "Delete" } + + expect(page).to have_text("Maria Silva has been deleted.") + end + + it "can read back what was done, and by whom" do + create(:user, full_name: "Maria Silva", email_address: "maria@example.com") + + visit admin_users_path + within("tr", text: "Maria Silva") { click_on "Edit" } + select "Administrator", from: "Role" + click_on "Save changes" + + click_on "Activity", match: :first + + expect(page).to have_text("Ada Lovelace") + expect(page).to have_text("Maria Silva") + expect(page).to have_text(I18n.t("admin.audit_events.actions.promoted")) + end + + it "refuses to remove the only administrator left" do + create(:user, full_name: "Maria Silva") + + visit admin_users_path + within("tr", text: "Ada Lovelace") { click_on "Delete" } + + expect(page).to have_text(I18n.t("activerecord.errors.models.user.attributes.base.last_administrator")) + expect(administrator.reload).to be_persisted + end + + it "imports a spreadsheet and reports what it did with each row", :perform_enqueued do + click_on "Imports", match: :first + attach_file "Spreadsheet", Rails.root.join("spec/fixtures/files/users-with-problems.csv") + + click_on "Start import" + + expect(page).to have_text(I18n.t("admin.user_imports.scheduled")) + + visit admin_user_import_path(UserImport.last) + + expect(page).to have_text("Rejected rows") + expect(UserImport.last).to be_completed_with_errors + expect(UserImport.last.created_users).to be_positive + end +end diff --git a/spec/system/authentication_spec.rb b/spec/system/authentication_spec.rb new file mode 100644 index 000000000..f742f1b3c --- /dev/null +++ b/spec/system/authentication_spec.rb @@ -0,0 +1,50 @@ +require "rails_helper" + +RSpec.describe "Signing in", :js do + it "takes an administrator to the dashboard" do + create(:user, :admin, full_name: "Ada Lovelace", email_address: "ada@example.com", + password: "a-sufficiently-long-password") + + visit new_session_path + fill_in "Email address", with: "ada@example.com" + fill_in "Password", with: "a-sufficiently-long-password" + click_on "Sign in" + + expect(page).to have_current_path(admin_dashboard_path) + expect(page).to have_text("Ada Lovelace") + end + + it "takes a regular user to their profile" do + create(:user, full_name: "Maria Silva", email_address: "maria@example.com", + password: "a-sufficiently-long-password") + + visit new_session_path + fill_in "Email address", with: "maria@example.com" + fill_in "Password", with: "a-sufficiently-long-password" + click_on "Sign in" + + expect(page).to have_current_path(profile_path) + expect(page).to have_text("Maria Silva") + end + + it "keeps the visitor on the form when the password is wrong" do + create(:user, email_address: "maria@example.com") + + visit new_session_path + fill_in "Email address", with: "maria@example.com" + fill_in "Password", with: "wrong password" + click_on "Sign in" + + expect(page).to have_current_path(new_session_path) + expect(page).to have_text(I18n.t("sessions.invalid_credentials")) + end + + it "switches the interface language from the flag picker" do + visit new_session_path + + choose_language("Português") + + expect(page).to have_text("Entrar") + expect(page).to have_field("E-mail") + end +end diff --git a/spec/system/live_updates_spec.rb b/spec/system/live_updates_spec.rb new file mode 100644 index 000000000..9d60a59d0 --- /dev/null +++ b/spec/system/live_updates_spec.rb @@ -0,0 +1,56 @@ +require "rails_helper" + +# Delivery in a real browser, over a real websocket: the page is loaded once +# and never reloaded, and the work that changes it happens afterwards, from +# the example itself. Excluded from the default run because it needs Solid +# Cable rather than the in-memory test adapter -- run it with bin/test --live. +RSpec.describe "Live updates", :js, :live do + let(:password) { "a-sufficiently-long-password" } + # Delivery travels through a polling loop on the cable database, and a busy + # CI machine is slower than a laptop, so the waits here are generous. They + # cost nothing when the update arrives, which is the ordinary case. + let(:delivery_wait) { 25 } + let(:administrator) do + create(:user, :admin, full_name: "Ada Lovelace", email_address: "ada@example.com", password: password) + end + + before do + visit new_session_path + fill_in "Email address", with: administrator.email_address + fill_in "Password", with: password + click_on "Sign in" + has_current_path?(admin_dashboard_path, wait: 5) + end + + # Solid Cable delivers what is published after a subscription exists, so a + # broadcast sent while the browser is still connecting is simply missed. + # Turbo marks its stream source element `connected` once the subscription is + # confirmed, which is the moment the page is really listening. + def wait_for_the_subscription + expect(page).to have_css("turbo-cable-stream-source[connected]", visible: :all) + end + + it "moves the dashboard counters when somebody else changes the roster" do + # The labels are uppercased by CSS, so they are matched without case. + expect(page).to have_text(/total users/i) + expect(page).to have_css("#user-counters", text: "1") + wait_for_the_subscription + + create(:user, full_name: "Maria Silva") + + expect(page).to have_css("#user-counters", text: "2", wait: delivery_wait) + end + + it "carries an import from waiting to finished without a reload" do + import = create(:user_import, :with_csv, administrator: administrator) + + visit admin_user_import_path(import) + expect(page).to have_text(/#{I18n.t("admin.user_imports.statuses.pending")}/i) + wait_for_the_subscription + + ProcessUserImportJob.perform_now(import.id) + + expect(page).to have_text(/#{I18n.t("admin.user_imports.statuses.completed")}/i, wait: delivery_wait) + expect(page).to have_current_path(admin_user_import_path(import)) + end +end diff --git a/spec/system/regular_user_journey_spec.rb b/spec/system/regular_user_journey_spec.rb new file mode 100644 index 000000000..4fc0f59c3 --- /dev/null +++ b/spec/system/regular_user_journey_spec.rb @@ -0,0 +1,96 @@ +require "rails_helper" + +# What a person with an ordinary account can do from end to end, and where the +# application stops them. +RSpec.describe "The regular user journey" do + let(:password) { "a-sufficiently-long-password" } + let!(:user) do + create(:user, full_name: "Maria Silva", email_address: "maria@example.com", password: password) + end + + def sign_in_as(email_address) + visit new_session_path + fill_in "Email address", with: email_address + fill_in "Password", with: password + click_on "Sign in" + end + + it "signs in, corrects their own details and sees the change" do + sign_in_as(user.email_address) + + expect(page).to have_current_path(profile_path) + + click_on "Edit profile" + fill_in "Full name", with: "Maria Silva Santos" + fill_in "Avatar URL", with: "https://example.com/maria.png" + click_on "Save changes" + + expect(page).to have_text(I18n.t("profiles.updated")) + expect(page).to have_text("Maria Silva Santos") + expect(user.reload.full_name).to eq("Maria Silva Santos") + end + + it "is refused an avatar URL that is not an ordinary web link" do + sign_in_as(user.email_address) + + visit edit_profile_path + fill_in "Avatar URL", with: "javascript:alert('xss')" + click_on "Save changes" + + expect(page).to have_text("must be an http or https link") + expect(user.reload.avatar_url).to be_nil + end + + it "keeps the language they chose, because it belongs to the account" do + sign_in_as(user.email_address) + + choose_language("Português") + + expect(page).to have_text("Meu perfil") + expect(user.reload.locale).to eq("pt-BR") + + # The sign out button appears in the sidebar and again in the mobile bar. + click_on "Sair", match: :first + # Back to English as a visitor, so what comes next cannot be the session + # remembering the choice -- it has to come from the account. + choose_language("English") + fill_in "Email address", with: user.email_address + fill_in "Password", with: password + click_on "Sign in" + + expect(page).to have_text("Meu perfil") + end + + it "is turned away from the administration area, plainly" do + sign_in_as(user.email_address) + + visit admin_users_path + + expect(page).to have_current_path(profile_path) + expect(page).to have_text(I18n.t("authorization.admin_only")) + end + + it "cannot reach another person's profile, because there is no URL to try" do + other = create(:user, full_name: "Someone Else") + sign_in_as(user.email_address) + + visit profile_path + + expect(page).to have_text("Maria Silva") + expect(page).to have_no_text(other.full_name) + end + + it "deletes their own account and cannot get back in" do + sign_in_as(user.email_address) + + click_on "Delete my account" + + expect(page).to have_current_path(new_session_path) + expect(page).to have_text(I18n.t("profiles.deleted")) + + sign_in_as("maria@example.com") + + expect(page).to have_text(I18n.t("sessions.invalid_credentials")) + expect(User.find_by(email_address: "maria@example.com")).to be_nil + end +end diff --git a/spec/system/responsive_layout_spec.rb b/spec/system/responsive_layout_spec.rb new file mode 100644 index 000000000..c54e6019c --- /dev/null +++ b/spec/system/responsive_layout_spec.rb @@ -0,0 +1,65 @@ +require "rails_helper" + +# The claim "responsive" is easy to make and easy to break: one table without a +# scroll container of its own, and the whole page scrolls sideways on a phone. +# So it is measured -- at 360 CSS pixels, which is narrower than the phones +# people actually carry. +RSpec.describe "The layout on a small screen", :js do + let(:password) { "a-sufficiently-long-password" } + let(:administrator) { create(:user, :admin, full_name: "Ada Lovelace", password: password) } + + def expect_no_sideways_scrolling + overflow = page.evaluate_script(<<~JS) + document.documentElement.scrollWidth - document.documentElement.clientWidth + JS + + expect(overflow).to be <= 0 + end + + before do + page.driver.resize(360, 760) + + visit new_session_path + fill_in "Email address", with: administrator.email_address + fill_in "Password", with: password + click_on "Sign in" + has_current_path?(admin_dashboard_path, wait: 5) + end + + it "fits the sign-in screen" do + click_on "Sign out", match: :first + + expect(page).to have_current_path(new_session_path) + expect_no_sideways_scrolling + end + + it "fits the dashboard" do + expect(page).to have_text(/total users/i) + expect_no_sideways_scrolling + end + + it "fits the list of users, table and all" do + create_list(:user, 3) + + visit admin_users_path + + expect(page).to have_text("Add user") + expect_no_sideways_scrolling + end + + it "fits a form" do + visit new_admin_user_path + + expect(page).to have_field("Full name") + expect_no_sideways_scrolling + end + + it "fits the activity trail" do + create(:audit_event, actor: administrator, subject: create(:user)) + + visit admin_audit_events_path + + expect(page).to have_text(/history/i) + expect_no_sideways_scrolling + end +end diff --git a/spec/system/screenshots_spec.rb b/spec/system/screenshots_spec.rb new file mode 100644 index 000000000..c991f48a6 --- /dev/null +++ b/spec/system/screenshots_spec.rb @@ -0,0 +1,82 @@ +require "rails_helper" + +# Not a test: this captures the reference images used in the README. It asserts +# only enough to know the page rendered before the shutter fires. Excluded from +# the default run; generate the images with SCREENSHOTS=1 bin/test. +# +# The images are written straight into docs/screenshots, so regenerating them +# is a diff rather than a copy out of tmp. Capybara resolves a relative path +# against its own save_path, hence the absolute one. +# +# Swagger UI is not captured here: it is served by an engine with its own +# content security policy, and Capybara's animation disabler reads the policy +# of every page it visits looking for a nonce it will not find there. +RSpec.describe "Screens", :js, :screenshots do + it "captures the way in" do + visit new_session_path + + expect(page).to have_text("Sign in") + page.save_screenshot(Rails.root.join("docs/screenshots/sign-in.png"), full: true) + end + + it "captures the admin journey" do + admin = create(:user, :admin, full_name: "Ada Lovelace", email_address: "ada@example.com", + password: "a-sufficiently-long-password") + create(:user, full_name: "Maria Silva", email_address: "maria@example.com") + create(:user, full_name: "João Souza", email_address: "joao@example.com") + create(:user, full_name: "Lucía Fernández", email_address: "lucia@example.com") + create(:user, :admin, full_name: "Grace Hopper", email_address: "grace@example.com") + + visit new_session_path + fill_in "Email address", with: admin.email_address + fill_in "Password", with: "a-sufficiently-long-password" + click_on "Sign in" + + expect(page).to have_text("Dashboard") + page.save_screenshot(Rails.root.join("docs/screenshots/dashboard.png"), full: true) + + visit admin_users_path + expect(page).to have_text("Maria Silva") + page.save_screenshot(Rails.root.join("docs/screenshots/users.png"), full: true) + + visit profile_path + expect(page).to have_text("Ada Lovelace") + page.save_screenshot(Rails.root.join("docs/screenshots/profile.png"), full: true) + + create(:audit_event, actor: admin, subject: User.find_by(email_address: "maria@example.com"), + action: :promoted) + create(:audit_event, actor: admin, subject: User.find_by(email_address: "joao@example.com"), + action: :created) + visit admin_audit_events_path + expect(page).to have_text(/#{I18n.t("admin.audit_events.actions.promoted")}/i) + page.save_screenshot(Rails.root.join("docs/screenshots/activity.png"), full: true) + end + + it "captures the import screens" do + administrator = create(:user, :admin, full_name: "Ada Lovelace", email_address: "ada@example.com", + password: "a-sufficiently-long-password") + + import = UserImport.new(administrator: administrator, status: :completed_with_errors, + total_rows: 6, processed_rows: 6, created_users: 4, rejected_rows: 2) + import.file.attach(io: Rails.root.join("spec/fixtures/files/users-with-problems.csv").open, + filename: "users-with-problems.csv") + import.save! + import.row_errors.create!(row_number: 3, email_address: nil, messages: ["Full name can't be blank"]) + import.row_errors.create!(row_number: 5, email_address: "maria@example.com", + messages: ["Email address has already been taken"]) + + visit new_session_path + fill_in "Email address", with: administrator.email_address + fill_in "Password", with: "a-sufficiently-long-password" + click_on "Sign in" + expect(page).to have_text("Dashboard") + + visit admin_user_imports_path + expect(page).to have_text("Imports") + page.save_screenshot(Rails.root.join("docs/screenshots/imports.png"), full: true) + + visit admin_user_import_path(import) + expect(page).to have_text("maria@example.com") + page.save_screenshot(Rails.root.join("docs/screenshots/import-detail.png"), full: true) + end +end diff --git a/spec/system/visitor_journey_spec.rb b/spec/system/visitor_journey_spec.rb new file mode 100644 index 000000000..e93e90775 --- /dev/null +++ b/spec/system/visitor_journey_spec.rb @@ -0,0 +1,54 @@ +require "rails_helper" + +# The whole path a visitor walks, in a browser, rather than one action at a +# time: the root is a signpost, the public form only ever creates a regular +# user, and nothing behind the sign-in page is reachable before signing in. +RSpec.describe "The visitor journey" do + it "signs up from the root and lands on their own profile" do + visit root_path + + expect(page).to have_current_path(new_session_path) + + click_on "Create an account" + fill_in "Full name", with: "Maria Silva" + fill_in "Email address", with: "maria@example.com" + fill_in "Password", with: "a-sufficiently-long-password" + fill_in "Confirm password", with: "a-sufficiently-long-password" + click_on "Create account" + + expect(page).to have_current_path(profile_path) + expect(page).to have_text("Maria Silva") + # The public form has no role field, and a hand-made request cannot add + # one: the created account is always a regular user. + expect(User.find_by(email_address: "maria@example.com")).to be_user + end + + it "is stopped at the door of every page that needs an account" do + [profile_path, edit_profile_path, admin_dashboard_path, admin_users_path, + admin_user_imports_path].each do |path| + visit path + + expect(page).to have_current_path(new_session_path) + end + end + + it "is returned to the page it asked for after signing in" do + create(:user, :admin, email_address: "ada@example.com", password: "a-sufficiently-long-password") + + visit admin_users_path + fill_in "Email address", with: "ada@example.com" + fill_in "Password", with: "a-sufficiently-long-password" + click_on "Sign in" + + expect(page).to have_current_path(admin_users_path) + end + + it "asks for a password reset without revealing whether the account exists" do + visit new_session_path + click_on "Forgot password?" + fill_in "Email address", with: "nobody@example.com" + click_on "Send reset instructions" + + expect(page).to have_text(I18n.t("passwords.reset_instructions_sent")) + end +end diff --git a/storage/.keep b/storage/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/swagger/v1/swagger.yaml b/swagger/v1/swagger.yaml new file mode 100644 index 000000000..578c48dda --- /dev/null +++ b/swagger/v1/swagger.yaml @@ -0,0 +1,385 @@ +--- +openapi: 3.0.1 +info: + title: Roster API + version: v1 + description: | + The JSON side of Roster. + + Authentication is a bearer token: POST an email address and password + to /api/v1/tokens and send the token back as + `Authorization: Bearer `. The token is signed rather than + stored, lasts 24 hours, and is invalidated by a password change. + + Everything under /api/v1/users requires an administrator. +servers: +- url: http://localhost:3000 + description: Development +- url: https://{host} + description: Deployment + variables: + host: + default: roster.example.com +components: + securitySchemes: + bearer_auth: + type: http + scheme: bearer + bearerFormat: signed token + schemas: + user: + type: object + properties: + id: + type: integer + example: 1 + full_name: + type: string + example: Ada Lovelace + email_address: + type: string + format: email + example: ada@example.com + role: + type: string + enum: + - user + - admin + locale: + type: string + enum: + - en + - pt-BR + - es + avatar_url: + type: string + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + required: + - id + - full_name + - email_address + - role + - locale + pagination: + type: object + properties: + page: + type: integer + pages: + type: integer + count: + type: integer + limit: + type: integer + required: + - page + - pages + - count + - limit + error: + type: object + properties: + error: + type: string + example: unauthorized + details: + type: object + nullable: true + required: + - error +paths: + "/api/v1/tokens": + post: + summary: Exchanges credentials for a bearer token + tags: + - Authentication + security: [] + parameters: [] + responses: + '201': + description: a token, valid for 24 hours + content: + application/json: + schema: + type: object + properties: + token: + type: string + expires_at: + type: string + format: date-time + user: + "$ref": "#/components/schemas/user" + required: + - token + - expires_at + - user + '401': + description: no such account + content: + application/json: + schema: + "$ref": "#/components/schemas/error" + requestBody: + content: + application/json: + schema: + type: object + properties: + email_address: + type: string + format: email + example: ada@example.com + password: + type: string + format: password + required: + - email_address + - password + required: true + "/api/v1/me": + get: + summary: Returns the account the token belongs to + tags: + - Authentication + security: + - bearer_auth: [] + responses: + '200': + description: the signed in account + content: + application/json: + schema: + "$ref": "#/components/schemas/user" + '401': + description: no token, or a token that has expired + content: + application/json: + schema: + "$ref": "#/components/schemas/error" + "/api/v1/users": + get: + summary: Lists the people in the system + tags: + - Users + security: + - bearer_auth: [] + parameters: + - name: query + in: query + required: false + schema: + type: string + description: Matches a name or an email address + - name: role + in: query + required: false + schema: + type: string + enum: + - user + - admin + - name: page + in: query + required: false + schema: + type: integer + - name: per_page + in: query + required: false + schema: + type: integer + maximum: 100 + description: Bounded, so a hand-edited URL cannot ask for the whole table + responses: + '200': + description: a page of people + content: + application/json: + schema: + type: object + properties: + users: + type: array + items: + "$ref": "#/components/schemas/user" + pagination: + "$ref": "#/components/schemas/pagination" + required: + - users + - pagination + '403': + description: the token belongs to somebody who is not an administrator + content: + application/json: + schema: + "$ref": "#/components/schemas/error" + '401': + description: no token + content: + application/json: + schema: + "$ref": "#/components/schemas/error" + post: + summary: Creates an account + tags: + - Users + security: + - bearer_auth: [] + parameters: [] + responses: + '201': + description: the account that was created + content: + application/json: + schema: + "$ref": "#/components/schemas/user" + '422': + description: the account was refused + content: + application/json: + schema: + "$ref": "#/components/schemas/error" + requestBody: + content: + application/json: + schema: + type: object + properties: + user: + type: object + properties: + full_name: + type: string + example: Maria Silva + email_address: + type: string + format: email + example: maria@example.com + password: + type: string + format: password + minLength: 8 + role: + type: string + enum: + - user + - admin + locale: + type: string + enum: + - en + - pt-BR + - es + avatar_url: + type: string + nullable: true + required: + - full_name + - email_address + - password + required: + - user + required: true + "/api/v1/users/{id}": + parameters: + - name: id + in: path + required: true + schema: + type: integer + get: + summary: Returns one person + tags: + - Users + security: + - bearer_auth: [] + responses: + '200': + description: the person + content: + application/json: + schema: + "$ref": "#/components/schemas/user" + '404': + description: nobody with that id + content: + application/json: + schema: + "$ref": "#/components/schemas/error" + patch: + summary: Updates a person + tags: + - Users + security: + - bearer_auth: [] + parameters: [] + responses: + '200': + description: the person as they now are + content: + application/json: + schema: + "$ref": "#/components/schemas/user" + '422': + description: the change was refused -- here, the last administrator + content: + application/json: + schema: + "$ref": "#/components/schemas/error" + requestBody: + content: + application/json: + schema: + type: object + properties: + user: + type: object + properties: + full_name: + type: string + email_address: + type: string + format: email + password: + type: string + format: password + minLength: 8 + role: + type: string + enum: + - user + - admin + locale: + type: string + enum: + - en + - pt-BR + - es + avatar_url: + type: string + nullable: true + required: + - user + required: true + delete: + summary: Removes a person + tags: + - Users + security: + - bearer_auth: [] + responses: + '204': + description: removed + '422': + description: the only administrator left may not be removed + content: + application/json: + schema: + "$ref": "#/components/schemas/error" 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