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

Open
wants to merge 20 commits into
base: master
Choose a base branch
from
Open
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
89 changes: 52 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,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
22 changes: 22 additions & 0 deletions app/javascript/packs/expiration_date_picker.ts
Original file line number Diff line number Diff line change
@@ -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<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,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;
}
167 changes: 167 additions & 0 deletions app/javascript/src/AccessTokens/components/ExpirationDatePicker.tsx
Original file line number Diff line number Diff line change
@@ -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<Props> = ({ 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 (
<Popover
bodyContent={(
<p>
Your local time zone differs from the provider default.
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"
>
<OutlinedQuestionCircleIcon noVerticalAlign />
</button>
</Popover>
)
}, [tzMismatch])

const handleOnChange = (_value: string, event: FormEvent<HTMLSelectElement>) => {
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 (
<>
<FormGroup
isRequired
fieldId={fieldName}
helperText={fieldHint}
label={fieldLabel}
labelIcon={labelIcon}
>
<FormSelect
className="pf-c-form-control-expiration"
id={fieldName}
value={selectedItem.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={dateValue} />
{selectedItem.id === 'custom' && (
<CalendarMonth className="pf-u-mt-md" date={pickedDate} onChange={setPickedDate} />
)}
{dateValue === '' && (
<>
<br />
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't like this break here, but I couldn't find an easy way to add some top margin, so that's OK...

I was looking at https://pf4.patternfly.org/utilities/spacing/, and was hoping that adding className="pf-u-mt-md" would help, but it didn't 😬 I think that probably the CSS containing this class is not being loaded... not sure, but I didn't investigate further.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Contributor

Choose a reason for hiding this comment

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

Also, now I think the parenthesis () are not needed, but well, not important.

<Alert 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>
</>
)}
</>
)
}

const ExpirationDatePickerWrapper = (props: Props, containerId: string): void => { createReactWrapper(<ExpirationDatePicker id={props.id} label={props.label} tzOffset={props.tzOffset} />, 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
13 changes: 13 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,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
11 changes: 11 additions & 0 deletions app/views/provider/admin/user/access_tokens/_form.html.slim
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 9 additions & 0 deletions app/views/provider/admin/user/access_tokens/index.html.slim
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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?
Expand All @@ -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"
Expand Down
1 change: 1 addition & 0 deletions app/views/provider/admin/user/access_tokens/new.html.slim
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Loading