Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ AllCops:
- 'tmp/**/*'
- 'vendor/**/*'
- 'public/**/*'
TargetRubyVersion: 2.7
TargetRailsVersion: 6.0
TargetRubyVersion: 3.0.4
TargetRailsVersion: 7.0
UseCache: true
DisabledByDefault: true
SuggestExtensions: false
Expand Down
3 changes: 2 additions & 1 deletion app/assets/stylesheets/components/_alert-icon.scss
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
// For displaying icons as "badges"
// at the top of modals
.iconic-modal-badge {
position: relative;
&::before {
background-repeat: no-repeat;
background-size: contain;
Expand All @@ -26,5 +27,5 @@
position: absolute;
left: 50%;
top: 0px;
transform: translateX(-50%) translateY(-50%);
transform: translate(-50%, -50%);
}
28 changes: 10 additions & 18 deletions app/assets/stylesheets/components/_personal-key.scss
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
.key-badge {
position: relative;
.personal-key-block {
@include u-padding-y(2);
background-image: url('personal-key/pkey-block.svg');
background-position: center;
background-repeat: no-repeat;

@include at-media('tablet') {
@include u-padding-x(1);
}
}

.separator-text__code {
.personal-key-block__code {
@include u-font-family('mono');
font-size: 1.5rem;

Expand All @@ -17,21 +24,6 @@
}
}

@include at-media('tablet') {
.separator-text > div {
&::after {
color: #000;
padding: 0 0.5rem;
}
}
}

.bg-pk-box {
background-image: url('personal-key/pkey-block.svg');
background-position: center;
background-repeat: no-repeat;
}

.bg-personal-key {
height: 145px;
background-image: url('personal-key/shield.svg');
Expand Down
3 changes: 1 addition & 2 deletions app/controllers/idv/otp_delivery_method_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ def send_phone_confirmation_otp_and_handle_result
result = send_phone_confirmation_otp
analytics.idv_phone_confirmation_otp_sent(**result.to_h)

irs_attempts_api_tracker.idv_phone_confirmation_otp_sent(
irs_attempts_api_tracker.idv_phone_otp_sent(
phone_number: @idv_phone,
success: result.success?,
otp_delivery_method: params[:otp_delivery_preference],
Expand All @@ -84,7 +84,6 @@ def send_phone_confirmation_otp_and_handle_result

def handle_send_phone_confirmation_otp_failure(result)
if send_phone_confirmation_otp_rate_limited?
irs_attempts_api_tracker.idv_phone_confirmation_otp_sent_rate_limited
handle_too_many_otp_sends
else
invalid_phone_number(result.extra[:telephony_response].error)
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/idv/otp_verification_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ def update
result = phone_confirmation_otp_verification_form.submit(code: params[:code])
analytics.idv_phone_confirmation_otp_submitted(**result.to_h)
irs_attempts_api_tracker.idv_phone_otp_submitted(
phone_number: idv_session.user_phone_confirmation_session.phone,
success: result.success?,
phone_number: idv_session.user_phone_confirmation_session.phone,
failure_reason: result.success? ? {} : result.extra.slice(:code_expired, :code_matches),
)

Expand Down
2 changes: 1 addition & 1 deletion app/controllers/idv/phone_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ def create
result = idv_form.submit(step_params)
analytics.idv_phone_confirmation_form_submitted(**result.to_h)
irs_attempts_api_tracker.idv_phone_submitted(
phone_number: step_params[:phone],
success: result.success?,
phone_number: step_params[:phone],
failure_reason: irs_attempts_api_tracker.parse_failure_reason(result),
)
flash[:error] = result.first_error_message if !result.success?
Expand Down
10 changes: 10 additions & 0 deletions app/controllers/mfa_confirmation_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ def skip
user_session.delete(:mfa_selections)
user_session.delete(:next_mfa_selection_choice)
analytics.user_registration_suggest_another_mfa_notice_skipped
analytics.user_registration_mfa_setup_complete(
mfa_method_counts: mfa_context.enabled_two_factor_configuration_counts_hash,
enabled_mfa_methods_count: mfa_context.enabled_mfa_methods_count,
pii_like_keypaths: [[:mfa_method_counts, :phone]],
success: true,
)
redirect_to after_mfa_setup_path
end

Expand Down Expand Up @@ -64,4 +70,8 @@ def handle_max_password_attempts_reached
sign_out
redirect_to root_url, flash: { error: t('errors.max_password_attempts_reached') }
end

def mfa_context
@mfa_context ||= MfaContext.new(current_user)
end
end
8 changes: 5 additions & 3 deletions app/controllers/openid_connect/logout_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,11 @@ def build_logout_form
def handle_successful_logout_request(result, redirect_uri)
if require_logout_confirmation?
analytics.oidc_logout_visited(**result.to_h.except(:redirect_uri))
@client_id = logout_params[:client_id]
@state = logout_params[:state]
@post_logout_redirect_uri = logout_params[:post_logout_redirect_uri]
@params = {
client_id: logout_params[:client_id],
post_logout_redirect_uri: logout_params[:post_logout_redirect_uri],
}
@params[:state] = logout_params[:state] if !logout_params[:state].nil?
render :index
else
analytics.logout_initiated(**result.to_h.except(:redirect_uri))
Expand Down
1 change: 1 addition & 0 deletions app/controllers/saml_idp_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ def capture_analytics
idv: identity_needs_verification?,
finish_profile: profile_needs_verification?,
requested_ial: requested_ial,
matching_cert_serial: saml_request.service_provider.matching_cert&.serial&.to_s,
)
analytics.saml_auth(**analytics_payload)
end
Expand Down
10 changes: 10 additions & 0 deletions app/controllers/sign_up/completions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ def show
def update
track_completion_event('agency-page')
update_verified_attributes
send_in_person_completion_survey
if decider.go_back_to_mobile_app?
sign_user_out_and_instruct_to_go_back_to_mobile_app
else
Expand Down Expand Up @@ -89,5 +90,14 @@ def pii
pii_string = Pii::Cacher.new(current_user, user_session).fetch_string
JSON.parse(pii_string || '{}', symbolize_names: true)
end

def send_in_person_completion_survey
return unless sp_session_ial == ::Idp::Constants::IAL2

Idv::InPerson::CompletionSurveySender.send_completion_survey(
current_user,
current_sp.issuer,
)
end
end
end
2 changes: 1 addition & 1 deletion app/controllers/users/sessions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ def pending_account_reset_request
).call
end

LETTERS_AND_DASHES = /\A[a-z0-9\-]+\Z/i.freeze
LETTERS_AND_DASHES = /\A[a-z0-9\-]+\Z/i

def request_id_if_valid
request_id = (params[:request_id] || sp_session[:request_id]).to_s
Expand Down
4 changes: 2 additions & 2 deletions app/controllers/users/two_factor_authentication_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -225,15 +225,15 @@ def track_events(otp_delivery_preference:)

if UserSessionContext.reauthentication_context?(context)
irs_attempts_api_tracker.mfa_login_phone_otp_sent(
reauthentication: true,
success: @telephony_result.success?,
reauthentication: true,
phone_number: parsed_phone.e164,
otp_delivery_method: otp_delivery_preference,
)
elsif UserSessionContext.authentication_context?(context)
irs_attempts_api_tracker.mfa_login_phone_otp_sent(
reauthentication: false,
success: @telephony_result.success?,
reauthentication: false,
phone_number: parsed_phone.e164,
otp_delivery_method: otp_delivery_preference,
)
Expand Down
9 changes: 6 additions & 3 deletions app/forms/openid_connect_logout_form.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ class OpenidConnectLogoutForm
},
if: :reject_id_token_hint?
validates :post_logout_redirect_uri, presence: true
validates :state, presence: true, length: { minimum: RANDOM_VALUE_MINIMUM_LENGTH }
validates :state,
length: { minimum: RANDOM_VALUE_MINIMUM_LENGTH },
if: -> { !state.nil? }

validate :id_token_hint_or_client_id_present,
if: -> { accept_client_id? && !reject_id_token_hint? }
Expand Down Expand Up @@ -150,9 +152,10 @@ def redirect_uri
end

def logout_redirect_uri
uri = post_logout_redirect_uri unless errors.include?(:redirect_uri)
return nil if errors.include?(:redirect_uri)
return post_logout_redirect_uri unless state.present?

UriService.add_params(uri, state: state)
UriService.add_params(post_logout_redirect_uri, state: state)
end

def error_redirect_uri
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export type TroubleshootingOption = Omit<BlockLinkProps, 'href'> & {
interface TroubleshootingOptionsProps {
headingTag?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';

heading?: string;
heading?: ReactNode;

options: TroubleshootingOption[];

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useContext } from 'react';
import { FlowContext } from '@18f/identity-verify-flow';
import { TroubleshootingOptions } from '@18f/identity-components';
import { useI18n } from '@18f/identity-react-i18n';
import { useI18n, formatHTML } from '@18f/identity-react-i18n';
import type { TroubleshootingOption } from '@18f/identity-components/troubleshooting-options';
import ServiceProviderContext from '../context/service-provider';
import MarketingSiteContext from '../context/marketing-site';
Expand Down Expand Up @@ -82,7 +82,9 @@ function DocumentCaptureTroubleshootingOptions({
{hasErrors && inPersonURL && showInPersonOption && (
<TroubleshootingOptions
isNewFeatures
heading={t('idv.troubleshooting.headings.are_you_near')}
heading={formatHTML(t('idv.troubleshooting.headings.are_you_near'), {
br: 'br',
})}
options={[
{
url: '#location',
Expand Down
45 changes: 45 additions & 0 deletions app/javascript/packages/react-i18n/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# `@18f/identity-react-i18n`

`@18f/identity-react-i18n` is a superset of the functionality provided by [`@18f/identity-i18n`](https://github.com/18F/identity-idp/tree/main/app/javascript/packages/i18n), tailored for use in a React application.

React-specific support includes:

- A `I18nContext` context provider to customize locale data within a React context scope.
- A corresponding `useI18n` hook to consume locale data from the closest `I18nContext`.
- A `formatHTML` helper to replace HTML elements in locale strings with live React elements.

## Usage

### `formatHTML`

Given an HTML string and an object of tag names to React component, returns a new React node where the mapped tag names are replaced by the resulting element of the rendered component.

Note that this is a very simplistic interpolation of HTML. It only supports self-closing and well-balanced, non-nested tag names, where there are no attributes or excess whitespace within the tag names. The tag name cannot contain regular expression special characters.

While the subject markup itself cannot contain attributes, the return value of the component can be any valid React element, with or without additional attributes.

```tsx
formatHTML('Hello <lg-sparkles>world</lg-sparkles>!', {
'lg-sparkles': ({ children }) => <span className="lg-sparkles">{children}</span>,
});
```

### `I18nContext`

```tsx
function App({ children }) {
return (
<I18nContext.Provider value={{ string_key: 'translation' }}>{children}</I18nContext.Provider>
);
}
```

### `useI18n`

```tsx
function MyComponent() {
const { t } = useI18n();

return <div>{t('string_key')}</div>;
}
```
75 changes: 75 additions & 0 deletions app/javascript/packages/react-i18n/format-html.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { render } from '@testing-library/react';
import formatHTML from './format-html';

describe('formatHTML', () => {
it('returns html string treated as escaped text without handler', () => {
const formatted = formatHTML('Hello <strong>world</strong>!', {});

const { container } = render(<>{formatted}</>);

expect(container.innerHTML).to.equal('Hello &lt;strong&gt;world&lt;/strong&gt;!');
});

it('returns html string chunked by component handlers', () => {
const formatted = formatHTML('Hello <strong>world</strong>!', {
strong: ({ children }) => <strong>{children}</strong>,
});

const { container } = render(<>{formatted}</>);

expect(container.innerHTML).to.equal('Hello <strong>world</strong>!');
});

it('returns html string chunked by string handlers', () => {
const formatted = formatHTML('Hello <strong>world</strong>!', {
strong: 'strong',
});

const { container } = render(<>{formatted}</>);

expect(container.innerHTML).to.equal('Hello <strong>world</strong>!');
});

it('returns html string chunked by multiple handlers', () => {
const formatted = formatHTML('Message: <lg-custom>Hello</lg-custom> <strong>world</strong>!', {
'lg-custom': () => <>Greetings</>,
strong: ({ children }) => <strong>{children}</strong>,
});

const { container } = render(<>{formatted}</>);

expect(container.innerHTML).to.equal('Message: Greetings <strong>world</strong>!');
});

it('removes dangling empty text fragment', () => {
const formatted = formatHTML('Hello <strong>world</strong>', {
strong: ({ children }) => <strong>{children}</strong>,
});

const { container } = render(<>{formatted}</>);

expect(container.childNodes).to.have.lengthOf(2);
});

it('allows (but discards) attributes in the input string', () => {
const formatted = formatHTML(
'<strong data-before>Hello</strong> <strong data-before>world</strong>',
{
strong: ({ children }) => <strong data-after>{children}</strong>,
},
);

const { container } = render(<>{formatted}</>);

expect(container.querySelectorAll('[data-after]')).to.have.lengthOf(2);
expect(container.querySelectorAll('[data-before]')).to.have.lengthOf(0);
});

it('supports self-closing (void) elements', () => {
const formatted = formatHTML('Hello<br /><br/><em>world</em><br/>!', { br: 'br', em: 'em' });

const { container } = render(<>{formatted}</>);

expect(container.innerHTML).to.equal('Hello<br><br><em>world</em><br>!');
});
});
Loading