diff --git a/.github/workflows/eslint.yml b/.github/workflows/eslint.yml index 7a11de5f..9844d838 100644 --- a/.github/workflows/eslint.yml +++ b/.github/workflows/eslint.yml @@ -14,7 +14,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: "22.13.1" + node-version: "22.18.0" cache: "npm" - name: Install JS dependencies diff --git a/.github/workflows/rspec.yml b/.github/workflows/rspec.yml index b5e78f39..cdfbd9d4 100644 --- a/.github/workflows/rspec.yml +++ b/.github/workflows/rspec.yml @@ -17,13 +17,13 @@ jobs: - name: Set up Ruby and install gems uses: ruby/setup-ruby@v1 with: - ruby-version: "3.4.1" + ruby-version: "3.4.5" bundler-cache: true - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: "22.13.1" + node-version: "22.18.0" cache: "npm" - name: Install JS dependencies diff --git a/.github/workflows/typescript.yml b/.github/workflows/typescript.yml index 5c846840..1029db7d 100644 --- a/.github/workflows/typescript.yml +++ b/.github/workflows/typescript.yml @@ -14,7 +14,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: "22.13.1" + node-version: "22.18.0" cache: "npm" - name: Install JS dependencies diff --git a/.gitignore b/.gitignore index 7e7a645f..1acaf4e2 100644 --- a/.gitignore +++ b/.gitignore @@ -74,3 +74,6 @@ tf/ !**/**/.keep # Sentry Config File .env.sentry-build-plugin + +registry/ +coverage/ \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit index bfcc7cec..bd011f6a 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -13,7 +13,9 @@ npx tsc --project tsconfig.json echo "Run prettier on project" npm run prettier -echo "Run rubocop on changed files, autofix any fixable offenses" -bundle exec rubocop --autocorrect --only-recognized-file-types $changed_files +# echo "Run rubocop on changed files, autofix any fixable offenses" +# bundle exec rubocop --only-recognized-file-types $changed_files +# bundle exec rubocop --autocorrect --only-recognized-file-types $changed_files +# bundle exec rubocop --only-recognized-file-types $changed_files -A git update-index --again \ No newline at end of file diff --git a/.kamal/hooks/docker-setup.sample b/.kamal/hooks/docker-setup.sample new file mode 100755 index 00000000..2fb07d7d --- /dev/null +++ b/.kamal/hooks/docker-setup.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Docker set up on $KAMAL_HOSTS..." diff --git a/.kamal/hooks/post-app-boot.sample b/.kamal/hooks/post-app-boot.sample new file mode 100755 index 00000000..70f9c4bc --- /dev/null +++ b/.kamal/hooks/post-app-boot.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Booted app version $KAMAL_VERSION on $KAMAL_HOSTS..." diff --git a/.kamal/hooks/post-deploy.sample b/.kamal/hooks/post-deploy.sample new file mode 100755 index 00000000..75efafc1 --- /dev/null +++ b/.kamal/hooks/post-deploy.sample @@ -0,0 +1,14 @@ +#!/bin/sh + +# A sample post-deploy hook +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_ROLE (if set) +# KAMAL_DESTINATION (if set) +# KAMAL_RUNTIME + +echo "$KAMAL_PERFORMER deployed $KAMAL_VERSION to $KAMAL_DESTINATION in $KAMAL_RUNTIME seconds" diff --git a/.kamal/hooks/post-proxy-reboot.sample b/.kamal/hooks/post-proxy-reboot.sample new file mode 100755 index 00000000..1435a677 --- /dev/null +++ b/.kamal/hooks/post-proxy-reboot.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Rebooted kamal-proxy on $KAMAL_HOSTS" diff --git a/.kamal/hooks/pre-app-boot.sample b/.kamal/hooks/pre-app-boot.sample new file mode 100755 index 00000000..45f73550 --- /dev/null +++ b/.kamal/hooks/pre-app-boot.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Booting app version $KAMAL_VERSION on $KAMAL_HOSTS..." diff --git a/.kamal/hooks/pre-build.sample b/.kamal/hooks/pre-build.sample new file mode 100755 index 00000000..f87d8113 --- /dev/null +++ b/.kamal/hooks/pre-build.sample @@ -0,0 +1,51 @@ +#!/bin/sh + +# A sample pre-build hook +# +# Checks: +# 1. We have a clean checkout +# 2. A remote is configured +# 3. The branch has been pushed to the remote +# 4. The version we are deploying matches the remote +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_ROLE (if set) +# KAMAL_DESTINATION (if set) + +if [ -n "$(git status --porcelain)" ]; then + echo "Git checkout is not clean, aborting..." >&2 + git status --porcelain >&2 + exit 1 +fi + +first_remote=$(git remote) + +if [ -z "$first_remote" ]; then + echo "No git remote set, aborting..." >&2 + exit 1 +fi + +current_branch=$(git branch --show-current) + +if [ -z "$current_branch" ]; then + echo "Not on a git branch, aborting..." >&2 + exit 1 +fi + +remote_head=$(git ls-remote $first_remote --tags $current_branch | cut -f1) + +if [ -z "$remote_head" ]; then + echo "Branch not pushed to remote, aborting..." >&2 + exit 1 +fi + +if [ "$KAMAL_VERSION" != "$remote_head" ]; then + echo "Version ($KAMAL_VERSION) does not match remote HEAD ($remote_head), aborting..." >&2 + exit 1 +fi + +exit 0 diff --git a/.kamal/hooks/pre-connect.sample b/.kamal/hooks/pre-connect.sample new file mode 100755 index 00000000..18e61d7e --- /dev/null +++ b/.kamal/hooks/pre-connect.sample @@ -0,0 +1,47 @@ +#!/usr/bin/env ruby + +# A sample pre-connect check +# +# Warms DNS before connecting to hosts in parallel +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_ROLE (if set) +# KAMAL_DESTINATION (if set) +# KAMAL_RUNTIME + +hosts = ENV["KAMAL_HOSTS"].split(",") +results = nil +max = 3 + +elapsed = Benchmark.realtime do + results = hosts.map do |host| + Thread.new do + tries = 1 + + begin + Socket.getaddrinfo(host, 0, Socket::AF_UNSPEC, Socket::SOCK_STREAM, nil, Socket::AI_CANONNAME) + rescue SocketError + if tries < max + puts "Retrying DNS warmup: #{host}" + tries += 1 + sleep rand + retry + else + puts "DNS warmup failed: #{host}" + host + end + end + + tries + end + end.map(&:value) +end + +retries = results.sum - hosts.size +nopes = results.count { |r| r == max } + +puts "Prewarmed %d DNS lookups in %.2f sec: %d retries, %d failures" % [ hosts.size, elapsed, retries, nopes ] diff --git a/.kamal/hooks/pre-deploy.sample b/.kamal/hooks/pre-deploy.sample new file mode 100755 index 00000000..1b280c71 --- /dev/null +++ b/.kamal/hooks/pre-deploy.sample @@ -0,0 +1,109 @@ +#!/usr/bin/env ruby + +# A sample pre-deploy hook +# +# Checks the Github status of the build, waiting for a pending build to complete for up to 720 seconds. +# +# Fails unless the combined status is "success" +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_COMMAND +# KAMAL_SUBCOMMAND +# KAMAL_ROLE (if set) +# KAMAL_DESTINATION (if set) + +# Only check the build status for production deployments +if ENV["KAMAL_COMMAND"] == "rollback" || ENV["KAMAL_DESTINATION"] != "production" + exit 0 +end + +require "bundler/inline" + +# true = install gems so this is fast on repeat invocations +gemfile(true, quiet: true) do + source "https://rubygems.org" + + gem "octokit" + gem "faraday-retry" +end + +MAX_ATTEMPTS = 72 +ATTEMPTS_GAP = 10 + +def exit_with_error(message) + $stderr.puts message + exit 1 +end + +class GithubStatusChecks + attr_reader :remote_url, :git_sha, :github_client, :combined_status + + def initialize + @remote_url = `git config --get remote.origin.url`.strip.delete_prefix("https://github.com/") + @git_sha = `git rev-parse HEAD`.strip + @github_client = Octokit::Client.new(access_token: ENV["GITHUB_TOKEN"]) + refresh! + end + + def refresh! + @combined_status = github_client.combined_status(remote_url, git_sha) + end + + def state + combined_status[:state] + end + + def first_status_url + first_status = combined_status[:statuses].find { |status| status[:state] == state } + first_status && first_status[:target_url] + end + + def complete_count + combined_status[:statuses].count { |status| status[:state] != "pending"} + end + + def total_count + combined_status[:statuses].count + end + + def current_status + if total_count > 0 + "Completed #{complete_count}/#{total_count} checks, see #{first_status_url} ..." + else + "Build not started..." + end + end +end + + +$stdout.sync = true + +puts "Checking build status..." +attempts = 0 +checks = GithubStatusChecks.new + +begin + loop do + case checks.state + when "success" + puts "Checks passed, see #{checks.first_status_url}" + exit 0 + when "failure" + exit_with_error "Checks failed, see #{checks.first_status_url}" + when "pending" + attempts += 1 + end + + exit_with_error "Checks are still pending, gave up after #{MAX_ATTEMPTS * ATTEMPTS_GAP} seconds" if attempts == MAX_ATTEMPTS + + puts checks.current_status + sleep(ATTEMPTS_GAP) + checks.refresh! + end +rescue Octokit::NotFound + exit_with_error "Build status could not be found" +end diff --git a/.kamal/hooks/pre-proxy-reboot.sample b/.kamal/hooks/pre-proxy-reboot.sample new file mode 100755 index 00000000..061f8059 --- /dev/null +++ b/.kamal/hooks/pre-proxy-reboot.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Rebooting kamal-proxy on $KAMAL_HOSTS..." diff --git a/.kamal/secrets b/.kamal/secrets new file mode 100644 index 00000000..40938e47 --- /dev/null +++ b/.kamal/secrets @@ -0,0 +1,49 @@ + +# Secrets defined here are available for reference under registry/password, env/secret, builder/secrets, +# and accessories/*/env/secret in config/deploy.yml. All secrets should be pulled from either +# password manager, ENV, or a file. DO NOT ENTER RAW CREDENTIALS HERE! This file needs to be safe for git. + +# Option 1: Read secrets from the environment +KAMAL_REGISTRY_PASSWORD=$GITHUB_ACCESS_TOKEN # .env.kamal + +# Option 2: Read secrets via a command +# RAILS_MASTER_KEY=$(cat config/master.key) + +# Option 3: Read secrets via kamal secrets helpers +# These will handle logging in and fetching the secrets in as few calls as possible +# There are adapters for 1Password, LastPass + Bitwarden +# +# SECRETS=$(kamal secrets fetch --adapter 1password --account my-account --from MyVault/MyItem KAMAL_REGISTRY_PASSWORD RAILS_MASTER_KEY) +# KAMAL_REGISTRY_PASSWORD=$(kamal secrets extract KAMAL_REGISTRY_PASSWORD $SECRETS) +# RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY $SECRETS) + +SECRETS=$(kamal secrets fetch --adapter bitwarden --account legis@sway.vote GOOGLE_CLOUD_PROJECT VITE_CLOUDFLARE_TURNSTILE_SITE_KEY GOOGLE_MAPS_API_KEY VITE_GOOGLE_RECAPTCHA_SITE_KEY VITE_SENTRY_IO_ID VITE_SENTRY_IO_ROUTE NEW_RELIC_USER_KEY NEW_RELIC_API_KEY NEW_RELIC_LICENSE_KEY NEW_RELIC_ACCOUNT_ID TWILIO_ACCOUNT_SID TWILIO_AUTH_TOKEN TWILIO_VERIFY_SERVICE_SID SENTRY_DSN SENTRY_ORG SENTRY_PROJECT SENTRY_AUTH_TOKEN CONGRESS_GOV_API_KEY OPEN_STATES_API_KEY ADMIN_PHONES SENDGRID_API_KEY SWAY_DATABASE_PASSWORD SECRET_KEY_BASE RAILS_MASTER_KEY VAPID_PRIVATE_KEY VAPID_PUBLIC_KEY TUNNEL_TOKEN) + +GOOGLE_CLOUD_PROJECT=$(kamal secrets extract GOOGLE_CLOUD_PROJECT $SECRETS) +VITE_CLOUDFLARE_TURNSTILE_SITE_KEY=$(kamal secrets extract VITE_CLOUDFLARE_TURNSTILE_SITE_KEY $SECRETS) +GOOGLE_MAPS_API_KEY=$(kamal secrets extract GOOGLE_MAPS_API_KEY $SECRETS) +VITE_GOOGLE_MAPS_API_KEY=$(kamal secrets extract GOOGLE_MAPS_API_KEY $SECRETS) +VITE_GOOGLE_RECAPTCHA_SITE_KEY=$(kamal secrets extract VITE_GOOGLE_RECAPTCHA_SITE_KEY $SECRETS) +VITE_SENTRY_IO_ID=$(kamal secrets extract VITE_SENTRY_IO_ID $SECRETS) +VITE_SENTRY_IO_ROUTE=$(kamal secrets extract VITE_SENTRY_IO_ROUTE $SECRETS) +NEW_RELIC_USER_KEY=$(kamal secrets extract NEW_RELIC_USER_KEY $SECRETS) +NEW_RELIC_API_KEY=$(kamal secrets extract NEW_RELIC_API_KEY $SECRETS) +NEW_RELIC_LICENSE_KEY=$(kamal secrets extract NEW_RELIC_LICENSE_KEY $SECRETS) +NEW_RELIC_ACCOUNT_ID=$(kamal secrets extract NEW_RELIC_ACCOUNT_ID $SECRETS) +TWILIO_ACCOUNT_SID=$(kamal secrets extract TWILIO_ACCOUNT_SID $SECRETS) +TWILIO_AUTH_TOKEN=$(kamal secrets extract TWILIO_AUTH_TOKEN $SECRETS) +TWILIO_VERIFY_SERVICE_SID=$(kamal secrets extract TWILIO_VERIFY_SERVICE_SID $SECRETS) +SENTRY_DSN=$(kamal secrets extract SENTRY_DSN $SECRETS) +SENTRY_ORG=$(kamal secrets extract SENTRY_ORG $SECRETS) +SENTRY_PROJECT=$(kamal secrets extract SENTRY_PROJECT $SECRETS) +SENTRY_AUTH_TOKEN=$(kamal secrets extract SENTRY_AUTH_TOKEN $SECRETS) +CONGRESS_GOV_API_KEY=$(kamal secrets extract CONGRESS_GOV_API_KEY $SECRETS) +OPEN_STATES_API_KEY=$(kamal secrets extract OPEN_STATES_API_KEY $SECRETS) +ADMIN_PHONES=$(kamal secrets extract ADMIN_PHONES $SECRETS) +SENDGRID_API_KEY=$(kamal secrets extract SENDGRID_API_KEY $SECRETS) +SWAY_DATABASE_PASSWORD=$(kamal secrets extract SWAY_DATABASE_PASSWORD $SECRETS) +SECRET_KEY_BASE=$(kamal secrets extract SECRET_KEY_BASE $SECRETS) +RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY $SECRETS) +VAPID_PRIVATE_KEY=$(kamal secrets extract VAPID_PRIVATE_KEY $SECRETS) +VAPID_PUBLIC_KEY=$(kamal secrets extract VAPID_PUBLIC_KEY $SECRETS) +TUNNEL_TOKEN=$(kamal secrets extract TUNNEL_TOKEN $SECRETS) \ No newline at end of file diff --git a/.node-version b/.node-version index cc7ce7f4..32cfab63 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -v22.13.1 +v22.18.0 diff --git a/.nvmrc b/.nvmrc index cc7ce7f4..32cfab63 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v22.13.1 +v22.18.0 diff --git a/.prettierignore b/.prettierignore index e0d5e88e..afb9d7c6 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,15 +1,32 @@ -./.vscode -./.firebase +./.bundle ./.github -./.certs +./.husky +./.kamal +./.rubocop +./.vscode + +bin/ +db/migrate/ +docker/ +log/ +node_modules/ +public/ +scripts/ +sorbet/ +storage/ +tmp/ +vendor/ + +db/schema.rb + +package.json +package-lock.json +Gemfile.lock -./keys **/**/node_modules **/build/** **/dist/** **/lib/** -./emulate_data/* -./firebase-export-*/* +**/**/*.log -**/**/*.log \ No newline at end of file diff --git a/.prettierrc b/.prettierrc index 6299f693..5f92b0a0 100644 --- a/.prettierrc +++ b/.prettierrc @@ -4,5 +4,45 @@ "printWidth": 120, "singleQuote": false, "bracketSpacing": true, - "arrowParens": "always" + "arrowParens": "always", + "plugins": ["@prettier/plugin-ruby"], + "semi": true, + "overrides": [ + { + "files": "**/*.rb", + "options": { + "semi": false, + "printWidth": 80, + "tabWidth": 2, + "singleQuote": true + } + }, + { + "files": "**/*.rbi", + "options": { + "semi": false, + "printWidth": 80, + "tabWidth": 2, + "singleQuote": true + } + }, + { + "files": "**/*.rake", + "options": { + "semi": false, + "printWidth": 80, + "tabWidth": 2, + "singleQuote": true + } + }, + { + "files": "Gemfile", + "options": { + "semi": false, + "printWidth": 80, + "tabWidth": 2, + "singleQuote": true + } + } + ] } diff --git a/.python-version b/.python-version deleted file mode 100644 index 2c073331..00000000 --- a/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.11 diff --git a/.rubocop.yml b/.rubocop.yml index 50941d4a..14ea09f5 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -2,21 +2,14 @@ inherit_mode: merge: - Exclude -require: - - standard - plugins: - rubocop-performance -inherit_gem: - standard: config/base.yml - standard-performance: config/base.yml - standard-custom: config/base.yml - inherit_from: - .rubocop/rails.yml - .rubocop/rspec.yml - .rubocop/strict.yml + - node_modules/@prettier/plugin-ruby/rubocop.yml AllCops: NewCops: disable @@ -25,3 +18,37 @@ AllCops: Exclude: - "db/migrate/*" - "sorbet/*" + +Lint/UnderscorePrefixedVariableName: + Enabled: false + +Style/GlobalStdStream: + Enabled: false + +Style/StringLiterals: + EnforcedStyle: double_quotes + +Layout/SpaceInsideHashLiteralBraces: + EnforcedStyle: space + +Layout/IndentationWidth: + Enable: true + Width: 2 + +Metrics/AbcSize: + Max: 50 + +Metrics/MethodLength: + Max: 50 + +Style/Documentation: + Enabled: false + +Style/FrozenStringLiteralComment: + Enabled: false + +Naming/PredicatePrefix: + Enabled: false + +Layout/LineLength: + Enabled: false diff --git a/.rubocop/rails.yml b/.rubocop/rails.yml index 0794eea7..77386eb1 100644 --- a/.rubocop/rails.yml +++ b/.rubocop/rails.yml @@ -1,6 +1,3 @@ -# Based on removed standard configuration: -# https://github.com/testdouble/standard/commit/94d133f477a5694084ac974d5ee01e8a66ce777e#diff-65478e10d5b2ef41c7293a110c0e6b7c - plugins: - rubocop-rails diff --git a/.ruby-version b/.ruby-version index 47b322c9..e69de29b 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +0,0 @@ -3.4.1 diff --git a/.vscode/settings.json b/.vscode/settings.json index c79259d3..da22f9d3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,8 @@ { "typescript.enablePromptUseWorkspaceTsdk": false, "typescript.tsdk": "node_modules/typescript/lib", - "editor.codeActionsOnSave": ["source.addMissingImports"] + "editor.codeActionsOnSave": ["source.addMissingImports"], + + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true } diff --git a/Gemfile b/Gemfile index 8c03a992..189cbd3b 100644 --- a/Gemfile +++ b/Gemfile @@ -2,7 +2,7 @@ source "https://rubygems.org" -ruby "3.4.1" +ruby "3.4.5" # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" # gem "rails", "~> 8" @@ -76,11 +76,11 @@ gem "faraday" # gem 'faraday_curl' # https://github.com/cedarcode/webauthn-ruby -gem "webauthn" +gem "webauthn", "~> 3" # phone/sms verification # https://www.twilio.com/docs/verify/sms -gem "twilio-ruby" +gem "twilio-ruby", "~> 7" # https://www.twilio.com/docs/sendgrid/for-developers/sending-email/quickstart-ruby # https://github.com/sendgrid/sendgrid-ruby @@ -114,13 +114,23 @@ gem "lograge" # Parse fetched xml data for US Congress votes gem "rexml" -gem "stackprof" -gem "sentry-ruby" -gem "sentry-rails" gem "newrelic_rpm" +gem "sentry-rails" +gem "sentry-ruby" +gem "stackprof" gem "solid_queue", "~> 1.1" +# https://github.com/yob/pdf-reader +# read vote tally PDFs from MD state legislator +gem "pdf-reader" + +group :production do + # https://github.com/modosc/cloudflare-rails + # To determine ip addresses in order to rate limit correctly + gem "cloudflare-rails" +end + group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem gem "debug", platforms: %i[mri windows] @@ -132,7 +142,7 @@ group :development, :test do # https://github.com/rspec/rspec-rails # Run against this stable release - gem "rspec-rails", "~> 7" + gem "rspec-rails", "~> 8" # https://github.com/thoughtbot/factory_bot_rails gem "factory_bot_rails" @@ -143,7 +153,7 @@ group :development, :test do # Generate types from gems # https://github.com/Shopify/tapioca - gem "tapioca", require: false + gem "tapioca", "~> 0.17", require: false end group :development do @@ -172,7 +182,18 @@ group :development do gem "better_errors" gem "binding_of_caller" - eval_gemfile "gemfiles/rubocop.gemfile" + gem "rubocop", "~> 1.80" + gem "rubocop-factory_bot" + gem "rubocop-performance" + gem "rubocop-rails" + gem "rubocop-rspec" + gem "rubocop-shopify" + gem "rubocop-thread_safety" + + gem "prettier_print" + gem "syntax_tree" + gem "syntax_tree-haml" + gem "syntax_tree-rbs" end group :test do @@ -181,4 +202,6 @@ group :test do gem "selenium-webdriver" gem "rails-controller-testing" + + gem "simplecov", require: false, group: :test end diff --git a/Gemfile.lock b/Gemfile.lock index e263985c..0cae87fa 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,7 @@ GEM remote: https://rubygems.org/ specs: + Ascii85 (2.0.1) actioncable (8.0.2.1) actionpack (= 8.0.2.1) activesupport (= 8.0.2.1) @@ -74,8 +75,9 @@ GEM uri (>= 0.13.1) addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) + afm (1.0.0) android_key_attestation (0.3.0) - annotaterb (4.18.0) + annotaterb (4.19.0) activerecord (>= 6.0.0) activesupport (>= 6.0.0) ast (2.4.3) @@ -85,7 +87,7 @@ GEM erubi (>= 1.0.0) rack (>= 0.9.0) rouge (>= 1.0.0) - bigdecimal (3.2.2) + bigdecimal (3.2.3) bindata (2.5.1) bindex (0.8.1) binding_of_caller (1.0.1) @@ -103,9 +105,14 @@ GEM regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) cbor (0.5.10.1) + cloudflare-rails (6.2.0) + actionpack (>= 7.1.0, < 8.1.0) + activesupport (>= 7.1.0, < 8.1.0) + railties (>= 7.1.0, < 8.1.0) + zeitwerk (>= 2.5.0) coderay (1.1.3) concurrent-ruby (1.3.5) - connection_pool (2.5.3) + connection_pool (2.5.4) cose (1.3.1) cbor (~> 0.5.9) openssl-signature_algorithm (~> 1.0) @@ -120,6 +127,7 @@ GEM diff-lcs (1.6.2) digest-crc (0.7.0) rake (>= 12.0.0, < 14.0.0) + docile (1.4.1) dotenv (3.1.8) drb (2.2.3) dry-cli (1.3.0) @@ -129,7 +137,7 @@ GEM tzinfo factory_bot (6.5.5) activesupport (>= 6.1.0) - factory_bot_rails (6.5.0) + factory_bot_rails (6.5.1) factory_bot (~> 6.5) railties (>= 6.1.0) faker (3.5.2) @@ -150,7 +158,7 @@ GEM csv (>= 3.0.0) globalid (1.2.1) activesupport (>= 6.1) - google-apis-core (1.0.1) + google-apis-core (1.0.2) addressable (~> 2.8, >= 2.8.7) faraday (~> 2.13) faraday-follow_redirects (~> 0.3) @@ -160,7 +168,7 @@ GEM retriable (~> 3.1) google-apis-iamcredentials_v1 (0.24.0) google-apis-core (>= 0.15.0, < 2.a) - google-apis-storage_v1 (0.55.0) + google-apis-storage_v1 (0.56.0) google-apis-core (>= 0.15.0, < 2.a) google-cloud-core (1.8.0) google-cloud-env (>= 1.0, < 3.a) @@ -179,17 +187,22 @@ GEM googleauth (~> 1.9) mini_mime (~> 1.0) google-logging-utils (0.2.0) - googleauth (1.14.0) + googleauth (1.15.0) faraday (>= 1.0, < 3.a) google-cloud-env (~> 2.2) google-logging-utils (~> 0.1) - jwt (>= 1.4, < 3.0) + jwt (>= 1.4, < 4.0) multi_json (~> 1.11) os (>= 0.9, < 2.0) signet (>= 0.16, < 2.a) + haml (6.3.0) + temple (>= 0.8.2) + thor + tilt + hashery (2.1.2) i18n (1.14.7) concurrent-ruby (~> 1.0) - inertia_rails (3.10.0) + inertia_rails (3.11.0) railties (>= 6) io-console (0.8.1) irb (1.15.2) @@ -218,7 +231,7 @@ GEM net-imap net-pop net-smtp - marcel (1.0.4) + marcel (1.1.0) matrix (0.4.3) method_source (1.1.0) mini_mime (1.1.5) @@ -229,7 +242,7 @@ GEM mutex_m (0.3.0) net-http (0.6.0) uri - net-imap (0.5.9) + net-imap (0.5.10) date net-protocol net-pop (0.1.2) @@ -239,13 +252,19 @@ GEM net-smtp (0.5.1) net-protocol netrc (0.11.0) - newrelic_rpm (9.20.0) + newrelic_rpm (9.21.0) nio4r (2.7.4) - nokogiri (1.18.9-arm64-darwin) + nokogiri (1.18.10-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.18.9-x86_64-darwin) + nokogiri (1.18.10-aarch64-linux-musl) racc (~> 1.4) - nokogiri (1.18.9-x86_64-linux-gnu) + nokogiri (1.18.10-arm64-darwin) + racc (~> 1.4) + nokogiri (1.18.10-x86_64-darwin) + racc (~> 1.4) + nokogiri (1.18.10-x86_64-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.10-x86_64-linux-musl) racc (~> 1.4) openssl (3.3.0) openssl-signature_algorithm (1.3.0) @@ -255,10 +274,17 @@ GEM parser (3.3.9.0) ast (~> 2.4.1) racc + pdf-reader (2.15.0) + Ascii85 (>= 1.0, < 3.0, != 2.0.0) + afm (>= 0.2.1, < 2) + hashery (~> 2.0) + ruby-rc4 + ttfunk pp (0.6.2) prettyprint + prettier_print (1.2.1) prettyprint (0.2.0) - prism (1.4.0) + prism (1.5.1) pry (0.15.2) coderay (~> 1.1) method_source (~> 1.0) @@ -266,11 +292,11 @@ GEM date stringio public_suffix (6.0.2) - puma (6.6.1) + puma (7.0.3) nio4r (~> 2.0) raabro (1.4.0) racc (1.8.1) - rack (3.2.0) + rack (3.2.1) rack-proxy (0.7.7) rack rack-session (2.1.1) @@ -318,12 +344,13 @@ GEM rbi (0.3.6) prism (~> 1.0) rbs (>= 3.4.4) - rbs (3.9.4) + rbs (4.0.0.dev.4) logger + prism (>= 1.3.0) rdoc (6.14.2) erb psych (>= 4.0.0) - regexp_parser (2.11.2) + regexp_parser (2.11.3) reline (0.6.2) io-console (~> 0.5) representable (3.2.0) @@ -332,8 +359,9 @@ GEM uber (< 0.2.0) request_store (1.7.0) rack (>= 1.4) + require-hooks (0.2.2) retriable (3.1.2) - rexml (3.4.1) + rexml (3.4.4) rgeo (3.0.1) rgeo-geojson (2.2.0) multi_json (~> 1.15) @@ -347,18 +375,18 @@ GEM rspec-mocks (3.13.5) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-rails (7.1.1) - actionpack (>= 7.0) - activesupport (>= 7.0) - railties (>= 7.0) + rspec-rails (8.0.2) + actionpack (>= 7.2) + activesupport (>= 7.2) + railties (>= 7.2) rspec-core (~> 3.13) rspec-expectations (~> 3.13) rspec-mocks (~> 3.13) rspec-support (~> 3.13) rspec-sorbet (1.9.2) sorbet-runtime - rspec-support (3.13.5) - rubocop (1.75.8) + rspec-support (3.13.6) + rubocop (1.80.2) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -366,7 +394,7 @@ GEM parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.44.0, < 2.0) + rubocop-ast (>= 1.46.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) rubocop-ast (1.46.0) @@ -375,17 +403,17 @@ GEM rubocop-factory_bot (2.27.1) lint_roller (~> 1.1) rubocop (~> 1.72, >= 1.72.1) - rubocop-performance (1.25.0) + rubocop-performance (1.26.0) lint_roller (~> 1.1) rubocop (>= 1.75.0, < 2.0) - rubocop-ast (>= 1.38.0, < 2.0) + rubocop-ast (>= 1.44.0, < 2.0) rubocop-rails (2.33.3) activesupport (>= 4.2.0) lint_roller (~> 1.1) rack (>= 1.1) rubocop (>= 1.75.0, < 2.0) rubocop-ast (>= 1.44.0, < 2.0) - rubocop-rspec (3.6.0) + rubocop-rspec (3.7.0) lint_roller (~> 1.1) rubocop (~> 1.72, >= 1.72.1) rubocop-shopify (2.17.1) @@ -395,7 +423,8 @@ GEM rubocop (~> 1.72, >= 1.72.1) rubocop-ast (>= 1.44.0, < 2.0) ruby-progressbar (1.13.0) - rubyzip (3.0.2) + ruby-rc4 (0.1.5) + rubyzip (3.1.0) safety_net_attestation (0.4.0) jwt (~> 2.0) securerandom (0.4.1) @@ -405,19 +434,25 @@ GEM rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 4.0) websocket (~> 1.0) - sentry-rails (5.26.0) + sentry-rails (5.27.0) railties (>= 5.0) - sentry-ruby (~> 5.26.0) - sentry-ruby (5.26.0) + sentry-ruby (~> 5.27.0) + sentry-ruby (5.27.0) bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) shortener (1.0.1) voight_kampff (~> 2.0) - signet (0.20.0) + signet (0.21.0) addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) - jwt (>= 1.5, < 3.0) + jwt (>= 1.5, < 4.0) multi_json (~> 1.10) + simplecov (0.22.0) + docile (~> 1.1) + simplecov-html (~> 0.11) + simplecov_json_formatter (~> 0.1) + simplecov-html (0.13.2) + simplecov_json_formatter (0.1.4) solid_queue (1.2.1) activejob (>= 7.1) activerecord (>= 7.1) @@ -425,18 +460,20 @@ GEM fugit (~> 1.11.0) railties (>= 7.1) thor (>= 1.3.1) - sorbet (0.5.12435) - sorbet-static (= 0.5.12435) - sorbet-runtime (0.5.12435) - sorbet-static (0.5.12435-universal-darwin) - sorbet-static (0.5.12435-x86_64-linux) - sorbet-static-and-runtime (0.5.12435) - sorbet (= 0.5.12435) - sorbet-runtime (= 0.5.12435) - spoom (1.6.3) + sorbet (0.6.12544) + sorbet-static (= 0.6.12544) + sorbet-runtime (0.6.12544) + sorbet-static (0.6.12544-aarch64-linux) + sorbet-static (0.6.12544-universal-darwin) + sorbet-static (0.6.12544-x86_64-linux) + sorbet-static-and-runtime (0.6.12544) + sorbet (= 0.6.12544) + sorbet-runtime (= 0.6.12544) + spoom (1.7.6) erubi (>= 1.10.0) prism (>= 0.28.0) rbi (>= 0.3.3) + rbs (>= 4.0.0.dev.4) rexml (>= 3.2.6) sorbet-static-and-runtime (>= 0.5.10187) thor (>= 0.19.2) @@ -451,46 +488,49 @@ GEM sqlite3 (2.7.3) mini_portile2 (~> 2.8.0) stackprof (0.2.27) - standard (1.50.0) - language_server-protocol (~> 3.17.0.2) - lint_roller (~> 1.0) - rubocop (~> 1.75.5) - standard-custom (~> 1.0.0) - standard-performance (~> 1.8) - standard-custom (1.0.2) - lint_roller (~> 1.0) - rubocop (~> 1.50) - standard-performance (1.8.0) - lint_roller (~> 1.1) - rubocop-performance (~> 1.25.0) stringio (3.1.7) - tapioca (0.16.11) + syntax_tree (6.3.0) + prettier_print (>= 1.2.0) + syntax_tree-haml (4.0.3) + haml (>= 5.2) + prettier_print (>= 1.2.1) + syntax_tree (>= 6.0.0) + syntax_tree-rbs (1.0.0) + prettier_print + rbs + syntax_tree (>= 2.0.1) + tapioca (0.17.7) benchmark bundler (>= 2.2.25) netrc (>= 0.11.0) parallel (>= 1.21.0) - rbi (~> 0.2) + rbi (>= 0.3.1) + require-hooks (>= 0.2.2) sorbet-static-and-runtime (>= 0.5.11087) - spoom (>= 1.2.0) + spoom (>= 1.7.0) thor (>= 1.2.0) yard-sorbet + temple (0.10.4) thor (1.4.0) + tilt (2.6.1) timeout (0.4.3) tpm-key_attestation (0.14.1) bindata (~> 2.4) openssl (> 2.0) openssl-signature_algorithm (~> 1.0) trailblazer-option (0.1.2) - twilio-ruby (7.7.1) + ttfunk (1.8.0) + bigdecimal (~> 3.1) + twilio-ruby (7.8.0) faraday (>= 0.9, < 3.0) jwt (>= 1.5, < 3.0) nokogiri (>= 1.6, < 2.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) uber (0.1.0) - unicode-display_width (3.1.5) - unicode-emoji (~> 4.0, >= 4.0.4) - unicode-emoji (4.0.4) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.1.0) uri (1.0.3) useragent (0.16.11) vite_rails (3.0.19) @@ -534,9 +574,15 @@ GEM zeitwerk (2.7.3) PLATFORMS + aarch64-linux + aarch64-linux-gnu + aarch64-linux-musl arm64-darwin universal-darwin + x86_64-darwin x86_64-linux + x86_64-linux-gnu + x86_64-linux-musl DEPENDENCIES annotaterb @@ -544,6 +590,7 @@ DEPENDENCIES binding_of_caller bootsnap capybara + cloudflare-rails concurrent-ruby debug dotenv @@ -556,6 +603,8 @@ DEPENDENCIES jbuilder lograge newrelic_rpm + pdf-reader + prettier_print pry puma (>= 5) rails (~> 8) @@ -563,36 +612,39 @@ DEPENDENCIES rexml rgeo rgeo-geojson - rspec-rails (~> 7) + rspec-rails (~> 8) rspec-sorbet - rubocop! - rubocop-factory_bot! - rubocop-performance! - rubocop-rails! - rubocop-rspec! - rubocop-shopify! - rubocop-thread_safety! + rubocop (~> 1.80) + rubocop-factory_bot + rubocop-performance + rubocop-rails + rubocop-rspec + rubocop-shopify + rubocop-thread_safety selenium-webdriver sentry-rails sentry-ruby shortener + simplecov solid_queue (~> 1.1) sorbet sorbet-runtime sprockets-rails sqlite3 (~> 2) stackprof - standard! - tapioca - twilio-ruby + syntax_tree + syntax_tree-haml + syntax_tree-rbs + tapioca (~> 0.17) + twilio-ruby (~> 7) tzinfo-data vite_rails web-console web-push - webauthn + webauthn (~> 3) RUBY VERSION - ruby 3.4.1p0 + ruby 3.4.5p51 BUNDLED WITH - 2.6.3 + 2.6.9 diff --git a/Procfile.dev b/Procfile.dev index 50487147..5233104a 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -1,3 +1,4 @@ -vite: bin/vite dev -web: bin/rails server -b 'ssl://localhost:3000?key=config/ssl/key.pem&cert=config/ssl/cert.pem&verify_mode=none' +vite: npm run start +#web: bin/rails server -b 'ssl://localhost:3333?key=config/ssl/key.pem&cert=config/ssl/cert.pem&verify_mode=none' +jobs: bin/jobs diff --git a/README.md b/README.md index 83d034d4..b5f1bfda 100644 --- a/README.md +++ b/README.md @@ -235,7 +235,7 @@ bundle exec rails db:seed #### Run Rails ```zsh -bin/rails server -b 'ssl://localhost:3000?key=config/ssl/key.pem&cert=config/ssl/cert.pem&verify_mode=none' +bin/rails server -b 'ssl://localhost:3333?key=config/ssl/key.pem&cert=config/ssl/cert.pem&verify_mode=none' ``` 2. In a second terminal window/tab/pane: @@ -254,7 +254,7 @@ npm install #### Browser -Open your browser to [https://localhost:3000](https://localhost:3000) to begin working with Sway. +Open your browser to [https://localhost:3333](https://localhost:3333) to begin working with Sway. ## Copyright / License diff --git a/app/controllers/admin/bills/creator_controller.rb b/app/controllers/admin/bills/creator_controller.rb deleted file mode 100644 index 29f7abd9..00000000 --- a/app/controllers/admin/bills/creator_controller.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -module Admin - module Bills - class CreatorController < ApplicationController - before_action :verify_is_admin - - def index - render inertia: "BillOfTheWeekCreatorPage" - end - end - end -end diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb index 8f732811..9d14300f 100644 --- a/app/controllers/admin_controller.rb +++ b/app/controllers/admin_controller.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true class AdminController < ApplicationController - before_action :verify_is_admin + before_action :verify_is_sway_admin end diff --git a/app/controllers/api_keys_controller.rb b/app/controllers/api_keys_controller.rb index d24a23bd..8f8c5bef 100644 --- a/app/controllers/api_keys_controller.rb +++ b/app/controllers/api_keys_controller.rb @@ -4,9 +4,7 @@ class ApiKeysController < ApplicationController # prepend_before_action :authenticate_with_api_key!, only: %i[index destroy] def index - render_component(Pages::API_KEYS, { - api_keys: current_user.api_keys - }) + render_component(Pages::API_KEYS, { api_keys: current_user.api_keys }) end def create @@ -14,10 +12,7 @@ def create api_key = current_user.api_keys.create!(token: SecureRandom.hex) flash[:notice] = "API Key Created!" - render json: { - **api_key.attributes, - token: api_key.token - }, status: :ok + render json: { **api_key.attributes, token: api_key.token }, status: :ok else flash[:alert] = "You may only have 1 API Key." route_component(api_keys_path) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 3ff72982..2a35ea64 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -10,8 +10,11 @@ class ApplicationController < ActionController::Base include Pages include SwayRoutes + rate_limit(to: 200, within: 1.minute, by: -> { request.domain }) + # https://inertia-rails.dev/guide/csrf-protection#handling-mismatches - rescue_from ActionController::InvalidAuthenticityToken, with: :inertia_page_expired_error + rescue_from ActionController::InvalidAuthenticityToken, + with: :inertia_page_expired_error # https://api.rubyonrails.org/classes/ActionController/RequestForgeryProtection/ClassMethods.html protect_from_forgery with: :exception, prepend: true @@ -19,30 +22,39 @@ class ApplicationController < ActionController::Base # newrelic_ignore_enduser before_action :is_api_request_and_is_route_api_accessible? - before_action :authenticate_user! + before_action :authenticate_sway_user! before_action :set_sway_locale_id_in_session - T::Configuration.inline_type_error_handler = lambda do |error, _opts| - Rails.logger.error error - end + after_action :add_rsl_license_header + + inertia_config( + # ..........*......DEPRECATION WARNING: To comply with the Inertia protocol, an empty errors hash `{errors: {}}` will be included to all responses by default starting with InertiaRails 4.0. To opt-in now, set `config.always_include_errors_hash = true`. To disable this warning, set it to `false`. (called from ApplicationController#render_component at /Users/dave/plebtech/sway/app/controllers/application_controller.rb:42) + always_include_errors_hash: true, + ) + + T::Configuration.inline_type_error_handler = + lambda { |error, _opts| Rails.logger.error error } - helper_method :current_user, :current_sway_locale, :verify_is_admin + helper_method :current_user, + :current_sway_locale, + :verify_is_sway_admin, + :invited_by_id @@_ssr_methods = {} - sig do - params( - page: T.nilable(String), - props: T.untyped - ).returns(T.untyped) - end + sig { params(page: T.nilable(String), props: T.untyped).returns(T.untyped) } def render_component(page, props = {}) return render_component(Pages::HOME) if page.nil? render(inertia: page, props: expand_props(props)) end - sig { params(route: T.nilable(String), new_params: T::Hash[T.any(String, Symbol), T.anything]).returns(T.untyped) } + sig do + params( + route: T.nilable(String), + new_params: T::Hash[T.any(String, Symbol), T.anything], + ).returns(T.untyped) + end def route_component(route, new_params = {}) return route_component(SwayRoutes::HOME) if route.nil? @@ -50,7 +62,7 @@ def route_component(route, new_params = {}) Rails.logger.info "ServerRendering.route - Route to page - #{route}" - render json: {route:, phone:, params: new_params} + render json: { route:, phone:, params: new_params } # end end @@ -58,15 +70,18 @@ def route_component(route, new_params = {}) inertia_share do { - user: current_user&.to_sway_json&.merge({ - address: current_user&.address&.attributes - }), + user: + current_user&.to_sway_json&.merge( + { address: current_user&.address&.attributes }, + ), sway_locale: current_sway_locale&.to_sway_json, - sway_locales: current_user&.sway_locales&.map(&:to_sway_json) || SwayLocale.all&.map(&:to_sway_json), + sway_locales: + current_user&.sway_locales&.map(&:to_sway_json) || + SwayLocale.all&.map(&:to_sway_json), params: { sway_locale_id: params[:sway_locale_id], - errors: params[:errros] - } + errors: params[:errros], + }, } end @@ -74,17 +89,20 @@ def route_component(route, new_params = {}) def sign_in(user) return if user.blank? - invited_by_id = cookies.permanent[UserInviter::INVITED_BY_SESSION_KEY] + _invited_by_id = invited_by_id # Reset session on sign_in to prevent session fixation attacks # https://guides.rubyonrails.org/security.html#session-fixation-countermeasures reset_session # Need to persist this value through registration - cookies.permanent[UserInviter::INVITED_BY_SESSION_KEY] = invited_by_id + cookies.permanent[UserInviter::INVITED_BY_SESSION_KEY] = _invited_by_id begin - cookies.encrypted[:refresh_token] = RefreshToken.for(user, request).as_cookie + cookies.encrypted[:refresh_token] = RefreshToken.for( + user, + request, + ).as_cookie session[:user_id] = user.id user.sign_in_count = user.sign_in_count + 1 @@ -94,10 +112,10 @@ def sign_in(user) user.current_sign_in_ip = request.remote_ip user.save - if user.is_registration_complete - cookies.permanent[:sway_locale_id] = user.default_sway_locale&.id - end - rescue => e + cookies.permanent[ + :sway_locale_id + ] = user.default_sway_locale&.id if user.is_registration_complete + rescue StandardError => e reset_session cookies.clear raise e @@ -118,21 +136,32 @@ def current_user sig { returns(T.nilable(SwayLocale)) } def current_sway_locale - @_current_sway_locale ||= find_current_sway_locale + @current_sway_locale ||= find_current_sway_locale + end + + def invited_by_id + Rails.logger.info "Getting invited_by_id from cookies: #{cookies.permanent[UserInviter::INVITED_BY_SESSION_KEY]}" + cookies.permanent[UserInviter::INVITED_BY_SESSION_KEY] end def authenticate_with_cookies u = User.find_by(id: session[:user_id]) if u.nil? && cookies.encrypted[:refresh_token].present? - current_refresh_token = RefreshToken.find_by(token: cookies.encrypted[:refresh_token]) + current_refresh_token = + RefreshToken.find_by(token: cookies.encrypted[:refresh_token]) if current_refresh_token&.is_valid?(request) - Rails.logger.info("authenticate_with_cookies - refreshing User with Refresh Token") + Rails.logger.info( + "authenticate_with_cookies - refreshing User with Refresh Token", + ) u = current_refresh_token.user if u.present? session[:user_id] = u.id - cookies.encrypted[:refresh_token] = RefreshToken.for(u, request).as_cookie + cookies.encrypted[:refresh_token] = RefreshToken.for( + u, + request, + ).as_cookie end end end @@ -142,22 +171,26 @@ def authenticate_with_cookies sig { void } def is_api_request_and_is_route_api_accessible? if request.path.starts_with?("/api/admin/") - unless authenticate_with_api_key! && current_user&.is_admin? + unless authenticate_with_api_key! && current_user&.is_sway_admin? render json: { - message: "Missing API Key. Include it an Authorization header." - }, status: :accepted + message: + "Missing API Key. Include it an Authorization header.", + }, + status: :accepted end elsif request.path.starts_with?("/api/") unless authenticate_with_api_key! render json: { - message: "Missing API Key. Include it an Authorization header." - }, status: :accepted + message: + "Missing API Key. Include it an Authorization header.", + }, + status: :accepted end end end sig { void } - def authenticate_user! + def authenticate_sway_user! u = current_user if u.nil? Rails.logger.info "No current user, redirect to root path" @@ -174,8 +207,8 @@ def authenticate_user! end sig { void } - def verify_is_admin - redirect_to root_path unless current_user&.is_admin? + def verify_is_sway_admin + redirect_to root_path unless current_user&.is_sway_admin? end private @@ -188,12 +221,24 @@ def set_sway_locale_id_in_session def find_current_sway_locale SwayLocale.find_by(id: cookies.permanent[:sway_locale_id]) || - SwayLocale.find_by_name(params[:sway_locale_name]) || # # rubocop:disable Rails/DynamicFindBy, set in query string for sharing - current_user&.default_sway_locale || - SwayLocale.default_locale # congress + SwayLocale.find_by_name(params[:sway_locale_name]) || # query string for sharing + current_user&.default_sway_locale || SwayLocale.default_locale # congress end def inertia_page_expired_error - redirect_back_or_to("/", allow_other_host: false, notice: "The page expired, please try again.") + redirect_back_or_to( + "/", + allow_other_host: false, + notice: "The page expired, please try again.", + ) + end + + # https://rslstandard.org/guide/http + # https://arstechnica.com/tech-policy/2025/09/pay-per-output-ai-firms-blindsided-by-beefed-up-robots-txt-instructions/ + def add_rsl_license_header + response.set_header( + "Link", + 'https://www.sway.vote/license.xml; rel="license"; type="application/rsl+xml', + ) end end diff --git a/app/controllers/bill_of_the_week_controller.rb b/app/controllers/bill_of_the_week_controller.rb index d9c20be9..557ffd33 100644 --- a/app/controllers/bill_of_the_week_controller.rb +++ b/app/controllers/bill_of_the_week_controller.rb @@ -2,22 +2,33 @@ # typed: true class BillOfTheWeekController < ApplicationController - skip_before_action :authenticate_user!, only: %i[index] + skip_before_action :authenticate_sway_user!, only: %i[index] def index - b = T.cast(Bill.of_the_week(sway_locale: current_sway_locale), T.nilable(T.any(Bill, T::Array[Bill]))) + b = + T.cast( + Bill.of_the_week(sway_locale: current_sway_locale), + T.nilable(T.any(Bill, T::Array[Bill])), + ) b = b.first if b.is_a?(Array) if b.present? - render_component(Pages::BILL_OF_THE_WEEK, -> { - to_render = b.render(current_user, current_sway_locale) - if params[:with]&.include?("legislator") - to_render[:legislator] = current_user&.legislators(current_sway_locale)&.first - end - to_render - }) + render_component( + Pages::BILL_OF_THE_WEEK, + -> do + to_render = b.render(current_user, current_sway_locale) + if params[:with]&.include?("legislator") + to_render[:legislator] = current_user&.legislators( + current_sway_locale, + )&.first + end + to_render + end, + ) else - flash[:notice] = "No Bill of the Week Available for #{current_sway_locale&.name}. Redirecting to Bills." + flash[ + :notice + ] = "No Bill of the Week Available for #{current_sway_locale&.name}. Redirecting to Bills." redirect_to bills_path end end diff --git a/app/controllers/bill_of_the_week_schedule_controller.rb b/app/controllers/bill_of_the_week_schedule_controller.rb index d1b28a5d..15e812a8 100644 --- a/app/controllers/bill_of_the_week_schedule_controller.rb +++ b/app/controllers/bill_of_the_week_schedule_controller.rb @@ -3,26 +3,52 @@ class BillOfTheWeekScheduleController < ApplicationController def update if @bill.present? - if @bill.update(scheduled_release_date_utc: bill_of_the_week_schedule_params[:scheduled_release_date_utc]) - flash[:notice] = @bill.scheduled_release_date_utc.blank? ? "Bill - #{@bill.title} - removed from schedule." : "Added bill - #{@bill.title} - to schedule." - route_component(edit_bill_path(@bill.id, tab_key: bill_of_the_week_schedule_params[:tab_key])) + if @bill.update( + scheduled_release_date_utc: + bill_of_the_week_schedule_params[:scheduled_release_date_utc], + ) + flash[:notice] = ( + if @bill.scheduled_release_date_utc.blank? + "Bill - #{@bill.title} - removed from schedule." + else + "Added bill - #{@bill.title} - to schedule." + end + ) + route_component( + edit_bill_path( + @bill.id, + tab_key: bill_of_the_week_schedule_params[:tab_key], + ), + ) else flash[:alert] = "Failed to update bill schedule." - render_component(Pages::BILL_CREATOR, {errors: @bill.errors}) + render_component(Pages::BILL_CREATOR, { errors: @bill.errors }) end else flash[:alert] = "Failed to update bill schedule. Bill not found." - route_component(edit_bill_path(@bill.id, tab_key: bill_of_the_week_schedule_params[:tab_key])) + route_component( + edit_bill_path( + @bill.id, + tab_key: bill_of_the_week_schedule_params[:tab_key], + ), + ) end end private def bill_of_the_week_schedule_params - params.require(:bill_of_the_week_schedule).permit(:bill_id, :scheduled_release_date_utc, :tab_key) + params.require(:bill_of_the_week_schedule).permit( + :bill_id, + :scheduled_release_date_utc, + :tab_key, + ) end def set_bill - @bill = Bill.includes(:sway_locale).find(bill_of_the_week_schedule_params[:bill_id]) + @bill = + Bill.includes(:sway_locale).find( + bill_of_the_week_schedule_params[:bill_id], + ) end end diff --git a/app/controllers/bill_score_districts_controller.rb b/app/controllers/bill_score_districts_controller.rb index 8e5ca178..9c3784bc 100644 --- a/app/controllers/bill_score_districts_controller.rb +++ b/app/controllers/bill_score_districts_controller.rb @@ -4,8 +4,11 @@ class BillScoreDistrictsController < ApplicationController # GET /bill_score_districts/1 or /bill_score_districts/1.json def show - render json: BillScoreDistrict.where(bill_score_id: bill_score_district_params[:bill_score_id]).map(&:to_sway_json), - status: :ok + render json: + BillScoreDistrict.where( + bill_score_id: bill_score_district_params[:bill_score_id], + ).map(&:to_sway_json), + status: :ok end private diff --git a/app/controllers/bill_scores_controller.rb b/app/controllers/bill_scores_controller.rb index d6f9f939..3b05a9c0 100644 --- a/app/controllers/bill_scores_controller.rb +++ b/app/controllers/bill_scores_controller.rb @@ -2,16 +2,14 @@ # typed: true class BillScoresController < ApplicationController - skip_before_action :authenticate_user!, only: %i[show] + skip_before_action :authenticate_sway_user!, only: %i[show] def show - render json: BillScore.find_by(bill_id: params[:id])&.to_builder_with_user(current_user)&.attributes!, status: :ok - end - - private - - # Only allow a list of trusted parameters through. - def bill_score_params - params.require(:bill_score).permit(:bill_id) + render json: + BillScore + .find_by(bill_id: params[:id]) + &.to_builder_with_user(current_user) + &.attributes!, + status: :ok end end diff --git a/app/controllers/bills_controller.rb b/app/controllers/bills_controller.rb index eb1aa92a..0734c8a7 100644 --- a/app/controllers/bills_controller.rb +++ b/app/controllers/bills_controller.rb @@ -4,38 +4,59 @@ class BillsController < ApplicationController include SwayGoogleCloudStorage - skip_before_action :authenticate_user!, only: %i[index show] + skip_before_action :authenticate_sway_user!, only: %i[index show] - before_action :verify_is_admin, only: %i[new edit create update destroy] + before_action :verify_is_sway_admin, only: %i[new edit create update destroy] before_action :set_bill, only: %i[show edit update destroy] + before_action :meta_title # GET /bills or /bills.json def index user_votes_by_bill_id = current_user&.user_votes&.index_by(&:bill_id) - render_component(Pages::BILLS, lambda do - { - bills: Bill.previous(current_sway_locale).map do |bill| - bill.to_sway_json.merge({ - user_vote: user_votes_by_bill_id&.dig(bill.id), - bill_score: bill.bill_score&.to_builder_with_user(current_user)&.attributes!&.except("is_a?") - }) - end, - districts: current_user&.districts(current_sway_locale)&.map(&:to_sway_json) || [] - } - end) + render_component( + Pages::BILLS, + lambda do + { + bills: + Bill + .previous(current_sway_locale) + .map do |bill| + bill.to_sway_json.merge( + { + user_vote: user_votes_by_bill_id&.dig(bill.id), + bill_score: + bill + .bill_score + &.to_builder_with_user(current_user) + &.attributes! + &.except("is_a?"), + }, + ) + end, + districts: + current_user&.districts(current_sway_locale)&.map(&:to_sway_json) || + [], + } + end, + ) end # GET /bills/1 or /bills/1.json def show if @bill.present? - render_component(Pages::BILL, -> { - to_render = @bill.render(current_user, current_sway_locale) - if params[:with]&.include?("legislator") - to_render[:legislator] = current_user&.legislators(current_sway_locale)&.first - end - to_render - }) + render_component( + Pages::BILL, + -> do + to_render = @bill.render(current_user, current_sway_locale) + if params[:with]&.include?("legislator") + to_render[:legislator] = current_user&.legislators( + current_sway_locale, + )&.first + end + to_render + end, + ) else redirect_to bills_path end @@ -45,69 +66,97 @@ def show # GET /bills/new def new - render_component(Pages::BILL_CREATOR, { - bills: current_sway_locale&.bills&.map(&:to_sway_json), - bill: Bill.new.attributes, - legislators: current_sway_locale&.legislators&.map(&:to_sway_json), - legislator_votes: [], - organizations: Organization.where(sway_locale: current_sway_locale).map(&:to_sway_json), - tab_key: params[:tab_key] - }) + render_component( + Pages::BILL_CREATOR, + { + bills: Bill.current_session(current_sway_locale)&.map(&:to_sway_json), + bill: Bill.new.attributes, + legislators: current_sway_locale&.legislators&.map(&:to_sway_json), + legislator_votes: [], + organizations: + Organization.where(sway_locale: current_sway_locale).map( + &:to_sway_json + ), + tab_key: params[:tab_key], + }, + ) end # GET /bills/1/edit def edit return redirect_to new_bill_path if @bill.blank? || @bill.id.blank? - render_component(Pages::BILL_CREATOR, { - bills: current_sway_locale&.bills&.map(&:to_sway_json), - bill: @bill.to_sway_json.tap do |b| - b[:organizations] = @bill.organizations.map(&:to_sway_json) - end, - legislators: current_sway_locale&.legislators&.filter do |l| - if current_sway_locale&.congress? && @bill.external_id.starts_with?("PN") - l.active && l.title.starts_with?("Sen") - else - l.active - end - end&.map(&:to_sway_json), - legislator_votes: @bill.legislator_votes.map(&:to_sway_json), - organizations: Organization.where(sway_locale: current_sway_locale).map(&:to_sway_json), - tab_key: params[:tab_key] - }) + render_component( + Pages::BILL_CREATOR, + { + bills: Bill.current_session(current_sway_locale)&.map(&:to_sway_json), + bill: + @bill.to_sway_json.tap do |b| + b[:organizations] = @bill.organizations.map(&:to_sway_json) + end, + legislators: + current_sway_locale + &.legislators + &.filter do |l| + if current_sway_locale&.congress? && + @bill.external_id.starts_with?("PN") + l.active && !l.title.starts_with?("Rep") + else + l.active + end + end + &.map(&:to_sway_json), + legislator_votes: @bill.legislator_votes.map(&:to_sway_json), + organizations: + Organization.where(sway_locale: current_sway_locale).map( + &:to_sway_json + ), + tab_key: params[:tab_key], + }, + ) end # POST /bills or /bills.json def create - b = Bill.find_by( - external_id: bill_params[:external_id], - sway_locale_id: bill_params[:sway_locale_id] || current_sway_locale&.id - ) - if b.nil? - b = Bill.new(**bill_params.except(*vote_params)) - end + b = + Bill.find_by( + external_id: bill_params[:external_id], + sway_locale_id: bill_params[:sway_locale_id] || current_sway_locale&.id, + ) + b = Bill.new(**bill_params.except(*vote_params)) if b.nil? b.legislator = Legislator.find(bill_params[:legislator_id]) if b.save create_vote(b) - route_component(edit_bill_path(b.id, {saved: "Bill Created", event_key: "legislator_votes"})) + route_component( + edit_bill_path( + b.id, + { saved: "Bill Created", event_key: "legislator_votes" }, + ), + ) else - Rails.logger.error("Error saving bill - #{b.errors.flat_map(&:message).join(" | ")}") - redirect_to new_bill_path({event_key: "bill"}), inertia: { - errors: b.errors - } + Rails.logger.error( + "Error saving bill - #{b.errors.flat_map(&:message).join(" | ")}", + ) + redirect_to new_bill_path({ event_key: "bill" }), + inertia: { + errors: b.errors, + } end rescue Exception => e # rubocop:disable Lint/RescueException Rails.logger.error(e) - redirect_to new_bill_path({event_key: "bill"}), inertia: {errors: {external_id: e}} + redirect_to new_bill_path({ event_key: "bill" }), + inertia: { + errors: { + external_id: e, + }, + } end # PATCH/PUT /bills/1 or /bills/1.json def update - if @bill.blank? - return redirect_to new_bill_path({event_key: "bill"}) - end + return redirect_to new_bill_path({ event_key: "bill" }) if @bill.blank? current_audio_path = @bill.audio_bucket_path.freeze if @bill.update(bill_params.except(*vote_params)) @@ -115,11 +164,17 @@ def update create_vote(@bill) - route_component(edit_bill_path(@bill.id, {saved: "Bill Updated", event_key: "legislator_votes"})) + route_component( + edit_bill_path( + @bill.id, + { saved: "Bill Updated", event_key: "legislator_votes" }, + ), + ) else - redirect_to edit_bill_path(@bill.id, {event_key: "bill"}), inertia: { - errors: @bill.errors - } + redirect_to edit_bill_path(@bill.id, { event_key: "bill" }), + inertia: { + errors: @bill.errors, + } end end @@ -130,10 +185,39 @@ def destroy new end + def meta_title + @meta_title = + case action_name + when "index" + if current_sway_locale.present? + "#{current_sway_locale&.human_name} Legislation" + else + "Sway Legislation" + end + when "show" + if @bill.present? && current_sway_locale.present? + "#{@bill.external_id} - #{current_sway_locale&.human_name}" + else + "Sway Legislation" + end + else + "Sway" + end + end + private def set_bill - @bill = T.let(Bill.includes(:legislator_votes, :organization_bill_positions, :legislator, :sway_locale).find(params[:id]), T.nilable(Bill)) + @bill = + T.let( + Bill.includes( + :legislator_votes, + :organization_bill_positions, + :legislator, + :sway_locale, + ).find(params[:id]), + T.nilable(Bill), + ) end def remove_audio(audio_path) @@ -141,16 +225,21 @@ def remove_audio(audio_path) delete_file( bucket_name: SwayGoogleCloudStorage::BUCKETS[:ASSETS], - file_name: audio_path + file_name: audio_path, ) end def create_vote(b) - return unless bill_params[:house_roll_call_vote_number] || bill_params[:senate_roll_call_vote_number] + unless bill_params[:house_roll_call_vote_number] || + bill_params[:senate_roll_call_vote_number] + return + end - Vote.find_or_create_by!(bill_id: b.id, + Vote.find_or_create_by!( + bill_id: b.id, house_roll_call_vote_number: bill_params[:house_roll_call_vote_number], - senate_roll_call_vote_number: bill_params[:senate_roll_call_vote_number]) + senate_roll_call_vote_number: bill_params[:senate_roll_call_vote_number], + ) end # Only allow a list of trusted parameters through. @@ -175,39 +264,56 @@ def bill_params :house_roll_call_vote_number, :senate_roll_call_vote_number, :audio_bucket_path, - :audio_by_line + :audio_by_line, ) end def vote_params - [ - :house_roll_call_vote_number, - :senate_roll_call_vote_number - ] + %i[house_roll_call_vote_number senate_roll_call_vote_number] end def legislator_vote_params - params.require(:legislator_votes).map do |p| - p.transform_keys(&:underscore).permit(:legislator_id, :bill_id, :support) - end + params + .require(:legislator_votes) + .map do |p| + p.transform_keys(&:underscore).permit( + :legislator_id, + :bill_id, + :support, + ) + end end def organizations_params - params.require(:organizations).map do |p| - p.transform_keys(&:underscore).permit( - :id, :sway_locale_id, :name, :icon_path, positions: [:id, :bill_id, :summary, :support] - ) - end + params + .require(:organizations) + .map do |p| + p.transform_keys(&:underscore).permit( + :id, + :sway_locale_id, + :name, + :icon_path, + positions: %i[id bill_id summary support], + ) + end end def params super.transform_keys(&:underscore) end - sig { params(organization: Organization, current_icon_path: T.nilable(String)).void } + sig do + params( + organization: Organization, + current_icon_path: T.nilable(String), + ).void + end def remove_icon(organization, current_icon_path) return unless organization.icon_path != current_icon_path - delete_file(bucket_name: SwayGoogleCloudStorage::BUCKETS[:ASSETS], file_name: current_icon_path) + delete_file( + bucket_name: SwayGoogleCloudStorage::BUCKETS[:ASSETS], + file_name: current_icon_path, + ) end end diff --git a/app/controllers/buckets/assets_controller.rb b/app/controllers/buckets/assets_controller.rb index 16a4f2a8..2eec588f 100644 --- a/app/controllers/buckets/assets_controller.rb +++ b/app/controllers/buckets/assets_controller.rb @@ -6,16 +6,21 @@ class AssetsController < ApplicationController extend T::Sig include SwayGoogleCloudStorage - before_action :verify_is_admin, only: %i[create] + before_action :verify_is_sway_admin # Upload a file to the assets bucket in GCP # return the new file location def create render json: { - bucket_file_path: file_name, - url: generate_put_signed_url_v4(bucket_name: SwayGoogleCloudStorage::BUCKETS[:ASSETS], file_name:, - content_type: buckets_assets_params[:mime_type]) - }, status: :ok + bucket_file_path: file_name, + url: + generate_put_signed_url_v4( + bucket_name: SwayGoogleCloudStorage::BUCKETS[:ASSETS], + file_name:, + content_type: buckets_assets_params[:mime_type], + ), + }, + status: :ok end private diff --git a/app/controllers/concerns/api_key_authenticatable.rb b/app/controllers/concerns/api_key_authenticatable.rb index acd4a183..25afc19d 100644 --- a/app/controllers/concerns/api_key_authenticatable.rb +++ b/app/controllers/concerns/api_key_authenticatable.rb @@ -9,8 +9,7 @@ module ApiKeyAuthenticatable include ActionController::HttpAuthentication::Basic::ControllerMethods include ActionController::HttpAuthentication::Token::ControllerMethods - attr_reader :current_api_key - attr_reader :current_bearer + attr_reader :current_api_key, :current_bearer # Use this to raise an error and automatically respond with a 401 HTTP status # code when API key authentication fails @@ -31,10 +30,9 @@ def authenticate_with_api_key private - attr_writer :current_api_key - attr_writer :current_bearer + attr_writer :current_api_key, :current_bearer - def authenticator(http_token, options) + def authenticator(http_token, _options) @current_api_key = ApiKey.find_by(token_digest: http_token) @current_api_key = ApiKey.authenticate_by_token(http_token) diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 0ba4e6f4..cc33e6de 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -46,10 +46,13 @@ def send_phone_verification(session, phone_) end begin - verification = twilio_client.verify.v2.services(service_sid).verifications.create( - to: "+1#{phone}", - channel: "sms" - ) + verification = + twilio_client + .verify + .v2 + .services(service_sid) + .verifications + .create(to: "+1#{phone}", channel: "sms") session[:phone] = phone if verification.present? @@ -65,10 +68,13 @@ def send_email_verification(session, email) return false unless session.present? && email.present? begin - verification = twilio_client.verify.v2.services(service_sid).verifications.create( - to: email, - channel: "email" - ) + verification = + twilio_client + .verify + .v2 + .services(service_sid) + .verifications + .create(to: email, channel: "email") session[:email] = email if verification.present? diff --git a/app/controllers/concerns/default_meta_taggable.rb b/app/controllers/concerns/default_meta_taggable.rb index e920e52e..042697ba 100644 --- a/app/controllers/concerns/default_meta_taggable.rb +++ b/app/controllers/concerns/default_meta_taggable.rb @@ -1,11 +1,7 @@ module DefaultMetaTaggable extend ActiveSupport::Concern - included do - inertia_share do - meta_inertia_data - end - end + included { inertia_share { meta_inertia_data } } def meta_title @meta_title || "Sway" @@ -28,6 +24,7 @@ def default_meta_tags { "description" => meta_description, "author" => "Plebeian Technologies, Inc.", + "title" => meta_title, "og:description" => meta_description, "og:type" => "website", "og:title" => meta_title, @@ -37,22 +34,23 @@ def default_meta_tags "twitter:title" => meta_title, "twitter:description" => meta_description, "twitter:image" => "https://sway.vote/images/sway-us-light.png", - "twitter:card" => "summary_large_image" + "twitter:card" => "summary_large_image", } end def meta_inertia_data { title: -> { meta_title }, - meta: lambda { - meta_tags.map do |tag_name, value| - id_key = tag_name.start_with?("og") ? :property : :name - {}.tap do |h| - h[id_key] = tag_name.to_s - h[:content] = value + meta: + lambda do + meta_tags.map do |tag_name, value| + id_key = tag_name.start_with?("og") ? :property : :name + {}.tap do |h| + h[id_key] = tag_name.to_s + h[:content] = value + end end - end - } + end, } end end diff --git a/app/controllers/concerns/pages.rb b/app/controllers/concerns/pages.rb index 2784fe46..f847ebcc 100644 --- a/app/controllers/concerns/pages.rb +++ b/app/controllers/concerns/pages.rb @@ -1,13 +1,20 @@ +# frozen_string_literal: true + module Pages API_KEYS = "ApiKeys" BILL = "Bill" BILLS = "Bills" BILL_CREATOR = "BillOfTheWeekCreatorPage" BILL_OF_THE_WEEK = "BillOfTheWeek" + GEOCODER = "geocoder" HOME = "Home" INFLUENCE = "Influence" INVITE = "Invite" LEGISLATORS = "Legislators" NOTIFICATIONS = "Notifications" REGISTRATION = "Registration" + USER_ORGANIZATION_MEMBERSHIPS = + "organizations/UserOrganizationMemberships_List" + USER_ORGANIZATION_MEMBERSHIP = "organizations/UserOrganizationMembership" + NEW_USER_ORGANIZATION_POSITION = "organizations/NewUserOrganizationPosition" end diff --git a/app/controllers/concerns/relying_party.rb b/app/controllers/concerns/relying_party.rb index 7751f5b8..fb762b86 100644 --- a/app/controllers/concerns/relying_party.rb +++ b/app/controllers/concerns/relying_party.rb @@ -9,14 +9,15 @@ module RelyingParty included do def relying_party - Rails.logger.info("RelyingParty.relying_party.origin - #{"#{T.unsafe(self).request.protocol}#{T.unsafe(self).request.host}"}") + Rails.logger.info( + "RelyingParty.relying_party.origin - #{"#{T.unsafe(self).request.protocol}#{T.unsafe(self).request.host}"}", + ) WebAuthn::RelyingParty.new( # This value needs to match `window.location.origin` evaluated by # the User Agent during registration and authentication ceremonies. - # origin: Rails.env.production? ? "https://app.sway.vote" : "https://localhost:3000", - origin: Rails.env.production? ? "#{T.unsafe(self).request.protocol}#{T.unsafe(self).request.host}" : "#{T.unsafe(self).request.protocol}#{T.unsafe(self).request.host_with_port}", - + # origin: Rails.env.production? ? "https://app.sway.vote" : "https://localhost:3333", + allowed_origins: origins, # Relying Party name for display purposes name: "sway-#{ENV["RAILS_ENV"]}", # Optionally configure a client timeout hint, in milliseconds. @@ -24,8 +25,7 @@ def relying_party # interaction with the user. # This hint may be overridden by the browser. # https://www.w3.org/TR/webauthn/#dom-publickeycredentialcreationoptions-timeout - # credential_options_timeout: 120_000 - + credential_options_timeout: 120_000, # You can optionally specify a different Relying Party ID # (https://www.w3.org/TR/webauthn/#relying-party-identifier) # if it differs from the default one. @@ -33,18 +33,29 @@ def relying_party # In this case the default would be "admin.example.com", but you can set it to # the suffix "example.com" # - id: Rails.env.production? ? "app.sway.vote" : nil + id: Rails.env.production? ? "app.sway.vote" : nil, # Configure preferred binary-to-text encoding scheme. This should match the encoding scheme # used in your client-side (user agent) code before sending the credential to the server. # Supported values: `:base64url` (default), `:base64` or `false` to disable all encoding. # # encoding: :base64url - # Possible values: "ES256", "ES384", "ES512", "PS256", "PS384", "PS512", "RS256", "RS384", "RS512", "RS1" # Default: ["ES256", "PS256", "RS256"] # # algorithms: ["ES384"] ) end + + private + + def origins + if Rails.env.production? + ["#{T.unsafe(self).request.protocol}#{T.unsafe(self).request.host}"] + else + [ + "#{T.unsafe(self).request.protocol}#{T.unsafe(self).request.host_with_port}", + ] + end + end end end diff --git a/app/controllers/concerns/sway_props.rb b/app/controllers/concerns/sway_props.rb index 05f17a54..a11c4844 100644 --- a/app/controllers/concerns/sway_props.rb +++ b/app/controllers/concerns/sway_props.rb @@ -8,10 +8,13 @@ module SwayProps included do sig do params( - props: T.nilable(T.any( - T::Hash[T.untyped, T.untyped], - T.proc.returns(T::Hash[T.untyped, T.untyped]) - )) + props: + T.nilable( + T.any( + T::Hash[T.untyped, T.untyped], + T.proc.returns(T::Hash[T.untyped, T.untyped]), + ), + ), ).returns(T::Hash[T.untyped, T.untyped]) end def expand_props(props) diff --git a/app/controllers/concerns/sway_routes.rb b/app/controllers/concerns/sway_routes.rb index fe8378d2..5109922f 100644 --- a/app/controllers/concerns/sway_routes.rb +++ b/app/controllers/concerns/sway_routes.rb @@ -1,12 +1,12 @@ module SwayRoutes - HOME = "home" - LEGISLATORS = "legislators" - REGISTRATION = "sway_registration" - BILL_OF_THE_WEEK = "bill_of_the_week" - BILL = "bill" - BILLS = "bills" - BILL_CREATOR = "admin/bills/creator" - INFLUENCE = "influence" - INVITE = "invites/:user_id/:invite_uuid" - NOTIFICATIONS = "notifications" + HOME = "home".freeze + LEGISLATORS = "legislators".freeze + REGISTRATION = "sway_registration".freeze + BILL_OF_THE_WEEK = "bill_of_the_week".freeze + BILL = "bill".freeze + BILLS = "bills".freeze + BILL_CREATOR = "admin/bills/creator".freeze + INFLUENCE = "influence".freeze + INVITE = "invites/:user_id/:invite_uuid".freeze + NOTIFICATIONS = "notifications".freeze end diff --git a/app/controllers/districts_controller.rb b/app/controllers/districts_controller.rb index 63a7ff10..2eeb4221 100644 --- a/app/controllers/districts_controller.rb +++ b/app/controllers/districts_controller.rb @@ -3,6 +3,8 @@ class DistrictsController < ApplicationController def index - render json: current_user&.districts(T.cast(current_sway_locale, SwayLocale)), status: :ok + render json: + current_user&.districts(T.cast(current_sway_locale, SwayLocale)), + status: :ok end end diff --git a/app/controllers/email_verification_controller.rb b/app/controllers/email_verification_controller.rb index 23d5f496..490cb166 100644 --- a/app/controllers/email_verification_controller.rb +++ b/app/controllers/email_verification_controller.rb @@ -7,7 +7,8 @@ class EmailVerificationController < ApplicationController before_action :set_twilio_client def create - if ENV.fetch("SKIP_PHONE_VERIFICATION", nil).present? || send_email_verification(session, email_verification_params[:email]) + if ENV.fetch("SKIP_PHONE_VERIFICATION", nil).present? || + send_email_verification(session, email_verification_params[:email]) session[:email] = email_verification_params[:email] end @@ -26,11 +27,13 @@ def update if ENV.fetch("SKIP_PHONE_VERIFICATION", nil).present? approved = true else - verification_check = @client.verify - .v2 - .services(service_sid) - .verification_checks - .create(to: session[:email], code: email_verification_params[:code]) + verification_check = + @client + .verify + .v2 + .services(service_sid) + .verification_checks + .create(to: session[:email], code: email_verification_params[:code]) approved = verification_check&.status == "approved" end @@ -48,19 +51,20 @@ def update def destroy current_user.update(email: nil, is_email_verified: false) flash[:notice] = "Email Verification Reset" - render json: { - success: true - } + render json: { success: true } end private def redirect_path - bill_path(email_verification_params[:bill_id], {with: "legislator,address"}) + bill_path( + email_verification_params[:bill_id], + { with: "legislator,address" }, + ) end def set_twilio_client - @client ||= Twilio::REST::Client.new(account_sid, auth_token) + @set_twilio_client ||= Twilio::REST::Client.new(account_sid, auth_token) end def account_sid diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index 9e54bac2..aebbedaf 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -2,16 +2,17 @@ # frozen_string_literal: true class HomeController < ApplicationController - skip_before_action :authenticate_user! + skip_before_action :authenticate_sway_user! def index u = current_user if u.nil? - render inertia: Pages::HOME, props: { - name: "Sway", - isBubbles: true, - params: params - } + render inertia: Pages::HOME, + props: { + name: "Sway", + isBubbles: true, + params: params, + } # T.unsafe(self).render_home({name: "Sway", isBubbles: true}) elsif u.is_registration_complete redirect_to legislators_path diff --git a/app/controllers/influence_controller.rb b/app/controllers/influence_controller.rb index ae327e1a..757f4679 100644 --- a/app/controllers/influence_controller.rb +++ b/app/controllers/influence_controller.rb @@ -10,10 +10,17 @@ def index if u.nil? || l.nil? redirect_to root_path elsif u.is_registration_complete - render_component(Pages::INFLUENCE, {influence: InfluenceService.new( - user: u, - sway_locale: l - ).to_builder.attributes!.except("isA?")}) + render_component( + Pages::INFLUENCE, + { + influence: + InfluenceService + .new(user: u, sway_locale: l) + .to_builder + .attributes! + .except("isA?"), + }, + ) else redirect_to sway_registration_index_path end diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index 92cc7f16..2b0b1e63 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -4,15 +4,14 @@ class InvitesController < ApplicationController extend T::Sig - skip_before_action :authenticate_user! + skip_before_action :authenticate_sway_user! def show - case i = UserInviter.find_by(invite_params) - when nil - # noop - else - cookies.permanent[UserInviter::INVITED_BY_SESSION_KEY] = i.user_id - end + UserInviter + .find_by(invite_params) + .tap do |i| + cookies.permanent[UserInviter::INVITED_BY_SESSION_KEY] = i&.user_id + end redirect_to root_path end diff --git a/app/controllers/legislator_votes_controller.rb b/app/controllers/legislator_votes_controller.rb index 0c1f757f..03429511 100644 --- a/app/controllers/legislator_votes_controller.rb +++ b/app/controllers/legislator_votes_controller.rb @@ -2,56 +2,76 @@ # typed: true class LegislatorVotesController < ApplicationController - before_action :verify_is_admin, only: %i[create] + before_action :verify_is_sway_admin, only: %i[create] before_action :set_bill, only: %i[show create] def index - render json: current_user&.legislators(T.cast(current_sway_locale, SwayLocale)), status: :ok + render json: + current_user&.legislators(T.cast(current_sway_locale, SwayLocale)), + status: :ok end def show - render json: LegislatorVote.where(bill: @bill).map(&:to_sway_json), status: :ok + render json: LegislatorVote.where(bill: @bill).map(&:to_sway_json), + status: :ok end def create legislator_votes_params[:legislator_votes].each do |param| - Rails.logger.info("LegislatorVotesController.create - creating new LegislatorVote for Bill: #{@bill.id}, Legislator: #{param[:legislator_id]}") + Rails.logger.info( + "LegislatorVotesController.create - creating new LegislatorVote for Bill: #{@bill.id}, Legislator: #{param[:legislator_id]}", + ) legislator = Legislator.find(param[:legislator_id].to_i) - existing_legislator_vote = LegislatorVote.find_by(legislator:, bill: @bill.id) + existing_legislator_vote = + LegislatorVote.find_by(legislator:, bill: @bill.id) existing_support = existing_legislator_vote&.support.freeze - new_legislator_vote = LegislatorVote.new({ - bill_id: @bill.id, - legislator:, - support: param[:support] - }) + new_legislator_vote = + LegislatorVote.new( + { bill_id: @bill.id, legislator:, support: param[:support] }, + ) if existing_legislator_vote.nil? new_legislator_vote.save! - Rails.logger.info("LegislatorVotesController.create - NEW Vote for Legislator #{legislator.id} TO: #{new_legislator_vote.support}") + Rails.logger.info( + "LegislatorVotesController.create - NEW Vote for Legislator #{legislator.id} TO: #{new_legislator_vote.support}", + ) elsif existing_legislator_vote.support != new_legislator_vote.support existing_legislator_vote.update!(support: new_legislator_vote.support) - Rails.logger.info("LegislatorVotesController.create - Vote: #{existing_legislator_vote.id} for Legislator #{legislator.id} CHANGED to: #{new_legislator_vote.support} from: #{existing_support}") + Rails.logger.info( + "LegislatorVotesController.create - Vote: #{existing_legislator_vote.id} for Legislator #{legislator.id} CHANGED to: #{new_legislator_vote.support} from: #{existing_support}", + ) else # Legislator Vote is unchanged, LegislatorDistrict score should remain unchanged - Rails.logger.info("LegislatorVotesController.create - Vote for Legislator #{legislator.id} is UNCHANGED") + Rails.logger.info( + "LegislatorVotesController.create - Vote for Legislator #{legislator.id} is UNCHANGED", + ) next end end rescue Exception => e # rubocop:disable Lint/RescueException Rails.logger.error(e) - redirect_to edit_bill_path(@bill.id, {event_key: "legislator_votes"}), inertia: { - errors: {legislator_votes: e} - } + redirect_to edit_bill_path(@bill.id, { event_key: "legislator_votes" }), + inertia: { + errors: { + legislator_votes: e, + }, + } else - redirect_to(edit_bill_path(@bill.id), {saved: "Legislator Votes Saved", event_key: "organizations"}) + redirect_to( + edit_bill_path(@bill.id), + { saved: "Legislator Votes Saved", event_key: "organizations" }, + ) end private def set_bill - @bill = Bill.includes(:legislator_votes, :sway_locale).find_by(id: legislator_votes_params[:bill_id]) + @bill = + Bill.includes(:legislator_votes, :sway_locale).find_by( + id: legislator_votes_params[:bill_id], + ) end def legislator_votes_params diff --git a/app/controllers/legislators_controller.rb b/app/controllers/legislators_controller.rb index 026c653a..8670ec2b 100644 --- a/app/controllers/legislators_controller.rb +++ b/app/controllers/legislators_controller.rb @@ -2,22 +2,36 @@ # typed: true class LegislatorsController < ApplicationController + before_action :set_legislator, only: %i[show] + skip_before_action :authenticate_sway_user!, only: %i[show] + # GET /legislators or /legislators.json def index - render_component(Pages::LEGISLATORS, - lambda do - { - legislators: json_legislators - } - end) + render_component( + Pages::LEGISLATORS, + lambda { { legislators: json_legislators } }, + ) + end + + def show + render_component( + Pages::LEGISLATORS, + lambda { { legislators: [@legislator.to_sway_json] } }, + ) end private + def set_legislator + @legislator = Legislator.find(params[:id]) + end + def json_legislators - current_user&.user_legislators&.joins(:legislator)&.where(active: true, legislators: {active: true})&.map do |ul| - ul.legislator.to_sway_json - end + current_user + &.user_legislators + &.joins(:legislator) + &.where(active: true, legislators: { active: true }) + &.map { |ul| ul.legislator.to_sway_json } end # Only allow a list of trusted parameters through. diff --git a/app/controllers/notifications/push_notification_subscriptions_controller.rb b/app/controllers/notifications/push_notification_subscriptions_controller.rb index 8b4ea847..b5dc8378 100644 --- a/app/controllers/notifications/push_notification_subscriptions_controller.rb +++ b/app/controllers/notifications/push_notification_subscriptions_controller.rb @@ -5,47 +5,49 @@ module Notifications class PushNotificationSubscriptionsController < ApplicationController extend T::Sig - before_action :set_subscription - def create - if @subscription.present? - @subscription.update!(subscribed: true) unless @subscription.subscribed + if subscription.present? + subscription.update!(subscribed: true) unless subscription.subscribed SwayPushNotificationService.new( title: "Notifications Activated", - body: "We'll send you one of these when a new Bill of the Week is released." + body: + "We'll send you one of these when a new Bill of the Week is released.", ).send_push_notification - render json: @subscription.attributes, status: :ok + render json: subscription.attributes, status: :ok else - render json: PushNotificationSubscription.create!( - **push_notification_subscription_params, - user: current_user, - subscribed: true - ).attributes, status: :ok + render json: + PushNotificationSubscription.create!( + **push_notification_subscription_params, + user: current_user, + subscribed: true, + ).attributes, + status: :ok end end def destroy - return if @subscription.blank? + return if subscription.blank? - @subscription.update!(subscribed: false) - render json: @subscription.attributes, status: :ok + subscription.update!(subscribed: false) + render json: subscription.attributes, status: :ok end private - def set_subscription - @subscription = current_user&.push_notification_subscriptions&.find do |s| - s.endpoint == push_notification_subscription_params[:endpoint] - end + def subscription + @subscription ||= + current_user&.push_notification_subscriptions&.find do |s| + s.endpoint == push_notification_subscription_params[:endpoint] + end end def push_notification_subscription_params params.require(:push_notification_subscription).permit( :endpoint, :p256dh, - :auth + :auth, ) end end diff --git a/app/controllers/notifications/push_notifications_controller.rb b/app/controllers/notifications/push_notifications_controller.rb index f787e845..f3eb5877 100644 --- a/app/controllers/notifications/push_notifications_controller.rb +++ b/app/controllers/notifications/push_notifications_controller.rb @@ -10,30 +10,39 @@ class PushNotificationsController < ApplicationController # Allow the user to test a push notification def create if @subscription.nil? - render json: {success: false, message: "Failed to send test notification. Try disabling and re-enabling notifications."}, status: :ok + render json: { + success: false, + message: + "Failed to send test notification. Try disabling and re-enabling notifications.", + }, + status: :ok else SwayPushNotificationService.new( @subscription, title: "Notifications Test", - body: "Test Web Push Notification." + body: "Test Web Push Notification.", ).send_push_notification - render json: {success: true, message: "Test notification sent. You should receive one soon..."}, status: :ok + render json: { + success: true, + message: + "Test notification sent. You should receive one soon...", + }, + status: :ok end end private def set_subscription - @subscription = current_user&.push_notification_subscriptions&.find do |s| - s.endpoint == push_notification_subscription_params[:endpoint] - end + @subscription = + current_user&.push_notification_subscriptions&.find do |s| + s.endpoint == push_notification_subscription_params[:endpoint] + end end def push_notification_subscription_params - params.require(:push_notification).permit( - :endpoint - ) + params.require(:push_notification).permit(:endpoint) end end end diff --git a/app/controllers/notifications_controller.rb b/app/controllers/notifications_controller.rb index 0ac4470f..233d9618 100644 --- a/app/controllers/notifications_controller.rb +++ b/app/controllers/notifications_controller.rb @@ -2,10 +2,15 @@ class NotificationsController < ApplicationController def index - render_component(Pages::NOTIFICATIONS, lambda do - { - subscriptions: current_user&.push_notification_subscriptions&.map(&:attributes) || [] - } - end) + render_component( + Pages::NOTIFICATIONS, + lambda do + { + subscriptions: + current_user&.push_notification_subscriptions&.map(&:attributes) || + [], + } + end, + ) end end diff --git a/app/controllers/organization_bill_positions_controller.rb b/app/controllers/organization_bill_positions_controller.rb index 1f5e0865..3d21f78e 100644 --- a/app/controllers/organization_bill_positions_controller.rb +++ b/app/controllers/organization_bill_positions_controller.rb @@ -1,31 +1,44 @@ # frozen_string_literal: true class OrganizationBillPositionsController < ApplicationController - before_action :verify_is_admin, only: %i[create] + before_action :verify_is_sway_admin, only: %i[create] def index if params[:bill_id] - render json: OrganizationBillPosition.where(bill_id: params[:bill_id]), status: :ok + render json: OrganizationBillPosition.where(bill_id: params[:bill_id]), + status: :ok elsif params[:organization_id] - render json: OrganizationBillPosition.where(organization: Organization.find_by(id: params[:organization_id])), - status: :ok + render json: + OrganizationBillPosition.where( + organization: + Organization.find_by(id: params[:organization_id]), + ), + status: :ok else render json: [], status: :ok end end def show - render json: OrganizationBillPosition.find_by( - bill_id: params[:bill_id], - organization: Organization.find_by(id: params[:organization_id]) - ), status: :ok + render json: + OrganizationBillPosition.find_by( + bill_id: params[:bill_id], + organization: Organization.find_by(id: params[:organization_id]), + ), + status: :ok end def create if organization_bill_positions_params[:positions].present? - OrganizationBillPosition.where(bill_id: organization_bill_positions_params[:positions].first[:bill_id]).destroy_all - - render json: OrganizationBillPosition.insert_all!(organization_bill_positions_params[:positions]), status: :ok # rubocop:disable Rails/SkipsModelValidations + OrganizationBillPosition.where( + bill_id: organization_bill_positions_params[:positions].first[:bill_id], + ).destroy_all + + render json: + OrganizationBillPosition.insert_all!( + organization_bill_positions_params[:positions], + ), + status: :ok # rubocop:disable Rails/SkipsModelValidations else render json: [], status: :no_content end @@ -34,10 +47,17 @@ def create private def organization_bill_positions_params - params.require(:organization_bill_position).permit(positions: %i[bill_id organization_id support summary]) + params.require(:organization_bill_position).permit( + positions: %i[bill_id organization_id support summary], + ) end def organization_bill_position_params - params.require(:organization_bill_position).permit(:bill_id, :organization_id, :support, :summary) + params.require(:organization_bill_position).permit( + :bill_id, + :organization_id, + :support, + :summary, + ) end end diff --git a/app/controllers/organizations/base_controller.rb b/app/controllers/organizations/base_controller.rb new file mode 100644 index 00000000..0545f342 --- /dev/null +++ b/app/controllers/organizations/base_controller.rb @@ -0,0 +1,84 @@ +class Organizations::BaseController < ApplicationController + inertia_share do + { + tab: params[:tab] || "positions", + new_position_id: params[:new_position_id], + } + end + + def with_props + { organization: organization.to_simple_builder.attributes! } + end + + def organization + @organization ||= Organization.find(params[:organization_id]) + end + + def members + @members ||= organization.user_organization_memberships.includes(:user) + end + + def positions + @positions ||= organization.positions.eager_load(:bill) + end + + def pending_changes + @pending_changes ||= OrganizationBillPositionChange.pending(organization) + end + + def current_user_membership + @current_user_membership ||= + UserOrganizationMembership.find_by( + organization: organization, + user: current_user, + ) + end + + def redirect_to_current_user_membership(**kwargs) + redirect_to organization_membership_path( + organization_id: organization.id, + id: current_user_membership.id, + tab: params[:tab], + **kwargs, + ) + end + + def membership_as_props + props = { + **current_user_membership.to_sway_json, + organization: organization.to_simple_builder.attributes!, + role: current_user_membership.role, + positions: + ( + positions.map do |p| + { + id: p.id, + support: p.support, + summary: p.summary, + bill: p.bill.to_sway_json, + } + end + ), + } + + if current_user_membership.admin? + props[:members] = ( + members.map do |m| + { + id: m.id, + user_id: m.user.id, + full_name: m.user.full_name, + email: m.user.email, + role: m.role, + } + end + ) + props[:pending_changes] = pending_changes.map(&:to_sway_json) + else + props[:pending_changes] = pending_changes.where( + updated_by: current_user, + ).map(&:to_sway_json) + end + props + end +end diff --git a/app/controllers/organizations/membership_invites_controller.rb b/app/controllers/organizations/membership_invites_controller.rb new file mode 100644 index 00000000..392da442 --- /dev/null +++ b/app/controllers/organizations/membership_invites_controller.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true +# typed: true + +class Organizations::MembershipInvitesController < Organizations::BaseController + before_action :require_admin! + + def create + @invite = + UserOrganizationMembershipInvite.new( + invite_params.merge( + inviter: current_user, + organization_id: params[:organization_id], + ), + ) + if @invite.save + if Rails.env.test? + UserOrganizationMembershipInviteMailer.invite(@invite).deliver_now + else + UserOrganizationMembershipInviteMailer.invite(@invite).deliver_later + end + render json: { success: true } + else + render json: { + errors: @invite.errors.full_messages, + }, + status: :unprocessable_entity + end + end + + private + + def invite_params + params.require(:invite).permit(:invitee_email, :role) + end + + def require_admin! + unless UserOrganizationMembership.find_by( + user: current_user, + organization_id: params[:organization_id], + role: :admin, + ).present? + render json: { error: "Forbidden" }, status: :forbidden + end + end +end diff --git a/app/controllers/organizations/memberships_controller.rb b/app/controllers/organizations/memberships_controller.rb new file mode 100644 index 00000000..44a1ca79 --- /dev/null +++ b/app/controllers/organizations/memberships_controller.rb @@ -0,0 +1,61 @@ +class Organizations::MembershipsController < Organizations::BaseController + before_action :current_user_must_be_organization_admin!, + only: %i[update destroy] + before_action :working_membership_must_be_same_organization_as_current_user!, + only: %i[update destroy] + + def show + render_component( + Pages::USER_ORGANIZATION_MEMBERSHIP, + membership: membership_as_props, + ) + end + + def update + if working_membership.update( + role: UserOrganizationMembership.roles.fetch(params[:role]), + ) + flash[:notice] = "Member updated." + else + Rails.logger.error(working_membership.errors.full_messages) + flash[:alert] = "Could not update member." + end + redirect_to_current_user_membership + end + + def destroy + if working_membership.destroy + flash[:notice] = "Member removed." + else + Rails.logger.error(working_membership.errors.full_messages) + flash[:alert] = "Failed to remove member." + end + + if working_membership.id == current_user_membership.id + redirect_to users_organization_memberships_path + else + redirect_to_current_user_membership + end + end + + private + + def current_user_must_be_organization_admin! + return if current_user_membership&.admin? + + flash[:alert] = "Forbidden" + redirect_to_current_user_membership + end + + def working_membership + @working_membership ||= UserOrganizationMembership.find(params[:id]) + end + + def working_membership_must_be_same_organization_as_current_user! + unless working_membership.organization_id == + current_user_membership.organization_id + flash[:alert] = "Forbidden" + redirect_to_current_user_membership + end + end +end diff --git a/app/controllers/organizations/position_changes_controller.rb b/app/controllers/organizations/position_changes_controller.rb new file mode 100644 index 00000000..454a16c7 --- /dev/null +++ b/app/controllers/organizations/position_changes_controller.rb @@ -0,0 +1,36 @@ +class Organizations::PositionChangesController < Organizations::BaseController + before_action :current_user_must_be_admin_to_approve! + before_action :set_change, only: %i[update] + + def update + if @change.update(approved_by: current_user) + position.support = @change.new_support + position.summary = @change.new_summary + position.active = true + position.save! + + flash[:notice] = "Change approved and position updated." + else + Rails.logger.error(change.errors.full_messages) + flash[:alert] = "Could not approve change." + end + redirect_to_current_user_membership + end + + private + + def position + @position ||= @change.organization_bill_position + end + + def set_change + @change = OrganizationBillPositionChange.find(params[:id]) + end + + def current_user_must_be_admin_to_approve! + return if current_user_membership.admin? + + flash[:alert] = "Forbidden" + redirect_to_current_user_membership + end +end diff --git a/app/controllers/organizations/positions_controller.rb b/app/controllers/organizations/positions_controller.rb new file mode 100644 index 00000000..330d491e --- /dev/null +++ b/app/controllers/organizations/positions_controller.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true +# typed: true + +class Organizations::PositionsController < Organizations::BaseController + before_action :set_position, only: %i[update destroy] + before_action :set_change, only: %i[update] + before_action :change_must_not_be_approved!, only: %i[update] + before_action :change_must_be_different!, only: %i[update] + + # TODO: Create a position on a bill + def create + position = + OrganizationBillPosition.find_or_initialize_by( + organization: organization, + bill_id: params[:bill_id], + ) + if position.active + flash[:alert] = "Position for that bill already exists." + redirect_to_current_user_membership and return + end + + position.support = params[:support] + position.summary = OrganizationBillPosition::DEFAULT_SUMMARY + + unless position.save + flash[:alert] = "Failed to create position." + Rails.logger.error(position.errors.full_messages) + redirect_to_current_user_membership and return + end + + change = + OrganizationBillPositionChange.find_or_initialize_by( + organization_bill_position: position, + ) + + change.previous_summary = "" + change.previous_support = params[:support] + change.new_support = params[:support] + change.new_summary = params[:summary] + change.updated_by = current_user + + if change.save + flash[:notice] = "Created position, subject to approval." + else + Rails.logger.error(change.errors.full_messages) + flash[:alert] = "Failed to create position." + end + + redirect_to_current_user_membership( + tab: "approvals", + new_position_id: position.id, + ) + end + + def new + render_component( + Pages::NEW_USER_ORGANIZATION_POSITION, + { + membership: membership_as_props, + bills: + Bill + .where(sway_locale: organization.sway_locale) + .where.not( + { + id: + organization + .organization_bill_positions + .where(active: true) + .select(:bill_id), + }, + ) + .map(&:to_sway_json), + }, + ) + end + + def update + if @change.save + flash[:notice] = "Created change to position, subject to approval." + else + Rails.logger.error(@position.errors.full_messages) + flash[:alert] = "Failed to save change to position." + end + redirect_to_current_user_membership + end + + def destroy + if current_user_membership.admin? + @position.update!(active: false) + flash[ + :notice + ] = "Position for bill #{@position.bill.title} de-activated and hidden." + else + flash[:alert] = "Forbidden" + end + redirect_to_current_user_membership + end + + private + + def set_position + @position = OrganizationBillPosition.find(params[:id]) + end + + def set_change + @change = + OrganizationBillPositionChange.find_by( + organization_bill_position: @position, + updated_by: current_user, + approved_by_id: nil, + ) + if @change.present? + @change.new_support = params[:support] + @change.new_summary = params[:summary] + @change.approved_by = nil + else + @change = + OrganizationBillPositionChange.new( + organization_bill_position: @position, + updated_by: current_user, + previous_support: @position.support, + previous_summary: @position.summary, + new_support: params[:support], + new_summary: params[:summary], + ) + end + end + + def change_must_not_be_approved! + return unless @change.approved? + + flash[:notice] = "Change to position has already been approved." + redirect_to_current_user_membership + end + + def change_must_be_different! + if @change.new_support == @position.support && + @change.new_summary == @position.summary + flash[:notice] = "No changes to position detected." + redirect_to_current_user_membership + end + end +end diff --git a/app/controllers/organizations_controller.rb b/app/controllers/organizations_controller.rb index 6b622b8c..b2c201cc 100644 --- a/app/controllers/organizations_controller.rb +++ b/app/controllers/organizations_controller.rb @@ -4,34 +4,37 @@ class OrganizationsController < ApplicationController include SwayGoogleCloudStorage - before_action :verify_is_admin, only: %i[create] + before_action :verify_is_sway_admin, only: %i[create] before_action :set_organization, only: %i[show] before_action :set_bill, only: %i[create] def index - render json: Organization.where(sway_locale_id: current_sway_locale&.id).map(&:to_sway_json), status: :ok + render json: + Organization.where(sway_locale_id: current_sway_locale&.id).map( + &:to_sway_json + ), + status: :ok end def show if @organization.present? render json: @organization.to_sway_json, status: :ok else - render json: {success: false, message: "Organization not found."}, status: :ok + render json: { + success: false, + message: "Organization not found.", + }, + status: :ok end end def create errored = T.let(false, T::Boolean) errors = { - organization: organizations_params[:organizations].map do |_| - { - label: nil, - value: nil, - summary: nil, - support: nil, - icon_path: nil - } - end + organization: + organizations_params[:organizations].map do |_| + { label: nil, value: nil, summary: nil, support: nil, icon_path: nil } + end, } organizations_params[:organizations].each_with_index do |param, index| @@ -42,7 +45,11 @@ def create if organization.save organization.remove_icon(current_icon_path) - position = OrganizationBillPosition.find_or_initialize_by(organization:, bill: @bill) + position = + OrganizationBillPosition.find_or_initialize_by( + organization:, + bill: @bill, + ) position.support = param[:support] position.summary = param[:summary] @@ -50,22 +57,22 @@ def create errored = true position.errors.each do |e| if errors[:organizations][index].key?(e.attribute) - errors[:organizations][index][e.attribute] = "#{e.attribute.capitalize} #{e.message}" + errors[:organizations][index][ + e.attribute + ] = "#{e.attribute.capitalize} #{e.message}" end end end else errored = true organization.errors.each do |e| - attribute_by_key = { - name: :label, - id: :value, - icon_path: :icon_path - } + attribute_by_key = { name: :label, id: :value, icon_path: :icon_path } attr = attribute_by_key[e.attribute] if attr.present? && errors[:organizations][index].key?(attr) - errors[:organizations][index][attr] = "#{attr} #{e.message.capitalize}" + errors[:organizations][index][ + attr + ] = "#{attr} #{e.message.capitalize}" end end end @@ -73,25 +80,49 @@ def create if errored flash[:alert] = "Error Saving Supporting/Opposing Arguments" - redirect_to edit_bill_path(@bill.id, {event_key: "organizations"}), inertia: {errors: errors} + redirect_to edit_bill_path(@bill.id, { event_key: "organizations" }), + inertia: { + errors: errors, + } else flash[:notice] = "Supporting/Opposing Arguments Saved" - redirect_to edit_bill_path(@bill.id, saved: "Supporting/Opposing Arguments Saved", event_key: "organizations") + redirect_to edit_bill_path( + @bill.id, + saved: "Supporting/Opposing Arguments Saved", + event_key: "organizations", + ) end end private def set_bill - @bill = Bill.includes(:organization_bill_positions, :sway_locale).find(organizations_params[:bill_id]) + @bill = + Bill.includes(:organization_bill_positions, :sway_locale).find( + organizations_params[:bill_id], + ) end def organizations_params - params.permit(:bill_id, organizations: %i[label value summary support icon_path]) + params.permit( + :bill_id, + organizations: %i[label value summary support icon_path], + ) end def find_or_initialize_organization_from_param(param) - o = param[:value].blank? ? nil : Organization.find_by(id: param[:value]).presence - o || Organization.find_or_initialize_by(name: param[:label], sway_locale: @bill.sway_locale) + o = + ( + if param[:value].blank? + nil + else + Organization.find_by(id: param[:value]).presence + end + ) + o || + Organization.find_or_initialize_by( + name: param[:label], + sway_locale: @bill.sway_locale, + ) end end diff --git a/app/controllers/phone_verification_controller.rb b/app/controllers/phone_verification_controller.rb index 5ff92151..2ed4a64f 100644 --- a/app/controllers/phone_verification_controller.rb +++ b/app/controllers/phone_verification_controller.rb @@ -8,14 +8,21 @@ class PhoneVerificationController < ApplicationController extend T::Sig before_action :set_twilio_client - skip_before_action :authenticate_user! + skip_before_action :authenticate_sway_user! def create if ENV.fetch("SKIP_PHONE_VERIFICATION", nil).present? session[:phone] = phone_verification_params[:phone] - render json: {success: true}, status: :ok + render json: { success: true }, status: :ok else - render json: {success: send_phone_verification(session, phone_verification_params[:phone])}, status: :ok + render json: { + success: + send_phone_verification( + session, + phone_verification_params[:phone], + ), + }, + status: :ok end end @@ -23,37 +30,41 @@ def update if ENV.fetch("SKIP_PHONE_VERIFICATION", nil).present? session[:verified_phone] = session[:phone] approved = true - u = User.find_or_create_by( - phone: "3333333333", - email: ENV.fetch("DEFAULT_USER_EMAIL"), - full_name: ENV.fetch("DEFAULT_USER_FULL_NAME").split("+").join(" ") - ) + u = + User.find_or_create_by( + phone: session[:phone], + email: "#{session[:phone]}@sway.vote", + full_name: ENV.fetch("DEFAULT_USER_FULL_NAME").split("+").join(" "), + ) u.update( phone: session[:phone], is_admin: true, is_email_verified: true, is_phone_verified: true, is_registered_to_vote: true, - is_registration_complete: true - ) - a = Address.find_or_create_by( - city: ENV.fetch("DEFAULT_CITY"), - postal_code: ENV.fetch("DEFAULT_REGION_CODE"), - region_code: ENV.fetch("DEFAULT_POSTAL_CODE"), - street: ENV.fetch("DEFAULT_STREET").split("+").join(" "), - latitude: ENV.fetch("DEFAULT_LATITUDE").to_f, - longitude: ENV.fetch("DEFAULT_LONGITUDE").to_f - ) - UserAddress.find_or_create_by( - user: u, - address: a + is_registration_complete: true, ) + a = + Address.find_or_create_by( + city: ENV.fetch("DEFAULT_CITY"), + postal_code: ENV.fetch("DEFAULT_REGION_CODE"), + region_code: ENV.fetch("DEFAULT_POSTAL_CODE"), + street: ENV.fetch("DEFAULT_STREET").split("+").join(" "), + latitude: ENV.fetch("DEFAULT_LATITUDE").to_f, + longitude: ENV.fetch("DEFAULT_LONGITUDE").to_f, + ) + UserAddress.find_or_create_by(user: u, address: a) else - verification_check = @client.verify - .v2 - .services(service_sid) - .verification_checks - .create(to: "+1#{session[:phone]}", code: phone_verification_params[:code]) + verification_check = + @client + .verify + .v2 + .services(service_sid) + .verification_checks + .create( + to: "+1#{session[:phone]}", + code: phone_verification_params[:code], + ) approved = verification_check&.status == "approved" @@ -66,13 +77,13 @@ def update end end - render json: {success: approved}, status: :ok + render json: { success: approved }, status: :ok end private def set_twilio_client - @client ||= Twilio::REST::Client.new(account_sid, auth_token) + @set_twilio_client ||= Twilio::REST::Client.new(account_sid, auth_token) end def account_sid diff --git a/app/controllers/sitemap_controller.rb b/app/controllers/sitemap_controller.rb new file mode 100644 index 00000000..1d98ff62 --- /dev/null +++ b/app/controllers/sitemap_controller.rb @@ -0,0 +1,12 @@ +class SitemapController < ApplicationController + skip_before_action :authenticate_sway_user! + + def index + @bills = Bill.all.includes(:sway_locale) # Replace YourModel with your actual model + @legislators = Legislator.all.includes(district: :sway_locale) # Replace YourModel with your actual model + @sway_locales = SwayLocale.all + respond_to do |format| + format.xml { render layout: false } # Render without layout + end + end +end diff --git a/app/controllers/sway_locales_controller.rb b/app/controllers/sway_locales_controller.rb index e239071b..adeaa678 100644 --- a/app/controllers/sway_locales_controller.rb +++ b/app/controllers/sway_locales_controller.rb @@ -2,14 +2,20 @@ # typed: true class SwayLocalesController < ApplicationController + skip_before_action :authenticate_sway_user! + # GET /sway_locales or /sway_locales.json def index - render json: current_user&.sway_locales&.map(&:to_sway_json), status: :ok + render json: + (current_user&.sway_locales || SwayLocale.all).map(&:to_sway_json), + status: :ok end # GET /sway_locales/1 or /sway_locales/1.json def show - locale = T.let(SwayLocale.find(params[:id]), T.nilable(SwayLocale)) || T.cast(SwayLocale.default_locale, SwayLocale) + locale = + T.let(SwayLocale.find(params[:id]), T.nilable(SwayLocale)) || + T.cast(SwayLocale.default_locale, SwayLocale) if locale.nil? nil else diff --git a/app/controllers/sway_registration_controller.rb b/app/controllers/sway_registration_controller.rb index 0423b0b1..0bb98683 100644 --- a/app/controllers/sway_registration_controller.rb +++ b/app/controllers/sway_registration_controller.rb @@ -4,11 +4,10 @@ class SwayRegistrationController < ApplicationController extend T::Sig - skip_before_action :authenticate_user! + skip_before_action :authenticate_sway_user! - T::Configuration.inline_type_error_handler = lambda do |error, _opts| - Rails.logger.error error - end + T::Configuration.inline_type_error_handler = + lambda { |error, _opts| Rails.logger.error error } def index u = current_user @@ -32,23 +31,27 @@ def create elsif u.has_user_legislators? redirect_to legislators_path else - T.cast(user_address(u).address, Address).sway_locales.each do |sway_locale| - SwayRegistrationService.new( - u, - T.cast(user_address(u).address, Address), - sway_locale, - invited_by_id: cookies.permanent[UserInviter::INVITED_BY_SESSION_KEY] - ).run - end + T + .cast(user_address(u).address, Address) + .sway_locales + .each do |sway_locale| + SwayRegistrationService.new( + u, + T.cast(user_address(u).address, Address), + sway_locale, + invited_by_id: invited_by_id, + ).run + end if u.is_registration_complete redirect_to legislators_path else - redirect_to sway_registration_index_path, inertia: { - errros: { - address: "Registration not complete." - } - } + redirect_to sway_registration_index_path, + inertia: { + errros: { + address: "Registration not complete.", + }, + } end end end @@ -69,13 +72,21 @@ def address postal_code: sway_registration_params.fetch(:postal_code), country: sway_registration_params.fetch(:country), latitude: sway_registration_params.fetch(:latitude), - longitude: sway_registration_params.fetch(:longitude) + longitude: sway_registration_params.fetch(:longitude), ) end sig { returns(ActionController::Parameters) } def sway_registration_params - params.require(:sway_registration).permit(:latitude, :longitude, :street, :city, :region, :region_code, - :postal_code, :country) + params.require(:sway_registration).permit( + :latitude, + :longitude, + :street, + :city, + :region, + :region_code, + :postal_code, + :country, + ) end end diff --git a/app/controllers/user_districts_controller.rb b/app/controllers/user_districts_controller.rb index 42d16a8e..ae8f5e7f 100644 --- a/app/controllers/user_districts_controller.rb +++ b/app/controllers/user_districts_controller.rb @@ -3,6 +3,8 @@ class UserDistrictsController < ApplicationController def index - render json: current_user&.districts(T.cast(current_sway_locale, SwayLocale)), status: :ok + render json: + current_user&.districts(T.cast(current_sway_locale, SwayLocale)), + status: :ok end end diff --git a/app/controllers/user_legislator_email_controller.rb b/app/controllers/user_legislator_email_controller.rb index 1b9dd6c7..06935c25 100644 --- a/app/controllers/user_legislator_email_controller.rb +++ b/app/controllers/user_legislator_email_controller.rb @@ -2,16 +2,14 @@ class UserLegislatorEmailController < ApplicationController before_action :set_bill def create - if @bill.present? - current_user.user_legislators_by_locale(@bill.sway_locale).each do |user_legislator| - UserLegislatorEmail.find_or_create_by({ - user_legislator:, - bill: @bill - }) + return if @bill.blank? + current_user + .user_legislators_by_locale(@bill.sway_locale) + .each do |user_legislator| + UserLegislatorEmail.find_or_create_by({ user_legislator:, bill: @bill }) end - redirect_to(bill_path(@bill.id)) - end + redirect_to(bill_path(@bill.id)) end private diff --git a/app/controllers/user_legislator_scores_controller.rb b/app/controllers/user_legislator_scores_controller.rb index 3eb34288..2e1dafbb 100644 --- a/app/controllers/user_legislator_scores_controller.rb +++ b/app/controllers/user_legislator_scores_controller.rb @@ -4,15 +4,18 @@ class UserLegislatorScoresController < ApplicationController # GET /user_legislator_scores or /user_legislator_scores.json def index - render json: UserLegislatorScore.where(user: current_user).map(&:to_sway_json), status: :ok + render json: + UserLegislatorScore.where(user: current_user).map(&:to_sway_json), + status: :ok end # GET /user_legislator_scores/1 or /user_legislator_scores/1.json def show - uls = UserLegislator.find_by( - user: current_user, - legislator_id: params[:id] - )&.user_legislator_score + uls = + UserLegislator.find_by( + user: current_user, + legislator_id: params[:id], + )&.user_legislator_score if uls.present? && !uls.empty? render json: uls.to_sway_json, status: :ok diff --git a/app/controllers/user_legislators_controller.rb b/app/controllers/user_legislators_controller.rb index ebcee537..7380ee74 100644 --- a/app/controllers/user_legislators_controller.rb +++ b/app/controllers/user_legislators_controller.rb @@ -3,7 +3,11 @@ class UserLegislatorsController < ApplicationController def index - render json: current_user&.user_legislators_by_locale(T.cast(current_sway_locale, SwayLocale)), status: :ok + render json: + current_user&.user_legislators_by_locale( + T.cast(current_sway_locale, SwayLocale), + ), + status: :ok end def create @@ -13,9 +17,10 @@ def create u, T.cast(u.address, Address), T.cast(current_sway_locale, SwayLocale), - invited_by_id: cookies.permanent[UserInviter::INVITED_BY_SESSION_KEY] + invited_by_id: invited_by_id, ).run - route_component(legislators_path) + # route_component(legislators_path) + render json: { success: true }, status: :ok end end diff --git a/app/controllers/user_votes_controller.rb b/app/controllers/user_votes_controller.rb index addf090f..10c63164 100644 --- a/app/controllers/user_votes_controller.rb +++ b/app/controllers/user_votes_controller.rb @@ -8,10 +8,7 @@ def index end def show - uv = UserVote.find_by( - user: current_user, - bill_id: params[:id] - ) + uv = UserVote.find_by(user: current_user, bill_id: params[:id]) if uv.present? render json: uv.to_json, status: :ok else @@ -21,18 +18,19 @@ def show # POST /user_votes or /user_votes.json def create - uv = UserVote.find_or_initialize_by( - user: current_user, - bill_id: user_vote_params[:bill_id] - ) + uv = + UserVote.find_or_initialize_by( + user: current_user, + bill_id: user_vote_params[:bill_id], + ) uv.support = user_vote_params[:support] uv.save redirect_to( bill_path(user_vote_params[:bill_id]), inertia: { - errors: uv.errors - } + errors: uv.errors, + }, ) end diff --git a/app/controllers/users/organization_memberships_controller.rb b/app/controllers/users/organization_memberships_controller.rb new file mode 100644 index 00000000..2fa8c828 --- /dev/null +++ b/app/controllers/users/organization_memberships_controller.rb @@ -0,0 +1,19 @@ +module Users + class OrganizationMembershipsController < ApplicationController + def index + u = current_user.to_sway_json + + render_component( + Pages::USER_ORGANIZATION_MEMBERSHIPS, + { + tab: params[:tab] || "positions", + user: { + **u, + memberships: + current_user.user_organization_memberships&.map(&:to_sway_json), + }, + }, + ) + end + end +end diff --git a/app/controllers/users/user_details_controller.rb b/app/controllers/users/user_details_controller.rb index c8217917..408377be 100644 --- a/app/controllers/users/user_details_controller.rb +++ b/app/controllers/users/user_details_controller.rb @@ -1,30 +1,30 @@ # typed: true -class Users::UserDetailsController < ApplicationController - extend T::Sig +module Users + class UserDetailsController < ApplicationController + extend T::Sig - def create - unless current_user.update(full_name: user_details_params[:full_name]) - flash[:error] = "Failed to save your name. Please try again." + def create + unless current_user.update(full_name: user_details_params[:full_name]) + flash[:error] = "Failed to save your name. Please try again." + end + redirect_to redirect_path, inertia: { errors: current_user.errors } end - redirect_to redirect_path, inertia: { - errors: current_user.errors - } - end - private + private - sig { returns(User) } - def current_user - T.cast(super, User) - end + sig { returns(User) } + def current_user + T.cast(super, User) + end - def redirect_path - bill_path(user_details_params[:bill_id], {with: "legislator,address"}) - end + def redirect_path + bill_path(user_details_params[:bill_id], { with: "legislator,address" }) + end - # Currently this flow only works on bill path, TODO: Decouple - def user_details_params - params.require(:user_detail).permit(:bill_id, :full_name) + # Currently this flow only works on bill path, TODO: Decouple + def user_details_params + params.require(:user_detail).permit(:bill_id, :full_name) + end end end diff --git a/app/controllers/users/webauthn/registration_controller.rb b/app/controllers/users/webauthn/registration_controller.rb index fbf54851..3e54a675 100644 --- a/app/controllers/users/webauthn/registration_controller.rb +++ b/app/controllers/users/webauthn/registration_controller.rb @@ -7,34 +7,46 @@ module Webauthn class RegistrationController < ApplicationController extend T::Sig - skip_before_action :authenticate_user! + rate_limit(to: 5, within: 1.minute) + + skip_before_action :authenticate_sway_user! def create - user = User.find_or_initialize_by( - phone: session[:verified_phone] - ) + user = User.find_or_initialize_by(phone: session[:verified_phone]) user.is_phone_verified = session[:verified_phone] == session[:phone] if user.is_phone_verified - - create_options = relying_party.options_for_registration( - user: { - name: session[:verified_phone], - id: user.webauthn_id - }, - authenticator_selection: {user_verification: "required"} - ) + create_options = + relying_party.options_for_registration( + user: { + name: session[:verified_phone], + id: user.webauthn_id, + }, + authenticator_selection: { + user_verification: "required", + }, + ) if user.valid? - session[:current_registration] = {challenge: create_options.challenge, user_attributes: user.attributes} + session[:current_registration] = { + challenge: create_options.challenge, + user_attributes: user.attributes, + } render json: create_options else - render json: {errors: user.errors.full_messages}, status: :unprocessable_entity + render json: { + errors: user.errors.full_messages, + }, + status: :unprocessable_entity end else - render json: {success: false, message: "Please confirm your phone number first."}, status: :ok + render json: { + success: false, + message: "Please confirm your phone number first.", + }, + status: :ok end end @@ -47,19 +59,21 @@ def callback end begin - webauthn_passkey = relying_party.verify_registration( - params, - challenge, - user_verification: true - ) - - passkey = Passkey.new( - user:, - external_id: Base64.strict_encode64(webauthn_passkey.raw_id), - label: params[:passkey_label], - public_key: webauthn_passkey.public_key, - sign_count: webauthn_passkey.sign_count - ) + webauthn_passkey = + relying_party.verify_registration( + params, + challenge, + user_verification: true, + ) + + passkey = + Passkey.new( + user:, + external_id: webauthn_passkey.id, + label: params[:passkey_label], + public_key: webauthn_passkey.public_key, + sign_count: webauthn_passkey.sign_count, + ) if passkey.save sign_in(user) @@ -67,15 +81,17 @@ def callback route_component(SwayRoutes::REGISTRATION) else render json: { - success: false, - message: "Couldn't register your Passkey" - }, status: :unprocessable_entity + success: false, + message: "Couldn't register your Passkey", + }, + status: :unprocessable_entity end rescue WebAuthn::Error => e render json: { - success: false, - message: "Verification failed: #{e.message}" - }, status: :unprocessable_entity + success: false, + message: "Verification failed: #{e.message}", + }, + status: :unprocessable_entity ensure session.delete(:current_registration) end diff --git a/app/controllers/users/webauthn/sessions_controller.rb b/app/controllers/users/webauthn/sessions_controller.rb index 9f4958ed..f37e0aa5 100644 --- a/app/controllers/users/webauthn/sessions_controller.rb +++ b/app/controllers/users/webauthn/sessions_controller.rb @@ -8,48 +8,74 @@ class SessionsController < ApplicationController extend T::Sig include Authentication - skip_before_action :authenticate_user! + rate_limit(to: 5, within: 1.minute) + + skip_before_action :authenticate_sway_user! before_action :verify_valid_phone, only: %i[create] def create user = User.find_by(phone:) - if user&.has_passkey? - get_options = relying_party.options_for_authentication( - allow: user.passkeys.pluck(:external_id), - user_verification: "required" - ) - - session[:current_authentication] = {challenge: get_options.challenge, phone:} + if user&.has_sway_passkey? + get_options = + relying_party.options_for_authentication( + allow_credentials: + user.passkeys.map do |p| + { id: p.external_id, type: "public-key" } + end, + user_verification: "required", + ) + + session[:current_authentication] = { + challenge: get_options.challenge, + phone:, + } render json: get_options elsif phone.present? if ENV.fetch("SKIP_PHONE_VERIFICATION", nil).present? session[:phone] = phone - render json: {success: true}, status: :accepted + render json: { success: true }, status: :accepted else - render json: {success: send_phone_verification(session, phone)}, status: :accepted + render json: { + success: send_phone_verification(session, phone), + }, + status: :accepted end else - render json: {success: false}, status: :unprocessable_entity + render json: { success: false }, status: :unprocessable_entity end end def callback - user = User.find_by(phone: session.dig(:current_authentication, "phone")) - raise "user #{session.dig(:current_authentication, "phone")} never initiated sign up" unless user + user = + User.find_by(phone: session.dig(:current_authentication, "phone")) + unless user + raise "user #{session.dig(:current_authentication, "phone")} never initiated sign up" + end begin - verified_webauthn_passkey, stored_passkey = relying_party.verify_authentication( - public_key_credential_params, - session.dig(:current_authentication, "challenge"), - user_verification: true - ) do |webauthn_passkey| - user.passkeys.find_by(external_id: Base64.strict_encode64(webauthn_passkey.raw_id)) - end - - stored_passkey.update!(sign_count: verified_webauthn_passkey.sign_count) + verified_webauthn_passkey, stored_passkey = + relying_party.verify_authentication( + public_key_credential_params, + session.dig(:current_authentication, "challenge"), + user_verification: true, + ) do |webauthn_passkey| + user + .passkeys + .where( + external_id: [ + webauthn_passkey.id, + Base64.strict_encode64(webauthn_passkey.raw_id), + ], + ) + .first + end + + stored_passkey.update!( + sign_count: verified_webauthn_passkey.sign_count, + ) sign_in(user) session[:verified_phone] = user.phone @@ -60,9 +86,10 @@ def callback end rescue WebAuthn::Error => e render json: { - success: false, - message: "Verification failed: #{e.message}" - }, status: :unprocessable_entity + success: false, + message: "Verification failed: #{e.message}", + }, + status: :unprocessable_entity ensure session.delete(:current_authentication) end @@ -79,22 +106,18 @@ def session_params end def public_key_credential_params - # params.require(:publicKeyCredential).permit(:type, :id, :rawId, :authenticatorAttachment, - # :response, :userHandle, :clientExtensionResults) params.require(:publicKeyCredential) end sig { returns(T.nilable(String)) } def phone - @_phone ||= session_params[:phone]&.remove_non_digits + @phone ||= session_params[:phone]&.remove_non_digits end def verify_valid_phone - if phone.blank? || phone&.size != 10 - redirect_to(root_path, errors: { - phone: "Phone must be 10 digits." - }) - end + return unless phone.blank? || phone&.size != 10 + + redirect_to(root_path, errors: { phone: "Phone must be 10 digits." }) end end end diff --git a/app/controllers/well_known_controller.rb b/app/controllers/well_known_controller.rb index 68943c86..a6b84269 100644 --- a/app/controllers/well_known_controller.rb +++ b/app/controllers/well_known_controller.rb @@ -2,16 +2,14 @@ # https://web.dev/articles/webauthn-related-origin-requests class WellKnownController < ApplicationController - skip_before_action :authenticate_user! + skip_before_action :authenticate_sway_user! def index - Rails.logger.info("WellKnownController.index.request.host - #{request.host}") + Rails.logger.info( + "WellKnownController.index.request.host - #{request.host}", + ) if request.host == "app.sway.vote" - render json: { - origins: [ - "https://sway.vote" - ] - } + render json: { origins: ["https://sway.vote"] } end end end diff --git a/app/frontend/components/Layout.tsx b/app/frontend/components/Layout.tsx deleted file mode 100644 index 7f717e0b..00000000 --- a/app/frontend/components/Layout.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import Footer from "app/frontend/components/Footer"; -import AppDrawer from "app/frontend/components/drawer/AppDrawer"; -import React, { Fragment, PropsWithChildren } from "react"; - -interface IProps extends PropsWithChildren { - [key: string]: any; -} - -const Layout_: React.FC = ({ children, ...props }) => ( - - {React.Children.map(children, (child, i) => ( -
-
- {React.isValidElement(child) ? ( - React.cloneElement(child, { ...(child.props as Record), ...props }) - ) : ( - - )} -
-
- ))} - -