+<% end %>
\ No newline at end of file
diff --git a/app-rails/app/views/application/_header.html.erb b/app-rails/app/views/application/_header.html.erb
new file mode 100644
index 0000000..6a61cc3
--- /dev/null
+++ b/app-rails/app/views/application/_header.html.erb
@@ -0,0 +1,40 @@
+
+
+
\ No newline at end of file
diff --git a/app-rails/app/views/application/sandbox.html.erb b/app-rails/app/views/application/sandbox.html.erb
new file mode 100644
index 0000000..a4ce636
--- /dev/null
+++ b/app-rails/app/views/application/sandbox.html.erb
@@ -0,0 +1,35 @@
+<%# This page is for development purposes and is only available during local development %>
+
+
Sandbox
+
+
us_form_with
+
+<%= us_form_with url: user_session_en_path do |f| %>
+ <%= f.text_field :username, { label: "Name", hint: 'As shown on your legal ID'} %>
+ <%= f.email_field :email, { class: 'usa-input--md' } %>
+ <%= f.password_field :password, { hint: "Test hint" } %>
+
+ <%= f.fieldset "What's your age?" do %>
+ <%= f.radio_button :age, "child", { label: "I am younger than 21" } %>
+ <%= f.radio_button :age, "adult", { label: "I am over 21" } %>
+ <% end %>
+
+ <%= f.yes_no :has_previous_leave, {
+ yes_options: { label: "Yes, I've taken leave before", hint: "You may be eligible for additional benefits" },
+ no_options: { label: "No, I haven't taken leave before" }
+ } %>
+
+ <%= f.fieldset "What pets do you have?" do %>
+ <%= f.hint "Fieldset hint text here" %>
+ <%= f.check_box :pet_dog, { label: "I own a dog" } %>
+ <%= f.check_box :pet_cat, { label: "I own a cat", hint: "They have nine lives"} %>
+ <% end %>
+
+ <%= f.select :city, ["Berlin", "Chicago", "Madrid"] %>
+
+ <%= f.text_area :message %>
+
+ <%= f.file_field :picture %>
+
+ <%= f.submit 'Save and continue' %>
+<% end %>
diff --git a/app-rails/app/views/home/index.html.erb b/app-rails/app/views/home/index.html.erb
new file mode 100644
index 0000000..64ed7c7
--- /dev/null
+++ b/app-rails/app/views/home/index.html.erb
@@ -0,0 +1,72 @@
+<% content_for :title, t(".title") %>
+<%= content_for :main_col_class, "bg-base-lightest" %>
+
+
+ <%= t(".title") %>
+
+
+ <%= t('.intro') %>
+
+
+
+
+
+
+
+
+ <%= t('.applicant_heading') %>
+
+
+
+
+ <%= image_tag 'applicant.jpg', alt: "Man with two younger children" %>
+
+
+
+
+ <%= t('.applicant_body') %>
+
+
+
+
+
+
+
+
+
+ <%= t('.employer_heading') %>
+
+
+
+
+ <%= image_tag 'employer.jpg', alt: "Person at a desk using a laptop" %>
+
+
+
+
+ <%= t('.employer_body') %>
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app-rails/app/views/layouts/application.html.erb b/app-rails/app/views/layouts/application.html.erb
index a80e872..196a22c 100644
--- a/app-rails/app/views/layouts/application.html.erb
+++ b/app-rails/app/views/layouts/application.html.erb
@@ -1,16 +1,47 @@
+<%# Top-level layout for all pages in the application %>
-
+
- TemplateApplicationRails
+
+ <%= content_for?(:title) ? "#{ yield(:title) } | #{ t('header.title') }" : t("header.title") %>
+
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
+ <%= favicon_link_tag asset_path('@uswds/uswds/dist/img/us_flag_small.png') %>
+
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
+ <%= yield :head %>
+
<%= javascript_importmap_tags %>
+ <%= javascript_include_tag '@uswds/uswds/dist/js/uswds-init.min.js' %>
- <%= yield %>
+
+ <% end %>
+<% end %>
\ No newline at end of file
diff --git a/app-rails/app/views/users/registrations/new_account_verification.html.erb b/app-rails/app/views/users/registrations/new_account_verification.html.erb
new file mode 100644
index 0000000..973bace
--- /dev/null
+++ b/app-rails/app/views/users/registrations/new_account_verification.html.erb
@@ -0,0 +1,26 @@
+<% content_for :title, t(".title") %>
+
+
\ No newline at end of file
diff --git a/app-rails/bin/db-migrate b/app-rails/bin/db-migrate
new file mode 100755
index 0000000..139f271
--- /dev/null
+++ b/app-rails/bin/db-migrate
@@ -0,0 +1,9 @@
+#!/bin/sh
+echo "Running migrations..."
+echo " DB_HOST=$DB_HOST"
+echo " DB_PORT=$DB_PORT"
+echo " DB_USER=$DB_USER"
+echo " DB_NAME=$DB_NAME"
+echo " DB_SCHEMA=$DB_SCHEMA"
+
+./bin/rake --trace db:migrate
diff --git a/app-rails/bin/dev b/app-rails/bin/dev
index 74ade16..edca718 100755
--- a/app-rails/bin/dev
+++ b/app-rails/bin/dev
@@ -1,8 +1,16 @@
#!/usr/bin/env sh
-if ! gem list foreman -i --silent; then
- echo "Installing foreman..."
- gem install foreman
+if gem list --no-installed --exact --silent overman; then
+ echo "Installing overman..."
+ gem install overman
fi
-exec foreman start -f Procfile.dev "$@"
+# Default to port 3000 if not specified
+export PORT="${PORT:-3000}"
+export RAILS_BINDING="${RAILS_BINDING:-localhost}"
+
+# -e /dev/null to disable the limited/broken .env file loading logic, which does
+# not respect existing env vars
+#
+# https://github.com/ddollar/foreman/pull/711
+exec overman start -f Procfile.dev -e /dev/null "$@"
diff --git a/app-rails/bin/rspec b/app-rails/bin/rspec
new file mode 100755
index 0000000..cc82ce7
--- /dev/null
+++ b/app-rails/bin/rspec
@@ -0,0 +1,7 @@
+#!/usr/bin/env sh
+
+# Since CI runs tests from within the container, which
+# sets RAILS_ENV to development, we need to override it:
+export RAILS_ENV=test
+
+bundle exec rspec --format documentation
diff --git a/app-rails/bin/rubocop b/app-rails/bin/rubocop
new file mode 100755
index 0000000..369a05b
--- /dev/null
+++ b/app-rails/bin/rubocop
@@ -0,0 +1,27 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+#
+# This file was generated by Bundler.
+#
+# The application 'rubocop' is installed as part of a gem, and
+# this file is here to facilitate running it.
+#
+
+ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
+
+bundle_binstub = File.expand_path("bundle", __dir__)
+
+if File.file?(bundle_binstub)
+ if File.read(bundle_binstub, 300).include?("This file was generated by Bundler")
+ load(bundle_binstub)
+ else
+ abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
+Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
+ end
+end
+
+require "rubygems"
+require "bundler/setup"
+
+load Gem.bin_path("rubocop", "rubocop")
diff --git a/app-rails/bin/wait-for-local-postgres.sh b/app-rails/bin/wait-for-local-postgres.sh
new file mode 100755
index 0000000..049073f
--- /dev/null
+++ b/app-rails/bin/wait-for-local-postgres.sh
@@ -0,0 +1,51 @@
+#!/bin/bash
+# wait-for-local-postgres
+
+set -e
+
+# Color formatting
+RED='\033[0;31m'
+NO_COLOR='\033[0m'
+
+MAX_WAIT_TIME=30 # seconds
+wait_time=0
+
+# If you run your DB on a port other than 5432, you'll need to specify
+# the port's environment variable you want to use for this to work properly
+# "export DB_PORT="
+DB_PORT="${DB_PORT:=5432}"
+
+# Support other container tools like `finch`
+DOCKER_CMD="${CONTAINER_CMD:=docker}"
+
+# If pg_isready isn't available, the loop would just keep going until it fails
+# instead just do a sleep and tell the user to install it. Not as good, but shouldn't
+# block developers who are just getting started this way
+if ! command -v pg_isready &>/dev/null; then
+ echo -e "${RED}Warning:${NO_COLOR} Postgres has not been installed locally, cannot use pg_isready to check if DB is available."
+ echo ""
+ echo "Please install postgresql:"
+ echo " MacOS: 'brew install postgresql'"
+ echo " Linux: 'sudo apt install postgresql-client-14 postgresql-client-common'"
+ echo ""
+ echo "Sleeping for 5 seconds instead"
+ sleep 5
+ exit 0
+fi
+
+# Use pg_isready to wait for the DB to be ready to accept connections
+# We check every 3 seconds and consider it failed if it gets to 30+
+# https://www.postgresql.org/docs/current/app-pg-isready.html
+until pg_isready -h localhost -p $DB_PORT -d local-postgres-db -q; do
+ echo "waiting on Postgres DB to initialize..."
+ sleep 3
+
+ wait_time=$(($wait_time + 3))
+ if [ $wait_time -gt $MAX_WAIT_TIME ]; then
+ echo -e "${RED}ERROR: Database appears to not be starting up, running \"${DOCKER_CMD} logs main-db\" to troubleshoot${NO_COLOR}"
+ ${DOCKER_CMD} logs database
+ exit 1
+ fi
+done
+
+echo "Postgres DB is ready after ~${wait_time} seconds"
diff --git a/app-rails/config/application.rb b/app-rails/config/application.rb
index 5448663..833da55 100644
--- a/app-rails/config/application.rb
+++ b/app-rails/config/application.rb
@@ -8,13 +8,28 @@
module TemplateApplicationRails
class Application < Rails::Application
+ # Internationalization
+ I18n.available_locales = [ :"en", :"es-US" ]
+ I18n.default_locale = :"en"
+ I18n.enforce_available_locales = true
+
+ # Support nested locale files
+ config.i18n.load_path += Dir[Rails.root.join("config", "locales", "**", "*.{rb,yml}")]
+
# Initialize configuration defaults for originally generated Rails version.
config.load_defaults 7.1
# Please, add to the `ignore` list any other `lib` subdirectories that do
# not contain `.rb` files, or that should not be reloaded or eager loaded.
# Common ones are `templates`, `generators`, or `middleware`, for example.
- config.autoload_lib(ignore: %w(assets tasks))
+ config.autoload_lib(ignore: %w[assets tasks generators])
+
+ # Prevent the form_with helper from wrapping input and labels with separate
+ # div elements when an error is present, since this breaks USWDS styling
+ # and functionality.
+ config.action_view.field_error_proc = Proc.new { |html_tag, instance|
+ html_tag
+ }
# Configuration for the application, engines, and railties goes here.
#
@@ -23,5 +38,16 @@ class Application < Rails::Application
#
# config.time_zone = "Central Time (US & Canada)"
# config.eager_load_paths << Rails.root.join("extras")
+
+ config.generators do |g|
+ g.factory_bot suffix: "factory"
+ end
+
+ # Support UUID generation. This was a callout in the ActiveStorage guide
+ # https://edgeguides.rubyonrails.org/active_storage_overview.html#setup
+ Rails.application.config.generators { |g| g.orm :active_record, primary_key_type: :uuid }
+
+ # Show a 403 Forbidden error page when Pundit raises a NotAuthorizedError
+ config.action_dispatch.rescue_responses["Pundit::NotAuthorizedError"] = :forbidden
end
end
diff --git a/app-rails/config/cable.yml b/app-rails/config/cable.yml
index 8979b1b..7e66857 100644
--- a/app-rails/config/cable.yml
+++ b/app-rails/config/cable.yml
@@ -7,4 +7,4 @@ test:
production:
adapter: redis
url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
- channel_prefix: template_application_rails_production
+ channel_prefix: app_rails_production
diff --git a/app-rails/config/database.yml b/app-rails/config/database.yml
index b2e35d7..868317a 100644
--- a/app-rails/config/database.yml
+++ b/app-rails/config/database.yml
@@ -19,15 +19,22 @@ default: &default
# https://guides.rubyonrails.org/configuring.html#database-pooling
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
+ # Support environment variables. Set defaults.
+ database: <%= ENV.fetch("DB_NAME") { nil } %>
+ username: <%= ENV.fetch("DB_USER") { nil } %>
+ password: <%= ENV.fetch("DB_PASSWORD") { nil } %>
+ host: <%= ENV.fetch("DB_HOST") { "localhost" } %>
+ port: <%= ENV.fetch("DB_PORT") { 5432 } %>
+ schema_search_path: <%= ENV.fetch("DB_SCHEMA") { public } %>
+
development:
<<: *default
- database: template_application_rails_development
# The specified database role being used to connect to PostgreSQL.
# To create additional roles in PostgreSQL see `$ createuser --help`.
# When left blank, PostgreSQL will use the default role. This is
# the same name as the operating system user running Rails.
- #username: template_application_rails
+ #username: app_rails
# The password associated with the PostgreSQL role (username).
#password:
@@ -55,7 +62,12 @@ development:
# Do not set this db to the same as development or production.
test:
<<: *default
- database: template_application_rails_test
+ database: app_rails_test
+
+# For mock-production: To test a "production"-like environment on a local machine,
+# allow the use of a non-RDS database.
+mock-production:
+ <<: *default
# As with config/credentials.yml, you never want to store sensitive information,
# like your database password, in your source code. If your source code is
@@ -79,6 +91,6 @@ test:
#
production:
<<: *default
- database: template_application_rails_production
- username: template_application_rails
- password: <%= ENV["TEMPLATE_APPLICATION_RAILS_DATABASE_PASSWORD"] %>
+ # Enable AWS RDS IAM Auth token generator
+ # For more details, see: https://github.com/haines/pg-aws_rds_iam
+ aws_rds_iam_auth_token_generator: default
diff --git a/app-rails/config/environments/development.rb b/app-rails/config/environments/development.rb
index 2e7fb48..abaf811 100644
--- a/app-rails/config/environments/development.rb
+++ b/app-rails/config/environments/development.rb
@@ -1,5 +1,8 @@
require "active_support/core_ext/integer/time"
+# Custom setting: set the default url.
+Rails.application.default_url_options = { host: ENV["APP_HOST"], port: ENV["APP_PORT"] }
+
Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb.
@@ -33,8 +36,9 @@
config.cache_store = :null_store
end
- # Store uploaded files on the local file system (see config/storage.yml for options).
- config.active_storage.service = :local
+ config.active_storage.service = ENV["AWS_BUCKET_NAME"] ? :amazon : :local
+
+ config.action_mailer.delivery_method = ENV["SES_EMAIL"] ? :sesv2 : :letter_opener
# Don't care if the mailer can't send.
config.action_mailer.raise_delivery_errors = false
@@ -72,5 +76,5 @@
# config.action_cable.disable_request_forgery_protection = true
# Raise error when a before_action's only/except options reference missing actions
- config.action_controller.raise_on_missing_callback_actions = true
+ # config.action_controller.raise_on_missing_callback_actions = true
end
diff --git a/app-rails/config/environments/mock-production.rb b/app-rails/config/environments/mock-production.rb
new file mode 100644
index 0000000..ecd6917
--- /dev/null
+++ b/app-rails/config/environments/mock-production.rb
@@ -0,0 +1,103 @@
+require "active_support/core_ext/integer/time"
+
+# Custom setting: set the default url.
+Rails.application.default_url_options = { host: ENV["APP_HOST"], port: ENV["APP_PORT"] }
+
+Rails.application.configure do
+ # Settings specified here will take precedence over those in config/application.rb.
+
+ # Code is not reloaded between requests.
+ config.enable_reloading = false
+
+ # Eager load code on boot. This eager loads most of Rails and
+ # your application in memory, allowing both threaded web servers
+ # and those relying on copy on write to perform better.
+ # Rake tasks automatically ignore this option for performance.
+ config.eager_load = true
+
+ # Full error reports are disabled and caching is turned on.
+ config.consider_all_requests_local = false
+ config.action_controller.perform_caching = true
+
+ # Ensures that a master key has been made available in ENV["RAILS_MASTER_KEY"], config/master.key, or an environment
+ # key such as config/credentials/production.key. This key is used to decrypt credentials (and other encrypted files).
+ # config.require_master_key = true
+
+ # Disable serving static files from `public/`, relying on NGINX/Apache to do so instead.
+ # config.public_file_server.enabled = false
+
+ # Compress CSS using a preprocessor.
+ # config.assets.css_compressor = :sass
+
+ # Do not fall back to assets pipeline if a precompiled asset is missed.
+ config.assets.compile = false
+
+ # Enable serving of images, stylesheets, and JavaScripts from an asset server.
+ # config.asset_host = "http://assets.example.com"
+
+ # Specifies the header that your server uses for sending files.
+ # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for Apache
+ # config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX
+
+ # Store uploaded files on the local file system (see config/storage.yml for options).
+ config.active_storage.service = :amazon
+
+ # Mount Action Cable outside main process or domain.
+ # config.action_cable.mount_path = nil
+ # config.action_cable.url = "wss://example.com/cable"
+ # config.action_cable.allowed_request_origins = [ "http://example.com", /http:\/\/example.*/ ]
+
+ # Assume all access to the app is happening through a SSL-terminating reverse proxy.
+ # Can be used together with config.force_ssl for Strict-Transport-Security and secure cookies.
+ # For mock-production: disable assume SSL to be able to test on a local machine
+ config.assume_ssl = false
+
+ # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
+ # For mock-production: disable SSL to be able to test on a local machine
+ config.force_ssl = false
+
+ # Log to STDOUT by default
+ config.logger = ActiveSupport::Logger.new(STDOUT)
+ .tap { |logger| logger.formatter = ::Logger::Formatter.new }
+ .then { |logger| ActiveSupport::TaggedLogging.new(logger) }
+
+ # Prepend all log lines with the following tags.
+ config.log_tags = [ :request_id ]
+
+ # "info" includes generic and useful information about system operation, but avoids logging too much
+ # information to avoid inadvertent exposure of personally identifiable information (PII). If you
+ # want to log everything, set the level to "debug".
+ config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info")
+
+ # Use a different cache store in production.
+ # config.cache_store = :mem_cache_store
+
+ # Use a real queuing backend for Active Job (and separate queues per environment).
+ # config.active_job.queue_adapter = :resque
+ # config.active_job.queue_name_prefix = "app_rails_production"
+
+ config.action_mailer.delivery_method = :sesv2
+ config.action_mailer.perform_caching = false
+
+ # Ignore bad email addresses and do not raise email delivery errors.
+ # Set this to true and configure the email server for immediate delivery to raise delivery errors.
+ # config.action_mailer.raise_delivery_errors = false
+
+ # Enable locale fallbacks for I18n (makes lookups for any locale fall back to
+ # the I18n.default_locale when a translation cannot be found).
+ config.i18n.fallbacks = true
+
+ # Don't log any deprecations.
+ config.active_support.report_deprecations = false
+
+ # Do not dump schema after migrations.
+ config.active_record.dump_schema_after_migration = false
+
+ # Enable DNS rebinding protection and other `Host` header attacks.
+ # config.hosts = [
+ # "example.com", # Allow requests from example.com
+ # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com`
+ # ]
+ # Skip DNS rebinding protection for the default health check endpoint.
+ # config.host_authorization = { exclude: ->(request) { request.path == "/up" } }
+end
diff --git a/app-rails/config/environments/production.rb b/app-rails/config/environments/production.rb
index bc10944..085748c 100644
--- a/app-rails/config/environments/production.rb
+++ b/app-rails/config/environments/production.rb
@@ -1,5 +1,8 @@
require "active_support/core_ext/integer/time"
+# Custom setting: set the default url.
+Rails.application.default_url_options = { host: ENV["APP_HOST"], port: ENV["APP_PORT"] }
+
Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb.
@@ -37,7 +40,7 @@
# config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX
# Store uploaded files on the local file system (see config/storage.yml for options).
- config.active_storage.service = :local
+ config.active_storage.service = :amazon
# Mount Action Cable outside main process or domain.
# config.action_cable.mount_path = nil
@@ -69,8 +72,9 @@
# Use a real queuing backend for Active Job (and separate queues per environment).
# config.active_job.queue_adapter = :resque
- # config.active_job.queue_name_prefix = "template_application_rails_production"
+ # config.active_job.queue_name_prefix = "app_rails_production"
+ config.action_mailer.delivery_method = :sesv2
config.action_mailer.perform_caching = false
# Ignore bad email addresses and do not raise email delivery errors.
diff --git a/app-rails/config/environments/test.rb b/app-rails/config/environments/test.rb
index adbb4a6..785ed87 100644
--- a/app-rails/config/environments/test.rb
+++ b/app-rails/config/environments/test.rb
@@ -5,6 +5,9 @@
# your test database is "scratch space" for the test suite and is wiped
# and recreated between test runs. Don't rely on the data there!
+# Custom setting: set the default url.
+Rails.application.default_url_options[:host] = "localhost"
+
Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb.
@@ -60,5 +63,5 @@
# config.action_view.annotate_rendered_view_with_filenames = true
# Raise error when a before_action's only/except options reference missing actions
- config.action_controller.raise_on_missing_callback_actions = true
+ # config.action_controller.raise_on_missing_callback_actions = true
end
diff --git a/app-rails/config/initializers/active_storage_attachment_callbacks.rb b/app-rails/config/initializers/active_storage_attachment_callbacks.rb
new file mode 100644
index 0000000..2e79919
--- /dev/null
+++ b/app-rails/config/initializers/active_storage_attachment_callbacks.rb
@@ -0,0 +1,37 @@
+# If a model uses Active Storage and it defines any of the defined methods (after_commit,
+# after_create_commit, or after_update_commit), then those methods will be called after
+# the attachment has been fully committed to database. This allows for post-processing
+# after uploads, since uploads are not immediately available.
+#
+# 🎩 Hat tip and thanks to:
+# - https://redgreen.no/2021/01/25/active-storage-callbacks.html
+# - https://stackoverflow.com/questions/53226228/callback-for-active-storage-file-upload
+Rails.configuration.to_prepare do
+ module ActiveStorage::Attachment::Callbacks
+ # Gives us some convenient shortcuts, like `prepended`
+ extend ActiveSupport::Concern
+
+ # When prepended into a class, define our callback
+ prepended do
+ after_commit :after_commit
+ after_create_commit :after_create_commit
+ after_update_commit :after_update_commit
+ end
+
+ # Callback methods
+ def after_commit
+ record.after_attachment_commit(self) if record.respond_to? :after_attachment_commit
+ end
+
+ def after_create_commit
+ record.after_attachment_create_commit(self) if record.respond_to? :after_attachment_create_commit
+ end
+
+ def after_update_commit
+ record.after_attachment_update_commit(self) if record.respond_to? :after_attachment_update_commit
+ end
+ end
+
+ # After defining the module, call on ActiveStorage::Attachment to prepend it in.
+ ActiveStorage::Attachment.prepend ActiveStorage::Attachment::Callbacks
+end
diff --git a/app-rails/config/initializers/assets.rb b/app-rails/config/initializers/assets.rb
index 2eeef96..f0e0ae0 100644
--- a/app-rails/config/initializers/assets.rb
+++ b/app-rails/config/initializers/assets.rb
@@ -5,8 +5,10 @@
# Add additional assets to the asset load path.
# Rails.application.config.assets.paths << Emoji.images_path
+Rails.application.config.assets.paths << Rails.root.join("node_modules")
# Precompile additional assets.
# application.js, application.css, and all non-JS/CSS in the app/assets
# folder are already added.
# Rails.application.config.assets.precompile += %w( admin.js admin.css )
+Rails.application.config.assets.paths << Rails.root.join("node_modules/@fortawesome/fontawesome-free/webfonts")
diff --git a/app-rails/config/initializers/devise.rb b/app-rails/config/initializers/devise.rb
new file mode 100644
index 0000000..b0dbf4d
--- /dev/null
+++ b/app-rails/config/initializers/devise.rb
@@ -0,0 +1,301 @@
+# frozen_string_literal: true
+
+# Use this hook to configure devise mailer, warden hooks and so forth.
+# Many of these configuration options can be set straight in your model.
+Devise.setup do |config|
+ # ==> Support AWS Cognito login
+ Devise.add_module :cognito_authenticatable, controller: :sessions, route: :session
+
+ # ==> Configuration for :timeoutable
+ # The time you want to timeout the user session without activity. After this
+ # time the user will be asked for credentials again. Default is 30 minutes.
+ config.timeout_in = 15.minutes
+
+ # ==> Navigation configuration
+ # Lists the formats that should be treated as navigational. Formats like
+ # :html should redirect to the sign in page when the user does not have
+ # access, but formats like :xml or :json, should return 401.
+ #
+ # If you have any extra navigational formats, like :iphone or :mobile, you
+ # should add them to the navigational formats lists.
+ #
+ # The "*/*" below is required to match Internet Explorer requests.
+ # config.navigational_formats = ['*/*', :html, :turbo_stream]
+
+ # The default HTTP method used to sign out a resource. Default is :delete.
+ config.sign_out_via = :delete
+
+ # ==> Hotwire/Turbo configuration
+ # When using Devise with Hotwire/Turbo, the http status for error responses
+ # and some redirects must match the following. The default in Devise for existing
+ # apps is `200 OK` and `302 Found` respectively, but new apps are generated with
+ # these new defaults that match Hotwire/Turbo behavior.
+ # Note: These might become the new default in future versions of Devise.
+ config.responder.error_status = :unprocessable_entity
+ config.responder.redirect_status = :see_other
+
+ # ==> ORM configuration
+ # Load and configure the ORM. Supports :active_record (default) and
+ # :mongoid (bson_ext recommended) by default. Other ORMs may be
+ # available as additional gems.
+ require "devise/orm/active_record"
+
+ # By default Devise will store the user in session. You can skip storage for
+ # particular strategies by setting this option.
+ # Notice that if you are skipping storage for all authentication paths, you
+ # may want to disable generating routes to Devise's sessions controller by
+ # passing skip: :sessions to `devise_for` in your config/routes.rb
+ config.skip_session_storage = [ :http_auth ]
+
+ # ==> Configuration for :rememberable
+ # The time the user will be remembered without asking for credentials again.
+ # config.remember_for = 2.weeks
+
+ # Invalidates all the remember me tokens when the user signs out.
+ config.expire_all_remember_me_on_sign_out = true
+
+ # If true, extends the user's remember period when remembered via cookie.
+ # config.extend_remember_period = false
+
+ # Options to be passed to the created cookie. For instance, you can set
+ # secure: true in order to force SSL only cookies.
+ # config.rememberable_options = {}
+
+ # The secret key used by Devise. Devise uses this key to generate
+ # random tokens. Changing this key will render invalid all existing
+ # confirmation, reset password and unlock tokens in the database.
+ # Devise will use the `secret_key_base` as its `secret_key`
+ # by default. You can change it below and use your own secret key.
+ # config.secret_key = '95130f242f8b18bee7a505a7b6819dcdc2f274771201c39b2627425f38e5e81e060c48c839815f975aeabe26d55de0b00243fd1b5b4170c3a117102e57308660'
+
+ # ==> Controller configuration
+ # Configure the parent class to the devise controllers.
+ # config.parent_controller = 'DeviseController'
+
+ # ==> Mailer Configuration
+ # Configure the e-mail address which will be shown in Devise::Mailer,
+ # note that it will be overwritten if you use your own mailer class
+ # with default "from" parameter.
+ # config.mailer_sender = 'please-change-me-at-config-initializers-devise@example.com'
+
+ # Configure the class responsible to send e-mails.
+ # config.mailer = 'Devise::Mailer'
+
+ # Configure the parent class responsible to send e-mails.
+ # config.parent_mailer = 'ActionMailer::Base'
+
+ # ==> Configuration for any authentication mechanism
+ # Configure which keys are used when authenticating a user. The default is
+ # just :email. You can configure it to use [:username, :subdomain], so for
+ # authenticating a user, both parameters are required. Remember that those
+ # parameters are used only when authenticating and not when retrieving from
+ # session. If you need permissions, you should implement that in a before filter.
+ # You can also supply a hash where the value is a boolean determining whether
+ # or not authentication should be aborted when the value is not present.
+ # config.authentication_keys = [:email]
+
+ # Configure parameters from the request object used for authentication. Each entry
+ # given should be a request method and it will automatically be passed to the
+ # find_for_authentication method and considered in your model lookup. For instance,
+ # if you set :request_keys to [:subdomain], :subdomain will be used on authentication.
+ # The same considerations mentioned for authentication_keys also apply to request_keys.
+ # config.request_keys = []
+
+ # Configure which authentication keys should be case-insensitive.
+ # These keys will be downcased upon creating or modifying a user and when used
+ # to authenticate or find a user. Default is :email.
+ # config.case_insensitive_keys = [:email]
+
+ # Configure which authentication keys should have whitespace stripped.
+ # These keys will have whitespace before and after removed upon creating or
+ # modifying a user and when used to authenticate or find a user. Default is :email.
+ # config.strip_whitespace_keys = [ :email ]
+
+ # Tell if authentication through request.params is enabled. True by default.
+ # It can be set to an array that will enable params authentication only for the
+ # given strategies, for example, `config.params_authenticatable = [:database]` will
+ # enable it only for database (email + password) authentication.
+ # config.params_authenticatable = true
+
+ # Tell if authentication through HTTP Auth is enabled. False by default.
+ # It can be set to an array that will enable http authentication only for the
+ # given strategies, for example, `config.http_authenticatable = [:database]` will
+ # enable it only for database authentication.
+ # For API-only applications to support authentication "out-of-the-box", you will likely want to
+ # enable this with :database unless you are using a custom strategy.
+ # The supported strategies are:
+ # :database = Support basic authentication with authentication key + password
+ # config.http_authenticatable = false
+
+ # If 401 status code should be returned for AJAX requests. True by default.
+ # config.http_authenticatable_on_xhr = true
+
+ # The realm used in Http Basic Authentication. 'Application' by default.
+ # config.http_authentication_realm = 'Application'
+
+ # It will change confirmation, password recovery and other workflows
+ # to behave the same regardless if the e-mail provided was right or wrong.
+ # Does not affect registerable.
+ # config.paranoid = true
+
+ # By default, Devise cleans up the CSRF token on authentication to
+ # avoid CSRF token fixation attacks. This means that, when using AJAX
+ # requests for sign in and sign up, you need to get a new CSRF token
+ # from the server. You can disable this option at your own risk.
+ # config.clean_up_csrf_token_on_authentication = true
+
+ # When false, Devise will not attempt to reload routes on eager load.
+ # This can reduce the time taken to boot the app but if your application
+ # requires the Devise mappings to be loaded during boot time the application
+ # won't boot properly.
+ # config.reload_routes = true
+
+ # ==> Configuration for :database_authenticatable
+ # For bcrypt, this is the cost for hashing the password and defaults to 12. If
+ # using other algorithms, it sets how many times you want the password to be hashed.
+ # The number of stretches used for generating the hashed password are stored
+ # with the hashed password. This allows you to change the stretches without
+ # invalidating existing passwords.
+ #
+ # Limiting the stretches to just one in testing will increase the performance of
+ # your test suite dramatically. However, it is STRONGLY RECOMMENDED to not use
+ # a value less than 10 in other environments. Note that, for bcrypt (the default
+ # algorithm), the cost increases exponentially with the number of stretches (e.g.
+ # a value of 20 is already extremely slow: approx. 60 seconds for 1 calculation).
+ # config.stretches = Rails.env.test? ? 1 : 12
+
+ # Set up a pepper to generate the hashed password.
+ # config.pepper = '3726e219e02fbaed55cfcd17bdec435e8af363d24585278cde8f03f826f9e1a56f6fdd7bb487e440ac057d78d1d9aeff96756d8daf8b962b52c0e1b5bad25f27'
+
+ # Send a notification to the original email when the user's email is changed.
+ # config.send_email_changed_notification = false
+
+ # Send a notification email when the user's password is changed.
+ # config.send_password_change_notification = false
+
+ # ==> Configuration for :confirmable
+ # A period that the user is allowed to access the website even without
+ # confirming their account. For instance, if set to 2.days, the user will be
+ # able to access the website for two days without confirming their account,
+ # access will be blocked just in the third day.
+ # You can also set it to nil, which will allow the user to access the website
+ # without confirming their account.
+ # Default is 0.days, meaning the user cannot access the website without
+ # confirming their account.
+ # config.allow_unconfirmed_access_for = 2.days
+
+ # A period that the user is allowed to confirm their account before their
+ # token becomes invalid. For example, if set to 3.days, the user can confirm
+ # their account within 3 days after the mail was sent, but on the fourth day
+ # their account can't be confirmed with the token any more.
+ # Default is nil, meaning there is no restriction on how long a user can take
+ # before confirming their account.
+ # config.confirm_within = 3.days
+
+ # If true, requires any email changes to be confirmed (exactly the same way as
+ # initial account confirmation) to be applied. Requires additional unconfirmed_email
+ # db field (see migrations). Until confirmed, new email is stored in
+ # unconfirmed_email column, and copied to email column on successful confirmation.
+ # config.reconfirmable = true
+
+ # Defines which key will be used when confirming an account
+ # config.confirmation_keys = [:email]
+
+ # ==> Configuration for :validatable
+ # Range for password length.
+ # config.password_length = 6..128
+
+ # Email regex used to validate email formats. It simply asserts that
+ # one (and only one) @ exists in the given string. This is mainly
+ # to give user feedback and not to assert the e-mail validity.
+ # config.email_regexp = /\A[^@\s]+@[^@\s]+\z/
+
+ # ==> Configuration for :lockable
+ # Defines which strategy will be used to lock an account.
+ # :failed_attempts = Locks an account after a number of failed attempts to sign in.
+ # :none = No lock strategy. You should handle locking by yourself.
+ # config.lock_strategy = :failed_attempts
+
+ # Defines which key will be used when locking and unlocking an account
+ # config.unlock_keys = [:email]
+
+ # Defines which strategy will be used to unlock an account.
+ # :email = Sends an unlock link to the user email
+ # :time = Re-enables login after a certain amount of time (see :unlock_in below)
+ # :both = Enables both strategies
+ # :none = No unlock strategy. You should handle unlocking by yourself.
+ # config.unlock_strategy = :both
+
+ # Number of authentication tries before locking an account if lock_strategy
+ # is failed attempts.
+ # config.maximum_attempts = 20
+
+ # Time interval to unlock the account if :time is enabled as unlock_strategy.
+ # config.unlock_in = 1.hour
+
+ # Warn on the last attempt before the account is locked.
+ # config.last_attempt_warning = true
+
+ # ==> Configuration for :recoverable
+ #
+ # Defines which key will be used when recovering the password for an account
+ # config.reset_password_keys = [:email]
+
+ # Time interval you can reset your password with a reset password key.
+ # Don't put a too small interval or your users won't have the time to
+ # change their passwords.
+ # config.reset_password_within = 6.hours
+
+ # When set to false, does not sign a user in automatically after their password is
+ # reset. Defaults to true, so a user is signed in automatically after a reset.
+ # config.sign_in_after_reset_password = true
+
+ # ==> Configuration for :encryptable
+ # Allow you to use another hashing or encryption algorithm besides bcrypt (default).
+ # You can use :sha1, :sha512 or algorithms from others authentication tools as
+ # :clearance_sha1, :authlogic_sha512 (then you should set stretches above to 20
+ # for default behavior) and :restful_authentication_sha1 (then you should set
+ # stretches to 10, and copy REST_AUTH_SITE_KEY to pepper).
+ #
+ # Require the `devise-encryptable` gem when using anything other than bcrypt
+ # config.encryptor = :sha512
+
+ # ==> Scopes configuration
+ # Turn scoped views on. Before rendering "sessions/new", it will first check for
+ # "users/sessions/new". It's turned off by default because it's slower if you
+ # are using only default views.
+ # config.scoped_views = false
+
+ # Configure the default scope given to Warden. By default it's the first
+ # devise role declared in your routes (usually :user).
+ # config.default_scope = :user
+
+ # Set this configuration to false if you want /users/sign_out to sign out
+ # only the current scope. By default, Devise signs out all scopes.
+ # config.sign_out_all_scopes = true
+
+ # ==> OmniAuth
+ # Add a new OmniAuth provider. Check the wiki for more information on setting
+ # up on your models and hooks.
+ # config.omniauth :github, 'APP_ID', 'APP_SECRET', scope: 'user,public_repo'
+
+ # ==> Mountable engine configurations
+ # When using Devise inside an engine, let's call it `MyEngine`, and this engine
+ # is mountable, there are some extra configurations to be taken into account.
+ # The following options are available, assuming the engine is mounted as:
+ #
+ # mount MyEngine, at: '/my_engine'
+ #
+ # The router that invoked `devise_for`, in the example above, would be:
+ # config.router_name = :my_engine
+ #
+ # When using OmniAuth, Devise cannot automatically set OmniAuth path,
+ # so you need to do it manually. For the users scope, it would be:
+ # config.omniauth_path_prefix = '/my_engine/users/auth'
+
+ # ==> Configuration for :registerable
+
+ # When set to false, does not sign a user in automatically after their password is
+ # changed. Defaults to true, so a user is signed in automatically after changing a password.
+ # config.sign_in_after_change_password = true
+end
diff --git a/app-rails/config/initializers/uswds.rb b/app-rails/config/initializers/uswds.rb
new file mode 100644
index 0000000..f49c8ea
--- /dev/null
+++ b/app-rails/config/initializers/uswds.rb
@@ -0,0 +1,9 @@
+# Ensure the USWDS dist folder is compiled as part of assets.
+#
+# This serves a couple different purposes:
+# - Ensures that images and fonts are exposed properly on the client-side,
+# since USWDS CSS references specific assets for their design components.
+# - Ensures that the js files are exposed properly for use in the application layout.
+#
+# See also: app/assets/stylesheets/_uswds-theme.scss
+Rails.application.config.assets.paths << Rails.root.join("node_modules/@uswds/uswds/dist")
diff --git a/app-rails/config/locales/defaults/en.yml b/app-rails/config/locales/defaults/en.yml
new file mode 100644
index 0000000..8dad02d
--- /dev/null
+++ b/app-rails/config/locales/defaults/en.yml
@@ -0,0 +1,59 @@
+en:
+ activerecord:
+ errors:
+ messages:
+ # Override error messages: https://guides.rubyonrails.org/i18n.html#error-message-interpolation
+ # blank: "'%{attribute}' can't be blank"
+ #
+ # i18n strings for ActiveStorageValidation
+ # See: https://github.com/igorkasyanchuk/active_storage_validations
+ content_type_invalid: "has an invalid content type"
+ file_size_not_less_than: "file size must be less than %{max_size} (current size is %{file_size})"
+ file_size_not_less_than_or_equal_to: "file size must be less than or equal to %{max_size} (current size is %{file_size})"
+ file_size_not_greater_than: "file size must be greater than %{min_size} (current size is %{file_size})"
+ file_size_not_greater_than_or_equal_to: "file size must be greater than or equal to %{min_size} (current size is %{file_size})"
+ file_size_not_between: "file size must be between %{min_size} and %{max_size} (current size is %{file_size})"
+ limit_out_of_range: "total number is out of range"
+ image_metadata_missing: "is not a valid image"
+ dimension_min_inclusion: "must be greater than or equal to %{width} x %{height} pixel."
+ dimension_max_inclusion: "must be less than or equal to %{width} x %{height} pixel."
+ dimension_width_inclusion: "width is not included between %{min} and %{max} pixel."
+ dimension_height_inclusion: "height is not included between %{min} and %{max} pixel."
+ dimension_width_greater_than_or_equal_to: "width must be greater than or equal to %{length} pixel."
+ dimension_height_greater_than_or_equal_to: "height must be greater than or equal to %{length} pixel."
+ dimension_width_less_than_or_equal_to: "width must be less than or equal to %{length} pixel."
+ dimension_height_less_than_or_equal_to: "height must be less than or equal to %{length} pixel."
+ dimension_width_equal_to: "width must be equal to %{length} pixel."
+ dimension_height_equal_to: "height must be equal to %{length} pixel."
+ aspect_ratio_not_square: "must be a square image"
+ aspect_ratio_not_portrait: "must be a portrait image"
+ aspect_ratio_not_landscape: "must be a landscape image"
+ aspect_ratio_is_not: "must have an aspect ratio of %{aspect_ratio}"
+ aspect_ratio_unknown: "has an unknown aspect ratio"
+ image_not_processable: "is not a valid image"
+ auth:
+ errors:
+ messages:
+ code_mismatch: "The code you entered is incorrect"
+ code_expired: "The code you entered has expired"
+ invalid_credentials: "Invalid email or password"
+ invalid_software_token: "Invalid code. Please try again."
+ invalid_password_format: "Password must be at least 12 characters long"
+ username_exists: "An account with the given email already exists"
+ user_not_confirmed: "You must confirm your email address first"
+ date:
+ formats:
+ default: "%m/%d/%Y"
+ short: "%b %d"
+ long: "%B %d, %Y"
+ helpers:
+ submit:
+ create: "Submit"
+ update: "Update"
+ time:
+ formats:
+ default: "%B %d, %Y"
+ long: "%B %d, %Y %-I:%M %p"
+ us_form_with:
+ boolean_true: "Yes"
+ boolean_false: "No"
diff --git a/app-rails/config/locales/defaults/es-US.yml b/app-rails/config/locales/defaults/es-US.yml
new file mode 100644
index 0000000..3146c4e
--- /dev/null
+++ b/app-rails/config/locales/defaults/es-US.yml
@@ -0,0 +1,17 @@
+es-US:
+ date:
+ formats:
+ default: "%m/%d/%Y"
+ short: "%b %d"
+ long: "%B %d, %Y"
+ helpers:
+ submit:
+ create: "Submit"
+ update: "Update"
+ time:
+ formats:
+ default: "%B %d, %Y"
+ long: "%B %d, %Y %-I:%M %p"
+ us_form_with:
+ boolean_true: "Sí"
+ boolean_false: "No"
diff --git a/app-rails/config/locales/devise.en.yml b/app-rails/config/locales/devise.en.yml
new file mode 100644
index 0000000..260e1c4
--- /dev/null
+++ b/app-rails/config/locales/devise.en.yml
@@ -0,0 +1,65 @@
+# Additional translations at https://github.com/heartcombo/devise/wiki/I18n
+
+en:
+ devise:
+ confirmations:
+ confirmed: "Your email address has been successfully confirmed."
+ send_instructions: "You will receive an email with instructions for how to confirm your email address in a few minutes."
+ send_paranoid_instructions: "If your email address exists in our database, you will receive an email with instructions for how to confirm your email address in a few minutes."
+ failure:
+ already_authenticated: "You are already signed in."
+ inactive: "Your account is not activated yet."
+ invalid: "Invalid %{authentication_keys} or password."
+ locked: "Your account is locked."
+ last_attempt: "You have one more attempt before your account is locked."
+ not_found_in_database: "Invalid %{authentication_keys} or password."
+ timeout: "Your session expired. Please sign in again to continue."
+ unauthenticated: "You need to sign in or sign up before continuing."
+ unconfirmed: "You have to confirm your email address before continuing."
+ mailer:
+ confirmation_instructions:
+ subject: "Confirmation instructions"
+ reset_password_instructions:
+ subject: "Reset password instructions"
+ unlock_instructions:
+ subject: "Unlock instructions"
+ email_changed:
+ subject: "Email Changed"
+ password_change:
+ subject: "Password Changed"
+ omniauth_callbacks:
+ failure: "Could not authenticate you from %{kind} because \"%{reason}\"."
+ success: "Successfully authenticated from %{kind} account."
+ passwords:
+ no_token: "You can't access this page without coming from a password reset email. If you do come from a password reset email, please make sure you used the full URL provided."
+ send_instructions: "You will receive an email with instructions on how to reset your password in a few minutes."
+ send_paranoid_instructions: "If your email address exists in our database, you will receive a password recovery link at your email address in a few minutes."
+ updated: "Your password has been changed successfully. You are now signed in."
+ updated_not_active: "Your password has been changed successfully."
+ registrations:
+ destroyed: "Bye! Your account has been successfully cancelled. We hope to see you again soon."
+ signed_up: "Welcome! You have signed up successfully."
+ signed_up_but_inactive: "You have signed up successfully. However, we could not sign you in because your account is not yet activated."
+ signed_up_but_locked: "You have signed up successfully. However, we could not sign you in because your account is locked."
+ signed_up_but_unconfirmed: "A message with a confirmation link has been sent to your email address. Please follow the link to activate your account."
+ update_needs_confirmation: "You updated your account successfully, but we need to verify your new email address. Please check your email and follow the confirmation link to confirm your new email address."
+ updated: "Your account has been updated successfully."
+ updated_but_not_signed_in: "Your account has been updated successfully, but since your password was changed, you need to sign in again."
+ sessions:
+ signed_in: "Signed in successfully."
+ signed_out: "Signed out successfully."
+ already_signed_out: "Signed out successfully."
+ unlocks:
+ send_instructions: "You will receive an email with instructions for how to unlock your account in a few minutes."
+ send_paranoid_instructions: "If your account exists, you will receive an email with instructions for how to unlock it in a few minutes."
+ unlocked: "Your account has been unlocked successfully. Please sign in to continue."
+ errors:
+ messages:
+ already_confirmed: "was already confirmed, please try signing in"
+ confirmation_period_expired: "needs to be confirmed within %{period}, please request a new one"
+ expired: "has expired, please request a new one"
+ not_found: "not found"
+ not_locked: "was not locked"
+ not_saved:
+ one: "1 error prohibited this %{resource} from being saved:"
+ other: "%{count} errors prohibited this %{resource} from being saved:"
diff --git a/app-rails/config/locales/en.yml b/app-rails/config/locales/en.yml
deleted file mode 100644
index 6c349ae..0000000
--- a/app-rails/config/locales/en.yml
+++ /dev/null
@@ -1,31 +0,0 @@
-# Files in the config/locales directory are used for internationalization and
-# are automatically loaded by Rails. If you want to use locales other than
-# English, add the necessary files in this directory.
-#
-# To use the locales, use `I18n.t`:
-#
-# I18n.t "hello"
-#
-# In views, this is aliased to just `t`:
-#
-# <%= t("hello") %>
-#
-# To use a different locale, set it with `I18n.locale`:
-#
-# I18n.locale = :es
-#
-# This would use the information in config/locales/es.yml.
-#
-# To learn more about the API, please read the Rails Internationalization guide
-# at https://guides.rubyonrails.org/i18n.html.
-#
-# Be aware that YAML interprets the following case-insensitive strings as
-# booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings
-# must be quoted to be interpreted as strings. For example:
-#
-# en:
-# "yes": yup
-# enabled: "ON"
-
-en:
- hello: "Hello world"
diff --git a/app-rails/config/locales/models/user/en.yml b/app-rails/config/locales/models/user/en.yml
new file mode 100644
index 0000000..8837148
--- /dev/null
+++ b/app-rails/config/locales/models/user/en.yml
@@ -0,0 +1,5 @@
+en:
+ activemodel:
+ attributes:
+ "users/mfa_preference_form":
+ mfa_preference: "Multi-factor authentication preference"
diff --git a/app-rails/config/locales/views/application/en.yml b/app-rails/config/locales/views/application/en.yml
new file mode 100644
index 0000000..ace9f59
--- /dev/null
+++ b/app-rails/config/locales/views/application/en.yml
@@ -0,0 +1,29 @@
+# See: https://guides.rubyonrails.org/i18n.html.
+#
+# Be aware that YAML interprets the following case-insensitive strings as
+# booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings
+# must be quoted to be interpreted as strings. For example:
+# "yes": yup
+# enabled: "ON"
+
+en:
+ header:
+ account: My account
+ language_toggle: English
+ logout: "Log out"
+ title: "Application Title"
+ close: "Close"
+ flash:
+ error_heading:
+ zero: "An unexpected error occurred"
+ one: "An error occurred"
+ other: "%{count} errors occurred"
+ error_fallback: "Sorry, we encountered an unexpected error. If this continues to happen, you may call the Contact Center."
+ reload_page: "Reload page"
+ breadcrumbs:
+ home: "Home"
+ buttons:
+ submit: "Submit"
+ tables:
+ column: "Column"
+ description: "Description"
diff --git a/app-rails/config/locales/views/application/es-US.yml b/app-rails/config/locales/views/application/es-US.yml
new file mode 100644
index 0000000..84c85dc
--- /dev/null
+++ b/app-rails/config/locales/views/application/es-US.yml
@@ -0,0 +1,4 @@
+es-US:
+ header:
+ language_toggle: Español
+ logout: Cerrar sesión
diff --git a/app-rails/config/locales/views/home/en.yml b/app-rails/config/locales/views/home/en.yml
new file mode 100644
index 0000000..84e5f0a
--- /dev/null
+++ b/app-rails/config/locales/views/home/en.yml
@@ -0,0 +1,12 @@
+en:
+ home:
+ index:
+ title: "Get started"
+ intro: "You can either apply for benefits or manage applications for an organization. If you need to do both, you will need to create separate accounts."
+ applicant_heading: "Applicants"
+ applicant_body: "I am applying for benefits."
+ applicant_signup: "Create an Applicant account"
+ employer_heading: "Employers"
+ employer_body: "I manage applications for my organization."
+ employer_signup: "Create an Employer account"
+ or_sign_in: "Or sign into an existing account"
diff --git a/app-rails/config/locales/views/sidenav/en.yml b/app-rails/config/locales/views/sidenav/en.yml
new file mode 100644
index 0000000..5b0564c
--- /dev/null
+++ b/app-rails/config/locales/views/sidenav/en.yml
@@ -0,0 +1,7 @@
+en:
+ layouts:
+ sidenav:
+ sidebar:
+ index: "Developer sandbox"
+ rails: "Ruby on Rails"
+ rails_guides: "Rails Guides"
diff --git a/app-rails/config/locales/views/users/en.yml b/app-rails/config/locales/views/users/en.yml
new file mode 100644
index 0000000..4ac479b
--- /dev/null
+++ b/app-rails/config/locales/views/users/en.yml
@@ -0,0 +1,77 @@
+en:
+ users:
+ hide_password: "Hide password"
+ show_password: "Show password"
+ password_hint: "Your password must be 12 characters or longer. Don’t use common phrases or repeated characters, like abc or 111."
+ accounts:
+ edit:
+ title: "My account"
+ change_email: "Change email"
+ change_password: "Change password"
+ change_password_instructions: "A 6-digit code will be emailed to you to start the password reset process."
+ mfa_preference: "Multi-factor authentication"
+ disable_mfa: "Disable multi-factor authentication"
+ enable_mfa: "Enable multi-factor authentication"
+ mfa_successfully_disabled: "Multi-factor authentication has been disabled."
+ passwords:
+ forgot:
+ title: "Forgot your password?"
+ instructions: "If an account exists for the email you provide, we will email a 6-digit verification code to it."
+ submit: "Send code"
+ reset:
+ title: "Create a new password"
+ instructions_heading: "Check your email"
+ instructions_html: |
+ We sent a 6-digit code to your email. If you don’t see that email, check your spam folder.
+ If you didn’t get an email with a code, you may not have an account with that email, or you may need to verify your email address first.
+ code: "6-digit code"
+ submit: "Set password"
+ success: "Your password has been reset."
+ sessions:
+ new:
+ title: "Sign in"
+ forgot_password: "Forgot your password?"
+ submit: "Log in"
+ no_account: "Don't have an account?"
+ create_account: "Create your account now"
+ challenge:
+ title: "Enter your authentication app code"
+ code: "6-digit code"
+ submit: "Submit"
+ registrations:
+ new:
+ are_applicant_heading: "Are you applying for benefits?"
+ are_applicant_body: "To apply for benefits, create an applicant account."
+ are_applicant_action: "Create an Applicant account"
+ are_employer_heading: "Are you an employer?"
+ are_employer_body: "To manage applications for an organization, create an employer account."
+ are_employer_action: "Create an Employer account"
+ have_existing_account: "Already have an account?"
+ login: "Log in"
+ title_applicant: "Create an Applicant account"
+ title_employer: "Create an Employer account"
+ new_account_verification:
+ title: "Verify your email address"
+ instructions: "We sent a 6 digit verification code to your email. Enter the code to verify your email."
+ code: "6-digit code"
+ resend: "Send a new code"
+ resend_success: "If you have an account under that email, then a new code has been sent."
+ mfa:
+ new:
+ title: "Add an authentication app"
+ lead: "Set up an authentication app to sign in using temporary security codes."
+ auth_apps_heading: "What's an authentication app?"
+ auth_apps_html: "Authentication applications are downloaded to your device and generate secure, six-digit codes you use to sign in to your accounts. Some popular options include: Google Authenticator, Authy, LastPass, 1Password."
+ qr_heading: "Scan this QR barcode with your app"
+ code_label: "Or enter this code manually into your authentication app"
+ temporary_code: "Enter the temporary code from your app"
+ open_app: "Open your authentication app"
+ create:
+ success: "Successfully added an authentication app"
+ preference:
+ title: Would you like to set up multi-factor authentication?
+ preference: Add an additional layer of protection to your account by selecting a multi-factor authentication method.
+ software_token_label: Authentication application
+ software_token_hint: "Download or use an authentication app of your choice to generate secure codes."
+ opt_out_label: "Skip for now"
+ opt_out_hint: "You can set up multi-factor authentication later in your account settings."
diff --git a/app-rails/config/routes.rb b/app-rails/config/routes.rb
index a125ef0..e13cf97 100644
--- a/app-rails/config/routes.rb
+++ b/app-rails/config/routes.rb
@@ -3,8 +3,50 @@
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
# Can be used by load balancers and uptime monitors to verify that the app is live.
- get "up" => "rails/health#show", as: :rails_health_check
+ get "health" => "rails/health#show", as: :rails_health_check
+ # Keep the default /up rails endpoint.
+ get "up" => "rails/health#show"
- # Defines the root path route ("/")
- # root "posts#index"
+ # Support locale prefixes for these routes:
+ localized do
+ # Defines the root path route ("/")
+ root "home#index"
+
+ # Session management
+ devise_for :users, controllers: { sessions: "users/sessions" }
+ devise_scope :user do
+ get "sessions/challenge" => "users/sessions#challenge", as: :session_challenge
+ post "sessions/challenge" => "users/sessions#respond_to_challenge", as: :respond_to_session_challenge
+ end
+
+ # Registration and account management
+ namespace :users do
+ resources :registrations, only: [ :create ]
+ get "registrations/applicant" => "registrations#new_applicant", as: :new_applicant_registration
+ get "registrations/employer" => "registrations#new_employer", as: :new_employer_registration
+
+ resources :mfa, only: [ :new, :create ]
+ get "mfa/preference" => "mfa#preference", as: :mfa_preference
+ post "mfa/preference" => "mfa#update_preference", as: :update_mfa_preference
+ delete "mfa" => "mfa#destroy", as: :disable_mfa
+
+ get "forgot-password" => "passwords#forgot"
+ post "forgot-password" => "passwords#send_reset_password_instructions"
+ get "reset-password" => "passwords#reset"
+ post "reset-password" => "passwords#confirm_reset"
+
+ get "verify-account" => "registrations#new_account_verification"
+ post "verify-account" => "registrations#create_account_verification"
+ post "resend-verification" => "registrations#resend_verification_code"
+
+ get "account" => "accounts#edit"
+ patch "account/email" => "accounts#update_email"
+ end
+
+ # Development-only sandbox
+ namespace :dev do
+ get "sandbox"
+ post "send_email"
+ end
+ end
end
diff --git a/app-rails/config/storage.yml b/app-rails/config/storage.yml
index 4942ab6..2ff2e69 100644
--- a/app-rails/config/storage.yml
+++ b/app-rails/config/storage.yml
@@ -6,6 +6,11 @@ local:
service: Disk
root: <%= Rails.root.join("storage") %>
+amazon:
+ service: S3
+ bucket: <%= ENV.fetch("AWS_BUCKET_NAME") { nil } %>
+
+
# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
# amazon:
# service: S3
diff --git a/app-rails/db/migrate/20240410212818_create_active_storage_tables.active_storage.rb b/app-rails/db/migrate/20240410212818_create_active_storage_tables.active_storage.rb
new file mode 100644
index 0000000..6bd8bd0
--- /dev/null
+++ b/app-rails/db/migrate/20240410212818_create_active_storage_tables.active_storage.rb
@@ -0,0 +1,57 @@
+# This migration comes from active_storage (originally 20170806125915)
+class CreateActiveStorageTables < ActiveRecord::Migration[7.0]
+ def change
+ # Use Active Record's configured type for primary and foreign keys
+ primary_key_type, foreign_key_type = primary_and_foreign_key_types
+
+ create_table :active_storage_blobs, id: primary_key_type do |t|
+ t.string :key, null: false
+ t.string :filename, null: false
+ t.string :content_type
+ t.text :metadata
+ t.string :service_name, null: false
+ t.bigint :byte_size, null: false
+ t.string :checksum
+
+ if connection.supports_datetime_with_precision?
+ t.datetime :created_at, precision: 6, null: false
+ else
+ t.datetime :created_at, null: false
+ end
+
+ t.index [ :key ], unique: true
+ end
+
+ create_table :active_storage_attachments, id: primary_key_type do |t|
+ t.string :name, null: false
+ t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type
+ t.references :blob, null: false, type: foreign_key_type
+
+ if connection.supports_datetime_with_precision?
+ t.datetime :created_at, precision: 6, null: false
+ else
+ t.datetime :created_at, null: false
+ end
+
+ t.index [ :record_type, :record_id, :name, :blob_id ], name: :index_active_storage_attachments_uniqueness, unique: true
+ t.foreign_key :active_storage_blobs, column: :blob_id
+ end
+
+ create_table :active_storage_variant_records, id: primary_key_type do |t|
+ t.belongs_to :blob, null: false, index: false, type: foreign_key_type
+ t.string :variation_digest, null: false
+
+ t.index [ :blob_id, :variation_digest ], name: :index_active_storage_variant_records_uniqueness, unique: true
+ t.foreign_key :active_storage_blobs, column: :blob_id
+ end
+ end
+
+ private
+ def primary_and_foreign_key_types
+ config = Rails.configuration.generators
+ setting = config.options[config.orm][:primary_key_type]
+ primary_key_type = setting || :primary_key
+ foreign_key_type = setting || :bigint
+ [ primary_key_type, foreign_key_type ]
+ end
+end
diff --git a/app-rails/db/migrate/20240410212926_create_users.rb b/app-rails/db/migrate/20240410212926_create_users.rb
new file mode 100644
index 0000000..15cd1a0
--- /dev/null
+++ b/app-rails/db/migrate/20240410212926_create_users.rb
@@ -0,0 +1,13 @@
+class CreateUsers < ActiveRecord::Migration[7.1]
+ def change
+ create_table :users, id: :uuid do |t|
+ t.string "uid", null: false
+ t.string "provider", null: false
+ t.string "email", default: "", null: false
+ t.integer "mfa_preference"
+ t.index [ "uid" ], name: "index_users_on_uid", unique: true
+
+ t.timestamps
+ end
+ end
+end
diff --git a/app-rails/db/migrate/20240410213056_create_user_roles.rb b/app-rails/db/migrate/20240410213056_create_user_roles.rb
new file mode 100644
index 0000000..d5b5535
--- /dev/null
+++ b/app-rails/db/migrate/20240410213056_create_user_roles.rb
@@ -0,0 +1,10 @@
+class CreateUserRoles < ActiveRecord::Migration[7.1]
+ def change
+ create_table :user_roles, id: :uuid do |t|
+ t.references "user", type: :uuid, null: false, foreign_key: true
+ t.integer "role", null: false
+
+ t.timestamps
+ end
+ end
+end
diff --git a/app-rails/db/schema.rb b/app-rails/db/schema.rb
new file mode 100644
index 0000000..709bdc0
--- /dev/null
+++ b/app-rails/db/schema.rb
@@ -0,0 +1,66 @@
+# This file is auto-generated from the current state of the database. Instead
+# of editing this file, please use the migrations feature of Active Record to
+# incrementally modify your database, and then regenerate this schema definition.
+#
+# This file is the source Rails uses to define your schema when running `bin/rails
+# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
+# be faster and is potentially less error prone than running all of your
+# migrations from scratch. Old migrations may fail to apply correctly if those
+# migrations use external dependencies or application code.
+#
+# It's strongly recommended that you check this file into your version control system.
+
+ActiveRecord::Schema[7.1].define(version: 2024_04_10_213056) do
+ # These are extensions that must be enabled in order to support this database
+ enable_extension "plpgsql"
+
+ create_table "active_storage_attachments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.string "name", null: false
+ t.string "record_type", null: false
+ t.uuid "record_id", null: false
+ t.uuid "blob_id", null: false
+ t.datetime "created_at", null: false
+ t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id"
+ t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true
+ end
+
+ create_table "active_storage_blobs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.string "key", null: false
+ t.string "filename", null: false
+ t.string "content_type"
+ t.text "metadata"
+ t.string "service_name", null: false
+ t.bigint "byte_size", null: false
+ t.string "checksum"
+ t.datetime "created_at", null: false
+ t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true
+ end
+
+ create_table "active_storage_variant_records", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.uuid "blob_id", null: false
+ t.string "variation_digest", null: false
+ t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
+ end
+
+ create_table "user_roles", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.uuid "user_id", null: false
+ t.integer "role", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["user_id"], name: "index_user_roles_on_user_id"
+ end
+
+ create_table "users", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.string "uid", null: false
+ t.string "provider", null: false
+ t.string "email", default: "", null: false
+ t.integer "mfa_preference"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["uid"], name: "index_users_on_uid", unique: true
+ end
+
+ add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
+ add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
+ add_foreign_key "user_roles", "users"
+end
diff --git a/app-rails/db/seeds.rb b/app-rails/db/seeds.rb
index 4fbd6ed..ee41fa7 100644
--- a/app-rails/db/seeds.rb
+++ b/app-rails/db/seeds.rb
@@ -7,3 +7,15 @@
# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name|
# MovieGenre.find_or_create_by!(name: genre_name)
# end
+
+# Load seeds per Rails environment.
+seed_filename = "#{ENV["SEED_FILENAME"] || Rails.env.downcase}.rb"
+seed_path = Rails.root.join('db', 'seeds', seed_filename)
+
+if File.file?(seed_path)
+ puts "🌱 Seeding the database with #{seed_filename}"
+ load(seed_path)
+ puts "🪴 Done seeding"
+else
+ puts "🌲 No seeding necessary"
+end
diff --git a/docs/app/README.md b/app-rails/db/seeds/.keep
similarity index 100%
rename from docs/app/README.md
rename to app-rails/db/seeds/.keep
diff --git a/app-rails/lib/generators/locale/locale_generator.rb b/app-rails/lib/generators/locale/locale_generator.rb
new file mode 100644
index 0000000..76c4652
--- /dev/null
+++ b/app-rails/lib/generators/locale/locale_generator.rb
@@ -0,0 +1,21 @@
+class LocaleGenerator < Rails::Generators::NamedBase
+ source_root File.expand_path("templates", __dir__)
+
+ def create_model_locale_file
+ template "model_locale.yml", "config/locales/models/#{file_name}/en.yml"
+ end
+
+ def create_view_locale_file
+ template "view_locale.yml", "config/locales/views/#{plural_file_name}/en.yml"
+ end
+
+ private
+
+ def file_name
+ name.underscore
+ end
+
+ def plural_file_name
+ file_name.pluralize
+ end
+end
diff --git a/app-rails/lib/generators/locale/templates/model_locale.yml b/app-rails/lib/generators/locale/templates/model_locale.yml
new file mode 100644
index 0000000..09f538b
--- /dev/null
+++ b/app-rails/lib/generators/locale/templates/model_locale.yml
@@ -0,0 +1,4 @@
+en:
+ activerecord:
+ attributes:
+ <%= file_name %>:
\ No newline at end of file
diff --git a/app-rails/lib/generators/locale/templates/view_locale.yml b/app-rails/lib/generators/locale/templates/view_locale.yml
new file mode 100644
index 0000000..6747d09
--- /dev/null
+++ b/app-rails/lib/generators/locale/templates/view_locale.yml
@@ -0,0 +1,6 @@
+en:
+ <%= plural_file_name %>:
+ index:
+ show:
+ new:
+ edit:
\ No newline at end of file
diff --git a/app-rails/local.env.example b/app-rails/local.env.example
new file mode 100644
index 0000000..2a40c53
--- /dev/null
+++ b/app-rails/local.env.example
@@ -0,0 +1,40 @@
+############################
+# Rails
+############################
+
+RAILS_ENV=development
+
+############################
+# Rails: Urls
+############################
+
+APP_HOST=localhost
+APP_PORT=3000
+
+############################
+# AWS services
+############################
+
+AWS_ACCESS_KEY_ID=
+AWS_SECRET_ACCESS_KEY=
+AWS_DEFAULT_REGION=
+
+# Uncomment and fill in an AWS S3 bucket name to use S3 for file storage
+# AWS_BUCKET_NAME=
+
+############################
+# Auth
+############################
+
+AWS_COGNITO_USER_POOL_ID=
+AWS_COGNITO_CLIENT_ID=
+AWS_COGNITO_CLIENT_SECRET=
+
+############################
+# Database
+############################
+
+DB_HOST=127.0.0.1
+DB_NAME=app_rails
+DB_USER=app_rails
+DB_PASSWORD=secret123
\ No newline at end of file
diff --git a/docs/app/decisions/README.md b/app-rails/node_modules/.keep
similarity index 100%
rename from docs/app/decisions/README.md
rename to app-rails/node_modules/.keep
diff --git a/app-rails/package-lock.json b/app-rails/package-lock.json
new file mode 100644
index 0000000..e26410f
--- /dev/null
+++ b/app-rails/package-lock.json
@@ -0,0 +1,500 @@
+{
+ "name": "app-rails",
+ "version": "1.0.0",
+ "lockfileVersion": 2,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "app-rails",
+ "version": "1.0.0",
+ "dependencies": {
+ "@uswds/uswds": "^3.7.1"
+ },
+ "devDependencies": {
+ "sass": "^1.71.1"
+ }
+ },
+ "node_modules/@uswds/uswds": {
+ "version": "3.8.0",
+ "resolved": "https://registry.npmjs.org/@uswds/uswds/-/uswds-3.8.0.tgz",
+ "integrity": "sha512-rMwCXe/u4HGkfskvS1Iuabapi/EXku3ChaIFW7y/dUhc7R1TXQhbbfp8YXEjmXPF0yqJnv9T08WPgS0fQqWZ8w==",
+ "dependencies": {
+ "classlist-polyfill": "1.2.0",
+ "object-assign": "4.1.1",
+ "receptor": "1.0.0",
+ "resolve-id-refs": "0.1.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "dev": true,
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
+ "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
+ "dev": true,
+ "dependencies": {
+ "fill-range": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "dev": true,
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/classlist-polyfill": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/classlist-polyfill/-/classlist-polyfill-1.2.0.tgz",
+ "integrity": "sha512-GzIjNdcEtH4ieA2S8NmrSxv7DfEV5fmixQeyTmqmRmRJPGpRBaSnA2a0VrCjyT8iW8JjEdMbKzDotAJf+ajgaQ=="
+ },
+ "node_modules/element-closest": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/element-closest/-/element-closest-2.0.2.tgz",
+ "integrity": "sha512-QCqAWP3kwj8Gz9UXncVXQGdrhnWxD8SQBSeZp5pOsyCcQ6RpL738L1/tfuwBiMi6F1fYkxqPnBrFBR4L+f49Cg==",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
+ "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
+ "dev": true,
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/immutable": {
+ "version": "4.3.5",
+ "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.5.tgz",
+ "integrity": "sha512-8eabxkth9gZatlwl5TBuJnCsoTADlL6ftEr7A4qgdaTsPyreilDSnUk57SO+jfKcNtxPa22U5KK6DSeAYhpBJw==",
+ "dev": true
+ },
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/keyboardevent-key-polyfill": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/keyboardevent-key-polyfill/-/keyboardevent-key-polyfill-1.1.0.tgz",
+ "integrity": "sha512-NTDqo7XhzL1fqmUzYroiyK2qGua7sOMzLav35BfNA/mPUSCtw8pZghHFMTYR9JdnJ23IQz695FcaM6EE6bpbFQ=="
+ },
+ "node_modules/matches-selector": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/matches-selector/-/matches-selector-1.2.0.tgz",
+ "integrity": "sha512-c4vLwYWyl+Ji+U43eU/G5FwxWd4ZH0ePUsFs5y0uwD9HUEFBXUQ1zUUan+78IpRD+y4pUfG0nAzNM292K7ItvA=="
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true,
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/receptor": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/receptor/-/receptor-1.0.0.tgz",
+ "integrity": "sha512-yvVEqVQDNzEmGkluCkEdbKSXqZb3WGxotI/VukXIQ+4/BXEeXVjWtmC6jWaR1BIsmEAGYQy3OTaNgDj2Svr01w==",
+ "dependencies": {
+ "element-closest": "^2.0.1",
+ "keyboardevent-key-polyfill": "^1.0.2",
+ "matches-selector": "^1.0.0",
+ "object-assign": "^4.1.0"
+ }
+ },
+ "node_modules/resolve-id-refs": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/resolve-id-refs/-/resolve-id-refs-0.1.0.tgz",
+ "integrity": "sha512-hNS03NEmVpJheF7yfyagNh57XuKc0z+NkSO0oBbeO67o6IJKoqlDfnNIxhjp7aTWwjmSWZQhtiGrOgZXVyM90w=="
+ },
+ "node_modules/sass": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/sass/-/sass-1.72.0.tgz",
+ "integrity": "sha512-Gpczt3WA56Ly0Mn8Sl21Vj94s1axi9hDIzDFn9Ph9x3C3p4nNyvsqJoQyVXKou6cBlfFWEgRW4rT8Tb4i3XnVA==",
+ "dev": true,
+ "dependencies": {
+ "chokidar": ">=3.0.0 <4.0.0",
+ "immutable": "^4.0.0",
+ "source-map-js": ">=0.6.2 <2.0.0"
+ },
+ "bin": {
+ "sass": "sass.js"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
+ "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ }
+ },
+ "dependencies": {
+ "@uswds/uswds": {
+ "version": "3.8.0",
+ "resolved": "https://registry.npmjs.org/@uswds/uswds/-/uswds-3.8.0.tgz",
+ "integrity": "sha512-rMwCXe/u4HGkfskvS1Iuabapi/EXku3ChaIFW7y/dUhc7R1TXQhbbfp8YXEjmXPF0yqJnv9T08WPgS0fQqWZ8w==",
+ "requires": {
+ "classlist-polyfill": "1.2.0",
+ "object-assign": "4.1.1",
+ "receptor": "1.0.0",
+ "resolve-id-refs": "0.1.0"
+ }
+ },
+ "anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "dev": true,
+ "requires": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ }
+ },
+ "binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "dev": true
+ },
+ "braces": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
+ "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
+ "dev": true,
+ "requires": {
+ "fill-range": "^7.0.1"
+ }
+ },
+ "chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "dev": true,
+ "requires": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "fsevents": "~2.3.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ }
+ },
+ "classlist-polyfill": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/classlist-polyfill/-/classlist-polyfill-1.2.0.tgz",
+ "integrity": "sha512-GzIjNdcEtH4ieA2S8NmrSxv7DfEV5fmixQeyTmqmRmRJPGpRBaSnA2a0VrCjyT8iW8JjEdMbKzDotAJf+ajgaQ=="
+ },
+ "element-closest": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/element-closest/-/element-closest-2.0.2.tgz",
+ "integrity": "sha512-QCqAWP3kwj8Gz9UXncVXQGdrhnWxD8SQBSeZp5pOsyCcQ6RpL738L1/tfuwBiMi6F1fYkxqPnBrFBR4L+f49Cg=="
+ },
+ "fill-range": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
+ "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
+ "dev": true,
+ "requires": {
+ "to-regex-range": "^5.0.1"
+ }
+ },
+ "fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "optional": true
+ },
+ "glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "requires": {
+ "is-glob": "^4.0.1"
+ }
+ },
+ "immutable": {
+ "version": "4.3.5",
+ "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.5.tgz",
+ "integrity": "sha512-8eabxkth9gZatlwl5TBuJnCsoTADlL6ftEr7A4qgdaTsPyreilDSnUk57SO+jfKcNtxPa22U5KK6DSeAYhpBJw==",
+ "dev": true
+ },
+ "is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "requires": {
+ "binary-extensions": "^2.0.0"
+ }
+ },
+ "is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true
+ },
+ "is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "requires": {
+ "is-extglob": "^2.1.1"
+ }
+ },
+ "is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true
+ },
+ "keyboardevent-key-polyfill": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/keyboardevent-key-polyfill/-/keyboardevent-key-polyfill-1.1.0.tgz",
+ "integrity": "sha512-NTDqo7XhzL1fqmUzYroiyK2qGua7sOMzLav35BfNA/mPUSCtw8pZghHFMTYR9JdnJ23IQz695FcaM6EE6bpbFQ=="
+ },
+ "matches-selector": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/matches-selector/-/matches-selector-1.2.0.tgz",
+ "integrity": "sha512-c4vLwYWyl+Ji+U43eU/G5FwxWd4ZH0ePUsFs5y0uwD9HUEFBXUQ1zUUan+78IpRD+y4pUfG0nAzNM292K7ItvA=="
+ },
+ "normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true
+ },
+ "object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="
+ },
+ "picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true
+ },
+ "readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "requires": {
+ "picomatch": "^2.2.1"
+ }
+ },
+ "receptor": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/receptor/-/receptor-1.0.0.tgz",
+ "integrity": "sha512-yvVEqVQDNzEmGkluCkEdbKSXqZb3WGxotI/VukXIQ+4/BXEeXVjWtmC6jWaR1BIsmEAGYQy3OTaNgDj2Svr01w==",
+ "requires": {
+ "element-closest": "^2.0.1",
+ "keyboardevent-key-polyfill": "^1.0.2",
+ "matches-selector": "^1.0.0",
+ "object-assign": "^4.1.0"
+ }
+ },
+ "resolve-id-refs": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/resolve-id-refs/-/resolve-id-refs-0.1.0.tgz",
+ "integrity": "sha512-hNS03NEmVpJheF7yfyagNh57XuKc0z+NkSO0oBbeO67o6IJKoqlDfnNIxhjp7aTWwjmSWZQhtiGrOgZXVyM90w=="
+ },
+ "sass": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/sass/-/sass-1.72.0.tgz",
+ "integrity": "sha512-Gpczt3WA56Ly0Mn8Sl21Vj94s1axi9hDIzDFn9Ph9x3C3p4nNyvsqJoQyVXKou6cBlfFWEgRW4rT8Tb4i3XnVA==",
+ "dev": true,
+ "requires": {
+ "chokidar": ">=3.0.0 <4.0.0",
+ "immutable": "^4.0.0",
+ "source-map-js": ">=0.6.2 <2.0.0"
+ }
+ },
+ "source-map-js": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
+ "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==",
+ "dev": true
+ },
+ "to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "requires": {
+ "is-number": "^7.0.0"
+ }
+ }
+ }
+}
diff --git a/app-rails/package.json b/app-rails/package.json
new file mode 100644
index 0000000..39deba8
--- /dev/null
+++ b/app-rails/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "app-rails",
+ "private": true,
+ "version": "1.0.0",
+ "scripts": {
+ "build:css": "sass ./app/assets/stylesheets/application.scss:./app/assets/builds/application.css --load-path=node_modules --load-path=node_modules/@uswds/uswds/packages"
+ },
+ "dependencies": {
+ "@uswds/uswds": "^3.7.1"
+ },
+ "devDependencies": {
+ "sass": "^1.71.1"
+ }
+}
diff --git a/app-rails/spec/adapters/cognito_adapter_spec.rb b/app-rails/spec/adapters/cognito_adapter_spec.rb
new file mode 100644
index 0000000..cf1ebdf
--- /dev/null
+++ b/app-rails/spec/adapters/cognito_adapter_spec.rb
@@ -0,0 +1,153 @@
+require "rails_helper"
+
+RSpec.describe Auth::CognitoAdapter do
+ let(:mock_client) { instance_double("Aws::CognitoIdentityProvider::Client") }
+ let(:adapter) { Auth::CognitoAdapter.new(client: mock_client) }
+ let(:email) { "test@example.com" }
+
+ describe "#associate_software_token" do
+ it "returns the secret_code" do
+ secret_code = "123456abcdef"
+ allow(mock_client).to receive(:associate_software_token).and_return(
+ Aws::CognitoIdentityProvider::Types::AssociateSoftwareTokenResponse.new(
+ secret_code: secret_code
+ )
+ )
+
+ response = adapter.associate_software_token("access_token")
+
+ expect(response).to eq(secret_code)
+ end
+
+ it "raises a provider error if cognito raises an unhandled service error" do
+ allow(mock_client).to receive(:associate_software_token).and_raise(
+ Aws::CognitoIdentityProvider::Errors::TooManyRequestsException.new(nil, "mock msg")
+ )
+
+ expect do
+ adapter.associate_software_token("access_token")
+ end.to raise_error(Auth::Errors::ProviderError)
+ end
+ end
+
+ describe "#create_account" do
+ it "raises an error if the email is already taken" do
+ allow(mock_client).to receive(:sign_up).and_raise(Aws::CognitoIdentityProvider::Errors::UsernameExistsException.new(nil, "mock msg"))
+
+ expect do
+ adapter.create_account(email, "password")
+ end.to raise_error(Auth::Errors::UsernameExists)
+ end
+
+ it "raises an error if the password is invalid" do
+ allow(mock_client).to receive(:sign_up).and_raise(Aws::CognitoIdentityProvider::Errors::InvalidPasswordException.new(nil, "mock msg"))
+
+ expect do
+ adapter.create_account(email, "password")
+ end.to raise_error(Auth::Errors::InvalidPasswordFormat)
+ end
+
+ it "raises an invalid password error if cognito raises an invalid parameter exception" do
+ allow(mock_client).to receive(:sign_up).and_raise(Aws::CognitoIdentityProvider::Errors::InvalidParameterException.new(nil, "mock msg"))
+
+ expect do
+ adapter.create_account(email, "a")
+ end.to raise_error(Auth::Errors::InvalidPasswordFormat)
+ end
+
+ it "raises a provider error if cognito raises an unhandled service error" do
+ allow(mock_client).to receive(:sign_up).and_raise(Aws::CognitoIdentityProvider::Errors::TooManyRequestsException.new(nil, "mock msg"))
+
+ expect do
+ adapter.create_account(email, "password")
+ end.to raise_error(Auth::Errors::ProviderError)
+ end
+ end
+
+ describe "#initiate_auth" do
+ it "raises an error if the password is incorrect" do
+ allow(mock_client).to receive(:admin_initiate_auth).and_raise(Aws::CognitoIdentityProvider::Errors::NotAuthorizedException.new(nil, "mock msg"))
+
+ expect do
+ adapter.initiate_auth(email, "password")
+ end.to raise_error(Auth::Errors::InvalidCredentials)
+ end
+
+ it "raises a provider error if cognito raises an unhandled service error" do
+ allow(mock_client).to receive(:admin_initiate_auth).and_raise(Aws::CognitoIdentityProvider::Errors::TooManyRequestsException.new(nil, "mock msg"))
+
+ expect do
+ adapter.initiate_auth(email, "password")
+ end.to raise_error(Auth::Errors::ProviderError)
+ end
+ end
+
+ describe "#forgot_password" do
+ it "responds with the confirmation channel" do
+ allow(mock_client).to receive(:forgot_password).and_return(
+ Aws::CognitoIdentityProvider::Types::ForgotPasswordResponse.new(
+ code_delivery_details: Aws::CognitoIdentityProvider::Types::CodeDeliveryDetailsType.new(delivery_medium: "EMAIL")
+ )
+ )
+ response = adapter.forgot_password(email)
+
+ expect(response).to eq({ confirmation_channel: "EMAIL" })
+ end
+ end
+
+ describe "#verify_software_token" do
+ it "sets the MFA preference when the token is verified" do
+ allow(mock_client).to receive(:verify_software_token).and_return(
+ Aws::CognitoIdentityProvider::Types::VerifySoftwareTokenResponse.new(
+ status: "SUCCESS"
+ )
+ )
+ allow(mock_client).to receive(:set_user_mfa_preference).and_return(
+ Aws::CognitoIdentityProvider::Types::SetUserMFAPreferenceResponse.new
+ )
+
+ adapter.verify_software_token("123456", "mock_token")
+
+ expect(mock_client).to have_received(:set_user_mfa_preference).with(
+ access_token: "mock_token",
+ software_token_mfa_settings: {
+ enabled: true,
+ preferred_mfa: true
+ }
+ )
+ end
+
+ it "raises an error if the response status isn't success" do
+ allow(mock_client).to receive(:verify_software_token).and_return(
+ Aws::CognitoIdentityProvider::Types::VerifySoftwareTokenResponse.new(
+ status: "FAILED"
+ )
+ )
+
+ expect do
+ adapter.verify_software_token("123456", "mock_token")
+ end.to raise_error(Auth::Errors::InvalidSoftwareToken)
+ end
+
+
+ it "raises an error when EnableSoftwareTokenMFAException is raised" do
+ allow(mock_client).to receive(:verify_software_token).and_raise(
+ Aws::CognitoIdentityProvider::Errors::EnableSoftwareTokenMFAException.new(nil, "mock msg")
+ )
+
+ expect do
+ adapter.verify_software_token("123456", "mock_token")
+ end.to raise_error(Auth::Errors::InvalidSoftwareToken)
+ end
+
+ it "raises a provider error when cognito raises an unhandled service error" do
+ allow(mock_client).to receive(:verify_software_token).and_raise(
+ Aws::CognitoIdentityProvider::Errors::TooManyRequestsException.new(nil, "mock msg")
+ )
+
+ expect do
+ adapter.verify_software_token("123456", "mock_token")
+ end.to raise_error(Auth::Errors::ProviderError)
+ end
+ end
+end
diff --git a/app-rails/spec/controllers/home_controller_spec.rb b/app-rails/spec/controllers/home_controller_spec.rb
new file mode 100644
index 0000000..46f1a2d
--- /dev/null
+++ b/app-rails/spec/controllers/home_controller_spec.rb
@@ -0,0 +1,13 @@
+require 'rails_helper'
+
+RSpec.describe HomeController do
+ render_views
+
+ describe "GET index" do
+ it "renders the index template" do
+ get :index, params: { locale: "en" }
+
+ expect(response.body).to have_selector("h1", text: /get started/i)
+ end
+ end
+end
diff --git a/app-rails/spec/controllers/users/accounts_controller_spec.rb b/app-rails/spec/controllers/users/accounts_controller_spec.rb
new file mode 100644
index 0000000..4294b57
--- /dev/null
+++ b/app-rails/spec/controllers/users/accounts_controller_spec.rb
@@ -0,0 +1,68 @@
+require 'rails_helper'
+
+RSpec.describe Users::AccountsController do
+ render_views
+
+ before do
+ allow(controller).to receive(:auth_service).and_return(
+ AuthService.new(Auth::MockAdapter.new)
+ )
+ end
+
+ describe "GET edit" do
+ it "renders the account edit form" do
+ user = create(:user)
+ sign_in user
+
+ get :edit, params: { locale: "en" }
+
+ expect(response.body).to have_field("users_update_email_form[email]", with: user.email)
+ end
+
+ it "shows disable MFA button if MFA is enabled" do
+ user = create(:user, mfa_preference: "software_token")
+ sign_in user
+
+ get :edit, params: { locale: "en" }
+
+ expect(response.body).to have_element("button", text: /disable multi-factor/i)
+ end
+
+ it "shows enable MFA button if MFA is disabled" do
+ user = create(:user, mfa_preference: "opt_out")
+ sign_in user
+
+ get :edit, params: { locale: "en" }
+
+ expect(response.body).to have_element("a", text: /enable multi-factor/i)
+ end
+ end
+
+ describe "PATCH update_email" do
+ it "updates the user's email" do
+ new_email = "new@example.com"
+ user = create(:user)
+ sign_in user
+
+ patch :update_email, params: {
+ users_update_email_form: { email: new_email },
+ locale: "en"
+ }
+ user.reload
+
+ expect(user.email).to eq(new_email)
+ end
+
+ it "validates the email" do
+ user = create(:user)
+ sign_in user
+
+ patch :update_email, params: {
+ users_update_email_form: { email: "invalid-email" },
+ locale: "en"
+ }
+
+ expect(response.status).to eq(422)
+ end
+ end
+end
diff --git a/app-rails/spec/controllers/users/mfa_controller_spec.rb b/app-rails/spec/controllers/users/mfa_controller_spec.rb
new file mode 100644
index 0000000..36603b9
--- /dev/null
+++ b/app-rails/spec/controllers/users/mfa_controller_spec.rb
@@ -0,0 +1,135 @@
+require 'rails_helper'
+
+RSpec.describe Users::MfaController do
+ render_views
+
+ before do
+ allow(controller).to receive(:auth_service).and_return(
+ AuthService.new(Auth::MockAdapter.new)
+ )
+ end
+
+ describe "preference" do
+ it "renders the MFA preference form" do
+ user = create(:user)
+ sign_in user
+
+ get :preference, params: { locale: "en" }
+
+ expect(response.body).to have_selector("h1", text: /multi-factor authentication/i)
+ end
+ end
+
+ describe "update_preference" do
+ it "updates the MFA preference for the user when opting out" do
+ user = create(:user, mfa_preference: nil)
+ sign_in user
+
+ patch :update_preference, params: {
+ users_mfa_preference_form: { mfa_preference: "opt_out" },
+ locale: "en"
+ }
+
+ expect(user.mfa_preference).to be_nil
+ user.reload
+ expect(user.mfa_preference).to eq("opt_out")
+
+ expect(response).to redirect_to(users_account_path)
+ end
+
+ it "does not set preference when selecting software token" do
+ user = create(:user, mfa_preference: nil)
+ sign_in user
+
+ patch :update_preference, params: {
+ users_mfa_preference_form: { mfa_preference: "software_token" },
+ locale: "en"
+ }
+
+ user.reload
+ expect(user.mfa_preference).to be_nil
+ expect(response).to redirect_to(action: :new)
+ end
+ end
+
+ describe "new" do
+ it "renders the MFA setup form" do
+ user = create(:user, access_token: JWT.encode({ exp: 1.day.from_now.to_i }, nil))
+ sign_in user
+
+ get :new, params: { locale: "en" }
+
+ expect(response.body).to have_selector("h1", text: /add an authentication app/i)
+ expect(response.body).to have_text("mock-secret")
+ end
+
+ it "redirects to the login page if the access token is close to expiring" do
+ user = create(:user, access_token: JWT.encode({ exp: 5.minutes.from_now.to_i }, nil))
+ sign_in user
+
+ get :new, params: { locale: "en" }
+
+ expect(response).to redirect_to(new_user_session_path)
+ end
+ end
+
+ describe "create" do
+ it "associates the software token with the user" do
+ user = create(
+ :user,
+ mfa_preference: nil,
+ access_token: JWT.encode({ exp: 1.day.from_now.to_i }, nil)
+ )
+ sign_in user
+
+ post :create, params: {
+ users_associate_mfa_form: { temporary_code: "123123" },
+ locale: "en"
+ }
+
+ expect(user.mfa_preference).not_to eq("software_token")
+ user.reload
+ expect(user.mfa_preference).to eq("software_token")
+
+ expect(response).to redirect_to(root_path)
+ end
+
+ it "redirects back to the setup page if the code is invalid" do
+ user = create(:user, access_token: JWT.encode({ exp: 1.day.from_now.to_i }, nil))
+ sign_in user
+
+ post :create, params: {
+ users_associate_mfa_form: { temporary_code: "wrong format" },
+ locale: "en"
+ }
+
+ expect(flash[:errors]).to include(/wrong length/i)
+ expect(response).to redirect_to(action: :new)
+ end
+
+ it "redirects back to the setup page if the code is incorrect" do
+ user = create(:user, access_token: JWT.encode({ exp: 1.day.from_now.to_i }, nil))
+ sign_in user
+
+ post :create, params: {
+ users_associate_mfa_form: { temporary_code: "000001" },
+ locale: "en"
+ }
+
+ expect(flash[:errors]).to include(/invalid code/i)
+ expect(response).to redirect_to(action: :new)
+ end
+ end
+
+ describe "destroy" do
+ it "disables MFA for the user" do
+ user = create(:user, mfa_preference: "software_token")
+ sign_in user
+
+ delete :destroy, params: { locale: "en" }
+
+ user.reload
+ expect(user.mfa_preference).to eq("opt_out")
+ end
+ end
+end
diff --git a/app-rails/spec/controllers/users/passwords_controller_spec.rb b/app-rails/spec/controllers/users/passwords_controller_spec.rb
new file mode 100644
index 0000000..ce0895c
--- /dev/null
+++ b/app-rails/spec/controllers/users/passwords_controller_spec.rb
@@ -0,0 +1,99 @@
+require 'rails_helper'
+
+RSpec.describe Users::PasswordsController do
+ render_views
+
+ before do
+ allow(controller).to receive(:auth_service).and_return(
+ AuthService.new(Auth::MockAdapter.new)
+ )
+ end
+
+ describe "GET forgot" do
+ it "renders the forgot password form" do
+ get :forgot, params: { locale: "en" }
+
+ expect(response.body).to have_field("users_forgot_password_form[email]")
+ end
+ end
+
+ describe "POST send_reset_password_instructions" do
+ it "redirects to the reset password form" do
+ post :send_reset_password_instructions, params: {
+ users_forgot_password_form: { email: "test@example.com" },
+ locale: "en"
+ }
+
+ expect(response).to redirect_to(users_reset_password_path)
+ end
+
+ it "validates email" do
+ post :send_reset_password_instructions, params: {
+ users_forgot_password_form: { email: "invalid" },
+ locale: "en"
+ }
+
+ expect(response.status).to eq(422)
+ end
+
+ it "handles auth provider errors" do
+ post :send_reset_password_instructions, params: {
+ users_forgot_password_form: { email: "UsernameExists@example.com" },
+ locale: "en"
+ }
+
+ expect(response.status).to eq(422)
+ end
+ end
+
+ describe "GET reset" do
+ it "renders the reset password form" do
+ get :reset, params: { locale: "en" }
+
+ expect(response.body).to have_field("users_reset_password_form[email]")
+ expect(response.body).to have_field("users_reset_password_form[code]")
+ expect(response.body).to have_field("users_reset_password_form[password]")
+ end
+ end
+
+ describe "POST confirm_reset" do
+ it "redirects to the login page" do
+ post :confirm_reset, params: {
+ users_reset_password_form: {
+ email: "test@example.com",
+ code: "123456",
+ password: "password"
+ },
+ locale: "en"
+ }
+
+ expect(response).to redirect_to(new_user_session_path)
+ end
+
+ it "validates email and code" do
+ post :confirm_reset, params: {
+ users_reset_password_form: {
+ email: "invalid",
+ code: "123456",
+ password: "password"
+ },
+ locale: "en"
+ }
+
+ expect(response.status).to eq(422)
+ end
+
+ it "handles auth provider errors" do
+ post :confirm_reset, params: {
+ users_reset_password_form: {
+ email: "test@example.com",
+ code: "000001",
+ password: "password"
+ },
+ locale: "en"
+ }
+
+ expect(response.status).to eq(422)
+ end
+ end
+end
diff --git a/app-rails/spec/controllers/users/registrations_controller_spec.rb b/app-rails/spec/controllers/users/registrations_controller_spec.rb
new file mode 100644
index 0000000..1043e88
--- /dev/null
+++ b/app-rails/spec/controllers/users/registrations_controller_spec.rb
@@ -0,0 +1,121 @@
+require 'rails_helper'
+
+RSpec.describe Users::RegistrationsController do
+ render_views
+
+ before do
+ allow(controller).to receive(:auth_service).and_return(
+ AuthService.new(Auth::MockAdapter.new)
+ )
+ end
+
+ describe "GET new_applicant" do
+ it "renders with applicant content and role" do
+ get :new_applicant, params: { locale: "en" }
+
+ expect(response.body).to have_selector("h1", text: /create an applicant account/i)
+ expect(response.body).to have_field("users_registration_form[role]", with: "applicant", type: :hidden)
+ end
+ end
+
+ describe "GET new_employer" do
+ it "renders with employer content and role" do
+ get :new_employer, params: { locale: "en" }
+
+ expect(response.body).to have_selector("h1", text: /create an employer account/i)
+ expect(response.body).to have_field("users_registration_form[role]", with: "employer", type: :hidden)
+ end
+ end
+
+ describe "POST create" do
+ it "creates a new user and routes to verify page" do
+ email = "test@example.com"
+
+ post :create, params: {
+ users_registration_form: {
+ email: email,
+ password: "password",
+ role: "employer"
+ },
+ locale: "en"
+ }
+ user = User.find_by(email: email)
+
+ expect(user).to be_present
+ expect(user.employer?).to be(true)
+ expect(response).to redirect_to(users_verify_account_path)
+ end
+
+ it "validates email" do
+ post :create, params: {
+ users_registration_form: {
+ email: "invalid",
+ password: "password",
+ role: "employer"
+ },
+ locale: "en"
+ }
+
+ expect(response.status).to eq(422)
+ end
+
+ it "handles auth provider errors" do
+ post :create, params: {
+ users_registration_form: {
+ email: "UsernameExists@example.com",
+ password: "password",
+ role: "employer"
+ },
+ locale: "en"
+ }
+
+ expect(response.status).to eq(422)
+ end
+ end
+
+ describe "GET new_account_verification" do
+ it "renders the account verification form" do
+ get :new_account_verification, params: { locale: "en" }
+
+ expect(response.body).to have_field("users_verify_account_form[code]")
+ end
+ end
+
+ describe "POST create_account_verification" do
+ it "redirects to sign in" do
+ post :create_account_verification, params: {
+ users_verify_account_form: {
+ email: "test@example.com",
+ code: "123456"
+ },
+ locale: "en"
+ }
+
+ expect(response).to redirect_to(new_user_session_path)
+ end
+
+ it "validates email" do
+ post :create_account_verification, params: {
+ users_verify_account_form: {
+ email: "invalid",
+ code: "123456"
+ },
+ locale: "en"
+ }
+
+ expect(response.status).to eq(422)
+ end
+
+ it "handles auth provider errors" do
+ post :create_account_verification, params: {
+ users_verify_account_form: {
+ email: "test@example.com",
+ code: "000001"
+ },
+ locale: "en"
+ }
+
+ expect(response.status).to eq(422)
+ end
+ end
+end
diff --git a/app-rails/spec/controllers/users/sessions_controller_spec.rb b/app-rails/spec/controllers/users/sessions_controller_spec.rb
new file mode 100644
index 0000000..94bf528
--- /dev/null
+++ b/app-rails/spec/controllers/users/sessions_controller_spec.rb
@@ -0,0 +1,144 @@
+require 'rails_helper'
+
+RSpec.describe Users::SessionsController do
+ render_views
+
+ # Hard-code so we can reliably connect the session to a test user we create
+ let (:uid) { "mock-uid" }
+ let (:uid_generator) { -> { uid } }
+
+ before do
+ @request.env["devise.mapping"] = Devise.mappings[:user]
+
+ allow(controller).to receive(:auth_service).and_return(
+ AuthService.new(Auth::MockAdapter.new(uid_generator: uid_generator))
+ )
+ end
+
+ describe "GET new" do
+ it "renders the login form" do
+ get :new, params: { locale: "en" }
+
+ expect(response.body).to have_selector("h1", text: /sign in/i)
+ end
+ end
+
+ describe "POST create" do
+ it "renders the form with errors if credentials are wrong" do
+ post :create, params: {
+ users_new_session_form: {
+ email: "text@example.com",
+ password: "wrong"
+ },
+ locale: "en"
+ }
+
+ expect(response.status).to eq(422)
+ expect(response.body).to have_selector("h1", text: /sign in/i)
+ expect(response.body).to have_selector(".usa-alert--error")
+ end
+
+ it "signs in a applicant and redirects to their account page (for now)" do
+ create(:user, uid: uid)
+
+ post :create, params: {
+ users_new_session_form: {
+ email: "test@example.com",
+ password: "password"
+ },
+ locale: "en"
+ }
+
+ expect(response).to redirect_to(users_account_path)
+ end
+
+ it "signs in and redirects to MFA preference page if a preference is not set" do
+ post :create, params: {
+ users_new_session_form: {
+ email: "test@example.com",
+ password: "password"
+ },
+ locale: "en"
+ }
+
+ expect(response).to redirect_to(users_mfa_preference_path)
+ end
+
+ it "redirects to the verify account page if the user is not confirmed" do
+ post :create, params: {
+ users_new_session_form: {
+ email: "unconfirmed@example.com",
+ password: "password"
+ },
+ locale: "en"
+ }
+
+ expect(response).to redirect_to(users_verify_account_path)
+ end
+
+ it "redirects to the challenge page if MFA is required" do
+ create(:user, uid: uid, mfa_preference: nil)
+
+ post :create, params: {
+ users_new_session_form: {
+ email: "mfa@example.com",
+ password: "password"
+ },
+ locale: "en"
+ }
+
+ expect(session[:challenge_session]).to eq("mock-session")
+ expect(session[:challenge_email]).to eq("mfa@example.com")
+ expect(response).to redirect_to(session_challenge_path)
+ end
+ end
+
+ describe "GET challenge" do
+ it "renders the MFA challenge form" do
+ session[:challenge_session] = "session"
+ session[:challenge_email] = "test@example.com"
+
+ get :challenge, params: { locale: "en" }
+
+ expect(response.body).to have_selector("h1", text: /enter your authentication app code/i)
+ end
+
+ it "redirects to the login page if there is no challenge session" do
+ get :challenge, params: { locale: "en" }
+
+ expect(response).to redirect_to(new_user_session_path)
+ end
+ end
+
+ describe "POST respond_to_challenge" do
+ it "renders the form with errors if the code is invalid" do
+ session[:challenge_session] = "session"
+ session[:challenge_email] = "test@example.com"
+
+ post :respond_to_challenge, params: {
+ users_auth_app_code_form: {
+ code: "wrong"
+ },
+ locale: "en"
+ }
+
+ expect(response.status).to eq(422)
+ expect(response.body).to have_selector(".usa-alert--error")
+ end
+
+ it "signs in a applicant and redirects to their account page (for now)" do
+ create(:user, uid: uid)
+ session[:challenge_session] = "session"
+ session[:challenge_email] = "test@example.com"
+
+ post :respond_to_challenge, params: {
+ users_auth_app_code_form: {
+ code: "123456"
+ },
+ locale: "en"
+ }
+
+ expect(response).to redirect_to(users_account_path)
+ end
+ end
+end
diff --git a/app-rails/spec/factories/user_role_factory.rb b/app-rails/spec/factories/user_role_factory.rb
new file mode 100644
index 0000000..230d96f
--- /dev/null
+++ b/app-rails/spec/factories/user_role_factory.rb
@@ -0,0 +1,18 @@
+FactoryBot.define do
+ factory :user_role do
+ user
+
+ trait :applicant do
+ role { "applicant" }
+ end
+
+ trait :employer do
+ role { "employer" }
+ end
+
+ trait :superadmin do
+ role { "superadmin" }
+ user { create(:user, :superadmin) }
+ end
+ end
+end
diff --git a/app-rails/spec/factories/users_factory.rb b/app-rails/spec/factories/users_factory.rb
new file mode 100644
index 0000000..c05a117
--- /dev/null
+++ b/app-rails/spec/factories/users_factory.rb
@@ -0,0 +1,20 @@
+FactoryBot.define do
+ factory :user do
+ email { Faker::Internet.email }
+ uid { Faker::Internet.uuid }
+ provider { "factory_bot" }
+ mfa_preference { "opt_out" }
+
+ trait :applicant do
+ user_role { create(:user_role, :applicant) }
+ end
+
+ trait :employer do
+ user_role { create(:user_role, :employer) }
+ end
+
+ trait :superadmin do
+ email { "test+admin@navapbc.com" }
+ end
+ end
+end
diff --git a/app-rails/spec/forms/users/new_session_form_spec.rb b/app-rails/spec/forms/users/new_session_form_spec.rb
new file mode 100644
index 0000000..f9b6107
--- /dev/null
+++ b/app-rails/spec/forms/users/new_session_form_spec.rb
@@ -0,0 +1,32 @@
+require "rails_helper"
+
+RSpec.describe Users::NewSessionForm do
+ it "passes validation with valid email and password" do
+ form = Users::NewSessionForm.new(
+ email: "test@example.com",
+ password: "password"
+ )
+
+ expect(form).to be_valid
+ end
+
+ it "requires email and password" do
+ form = Users::NewSessionForm.new({
+ email: "",
+ password: ""
+ })
+
+ expect(form).not_to be_valid
+ expect(form.errors.of_kind?(:email, :blank)).to be_truthy
+ expect(form.errors.of_kind?(:password, :blank)).to be_truthy
+ end
+
+ it "requires a valid email" do
+ form = Users::NewSessionForm.new(
+ email: "not_an_email"
+ )
+
+ expect(form).not_to be_valid
+ expect(form.errors.of_kind?(:email, :invalid)).to be_truthy
+ end
+end
diff --git a/app-rails/spec/forms/users/registration_form_spec.rb b/app-rails/spec/forms/users/registration_form_spec.rb
new file mode 100644
index 0000000..33a6365
--- /dev/null
+++ b/app-rails/spec/forms/users/registration_form_spec.rb
@@ -0,0 +1,39 @@
+require "rails_helper"
+
+valid_password = "password1234"
+
+RSpec.describe Users::RegistrationForm do
+ let (:form) { Users::RegistrationForm.new(role: "applicant") }
+
+ it "passes validation with valid email and password" do
+ form.email = "test@example.com"
+ form.password = valid_password
+ form.password_confirmation = valid_password
+
+ expect(form).to be_valid
+ end
+
+ it "requires email and password" do
+ form.email = ""
+ form.password = ""
+
+ expect(form).not_to be_valid
+ expect(form.errors.of_kind?(:email, :blank)).to be_truthy
+ expect(form.errors.of_kind?(:password, :blank)).to be_truthy
+ end
+
+ it "confirms the password matches" do
+ form.password = valid_password
+ form.password_confirmation = "not_the_same"
+
+ expect(form).not_to be_valid
+ expect(form.errors.of_kind?(:password_confirmation, :confirmation)).to be_truthy
+ end
+
+ it "requires a valid email" do
+ form.email = "not_an_email"
+
+ expect(form).not_to be_valid
+ expect(form.errors.of_kind?(:email, :invalid)).to be_truthy
+ end
+end
diff --git a/app-rails/spec/forms/users/verify_account_form_spec.rb b/app-rails/spec/forms/users/verify_account_form_spec.rb
new file mode 100644
index 0000000..83ed152
--- /dev/null
+++ b/app-rails/spec/forms/users/verify_account_form_spec.rb
@@ -0,0 +1,31 @@
+require "rails_helper"
+
+RSpec.describe Users::VerifyAccountForm do
+ it "passes validation with valid email and code" do
+ form = Users::VerifyAccountForm.new(email: "test@example.com", code: "123456")
+
+ expect(form).to be_valid
+ end
+
+ it "requires email and code" do
+ form = Users::VerifyAccountForm.new(email: nil, code: nil)
+
+ expect(form).to be_invalid
+ expect(form.errors.of_kind?(:email, :blank)).to be_truthy
+ expect(form.errors.of_kind?(:code, :blank)).to be_truthy
+ end
+
+ it "requires email to be a valid email" do
+ form = Users::VerifyAccountForm.new(email: "invalid-email", code: "123456")
+
+ expect(form).to be_invalid
+ expect(form.errors.of_kind?(:email, :invalid)).to be_truthy
+ end
+
+ it "requires code to be 6 characters" do
+ form = Users::VerifyAccountForm.new(email: "test@example.com", code: "12345")
+
+ expect(form).to be_invalid
+ expect(form.errors.of_kind?(:code, :wrong_length)).to be_truthy
+ end
+end
diff --git a/app-rails/spec/models/user_spec.rb b/app-rails/spec/models/user_spec.rb
new file mode 100644
index 0000000..e69425a
--- /dev/null
+++ b/app-rails/spec/models/user_spec.rb
@@ -0,0 +1,71 @@
+require 'rails_helper'
+
+RSpec.describe User, type: :model do
+ describe "access_token" do
+ let(:user) { build(:user) }
+
+ it "is an attr_accessor so we can store the token in the session" do
+ expect(user).to respond_to(:access_token)
+ expect(user).to respond_to(:access_token=)
+ end
+ end
+
+ describe "access_token_expires_within_minutes?" do
+ let(:user) { build(:user) }
+ let(:access_token) {
+ JWT.encode({ exp: 5.minutes.from_now.to_i }, nil)
+ }
+
+ it "returns true if the access token expires within the designated minutes" do
+ expect(user.access_token_expires_within_minutes?(access_token, 5)).to eq(true)
+ end
+
+ it "returns false if the access token is not expiring within the designated minutes" do
+ expect(user.access_token_expires_within_minutes?(access_token, 1)).to eq(false)
+ end
+ end
+
+ describe "applicant?" do
+ let(:user) { build(:user) }
+
+ it "returns true if the user has a applicant role" do
+ user.user_role = build(:user_role, :applicant)
+ expect(user.applicant?).to eq(true)
+ end
+
+ it "returns false if the user does not have a applicant role" do
+ user.user_role = build(:user_role, :employer)
+ expect(user.applicant?).to eq(false)
+ end
+ end
+
+ describe "employer?" do
+ let(:user) { build(:user) }
+
+ it "returns true if the user has an employer role" do
+ user.user_role = build(:user_role, :employer)
+ expect(user.employer?).to eq(true)
+ end
+
+ it "returns false if the user does not have an employer role" do
+ user.user_role = build(:user_role, :applicant)
+ expect(user.employer?).to eq(false)
+ end
+ end
+
+ describe "superadmin?" do
+ let(:user) { build(:user) }
+
+ pending "returns true for a superadmin"
+
+ it "returns false for a applicant" do
+ user.user_role = build(:user_role, :applicant)
+ expect(user.superadmin?).to eq(false)
+ end
+
+ it "returns false for an employer" do
+ user.user_role = build(:user_role, :employer)
+ expect(user.superadmin?).to eq(false)
+ end
+ end
+end
diff --git a/app-rails/spec/rails_helper.rb b/app-rails/spec/rails_helper.rb
new file mode 100644
index 0000000..0dba589
--- /dev/null
+++ b/app-rails/spec/rails_helper.rb
@@ -0,0 +1,74 @@
+# SimpleCov needs to be required before any of your application code is loaded.
+require 'simplecov'
+SimpleCov.start 'rails' do
+ add_filter 'lib/templates/'
+ add_filter 'lib/generators/'
+end
+
+require 'spec_helper'
+ENV['RAILS_ENV'] ||= 'test'
+require_relative '../config/environment'
+# Prevent database truncation if the environment is production
+abort("The Rails environment is running in production mode!") if Rails.env.production?
+require 'rspec/rails'
+# Add additional requires below this line. Rails is not loaded until this point!
+require_relative 'support/factory_bot'
+
+# Requires supporting ruby files with custom matchers and macros, etc, in
+# spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are
+# run as spec files by default. This means that files in spec/support that end
+# in _spec.rb will both be required and run as specs, causing the specs to be
+# run twice. It is recommended that you do not name files matching this glob to
+# end with _spec.rb. You can configure this pattern with the --pattern
+# option on the command line or in ~/.rspec, .rspec or `.rspec-local`.
+#
+# The following line is provided for convenience purposes. It has the downside
+# of increasing the boot-up time by auto-requiring all files in the support
+# directory. Alternatively, in the individual `*_spec.rb` files, manually
+# require only the support files necessary.
+#
+# Rails.root.glob('spec/support/**/*.rb').sort.each { |f| require f }
+
+# Checks for pending migrations and applies them before tests are run.
+# If you are not using ActiveRecord, you can remove these lines.
+begin
+ ActiveRecord::Migration.maintain_test_schema!
+rescue ActiveRecord::PendingMigrationError => e
+ abort e.to_s.strip
+end
+RSpec.configure do |config|
+ config.include Devise::Test::ControllerHelpers, type: :controller
+
+ # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures
+ config.fixture_paths = [
+ Rails.root.join('spec/fixtures')
+ ]
+
+ # If you're not using ActiveRecord, or you'd prefer not to run each of your
+ # examples within a transaction, remove the following line or assign false
+ # instead of true.
+ config.use_transactional_fixtures = true
+
+ # You can uncomment this line to turn off ActiveRecord support entirely.
+ # config.use_active_record = false
+
+ # RSpec Rails can automatically mix in different behaviours to your tests
+ # based on their file location, for example enabling you to call `get` and
+ # `post` in specs under `spec/controllers`.
+ #
+ # You can disable this behaviour by removing the line below, and instead
+ # explicitly tag your specs with their type, e.g.:
+ #
+ # RSpec.describe UsersController, type: :controller do
+ # # ...
+ # end
+ #
+ # The different available types are documented in the features, such as in
+ # https://rspec.info/features/6-0/rspec-rails
+ config.infer_spec_type_from_file_location!
+
+ # Filter lines from Rails gems in backtraces.
+ config.filter_rails_from_backtrace!
+ # arbitrary gems may also be filtered via:
+ # config.filter_gems_from_backtrace("gem name")
+end
diff --git a/app-rails/spec/services/auth_service_spec.rb b/app-rails/spec/services/auth_service_spec.rb
new file mode 100644
index 0000000..029d091
--- /dev/null
+++ b/app-rails/spec/services/auth_service_spec.rb
@@ -0,0 +1,62 @@
+require "rails_helper"
+
+RSpec.describe AuthService do
+ let(:mock_uid) { "mock_uid" }
+ let(:mock_auth_adapter) { Auth::MockAdapter.new(uid_generator: -> { mock_uid }) }
+
+ describe "#register" do
+ it "creates a new user with the given role" do
+ auth_service = AuthService.new(mock_auth_adapter)
+
+ auth_service.register("test@example.com", "password", "employer")
+
+ user = User.find_by(uid: mock_uid)
+ expect(user).to be_present
+ expect(user.provider).to eq("mock")
+ expect(user.email).to eq("test@example.com")
+ expect(user.employer?).to eq(true)
+ end
+ end
+
+ describe "#change_email" do
+ it "updates the user's email" do
+ auth_service = AuthService.new(mock_auth_adapter)
+ User.create!(uid: mock_uid, email: "test@example.com", provider: "mock")
+
+ auth_service.change_email(mock_uid, "new@example.com")
+
+ user = User.find_by(uid: mock_uid)
+ expect(user.email).to eq("new@example.com")
+ end
+ end
+
+ describe "#initiate_auth" do
+ it "creates a new user if one does not exist" do
+ auth_service = AuthService.new(mock_auth_adapter)
+
+ response = auth_service.initiate_auth("test@example.com", "password")
+
+ user = User.find_by(uid: mock_uid)
+ expect(response[:user]).to eq(user)
+ expect(user.applicant?).to eq(true)
+ end
+
+ it "updates the user's email if it has changed" do
+ auth_service = AuthService.new(mock_auth_adapter)
+ User.create!(uid: mock_uid, email: "oldie@example.com", provider: "mock")
+
+ response = auth_service.initiate_auth("new@example.com", "password")
+
+ user = User.find_by(uid: mock_uid)
+ expect(user.email).to eq("new@example.com")
+ end
+ end
+
+ describe "#verify_account" do
+ it "returns empty struct on success" do
+ auth_service = AuthService.new(mock_auth_adapter)
+
+ expect(auth_service.verify_account("test@example.com", "123456")).to eq({})
+ end
+ end
+end
diff --git a/app-rails/spec/spec_helper.rb b/app-rails/spec/spec_helper.rb
new file mode 100644
index 0000000..2922697
--- /dev/null
+++ b/app-rails/spec/spec_helper.rb
@@ -0,0 +1,96 @@
+require 'pundit/matchers'
+
+# This file was generated by the `rails generate rspec:install` command. Conventionally, all
+# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
+# The generated `.rspec` file contains `--require spec_helper` which will cause
+# this file to always be loaded, without a need to explicitly require it in any
+# files.
+#
+# Given that it is always loaded, you are encouraged to keep this file as
+# light-weight as possible. Requiring heavyweight dependencies from this file
+# will add to the boot time of your test suite on EVERY test run, even for an
+# individual file that may not need all of that loaded. Instead, consider making
+# a separate helper file that requires the additional dependencies and performs
+# the additional setup, and require it from the spec files that actually need
+# it.
+#
+# See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
+RSpec.configure do |config|
+ # rspec-expectations config goes here. You can use an alternate
+ # assertion/expectation library such as wrong or the stdlib/minitest
+ # assertions if you prefer.
+ config.expect_with :rspec do |expectations|
+ # This option will default to `true` in RSpec 4. It makes the `description`
+ # and `failure_message` of custom matchers include text for helper methods
+ # defined using `chain`, e.g.:
+ # be_bigger_than(2).and_smaller_than(4).description
+ # # => "be bigger than 2 and smaller than 4"
+ # ...rather than:
+ # # => "be bigger than 2"
+ expectations.include_chain_clauses_in_custom_matcher_descriptions = true
+ end
+
+ # rspec-mocks config goes here. You can use an alternate test double
+ # library (such as bogus or mocha) by changing the `mock_with` option here.
+ config.mock_with :rspec do |mocks|
+ # Prevents you from mocking or stubbing a method that does not exist on
+ # a real object. This is generally recommended, and will default to
+ # `true` in RSpec 4.
+ mocks.verify_partial_doubles = true
+ end
+
+ # This option will default to `:apply_to_host_groups` in RSpec 4 (and will
+ # have no way to turn it off -- the option exists only for backwards
+ # compatibility in RSpec 3). It causes shared context metadata to be
+ # inherited by the metadata hash of host groups and examples, rather than
+ # triggering implicit auto-inclusion in groups with matching metadata.
+ config.shared_context_metadata_behavior = :apply_to_host_groups
+
+# The settings below are suggested to provide a good initial experience
+# with RSpec, but feel free to customize to your heart's content.
+=begin
+ # This allows you to limit a spec run to individual examples or groups
+ # you care about by tagging them with `:focus` metadata. When nothing
+ # is tagged with `:focus`, all examples get run. RSpec also provides
+ # aliases for `it`, `describe`, and `context` that include `:focus`
+ # metadata: `fit`, `fdescribe` and `fcontext`, respectively.
+ config.filter_run_when_matching :focus
+
+ # Allows RSpec to persist some state between runs in order to support
+ # the `--only-failures` and `--next-failure` CLI options. We recommend
+ # you configure your source control system to ignore this file.
+ config.example_status_persistence_file_path = "spec/examples.txt"
+
+ # Limits the available syntax to the non-monkey patched syntax that is
+ # recommended. For more details, see:
+ # https://rspec.info/features/3-12/rspec-core/configuration/zero-monkey-patching-mode/
+ config.disable_monkey_patching!
+
+ # Many RSpec users commonly either run the entire suite or an individual
+ # file, and it's useful to allow more verbose output when running an
+ # individual spec file.
+ if config.files_to_run.one?
+ # Use the documentation formatter for detailed output,
+ # unless a formatter has already been configured
+ # (e.g. via a command-line flag).
+ config.default_formatter = "doc"
+ end
+
+ # Print the 10 slowest examples and example groups at the
+ # end of the spec run, to help surface which specs are running
+ # particularly slow.
+ config.profile_examples = 10
+
+ # Run specs in random order to surface order dependencies. If you find an
+ # order dependency and want to debug it, you can fix the order by providing
+ # the seed, which is printed after each run.
+ # --seed 1234
+ config.order = :random
+
+ # Seed global randomization in this process using the `--seed` CLI option.
+ # Setting this allows you to use `--seed` to deterministically reproduce
+ # test failures related to randomization by passing the same `--seed` value
+ # as the one that triggered the failure.
+ Kernel.srand config.seed
+=end
+end
diff --git a/app-rails/spec/support/factory_bot.rb b/app-rails/spec/support/factory_bot.rb
new file mode 100644
index 0000000..c7890e4
--- /dev/null
+++ b/app-rails/spec/support/factory_bot.rb
@@ -0,0 +1,3 @@
+RSpec.configure do |config|
+ config.include FactoryBot::Syntax::Methods
+end
diff --git a/app-rails/vendor/bundle/.keep b/app-rails/vendor/bundle/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/docker-compose.mock-production.yml b/docker-compose.mock-production.yml
index e69de29..0555d75 100644
--- a/docker-compose.mock-production.yml
+++ b/docker-compose.mock-production.yml
@@ -0,0 +1,35 @@
+services:
+ database:
+ image: postgres:14-alpine
+ command: postgres -c "log_lock_waits=on" -N 1000 -c "fsync=off"
+ environment:
+ POSTGRES_PASSWORD: secret123
+ POSTGRES_USER: app_rails
+ healthcheck:
+ test: "pg_isready --username=app_rails"
+ timeout: 10s
+ retries: 20
+ ports:
+ - "5432:5432"
+
+ # Rails app
+ # Configured for "production" RAILS_ENV
+ app-rails:
+ build:
+ context: ./app-rails
+ target: release
+ depends_on:
+ database:
+ condition: service_healthy
+ env_file: ./app-rails/.env
+ environment:
+ - DB_HOST=database
+ - RAILS_BINDING=0.0.0.0
+ # The following env vars allow testing a "production"-like environment on a local
+ # machine.
+ - RAILS_ENV=mock-production
+ - SECRET_KEY_BASE=verysecret
+ - DISABLE_DATABASE_ENVIRONMENT_CHECK=1
+ - AWS_DEFAULT_REGION=us-east-1
+ ports:
+ - 3200:3000
diff --git a/docker-compose.yml b/docker-compose.yml
index e69de29..7d3deba 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -0,0 +1,46 @@
+services:
+ database:
+ image: postgres:14-alpine
+ command: postgres -c "log_lock_waits=on" -N 1000 -c "fsync=off"
+ environment:
+ POSTGRES_PASSWORD: secret123
+ POSTGRES_USER: app_rails
+ healthcheck:
+ test: "pg_isready --username=app_rails"
+ timeout: 10s
+ retries: 20
+ ports:
+ - "5432:5432"
+ volumes:
+ - database_data:/var/lib/postgresql/data
+
+ # Rails app
+ # Configured for "development" RAILS_ENV
+ app-rails:
+ build:
+ context: ./app-rails
+ target: dev
+ depends_on:
+ database:
+ condition: service_healthy
+ env_file: ./app-rails/.env
+ environment:
+ - DB_HOST=database
+ - RAILS_BINDING=0.0.0.0
+ - RAILS_ENV=development
+ ports:
+ - 3100:3000
+ volumes:
+ - ./app-rails:/rails
+ # Use named volumes for directories that the container should use the guest
+ # machine's dir instead of the host machine's dir, which may be divergent.
+ # This is especially true for any dependency or temp directories.
+ - app_rails_nodemodules:/rails/node_modules
+ - app_rails_tmp:/rails/tmp
+ - app_rails_storage:/rails/storage
+
+volumes:
+ database_data:
+ app_rails_nodemodules:
+ app_rails_tmp:
+ app_rails_storage:
diff --git a/docs/app-rails/README.md b/docs/app-rails/README.md
new file mode 100644
index 0000000..6b0ab43
--- /dev/null
+++ b/docs/app-rails/README.md
@@ -0,0 +1,15 @@
+# Documentation for app-rails
+
+## 📂 Directory structure
+
+```text
+├── decisions # Architecture Decision Records
+├── media # Diagrams, images
+├── *.md # Documentation about the application
+```
+
+## Recommended reading
+
+- Start by reading the application's [main README](/app-rails/README.md) if you haven't already
+- Next, review the [Technical Foundation](./technical-foundation.md)
+- Continue reviewing the other documentation in this directory as desired
\ No newline at end of file
diff --git a/docs/app-rails/auth.md b/docs/app-rails/auth.md
new file mode 100644
index 0000000..56089cb
--- /dev/null
+++ b/docs/app-rails/auth.md
@@ -0,0 +1,38 @@
+# Authentication & Authorization
+
+## Applicant & Employer Authentication
+
+Authentication is the process of verifying the credentials of a user. We use AWS Cognito for authentication.
+
+- User credentials are stored in AWS Cognito
+- Password policy is enforced by Cognito
+- Custom pages are built for the AWS Cognito flows (login, signup, forgot password, etc.). We aren't using the hosted UI that Cognito provides since we need more control over the UI and content.
+- [Devise](https://www.rubydoc.info/github/heartcombo/devise/main) and [Warden](https://www.rubydoc.info/github/hassox/warden) facilitate auth and session management
+
+## Authorization
+
+Authorization is the process of determining whether a user has access to a specific resource. We use [Pundit](https://github.com/varvet/pundit) for authorization.
+
+- User roles are defined in the `user_roles` table
+- Policies (`app/policies`) are created for each model to define who can perform what actions
+- Policies are used in controllers to authorize actions
+- Policies are used in views to show/hide elements based on user permissions
+
+### Generating policies
+
+```sh
+make new-authz-policy MODEL=Foo
+```
+
+### Testing policies
+
+[`pundit-matchers`](https://github.com/pundit-community/pundit-matchers) provides RSpec matchers for testing Pundit policies. Refer to existing policy spec files, or the spec file generated when creating a new policy, for examples.
+
+### Verifications
+
+We use a few `after_action` Pundit callbacks in the application controller to verify that our controllers are authorizing resources correctly. These aren't foolproof, but they can help catch some common mistakes:
+
+- If you forget to call `authorize` in a controller action, you'll see an exception like `AuthorizationNotPerformedError`.
+- If you forget to call `policy_scope` in a controller action, you'll see an exception like `PolicyScopingNotPerformedError`.
+
+To opt out of these checks on actions that don't need them, you can add `skip_after_action :verify_authorized` or `skip_after_action :verify_policy_scoped` to your controller. Alternatively, you can add `skip_authorization` or `skip_policy_scope` to your controller action.
diff --git a/docs/app-rails/container-images.md b/docs/app-rails/container-images.md
new file mode 100644
index 0000000..051242c
--- /dev/null
+++ b/docs/app-rails/container-images.md
@@ -0,0 +1,67 @@
+# Container images
+
+This application employs the [Docker multi-stage build strategy](https://docs.docker.com/build/building/multi-stage/) to build separate docker images for different purposes:
+
+* `dev`: designed to run tests and support local development on a developer machine
+* `release`: optimized for deployment to production or other hosted environments
+
+## Local development: `dev`
+
+You can run the application locally within a container or natively. When running the application in a container locally, use the `dev` image.
+
+Example `docker-compose.yml` snippet:
+
+```yaml
+services:
+ local-development-app:
+ build:
+ target: dev
+```
+
+## Deployment to hosted environments: `release`
+
+When you deploy this application to hosted environments (e.g. AWS, Azure, GCP), use the `release` image.
+
+Example `docker-compose.yml` snippet:
+
+```yaml
+services:
+ hosted-app:
+ build:
+ target: release
+```
+
+## Testing `release` locally
+
+It is useful to be able to test the `release` image locally without needing to run a deploy to a hosted environment. For example, this can decrease the iteration time when troubleshooting the production asset precompile pipeline.
+
+In addition to the default Rails environments (i.e. `test`, `development`, `production`), this application includes a `mock-production` Rails environment, which uses "production-like" configuration. Specifically, SSL is disabled in [`mock-production.rb`](/app-rails/config/environments/mock-production.rb):
+
+```ruby
+config.assume_ssl = false
+config.force_ssl = false
+```
+
+### Instructions
+
+Follow these steps to run the `release` image locally. This process uses [`docker-compose.mock-production.yml`](/docker-compose.mock-production.yml).
+
+#### 1. Change to the application directory
+
+```bash
+cd app-rails
+```
+
+#### 2. Initialize the container
+
+```bash
+make init-container DOCKER_COMPOSE_ARGS="-f ../docker-compose.mock-production.yml"
+```
+
+#### 3. Start the container
+
+```bash
+make start-container DOCKER_COMPOSE_ARGS="-f ../docker-compose.mock-production.yml"
+```
+
+To stop the container, type `Ctrl-C`.
\ No newline at end of file
diff --git a/docs/app-rails/decisions/README.md b/docs/app-rails/decisions/README.md
new file mode 100644
index 0000000..e69de29
diff --git a/docs/app-rails/forms.md b/docs/app-rails/forms.md
new file mode 100644
index 0000000..22032e7
--- /dev/null
+++ b/docs/app-rails/forms.md
@@ -0,0 +1,102 @@
+# Forms
+
+A custom `us_form_with` helper is provided to create forms. This adds the necessary U.S. Web Design System classes to the form and its elements, as well as simplifies the creation of labels, hint text, and inline error messages.
+
+The `us_form_with` helper is a drop-in replacement for the standard `form_with` helper. It accepts the same arguments and options as `form_with`, and exposes the same [field helpers](https://guides.rubyonrails.org/form_helpers.html).
+
+Example usage:
+
+```erb
+<%= us_form_with model: @claim do |f| %>
+ <%= f.text_field :name, { hint: t(".name.hint") } %>
+
+ <%= f.fieldset t("claim_types.legend") do %>
+ <%= f.radio_button :claim_type, "medical" %>
+ <%= f.radio_button :claim_type, "family" %>
+ <% end %>
+
+ <%= f.submit %>
+<% end %>
+```
+
+## Labels
+
+When using the `us_form_with` helper, you don't need to use a separate `label` helper. Instead, you can pass a `label` option to the field helper:
+
+```erb
+<%= f.text_field :name, { label: "Full name" } %>
+```
+
+## Hint text
+
+You can add hint text to a field by passing a `hint` option to the field helper:
+
+```erb
+<%= f.text_field :name, { hint: "Enter your full name" } %>
+```
+
+To include hint text within a fieldset, use the `hint` helper:
+
+```erb
+<%= f.fieldset "Notification preferences" do %>
+ <%= f.hint "Select the ways you'd like to be notified" %>
+```
+
+## Fieldsets
+
+Use the custom `fieldset` helper to create a fieldset with a legend, rather than the standard `field_set_tag` helper.
+
+```erb
+<%= f.fieldset "Notification preferences" do %>
+ <%= f.check_box :email %>
+ <%= f.check_box :sms %>
+<% end %>
+```
+
+Use `field_error` to display an error message for a radio group:
+
+```erb
+<%= f.fieldset "Plan" do %>
+ <%= f.field_error :plan %>
+ <%= f.radio_button :plan, "health" %>
+ <%= f.radio_button :plan, "medical" %>
+<% end %>
+```
+
+A `human_name` helper is provided to format a field name for display to the user. This is primarily useful for fieldset legends – other fields already utilize this behind the scenes.
+
+```erb
+<%= f.fieldset f.human_name(:notification_preferences) do %>
+ <%= f.check_box :email %>
+ <%= f.check_box :sms %>
+<% end %>
+```
+
+## Yes/No fields
+
+Use the custom `yes_no` helper to create a pair of radio buttons for a boolean field:
+
+```erb
+<%= f.yes_no :has_previous_leave %>
+```
+
+To apply custom labels or hint text to the radio buttons, pass in `yes_options` and `no_options`:
+
+```erb
+<%= f.yes_no :has_previous_leave, {
+ yes_options: { label: "Yes, I've taken leave before" },
+ no_options: { label: "No, I haven't taken leave before" }
+} %>
+```
+
+## Text inputs
+
+Control the width of a text input by passing a `width` option to the field helper, with a value corresponding to the `width` values accepted by [the `usa-input--[width]` class](https://designsystem.digital.gov/components/text-input/#using-the-text-input-component-2).
+
+```erb
+<%= f.text_field :name, { width: "md" } %>
+```
+
+## Testing
+
+When running the app locally, a test form is available at `/dev/sandbox`
diff --git a/docs/app-rails/internationalization.md b/docs/app-rails/internationalization.md
new file mode 100644
index 0000000..00a9af5
--- /dev/null
+++ b/docs/app-rails/internationalization.md
@@ -0,0 +1,65 @@
+# Internationalization
+
+We use Rails' built-in internationalization (i18n) to support multiple languages, [read the general documentation here](https://guides.rubyonrails.org/i18n.html).
+
+To create new locale files for a model and/or its views, you can run:
+
+```sh
+make locale MODEL=MyModel
+```
+
+## Organization of locale files
+
+Placing translations for all parts of an application in one file per locale can become hard to manage. We've chosen to organize our translations into multiple YAML files, using the following hierarchy
+
+- `defaults` - fallback errors, date, and time formats
+- `models` - model and attribute names
+- `views` - strings specifically rendered in views, partials, or layouts
+
+> [!CAUTION]
+> Be aware that YAML interprets the following case-insensitive strings as booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings must be quoted to be interpreted as strings. For example: `"yes": yup` and `enabled: "ON"`
+
+## Routes and links
+
+The active locale is based on the URL. For example, to use the Spanish locale, visit: `/es-US`.
+
+When adding a new route, ensure it's nested within the `localized` block so that it can be viewed at the appropriate locale.
+
+To ensure links preserve the locale, it's important for them to include the locale param, which happens automatically when using `link_to` or `url_for` helpers:
+
+```erb
+<%= link_to "Apply", controller: "claims", action: "new" %>
+```
+
+## Rendering content
+
+To render content, use `I18n.t`:
+
+```ruby
+I18n.t("hello")
+```
+
+In views, this is aliased to just `t`:
+
+```erb
+<%= t("hello") %>
+```
+
+### Dates
+
+To format dates, use the custom `local_time` helper:
+
+```erb
+<%= local_time(foo.created_at) %>
+```
+
+To include the time, use the `:format` option:
+
+```erb
+<%= local_time(foo.created_at, format: :long) %>
+```
+
+## Adding a new language
+
+1. Add new YAML file(s) in `config/locales`
+1. Update `available_locales` in `config/application.rb` to include the new locale
diff --git a/docs/app-rails/media/.keep b/docs/app-rails/media/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/docs/app-rails/software-architecture.md b/docs/app-rails/software-architecture.md
new file mode 100644
index 0000000..591c09d
--- /dev/null
+++ b/docs/app-rails/software-architecture.md
@@ -0,0 +1,84 @@
+# Software architecture
+
+## Rails
+
+Rails follows the [Model-View-Controller (MVC)](https://guides.rubyonrails.org/getting_started.html#mvc-and-you) pattern.
+
+### Controllers & routing
+
+Controllers contain actions which handle routing requests. Controllers should be relatively "dumb" and should not contain any business logic.
+
+When handling CRUD operations, controllers are responsible for initiating persistence operations, such as quering the database and instantiating objects or creating new records. Controllers display data for requests by rendering views.
+
+### Models & inheritance
+
+Models are ruby classes and can only have one parent.
+
+Domain modeling is expressed in rails through models. Models in rails are powerful because of ActiveRecord and [associations](https://guides.rubyonrails.org/association_basics.html). All database relationships should be expressed through the ActiveRecord.
+
+Model inheritance is very powerful, but slightly complicated. [Single table inheritance](https://guides.rubyonrails.org/association_basics.html#single-table-inheritance-sti) should only be used if the subclasses have largely the same attributes/database fields. STI creates one table that is used for all subclasses; this means that all fields for all the subclasses are appended to the same table.
+
+If the subclasses have many distinct attributes, use [delegated types](https://guides.rubyonrails.org/association_basics.html#delegated-types) instead. Delegated types creates a single shared table for shared attributes, but maintains separate tables for each subclass's distinct attributes.
+
+### Validations & callbacks
+
+ActiveRecord provides an easy way to handle model [validations](https://guides.rubyonrails.org/active_record_validations.html) with many built-in validators. Any validation logic that should be used in more than one model (e.g. validating a tax id) should be moved into a custom validator.
+
+[Callbacks](https://guides.rubyonrails.org/active_record_callbacks.html) in rails are powerful ways to hook into the object lifecycle, allowing events to trigger custom actions. Callbacks should primarily be used for persistence logic: logic that is related to persisting information to the database. Business logic should generally be expressed as methods that can be optionally invoked. Drawing the line between persistence logic and business logic can be challenging.
+
+### Concerns
+
+Concerns are ruby modules and single class can have multiple modules can be mixed into it. Rails supports concerns for models and controllers. Any reusable code that can be DRY'd should be moved into a concern.
+
+Special care should be taken with model concerns. Model concerns make assumptions about what attributes the model that includes it will have defined. This leans heavily into ruby's preference for duck typing. This allows for a lot of flexibility but defensive programming should be employed so that guardrails prevent unwanted behavior.
+
+## Additional patterns
+
+### Services
+
+Services encapsulate business logic. They are typically used by controllers to perform actions and return data. Services should receive external dependencies as arguments (AKA dependency injection) to make testing easier. Services should be thin wrappers and should not contain explicit knowledge about models.
+
+### Adapters
+
+Adapters encapsulate interactions with external services. A mock adapter can be used in tests to avoid making real requests.
+
+### Form objects
+
+Form objects are responsible for validation and data manipulation that needs to happen before data is persisted (i.e combining fields, formatting, etc).
+
+These are for forms not directly tied to a single ActiveRecord model or backed by a database table.
+
+Benefits of form objects are:
+
+- Separation of concerns: validation logic is separated from the controller
+- Testability: validation logic can be unit tested without needing to test the controller
+- Consistency: use the same ActiveModel validations as ActiveRecord models
+- Reusability: can be reused across multiple controllers
+
+### Tying it all together
+
+Controllers handle requests, using form objects to validate and manipulate data.
+
+Controllers initialize adapters, which are passed into services, which use the adapters to interact with external services. This allows us to swap adapters for testing, to avoid making real requests.
+
+```mermaid
+graph TD
+ Ctrl[Controller]
+ Service[Service]
+ Form[Form object]
+ Adpt[Adapter]
+
+ Jobs -->|uses| Service
+ Ctrl -->|uses| Service
+ Ctrl -->|uses| Form
+ Ctrl -->|renders| View
+
+ Form -->|uses| AM[ActiveModel]
+
+ Service -->|uses| Adpt
+ Service -->|uses| AR[ActiveRecord]
+ AR -->|callbacks| AR
+
+ Adpt -->|calls| ExtSvc[External Service]
+ AR -->|persists| DB[Database]
+```
diff --git a/docs/app-rails/technical-foundation.md b/docs/app-rails/technical-foundation.md
new file mode 100644
index 0000000..8198d83
--- /dev/null
+++ b/docs/app-rails/technical-foundation.md
@@ -0,0 +1,135 @@
+# Technical foundation
+
+## 🧑🎨 Frontend
+
+### 🇺🇸 USWDS
+
+The frontend utilizes the [U.S. Web Design System (USWDS)](https://designsystem.digital.gov/) for styling and common components.
+
+To reference USWDS assets, use Rails asset helpers like:
+
+```erb
+<%= image_tag "@uswds/uswds/dist/img/usa-icons/close.svg", alt: "Close" %>
+
+
+```
+
+### 🌎 Internationalization (i18n)
+
+The app uses [Rails' built-in i18n support](https://guides.rubyonrails.org/i18n.html). Locale strings are located in [`config/locales`](/app-rails/config/locales).
+
+Refer to the [Internationalization doc](./internationalization.md) for more details and conventions.
+
+## ⚙️ Backend
+
+### 💽 Database
+
+Postgresql is used for the database.
+
+#### Commands
+
+- Run migrations: `make db-migrate`
+- Seed the database: `make db-seed`
+- Reset the database: `make db-reset`
+ - This will drop the database, recreate the schema based on `db/schema.rb` (not the migrations!), and seed the database
+- Access the database console: `make db-console`
+
+#### UUIDs
+
+We have are using [UUIDs for primary keys](https://guides.rubyonrails.org/active_record_postgresql.html#uuid-primary-keys) instead of autoincrementing integers. This has a few implications to beware of:
+
+- ⚠️ Using ActiveRecord functions like `Foo.first` and `Foo.last` have unreliable results
+- Generating new models or scaffolds requires passing the `--primary-key-type=uuid` flag. For instance, `make rails-generate GENERATE_COMMAND="model Foo --primary-key-type=uuid"`
+- `pgcrypto` is a required dependency
+
+#### Enums
+
+⚠️ Important! Enum order cannot be changed.
+From https://api.rubyonrails.org/v7.1.3.2/classes/ActiveRecord/Enum.html:
+
+> ... once a value is added to the enum array, its position in the array must be
+> maintained, and new values should only be added to the end of the array. To remove
+> unused values, the explicit hash syntax should be used.
+
+- Use explicit hashes that map the enum symbol to an integer, instead of implicit arrays. For example: `{ approved: 0, denied: 1}` instead of `[:approved, :denied]`.
+
+### 📫 Notifications
+
+The app uses [Action Mailer](https://guides.rubyonrails.org/action_mailer_basics.html) for sending email notifications. During local development, it uses `letter_opener` to open emails in the browser instead of sending them.
+
+To preview email views in the browser, visit: `/rails/mailers`
+
+To test AWS SES email sending locally:
+
+1. Set the `AWS_*` env var in your `.env` file
+1. Set the `SES_EMAIL` env var to a verified sending identity
+1. Restart the server
+
+### 🎭 Authentication
+
+The app provides a service and configuration for authentication with AWS Cognito.
+
+Refer to the [Auth doc](./auth.md) for more details and conventions.
+
+## 🔄 Continuous integration
+
+### 🧪 Unit testing
+
+[RSpec](https://rspec.info/) is used for testing. [Capybara](https://www.rubydoc.info/gems/capybara/Capybara/RSpecMatchers) matchers are available.
+
+Run tests with:
+
+```sh
+make test
+```
+
+Pass additional arguments to `rspec` with `args`:
+
+```sh
+make test args="spec/path/to/specific_test.rb"
+```
+
+### 🧹 Linting
+
+[Rubocop](https://rubocop.org/) is used for linting.
+
+Run linting with auto-fixing with:
+
+```sh
+make lint
+```
+
+## ℹ️ Developer norms, tooling, and tips
+
+### 🤖 Norms
+
+Use the rails generator when creating new models, migrations, etc. It does most of the heavy lifting for you.
+
+To create a full scaffold of controller, views, model, database migration:
+
+```sh
+make rails-generate GENERATE_COMMAND="scaffold Foo --primary-key-type=uuid"
+```
+
+To create a database migration:
+
+```sh
+make rails-generate GENERATE_COMMAND="migration AddColumnToTable"
+```
+
+To create a model:
+
+```sh
+make rails-generate GENERATE_COMMAND="model Foo --primary-key-type=uuid"
+```
+
+### 🐛 Debugging
+
+Rails has some useful [built-in debugging tools](https://guides.rubyonrails.org/debugging_rails_applications.html).
+
+- Start the rails console: `make rails-console`
+- Run a console in the browser:
+ - Add a `console` line and an interactive console, similar to the rails console, will appear in the bottom half of your browser window
+- Run the debugger:
+ - Add a `debugger` line and the rails server will pause and start the debugger
+ - Note: this is not yet configured for this application when doing local development in a container
diff --git a/template-only-bin/install-template.sh b/template-only-bin/install-template.sh
index 9b7805f..303fde0 100755
--- a/template-only-bin/install-template.sh
+++ b/template-only-bin/install-template.sh
@@ -33,6 +33,7 @@ fi
echo "Copying files from $template_name..."
# Note: Keep this list of paths in sync with INCLUDE_PATHS in update-template.sh
# @TODO: Add .grype.yml
+# See https://github.com/navapbc/template-application-rails/issues/16
cp -r \
.github \
.gitignore \