mirror of
				https://github.com/optim-enterprises-bv/vault.git
				synced 2025-10-31 02:28:09 +00:00 
			
		
		
		
	UI: PKI Roles Edit (#18194)
This commit is contained in:
		| @@ -24,6 +24,7 @@ export default class PkiRoleModel extends Model { | ||||
|   @attr('string', { | ||||
|     label: 'Role name', | ||||
|     fieldValue: 'name', | ||||
|     editDisabled: true, | ||||
|   }) | ||||
|   name; | ||||
|  | ||||
| @@ -50,7 +51,6 @@ export default class PkiRoleModel extends Model { | ||||
|     helperTextEnabled: | ||||
|       'Also called the not_before_duration property. Allows certificates to be valid for a certain time period before now. This is useful to correct clock misalignment on various systems when setting up your CA.', | ||||
|     editType: 'ttl', | ||||
|     hideToggle: true, | ||||
|     defaultValue: '30s', // The API type is "duration" which accepts both an integer and string e.g. 30 || '30s' | ||||
|   }) | ||||
|   notBeforeDuration; | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| @import 'ember-basic-dropdown'; | ||||
| @import 'ember-power-select'; | ||||
| @import './core'; | ||||
| @import './engines'; | ||||
|  | ||||
| @mixin font-face($name) { | ||||
|   @font-face { | ||||
|   | ||||
| @@ -287,3 +287,17 @@ ul.bullet { | ||||
| .has-text-align-center { | ||||
|   text-align: center; | ||||
| } | ||||
| // Screen Readers only | ||||
| .sr-only { | ||||
|   border: 0 !important; | ||||
|   clip: rect(1px, 1px, 1px, 1px) !important; | ||||
|   -webkit-clip-path: inset(50%) !important; | ||||
|   clip-path: inset(50%) !important; | ||||
|   height: 1px !important; | ||||
|   overflow: hidden !important; | ||||
|   margin: -1px !important; | ||||
|   padding: 0 !important; | ||||
|   position: absolute !important; | ||||
|   width: 1px !important; | ||||
|   white-space: nowrap !important; | ||||
| } | ||||
|   | ||||
							
								
								
									
										2
									
								
								ui/app/styles/engines.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								ui/app/styles/engines.scss
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | ||||
| // PKI Engine styles | ||||
| @import './pki/pki-not-valid-after-form'; | ||||
							
								
								
									
										3
									
								
								ui/app/styles/pki/pki-not-valid-after-form.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								ui/app/styles/pki/pki-not-valid-after-form.scss
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| .pki-radiogroup-label { | ||||
|   align-items: baseline; | ||||
| } | ||||
| @@ -61,6 +61,8 @@ | ||||
|         {{/if}} | ||||
|       {{else if @formatDate}} | ||||
|         {{date-format @value @formatDate}} | ||||
|       {{else if @formatTtl}} | ||||
|         {{this.formattedTtl}} | ||||
|       {{else}} | ||||
|         {{#if (eq @type "array")}} | ||||
|           <InfoTableItemArray | ||||
|   | ||||
| @@ -2,6 +2,7 @@ import { typeOf } from '@ember/utils'; | ||||
| import Component from '@glimmer/component'; | ||||
| import { tracked } from '@glimmer/tracking'; | ||||
| import { action } from '@ember/object'; | ||||
| import { convertFromSeconds, largestUnitFromSeconds } from 'core/utils/duration-utils'; | ||||
|  | ||||
| /** | ||||
|  * @module InfoTableRow | ||||
| @@ -56,6 +57,14 @@ export default class InfoTableRowComponent extends Component { | ||||
|         return false; | ||||
|     } | ||||
|   } | ||||
|   get formattedTtl() { | ||||
|     const { value } = this.args; | ||||
|     if (Number.isInteger(value)) { | ||||
|       const unit = largestUnitFromSeconds(value); | ||||
|       return `${convertFromSeconds(value, unit)}${unit}`; | ||||
|     } | ||||
|     return value; | ||||
|   } | ||||
|  | ||||
|   @action | ||||
|   calculateLabelOverflow(el) { | ||||
|   | ||||
| @@ -1,49 +0,0 @@ | ||||
| <div class="column is-narrow is-flex-center has-text-grey has-right-margin-s has-top-margin-negative-s"> | ||||
|   <RadioButton | ||||
|     class="radio" | ||||
|     name="ttl" | ||||
|     @value="ttl" | ||||
|     @onChange={{this.onRadioButtonChange}} | ||||
|     @groupValue={{this.groupValue}} | ||||
|     data-test-radio-button="ttl" | ||||
|   /> | ||||
|   <label class="has-left-margin-xs"> | ||||
|     <TtlPicker | ||||
|       data-test-input="ttl" | ||||
|       @onChange={{this.setAndBroadcastTtl}} | ||||
|       @label="TTL" | ||||
|       @helperTextEnabled={{@attr.options.helperTextEnabled}} | ||||
|       @description={{@attr.helpText}} | ||||
|       @time={{this.ttlTime}} | ||||
|       @unit="d" | ||||
|       @hideToggle={{true}} | ||||
|     /> | ||||
|   </label> | ||||
| </div> | ||||
| <div class="column is-narrow is-flex-center has-text-grey has-right-margin-s"> | ||||
|   <RadioButton | ||||
|     class="radio" | ||||
|     name="not_after" | ||||
|     @value="specificDate" | ||||
|     @onChange={{this.onRadioButtonChange}} | ||||
|     @groupValue={{this.groupValue}} | ||||
|     data-test-radio-button="not_after" | ||||
|   /> | ||||
|   <label class="has-left-margin-xs"> | ||||
|     <span class="ttl-picker-label is-large">Specific date</span><br /> | ||||
|     <p class="sub-text"> | ||||
|       This value format should be given in UTC format YYYY-MM-ddTHH:MM:SSZ. | ||||
|     </p> | ||||
|     {{#if (eq this.groupValue "specificDate")}} | ||||
|       <input | ||||
|         id="not_after" | ||||
|         autocomplete="off" | ||||
|         spellcheck="false" | ||||
|         value={{this.notAfter}} | ||||
|         {{on "input" this.setAndBroadcastInput}} | ||||
|         class="input" | ||||
|         data-test-input="not_after" | ||||
|       /> | ||||
|     {{/if}} | ||||
|   </label> | ||||
| </div> | ||||
| @@ -1,52 +0,0 @@ | ||||
| import Component from '@glimmer/component'; | ||||
| import { action } from '@ember/object'; | ||||
| import { tracked } from '@glimmer/tracking'; | ||||
|  | ||||
| /** | ||||
|  * @module RadioSelectTtlOrString | ||||
|  * `RadioSelectTtlOrString` components are yielded out within the formField component when the editType on the model is yield. | ||||
|  * The component is two radio buttons, where the first option is a TTL, and the second option is an input field without a title. | ||||
|  * This component is used in the PKI engine inside various forms. | ||||
|  * | ||||
|  * @example | ||||
|  * ```js | ||||
|  * {{#each @model.fields as |attr|}} | ||||
|  *  <RadioSelectTtlOrString @attr={{attr}} @model={{this.model}} /> | ||||
|  * {{/each}} | ||||
|  * ``` | ||||
|  * @param {Model} model - Ember Data model that `attr` is defined on. | ||||
|  * @param {Object} attr - Usually derived from ember model `attributes` lookup, and all members of `attr.options` are optional. | ||||
|  */ | ||||
|  | ||||
| export default class RadioSelectTtlOrString extends Component { | ||||
|   @tracked groupValue = 'ttl'; | ||||
|   @tracked ttlTime; | ||||
|   @tracked notAfter; | ||||
|  | ||||
|   @action onRadioButtonChange(selection) { | ||||
|     this.groupValue = selection; | ||||
|     // Clear the previous selection if they have clicked the other radio button. | ||||
|     if (selection === 'specificDate') { | ||||
|       this.args.model.set('ttl', ''); | ||||
|       this.ttlTime = ''; | ||||
|     } | ||||
|     if (selection === 'ttl') { | ||||
|       this.args.model.set('notAfter', ''); | ||||
|       this.notAfter = ''; | ||||
|       this.args.model.set('ttl', this.ttlTime); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @action setAndBroadcastTtl(value) { | ||||
|     const valueToSet = value.enabled === true ? `${value.seconds}s` : 0; | ||||
|     if (this.groupValue === 'specificDate') { | ||||
|       // do not save ttl on the model until the ttl radio button is selected | ||||
|       return; | ||||
|     } | ||||
|     this.args.model.set('ttl', `${valueToSet}`); | ||||
|   } | ||||
|  | ||||
|   @action setAndBroadcastInput(event) { | ||||
|     this.args.model.set('notAfter', event.target.value); | ||||
|   } | ||||
| } | ||||
| @@ -28,37 +28,12 @@ import Duration from '@icholy/duration'; | ||||
| import { guidFor } from '@ember/object/internals'; | ||||
| import Ember from 'ember'; | ||||
| import { restartableTask, timeout } from 'ember-concurrency'; | ||||
|  | ||||
| export const secondsMap = { | ||||
|   s: 1, | ||||
|   m: 60, | ||||
|   h: 3600, | ||||
|   d: 86400, | ||||
| }; | ||||
| const convertToSeconds = (time, unit) => { | ||||
|   return time * secondsMap[unit]; | ||||
| }; | ||||
| const convertFromSeconds = (seconds, unit) => { | ||||
|   return seconds / secondsMap[unit]; | ||||
| }; | ||||
| const goSafeConvertFromSeconds = (seconds, unit) => { | ||||
|   // Go only accepts s, m, or h units | ||||
|   const u = unit === 'd' ? 'h' : unit; | ||||
|   return convertFromSeconds(seconds, u) + u; | ||||
| }; | ||||
| const largestUnitFromSeconds = (seconds) => { | ||||
|   let unit = 's'; | ||||
|   if (seconds === 0) return unit; | ||||
|   // get largest unit with no remainder | ||||
|   if (seconds % secondsMap.d === 0) { | ||||
|     unit = 'd'; | ||||
|   } else if (seconds % secondsMap.h === 0) { | ||||
|     unit = 'h'; | ||||
|   } else if (seconds % secondsMap.m === 0) { | ||||
|     unit = 'm'; | ||||
|   } | ||||
|   return unit; | ||||
| }; | ||||
| import { | ||||
|   convertFromSeconds, | ||||
|   convertToSeconds, | ||||
|   goSafeConvertFromSeconds, | ||||
|   largestUnitFromSeconds, | ||||
| } from 'core/utils/duration-utils'; | ||||
| export default class TtlPickerComponent extends Component { | ||||
|   @tracked enableTTL = false; | ||||
|   @tracked recalculateSeconds = false; | ||||
|   | ||||
							
								
								
									
										52
									
								
								ui/lib/core/addon/decorators/confirm-leave.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								ui/lib/core/addon/decorators/confirm-leave.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,52 @@ | ||||
| import { action } from '@ember/object'; | ||||
| import Route from '@ember/routing/route'; | ||||
| import { inject as service } from '@ember/service'; | ||||
|  | ||||
| /** | ||||
|  * Confirm that the user wants to discard unsaved changes before leaving the page. | ||||
|  * This decorator hooks into the willTransition action. If you override setupController, | ||||
|  * be sure to set 'model' on the controller to store data or this won't work. | ||||
|  */ | ||||
| export function withConfirmLeave() { | ||||
|   return function decorator(SuperClass) { | ||||
|     if (!Object.prototype.isPrototypeOf.call(Route, SuperClass)) { | ||||
|       // eslint-disable-next-line | ||||
|       console.error( | ||||
|         'withConfirmLeave decorator must be used on instance of ember Route class. Decorator not applied to returned class' | ||||
|       ); | ||||
|       return SuperClass; | ||||
|     } | ||||
|     return class ConfirmLeave extends SuperClass { | ||||
|       @service store; | ||||
|  | ||||
|       @action | ||||
|       willTransition(transition) { | ||||
|         try { | ||||
|           super.willTransition(...arguments); | ||||
|         } catch (e) { | ||||
|           // if the SuperClass doesn't have willTransition | ||||
|           // defined it will throw an error. | ||||
|         } | ||||
|         const model = this.controller.get('model'); | ||||
|         if (model && model.hasDirtyAttributes) { | ||||
|           if ( | ||||
|             window.confirm( | ||||
|               'You have unsaved changes. Navigating away will discard these changes. Are you sure you want to discard your changes?' | ||||
|             ) | ||||
|           ) { | ||||
|             // error is thrown when you attempt to unload a record that is inFlight (isSaving) | ||||
|             if (!model || !model.unloadRecord || model.isSaving) { | ||||
|               return; | ||||
|             } | ||||
|             model.rollbackAttributes(); | ||||
|             model.destroy(); | ||||
|             return true; | ||||
|           } else { | ||||
|             transition.abort(); | ||||
|             return false; | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     }; | ||||
|   }; | ||||
| } | ||||
							
								
								
									
										41
									
								
								ui/lib/core/addon/utils/duration-utils.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								ui/lib/core/addon/utils/duration-utils.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,41 @@ | ||||
| /** | ||||
|  * These utils are used for managing Duration type values | ||||
|  * (eg. '30m', '365d'). Most often used in the context of TTLs | ||||
|  */ | ||||
| interface SecondsMap { | ||||
|   s: 1; | ||||
|   m: 60; | ||||
|   h: 3600; | ||||
|   d: 86400; | ||||
|   [key: string]: number; | ||||
| } | ||||
| export const secondsMap: SecondsMap = { | ||||
|   s: 1, | ||||
|   m: 60, | ||||
|   h: 3600, | ||||
|   d: 86400, | ||||
| }; | ||||
| export const convertToSeconds = (time: number, unit: string) => { | ||||
|   return time * (secondsMap[unit] || 1); | ||||
| }; | ||||
| export const convertFromSeconds = (seconds: number, unit: string) => { | ||||
|   return seconds / (secondsMap[unit] || 1); | ||||
| }; | ||||
| export const goSafeConvertFromSeconds = (seconds: number, unit: string) => { | ||||
|   // Go only accepts s, m, or h units | ||||
|   const u = unit === 'd' ? 'h' : unit; | ||||
|   return convertFromSeconds(seconds, u) + u; | ||||
| }; | ||||
| export const largestUnitFromSeconds = (seconds: number) => { | ||||
|   let unit = 's'; | ||||
|   if (seconds === 0) return unit; | ||||
|   // get largest unit with no remainder | ||||
|   if (seconds % secondsMap.d === 0) { | ||||
|     unit = 'd'; | ||||
|   } else if (seconds % secondsMap.h === 0) { | ||||
|     unit = 'h'; | ||||
|   } else if (seconds % secondsMap.m === 0) { | ||||
|     unit = 'm'; | ||||
|   } | ||||
|   return unit; | ||||
| }; | ||||
| @@ -1 +0,0 @@ | ||||
| export { default } from 'core/components/radio-select-ttl-or-string'; | ||||
							
								
								
									
										1
									
								
								ui/lib/core/app/decorators/confirm-leave.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								ui/lib/core/app/decorators/confirm-leave.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| export { withConfirmLeave } from 'core/decorators/confirm-leave'; | ||||
| @@ -62,14 +62,23 @@ | ||||
|             @value={{not val}} | ||||
|             @alwaysRender={{true}} | ||||
|           /> | ||||
|         {{else if (eq attr.name "customTtl")}} | ||||
|           {{! Show either notAfter or ttl }} | ||||
|           <InfoTableRow | ||||
|             @label={{capitalize (or attr.options.detailsLabel attr.options.label (humanize (dasherize attr.name)))}} | ||||
|             @value={{or @role.notAfter @role.ttl}} | ||||
|             @alwaysRender={{true}} | ||||
|             @formatDate={{if @role.notAfter "MMM d yyyy HH:mm zzzz"}} | ||||
|             @formatTtl={{@role.ttl}} | ||||
|           /> | ||||
|         {{else}} | ||||
|           <InfoTableRow | ||||
|             @label={{capitalize (or attr.options.detailsLabel attr.options.label (humanize (dasherize attr.name)))}} | ||||
|             @value={{val}} | ||||
|             @alwaysRender={{true}} | ||||
|             @formatDate={{eq attr.name "customTtl"}} | ||||
|             @type={{or attr.type attr.options.type}} | ||||
|             @defaultShown={{attr.options.defaultShown}} | ||||
|             @formatTtl={{eq attr.options.editType "ttl"}} | ||||
|           /> | ||||
|         {{/if}} | ||||
|       {{/let}} | ||||
|   | ||||
							
								
								
									
										56
									
								
								ui/lib/pki/addon/components/pki-not-valid-after-form.hbs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								ui/lib/pki/addon/components/pki-not-valid-after-form.hbs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,56 @@ | ||||
| <div class="column is-narrow is-flex-center has-text-grey has-right-margin-s has-top-margin-negative-s pki-radiogroup-label"> | ||||
|   <RadioButton | ||||
|     id="ttlType" | ||||
|     class="radio" | ||||
|     name="notValidAfterOption" | ||||
|     @value="ttl" | ||||
|     @onChange={{this.onRadioButtonChange}} | ||||
|     @groupValue={{this.groupValue}} | ||||
|     data-test-radio-button="ttl" | ||||
|   /> | ||||
|   <div class="has-left-margin-xs"> | ||||
|     <label class="has-left-margin-xs" for="ttlType" data-test-radio-label="ttl"> | ||||
|       <span class="ttl-picker-label is-large">TTL</span> | ||||
|     </label> | ||||
|     {{#if (eq this.groupValue "ttl")}} | ||||
|       <TtlPicker | ||||
|         data-test-input="ttl" | ||||
|         @onChange={{this.setAndBroadcastTtl}} | ||||
|         @label="TTL" | ||||
|         @helperTextEnabled={{@attr.options.helperTextEnabled}} | ||||
|         @description={{@attr.helpText}} | ||||
|         @initialValue={{@model.ttl}} | ||||
|         @hideToggle={{true}} | ||||
|       > | ||||
|         <label class="sr-only" for="ttl">Set relative certificate expiry with TTL</label> | ||||
|       </TtlPicker> | ||||
|     {{/if}} | ||||
|   </div> | ||||
| </div> | ||||
| <div class="column is-narrow is-flex-center has-text-grey has-right-margin-s pki-radiogroup-label"> | ||||
|   <RadioButton | ||||
|     id="dateType" | ||||
|     class="radio" | ||||
|     name="notValidAfterOption" | ||||
|     @value="specificDate" | ||||
|     @onChange={{this.onRadioButtonChange}} | ||||
|     @groupValue={{this.groupValue}} | ||||
|     data-test-radio-button="not_after" | ||||
|   /> | ||||
|   <div class="has-left-margin-xs"> | ||||
|     <label class="ttl-picker-label is-large" for="dateType" data-test-radio-label="specificDate">Specific date</label> | ||||
|     {{#if (eq this.groupValue "specificDate")}} | ||||
|       <label class="sr-only" for="not_after">Set certificate expiry with specified date value</label> | ||||
|       <Input | ||||
|         id="not_after" | ||||
|         @type="date" | ||||
|         autocomplete="off" | ||||
|         spellcheck="false" | ||||
|         @value={{this.formDate}} | ||||
|         {{on "input" this.setAndBroadcastInput}} | ||||
|         class="input" | ||||
|         data-test-input="not_after" | ||||
|       /> | ||||
|     {{/if}} | ||||
|   </div> | ||||
| </div> | ||||
							
								
								
									
										78
									
								
								ui/lib/pki/addon/components/pki-not-valid-after-form.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								ui/lib/pki/addon/components/pki-not-valid-after-form.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,78 @@ | ||||
| import Component from '@glimmer/component'; | ||||
| import { action } from '@ember/object'; | ||||
| import { tracked } from '@glimmer/tracking'; | ||||
| import { HTMLElementEvent } from 'forms'; | ||||
| import { format } from 'date-fns'; | ||||
|  | ||||
| /** | ||||
|  * <PkiNotValidAfterForm /> components are used to manage two mutually exclusive role options in the form. | ||||
|  */ | ||||
| interface Args { | ||||
|   model: { | ||||
|     notAfter: string; | ||||
|     ttl: string | number; | ||||
|     set: (key: string, value: string | number) => void; | ||||
|   }; | ||||
| } | ||||
|  | ||||
| export default class RadioSelectTtlOrString extends Component<Args> { | ||||
|   @tracked groupValue = 'ttl'; | ||||
|   @tracked cachedNotAfter: string; | ||||
|   @tracked cachedTtl: string | number; | ||||
|   @tracked formDate: string; | ||||
|  | ||||
|   constructor(owner: unknown, args: Args) { | ||||
|     super(owner, args); | ||||
|     const { model } = this.args; | ||||
|     this.cachedNotAfter = model.notAfter || ''; | ||||
|     this.formDate = this.calculateFormDate(model.notAfter); | ||||
|     this.cachedTtl = model.ttl || ''; | ||||
|     if (model.notAfter) { | ||||
|       this.groupValue = 'specificDate'; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   calculateFormDate(value: string) { | ||||
|     // API expects and returns full ISO string | ||||
|     // but the form input only accepts yyyy-MM-dd format | ||||
|     if (value) { | ||||
|       return format(new Date(value), 'yyyy-MM-dd'); | ||||
|     } | ||||
|     return ''; | ||||
|   } | ||||
|  | ||||
|   @action onRadioButtonChange(selection: string) { | ||||
|     this.groupValue = selection; | ||||
|     // Clear the previous selection if they have clicked the other radio button. | ||||
|     if (selection === 'specificDate') { | ||||
|       this.args.model.ttl = ''; | ||||
|       this.args.model.notAfter = this.cachedNotAfter; | ||||
|       this.formDate = this.calculateFormDate(this.cachedNotAfter); | ||||
|     } | ||||
|     if (selection === 'ttl') { | ||||
|       this.args.model.notAfter = ''; | ||||
|       this.args.model.ttl = this.cachedTtl; | ||||
|       this.formDate = ''; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @action setAndBroadcastTtl(ttlObject: { enabled: boolean; goSafeTimeString: string }) { | ||||
|     const { enabled, goSafeTimeString } = ttlObject; | ||||
|     if (this.groupValue === 'specificDate') { | ||||
|       // do not save ttl on the model unless the ttl radio button is selected | ||||
|       return; | ||||
|     } | ||||
|     const ttlVal = enabled === true ? goSafeTimeString : 0; | ||||
|     this.cachedTtl = ttlVal; | ||||
|     this.args.model.ttl = ttlVal; | ||||
|   } | ||||
|  | ||||
|   @action setAndBroadcastInput(evt: HTMLElementEvent<HTMLInputElement>) { | ||||
|     const setDate = evt.target.valueAsDate?.toISOString(); | ||||
|     if (!setDate) return; | ||||
|  | ||||
|     this.cachedNotAfter = setDate; | ||||
|     this.args.model.notAfter = setDate; | ||||
|     this.formDate = this.calculateFormDate(setDate); | ||||
|   } | ||||
| } | ||||
| @@ -1,26 +1,14 @@ | ||||
| <PageHeader as |p|> | ||||
|   <p.top> | ||||
|     <KeyValueHeader | ||||
|       @root={{hash label="role" text="role" path="vault.cluster.secrets.backend.pki.roles.index"}} | ||||
|       @isEngine={{true}} | ||||
|     > | ||||
|       <li> | ||||
|         <span class="sep"> | ||||
|           / | ||||
|         </span> | ||||
|         <LinkTo @route="roles.index"> | ||||
|           {{@model.backend}} | ||||
|         </LinkTo> | ||||
|       </li> | ||||
|     </KeyValueHeader> | ||||
|     <Page::Breadcrumbs @breadcrumbs={{this.breadcrumbs}} /> | ||||
|   </p.top> | ||||
|   <p.levelLeft> | ||||
|     <h1 class="title is-3"> | ||||
|     <h1 class="title is-3" data-test-role-details-title> | ||||
|       {{#if @model.isNew}} | ||||
|         Create a PKI role | ||||
|       {{else}} | ||||
|         Edit a | ||||
|         {{@model.id}} | ||||
|         Edit | ||||
|         {{@model.name}} | ||||
|       {{/if}} | ||||
|     </h1> | ||||
|   </p.levelLeft> | ||||
| @@ -43,7 +31,7 @@ | ||||
|               @modelValidations={{this.modelValidations}} | ||||
|               @showHelpText={{false}} | ||||
|             > | ||||
|               <RadioSelectTtlOrString @attr={{attr}} @model={{@model}} /> | ||||
|               <PkiNotValidAfterForm @attr={{attr}} @model={{@model}} /> | ||||
|             </FormField> | ||||
|           {{/each}} | ||||
|         {{else}} | ||||
|   | ||||
| @@ -27,6 +27,19 @@ export default class PkiRoleForm extends Component { | ||||
|   @tracked invalidFormAlert; | ||||
|   @tracked modelValidations; | ||||
|  | ||||
|   get breadcrumbs() { | ||||
|     const backend = this.args.model.backend || 'pki'; | ||||
|     const crumbs = [ | ||||
|       { label: 'secrets', route: 'secrets', linkExternal: true }, | ||||
|       { label: backend, route: 'overview' }, | ||||
|       { label: 'roles', route: 'roles.index' }, | ||||
|     ]; | ||||
|     if (!this.args.model.isNew) { | ||||
|       crumbs.push({ label: this.args.model.id, route: 'roles.role.details' }, { label: 'edit' }); | ||||
|     } | ||||
|     return crumbs; | ||||
|   } | ||||
|  | ||||
|   @task | ||||
|   *save(event) { | ||||
|     event.preventDefault(); | ||||
|   | ||||
| @@ -1,14 +1,11 @@ | ||||
| import Route from '@ember/routing/route'; | ||||
| import { inject as service } from '@ember/service'; | ||||
| import { withConfirmLeave } from 'core/decorators/confirm-leave'; | ||||
| import PkiRolesIndexRoute from '.'; | ||||
|  | ||||
| export default class PkiRolesCreateRoute extends Route { | ||||
| @withConfirmLeave() | ||||
| export default class PkiRolesCreateRoute extends PkiRolesIndexRoute { | ||||
|   @service store; | ||||
|   @service secretMountPath; | ||||
|   @service pathHelp; | ||||
|  | ||||
|   beforeModel() { | ||||
|     return this.pathHelp.getNewModel('pki/role', 'pki'); | ||||
|   } | ||||
|  | ||||
|   model() { | ||||
|     return this.store.createRecord('pki/role', { | ||||
|   | ||||
| @@ -1,3 +1,13 @@ | ||||
| import Route from '@ember/routing/route'; | ||||
| import { withConfirmLeave } from 'core/decorators/confirm-leave'; | ||||
| import PkiRolesIndexRoute from '../index'; | ||||
|  | ||||
| export default class PkiRoleEditRoute extends Route {} | ||||
| @withConfirmLeave() | ||||
| export default class PkiRoleEditRoute extends PkiRolesIndexRoute { | ||||
|   model() { | ||||
|     const { role } = this.paramsFor('roles/role'); | ||||
|     return this.store.queryRecord('pki/role', { | ||||
|       backend: this.secretMountPath.currentPath, | ||||
|       id: role, | ||||
|     }); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1 +1,5 @@ | ||||
| roles.role.edit | ||||
| <PkiRoleForm | ||||
|   @model={{this.model}} | ||||
|   @onCancel={{transition-to "vault.cluster.secrets.backend.pki.roles.role.details" this.model.id}} | ||||
|   @onSave={{transition-to "vault.cluster.secrets.backend.pki.roles.role.details" this.model.id}} | ||||
| /> | ||||
							
								
								
									
										10
									
								
								ui/tests/helpers/pki/pki-not-valid-after-form.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								ui/tests/helpers/pki/pki-not-valid-after-form.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | ||||
| export const SELECTORS = { | ||||
|   radioTtl: '[data-test-radio-button="ttl"]', | ||||
|   radioTtlLabel: '[data-test-radio-label="ttl"]', | ||||
|   radioDate: '[data-test-radio-button="not_after"]', | ||||
|   radioDateLabel: '[data-test-radio-label="specificDate"]', | ||||
|   ttlForm: '[data-test-ttl-inputs]', | ||||
|   ttlTimeInput: '[data-test-ttl-value="TTL"]', | ||||
|   ttlUnitInput: '[data-test-select="ttl-unit"]', | ||||
|   dateInput: '[data-test-input="not_after"]', | ||||
| }; | ||||
| @@ -6,4 +6,5 @@ export const SELECTORS = { | ||||
|   noStoreValue: '[data-test-value-div="Store in storage backend"]', | ||||
|   keyUsageValue: '[data-test-value-div="Key usage"]', | ||||
|   extKeyUsageValue: '[data-test-value-div="Ext key usage"]', | ||||
|   customTtlValue: '[data-test-value-div="Issued certificates expire after"]', | ||||
| }; | ||||
|   | ||||
| @@ -2,7 +2,7 @@ import { module, test } from 'qunit'; | ||||
| import { resolve } from 'rsvp'; | ||||
| import Service from '@ember/service'; | ||||
| import { setupRenderingTest } from 'ember-qunit'; | ||||
| import { render, triggerEvent } from '@ember/test-helpers'; | ||||
| import { render, settled, triggerEvent } from '@ember/test-helpers'; | ||||
| import hbs from 'htmlbars-inline-precompile'; | ||||
|  | ||||
| const VALUE = 'test value'; | ||||
| @@ -264,4 +264,18 @@ module('Integration | Component | InfoTableRow', function (hooks) { | ||||
|  | ||||
|     assert.dom('[data-test-value-div]').hasText(yearString, 'Renders date with passed format'); | ||||
|   }); | ||||
|  | ||||
|   test('Formats the value as TTL when formatTtl present', async function (assert) { | ||||
|     this.set('value', 6000); | ||||
|     await render(hbs`<InfoTableRow | ||||
|       @label={{this.label}} | ||||
|       @value={{this.value}} | ||||
|       @formatTtl={{true}} | ||||
|     />`); | ||||
|  | ||||
|     assert.dom('[data-test-value-div]').hasText('100m', 'Translates number value to largest unit'); | ||||
|     this.set('value', '45m'); | ||||
|     await settled(); | ||||
|     assert.dom('[data-test-value-div]').hasText('45m', 'Renders non-number values as-is'); | ||||
|   }); | ||||
| }); | ||||
|   | ||||
| @@ -0,0 +1,133 @@ | ||||
| import { module, test } from 'qunit'; | ||||
| import { setupRenderingTest } from 'ember-qunit'; | ||||
| import { render, click, fillIn } from '@ember/test-helpers'; | ||||
| import { hbs } from 'ember-cli-htmlbars'; | ||||
| import { setupEngine } from 'ember-engines/test-support'; | ||||
| import { SELECTORS } from 'vault/tests/helpers/pki/pki-not-valid-after-form'; | ||||
|  | ||||
| module('Integration | Component | pki-not-valid-after-form', function (hooks) { | ||||
|   setupRenderingTest(hooks); | ||||
|   setupEngine(hooks, 'pki'); | ||||
|  | ||||
|   hooks.beforeEach(function () { | ||||
|     this.store = this.owner.lookup('service:store'); | ||||
|     this.model = this.store.createRecord('pki/role', { backend: 'pki' }); | ||||
|     this.attr = { | ||||
|       helpText: '', | ||||
|       options: { | ||||
|         helperTextEnabled: 'toggled on and shows text', | ||||
|       }, | ||||
|     }; | ||||
|   }); | ||||
|  | ||||
|   test('it should render the component with ttl selected by default', async function (assert) { | ||||
|     assert.expect(3); | ||||
|     await render( | ||||
|       hbs` | ||||
|       <div class="has-top-margin-xxl"> | ||||
|         <PkiNotValidAfterForm | ||||
|           @model={{this.model}} | ||||
|           @attr={{this.attr}} | ||||
|         /> | ||||
|        </div> | ||||
|   `, | ||||
|       { owner: this.engine } | ||||
|     ); | ||||
|     assert.dom(SELECTORS.ttlForm).exists('shows the TTL picker'); | ||||
|     assert.dom(SELECTORS.ttlTimeInput).hasValue('', 'default TTL is empty'); | ||||
|     assert.dom(SELECTORS.radioTtl).isChecked('ttl is selected by default'); | ||||
|   }); | ||||
|  | ||||
|   test('it clears and resets model properties from cache when changing radio selection', async function (assert) { | ||||
|     await render( | ||||
|       hbs` | ||||
|       <div class="has-top-margin-xxl"> | ||||
|         <PkiNotValidAfterForm | ||||
|           @model={{this.model}} | ||||
|           @attr={{this.attr}} | ||||
|         /> | ||||
|        </div> | ||||
|   `, | ||||
|       { owner: this.engine } | ||||
|     ); | ||||
|     assert.dom(SELECTORS.radioTtl).isChecked('notBeforeDate radio is selected'); | ||||
|     assert.dom(SELECTORS.ttlForm).exists({ count: 1 }, 'shows TTL form'); | ||||
|     assert.dom(SELECTORS.radioDate).isNotChecked('NotAfter selection not checked'); | ||||
|     assert.dom(SELECTORS.dateInput).doesNotExist('does not show date input field'); | ||||
|  | ||||
|     await click(SELECTORS.radioDateLabel); | ||||
|  | ||||
|     assert.dom(SELECTORS.radioDate).isChecked('selects NotAfter radio when label clicked'); | ||||
|     assert.dom(SELECTORS.dateInput).exists({ count: 1 }, 'shows date input field'); | ||||
|     assert.dom(SELECTORS.radioTtl).isNotChecked('notBeforeDate radio is deselected'); | ||||
|     assert.dom(SELECTORS.ttlForm).doesNotExist('hides TTL form'); | ||||
|  | ||||
|     const utcDate = '1994-11-05'; | ||||
|     const notAfterExpected = '1994-11-05T00:00:00.000Z'; | ||||
|     const ttlDate = 1; | ||||
|     await fillIn('[data-test-input="not_after"]', utcDate); | ||||
|     assert.strictEqual( | ||||
|       this.model.notAfter, | ||||
|       notAfterExpected, | ||||
|       'sets the model property notAfter when this value is selected and filled in.' | ||||
|     ); | ||||
|     await click('[data-test-radio-button="ttl"]'); | ||||
|     assert.strictEqual( | ||||
|       this.model.notAfter, | ||||
|       '', | ||||
|       'The notAfter is cleared on the model because the radio button was selected.' | ||||
|     ); | ||||
|     await fillIn('[data-test-ttl-value="TTL"]', ttlDate); | ||||
|     assert.strictEqual( | ||||
|       this.model.ttl, | ||||
|       '1s', | ||||
|       'The ttl is now saved on the model because the radio button was selected.' | ||||
|     ); | ||||
|  | ||||
|     await click('[data-test-radio-button="not_after"]'); | ||||
|     assert.strictEqual(this.model.ttl, '', 'TTL is cleared after radio select.'); | ||||
|     assert.strictEqual(this.model.notAfter, notAfterExpected, 'notAfter gets populated from local cache'); | ||||
|   }); | ||||
|   test('Form renders properly for edit when TTL present', async function (assert) { | ||||
|     this.model = this.store.createRecord('pki/role', { backend: 'pki', ttl: 6000 }); | ||||
|     await render( | ||||
|       hbs` | ||||
|       <div class="has-top-margin-xxl"> | ||||
|         <PkiNotValidAfterForm | ||||
|           @model={{this.model}} | ||||
|           @attr={{this.attr}} | ||||
|         /> | ||||
|        </div> | ||||
|   `, | ||||
|       { owner: this.engine } | ||||
|     ); | ||||
|     assert.dom(SELECTORS.radioTtl).isChecked('notBeforeDate radio is selected'); | ||||
|     assert.dom(SELECTORS.ttlForm).exists({ count: 1 }, 'shows TTL form'); | ||||
|     assert.dom(SELECTORS.radioDate).isNotChecked('NotAfter selection not checked'); | ||||
|     assert.dom(SELECTORS.dateInput).doesNotExist('does not show date input field'); | ||||
|  | ||||
|     assert.dom(SELECTORS.ttlTimeInput).hasValue('100', 'TTL value is correctly shown'); | ||||
|     assert.dom(SELECTORS.ttlUnitInput).hasValue('m', 'TTL unit is correctly shown'); | ||||
|   }); | ||||
|   test('Form renders properly for edit when notAfter present', async function (assert) { | ||||
|     const utcDate = '1994-11-05T00:00:00.000Z'; | ||||
|     this.model = this.store.createRecord('pki/role', { backend: 'pki', notAfter: utcDate }); | ||||
|     await render( | ||||
|       hbs` | ||||
|       <div class="has-top-margin-xxl"> | ||||
|         <PkiNotValidAfterForm | ||||
|           @model={{this.model}} | ||||
|           @attr={{this.attr}} | ||||
|         /> | ||||
|        </div> | ||||
|   `, | ||||
|       { owner: this.engine } | ||||
|     ); | ||||
|     assert.dom(SELECTORS.radioDate).isChecked('notAfter radio is selected'); | ||||
|     assert.dom(SELECTORS.dateInput).exists({ count: 1 }, 'shows date picker'); | ||||
|     assert.dom(SELECTORS.radioTtl).isNotChecked('ttl radio not selected'); | ||||
|     assert.dom(SELECTORS.ttlForm).doesNotExist('does not show date TTL picker'); | ||||
|     // Due to timezones, can't check specific match on input date | ||||
|     assert.dom(SELECTORS.dateInput).hasAnyValue('date input shows date'); | ||||
|   }); | ||||
| }); | ||||
| @@ -1,87 +0,0 @@ | ||||
| import { module, test } from 'qunit'; | ||||
| import { setupRenderingTest } from 'ember-qunit'; | ||||
| import { render, click, fillIn } from '@ember/test-helpers'; | ||||
| import { hbs } from 'ember-cli-htmlbars'; | ||||
| import { setupEngine } from 'ember-engines/test-support'; | ||||
|  | ||||
| module('Integration | Component | radio-select-ttl-or-string', function (hooks) { | ||||
|   setupRenderingTest(hooks); | ||||
|   setupEngine(hooks, 'pki'); | ||||
|  | ||||
|   hooks.beforeEach(function () { | ||||
|     this.store = this.owner.lookup('service:store'); | ||||
|     this.model = this.store.createRecord('pki/role'); | ||||
|     this.model.backend = 'pki'; | ||||
|     this.attr = { | ||||
|       helpText: '', | ||||
|       options: { | ||||
|         helperTextEnabled: 'toggled on and shows text', | ||||
|       }, | ||||
|     }; | ||||
|   }); | ||||
|  | ||||
|   test('it should render the component and init with ttl selected', async function (assert) { | ||||
|     assert.expect(3); | ||||
|     await render( | ||||
|       hbs` | ||||
|       <div class="has-top-margin-xxl"> | ||||
|         <RadioSelectTtlOrString | ||||
|           @model={{this.model}} | ||||
|           @attr={{this.attr}} | ||||
|         /> | ||||
|        </div> | ||||
|   `, | ||||
|       { owner: this.engine } | ||||
|     ); | ||||
|     assert.dom('[data-test-ttl-inputs]').exists('shows the TTL component'); | ||||
|     assert.dom('[data-test-ttl-value]').hasValue('', 'default TTL is empty'); | ||||
|     assert.dom('[data-test-radio-button="ttl"]').isChecked('ttl is selected by default'); | ||||
|   }); | ||||
|  | ||||
|   test('it should set the model properties ttl or notAfter based on the radio button selections', async function (assert) { | ||||
|     assert.expect(7); | ||||
|     await render( | ||||
|       hbs` | ||||
|       <div class="has-top-margin-xxl"> | ||||
|         <RadioSelectTtlOrString | ||||
|           @model={{this.model}} | ||||
|           @attr={{this.attr}} | ||||
|         /> | ||||
|        </div> | ||||
|   `, | ||||
|       { owner: this.engine } | ||||
|     ); | ||||
|     assert.dom('[data-test-input="not_after"]').doesNotExist('does not show input field on initial render'); | ||||
|  | ||||
|     await click('[data-test-radio-button="not_after"]'); | ||||
|     assert | ||||
|       .dom('[data-test-input="not_after"]') | ||||
|       .exists('does show input field after clicking the radio button'); | ||||
|  | ||||
|     const utcDate = '1994-11-05T08:15:30-05:0'; | ||||
|     const ttlDate = 1; | ||||
|     await fillIn('[data-test-input="not_after"]', utcDate); | ||||
|     assert.strictEqual( | ||||
|       this.model.notAfter, | ||||
|       utcDate, | ||||
|       'sets the model property notAfter when this value is selected and filled in.' | ||||
|     ); | ||||
|  | ||||
|     await click('[data-test-radio-button="ttl"]'); | ||||
|     assert.strictEqual( | ||||
|       this.model.notAfter, | ||||
|       '', | ||||
|       'The notAfter is cleared on the model because the radio button was selected.' | ||||
|     ); | ||||
|     await fillIn('[data-test-ttl-value="TTL"]', ttlDate); | ||||
|     assert.strictEqual( | ||||
|       this.model.ttl, | ||||
|       '1s', | ||||
|       'The ttl is now saved on the model because the radio button was selected.' | ||||
|     ); | ||||
|  | ||||
|     await click('[data-test-radio-button="not_after"]'); | ||||
|     assert.strictEqual(this.model.ttl, '', 'TTL is cleared after radio select.'); | ||||
|     assert.strictEqual(this.model.notAfter, '', 'notAfter is cleared after radio select.'); | ||||
|   }); | ||||
| }); | ||||
| @@ -13,15 +13,16 @@ module('Integration | Component | pki role details page', function (hooks) { | ||||
|     this.store = this.owner.lookup('service:store'); | ||||
|     this.model = this.store.createRecord('pki/role', { | ||||
|       name: 'Foobar', | ||||
|       backend: 'pki', | ||||
|       noStore: false, | ||||
|       keyUsage: [], | ||||
|       extKeyUsage: ['bar', 'baz'], | ||||
|       ttl: 600, | ||||
|     }); | ||||
|     this.model.backend = 'pki'; | ||||
|   }); | ||||
| 
 | ||||
|   test('it should render the page component', async function (assert) { | ||||
|     assert.expect(7); | ||||
|     assert.expect(8); | ||||
|     await render( | ||||
|       hbs` | ||||
|       <Page::PkiRoleDetails @role={{this.model}} /> | ||||
| @@ -38,5 +39,25 @@ module('Integration | Component | pki role details page', function (hooks) { | ||||
|       .dom(SELECTORS.extKeyUsageValue) | ||||
|       .hasText('bar, baz,', 'Key usage shows comma-joined values when array has items'); | ||||
|     assert.dom(SELECTORS.noStoreValue).containsText('Yes', 'noStore shows opposite of what the value is'); | ||||
|     assert.dom(SELECTORS.customTtlValue).containsText('10m', 'TTL shown as duration'); | ||||
|   }); | ||||
| 
 | ||||
|   test('it should render the notAfter date if present', async function (assert) { | ||||
|     assert.expect(1); | ||||
|     this.model = this.store.createRecord('pki/role', { | ||||
|       name: 'Foobar', | ||||
|       backend: 'pki', | ||||
|       noStore: false, | ||||
|       keyUsage: [], | ||||
|       extKeyUsage: ['bar', 'baz'], | ||||
|       notAfter: '2030-05-04T12:00:00.000Z', | ||||
|     }); | ||||
|     await render( | ||||
|       hbs` | ||||
|       <Page::PkiRoleDetails @role={{this.model}} /> | ||||
|   `,
 | ||||
|       { owner: this.engine } | ||||
|     ); | ||||
|     assert.dom(SELECTORS.customTtlValue).containsText('May', 'Formats the notAfter date instead of TTL'); | ||||
|   }); | ||||
| }); | ||||
		Reference in New Issue
	
	Block a user
	 Chelsea Shaw
					Chelsea Shaw