Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

THREESCALE-10245: access tokens expiration UI #3943

Merged
merged 4 commits into from
Jan 23, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
87 changes: 50 additions & 37 deletions app/controllers/provider/admin/user/access_tokens_controller.rb
Original file line number Diff line number Diff line change
@@ -1,44 +1,57 @@
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

private
def new
@presenter = AccessTokensNewPresenter.new(current_account)
end

def authorize_access_tokens
authorize! :manage, :access_tokens, current_user
end
def create
@presenter = AccessTokensNewPresenter.new(current_account)
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 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 begin_of_association_chain
current_user
def authorize_access_tokens
authorize! :manage, :access_tokens, current_user
end

def begin_of_association_chain
current_user
end
end
end
end
end
21 changes: 21 additions & 0 deletions app/javascript/packs/expiration_date_picker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { ExpirationDatePickerWrapper } from 'AccessTokens/components/ExpirationDatePicker'
import { safeFromJsonString } from 'utilities/json-utils'

import type { Props } from 'AccessTokens/components/ExpirationDatePicker'

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<Props>(container.dataset.props)

if (!props) {
throw new Error('Missing props')
}

ExpirationDatePickerWrapper(props, containerId)
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
@import '~@patternfly/patternfly/patternfly-addons.css';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like something we would import at an upper level, like a base css file. I am not sure what it is for (it's used only in dev portal). I'm worried that it's going to be duplicated someday, and unfortunately there's no way to dedupe @imports from individual scss files.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is to load the pf-u-mt-md class. I could include it from somewhere else, do you have a suggestion?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The best way to do this would be to create a new scss pack that imports patternfly-addons alone, then import the pack alongside expiration_date_picker.ts. HOWEVER, it's not something we have to do right now. Let's keep it like this and improve only if it's ever necessary.


.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;
}
177 changes: 177 additions & 0 deletions app/javascript/src/AccessTokens/components/ExpirationDatePicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { useState } from 'react'
import {
Alert,
CalendarMonth,
FormGroup,
FormSelect,
FormSelectOption,
Popover
} from '@patternfly/react-core'
import ExclamationTriangleIcon from '@patternfly/react-icons/dist/js/icons/exclamation-triangle-icon'

import { createReactWrapper } from 'utilities/createReactWrapper'

import type { FunctionComponent } from 'react'

import './ExpirationDatePicker.scss'

interface ExpirationItem {
id: string;
label: string;
period: number; // In days
}

const collection: ExpirationItem[] = [
{ id: '7', label: '7 days', period: 7 },
jlledom marked this conversation as resolved.
Show resolved Hide resolved
{ 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 today: Date = new Date()
const tomorrow: Date = new Date(today)
tomorrow.setDate(today.getDate() + 1)
const dayMs = 60 * 60 * 24 * 1000

const computeDropdownDate = (dropdownSelectedItem: ExpirationItem) => {
if (dropdownSelectedItem.period === 0) return null

return new Date(today.getTime() + dropdownSelectedItem.period * dayMs)
}

const computeSelectedDate = (dropdownDate: Date | null, dropdownSelectedItem: ExpirationItem, calendarPickedDate: Date) => {
if (dropdownDate) return dropdownDate

return dropdownSelectedItem.id === 'custom' ? calendarPickedDate : null
}

const computeFormattedDateValue = (selectedDate: Date | null) => {
if (!selectedDate) return ''

const formatter = Intl.DateTimeFormat('en-US', {
month: 'long', day: 'numeric', year: 'numeric', hour: 'numeric', minute: 'numeric', hour12: false
})

return formatter.format(selectedDate)
}

const computeFieldHint = (formattedDateValue: string) => {
if (!formattedDateValue) return

return `The token will expire on ${formattedDateValue}`
}

const computeTzMismatch = (tzOffset: number) => {
// Timezone offset in the same format as ActiveSupport
const jsTzOffset = new Date().getTimezoneOffset() * -60
Comment on lines +67 to +68
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wow, that's confusing 😅 Not only they are not in the same granularity (seconds vs minutes), but also different signs (negative vs positive) 🤪


return jsTzOffset !== tzOffset
}

const computeTzMismatchIcon = (tzMismatch: boolean) => {
if (!tzMismatch) return

return (
<Popover
bodyContent={(
<p>
Your local time zone differs from the account settings.
The token will expire at the time you selected in your local time zone.
</p>
)}
headerContent={(
<span>Time zone mismatch</span>
)}
>
<button
aria-describedby="form-group-label-info"
aria-label="Time zone mismatch warning"
className="pf-c-form__group-label-help"
type="button"
>
<ExclamationTriangleIcon noVerticalAlign />
</button>
</Popover>
)
}

interface Props {
id: string;
label: string | null;
tzOffset: number;
}

const ExpirationDatePicker: FunctionComponent<Props> = ({ id, label, tzOffset }) => {
const [dropdownSelectedItem, setDropdownSelectedItem] = useState(collection[0])
const [calendarPickedDate, setCalendarPickedDate] = useState(tomorrow)

const dropdownDate = computeDropdownDate(dropdownSelectedItem)
const selectedDate = computeSelectedDate(dropdownDate, dropdownSelectedItem, calendarPickedDate)
const formattedDateValue = computeFormattedDateValue(selectedDate)
const fieldHint = computeFieldHint(formattedDateValue)
const tzMismatch = computeTzMismatch(tzOffset)
const tzMismatchIcon = computeTzMismatchIcon(tzMismatch)
const inputDateValue = selectedDate ? selectedDate.toISOString() : ''
const fieldName = `human_${id}`
const fieldLabel = label ?? 'Expires in'

const handleOnChange = (value: string) => {
const selected = collection.find(i => i.id === value) ?? null

if (selected === null) return

setDropdownSelectedItem(selected)
setCalendarPickedDate(tomorrow)
}

const dateValidator = (date: Date): boolean => {
return date >= today
}

return (
<>
<FormGroup
isRequired
fieldId={fieldName}
helperText={fieldHint}
label={fieldLabel}
labelIcon={tzMismatchIcon}
>
<FormSelect
className="pf-c-form-control-expiration"
id={fieldName}
value={dropdownSelectedItem.id}
onChange={handleOnChange}
>
{collection.map((item: ExpirationItem) => {
return (
<FormSelectOption
key={item.id}
label={item.label}
value={item.id}
/>
)
})}
</FormSelect>
</FormGroup>
<input id={id} name={id} type="hidden" value={inputDateValue} />
{dropdownSelectedItem.id === 'custom' && (
<CalendarMonth className="pf-u-mt-md" date={calendarPickedDate} validators={[dateValidator]} onChange={setCalendarPickedDate} />
)}
{!selectedDate && (
<Alert className="pf-u-mt-md" title="Expiration is recommended" variant="warning">
It is strongly recommended that you set an expiration date for your token to help keep your information
secure
</Alert>
)}
</>
)
}

// eslint-disable-next-line react/jsx-props-no-spreading
const ExpirationDatePickerWrapper = (props: Props, containerId: string): void => { createReactWrapper(<ExpirationDatePicker {...props} />, containerId) }

export type { ExpirationItem, Props }
export { ExpirationDatePicker, ExpirationDatePickerWrapper }
2 changes: 1 addition & 1 deletion app/models/access_token.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions app/presenters/provider/admin/user/access_tokens_new_presenter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# frozen_string_literal: true

class Provider::Admin::User::AccessTokensNewPresenter

def initialize(provider)
@timezone = ActiveSupport::TimeZone.new(provider.timezone)
end

def provider_timezone_offset
@timezone.utc_offset
end

def date_picker_props
{
id: 'access_token[expires_at]',
label: I18n.t('access_token_options.expires_in'),
tzOffset: provider_timezone_offset
}
end
end
15 changes: 13 additions & 2 deletions app/views/provider/admin/user/access_tokens/_form.html.slim
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,19 @@
input_html: { autofocus: true }

= form.input :scopes, as: :patternfly_check_boxes,
collection: @access_token.available_scopes.to_collection_for_check_boxes
collection: access_token.available_scopes.to_collection_for_check_boxes

= form.input :permission, as: :patternfly_select,
collection: @access_token.available_permissions,
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=date_picker_props.to_json
2 changes: 1 addition & 1 deletion app/views/provider/admin/user/access_tokens/edit.html.slim
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ div class="pf-c-card"
= semantic_form_for @access_token, builder: Fields::PatternflyFormBuilder,
url: [:provider, :admin, :user, @access_token],
html: { class: 'pf-c-form pf-m-limit-width' } do |f|
= render 'form', form: f
= render partial: 'form', locals: { form: f, access_token: @access_token }

= f.actions do
= f.commit_button t('.submit_button_label')
Expand Down
Loading