diff --git a/app/controllers/provider/admin/user/access_tokens_controller.rb b/app/controllers/provider/admin/user/access_tokens_controller.rb index dc2d56ef68..fe248d8487 100644 --- a/app/controllers/provider/admin/user/access_tokens_controller.rb +++ b/app/controllers/provider/admin/user/access_tokens_controller.rb @@ -1,44 +1,59 @@ -class Provider::Admin::User::AccessTokensController < Provider::Admin::User::BaseController - inherit_resources - defaults route_prefix: 'provider_admin_user', resource_class: AccessToken - actions :index, :new, :create, :edit, :update, :destroy - - authorize_resource - activate_menu :account, :personal, :tokens - before_action :authorize_access_tokens - before_action :disable_client_cache - - def create - create! do |success, _failure| - success.html do - flash[:token] = @access_token.id - flash[:notice] = 'Access Token was successfully created.' - redirect_to(collection_url) - end - end - end +# frozen_string_literal: true - def index - index! - @last_access_key = flash[:token] - end +module Provider + module Admin + module User + class AccessTokensController < BaseController + inherit_resources + defaults route_prefix: 'provider_admin_user', resource_class: AccessToken + actions :index, :new, :create, :edit, :update, :destroy - def update - update! do |success, _failure| - success.html do - flash[:notice] = 'Access Token was successfully updated.' - redirect_to(collection_url) - end - end - end + authorize_resource + activate_menu :account, :personal, :tokens + before_action :authorize_access_tokens + before_action :disable_client_cache + before_action :load_presenter, only: %i[new create] - private + def new; end - def authorize_access_tokens - authorize! :manage, :access_tokens, current_user - end + def create + create! do |success, _failure| + success.html do + flash[:token] = @access_token.id + flash[:notice] = 'Access Token was successfully created.' + redirect_to(collection_url) + end + end + end - def begin_of_association_chain - current_user + def index + index! + @last_access_key = flash[:token] + end + + def update + update! do |success, _failure| + success.html do + flash[:notice] = 'Access Token was successfully updated.' + redirect_to(collection_url) + end + end + end + + private + + def load_presenter + @presenter = AccessTokensNewPresenter.new(current_account) + end + + def authorize_access_tokens + authorize! :manage, :access_tokens, current_user + end + + def begin_of_association_chain + current_user + end + end + end end end diff --git a/app/javascript/packs/expiration_date_picker.ts b/app/javascript/packs/expiration_date_picker.ts new file mode 100644 index 0000000000..1425b79051 --- /dev/null +++ b/app/javascript/packs/expiration_date_picker.ts @@ -0,0 +1,22 @@ +import { ExpirationDatePickerWrapper } from 'AccessTokens/components/ExpirationDatePicker' + +import type { Props } from 'AccessTokens/components/ExpirationDatePicker' + +import { safeFromJsonString } from '../src/utilities/json-utils' + +document.addEventListener('DOMContentLoaded', () => { + const containerId = 'expiration-date-picker-container' + const container = document.getElementById(containerId) + + if (!container) { + throw new Error(`Missing container with id "${containerId}"`) + } + + const props = safeFromJsonString(container.dataset.props) + + if (!props) { + throw new Error('Missing props') + } + + ExpirationDatePickerWrapper(props, containerId) +}) diff --git a/app/javascript/src/AccessTokens/components/ExpirationDatePicker.scss b/app/javascript/src/AccessTokens/components/ExpirationDatePicker.scss new file mode 100644 index 0000000000..7ca7c4307f --- /dev/null +++ b/app/javascript/src/AccessTokens/components/ExpirationDatePicker.scss @@ -0,0 +1,14 @@ +@import '~@patternfly/patternfly/patternfly-addons.css'; + +.pf-c-calendar-month, .pf-c-form-control-expiration { + width: 50%; +} + +.pf-c-form-control-expiration { + margin-right: 1em; +} + +button.pf-c-form__group-label-help { + min-width: auto; + width: auto; +} diff --git a/app/javascript/src/AccessTokens/components/ExpirationDatePicker.tsx b/app/javascript/src/AccessTokens/components/ExpirationDatePicker.tsx new file mode 100644 index 0000000000..89a0d154f8 --- /dev/null +++ b/app/javascript/src/AccessTokens/components/ExpirationDatePicker.tsx @@ -0,0 +1,167 @@ +import { useState, useMemo } from 'react' +import { Alert, CalendarMonth, FormGroup, FormSelect, FormSelectOption, Popover } from '@patternfly/react-core' +import OutlinedQuestionCircleIcon from '@patternfly/react-icons/dist/js/icons/outlined-question-circle-icon' + +import { createReactWrapper } from 'utilities/createReactWrapper' + +import type { FunctionComponent, FormEvent } from 'react' + +import './ExpirationDatePicker.scss' + +interface ExpirationItem { + id: string; + label: string; + period: number; // In seconds +} + +const collection: ExpirationItem[] = [ + { id: '7', label: '7 days', period: 7 }, + { id: '30', label: '30 days', period: 30 }, + { id: '60', label: '60 days', period: 60 }, + { id: '90', label: '90 days', period: 90 }, + { id: 'custom', label: 'Custom...', period: 0 }, + { id: 'no-exp', label: 'No expiration', period: 0 } +] + +const dayMs = 60 * 60 * 24 * 1000 + +interface Props { + id: string; + label: string | null; + tzOffset?: number; +} + +const ExpirationDatePicker: FunctionComponent = ({ id, label, tzOffset }) => { + const [selectedItem, setSelectedItem] = useState(collection[0]) + const [pickedDate, setPickedDate] = useState(new Date()) + const fieldName = `human_${id}` + const fieldLabel = label ?? 'Expires in' + + const fieldDate = useMemo(() => { + if (selectedItem.period === 0) return null + + return new Date(new Date().getTime() + selectedItem.period * dayMs) + }, [selectedItem]) + + const dateValue = useMemo(() => { + let value = '' + + if (fieldDate) { + value = fieldDate.toISOString() + } else if (selectedItem.id === 'custom' ) { + value = pickedDate.toISOString() + } + + return value + }, [fieldDate, selectedItem, pickedDate]) + + const formattedDateValue = useMemo(() => { + if (!dateValue) return + + const formatter = Intl.DateTimeFormat('en-US', { + month: 'long', day: 'numeric', year: 'numeric', hour: 'numeric', minute: 'numeric', hour12: false + }) + const date = new Date(dateValue) + + return formatter.format(date) + }, [dateValue]) + + const fieldHint = useMemo(() => { + if (!formattedDateValue) return + + return `The token will expire on ${formattedDateValue}` + }, [formattedDateValue]) + + const tzMismatch = useMemo(() => { + if (tzOffset === undefined) return + + // Timezone offset in the same format as ActiveSupport + const jsTzOffset = new Date().getTimezoneOffset() * -60 + + return jsTzOffset !== tzOffset + }, [tzOffset]) + + const labelIcon = useMemo(() => { + if (!tzMismatch) return + + return ( + + Your local time zone differs from the provider default. + The token will expire at the time you selected in your local time zone. +

+ )} + headerContent={( + Time zone mismatch + )} + > + +
+ ) + }, [tzMismatch]) + + const handleOnChange = (_value: string, event: FormEvent) => { + const value = (event.target as HTMLSelectElement).value + const selected = collection.find(i => i.id === value) ?? null + + if (selected === null) return + + setSelectedItem(selected) + setPickedDate(new Date()) + } + + return ( + <> + + + {collection.map((item: ExpirationItem) => { + return ( + + ) + })} + + + + {selectedItem.id === 'custom' && ( + + )} + {dateValue === '' && ( + <> +
+ + It is strongly recommended that you set an expiration date for your token to help keep your information + secure + + + )} + + ) +} + +const ExpirationDatePickerWrapper = (props: Props, containerId: string): void => { createReactWrapper(, containerId) } + +export type { ExpirationItem, Props } +export { ExpirationDatePicker, ExpirationDatePickerWrapper } diff --git a/app/models/access_token.rb b/app/models/access_token.rb index 1b1b79cad6..c38d0f224d 100644 --- a/app/models/access_token.rb +++ b/app/models/access_token.rb @@ -140,7 +140,7 @@ def validate_scope_exists def expires_at=(value) return if value.blank? - DateTime.strptime(value) + DateTime.parse(value) super value rescue StandardError diff --git a/app/presenters/provider/admin/user/access_tokens_new_presenter.rb b/app/presenters/provider/admin/user/access_tokens_new_presenter.rb new file mode 100644 index 0000000000..ac85c33f5f --- /dev/null +++ b/app/presenters/provider/admin/user/access_tokens_new_presenter.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class Provider::Admin::User::AccessTokensNewPresenter + + def initialize(provider) + @provider = provider + @timezone = ActiveSupport::TimeZone.new(provider.timezone) + end + + def provider_timezone_offset + @timezone.utc_offset + end +end diff --git a/app/views/provider/admin/user/access_tokens/_form.html.slim b/app/views/provider/admin/user/access_tokens/_form.html.slim index eccdf3a684..8a32a15976 100644 --- a/app/views/provider/admin/user/access_tokens/_form.html.slim +++ b/app/views/provider/admin/user/access_tokens/_form.html.slim @@ -7,3 +7,14 @@ = form.input :permission, as: :patternfly_select, collection: @access_token.available_permissions, include_blank: false + +- if @access_token.persisted? + .pf-c-form__group + .pf-c-form__group-label + label.pf-c-form__label + span.pf-c-form__label-text + = t('access_token_options.expires_at') + .pf-c-form__group-control + = @access_token.expires_at.present? ? l(@access_token.expires_at) : t('access_token_options.no_expiration') +- else + div id='expiration-date-picker-container' data-props={ id: 'access_token[expires_at]', label: t('access_token_options.expires_in'), tzOffset: @presenter.provider_timezone_offset }.to_json diff --git a/app/views/provider/admin/user/access_tokens/index.html.slim b/app/views/provider/admin/user/access_tokens/index.html.slim index d571f646db..163a7edcc6 100644 --- a/app/views/provider/admin/user/access_tokens/index.html.slim +++ b/app/views/provider/admin/user/access_tokens/index.html.slim @@ -31,6 +31,13 @@ dd class="pf-c-description-list__description" div class="pf-c-description-list__text" = token.human_permission + div class="pf-c-description-list__group" + dt class="pf-c-description-list__term" + span class="pf-c-description-list__text" + | Expires at + dd class="pf-c-description-list__description" + div class="pf-c-description-list__text" + = token.expires_at.present? ? l(token.expires_at) : t('access_token_options.no_expiration') div class="pf-c-description-list__group" dt class="pf-c-description-list__term" span class="pf-c-description-list__text" @@ -58,6 +65,7 @@ tr role="row" th role="columnheader" scope="col" Name th role="columnheader" scope="col" Scopes + th role="columnheader" scope="col" Expiration th role="columnheader" scope="col" Permission th role="columnheader" scope="col" class="pf-c-table__action pf-m-fit-content" = fancy_link_to 'Add Access Token', new_provider_admin_user_access_token_path, class: 'new' if allowed_scopes.any? @@ -67,6 +75,7 @@ tr role="row" td role="cell" data-label="Name" = token.name td role="cell" data-label="Scopes" = token.human_scopes.to_sentence + td role="cell" data-label="Expiration" = token.expires_at.present? ? l(token.expires_at) : t('access_token_options.no_expiration') td role="cell" data-label="Permission" = token.human_permission td role="cell" class="pf-c-table__action" div class="pf-c-overflow-menu" diff --git a/app/views/provider/admin/user/access_tokens/new.html.slim b/app/views/provider/admin/user/access_tokens/new.html.slim index be3ace8aaa..d69e35a522 100644 --- a/app/views/provider/admin/user/access_tokens/new.html.slim +++ b/app/views/provider/admin/user/access_tokens/new.html.slim @@ -2,6 +2,7 @@ - content_for :javascripts do = javascript_packs_with_chunks_tag 'pf_form' + = javascript_packs_with_chunks_tag 'expiration_date_picker' div class="pf-c-card" div class="pf-c-card__body" diff --git a/config/locales/en.yml b/config/locales/en.yml index 3c98638064..966ee00e2e 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -65,6 +65,9 @@ en: cms: 'Developer Portal API' ro: 'Read Only' rw: 'Read & Write' + no_expiration: 'Never expires' + expires_at: 'Expires at' + expires_in: 'Expires in' notification_category_titles: account: 'Accounts' application: 'Applications' diff --git a/features/provider/admin/user/access_tokens.feature b/features/provider/admin/user/access_tokens.feature index 191277e5f2..eb9222edb0 100644 --- a/features/provider/admin/user/access_tokens.feature +++ b/features/provider/admin/user/access_tokens.feature @@ -24,9 +24,9 @@ Feature: Provider Admin Access tokens Scenario: Tokens are listed in a table Then the table should contain the following: - | Name | Scopes | Permission | - | Potato | Analytics API | Read Only | - | Banana | Billing API | Read & Write | + | Name | Scopes | Expiration | Permission | + | Potato | Analytics API | Never expires | Read Only | + | Banana | Billing API | Never expires | Read & Write | Rule: New page Background: @@ -42,6 +42,7 @@ Feature: Provider Admin Access tokens Then there is a required field "Name" And there is a required field "Scopes" And there is a required field "Permission" + And there is a required field "Expires in" And the submit button is enabled Scenario: Create access tokens without required fields @@ -49,26 +50,29 @@ Feature: Provider Admin Access tokens Then field "Name" has inline error "can't be blank" And field "Scopes" has inline error "select at least one scope" And field "Permission" has no inline error + And field "Expires in" has no inline error Scenario: Create access token When they press "Create Access Token" And the form is submitted with: - | Name | LeToken | - | Analytics API | Yes | - | Permission | Read & Write | + | Name | LeToken | + | Analytics API | Yes | + | Permission | Read & Write | + | Expires in | No expiration | Then the current page is the personal tokens page And they should see the flash message "Access token was successfully created" And should see the following details: - | Name | LeToken | - | Scopes | Analytics API | - | Permission | Read & Write | + | Name | LeToken | + | Scopes | Analytics API | + | Permission | Read & Write | + | Expires at | Never expires | And there should be a link to "I have copied the token" Rule: Edit page Background: Given the provider has the following access tokens: - | Name | Scopes | Permission | - | LeToken | Billing API, Analytics API | Read Only | + | Name | Scopes | Permission | Expires at | + | LeToken | Billing API, Analytics API | Read Only | 2030-01-01T00:00:00Z | And they go to the access token's edit page Scenario: Navigation to edit page diff --git a/spec/javascripts/AccessTokens/components/ExpirationDatePicker.spec.tsx b/spec/javascripts/AccessTokens/components/ExpirationDatePicker.spec.tsx new file mode 100644 index 0000000000..849d5fca2f --- /dev/null +++ b/spec/javascripts/AccessTokens/components/ExpirationDatePicker.spec.tsx @@ -0,0 +1,162 @@ +import { mount } from 'enzyme' + +import { ExpirationDatePicker } from 'AccessTokens/components/ExpirationDatePicker' + +import type { ExpirationItem, Props } from 'AccessTokens/components/ExpirationDatePicker' +import type { ReactWrapper } from 'enzyme' + +const msExp = /\.\d{3}Z$/ + +const defaultProps: Props = { + id: 'expires_at', + label: 'Expires in', + tzOffset: 0 +} + +const mountWrapper = (props: Partial = {}) => mount() + +const selectItem = (wrapper: ReactWrapper>, item: ExpirationItem) => { + wrapper.find('select.pf-c-form-control-expiration').simulate('change', { target: { value: item.id.toString() } }) +} + +const pickDate = (wrapper: ReactWrapper>) => { + /* + * Pick tomorrow, to do so, we get the date selected by default which is today and click the next one. + * It could happen that today is the last day in the calendar, in that case we pick the previous day, yesterday. + * In any case, we return the picked date to the caller. + */ + const targetDate = new Date() + targetDate.setHours(0) + targetDate.setMinutes(0) + targetDate.setSeconds(0) + targetDate.setMilliseconds(0) + + const tomorrowButton = wrapper.find('.pf-m-selected + td > button') + + if (tomorrowButton.length === 0) { + // No tomorrow, pick yesterday + const dayButtons = wrapper.find('button.pf-c-calendar-month__date') + const yesterdayButton = dayButtons.at(dayButtons.length - 2) + + yesterdayButton.simulate('click') + targetDate.setDate(targetDate.getDate() - 1) + return targetDate + } + + tomorrowButton.simulate('click') + targetDate.setDate(targetDate.getDate() + 1) + return targetDate +} + +const dateFormatter = Intl.DateTimeFormat('en-US', { + month: 'long', day: 'numeric', year: 'numeric', hour: 'numeric', minute: 'numeric', hour12: false +}) + +it('should render itself', () => { + const wrapper = mountWrapper() + expect(wrapper.exists()).toEqual(true) +}) + +describe('select a period', () => { + const targetItem: ExpirationItem = { id: '90', label: '90 days', period: 90 } + + it('should update hint to the correct date', () => { + const wrapper = mountWrapper() + const targetDate = new Date(new Date().getTime() + 1000 * 60 * 60 * 24 * targetItem.period) + const expectedHint = `The token will expire on ${dateFormatter.format(targetDate)}` + + selectItem(wrapper, targetItem) + const hint = wrapper.find('.pf-c-form__helper-text').text() + + expect(hint).toBe(expectedHint) + }) + + it('should update hidden input value to the correct timestamp', () => { + const wrapper = mountWrapper() + const targetDate = new Date(new Date().getTime() + 1000 * 60 * 60 * 24 * targetItem.period) + const expectedValue = targetDate.toISOString().replace(msExp, 'Z') + + selectItem(wrapper, targetItem) + const value = (wrapper.find(`input#${defaultProps.id}`).prop('value') as string).replace(msExp, 'Z') + + expect(value).toBe(expectedValue) + }) +}) + +describe('select "Custom"', () => { + const targetItem: ExpirationItem = { id: 'custom', label: 'Custom...', period: 0 } + + it('should show a calendar', () => { + const wrapper = mountWrapper() + + selectItem(wrapper, targetItem) + const calendar = wrapper.find('.pf-c-calendar-month') + + expect(calendar.exists()).toBe(true) + }) + + describe('pick a date from the calendar', () => { + it('should update hint to the correct date', () => { + const wrapper = mountWrapper() + + selectItem(wrapper, targetItem) + const targetDate = pickDate(wrapper) + const expectedHint = `The token will expire on ${dateFormatter.format(targetDate)}` + const hint = wrapper.find('.pf-c-form__helper-text').text() + + expect(hint).toBe(expectedHint) + }) + + it('should update hidden input value to the correct timestamp', () => { + const wrapper = mountWrapper() + + selectItem(wrapper, targetItem) + const targetDate = pickDate(wrapper) + const expectedValue = targetDate.toISOString().replace(msExp, 'Z') + const value = (wrapper.find(`input#${defaultProps.id}`).prop('value') as string).replace(msExp, 'Z') + + expect(value).toBe(expectedValue) + }) + }) +}) + +describe('select "No expiration"', () => { + const targetItem: ExpirationItem = { id: 'no-exp', label: 'No expiration', period: 0 } + + it('should show a warning', () => { + const wrapper = mountWrapper() + + selectItem(wrapper, targetItem) + const warning = wrapper.find('.pf-c-alert.pf-m-warning') + + expect(warning.exists()).toBe(true) + }) + + it('should update hidden input value to empty', () => { + const wrapper = mountWrapper() + const expectedValue = '' + + selectItem(wrapper, targetItem) + const value = wrapper.find(`input#${defaultProps.id}`).prop('value') + + expect(value).toBe(expectedValue) + }) +}) + +describe('time zone matches', () => { + it('should not show a warning', ()=> { + jest.spyOn(Date.prototype, 'getTimezoneOffset').mockImplementation(() => (0)) + const wrapper = mountWrapper() + + expect(wrapper.exists('.pf-c-form__group-label-help')).toEqual(false) + }) +}) + +describe('time zone mismatches', () => { + it('should show a warning', ()=> { + jest.spyOn(Date.prototype, 'getTimezoneOffset').mockImplementation(() => (-120)) + const wrapper = mountWrapper() + + expect(wrapper.exists('.pf-c-form__group-label-help')).toEqual(true) + }) +}) \ No newline at end of file