diff --git a/.env b/.env new file mode 100644 index 000000000..caecd67db --- /dev/null +++ b/.env @@ -0,0 +1,4 @@ +ARGYLE_API_TOKEN= +ARGYLE_SANDBOX= +ARGYLE_WEBHOOK_SECRET= +NGROK_URL= diff --git a/Gemfile.lock b/Gemfile.lock index 6f7c6ad1e..62e9e0706 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -162,6 +162,8 @@ GEM nio4r (2.7.1) nokogiri (1.16.4-arm64-darwin) racc (~> 1.4) + nokogiri (1.16.4-x86_64-darwin) + racc (~> 1.4) nokogiri (1.16.4-x86_64-linux) racc (~> 1.4) parallel (1.24.0) @@ -336,6 +338,7 @@ GEM PLATFORMS arm64-darwin-23 + x86_64-darwin-20 x86_64-linux DEPENDENCIES diff --git a/README.md b/README.md index d7c128a37..bdbcb4cbd 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,9 @@ guide for an introduction to the framework. * Chromedriver must be allowed to run. You can either do that by: * The command line: `xattr -d com.apple.quarantine $(which chromedriver)` (this is the only option if you are on Big Sur) * Manually: clicking "allow" when you run the integration tests for the first time and a dialogue opens up + * [Ngrok](https://ngrok.com/download): brew install ngrok/ngrok/ngrok + * Sign up for an account: https://dashboard.ngrok.com/signup + * run `ngrok config add-authtoken {token goes here}` * Set up rbenv and nodenv: * `echo 'if which nodenv >/dev/null 2>/dev/null; then eval "$(nodenv init -)"; fi' >> ~/.zshrc` * `echo 'if which rbenv >/dev/null 2>/dev/null; then eval "$(rbenv init -)"; fi' >> ~/.zshrc` @@ -59,6 +62,8 @@ of the test. To run locally, use `bin/dev` +Separately, run `ngrok 3000`. Copy the Forwarding URL into your .env.local value for `NGROK_URL`. + ## Security ### Authentication diff --git a/app/assets/stylesheets/application.postcss.css b/app/assets/stylesheets/application.postcss.css index 07eaf25fc..c49a99dc0 100644 --- a/app/assets/stylesheets/application.postcss.css +++ b/app/assets/stylesheets/application.postcss.css @@ -1,3 +1,18 @@ /* Entry point for your PostCSS build */ @forward "uswds-settings.scss"; @forward "uswds-components.scss"; + +.argyle-loading { + position: relative; +} + +.argyle-loading::after { + background-color: rgba(0, 0, 0, 0.128); + content: ""; + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + z-index: 9999; +} diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb index 0ff5442f4..f78d816c3 100644 --- a/app/channels/application_cable/connection.rb +++ b/app/channels/application_cable/connection.rb @@ -1,4 +1,7 @@ module ApplicationCable class Connection < ActionCable::Connection::Base + def session + @request.session + end end end diff --git a/app/channels/argyle_paystubs_channel.rb b/app/channels/argyle_paystubs_channel.rb new file mode 100644 index 000000000..5c44105de --- /dev/null +++ b/app/channels/argyle_paystubs_channel.rb @@ -0,0 +1,10 @@ +class ArgylePaystubsChannel < ApplicationCable::Channel + def subscribed + cbv_flow = CbvFlow.find(connection.session[:cbv_flow_id]) + stream_for cbv_flow + end + + def unsubscribed + # Any cleanup needed when channel is unsubscribed + end +end diff --git a/app/controllers/cbv_flows_controller.rb b/app/controllers/cbv_flows_controller.rb index 4e57fc3d8..8ef262285 100644 --- a/app/controllers/cbv_flows_controller.rb +++ b/app/controllers/cbv_flows_controller.rb @@ -1,7 +1,7 @@ class CbvFlowsController < ApplicationController USER_TOKEN_ENDPOINT = 'https://api-sandbox.argyle.com/v2/users'; ITEMS_ENDPOINT = 'https://api-sandbox.argyle.com/v2/items'; - PAYSTUBS_ENDPOINT = 'https://api-sandbox.argyle.com/v2/paystubs?limit=2&user=' + PAYSTUBS_ENDPOINT = 'https://api-sandbox.argyle.com/v2/paystubs?user=' before_action :set_cbv_flow @@ -58,9 +58,9 @@ def next_path def fetch_and_store_argyle_token return session[:argyle_user_token] if session[:argyle_user_token].present? - raise "ARGYLE_API_TOKEN environment variable is blank. Make sure you have the .env.local.local from 1Password." if ENV['ARGYLE_API_TOKEN'].blank? + raise "ARGYLE_API_TOKEN environment variable is blank. Make sure you have the .env.local.local from 1Password." if Rails.application.credentials.argyle[:api_key].blank? - res = Net::HTTP.post(URI.parse(USER_TOKEN_ENDPOINT), "", {"Authorization" => "Basic #{ENV['ARGYLE_API_TOKEN']}"}) + res = Net::HTTP.post(URI.parse(USER_TOKEN_ENDPOINT), "", {"Authorization" => "Basic #{Rails.application.credentials.argyle[:api_key]}"}) parsed = JSON.parse(res.body) raise "Argyle API error: #{parsed['detail']}" if res.code.to_i >= 400 @@ -71,14 +71,14 @@ def fetch_and_store_argyle_token end def fetch_employers - res = Net::HTTP.get(URI.parse(ITEMS_ENDPOINT), {"Authorization" => "Basic #{ENV['ARGYLE_API_TOKEN']}"}) + res = Net::HTTP.get(URI.parse(ITEMS_ENDPOINT), {"Authorization" => "Basic #{Rails.application.credentials.argyle[:api_key]}"}) parsed = JSON.parse(res) parsed['results'] end def fetch_payroll - res = Net::HTTP.get(URI.parse("#{PAYSTUBS_ENDPOINT}#{@cbv_flow.argyle_user_id}"), {"Authorization" => "Basic #{ENV['ARGYLE_API_TOKEN']}"}) + res = Net::HTTP.get(URI.parse("#{PAYSTUBS_ENDPOINT}#{@cbv_flow.argyle_user_id}"), {"Authorization" => "Basic #{Rails.application.credentials.argyle[:api_key]}"}) parsed = JSON.parse(res) parsed['results'] diff --git a/app/controllers/webhooks/argyle/events_controller.rb b/app/controllers/webhooks/argyle/events_controller.rb new file mode 100644 index 000000000..f9e95c052 --- /dev/null +++ b/app/controllers/webhooks/argyle/events_controller.rb @@ -0,0 +1,20 @@ +class Webhooks::Argyle::EventsController < ApplicationController + skip_before_action :verify_authenticity_token + + def create + signature = OpenSSL::HMAC.hexdigest('SHA512', ENV['ARGYLE_WEBHOOK_SECRET'], request.raw_post) + + unless request.headers["X-Argyle-Signature"] == signature + return render json: { error: 'Invalid signature' }, status: :unauthorized + end + + if params['event'] == 'paystubs.fully_synced' || params['event'] == 'paystubs.partially_synced' + @cbv_flow = CbvFlow.find_by_argyle_user_id(params['data']['user']) + + if @cbv_flow + @cbv_flow.update(payroll_data_available_from: params['data']['available_from']) + ArgylePaystubsChannel.broadcast_to(@cbv_flow, params) + end + end + end +end diff --git a/app/helpers/webhooks/argyle/events_helper.rb b/app/helpers/webhooks/argyle/events_helper.rb new file mode 100644 index 000000000..4f45aa0e6 --- /dev/null +++ b/app/helpers/webhooks/argyle/events_helper.rb @@ -0,0 +1,2 @@ +module Webhooks::Argyle::EventsHelper +end diff --git a/app/javascript/controllers/cbv_flows_controller.js b/app/javascript/controllers/cbv_flows_controller.js index 63ad46acf..cfa3f4003 100644 --- a/app/javascript/controllers/cbv_flows_controller.js +++ b/app/javascript/controllers/cbv_flows_controller.js @@ -1,4 +1,5 @@ import { Controller } from "@hotwired/stimulus" +import * as ActionCable from '@rails/actioncable' import metaContent from "../utilities/meta"; import { loadArgyle, initializeArgyle } from "../utilities/argyle" @@ -8,7 +9,8 @@ function toOptionHTML({ value }) { } export default class extends Controller { - static targets = ["options", "continue", "userAccountId"]; + static targets = ["options", "continue", "userAccountId", "fullySynced", "form"]; + static classes = ["loading"] selection = null; @@ -16,15 +18,36 @@ export default class extends Controller { argyleUserToken = null; + cable = ActionCable.createConsumer(); + + // TODO: information stored on the CbvFlow model can infer whether the paystubs are sync'd + // by checking the value of payroll_data_available_from. We should make that the initial value. + fullySynced = false; + connect() { // check for this value when connected this.argyleUserToken = metaContent('argyle_user_token'); + this.cable.subscriptions.create({ channel: 'ArgylePaystubsChannel' }, { + connected: () => { + console.log("Connected to the channel:", this); + }, + disconnected: () => { + console.log("Disconnected"); + }, + received: (data) => { + console.log("Received some data:", data); + if (data.event === 'paystubs.fully_synced' || data.event === 'paystubs.partially_synced') { + this.fullySynced = true; + + this.formTarget.submit(); + } + } + }); } onSignInSuccess(event) { this.userAccountIdTarget.value = event.accountId; - - this.element.submit(); + this.element.classList.add(this.loadingClass); } onAccountError(event) { diff --git a/app/views/cbv_flows/employer_search.html.erb b/app/views/cbv_flows/employer_search.html.erb index 51c9b2a6a..b5a0d2752 100644 --- a/app/views/cbv_flows/employer_search.html.erb +++ b/app/views/cbv_flows/employer_search.html.erb @@ -5,23 +5,25 @@

Get payment info from your employer.

-<%= form_with url: next_path, method: :post, data: { controller: "cbv-flows" } do |f| %> - -
- - -
- <%= f.submit "Continue", - class: "usa-button usa-button--outline", - disabled: "disabled", - type: "submit", - data: { action: "click->cbv-flows#submit", 'cbv-flows-target': "continue" } - %> -<% end %> +
+ <%= form_with url: next_path, method: :post, data: { 'cbv-flows-target': "form" } do |f| %> + +
+ + +
+ <%= f.submit "Continue", + class: "usa-button usa-button--outline", + disabled: "disabled", + type: "submit", + data: { action: "click->cbv-flows#submit", 'cbv-flows-target': "continue" } + %> + <% end %> +
diff --git a/config/environments/development.rb b/config/environments/development.rb index 948ca5eb9..b82fc71c4 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -21,6 +21,8 @@ # Enable server timing config.server_timing = true + config.hosts << ENV['NGROK_URL'] if ENV['NGROK_URL'].present? + # Enable/disable caching. By default caching is disabled. # Run rails dev:cache to toggle caching. if Rails.root.join("tmp/caching-dev.txt").exist? diff --git a/config/routes.rb b/config/routes.rb index 623347433..99fe445dc 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -21,4 +21,10 @@ get '/reset' => 'cbv_flows#reset' end end + + namespace :webhooks do + namespace :argyle do + resources :events, only: :create + end + end end diff --git a/db/migrate/20240501192504_add_payroll_column_to_cbv_flows.rb b/db/migrate/20240501192504_add_payroll_column_to_cbv_flows.rb new file mode 100644 index 000000000..069d8df21 --- /dev/null +++ b/db/migrate/20240501192504_add_payroll_column_to_cbv_flows.rb @@ -0,0 +1,5 @@ +class AddPayrollColumnToCbvFlows < ActiveRecord::Migration[7.0] + def change + add_column :cbv_flows, :payroll_data_available_from, :date + end +end diff --git a/db/schema.rb b/db/schema.rb index eff8bbd8a..7b9743eeb 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2024_04_26_180929) do +ActiveRecord::Schema[7.0].define(version: 2024_05_01_192504) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -24,6 +24,7 @@ t.string "argyle_user_id" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.date "payroll_data_available_from" end end diff --git a/package.json b/package.json index 444e742a6..e8fbeb10e 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "@csstools/postcss-sass": "^5.1.1", "@hotwired/stimulus": "^3.2.2", "@hotwired/turbo-rails": "^8.0.4", + "@rails/actioncable": "^7.1.3-2", "@uswds/uswds": "^3.8.0", "autoprefixer": "^10.4.19", "esbuild": "^0.20.2", diff --git a/spec/channels/argyle_paystubs_channel_spec.rb b/spec/channels/argyle_paystubs_channel_spec.rb new file mode 100644 index 000000000..65c1e7e7e --- /dev/null +++ b/spec/channels/argyle_paystubs_channel_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe ArgylePaystubsChannel, type: :channel do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/helpers/webhooks/argyle/events_helper_spec.rb b/spec/helpers/webhooks/argyle/events_helper_spec.rb new file mode 100644 index 000000000..e0b84b735 --- /dev/null +++ b/spec/helpers/webhooks/argyle/events_helper_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +# Specs in this file have access to a helper object that includes +# the Webhooks::Argyle::EventsHelper. For example: +# +# describe Webhooks::Argyle::EventsHelper do +# describe "string concat" do +# it "concats two strings with spaces" do +# expect(helper.concat_strings("this","that")).to eq("this that") +# end +# end +# end +RSpec.describe Webhooks::Argyle::EventsHelper, type: :helper do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/requests/webhooks/argyle/events_spec.rb b/spec/requests/webhooks/argyle/events_spec.rb new file mode 100644 index 000000000..e27e97ced --- /dev/null +++ b/spec/requests/webhooks/argyle/events_spec.rb @@ -0,0 +1,7 @@ +require 'rails_helper' + +RSpec.describe "Webhooks::Argyle::Events", type: :request do + describe "GET /index" do + pending "add some examples (or delete) #{__FILE__}" + end +end diff --git a/yarn.lock b/yarn.lock index d0b735861..39b635adc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -229,6 +229,11 @@ resolved "https://registry.yarnpkg.com/@rails/actioncable/-/actioncable-7.1.3.tgz#4db480347775aeecd4dde2405659eef74a458881" integrity sha512-ojNvnoZtPN0pYvVFtlO7dyEN9Oml1B6IDM+whGKVak69MMYW99lC2NOWXWeE3bmwEydbP/nn6ERcpfjHVjYQjA== +"@rails/actioncable@^7.1.3-2": + version "7.1.3-2" + resolved "https://registry.yarnpkg.com/@rails/actioncable/-/actioncable-7.1.3-2.tgz#7105fd8fc6db87d11510d5e9ac8e5b68add6124e" + integrity sha512-0j/bo3Aehk3R3LTc/Nu4eegvUR+8kRObq/t1BtBLGF8xdPzrUQciv+KvAABdACl4Vw82yo0LB1r0J0zVH9Gefg== + "@sindresorhus/merge-streams@^2.1.0": version "2.3.0" resolved "https://registry.yarnpkg.com/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz#719df7fb41766bc143369eaa0dd56d8dc87c9958"