diff --git a/.eslintrc b/.eslintrc
index c08354567fc..519066c9cad 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -6,7 +6,7 @@
"plugin:react-hooks/recommended",
"plugin:react/jsx-runtime"
],
- "plugins": ["@18f/eslint-plugin-identity"],
+ "plugins": ["@18f/eslint-plugin-identity", "testing-library"],
"env": {
"browser": true,
"commonjs": true
@@ -20,10 +20,6 @@
{
"selector": "AssignmentExpression[left.property.name='href'][right.type=/(Template)?Literal/]",
"message": "Do not assign window.location.href to a string or string template to avoid losing i18n parameters"
- },
- {
- "selector": "ExpressionStatement[expression.callee.object.name='userEvent']",
- "message": "Await the promised result of a userEvent interaction"
}
]
},
@@ -44,11 +40,17 @@
"devDependencies": true,
"packageDir": "."
}
- ]
+ ],
+ "testing-library/await-async-events": "error",
+ "testing-library/await-async-queries": "error",
+ "testing-library/await-async-utils": "error",
+ "testing-library/no-await-sync-events": "error",
+ "testing-library/no-await-sync-queries": "error",
+ "testing-library/no-debugging-utils": "error"
}
},
{
- // Turn off react linting rules for most packages/files
+ // Turn off react linting rules for most packages/files
"files": [
"spec/**",
"app/javascript/packs/**",
@@ -63,8 +65,8 @@
"app/javascript/packages/validated-field/**",
"app/javascript/packages/verify-flow/**",
// In progress: enabling these rules for all files in packages/document-capture
- "app/javascript/packages/document-capture/context/**",
- "app/javascript/packages/document-capture/higher-order/**",
+ "app/javascript/packages/document-capture/context/**",
+ "app/javascript/packages/document-capture/higher-order/**",
"app/javascript/packages/document-capture/hooks/**",
// Comment out a file to enable react lint rules for that file only
"app/javascript/packages/document-capture/components/acuant-camera.tsx",
@@ -113,6 +115,6 @@
"react-hooks/exhaustive-deps": "off",
"react-hooks/rules-of-hooks": "off"
}
- },
+ }
]
-}
\ No newline at end of file
+}
diff --git a/Gemfile.lock b/Gemfile.lock
index 7fca116d58a..66fcf365f19 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -208,7 +208,7 @@ GEM
erubi (~> 1.4)
parser (>= 2.4)
smart_properties
- bigdecimal (3.1.4)
+ bigdecimal (3.1.5)
bindata (2.4.15)
bootsnap (1.17.0)
msgpack (~> 1.2)
@@ -427,7 +427,7 @@ GEM
net-ssh (6.1.0)
newrelic_rpm (9.6.0)
base64
- nio4r (2.6.1)
+ nio4r (2.7.0)
nokogiri (1.14.5)
mini_portile2 (~> 2.8.0)
racc (~> 1.4)
@@ -472,7 +472,7 @@ GEM
psych (5.1.1.1)
stringio
public_suffix (5.0.3)
- puma (6.4.0)
+ puma (6.4.2)
nio4r (~> 2.0)
raabro (1.4.0)
racc (1.7.3)
@@ -561,7 +561,7 @@ GEM
activerecord (>= 5.0)
rgeo (>= 1.0.0)
rotp (6.2.0)
- rouge (4.1.3)
+ rouge (4.2.0)
rqrcode (2.1.0)
chunky_png (~> 1.0)
rqrcode_core (~> 1.0)
@@ -684,7 +684,7 @@ GEM
activemodel
mail (>= 2.6.1)
simpleidn
- view_component (3.8.0)
+ view_component (3.9.0)
activesupport (>= 5.2.0, < 8.0)
concurrent-ruby (~> 1.0)
method_source (~> 1.0)
diff --git a/app/assets/stylesheets/_uswds-core.scss b/app/assets/stylesheets/_uswds-core.scss
index 94b1aeb15da..2da34521ec5 100644
--- a/app/assets/stylesheets/_uswds-core.scss
+++ b/app/assets/stylesheets/_uswds-core.scss
@@ -16,7 +16,6 @@
'border-color',
'border-style',
'border-width',
- 'clearfix',
'color',
'display',
'flex',
@@ -30,7 +29,6 @@
'margin',
'margin-horizontal',
'margin-vertical',
- 'maxw',
'padding',
'position',
'text-align',
diff --git a/app/controllers/concerns/idv/document_capture_concern.rb b/app/controllers/concerns/idv/document_capture_concern.rb
index f114c76f2fd..558226afbbc 100644
--- a/app/controllers/concerns/idv/document_capture_concern.rb
+++ b/app/controllers/concerns/idv/document_capture_concern.rb
@@ -43,6 +43,7 @@ def extract_pii_from_doc(user, response, store_in_session: false)
if store_in_session
idv_session.pii_from_doc ||= {}
idv_session.pii_from_doc.merge!(pii_from_doc)
+ idv_session.selfie_check_performed = response.selfie_check_performed
end
end
diff --git a/app/controllers/openid_connect/authorization_controller.rb b/app/controllers/openid_connect/authorization_controller.rb
index e35fe9164a9..60f8bea2162 100644
--- a/app/controllers/openid_connect/authorization_controller.rb
+++ b/app/controllers/openid_connect/authorization_controller.rb
@@ -10,6 +10,7 @@ class AuthorizationController < ApplicationController
include BillableEventTrackable
include ForcedReauthenticationConcern
+ before_action :block_biometric_requests_in_production, only: [:index]
before_action :build_authorize_form_from_params, only: [:index]
before_action :pre_validate_authorize_form, only: [:index]
before_action :sign_out_if_prompt_param_is_login_and_user_is_signed_in, only: [:index]
@@ -28,6 +29,7 @@ def index
return redirect_to reactivate_account_url if user_needs_to_reactivate_account?
return redirect_to url_for_pending_profile_reason if user_has_pending_profile?
return redirect_to idv_url if identity_needs_verification?
+ return redirect_to idv_url if selfie_needed?
end
return redirect_to sign_up_completed_url if needs_completion_screen_reason
link_identity_to_service_provider
@@ -44,6 +46,13 @@ def index
private
+ def block_biometric_requests_in_production
+ if params['biometric_comparison_required'] == 'true' &&
+ FeatureManagement.idv_block_biometrics_requests?
+ render_not_acceptable
+ end
+ end
+
def check_sp_active
return if @authorize_form.service_provider&.active?
redirect_to sp_inactive_error_url
@@ -99,6 +108,11 @@ def identity_needs_verification?
current_user.reproof_for_irs?(service_provider: current_sp)
end
+ def selfie_needed?
+ decorated_sp_session.selfie_required? &&
+ !current_user.identity_verified_with_selfie?
+ end
+
def build_authorize_form_from_params
@authorize_form = OpenidConnectAuthorizeForm.new(authorization_params)
end
diff --git a/app/controllers/openid_connect/logout_controller.rb b/app/controllers/openid_connect/logout_controller.rb
index fd549921441..a488159319d 100644
--- a/app/controllers/openid_connect/logout_controller.rb
+++ b/app/controllers/openid_connect/logout_controller.rb
@@ -5,6 +5,7 @@ class LogoutController < ApplicationController
include SecureHeadersConcern
include FullyAuthenticatable
+ before_action :set_devise_failure_redirect_for_concurrent_session_logout, only: [:index]
before_action :confirm_two_factor_authenticated, only: [:delete]
def index
@@ -39,6 +40,10 @@ def delete
private
+ def set_devise_failure_redirect_for_concurrent_session_logout
+ request.env['devise_session_limited_failure_redirect_url'] = request.url
+ end
+
def redirect_user(redirect_uri, user_uuid)
redirect_method = IdentityConfig.store.openid_connect_redirect_uuid_override_map.fetch(
user_uuid,
diff --git a/app/controllers/saml_idp_controller.rb b/app/controllers/saml_idp_controller.rb
index a9dc846e012..2387631005b 100644
--- a/app/controllers/saml_idp_controller.rb
+++ b/app/controllers/saml_idp_controller.rb
@@ -17,6 +17,7 @@ class SamlIdpController < ApplicationController
skip_before_action :verify_authenticity_token
before_action :require_path_year
+ before_action :set_devise_failure_redirect_for_concurrent_session_logout, only: :logout
before_action :handle_banned_user
before_action :bump_auth_count, only: :auth
before_action :redirect_to_sign_in, only: :auth, unless: :user_signed_in?
@@ -105,6 +106,10 @@ def prompt_for_password_if_ial2_request_and_pii_locked
redirect_to capture_password_url
end
+ def set_devise_failure_redirect_for_concurrent_session_logout
+ request.env['devise_session_limited_failure_redirect_url'] = request.url
+ end
+
def pii_requested_but_locked?
if (sp_session && sp_session_ial > 1) || ial_context.ialmax_requested?
current_user.identity_verified? &&
diff --git a/app/controllers/sign_up/completions_controller.rb b/app/controllers/sign_up/completions_controller.rb
index 33af693179a..5df57436ae4 100644
--- a/app/controllers/sign_up/completions_controller.rb
+++ b/app/controllers/sign_up/completions_controller.rb
@@ -3,7 +3,8 @@ class CompletionsController < ApplicationController
include SecureHeadersConcern
before_action :confirm_two_factor_authenticated
- before_action :verify_confirmed, if: :ial2?
+ before_action :confirm_identity_verified, if: :ial2?
+ before_action :confirm_selfie_captured, if: :selfie_required?
before_action :apply_secure_headers_override, only: [:show, :update]
before_action :verify_needs_completions_screen
@@ -31,10 +32,14 @@ def update
private
- def verify_confirmed
+ def confirm_identity_verified
redirect_to idv_url if current_user.identity_not_verified?
end
+ def confirm_selfie_captured
+ redirect_to idv_url if !current_user.identity_verified_with_selfie?
+ end
+
def verify_needs_completions_screen
return_to_account unless needs_completion_screen_reason
end
@@ -62,6 +67,10 @@ def ial2_requested?
!!(ial2? || (ial_max? && current_user.identity_verified?))
end
+ def selfie_required?
+ decorated_sp_session.selfie_required?
+ end
+
def return_to_account
track_completion_event('account-page')
redirect_to account_url
diff --git a/app/controllers/two_factor_authentication/backup_code_verification_controller.rb b/app/controllers/two_factor_authentication/backup_code_verification_controller.rb
index 231ca2b780d..d7818e091e1 100644
--- a/app/controllers/two_factor_authentication/backup_code_verification_controller.rb
+++ b/app/controllers/two_factor_authentication/backup_code_verification_controller.rb
@@ -19,7 +19,9 @@ def show
def create
@backup_code_form = BackupCodeVerificationForm.new(current_user)
result = @backup_code_form.submit(backup_code_params)
- analytics.track_mfa_submit_event(result.to_h)
+ analytics.track_mfa_submit_event(
+ result.to_h.merge(new_device: user_session[:new_device]),
+ )
irs_attempts_api_tracker.mfa_login_backup_code(success: result.success?)
handle_result(result)
end
diff --git a/app/controllers/two_factor_authentication/otp_verification_controller.rb b/app/controllers/two_factor_authentication/otp_verification_controller.rb
index 43cc0937b97..2f57b2d6286 100644
--- a/app/controllers/two_factor_authentication/otp_verification_controller.rb
+++ b/app/controllers/two_factor_authentication/otp_verification_controller.rb
@@ -126,7 +126,7 @@ def form_params
end
def post_analytics(result)
- properties = result.to_h.merge(analytics_properties)
+ properties = result.to_h.merge(analytics_properties, new_device: user_session[:new_device])
analytics.multi_factor_auth_setup(**properties) if context == 'confirmation'
analytics.track_mfa_submit_event(properties)
diff --git a/app/controllers/two_factor_authentication/personal_key_verification_controller.rb b/app/controllers/two_factor_authentication/personal_key_verification_controller.rb
index c30e83ecc86..1f46304ab10 100644
--- a/app/controllers/two_factor_authentication/personal_key_verification_controller.rb
+++ b/app/controllers/two_factor_authentication/personal_key_verification_controller.rb
@@ -26,6 +26,7 @@ def track_analytics(result)
analytics_hash = result.to_h.merge(
multi_factor_auth_method: 'personal-key',
multi_factor_auth_method_created_at: mfa_created_at&.strftime('%s%L'),
+ new_device: user_session[:new_device],
)
analytics.track_mfa_submit_event(analytics_hash)
diff --git a/app/controllers/two_factor_authentication/piv_cac_verification_controller.rb b/app/controllers/two_factor_authentication/piv_cac_verification_controller.rb
index 52555750d54..78dd80fb34d 100644
--- a/app/controllers/two_factor_authentication/piv_cac_verification_controller.rb
+++ b/app/controllers/two_factor_authentication/piv_cac_verification_controller.rb
@@ -102,6 +102,7 @@ def analytics_properties
context: context,
multi_factor_auth_method: 'piv_cac',
piv_cac_configuration_id: piv_cac_verification_form&.piv_cac_configuration&.id,
+ new_device: user_session[:new_device],
}
end
end
diff --git a/app/controllers/two_factor_authentication/totp_verification_controller.rb b/app/controllers/two_factor_authentication/totp_verification_controller.rb
index d55520cfdec..0860c2b8c32 100644
--- a/app/controllers/two_factor_authentication/totp_verification_controller.rb
+++ b/app/controllers/two_factor_authentication/totp_verification_controller.rb
@@ -18,8 +18,7 @@ def show
def create
result = TotpVerificationForm.new(current_user, params.require(:code).strip).submit
-
- analytics.track_mfa_submit_event(result.to_h)
+ analytics.track_mfa_submit_event(result.to_h.merge(new_device: user_session[:new_device]))
irs_attempts_api_tracker.mfa_login_totp(success: result.success?)
if result.success?
diff --git a/app/controllers/two_factor_authentication/webauthn_verification_controller.rb b/app/controllers/two_factor_authentication/webauthn_verification_controller.rb
index 39b1e0a42ec..df9c15076d9 100644
--- a/app/controllers/two_factor_authentication/webauthn_verification_controller.rb
+++ b/app/controllers/two_factor_authentication/webauthn_verification_controller.rb
@@ -19,6 +19,7 @@ def confirm
**analytics_properties,
multi_factor_auth_method_created_at:
webauthn_configuration_or_latest.created_at.strftime('%s%L'),
+ new_device: user_session[:new_device],
)
if analytics_properties[:multi_factor_auth_method] == 'webauthn_platform'
diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb
index e807997d4e6..7b019aa33da 100644
--- a/app/controllers/users/sessions_controller.rb
+++ b/app/controllers/users/sessions_controller.rb
@@ -115,6 +115,7 @@ def process_locked_out_user
def handle_valid_authentication
sign_in(resource_name, resource)
cache_profiles(auth_params[:password])
+ user_session[:new_device] = current_user.new_device?(cookie_uuid: cookies[:device])
create_user_event(:sign_in_before_2fa)
EmailAddress.update_last_sign_in_at_on_user_id_and_email(
user_id: current_user.id,
diff --git a/app/javascript/packages/address-search/components/address-search.spec.tsx b/app/javascript/packages/address-search/components/address-search.spec.tsx
index 2ea8c677245..5b053153b69 100644
--- a/app/javascript/packages/address-search/components/address-search.spec.tsx
+++ b/app/javascript/packages/address-search/components/address-search.spec.tsx
@@ -9,7 +9,7 @@ describe('AddressSearch', () => {
const locationsURL = 'https://localhost:3000/locations/endpoint';
context('Page Heading and PO Search About Message', () => {
- it('both render when handleLocationSelect is not null', async () => {
+ it('both render when handleLocationSelect is not null', () => {
const handleLocationsFound = sandbox.stub();
const onSelect = sinon.stub();
const { queryByText, queryByRole } = render(
@@ -25,8 +25,8 @@ describe('AddressSearch', () => {
,
);
- const heading = await queryByText('in_person_proofing.headings.po_search.location');
- const aboutMessage = await queryByText(
+ const heading = queryByText('in_person_proofing.headings.po_search.location');
+ const aboutMessage = queryByText(
'in_person_proofing.body.location.po_search.po_search_about',
);
@@ -37,7 +37,7 @@ describe('AddressSearch', () => {
).to.exist();
});
- it('both do not render when handleLocationSelect is null', async () => {
+ it('both do not render when handleLocationSelect is null', () => {
const handleLocationsFound = sandbox.stub();
const onSelect = sinon.stub();
const { queryByText } = render(
@@ -53,8 +53,8 @@ describe('AddressSearch', () => {
,
);
- const heading = await queryByText('in_person_proofing.headings.po_search.location');
- const aboutMessage = await queryByText(
+ const heading = queryByText('in_person_proofing.headings.po_search.location');
+ const aboutMessage = queryByText(
'in_person_proofing.body.location.po_search.po_search_about',
);
expect(heading).to.be.empty;
diff --git a/app/javascript/packages/address-search/components/full-address-search.spec.tsx b/app/javascript/packages/address-search/components/full-address-search.spec.tsx
index 91c3cbcd2d4..94be94460d6 100644
--- a/app/javascript/packages/address-search/components/full-address-search.spec.tsx
+++ b/app/javascript/packages/address-search/components/full-address-search.spec.tsx
@@ -14,7 +14,7 @@ describe('FullAddressSearch', () => {
const usStatesTerritories = [['Delware', 'DE']];
context('Page Heading and PO Search About Message', () => {
- it('both render when handleLocationSelect is not null', async () => {
+ it('both render when handleLocationSelect is not null', () => {
const handleLocationsFound = sandbox.stub();
const onSelect = sinon.stub();
const { queryByText, queryByRole } = render(
@@ -30,8 +30,8 @@ describe('FullAddressSearch', () => {
,
);
- const heading = await queryByText('in_person_proofing.headings.po_search.location');
- const aboutMessage = await queryByText(
+ const heading = queryByText('in_person_proofing.headings.po_search.location');
+ const aboutMessage = queryByText(
'in_person_proofing.body.location.po_search.po_search_about',
);
@@ -42,7 +42,7 @@ describe('FullAddressSearch', () => {
).to.exist();
});
- it('both do not render when handleLocationSelect is null', async () => {
+ it('both do not render when handleLocationSelect is null', () => {
const handleLocationsFound = sandbox.stub();
const { queryByText } = render(
new Map() }}>
@@ -57,8 +57,8 @@ describe('FullAddressSearch', () => {
,
);
- const heading = await queryByText('in_person_proofing.headings.po_search.location');
- const aboutMessage = await queryByText(
+ const heading = queryByText('in_person_proofing.headings.po_search.location');
+ const aboutMessage = queryByText(
'in_person_proofing.body.location.po_search.po_search_about',
);
expect(heading).to.be.empty;
@@ -67,7 +67,7 @@ describe('FullAddressSearch', () => {
});
context('Address Search Label Text', () => {
- it('does not render when handleLocationSelect is not null', async () => {
+ it('does not render when handleLocationSelect is not null', () => {
const handleLocationsFound = sandbox.stub();
const onSelect = sinon.stub();
const { queryByText } = render(
@@ -83,13 +83,11 @@ describe('FullAddressSearch', () => {
,
);
- const searchLabel = await queryByText(
- 'in_person_proofing.headings.po_search.address_search_label',
- );
+ const searchLabel = queryByText('in_person_proofing.headings.po_search.address_search_label');
expect(searchLabel).to.be.empty;
});
- it('renders when handleLocationSelect is null', async () => {
+ it('renders when handleLocationSelect is null', () => {
const handleLocationsFound = sandbox.stub();
const { queryByText } = render(
new Map() }}>
@@ -104,7 +102,7 @@ describe('FullAddressSearch', () => {
,
);
- const searchLabel = await queryByText(
+ const searchLabel = queryByText(
'in_person_proofing.body.location.po_search.address_search_label',
);
expect(searchLabel).to.exist();
diff --git a/app/javascript/packages/assets/README.md b/app/javascript/packages/assets/README.md
new file mode 100644
index 00000000000..1ceefbb9399
--- /dev/null
+++ b/app/javascript/packages/assets/README.md
@@ -0,0 +1,35 @@
+# `@18f/identity-assets`
+
+Utilities for resolving asset URLs from [Ruby on Rails' Asset Pipeline](https://guides.rubyonrails.org/asset_pipeline.html).
+
+## Usage
+
+Within your code, use `getAssetPath` and provide a raw asset path, where the expected return value is the URL resolved by the Ruby on Rails Asset pipeline:
+
+```ts
+const spriteURL = getAssetPath('sprite.svg');
+```
+
+The included Webpack plugin will scan for references to `getAssetPath` and add those as assets of the associated Webpack entrypoint.
+
+```ts
+// webpack.config.js
+
+module.exports = {
+ // ...
+ plugins: [
+ // ...
+ new RailsAssetsWebpackPlugin(),
+ ],
+};
+```
+
+The expectation is that this can be used in combination with a tool like [`WebpackManifestPlugin`](https://github.com/shellscape/webpack-manifest-plugin) to generate a JSON manifest of all assets expected to be loaded with a given Webpack entrypoint, so that the backend can ensure those asset paths are populated into a `
+```
diff --git a/app/javascript/packages/build-sass/CHANGELOG.md b/app/javascript/packages/build-sass/CHANGELOG.md
index f011da1f5a7..c66c5e6ec55 100644
--- a/app/javascript/packages/build-sass/CHANGELOG.md
+++ b/app/javascript/packages/build-sass/CHANGELOG.md
@@ -4,6 +4,10 @@
- Requires Node.js v18 or newer
+### Improvements
+
+- `--out-dir` is now optional. If omitted, files will be output in the same directory as their source files.
+
## 2.0.0
### Breaking Changes
diff --git a/app/javascript/packages/build-sass/README.md b/app/javascript/packages/build-sass/README.md
index 09c3858c0ba..d2d5409d1b3 100644
--- a/app/javascript/packages/build-sass/README.md
+++ b/app/javascript/packages/build-sass/README.md
@@ -6,7 +6,7 @@ Why use it?
- ⚡️ **It's fast**, since it uses native Dart Sass binary through [`sass-embedded`](http://npmjs.com/package/sass-embedded), and the Rust-based [Lightning CSS](https://www.npmjs.com/package/lightningcss) for autoprefixing and minification.
- 💻 **It includes a CLI**, so it's easy to integrate with command-based build pipelines like NPM scripts or Makefile.
-- 🚀 **It has relevant defaults**, as as to require as little additional configuration as possible.
+- 🚀 **It has relevant defaults**, to work out of the box with minimal or no additional configuration.
Default behavior includes:
@@ -23,7 +23,7 @@ Default behavior includes:
Invoke the included `build-sass` executable with the source files and any relevant command flags.
```
-npx build-sass path/to/sass/*.scss --out-dir=build
+npx build-sass path/to/sass/*.scss
```
Flags:
diff --git a/app/javascript/packages/build-sass/cli.js b/app/javascript/packages/build-sass/cli.js
index 34cc6ecb7b1..f755e384526 100755
--- a/app/javascript/packages/build-sass/cli.js
+++ b/app/javascript/packages/build-sass/cli.js
@@ -29,10 +29,6 @@ const { values: flags, positionals: fileArgs } = parseArgs({
const { watch: isWatching, 'out-dir': outDir, 'load-path': loadPaths = [] } = flags;
loadPaths.push(...getDefaultLoadPaths());
-if (!outDir) {
- throw new TypeError('Output directory must be provided using the `--out-dir` option.');
-}
-
/** @type {BuildOptions & SyncSassOptions} */
const options = { outDir, loadPaths, optimize: isProduction };
@@ -84,9 +80,13 @@ function build(files) {
);
}
-mkdir(outDir, { recursive: true })
- .then(() => build(fileArgs))
- .catch((error) => {
- console.error(error);
- process.exitCode = 1;
- });
+if (outDir) {
+ await mkdir(outDir, { recursive: true });
+}
+
+try {
+ await build(fileArgs);
+} catch (error) {
+ console.error(error);
+ process.exitCode = 1;
+}
diff --git a/app/javascript/packages/build-sass/cli.spec.js b/app/javascript/packages/build-sass/cli.spec.js
index a4a47896483..e2fca1cee5c 100644
--- a/app/javascript/packages/build-sass/cli.spec.js
+++ b/app/javascript/packages/build-sass/cli.spec.js
@@ -18,4 +18,12 @@ describe('cli', () => {
await stat(join(cwd, 'fixtures/missing-out-dir/in.css.scss'));
});
});
+
+ context('with unconfigured output directory', () => {
+ it('outputs in the same directory as the input file', async () => {
+ await exec('./cli.js fixtures/default-out-dir/styles.css.scss', { cwd });
+
+ await stat(join(cwd, 'fixtures/default-out-dir/styles.css'));
+ });
+ });
});
diff --git a/app/javascript/packages/build-sass/fixtures/default-out-dir/.gitignore b/app/javascript/packages/build-sass/fixtures/default-out-dir/.gitignore
new file mode 100644
index 00000000000..abdfa7226b6
--- /dev/null
+++ b/app/javascript/packages/build-sass/fixtures/default-out-dir/.gitignore
@@ -0,0 +1 @@
+styles.css
diff --git a/app/javascript/packages/build-sass/fixtures/default-out-dir/styles.css.scss b/app/javascript/packages/build-sass/fixtures/default-out-dir/styles.css.scss
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/app/javascript/packages/build-sass/index.js b/app/javascript/packages/build-sass/index.js
index 8e6741b5c24..38dca4cb5c1 100644
--- a/app/javascript/packages/build-sass/index.js
+++ b/app/javascript/packages/build-sass/index.js
@@ -1,4 +1,4 @@
-import { basename, join } from 'node:path';
+import { basename, join, dirname } from 'node:path';
import { createWriteStream } from 'node:fs';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
@@ -29,7 +29,7 @@ const TARGETS = browserslistToTargets(
* @return {Promise}
*/
export async function buildFile(file, options) {
- const { outDir, optimize, loadPaths = [], ...sassOptions } = options;
+ const { outDir = dirname(file), optimize, loadPaths = [], ...sassOptions } = options;
const sassResult = sassCompile(file, {
style: optimize ? 'compressed' : 'expanded',
...sassOptions,
@@ -46,9 +46,7 @@ export async function buildFile(file, options) {
targets: TARGETS,
});
- if (outDir) {
- outFile = join(outDir, outFile);
- }
+ outFile = join(outDir, outFile);
await pipeline(Readable.from(lightningResult.code), createWriteStream(outFile));
diff --git a/app/javascript/packages/config/README.md b/app/javascript/packages/config/README.md
new file mode 100644
index 00000000000..cbd08991cfe
--- /dev/null
+++ b/app/javascript/packages/config/README.md
@@ -0,0 +1,21 @@
+# `@18f/identity-config`
+
+Utilities for retrieving global application configuration values.
+
+## Usage
+
+From your JavaScript code, retrieve a configuration value using the `getConfigValue` export:
+
+```ts
+const appName = getConfigValue('appName');
+```
+
+The configuration is expected to be bootstrapped in page markup within a `
+```
diff --git a/app/javascript/packages/device/README.md b/app/javascript/packages/device/README.md
new file mode 100644
index 00000000000..4eddb5570db
--- /dev/null
+++ b/app/javascript/packages/device/README.md
@@ -0,0 +1,34 @@
+# `@18f/identity-device`
+
+Utilities for detecting details about the user's device.
+
+## Usage
+
+Import the desired utility function from the package:
+
+```ts
+import { isLikelyMobile } from '@18f/identity-device';
+
+isLikelyMobile();
+// true
+```
+
+## API
+
+### `isIPad`
+
+Returns true if the device is an iPad, or false otherwise.
+
+iPadOS devices no longer list the correct user agent. As a proxy, we check for the incorrect one (Macintosh) then test the number of touchpoints, which for iPads will be 5.
+
+### `isLikelyMobile`
+
+Returns true if the device is likely a mobile device, or false otherwise. This is a rough approximation, using device user agent sniffing.
+
+### `hasMediaAccess`
+
+Returns true if the current device allows access to camera device APIs.
+
+### `isCameraCapableMobile`
+
+Returns true if the current device is assumed to be a mobile device where a camera is available, or false otherwise. This is a rough approximation, using device user agent sniffing and availability of camera device APIs.
diff --git a/app/javascript/packages/document-capture-polling/README.md b/app/javascript/packages/document-capture-polling/README.md
new file mode 100644
index 00000000000..18f42cb180a
--- /dev/null
+++ b/app/javascript/packages/document-capture-polling/README.md
@@ -0,0 +1,20 @@
+# `@18f/identity-document-capture-polling`
+
+Package implementing behaviors associated with the hybrid handoff document capture flow, where document capture is initiated on a desktop computer and completed on a mobile device. The behaviors of this package are responsible for polling for the result of a document capture happening on another device, and redirecting the user upon completion or failure.
+
+## Usage
+
+Initialize the package's binding with the polling endpoint and required elements:
+
+```ts
+import { DocumentCapturePolling } from '@18f/identity-document-capture-polling';
+
+new DocumentCapturePolling({
+ statusEndpoint: '/path/to/endpoint',
+ elements: {
+ backLink: document.querySelector('.link-sent-back-link'),
+ form: document.querySelector('.link-sent-continue-button-form'),
+ },
+}).bind();
+```
+
diff --git a/app/javascript/packages/document-capture/README.md b/app/javascript/packages/document-capture/README.md
new file mode 100644
index 00000000000..7083f63bbcc
--- /dev/null
+++ b/app/javascript/packages/document-capture/README.md
@@ -0,0 +1,22 @@
+# `@18f/identity-document-capture`
+
+React components for displaying a user interface for a user to upload or capture photos of their state-issued identification.
+
+## Usage
+
+Render the included `` React component. Most settings are expected to be configured by wrapping the component with one or more of the included context provider components.
+
+```tsx
+import { render } from 'react-dom';
+import { DocumentCapture, AnalyticsContextProvider } from '@18f/identity-document-capture';
+
+const appRoot = document.getElementById('app-root');
+
+render(
+
+ {/* ... */}
+
+ ,
+ appRoot
+);
+```
diff --git a/app/javascript/packages/document-capture/components/in-person-location-full-address-entry-post-office-search-step.spec.tsx b/app/javascript/packages/document-capture/components/in-person-location-full-address-entry-post-office-search-step.spec.tsx
index 1771ad0fa3f..e82f66c621f 100644
--- a/app/javascript/packages/document-capture/components/in-person-location-full-address-entry-post-office-search-step.spec.tsx
+++ b/app/javascript/packages/document-capture/components/in-person-location-full-address-entry-post-office-search-step.spec.tsx
@@ -44,9 +44,11 @@ const DEFAULT_PROPS = {
describe('InPersonLocationFullAddressEntryPostOfficeSearchStep', () => {
const usStatesTerritories: [string, string][] = [['Delware', 'DE']];
const locationsURL = 'https://localhost:3000/locations/endpoint';
+ const inPersonURL = '#in_person';
const wrapper: ComponentType = ({ children }) => (
{
beforeEach(() => {
server.resetHandlers();
// todo: should we return USPS_RESPONSE here?
- server.use(rest.post(locationsURL, (_req, res, ctx) => res(ctx.json([{ name: 'Baltimore' }]))));
+ server.use(
+ rest.post(locationsURL, (_req, res, ctx) => res(ctx.json([{ name: 'Baltimore' }]))),
+ rest.put(locationsURL, (_req, res, ctx) => res(ctx.json({ success: true }))),
+ );
});
it('renders the step', () => {
@@ -326,8 +331,11 @@ describe('InPersonLocationFullAddressEntryPostOfficeSearchStep', () => {
await findByLabelText('in_person_proofing.body.location.po_search.zipcode_label'),
);
- await userEvent.click(findAllByText('in_person_proofing.body.location.location_button')[0]);
+ await userEvent.click(
+ (await findAllByText('in_person_proofing.body.location.location_button'))[0],
+ );
- expect(await queryByText('simple_form.required.text')).to.be.null();
+ expect(queryByText('simple_form.required.text')).to.be.null();
+ expect(window.location.hash).to.equal(inPersonURL);
});
});
diff --git a/app/javascript/packages/document-capture/components/in-person-location-post-office-search-step.spec.tsx b/app/javascript/packages/document-capture/components/in-person-location-post-office-search-step.spec.tsx
index 8b4472f3e1e..d5f62a77f4f 100644
--- a/app/javascript/packages/document-capture/components/in-person-location-post-office-search-step.spec.tsx
+++ b/app/javascript/packages/document-capture/components/in-person-location-post-office-search-step.spec.tsx
@@ -60,9 +60,11 @@ describe('InPersonLocationPostOfficeSearchStep', () => {
const usStatesTerritories: [string, string][] = [['Delware', 'DE']];
const locationsURL = 'https://localhost:3000/locations/endpoint';
const addressSearchURL = 'https://localhost:3000/addresses/endpoint';
+ const inPersonURL = '#in_person';
const wrapper: ComponentType = ({ children }) => (
{
await userEvent.click(
await findByText('in_person_proofing.body.location.po_search.search_button'),
);
- const moreResults = await queryAllByText('in_person_proofing.body.location.location_button');
+ const moreResults = queryAllByText('in_person_proofing.body.location.location_button');
expect(moreResults).to.be.empty();
});
@@ -366,6 +368,7 @@ describe('InPersonLocationPostOfficeSearchStep', () => {
res(ctx.json(DEFAULT_RESPONSE), ctx.status(200)),
),
rest.post(locationsURL, (_req, res, ctx) => res(ctx.json([{ name: 'Baltimore' }]))),
+ rest.put(locationsURL, (_req, res, ctx) => res(ctx.json({ success: true }))),
);
});
@@ -387,9 +390,12 @@ describe('InPersonLocationPostOfficeSearchStep', () => {
await findByLabelText('in_person_proofing.body.location.po_search.address_search_label'),
);
- await userEvent.click(findAllByText('in_person_proofing.body.location.location_button')[0]);
+ await userEvent.click(
+ (await findAllByText('in_person_proofing.body.location.location_button'))[0],
+ );
- expect(await queryByText('in_person_proofing.body.location.inline_error')).to.be.null();
+ expect(queryByText('in_person_proofing.body.location.inline_error')).to.be.null();
+ expect(window.location.hash).to.equal(inPersonURL);
});
});
});
diff --git a/app/javascript/packages/document-capture/components/status-message.jsx b/app/javascript/packages/document-capture/components/status-message.tsx
similarity index 51%
rename from app/javascript/packages/document-capture/components/status-message.jsx
rename to app/javascript/packages/document-capture/components/status-message.tsx
index 4d16415aaeb..9b6518901e8 100644
--- a/app/javascript/packages/document-capture/components/status-message.jsx
+++ b/app/javascript/packages/document-capture/components/status-message.tsx
@@ -1,25 +1,17 @@
-/** @typedef {import('react').ReactNode} ReactNode */
+import type { ReactNode } from 'react';
-/**
- * @enum {string}
- */
-export const Status = {
- ERROR: 'ERROR',
- SUCCESS: 'SUCCESS',
-};
+export enum Status {
+ ERROR = 'ERROR',
+ SUCCESS = 'SUCCESS',
+}
-/**
- * @typedef StatusMessageProps
- *
- * @prop {Status} status
- * @prop {string=} className
- * @prop {ReactNode=} children
- */
+interface StatusMessageProps {
+ status: Status;
+ className?: string;
+ children?: ReactNode;
+}
-/**
- * @param {StatusMessageProps} props
- */
-function StatusMessage({ status, className, children }) {
+function StatusMessage({ status, className, children }: StatusMessageProps) {
const classes = [
status === Status.ERROR && 'usa-error-message',
status === Status.SUCCESS && 'usa-success-message',
diff --git a/app/javascript/packages/form-steps/README.md b/app/javascript/packages/form-steps/README.md
new file mode 100644
index 00000000000..4435bfac638
--- /dev/null
+++ b/app/javascript/packages/form-steps/README.md
@@ -0,0 +1,21 @@
+# `@18f/identity-form-steps`
+
+React components for managing a user's progression through a series of steps in a form.
+
+## Usage
+
+At a minimum, render the `` React component with an array of step configurations. Each step must include a `name` and `form`, where the `form` is a React component that will be rendered once the user reaches the step.
+
+```tsx
+import { render } from 'react-dom';
+import { FormSteps } from '@18f/identity-form-steps';
+
+const STEPS = [
+ { name: 'First Step', form: () =>
Welcome to the first step!
},
+ { name: 'Second Step', form: () =>
Welcome to the second step!
},
+];
+
+const appRoot = document.getElementById('app-root');
+
+render(, appRoot);
+```
diff --git a/app/javascript/packages/form-steps/form-steps.spec.tsx b/app/javascript/packages/form-steps/form-steps.spec.tsx
index 1f2c4c00a78..6877d2c6d2e 100644
--- a/app/javascript/packages/form-steps/form-steps.spec.tsx
+++ b/app/javascript/packages/form-steps/form-steps.spec.tsx
@@ -405,7 +405,7 @@ describe('FormSteps', () => {
await userEvent.click(getByText(t('forms.buttons.continue')));
- await expect(findByText('Second Title')).to.be.fulfilled();
+ await findByText('Second Title');
await expect(checkFormHasExpectedErrors()).to.be.rejected();
window.history.back();
diff --git a/app/javascript/packages/masked-text-toggle/README.md b/app/javascript/packages/masked-text-toggle/README.md
new file mode 100644
index 00000000000..d346c20fa18
--- /dev/null
+++ b/app/javascript/packages/masked-text-toggle/README.md
@@ -0,0 +1,32 @@
+# `@18f/identity-masked-text-toggle`
+
+Package implementing behaviors associated with toggling the visibility of text which is masked by default due to its sensitivity.
+
+For example, a Social Security number may be masked to show only `6**-**-***4` by default, and allow a user to toggle the visibility of the full number if desired.
+
+## Usage
+
+Initialize the package's binding with the polling endpoint and required elements:
+
+```ts
+import MaskedTextToggle from '@18f/identity-masked-text-toggle';
+
+const toggle = document.querySelector('.masked-text-toggle');
+new MaskedTextToggle(toggle).bind();
+```
+
+The given toggle element is expected to be a checkbox, associated with the masked text wrapper by an `aria-controls` attribute.
+
+The masked text wrapper is expected to contain two variations of the text: a masked form (with a `data-masked="true"` attribute) and a revealed form (with a `data-masked="false"` attribute). The package will toggle visibility using the `display-none` utility class from the U.S. Web Design System.
+
+```html
+
+
+ 6**-**-***4
+
+
+ 666-12-1234
+
+
+
+```
diff --git a/app/javascript/packages/memorable-date/README.md b/app/javascript/packages/memorable-date/README.md
new file mode 100644
index 00000000000..9865cc45843
--- /dev/null
+++ b/app/javascript/packages/memorable-date/README.md
@@ -0,0 +1,22 @@
+# `@18f/identity-memorable-date`
+
+Custom element implementing behaviors associated with Login.gov's adaptation of the U.S. Web Design System [Memorable Date component](https://designsystem.digital.gov/components/memorable-date/).
+
+## Usage
+
+Importing the element will register the `` custom element:
+
+```ts
+import '@18f/identity-memorable-date/memorable-date-element';
+```
+
+The custom element will implement modal behavior, but all markup must already exist.
+
+```html
+
+
+
+
+
+
+```
diff --git a/app/javascript/packages/password-confirmation/README.md b/app/javascript/packages/password-confirmation/README.md
index 5a5ae306cc8..97ad4545006 100644
--- a/app/javascript/packages/password-confirmation/README.md
+++ b/app/javascript/packages/password-confirmation/README.md
@@ -1,4 +1,4 @@
-# `@18f/password-confirmation`
+# `@18f/identity-password-confirmation`
Custom element implementation that adds password inputs with validation for confirmation.
@@ -7,7 +7,7 @@ Custom element implementation that adds password inputs with validation for conf
Importing the element will register the `` custom element:
```ts
-import '@18f/password-confirmation/password-confirmation-element';
+import '@18f/identity-password-confirmation/password-confirmation-element';
```
The custom element will implement the behavior for validation, but all markup must already exist.
diff --git a/app/javascript/packages/password-toggle/README.md b/app/javascript/packages/password-toggle/README.md
new file mode 100644
index 00000000000..84d23f0e595
--- /dev/null
+++ b/app/javascript/packages/password-toggle/README.md
@@ -0,0 +1,22 @@
+# `@18f/identity-password-toggle`
+
+Custom element implementation that toggles the visibility of a password field text in response to clicking a checkbox.
+
+## Usage
+
+Importing the element will register the `` custom element:
+
+```ts
+import '@18f/identity-password-toggle/password-toggle-element';
+```
+
+The custom element will implement associatd behaviors, but all markup must already exist.
+
+```html
+
+
+
+
+
+
+```
diff --git a/app/javascript/packages/phone-input/README.md b/app/javascript/packages/phone-input/README.md
new file mode 100644
index 00000000000..fa14a63cace
--- /dev/null
+++ b/app/javascript/packages/phone-input/README.md
@@ -0,0 +1,31 @@
+# `@18f/identity-phone-input`
+
+Custom element implementation initializes an interactive phone input, using [`intl-tel-input`](https://github.com/jackocnr/intl-tel-input).
+
+## Usage
+
+Importing the element will register the `` custom element:
+
+```ts
+import '@18f/identity-phone-input';
+```
+
+The custom element will implement associatd behaviors, but all markup must already exist.
+
+```html
+
+
+
+
+
+
+
+
+
+```
diff --git a/app/javascript/packages/spinner-button/README.md b/app/javascript/packages/spinner-button/README.md
new file mode 100644
index 00000000000..54702984562
--- /dev/null
+++ b/app/javascript/packages/spinner-button/README.md
@@ -0,0 +1,38 @@
+# `@18f/identity-spinner-button`
+
+Custom element and React component for displaying a spinner effect on a button when clicked.
+
+## Usage
+
+### Custom Element
+
+Importing the element will register the `` custom element:
+
+```ts
+import '@18f/identity-spinner-button/spinner-button-element';
+```
+
+The custom element will implement associated behaviors, but all markup must already exist, rendered server-side or by the included React component.
+
+When clicked, the a `spinner-button--spinner-active` class will be added to the root element, which can be used to control the visibility of associated elements using custom CSS styles.
+
+```html
+
+
+
+```
+
+### React
+
+The package exports a `` component.
+
+```tsx
+import { render } from 'react-dom';
+import { SpinnerButton } from '@18f/identity-spinner-button';
+
+const appRoot = document.getElementById('app-root');
+
+render(Spin!, appRoot)
+```
diff --git a/app/javascript/packages/time-element/README.md b/app/javascript/packages/time-element/README.md
new file mode 100644
index 00000000000..49e7c385778
--- /dev/null
+++ b/app/javascript/packages/time-element/README.md
@@ -0,0 +1,21 @@
+# `@18f/identity-time-element`
+
+Custom element which converts a time string rendered by the server into the user's local time.
+
+## Usage
+
+The package exports a `TimeElement` custom element, which can be registered using the browser's custom element registry:
+
+```ts
+import { TimeElement } from '@18f/identity-time-element';
+
+customElements.define('lg-time', TimeElement);
+```
+
+The custom element will implement associated behaviors, but all markup must already exist.
+
+```html
+
+ September 26, 2023 at 4:20 PM
+
+```
diff --git a/app/javascript/packages/validated-field/README.md b/app/javascript/packages/validated-field/README.md
new file mode 100644
index 00000000000..e8fc974b5ab
--- /dev/null
+++ b/app/javascript/packages/validated-field/README.md
@@ -0,0 +1,45 @@
+# `@18f/identity-validated-field`
+
+Custom element and React component for controlling validation behavior associated with a form input.
+
+It enhances the behavior of an input by:
+
+- Displaying an error message on the page when form submission results in a validation error
+- Moving focus to the first invalid field when form submission results in a validation error
+- Providing default error messages for common validation constraints (e.g. required field missing)
+- Allowing you to customize error messages associated with default field validation
+- Resetting the error state when an input value changes
+
+## Usage
+
+### Custom Element
+
+Importing the element will register the `` custom element:
+
+```ts
+import '@18f/identity-validated-field/validated-field-element';
+```
+
+The custom element will implement associated behaviors, but all markup must already exist, rendered server-side or by the included React component.
+
+```html
+
+
+
+
+
+
+```
+
+### React
+
+The package exports a `` component. If rendered without a child, it will render a text input by default.
+
+```tsx
+import { render } from 'react-dom';
+import { ValidatedField } from '@18f/identity-validated-field';
+
+const appRoot = document.getElementById('app-root');
+
+render(, appRoot);
+```
diff --git a/app/jobs/resolution_proofing_job.rb b/app/jobs/resolution_proofing_job.rb
index 13d355bf9d8..96b5dba19d7 100644
--- a/app/jobs/resolution_proofing_job.rb
+++ b/app/jobs/resolution_proofing_job.rb
@@ -19,8 +19,8 @@ def perform(
encrypted_arguments:,
trace_id:,
should_proof_state_id:,
- double_address_verification: false,
- ipp_enrollment_in_progress: false,
+ double_address_verification: nil,
+ ipp_enrollment_in_progress: true,
user_id: nil,
threatmetrix_session_id: nil,
request_ip: nil,
diff --git a/app/models/profile.rb b/app/models/profile.rb
index 7c439e11054..2215f638668 100644
--- a/app/models/profile.rb
+++ b/app/models/profile.rb
@@ -33,6 +33,7 @@ class Profile < ApplicationRecord
enum idv_level: {
legacy_unsupervised: 1,
legacy_in_person: 2,
+ unsupervised_with_selfie: 3,
}
attr_reader :personal_key
diff --git a/app/models/user.rb b/app/models/user.rb
index 7ded8bf2af1..a92c017fefb 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -346,6 +346,10 @@ def identity_verified?(service_provider: nil)
active_profile.present? && !reproof_for_irs?(service_provider: service_provider)
end
+ def identity_verified_with_selfie?
+ active_profile&.idv_level == 'unsupervised_with_selfie'
+ end
+
def reproof_for_irs?(service_provider:)
return false unless service_provider&.irs_attempts_api_enabled
return false unless active_profile.present?
@@ -400,6 +404,10 @@ def has_devices?
!recent_devices.empty?
end
+ def new_device?(cookie_uuid:)
+ !cookie_uuid || !devices.exists?(cookie_uuid:)
+ end
+
# Returns the number of times the user has signed in, corresponding to the `sign_in_before_2fa`
# event.
#
diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb
index 45b85cd8cf6..f1956723085 100644
--- a/app/services/analytics_events.rb
+++ b/app/services/analytics_events.rb
@@ -3071,6 +3071,7 @@ def logout_initiated(
# @param [Boolean] success Whether authentication was successful
# @param [Hash] errors Authentication error reasons, if unsuccessful
# @param [String] context
+ # @param [Boolean] new_device
# @param [String] multi_factor_auth_method
# @param [DateTime] multi_factor_auth_method_created_at time auth method was created
# @param [Integer] auth_app_configuration_id
@@ -3088,6 +3089,7 @@ def multi_factor_auth(
success:,
errors: nil,
context: nil,
+ new_device: nil,
multi_factor_auth_method: nil,
multi_factor_auth_method_created_at: nil,
auth_app_configuration_id: nil,
@@ -3108,6 +3110,7 @@ def multi_factor_auth(
success: success,
errors: errors,
context: context,
+ new_device: new_device,
multi_factor_auth_method: multi_factor_auth_method,
multi_factor_auth_method_created_at: multi_factor_auth_method_created_at,
auth_app_configuration_id: auth_app_configuration_id,
diff --git a/app/services/idv/agent.rb b/app/services/idv/agent.rb
index 1bea425d38f..dc0a117d774 100644
--- a/app/services/idv/agent.rb
+++ b/app/services/idv/agent.rb
@@ -11,7 +11,7 @@ def proof_resolution(
user_id:,
threatmetrix_session_id:,
request_ip:,
- ipp_enrollment_in_progress: false
+ ipp_enrollment_in_progress: true
)
document_capture_session.create_proofing_session
@@ -28,7 +28,7 @@ def proof_resolution(
user_id: user_id,
threatmetrix_session_id: threatmetrix_session_id,
request_ip: request_ip,
- double_address_verification: ipp_enrollment_in_progress,
+ ipp_enrollment_in_progress: ipp_enrollment_in_progress,
}
if IdentityConfig.store.ruby_workers_idv_enabled
diff --git a/app/services/idv/profile_maker.rb b/app/services/idv/profile_maker.rb
index 2181f15ec35..75d48aa760a 100644
--- a/app/services/idv/profile_maker.rb
+++ b/app/services/idv/profile_maker.rb
@@ -18,6 +18,7 @@ def save_profile(
fraud_pending_reason:,
gpo_verification_needed:,
in_person_verification_needed:,
+ selfie_check_performed:,
deactivation_reason: nil
)
profile = Profile.new(user: user, active: false, deactivation_reason: deactivation_reason)
@@ -27,11 +28,10 @@ def save_profile(
profile.proofing_components = current_proofing_components
profile.fraud_pending_reason = fraud_pending_reason
- profile.idv_level = if in_person_verification_needed
- :legacy_in_person
- else
- :legacy_unsupervised
- end
+ profile.idv_level = set_idv_level(
+ in_person_verification_needed: in_person_verification_needed,
+ selfie_check_performed: selfie_check_performed,
+ )
profile.save!
profile.deactivate_for_gpo_verification if gpo_verification_needed
@@ -43,6 +43,16 @@ def save_profile(
private
+ def set_idv_level(in_person_verification_needed:, selfie_check_performed:)
+ if in_person_verification_needed
+ :legacy_in_person
+ elsif !FeatureManagement.idv_block_biometrics_requests? && selfie_check_performed
+ :unsupervised_with_selfie
+ else
+ :legacy_unsupervised
+ end
+ end
+
def current_proofing_components
user.proofing_component&.as_json || {}
end
diff --git a/app/services/idv/session.rb b/app/services/idv/session.rb
index 8d5ef75fdda..106c3d49df7 100644
--- a/app/services/idv/session.rb
+++ b/app/services/idv/session.rb
@@ -22,6 +22,7 @@ class Session
profile_id
redo_document_capture
resolution_successful
+ selfie_check_performed
skip_doc_auth
skip_hybrid_handoff
ssn
@@ -63,6 +64,7 @@ def create_profile_from_applicant_with_password(user_password)
fraud_pending_reason: threatmetrix_fraud_pending_reason,
gpo_verification_needed: !phone_confirmed? || verify_by_mail?,
in_person_verification_needed: current_user.has_in_person_enrollment?,
+ selfie_check_performed: session[:selfie_check_performed],
)
profile.activate unless profile.reason_not_to_activate
diff --git a/app/services/proofing/resolution/progressive_proofer.rb b/app/services/proofing/resolution/progressive_proofer.rb
index 6aed174e67c..43216ae1c42 100644
--- a/app/services/proofing/resolution/progressive_proofer.rb
+++ b/app/services/proofing/resolution/progressive_proofer.rb
@@ -13,10 +13,6 @@ def initialize(instant_verify_ab_test_discriminator = nil)
end
# @param [Hash] applicant_pii keys are symbols and values are strings, confidential user info
- # @param [Boolean] double_address_verification flag that indicates if user will have
- # both state id address and current residential address verified. Note this value is here as
- # a placeholder until it can be replaced with ipp_enrollment_in_progress in ticket LG-353:
- # https://cm-jira.usa.gov/browse/LG-11353
# @param [Boolean] ipp_enrollment_in_progress flag that indicates if user will have
# both state id address and current residential address verified
# @param [String] request_ip IP address for request
@@ -28,13 +24,13 @@ def initialize(instant_verify_ab_test_discriminator = nil)
# @return [ResultAdjudicator] object which contains the logic to determine proofing's result
def proof(
applicant_pii:,
- ipp_enrollment_in_progress:,
request_ip:,
should_proof_state_id:,
threatmetrix_session_id:,
timer:,
user_email:,
- double_address_verification: false
+ double_address_verification: nil,
+ ipp_enrollment_in_progress: true
)
device_profiling_result = proof_with_threatmetrix_if_needed(
applicant_pii: applicant_pii,
@@ -52,7 +48,7 @@ def proof(
)
applicant_pii_transformed = applicant_pii.clone
- if ipp_enrollment_in_progress || double_address_verification
+ if ipp_enrollment_in_progress
applicant_pii_transformed = with_state_id_address(applicant_pii_transformed)
end
@@ -115,19 +111,20 @@ def proof_with_threatmetrix_if_needed(
end
end
+ # rubocop:disable Lint/UnusedMethodArgument
def proof_residential_address_if_needed(
applicant_pii:,
timer:,
double_address_verification: false,
ipp_enrollment_in_progress: false
)
- return residential_address_unnecessary_result unless
- ipp_enrollment_in_progress || double_address_verification
+ return residential_address_unnecessary_result unless ipp_enrollment_in_progress
timer.time('residential address') do
resolution_proofer.proof(applicant_pii)
end
end
+ # rubocop:enable Lint/UnusedMethodArgument
def residential_address_unnecessary_result
Proofing::Resolution::Result.new(
@@ -141,12 +138,12 @@ def resolution_cannot_pass
)
end
+ # rubocop:disable Lint/UnusedMethodArgument
def proof_id_address_with_lexis_nexis_if_needed(applicant_pii:, timer:,
residential_instant_verify_result:,
double_address_verification:,
ipp_enrollment_in_progress:)
- if applicant_pii[:same_address_as_id] == 'true' &&
- (ipp_enrollment_in_progress || double_address_verification)
+ if applicant_pii[:same_address_as_id] == 'true' && ipp_enrollment_in_progress
return residential_instant_verify_result
end
return resolution_cannot_pass unless residential_instant_verify_result.success?
@@ -163,14 +160,13 @@ def should_proof_state_id_with_aamva?(ipp_enrollment_in_progress:, same_address_
return false unless should_proof_state_id
# If the user is in double-address-verification and they have changed their address then
# they are not eligible for get-to-yes
- # rubocop:disable Layout/LineLength
- if !(ipp_enrollment_in_progress || double_address_verification) || same_address_as_id == 'true'
- # rubocop:enable Layout/LineLength
+ if !ipp_enrollment_in_progress || same_address_as_id == 'true'
user_can_pass_after_state_id_check?(instant_verify_result)
else
residential_instant_verify_result.success?
end
end
+ # rubocop:enable Lint/UnusedMethodArgument
def proof_id_with_aamva_if_needed(
applicant_pii:, timer:,
diff --git a/app/services/proofing/resolution/result_adjudicator.rb b/app/services/proofing/resolution/result_adjudicator.rb
index 17f37f6f3fb..3d9a05224d4 100644
--- a/app/services/proofing/resolution/result_adjudicator.rb
+++ b/app/services/proofing/resolution/result_adjudicator.rb
@@ -13,7 +13,7 @@ def initialize(
ipp_enrollment_in_progress:,
device_profiling_result:,
same_address_as_id:,
- double_address_verification: false
+ double_address_verification: true
)
@resolution_result = resolution_result
@state_id_result = state_id_result
@@ -90,7 +90,7 @@ def device_profiling_result_and_reason
def resolution_result_and_reason
if !residential_resolution_result.success? && same_address_as_id == 'false' &&
- (ipp_enrollment_in_progress || double_address_verification)
+ ipp_enrollment_in_progress
[false, :fail_resolution_skip_state_id]
elsif resolution_result.success? && state_id_result.success?
[true, :pass_resolution_and_state_id]
diff --git a/app/views/users/two_factor_authentication_setup/index.html.erb b/app/views/users/two_factor_authentication_setup/index.html.erb
index eff5c26ec90..eb9cdb8bdc7 100644
--- a/app/views/users/two_factor_authentication_setup/index.html.erb
+++ b/app/views/users/two_factor_authentication_setup/index.html.erb
@@ -12,7 +12,7 @@
<%= render PageHeadingComponent.new.with_content(@presenter.heading) %>
-