UI: [VAULT-19096] Customizable banners (#23945)

* UI: [VAULT-21521] Initial config-ui engine and routes set up (#23922)

* UI: [VAULT-21526] Create adapter, serializer, and model files (#23947)

* UI: [VAULT-21588] Add Custom Messages to the sidebar (#23946)

* UI: [VAULT-21527] Mirage setup (#24000)

* UI: [VAULT-21530] Custom Messages List View w/ Pagination and LazyPaginatedQuery (#24133)

* UI: Add list to adapter query param (#24187)

* UI: [VAULT-21532] Create message (#24407)

* WIP create message

* Add breadcrumns

* Create and edit form

* Add save to create/edit form

* Add cancel and todo

* Fix cancel route

* Fix breadcrumb label to be title case

* add start time logic

* Update breadcrumb

* Fix breadcrumbs and merge conflict test

* Update create form description

* Fix sidenav so it always highlights

* Fix up forms

* Mostly working create form

* Form cleanup

* Fix link title and href form fields

* Default startTime

* Fix messages

* Update dropdown to use the updated ConfirmAction component

* Update create and edit form

* Add wip tests

* Fix breadcrumb formatter

* Comment out test

* Update create message test

* Update more tests

* Add comment for fixing date on edit

* Update Message form

* Code cleanup!

* Add validation tests

* Remove authenticated from route model

* SOme more code cleanup

* Add controller so authenticated is parsed

* Working radio buttons

* Use an object instead of arrays

* Wip date form

* Fix license headers

* Fix license headers addition of files

* Fix copyright format issues and clean up code

* Fix tests

* Rename FormField radio getter and ay11 improvements

* Address feedback

* Fix specific date so it remembers the values

* Address feedback!

* Update more form fields

* Use formfield action instead

* Update to every

* Update syntax of onchange

* Fix tests

* Update willDestroy so it doesnt break tests

* Remove set and brodcast datetimelocal

* Put FormField back the way it was in favor of putting FormField to a seperate PR

* Remove getter in formfield component file

* Address more feedback

* Put back test

* Update datetime string format var name and location

* UI: [VAULT-21534 VAULT-21533 VAULT-21536] edit, preview, and delete custom message (#24603)

* Working edit

* VAULT-21536 update delete message and create/update flash message

* VAULT-21533 add preview modal

* Update serializer

* Preview refinements

* Move preview to its own component

* Move breadcrumbs to setupController

* Add more tests

* Address some feedback

* Address more feedback!

* Update serailizer

* Remove stylesheet

* Add comment

* UI: [VAULT-21435] Message details (#24645)

* WIP

* Fix timezone bug

* Fix date issues on create/edit form

* Add details screen

* Use allFields instead of formFields

* Fix tests

* Address comments!

* UI: VAULT-21538 unauth endpoint message display (#24665)

* WIP unauth display

* Add modal custom message

* Close multiple modals

* Update todo with ticket number

* On init make custom message request

* Use serializer

* Update fetchMessages

* Add copyright headers

* Add services and serializers

* Send null instead of empty strings

* Fix tests!

* Add copywrite headers

* Add some acceptance tests

* Test cleanup

* Put tests back

* pass hooks to module

* Move module out

* Seperate tests

* Copywrite

* Add aria-prohibited-attr runList options

* Code cleanup

* Add date-time-local transform

* Add copyright headers

* Remove comments

* Remove date transform stuff for now!

* Put getISODateFormat back into the serailize function

* UI: Date time local transform (#24694)

* Date time local

* Add deserialize

* Add copyright header

* check if date exists

* Use parseISO for date strings since datefns requires this in new update

* Update tests

* Ensure we cehck for an ISOString

* Add checks so tests wont fail

* Update parseISO

* Address feedback

* UI: multiple banner message on create and edit form (#24742)

* WIP multiple banner message on create and edit form

* Fix tests

* Put checks back

* Add try/catch to query

* Fix breadcrumbs

* Add page size to pagination

* Add multiple modal message tests

* Address feedback

* Check for valid form first

* Add extra checks

* Address feedback

* Move getter to the route

* Fix tests!

* Address more feedback

* Use still when cancelling

* Update multiple banner modal

* Fix tests

* Set user confirmation to empty string

* UI: VAULT-21539 auth messages display (#24842)

* WIP auth message display

* Move block to show only when authenticated

* VAULT-22046 working search by name

* Some code clean up

* Fix merge conflict

* Add tests

* Fetch messages again after creation

* UI: [VAULT-22908] Update kv object editor, add max number of messages reached modal, small improvements (#24918)

* Update kv object editor to only use a single row

* continute using kv editype

* Fix failing dashboard tests!

* Fix failing test on sidebranch

* Fix tests and update validations

* Add optional tag

* Address feedback

* Add documentation

* Clear messages when logging out

* Fix tests!

* Add 100 message limit modal

* Add max message modal test

* Do more checks!

* Pair with Claire on the refactor of validator!

* Only show validationerror for multiple rows

* Update pageSize to 100 since when paginations are active it causes accessbility errors

* Fix tests!

* Add links to test

* Make banners dismissable

* Add cancel button

* Address feedback!

* Update test selectors

* Update validator

* Remove validations check in kvobjecteditor

* Revert validationError in kvobjecteditor template

* Put back if/else statements for link

* Add changelog

* UI: fix link bug and add colors (#24977)

* Fix edit bug and put transform back

* Edit badgeColor

* Add tests

* Revert changes to transform

* Edit badge colors

* remove universal object transform

* Update changelog filename

* UI: Add form inline warning (#24986)

* Add form inline warning

* Remove title

* Only show form warning for unauth

* Address feedback!
This commit is contained in:
Kianna
2024-01-23 15:04:17 -08:00
committed by GitHub
parent b49c673ef5
commit b85365e980
64 changed files with 2452 additions and 22 deletions

3
changelog/23945.txt Normal file
View File

@@ -0,0 +1,3 @@
```release-note:feature
**Custom messages**: Introduces custom messages settings, allowing users to view, and operators to configure system-wide messages.
```

View File

@@ -0,0 +1,27 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import ApplicationAdapter from '../application';
export default class MessageAdapter extends ApplicationAdapter {
pathForType() {
return 'config/ui/custom-messages';
}
query(store, type, query) {
const { authenticated } = query;
return super.query(store, type, { authenticated, list: true });
}
queryRecord(store, type, id) {
return this.ajax(`${this.buildURL(type)}/${id}`, 'GET');
}
updateRecord(store, type, snapshot) {
return this.ajax(`${this.buildURL(type)}/${snapshot.record.id}`, 'POST', {
data: this.serialize(snapshot.record),
});
}
}

View File

@@ -13,6 +13,11 @@ export default class App extends Application {
podModulePrefix = config.podModulePrefix;
Resolver = Resolver;
engines = {
configUi: {
dependencies: {
services: ['auth', 'flash-messages', 'namespace', 'router', 'store', 'version', 'customMessages'],
},
},
openApiExplorer: {
dependencies: {
services: ['auth', 'flash-messages', 'namespace', 'router', 'version'],

View File

@@ -105,4 +105,13 @@
data-test-sidebar-nav-link="Seal Vault"
/>
{{/if}}
{{#if (has-permission "settings")}}
<Nav.Title data-test-sidebar-nav-heading="Settings">Settings</Nav.Title>
<Nav.Link
@route="vault.cluster.config-ui.messages"
@text="Custom Messages"
data-test-sidebar-nav-link="Custom Messages"
/>
{{/if}}
</Hds::SideNav::Portal>

View File

@@ -16,6 +16,7 @@ export default Controller.extend({
permissions: service(),
namespaceService: service('namespace'),
flashMessages: service(),
customMessages: service(),
vaultVersion: service('version'),
console: service(),

View File

@@ -17,6 +17,7 @@ export default Controller.extend({
version: service(),
auth: service(),
router: service(),
customMessages: service(),
queryParams: [{ authMethod: 'with', oidcProvider: 'o' }],
namespaceQueryParam: alias('clusterController.namespaceQueryParam'),
wrappedToken: alias('vaultController.wrappedToken'),
@@ -52,6 +53,7 @@ export default Controller.extend({
yield timeout(500);
const ns = this.fullNamespaceFromInput(value);
this.namespaceService.setNamespace(ns, true);
this.customMessages.fetchMessages(ns);
this.set('namespaceQueryParam', ns);
}).restartable(),
@@ -67,6 +69,8 @@ export default Controller.extend({
transition = this.router.transitionTo('vault.cluster', { queryParams: { namespace } });
}
transition.followRedirects().then(() => {
this.customMessages.fetchMessages(namespace);
if (isRoot) {
this.auth.set('isRootToken', true);
this.flashMessages.warning(

View File

@@ -0,0 +1,125 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Model, { attr } from '@ember-data/model';
import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
import { isAfter, addDays, startOfDay, parseISO } from 'date-fns';
import { withModelValidations } from 'vault/decorators/model-validations';
import { withFormFields } from 'vault/decorators/model-form-fields';
const validations = {
title: [{ type: 'presence', message: 'Title is required.' }],
message: [{ type: 'presence', message: 'Message is required.' }],
link: [
{
validator(model) {
if (!model?.link) return true;
const [title] = Object.keys(model.link);
const [href] = Object.values(model.link);
return title || href ? !!(title && href) : true;
},
message: 'Link title and url are required.',
},
],
};
@withModelValidations(validations)
@withFormFields(['authenticated', 'type', 'title', 'message', 'link', 'startTime', 'endTime'])
export default class MessageModel extends Model {
@attr('boolean') active;
@attr('string', {
label: 'Type',
editType: 'radio',
possibleValues: [
{
label: 'Alert message',
subText:
'A banner that appears on the top of every page to display brief but high-signal messages like an update or system alert.',
value: 'banner',
},
{
label: 'Modal',
subText: 'A pop-up window used to bring immediate attention for important notifications or actions.',
value: 'modal',
},
],
defaultValue: 'banner',
})
type;
// The authenticated attr is a boolean. The authenticatedString getter and setter is used only in forms to get and set the boolean via
// strings values. The server and query params expects the attr to be boolean values.
@attr({
label: 'Where should we display this message?',
editType: 'radio',
fieldValue: 'authenticatedString',
possibleValues: [
{
label: 'After the user logs in',
subText: 'Display to users after they have successfully logged in to Vault.',
value: 'authenticated',
},
{
label: 'On the login page',
subText: 'Display to users on the login page before they have authenticated.',
value: 'unauthenticated',
},
],
defaultValue: true,
})
authenticated;
get authenticatedString() {
return this.authenticated ? 'authenticated' : 'unauthenticated';
}
set authenticatedString(value) {
this.authenticated = value === 'authenticated' ? true : false;
}
@attr('string')
title;
@attr('string', {
editType: 'textarea',
})
message;
@attr('dateTimeLocal', {
editType: 'dateTimeLocal',
label: 'Message starts',
subText: 'Defaults to 12:00 a.m. the following day (local timezone).',
defaultValue: addDays(startOfDay(new Date()), 1).toISOString(),
})
startTime;
@attr('dateTimeLocal', { editType: 'yield', label: 'Message expires' }) endTime;
@attr('object', {
editType: 'kv',
keyPlaceholder: 'Display text (e.g. Learn more)',
valuePlaceholder: 'Link URL (e.g. https://www.learnmore.com)',
label: 'Link (optional)',
isSingleRow: true,
allowWhiteSpace: true,
})
link;
// date helpers
get isStartTimeAfterToday() {
return isAfter(parseISO(this.startTime), new Date());
}
// capabilities
@lazyCapabilities(apiPath`sys/config/ui/custom-messages`) customMessagesPath;
get canCreateCustomMessages() {
return this.customMessagesPath.get('canCreate') !== false;
}
get canReadCustomMessages() {
return this.customMessagesPath.get('canRead') !== false;
}
get canEditCustomMessages() {
return this.customMessagesPath.get('canUpdate') !== false;
}
get canDeleteCustomMessages() {
return this.customMessagesPath.get('canDelete') !== false;
}
}

View File

@@ -15,6 +15,7 @@ Router.map(function () {
this.route('vault', { path: '/' }, function () {
this.route('cluster', { path: '/:cluster_name' }, function () {
this.route('dashboard');
this.mount('config-ui');
this.mount('sync');
this.route('oidc-provider-ns', { path: '/*namespace/identity/oidc/provider/:provider_name/authorize' });
this.route('oidc-provider', { path: '/identity/oidc/provider/:provider_name/authorize' });

View File

@@ -33,6 +33,7 @@ export default Route.extend(ModelBoundaryRoute, ClusterRoute, {
permissions: service(),
store: service(),
auth: service(),
customMessages: service(),
featureFlagService: service('featureFlag'),
currentCluster: service(),
modelTypes: computed(function () {

View File

@@ -18,6 +18,7 @@ export default Route.extend(ModelBoundaryRoute, {
namespaceService: service('namespace'),
router: service(),
version: service(),
customMessages: service(),
modelTypes: computed(function () {
return ['secret', 'secret-engine'];
@@ -34,6 +35,7 @@ export default Route.extend(ModelBoundaryRoute, {
this.flashMessages.clearMessages();
this.permissions.reset();
this.version.version = null;
this.customMessages.clearCustomMessages();
queryParams.with = authType;
if (ns) {

View File

@@ -0,0 +1,58 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { decodeString, encodeString } from 'core/utils/b64';
import ApplicationSerializer from '../application';
export default class MessageSerializer extends ApplicationSerializer {
attrs = {
active: { serialize: false },
start_time: { serialize: false },
end_time: { serialize: false },
};
normalizeResponse(store, primaryModelClass, payload, id, requestType) {
if (requestType === 'query' && !payload.meta) {
const transformed = this.mapPayload(payload);
return super.normalizeResponse(store, primaryModelClass, transformed, id, requestType);
}
if (requestType === 'queryRecord') {
const transformed = {
...payload.data,
message: decodeString(payload.data.message),
};
return super.normalizeResponse(store, primaryModelClass, transformed, id, requestType);
}
return super.normalizeResponse(store, primaryModelClass, payload, id, requestType);
}
serialize() {
const json = super.serialize(...arguments);
json.message = encodeString(json.message);
return json;
}
mapPayload(payload) {
if (payload.data) {
if (payload.data?.keys && Array.isArray(payload.data.keys)) {
return payload.data.keys.map((key) => {
const data = {
id: key,
...payload.data.key_info[key],
};
if (data.message) data.message = decodeString(data.message);
return data;
});
}
Object.assign(payload, payload.data);
delete payload.data;
}
return payload;
}
extractLazyPaginatedData(payload) {
return this.mapPayload(payload);
}
}

View File

@@ -0,0 +1,63 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { action } from '@ember/object';
import Service, { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { TrackedObject } from 'tracked-built-ins';
export default class CustomMessagesService extends Service {
@service store;
@service namespace;
@service auth;
@tracked messages = [];
@tracked showMessageModal = true;
bannerState = new TrackedObject();
constructor() {
super(...arguments);
this.fetchMessages(this.namespace.path);
}
get bannerMessages() {
if (!this.messages || !this.messages.length) return [];
return this.messages?.filter((message) => message?.type === 'banner');
}
get modalMessages() {
if (!this.messages || !this.messages.length) return [];
return this.messages?.filter((message) => message?.type === 'modal');
}
async fetchMessages(ns) {
try {
const url = this.auth.currentToken
? '/v1/sys/internal/ui/authenticated-messages'
: '/v1/sys/internal/ui/unauthenticated-messages';
const opts = {
method: 'GET',
headers: {},
};
if (this.auth.currentToken) opts.headers['X-Vault-Token'] = this.auth.currentToken;
if (ns) opts.headers['X-Vault-Namespace'] = ns;
const result = await fetch(url, opts);
const body = await result.json();
if (body.errors) return (this.messages = []);
const serializer = this.store.serializerFor('config-ui/message');
this.messages = serializer.mapPayload(body);
this.bannerMessages?.forEach((bm) => (this.bannerState[bm.id] = true));
} catch (e) {
return e;
}
}
clearCustomMessages() {
this.messages = [];
}
@action
onBannerDismiss(id) {
this.bannerState[id] = false;
}
}

View File

@@ -41,6 +41,9 @@ const API_PATHS = {
activity: 'sys/internal/counters/activity',
config: 'sys/internal/counters/config',
},
settings: {
customMessages: 'sys/config/ui/custom-messages',
},
};
const API_PATHS_TO_ROUTE_PARAMS = {

View File

@@ -49,6 +49,11 @@ select.has-error-border,
border: 1px solid $red-500;
}
.error-border-child-inputs input,
.error-border-child-inputs textarea {
border: 1px solid $red-500;
}
// specifically for the SearchSelect dropdown.
.dropdown-has-error-border > div.ember-basic-dropdown-trigger {
border: 1px solid $red-500;

View File

@@ -50,6 +50,11 @@
visibility: hidden;
}
// overflow
.is-overflow-hidden {
overflow: hidden;
}
// width and height
.is-fullwidth {
width: 100%;
@@ -59,6 +64,10 @@
width: 75%;
}
.is-two-thirds-width {
width: 66%;
}
.is-auto-width {
width: auto;
}
@@ -75,6 +84,10 @@
height: 125px;
}
.is-calc-large-height {
height: calc($desktop * 0.66);
}
// float
.is-pulled-left {
float: left !important;

View File

@@ -98,6 +98,10 @@
margin: $spacing-4 0;
}
.has-top-margin-negative-m {
margin-top: -$spacing-16;
}
.has-top-bottom-margin-negative-m {
margin-top: -$spacing-16;
margin-bottom: -$spacing-16;

View File

@@ -2,7 +2,6 @@
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<div class="auth-form" data-test-auth-form>
{{#if (and this.waitingForOktaNumberChallenge (not this.cancelAuthForOktaNumberChallenge))}}
<OktaNumberChallenge

View File

@@ -3,6 +3,45 @@
SPDX-License-Identifier: BUSL-1.1
~}}
<Sidebar::Nav::Cluster />
{{#each this.customMessages.bannerMessages as |bannerMessage|}}
{{#if (get this.customMessages.bannerState bannerMessage.id)}}
<Hds::Alert
@type="inline"
@color="highlight"
data-test-custom-alert={{bannerMessage.id}}
@onDismiss={{fn this.customMessages.onBannerDismiss bannerMessage.id}}
as |A|
>
<A.Title data-test-custom-alert-title={{bannerMessage.id}}>{{bannerMessage.title}}</A.Title>
<A.Description data-test-custom-alert-description={{bannerMessage.id}}>
{{bannerMessage.message}}
{{#unless (is-empty-value bannerMessage.link)}}
{{#each-in bannerMessage.link as |title href|}}
<Hds::Link::Inline @icon="external-link" @isHrefExternal={{true}} @href={{href}}>{{title}}</Hds::Link::Inline>
{{/each-in}}
{{/unless}}
</A.Description>
</Hds::Alert>
{{/if}}
{{/each}}
{{#each this.customMessages.modalMessages as |modalMessage|}}
<Hds::Modal id={{modalMessage.id}} @size="large" @color="warning" data-test-modal={{modalMessage.id}} as |M|>
<M.Header data-test-modal-title={{modalMessage.id}}>
{{modalMessage.title}}
</M.Header>
<M.Body data-test-modal-body={{modalMessage.id}}>
{{modalMessage.message}}
{{#unless (is-empty-value modalMessage.link)}}
{{#each-in modalMessage.link as |title href|}}
<Hds::Link::Inline @icon="external-link" @isHrefExternal={{true}} @href={{href}}>{{title}}</Hds::Link::Inline>
{{/each-in}}
{{/unless}}
</M.Body>
<M.Footer as |F|>
<Hds::Button @text="Confirm" {{on "click" F.close}} data-test-modal-button={{modalMessage.id}} />
</M.Footer>
</Hds::Modal>
{{/each}}
<div class="cluster-banners-wrapper">
{{#if this.activeCluster.version.isEnterprise}}
<LicenseBanners

View File

@@ -0,0 +1,34 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Transform from '@ember-data/serializer/transform';
import { datetimeLocalStringFormat } from 'core/utils/date-formatters';
import { format } from 'date-fns';
export default class DateTimeLocalTransform extends Transform {
getISODateFormat(deserializedDate) {
if (!deserializedDate) return null;
// if the date is a date object or in local date time format ("yyyy-MM-dd'T'HH:mm"), we want to ensure
// it gets converted to an ISOString
if (
typeof deserializedDate === 'object' ||
(typeof deserializedDate === 'string' && !deserializedDate.includes('Z'))
) {
return new Date(deserializedDate).toISOString();
}
return deserializedDate;
}
deserialize(serialized) {
if (!serialized) return null;
return format(new Date(serialized), datetimeLocalStringFormat);
}
serialize(deserialized) {
return this.getISODateFormat(deserialized);
}
}

View File

@@ -0,0 +1,54 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<div class="has-top-padding-xs has-bottom-padding-s is-narrow is-flex-align-baseline">
<RadioButton
class="radio"
name={{@attr.name}}
id="never"
value="never"
@value="never"
@onChange={{fn (mut @message.endTime) ""}}
@groupValue={{this.groupValue}}
/>
<label for="never" class="has-left-margin-xs has-text-black is-size-7">
<span class="has-left-margin-xs">
Never
</span>
<p class="has-left-margin-xs has-text-grey is-size-8">
This message will never expire unless manually deleted by an operator.
</p>
</label>
</div>
<div class="has-top-padding-xs has-bottom-padding-s is-narrow is-flex-align-baseline">
<RadioButton
class="radio"
name={{@attr.name}}
id="specificDate"
value="specificDate"
@value="specificDate"
@onChange={{fn (mut @message.endTime) this.formDateTime}}
@groupValue={{this.groupValue}}
/>
<label for="specificDate" class="has-left-margin-xs has-text-black is-size-7">
<span class="has-left-margin-xs">
Specific date
</span>
<p class="has-left-margin-xs has-text-grey is-size-8">
This message will expire at midnight (local timezone) at the specific date.
</p>
<div class="has-left-margin-xs control">
<Input
@type="datetime-local"
@value={{if this.formDateTime (date-format this.formDateTime this.datetimeLocalStringFormat) ""}}
class="input has-top-margin-xs is-auto-width"
name="endTime"
data-test-input="endTime"
{{on "change" (pipe (pick "target.value") (fn (mut @message.endTime)))}}
/>
</div>
</label>
</div>

View File

@@ -0,0 +1,33 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { datetimeLocalStringFormat } from 'core/utils/date-formatters';
/**
* @module Messages::MessageExpirationDateForm
* Messages::MessageExpirationDateForm components are used to display list of messages.
* @example
* ```js
* <Messages::MessageExpirationDateForm @message={{this.message}} @attr={{attr}} />
* ```
* @param {array} messages - array message objects
*/
export default class MessageExpirationDateForm extends Component {
datetimeLocalStringFormat = datetimeLocalStringFormat;
@tracked groupValue = 'never';
@tracked formDateTime = '';
constructor() {
super(...arguments);
if (this.args.message.endTime) {
this.groupValue = 'specificDate';
this.formDateTime = this.args.message.endTime;
}
}
}

View File

@@ -0,0 +1,117 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<Messages::TabPageHeader
@authenticated={{@message.authenticated}}
@pageTitle="{{if @message.isNew 'Create' 'Edit'}} message"
@breadcrumbs={{@breadcrumbs}}
/>
<form id="message-create-edit-form" {{on "submit" (perform this.save)}} data-test-form="create-and-edit">
<div class="box is-sideless is-fullwidth is-marginless has-top-padding-s">
<Hds::Text::Body @tag="p" class="has-bottom-margin-l" data-test-form-subtext>
{{if @message.isNew "Create" "Edit"}}
a custom message for all users when they access a Vault system via the UI.
</Hds::Text::Body>
<MessageError @errorMessage={{this.errorBanner}} class="has-top-margin-s" />
{{#each @message.formFields as |attr|}}
<FormField @attr={{attr}} @model={{@message}} @modelValidations={{this.modelValidations}} class="has-bottom-margin-m">
<Messages::MessageExpirationDateForm @message={{@message}} @attr={{attr}} />
</FormField>
{{#if (and (eq attr.name "message") (not @message.authenticated))}}
<Hds::Alert class="has-top-margin-negative-m has-bottom-margin-l" @type="compact" @color="highlight" as |A|>
<A.Description>Note: Do not include sensitive info in this message since users are unauthenticated at this stage.</A.Description>
</Hds::Alert>
{{/if}}
{{/each}}
<Hds::ButtonSet class="has-top-margin-s has-bottom-margin-m has-top-margin-xl">
<Hds::Button
@text="Preview"
@color="tertiary"
@icon="eye"
disabled={{and (not @message.title @message.message)}}
{{on "click" (fn (mut this.showMessagePreviewModal) true)}}
data-test-button="preview"
/>
<Hds::Button @text="{{if @message.isNew 'Create' 'Edit'}} message" data-test-button="create-message" type="submit" />
<Hds::Button
@text="Cancel"
@color="secondary"
@route="messages"
@query={{hash authenticated=@message.authenticated}}
/>
</Hds::ButtonSet>
</div>
{{#if this.showMultipleModalsMessage}}
<Hds::Modal
id="multiple-message-modal"
@size="large"
@color="warning"
@onClose={{fn (mut this.showMultipleModalsMessage) false}}
data-test-modal="preview modal"
as |M|
>
<M.Header data-test-modal-title="Warning: more than one modal">
Warning: more than one modal
{{if @message.authenticated "after the user logs in" "on the login page"}}
</M.Header>
<M.Body data-test-modal-body="Warning: more than one modal">
You have an active modal configured
{{if @message.authenticated "after the user logs in" "on the login page"}}
and are trying to create another one. It is recommended to
<b>avoid having more than one modal</b>
at once as it can be intrusive for users. Would you like to continue creating your message? Click “Confirm” to
continue.
</M.Body>
<M.Footer>
<Hds::Button
@text="Confirm"
{{on "click" (fn this.updateUserConfirmation "confirmed")}}
data-test-modal-button="confirm"
/>
<Hds::Button
@text="Cancel"
@color="secondary"
{{on "click" (fn this.updateUserConfirmation "cancel")}}
data-test-modal-button="cancel"
/>
</M.Footer>
</Hds::Modal>
{{/if}}
</form>
{{#if this.showMessagePreviewModal}}
{{#if (eq @message.type "modal")}}
<Hds::Modal
id="message-modal-preview"
@size="large"
@color="warning"
@onClose={{fn (mut this.showMessagePreviewModal) false}}
data-test-modal="preview modal"
as |M|
>
<M.Header data-test-modal-title={{@message.title}}>
{{@message.title}}
</M.Header>
<M.Body data-test-modal-body={{@message.title}}>
{{@message.message}}
{{#if @message.linkHref}}
<Hds::Link::Inline @icon="external-link" @isHrefExternal={{true}} @href={{@message.linkHref}}>
{{@message.linkTitle}}
</Hds::Link::Inline>
{{/if}}
</M.Body>
<M.Footer as |F|>
<Hds::Button @text="Confirm" {{on "click" F.close}} data-test-modal-button="Close" />
</M.Footer>
</Hds::Modal>
{{else}}
<Messages::PreviewImage @message={{@message}} @showMessagePreviewModal={{this.showMessagePreviewModal}} />
{{/if}}
{{/if}}

View File

@@ -0,0 +1,95 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { task, timeout } from 'ember-concurrency';
import errorMessage from 'vault/utils/error-message';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import Ember from 'ember';
/**
* @module Page::CreateAndEditMessageForm
* Page::CreateAndEditMessageForm components are used to display create and edit message form fields.
* @example
* ```js
* <Page::CreateAndEditMessageForm @message={{this.message}} />
* ```
* @param {model} message - message model to pass to form components
*/
export default class MessagesList extends Component {
@service router;
@service store;
@service flashMessages;
@service customMessages;
@service namespace;
@tracked errorBanner = '';
@tracked modelValidations;
@tracked invalidFormMessage;
@tracked showMessagePreviewModal = false;
@tracked showMultipleModalsMessage = false;
@tracked userConfirmation = '';
willDestroy() {
super.willDestroy();
const noTeardown = this.store && !this.store.isDestroying;
const { model } = this;
if (noTeardown && model && model.get('isDirty') && !model.isDestroyed && !model.isDestroying) {
model.rollbackAttributes();
}
}
@task
*save(event) {
event.preventDefault();
try {
this.userConfirmation = '';
const { isValid, state, invalidFormMessage } = this.args.message.validate();
this.modelValidations = isValid ? null : state;
this.invalidFormAlert = invalidFormMessage;
if (this.args.hasSomeActiveModals && this.args.message.type === 'modal') {
this.showMultipleModalsMessage = true;
const isConfirmed = yield this.getUserConfirmation.perform();
if (!isConfirmed) return;
}
if (isValid) {
const { isNew } = this.args.message;
const { id, title } = yield this.args.message.save();
this.flashMessages.success(`Successfully ${isNew ? 'created' : 'updated'} ${title} message.`);
this.store.clearDataset('config-ui/message');
this.customMessages.fetchMessages(this.namespace.path);
this.router.transitionTo('vault.cluster.config-ui.messages.message.details', id);
}
} catch (error) {
this.errorBanner = errorMessage(error);
this.invalidFormAlert = 'There was an error submitting this form.';
}
}
@task
*getUserConfirmation() {
while (true) {
if (Ember.testing) {
return;
}
if (this.userConfirmation) {
return this.userConfirmation === 'confirmed';
}
yield timeout(500);
}
}
@action
updateUserConfirmation(userConfirmation) {
this.userConfirmation = userConfirmation;
this.showMultipleModalsMessage = false;
}
}

View File

@@ -0,0 +1,60 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<Messages::TabPageHeader
@authenticated={{@message.authenticated}}
@pageTitle={{@message.title}}
@breadcrumbs={{@breadcrumbs}}
/>
<Toolbar>
<ToolbarActions aria-label="message delete and edit">
{{#if @message.canDeleteCustomMessages}}
<ConfirmAction
class="toolbar-button"
@buttonColor="secondary"
@onConfirmAction={{this.deleteMessage}}
@confirmTitle="Are you sure?"
@confirmMessage="This will delete this message permanently. You cannot undo this action."
@buttonText="Delete message"
data-test-confirm-action="Delete message"
/>
<div class="toolbar-separator"></div>
{{/if}}
{{#if @message.canEditCustomMessages}}
<LinkTo class="toolbar-link" @route="messages.message.edit" @model={{@message.id}} data-test-link="edit">
Edit message
<Icon @name="chevron-right" />
</LinkTo>
{{/if}}
</ToolbarActions>
</Toolbar>
{{#each @message.allFields as |attr|}}
{{#if (or (eq attr.name "endTime") (eq attr.name "startTime"))}}
{{! if the attr is an endTime and is falsy, we want to show a 'Never' text value }}
<InfoTableRow
@label={{capitalize (humanize (dasherize attr.name))}}
@value={{if
(and (eq attr.name "endTime") (not (get @message attr.name)))
"Never"
(date-format (get @message attr.name) "MMM d, yyyy hh:mm aaa" withTimeZone=true)
}}
/>
{{else if (eq attr.name "link")}}
{{#if (is-empty-value @message.link)}}
<InfoTableRow @label="Link" @value="None" />
{{else}}
{{#each-in @message.link as |title href|}}
<InfoTableRow @label="Link" @value={{title}}>
<Hds::Link::Inline @icon="external-link" @href={{href}} data-test-link="message link">{{title}}</Hds::Link::Inline>
</InfoTableRow>
{{/each-in}}
{{/if}}
{{else}}
<InfoTableRow @label={{capitalize (humanize (dasherize attr.name))}} @value={{get @message attr.name}} />
{{/if}}
{{/each}}

View File

@@ -0,0 +1,35 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
/**
* @module Page::MessageDetails
* Page::MessageDetails components are used to display a message
* @example
* ```js
* <Page::MessageDetails @message={{this.message}} />
* ```
* @param {model} message - message model
*/
export default class MessageDetails extends Component {
@service store;
@service router;
@service flashMessages;
@service customMessages;
@service namespace;
@action
async deleteMessage() {
this.store.clearDataset('config-ui/message');
await this.args.message.destroyRecord(this.args.message.id);
this.router.transitionTo('vault.cluster.config-ui.messages');
this.customMessages.fetchMessages(this.namespace.path);
this.flashMessages.success(`Successfully deleted ${this.args.message.title}.`);
}
}

View File

@@ -0,0 +1,134 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<Messages::TabPageHeader
@authenticated={{@authenticated}}
@pageTitle="Custom messages"
@showTabs={{true}}
@breadcrumbs={{this.breadcrumbs}}
>
<:toolbarFilters>
{{#if @messages.meta.total}}
<FilterInput
aria-label="Search by message title"
placeholder="Search by message title"
value={{@pageFilter}}
@autofocus={{true}}
@wait={{200}}
@onInput={{this.onFilterChange}}
/>
{{/if}}
</:toolbarFilters>
<:toolbarActions>
<Hds::Button
@text="Create message"
@icon="plus"
@color="secondary"
class="toolbar-button"
{{on "click" this.createMessage}}
data-test-button="create message"
aria-label="create message"
/>
</:toolbarActions>
</Messages::TabPageHeader>
{{#if @messages.length}}
{{#each this.formattedMessages as |message|}}
<LinkedBlock
data-test-list-item={{message.id}}
class="list-item-row"
@params={{array "messages.message.details" message.id}}
@linkPrefix="vault.cluster.config-ui"
>
<div class="level is-mobile">
<div class="level-left">
<div>
<Hds::Text::Display @tag="h2" data-linked-block-title={{message.id}}>
<Icon @name="message-circle" class="auto-width" aria-label="message" />
{{message.title}}
</Hds::Text::Display>
<div class="has-top-margin-xs">
<Hds::Badge @text={{message.badgeDisplayText}} @color={{message.badgeColor}} data-test-badge={{message.id}} />
<Hds::Badge
@text={{(capitalize message.type)}}
@color={{message.badgeColor}}
data-test-badge={{message.type}}
/>
</div>
</div>
</div>
<div class="level-right is-flex is-paddingless is-marginless">
<div class="level-item">
<PopupMenu @name="engine-menu">
<nav class="menu">
<ul class="menu-list">
{{#if message.canEditCustomMessages}}
<li class="action">
<LinkTo @route="messages.message.edit" @model={{message.id}}>
Edit
</LinkTo>
</li>
{{/if}}
{{#if message.canDeleteCustomMessages}}
<ConfirmAction
@isInDropdown={{true}}
@buttonText="Delete"
@confirmTitle="Are you sure?"
@confirmMessage="This will delete this message permanently. You cannot undo this action."
@onConfirmAction={{perform this.deleteMessage message}}
/>
{{/if}}
</ul>
</nav>
</PopupMenu>
</div>
</div>
</div>
</LinkedBlock>
{{/each}}
<Hds::Pagination::Numbered
class="has-top-margin-m has-bottom-margin-m"
@currentPage={{@messages.meta.currentPage}}
@currentPageSize={{@messages.meta.pageSize}}
@route="messages.index"
@showSizeSelector={{false}}
@totalItems={{@messages.meta.total}}
@queryFunction={{this.paginationQueryParams}}
/>
{{else}}
<EmptyState
@title="No messages yet"
@message="Add a custom message for all users after they log into Vault. Create message to get started."
>
<Hds::Link::Standalone
@icon="plus"
@iconPosition="leading"
@text="Create message"
@route="messages.create"
@query={{hash authenticated=@authenticated}}
class="is-no-underline"
data-test-action-text="Create message"
/>
</EmptyState>
{{/if}}
{{#if this.showMaxMessageModal}}
<Hds::Modal
id="maximum-message-modal"
@color="warning"
@onClose={{fn (mut this.showMaxMessageModal) false}}
data-test-modal="maximum-message-modal"
as |M|
>
<M.Header data-test-modal-title="maximum-message-modal">
Maximum number of messages reached
</M.Header>
<M.Body data-test-modal-body="maximum-message-modal">
Vault can only store up to 100 messages. To create a message, delete one of your messages to clear up space.
</M.Body>
<M.Footer as |F|>
<Hds::Button @text="Close" {{on "click" F.close}} data-test-modal-button="maximum-message-modal" />
</M.Footer>
</Hds::Modal>
{{/if}}

View File

@@ -0,0 +1,107 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { task } from 'ember-concurrency';
import { dateFormat } from 'core/helpers/date-format';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
/**
* @module Page::MessagesList
* Page::MessagesList components are used to display list of messages.
* @example
* ```js
* <Page::MessagesList @messages={{this.messages}} />
* ```
* @param {array} messages - array message objects
*/
export default class MessagesList extends Component {
@service store;
@service router;
@service flashMessages;
@service namespace;
@service customMessages;
@tracked showMaxMessageModal = false;
get formattedMessages() {
return this.args.messages.map((message) => {
let badgeDisplayText = '';
let badgeColor = 'neutral';
if (message.active) {
if (message.endTime) {
badgeDisplayText = `Active until ${dateFormat([message.endTime, 'MMM d, yyyy hh:mm aaa'], {
withTimeZone: true,
})}`;
} else {
badgeDisplayText = 'Active';
}
badgeColor = 'success';
} else {
if (message.isStartTimeAfterToday) {
badgeDisplayText = `Scheduled: ${dateFormat([message.startTime, 'MMM d, yyyy hh:mm aaa'], {
withTimeZone: true,
})}`;
badgeColor = 'highlight';
} else {
badgeDisplayText = `Inactive: ${dateFormat([message.startTime, 'MMM d, yyyy hh:mm aaa'], {
withTimeZone: true,
})}`;
badgeColor = 'neutral';
}
}
message.badgeDisplayText = badgeDisplayText;
message.badgeColor = badgeColor;
return message;
});
}
get breadcrumbs() {
const label = this.args.authenticated ? 'After User Logs In' : 'On Login Page';
return [{ label: 'Messages' }, { label }];
}
// callback from HDS pagination to set the queryParams page
get paginationQueryParams() {
return (page) => {
return {
page,
};
};
}
@task
*deleteMessage(message) {
this.store.clearDataset('config-ui/message');
yield message.destroyRecord(message.id);
this.router.transitionTo('vault.cluster.config-ui.messages');
this.customMessages.fetchMessages(this.namespace.path);
this.flashMessages.success(`Successfully deleted ${message.title}.`);
}
@action
onFilterChange(pageFilter) {
this.router.transitionTo('vault.cluster.config-ui.messages', {
queryParams: { pageFilter },
});
}
@action
createMessage() {
if (this.args.messages?.meta.total >= 100) {
this.showMaxMessageModal = true;
return;
}
this.router.transitionTo('vault.cluster.config-ui.messages.create', {
queryParams: { authenticated: this.args.authenticated },
});
}
}

View File

@@ -0,0 +1,40 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<Hds::Modal
@onClose={{fn (mut @showMessagePreviewModal) false}}
id="message-alert-preview"
class="is-calc-large-height is-two-thirds-width"
data-test-modal="preview image"
as |M|
>
<M.Body class="is-paddingless is-overflow-hidden">
<Hds::Alert
@type="page"
@color="warning"
@onDismiss={{fn (mut @showMessagePreviewModal) false}}
class="has-bottom-margin-s"
data-test-custom-alert={{@message.title}}
as |A|
>
<A.Title data-test-custom-alert-title={{@message.title}}>{{@message.title}}</A.Title>
<A.Description data-test-custom-alert-description={{@message.title}}>
{{@message.message}}
{{#if @message.linkHref}}
<Hds::Link::Inline @icon="external-link" @href={{@message.linkHref}}>
{{@message.linkTitle}}
</Hds::Link::Inline>
{{/if}}
</A.Description>
</Hds::Alert>
<img
src={{img-path (if @message.authenticated "~/custom-messages-dashboard.png" "~/custom-messages-login.png")}}
alt={{if @message.authenticated "dashboard page preview" "login page preview"}}
/>
</M.Body>
<M.Footer as |F|>
<Hds::Button @text="Close preview" {{on "click" F.close}} data-test-modal-button="Close" />
</M.Footer>
</Hds::Modal>

View File

@@ -0,0 +1,58 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<PageHeader as |p|>
{{#if @breadcrumbs}}
<p.top>
<Page::Breadcrumbs @breadcrumbs={{@breadcrumbs}} />
</p.top>
{{/if}}
<p.levelLeft>
<Hds::Text::Display @tag="h2" @size="500" class="has-top-margin-m" data-test-page-title>
{{@pageTitle}}
</Hds::Text::Display>
</p.levelLeft>
</PageHeader>
{{#if @showTabs}}
<div class="tabs-container box is-bottomless is-marginless is-paddingless">
<nav class="tabs" aria-label="custom-messages">
<ul>
<li>
{{! Explicitly setting page to 1 here since we want to reset the page param on transition}}
<LinkTo
class={{if @authenticated "active"}}
@route="messages"
@query={{hash authenticated=true page=1}}
data-test-custom-messages-tab="After user logs in"
>
After user logs in
</LinkTo>
</li>
<li>
<LinkTo
class={{unless @authenticated "active"}}
@route="messages"
@query={{hash authenticated=false page=1}}
data-test-custom-messages-tab="On login page"
>
On login page
</LinkTo>
</li>
</ul>
</nav>
</div>
{{#if (or (has-block "toolbarFilters") (has-block "toolbarActions"))}}
<Toolbar aria-label="Create message">
<ToolbarFilters aria-label="Create message">
{{yield to="toolbarFilters"}}
</ToolbarFilters>
<ToolbarActions aria-label="Create message">
{{yield to="toolbarActions"}}
</ToolbarActions>
</Toolbar>
{{/if}}
{{/if}}

View File

@@ -0,0 +1,11 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Controller from '@ember/controller';
export default class MessagesController extends Controller {
queryParams = ['authenticated'];
authenticated = true;
}

View File

@@ -0,0 +1,13 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Controller from '@ember/controller';
export default class MessagesController extends Controller {
queryParams = ['authenticated', 'page'];
authenticated = true;
page = 1;
pageFilter = '';
}

View File

@@ -0,0 +1,23 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Engine from '@ember/engine';
import loadInitializers from 'ember-load-initializers';
import Resolver from 'ember-resolver';
import config from './config/environment';
const { modulePrefix } = config;
export default class ConfigUiEngine extends Engine {
modulePrefix = modulePrefix;
Resolver = Resolver;
dependencies = {
services: ['auth', 'store', 'flash-messages', 'namespace', 'router', 'version', 'customMessages'],
};
}
loadInitializers(ConfigUiEngine, modulePrefix);

View File

@@ -0,0 +1,16 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import buildRoutes from 'ember-engines/routes';
export default buildRoutes(function () {
this.route('messages', function () {
this.route('create');
this.route('message', { path: '/:id' }, function () {
this.route('details');
this.route('edit');
});
});
});

View File

@@ -0,0 +1,52 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
export default class MessagesCreateRoute extends Route {
@service store;
queryParams = {
authenticated: {
refreshModel: true,
},
};
async getMessages(authenticated) {
try {
return await this.store.query('config-ui/message', {
authenticated,
});
} catch {
return [];
}
}
async model(params) {
const { authenticated } = params;
const message = this.store.createRecord('config-ui/message', {
authenticated,
});
const messages = await this.getMessages(authenticated);
return {
message,
messages,
authenticated,
hasSomeActiveModals:
messages.length && messages?.some((message) => message.type === 'modal' && message.active),
};
}
setupController(controller, resolvedModel) {
super.setupController(controller, resolvedModel);
controller.breadcrumbs = [
{ label: 'Messages', route: 'messages', query: { authenticated: !!resolvedModel.authenticated } },
{ label: 'Create Message' },
];
}
}

View File

@@ -0,0 +1,55 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { hash } from 'rsvp';
export default class MessagesRoute extends Route {
@service store;
queryParams = {
page: {
refreshModel: true,
},
authenticated: {
refreshModel: true,
},
pageFilter: {
refreshModel: true,
},
};
model(params) {
const { authenticated, page, pageFilter } = params;
const filter = pageFilter
? (dataset) => dataset.filter((item) => item?.title.toLowerCase().includes(pageFilter.toLowerCase()))
: null;
const messages = this.store
.lazyPaginatedQuery('config-ui/message', {
authenticated,
pageFilter: filter,
responsePath: 'data.keys',
page: page || 1,
size: 10,
})
.catch((e) => {
if (e.httpStatus === 404) {
return [];
}
throw e;
});
return hash({
pageFilter,
messages,
});
}
setupController(controller, resolvedModel) {
super.setupController(controller, resolvedModel);
const label = controller.authenticated ? 'After User Logs In' : 'On Login Page';
controller.breadcrumbs = [{ label: 'Messages' }, { label }];
}
}

View File

@@ -0,0 +1,26 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class MessagesMessageDetailsRoute extends Route {
@service store;
model() {
const { id } = this.paramsFor('messages.message');
return this.store.queryRecord('config-ui/message', id);
}
setupController(controller, resolvedModel) {
super.setupController(controller, resolvedModel);
controller.breadcrumbs = [
{ label: 'Messages', route: 'messages', query: { authenticated: resolvedModel.authenticated } },
{ label: resolvedModel.title },
];
}
}

View File

@@ -0,0 +1,37 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { hash } from 'rsvp';
export default class MessagesMessageEditRoute extends Route {
@service store;
getMessages(authenticated = true) {
return this.store.query('config-ui/message', { authenticated }).catch(() => []);
}
async model() {
const { id } = this.paramsFor('messages.message');
const message = await this.store.queryRecord('config-ui/message', id);
const messages = await this.getMessages(message.authenticated);
return hash({
message,
messages,
hasSomeActiveModals:
messages.length && messages?.some((message) => message.type === 'modal' && message.active),
});
}
setupController(controller, resolvedModel) {
super.setupController(controller, resolvedModel);
controller.breadcrumbs = [
{ label: 'Messages', route: 'messages', query: { authenticated: resolvedModel.message.authenticated } },
{ label: 'Edit Message' },
];
}
}

View File

@@ -0,0 +1,11 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<Messages::Page::CreateAndEditMessageForm
@message={{this.model.message}}
@messages={{this.model.messages}}
@hasSomeActiveModals={{this.model.hasSomeActiveModals}}
@breadcrumbs={{this.breadcrumbs}}
/>

View File

@@ -0,0 +1,10 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<Messages::Page::List
@messages={{this.model.messages}}
@authenticated={{this.authenticated}}
@pageFilter={{this.model.pageFilter}}
/>

View File

@@ -0,0 +1,6 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<Messages::Page::Details @message={{this.model}} @breadcrumbs={{this.breadcrumbs}} />

View File

@@ -0,0 +1,11 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<Messages::Page::CreateAndEditMessageForm
@message={{this.model.message}}
@messages={{this.model.messages}}
@hasSomeActiveModals={{this.model.hasSomeActiveModals}}
@breadcrumbs={{this.breadcrumbs}}
/>

View File

@@ -0,0 +1,15 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
'use strict';
module.exports = function (environment) {
const ENV = {
modulePrefix: 'config-ui',
environment: environment,
};
return ENV;
};

17
ui/lib/config-ui/index.js Normal file
View File

@@ -0,0 +1,17 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
/* eslint-disable n/no-extraneous-require */
const { buildEngine } = require('ember-engines/lib/engine-addon');
module.exports = buildEngine({
name: 'config-ui',
lazyLoading: {
enabled: false,
},
isDevelopingAddon() {
return true;
},
});

View File

@@ -0,0 +1,16 @@
{
"name": "config-ui",
"keywords": [
"ember-addon",
"ember-engine"
],
"dependencies": {
"ember-cli-htmlbars": "*",
"ember-cli-babel": "*"
},
"ember-addon": {
"paths": [
"../core"
]
}
}

View File

@@ -137,8 +137,11 @@
@helpText={{@attr.options.helpText}}
@subText={{@attr.options.subText}}
@onKeyUp={{this.handleKeyUp}}
@validationError={{this.validationError}}
class={{if @attr.options.isSectionHeader "form-section"}}
@keyPlaceholder={{@attr.options.keyPlaceholder}}
@valuePlaceholder={{@attr.options.valuePlaceholder}}
@isSingleRow={{@attr.options.isSingleRow}}
@allowWhiteSpace={{@attr.options.allowWhiteSpace}}
class="{{if this.validationError 'error-border-child-inputs'}} {{if @attr.options.isSectionHeader 'form-section'}}"
/>
{{else if (eq @attr.options.editType "file")}}
{{! File Input }}

View File

@@ -53,21 +53,23 @@
/>
{{/if}}
</div>
<div class="column is-1">
{{#if (eq this.kvData.length (inc index))}}
<Hds::Button @text="Add" {{on "click" this.addRow}} data-test-kv-add-row={{index}} @isFullWidth={{true}} />
{{else}}
<Hds::Button
@text="Delete row"
@color="secondary"
{{on "click" (fn this.deleteRow row index)}}
@icon="trash"
@isIconOnly={{true}}
@isFullWidth={{true}}
data-test-kv-delete-row={{index}}
/>
{{/if}}
</div>
{{#unless @isSingleRow}}
<div class="column is-1">
{{#if (eq this.kvData.length (inc index))}}
<Hds::Button @text="Add" {{on "click" this.addRow}} data-test-kv-add-row={{index}} @isFullWidth={{true}} />
{{else}}
<Hds::Button
@text="Delete row"
@color="secondary"
{{on "click" (fn this.deleteRow row index)}}
@icon="trash"
@isIconOnly={{true}}
@isFullWidth={{true}}
data-test-kv-delete-row={{index}}
/>
{{/if}}
</div>
{{/unless}}
</div>
{{#if (includes index this.whitespaceWarningRows)}}
<div class="has-bottom-margin-s">

View File

@@ -27,6 +27,7 @@ import KVObject from 'vault/lib/kv-object';
* @param {string} value - the value is captured from the model.
* @param {function} onChange - function that captures the value on change
* @param {boolean} [isMasked = false] - when true the <MaskedInput> renders instead of the default <textarea> to input the value portion of the key/value object
* @param {boolean} [isSingleRow = false] - when true the kv object editor will only show one row and hide the Add button
* @param {function} [onKeyUp] - function passed in that handles the dom keyup event. Used for validation on the kv custom metadata.
* @param {string} [label] - label displayed over key value inputs
* @param {string} [labelClass] - override default label class in FormFieldLabel component
@@ -55,7 +56,10 @@ export default class KvObjectEditor extends Component {
@action
createKvData(elem, [value]) {
this.kvData = KVObject.create({ content: [] }).fromJSON(value);
this.addRow();
if (!this.args.isSingleRow || !value || Object.keys(value).length < 1) {
this.addRow();
}
}
@action
addRow() {
@@ -86,6 +90,9 @@ export default class KvObjectEditor extends Component {
}
@action
validateKey(rowIndex, event) {
if (this.args.allowWhiteSpace) {
return;
}
const { value } = event.target;
const keyHasWhitespace = new RegExp('\\s', 'g').test(value);
const rows = [...this.whitespaceWarningRows];

View File

@@ -5,6 +5,8 @@
import { format, parseISO } from 'date-fns';
export const datetimeLocalStringFormat = "yyyy-MM-dd'T'HH:mm";
export const ARRAY_OF_MONTHS = [
'January',
'February',

View File

@@ -0,0 +1,164 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
export default function (server) {
server.get('/sys/config/ui/custom-messages', (schema, request) => {
if (request.queryParams.authenticated && JSON.parse(request.queryParams.authenticated)) {
return {
data: {
key_info: {
'01234567-89ab-cdef-0123-456789abcdef': {
title: 'Has expiration date',
type: 'modal',
authenticated: true,
start_time: '2023-10-15T02:36:43.986212308Z',
end_time: '2023-12-17T02:36:43.986212308Z',
active: true,
},
'22234567-89ab-cdef-0123-456789abcdef': {
title: 'No expiration date',
type: 'modal',
authenticated: true,
start_time: '2023-10-15T02:36:43.986212308Z',
end_time: '',
active: true,
},
'76543210-89ab-cdef-0123-456789abcdef': {
title: 'Inactive message',
type: 'banner',
authenticated: true,
start_time: '2023-10-15T02:36:43.986212308Z',
end_time: '2023-11-15T02:36:43.986212308Z',
active: false,
},
'11543210-89ab-cdef-0123-456789abcdef': {
title: 'Inactive, but start time is past current date',
type: 'banner',
authenticated: true,
start_time: '2024-10-15T02:36:43.986212308Z',
end_time: '2024-11-15T02:36:43.986212308Z',
active: false,
},
},
keys: [
'01234567-89ab-cdef-0123-456789abcdef',
'22234567-89ab-cdef-0123-456789abcdef',
'76543210-89ab-cdef-0123-456789abcdef',
'11543210-89ab-cdef-0123-456789abcdef',
],
},
};
}
return {
data: {
key_info: {
'8d6ba39-5c23-50af-3d79-76c26a2845f49': {
title: 'Unauthenticated custom message title',
type: 'modal',
authenticated: false,
start_time: '2023-10-15T02:36:43.986212308Z',
end_time: '2024-10-15T02:36:43.986212308Z',
active: true,
},
'281e580-da16-89c5-4666-16480e4b7c11d': {
title: 'Unauthenticated custom message title two',
type: 'banner',
authenticated: false,
start_time: '2021-10-15T02:36:43.986212308Z',
end_time: '2021-11-15T02:36:43.986212308Z',
active: false,
},
},
keys: ['8d6ba39-5c23-50af-3d79-76c26a2845f49', '281e580-da16-89c5-4666-16480e4b7c11d'],
},
};
});
server.post('/sys/config/ui/custom-messages', () => {
return {
id: '01234567-89ab-cdef-0123-456789abcdef',
data: {
active: true,
start_time: '2023-10-15T02:36:43.986212308Z',
end_time: '2024-10-15T02:36:43.986212308Z',
type: 'modal',
authenticated: false,
},
};
});
server.get('/sys/internal/ui/unauthenticated-messages', () => {
return {
request_id: '664fbad0-fcd8-9023-4c5b-81a7962e9f4b',
lease_id: '',
renewable: false,
lease_duration: 0,
data: {
key_info: {
'02180e3f-bd5b-a851-bcc9-6f7983806df0': {
authenticated: false,
end_time: null,
link: {
title: '',
},
message: 'aGVsbG8gd29ybGQgaGVsbG8gd29scmQ=',
options: null,
start_time: '2024-01-04T08:00:00Z',
title: 'Banner title',
type: 'banner',
},
'a7d7d9b1-a1ca-800c-17c5-0783be88e29c': {
authenticated: false,
end_time: null,
link: {
title: '',
},
message: 'aGVyZSBpcyBhIGNvb2wgbWVzc2FnZQ==',
options: null,
start_time: '2024-01-01T08:00:00Z',
title: 'Modal title',
type: 'modal',
},
},
keys: ['02180e3f-bd5b-a851-bcc9-6f7983806df0', 'a7d7d9b1-a1ca-800c-17c5-0783be88e29c'],
},
wrap_info: null,
warnings: null,
auth: null,
mount_type: '',
};
});
server.get('/sys/internal/ui/authenticated-messages', () => {
return {
data: {
key_info: {
'6543210-89ab-cdef-0123-456780abcieh': {
title: 'Authenticated Title One',
message:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur nulla augue, placerat quis risus blandit, molestie imperdiet massa. Sed blandit rutrum odio quis varius. Fusce purus orci, maximus ac libero.',
type: 'modal',
authenticated: true,
start_time: '2023-10-15T02:36:43.986212308Z',
end_time: '2024-10-15T02:36:43.986212308Z',
options: {},
},
'00123858-89ab-cdef-0123-789037ejhdgt': {
title: 'Authenticated Title One',
message:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur nulla augue, placerat quis risus blandit, molestie imperdiet massa. Sed blandit rutrum odio quis varius. Fusce purus orci, maximus ac libero.',
type: 'banner',
authenticated: true,
start_time: '2021-10-15T02:36:43.986212308Z',
end_time: '2031-10-15T02:36:43.986212308Z',
options: {},
},
},
keys: ['6543210-89ab-cdef-0123-456780abcieh', '00123858-89ab-cdef-0123-789037ejhdgt'],
},
};
});
}

View File

@@ -7,6 +7,7 @@
// individual lookup done in mirage config
import base from './base';
import chrootNamespace from './chroot-namespace';
import customMessages from './custom-messages';
import clients from './clients';
import db from './db';
import hcpLink from './hcp-link';
@@ -32,5 +33,6 @@ export {
mfaLogin,
oidcConfig,
reducedDisclosure,
customMessages,
sync,
};

View File

@@ -235,13 +235,14 @@
"private": true,
"ember-addon": {
"paths": [
"lib/config-ui",
"lib/core",
"lib/css",
"lib/keep-gitkeep",
"lib/kmip",
"lib/kubernetes",
"lib/ldap",
"lib/kv",
"lib/ldap",
"lib/open-api-explorer",
"lib/pki",
"lib/replication",

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -0,0 +1,112 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { click, visit } from '@ember/test-helpers';
import { PAGE } from 'vault/tests/helpers/config-ui/message-selectors';
import { setupMirage } from 'ember-cli-mirage/test-support';
const unauthenticatedMessageResponse = {
request_id: '664fbad0-fcd8-9023-4c5b-81a7962e9f4b',
lease_id: '',
renewable: false,
lease_duration: 0,
data: {
key_info: {
'some-awesome-id-2': {
authenticated: false,
end_time: null,
link: {
'some alert link': 'world',
},
message: 'aGVsbG8gd29ybGQgaGVsbG8gd29scmQ=',
options: null,
start_time: '2024-01-04T08:00:00Z',
title: 'Banner title',
type: 'banner',
},
'some-awesome-id-1': {
authenticated: false,
end_time: null,
message: 'aGVyZSBpcyBhIGNvb2wgbWVzc2FnZQ==',
options: null,
start_time: '2024-01-01T08:00:00Z',
title: 'Modal title',
type: 'modal',
},
},
keys: ['some-awesome-id-2', 'some-awesome-id-1'],
},
wrap_info: null,
warnings: null,
auth: null,
mount_type: '',
};
module('Acceptance | auth custom messages auth tests', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
hooks.beforeEach(function () {
return this.server.get('/sys/internal/ui/mounts', () => ({}));
});
test('it shows the alert banner and modal message', async function (assert) {
this.server.get('/sys/internal/ui/unauthenticated-messages', function () {
return unauthenticatedMessageResponse;
});
await visit('/vault/auth');
const modalId = 'some-awesome-id-1';
const alertId = 'some-awesome-id-2';
assert.dom(PAGE.modal(modalId)).exists();
assert.dom(PAGE.modalTitle(modalId)).hasText('Modal title');
assert.dom(PAGE.modalBody(modalId)).exists();
assert.dom(PAGE.modalBody(modalId)).hasText('here is a cool message');
await click(PAGE.modalButton(modalId));
assert.dom(PAGE.alertTitle(alertId)).hasText('Banner title');
assert.dom(PAGE.alertDescription(alertId)).hasText('hello world hello wolrd some alert link');
});
test('it shows the multiple modal messages', async function (assert) {
const modalIdOne = 'some-awesome-id-2';
const modalIdTwo = 'some-awesome-id-1';
this.server.get('/sys/internal/ui/unauthenticated-messages', function () {
unauthenticatedMessageResponse.data.key_info[modalIdOne].type = 'modal';
unauthenticatedMessageResponse.data.key_info[modalIdOne].title = 'Modal title 1';
unauthenticatedMessageResponse.data.key_info[modalIdTwo].type = 'modal';
unauthenticatedMessageResponse.data.key_info[modalIdTwo].title = 'Modal title 2';
return unauthenticatedMessageResponse;
});
await visit('/vault/auth');
assert.dom(PAGE.modal(modalIdOne)).exists();
assert.dom(PAGE.modalTitle(modalIdOne)).hasText('Modal title 1');
assert.dom(PAGE.modalBody(modalIdOne)).exists();
assert.dom(PAGE.modalBody(modalIdOne)).hasText('hello world hello wolrd some alert link');
await click(PAGE.modalButton(modalIdOne));
assert.dom(PAGE.modal(modalIdTwo)).exists();
assert.dom(PAGE.modalTitle(modalIdTwo)).hasText('Modal title 2');
assert.dom(PAGE.modalBody(modalIdTwo)).exists();
assert.dom(PAGE.modalBody(modalIdTwo)).hasText('here is a cool message');
await click(PAGE.modalButton(modalIdTwo));
});
test('it shows the multiple banner messages', async function (assert) {
const bannerIdOne = 'some-awesome-id-2';
const bannerIdTwo = 'some-awesome-id-1';
this.server.get('/sys/internal/ui/unauthenticated-messages', function () {
unauthenticatedMessageResponse.data.key_info[bannerIdOne].type = 'banner';
unauthenticatedMessageResponse.data.key_info[bannerIdOne].title = 'Banner title 1';
unauthenticatedMessageResponse.data.key_info[bannerIdTwo].type = 'banner';
unauthenticatedMessageResponse.data.key_info[bannerIdTwo].title = 'Banner title 2';
return unauthenticatedMessageResponse;
});
await visit('/vault/auth');
assert.dom(PAGE.alertTitle(bannerIdOne)).hasText('Banner title 1');
assert.dom(PAGE.alertDescription(bannerIdOne)).hasText('hello world hello wolrd some alert link');
assert.dom(PAGE.alertTitle(bannerIdTwo)).hasText('Banner title 2');
assert.dom(PAGE.alertDescription(bannerIdTwo)).hasText('here is a cool message');
});
});

View File

@@ -31,11 +31,49 @@ import connectionPage from 'vault/tests/pages/secrets/backend/database/connectio
import { v4 as uuidv4 } from 'uuid';
import { SELECTORS } from 'vault/tests/helpers/components/dashboard/dashboard-selectors';
import { PAGE } from 'vault/tests/helpers/config-ui/message-selectors';
const consoleComponent = create(consoleClass);
const createNS = async (name) => consoleComponent.runCommands(`write sys/namespaces/${name} -force`);
const authenticatedMessageResponse = {
request_id: '664fbad0-fcd8-9023-4c5b-81a7962e9f4b',
lease_id: '',
renewable: false,
lease_duration: 0,
data: {
key_info: {
'some-awesome-id-2': {
authenticated: true,
end_time: null,
link: {
'some link title': 'www.link.com',
},
message: 'aGVsbG8gd29ybGQgaGVsbG8gd29scmQ=',
options: null,
start_time: '2024-01-04T08:00:00Z',
title: 'Banner title',
type: 'banner',
},
'some-awesome-id-1': {
authenticated: true,
end_time: null,
message: 'aGVyZSBpcyBhIGNvb2wgbWVzc2FnZQ==',
options: null,
start_time: '2024-01-01T08:00:00Z',
title: 'Modal title',
type: 'modal',
},
},
keys: ['some-awesome-id-2', 'some-awesome-id-1'],
},
wrap_info: null,
warnings: null,
auth: null,
mount_type: '',
};
module('Acceptance | landing page dashboard', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
@@ -434,4 +472,66 @@ module('Acceptance | landing page dashboard', function (hooks) {
assert.dom(SELECTORS.tooltipIcon('dr-perf', 'Performance primary', 'check-circle')).exists();
});
});
module('custom messages auth tests', function (hooks) {
hooks.beforeEach(function () {
return this.server.get('/sys/internal/ui/mounts', () => ({}));
});
test('it shows the alert banner and modal message', async function (assert) {
this.server.get('/sys/internal/ui/unauthenticated-messages', function () {
return authenticatedMessageResponse;
});
await visit('/vault/dashboard');
const modalId = 'some-awesome-id-1';
const alertId = 'some-awesome-id-2';
assert.dom(PAGE.modal(modalId)).exists();
assert.dom(PAGE.modalTitle(modalId)).hasText('Modal title');
assert.dom(PAGE.modalBody(modalId)).exists();
assert.dom(PAGE.modalBody(modalId)).hasText('here is a cool message');
await click(PAGE.modalButton(modalId));
assert.dom(PAGE.alertTitle(alertId)).hasText('Banner title');
assert.dom(PAGE.alertDescription(alertId)).hasText('hello world hello wolrd some link title');
});
test('it shows the multiple modal messages', async function (assert) {
const modalIdOne = 'some-awesome-id-2';
const modalIdTwo = 'some-awesome-id-1';
this.server.get('/sys/internal/ui/unauthenticated-messages', function () {
authenticatedMessageResponse.data.key_info[modalIdOne].type = 'modal';
authenticatedMessageResponse.data.key_info[modalIdOne].title = 'Modal title 1';
authenticatedMessageResponse.data.key_info[modalIdTwo].type = 'modal';
authenticatedMessageResponse.data.key_info[modalIdTwo].title = 'Modal title 2';
return authenticatedMessageResponse;
});
await visit('/vault/dashboard');
assert.dom(PAGE.modal(modalIdOne)).exists();
assert.dom(PAGE.modalTitle(modalIdOne)).hasText('Modal title 1');
assert.dom(PAGE.modalBody(modalIdOne)).exists();
assert.dom(PAGE.modalBody(modalIdOne)).hasText('hello world hello wolrd some link title');
await click(PAGE.modalButton(modalIdOne));
assert.dom(PAGE.modal(modalIdTwo)).exists();
assert.dom(PAGE.modalTitle(modalIdTwo)).hasText('Modal title 2');
assert.dom(PAGE.modalBody(modalIdTwo)).exists();
assert.dom(PAGE.modalBody(modalIdTwo)).hasText('here is a cool message');
await click(PAGE.modalButton(modalIdTwo));
});
test('it shows the multiple banner messages', async function (assert) {
const bannerIdOne = 'some-awesome-id-2';
const bannerIdTwo = 'some-awesome-id-1';
this.server.get('/sys/internal/ui/unauthenticated-messages', function () {
authenticatedMessageResponse.data.key_info[bannerIdOne].type = 'banner';
authenticatedMessageResponse.data.key_info[bannerIdOne].title = 'Banner title 1';
authenticatedMessageResponse.data.key_info[bannerIdTwo].type = 'banner';
authenticatedMessageResponse.data.key_info[bannerIdTwo].title = 'Banner title 2';
return authenticatedMessageResponse;
});
await visit('/vault/dashboard');
assert.dom(PAGE.alertTitle(bannerIdOne)).hasText('Banner title 1');
assert.dom(PAGE.alertDescription(bannerIdOne)).hasText('hello world hello wolrd some link title');
assert.dom(PAGE.alertTitle(bannerIdTwo)).hasText('Banner title 2');
assert.dom(PAGE.alertDescription(bannerIdTwo)).hasText('here is a cool message');
});
});
});

View File

@@ -35,7 +35,7 @@ module('Acceptance | mfa-login', function (hooks) {
});
const login = async (user) => {
await visit('/vault/auth');
await visit('/vault/auth?with=token');
await fillIn('[data-test-select="auth-method"]', 'userpass');
await fillIn('[data-test-username]', user);
await fillIn('[data-test-password]', 'test');

View File

@@ -0,0 +1,22 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
export const PAGE = {
// General selectors that are common between pages
radio: (radioName) => `[data-test-radio="${radioName}"]`,
field: (fieldName) => `[data-test-field="${fieldName}"]`,
input: (input) => `[data-test-input="${input}"]`,
button: (buttonName) => `[data-test-button="${buttonName}"]`,
inlineErrorMessage: `[data-test-inline-error-message]`,
fieldVaildation: (fieldName) => `[data-test-field-validation="${fieldName}"]`,
modal: (name) => `[data-test-modal="${name}"]`,
modalTitle: (title) => `[data-test-modal-title="${title}"]`,
modalBody: (name) => `[data-test-modal-body="${name}"]`,
modalButton: (name) => `[data-test-modal-button="${name}"]`,
alert: (name) => `data-test-custom-alert=${name}`,
alertTitle: (name) => `[data-test-custom-alert-title="${name}"]`,
alertDescription: (name) => `[data-test-custom-alert-description="${name}"]`,
badge: (name) => `[data-test-badge="${name}"]`,
};

View File

@@ -0,0 +1,227 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'vault/tests/helpers';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { setupEngine } from 'ember-engines/test-support';
import { render, click, fillIn } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { datetimeLocalStringFormat } from 'core/utils/date-formatters';
import { format, addDays, startOfDay } from 'date-fns';
import { PAGE } from 'vault/tests/helpers/config-ui/message-selectors';
module('Integration | Component | messages/page/create-and-edit-message', function (hooks) {
setupRenderingTest(hooks);
setupEngine(hooks, 'config-ui');
setupMirage(hooks);
hooks.beforeEach(function () {
this.context = { owner: this.engine };
this.store = this.owner.lookup('service:store');
this.message = this.store.createRecord('config-ui/message');
});
test('it should display all the create form fields and default radio button values', async function (assert) {
await render(hbs`<Messages::Page::CreateAndEditMessageForm @message={{this.message}} />`, {
owner: this.engine,
});
assert.dom('[data-test-page-title]').hasText('Create message');
assert
.dom('[data-test-form-subtext]')
.hasText('Create a custom message for all users when they access a Vault system via the UI.');
assert.dom(PAGE.radio('authenticated')).exists();
assert.dom(PAGE.radio('unauthenticated')).exists();
assert.dom(PAGE.radio('authenticated')).isChecked();
assert.dom(PAGE.radio('unauthenticated')).isNotChecked();
assert.dom(PAGE.radio('banner')).exists();
assert.dom(PAGE.radio('modal')).exists();
assert.dom(PAGE.radio('banner')).isChecked();
assert.dom(PAGE.radio('modal')).isNotChecked();
assert.dom(PAGE.field('title')).exists();
assert.dom(PAGE.field('message')).exists();
assert.dom('[data-test-kv-key="0"]').exists();
assert.dom('[data-test-kv-value="0"]').exists();
assert.dom(PAGE.input('startTime')).exists();
assert
.dom(PAGE.input('startTime'))
.hasValue(format(addDays(startOfDay(new Date()), 1), datetimeLocalStringFormat));
assert.dom(PAGE.input('endTime')).exists();
assert.dom(PAGE.input('endTime')).hasValue('');
});
test('it should create new message', async function (assert) {
assert.expect(1);
this.server.post('/sys/config/ui/custom-messages', () => {
assert.ok(true, 'POST request made to create message');
});
await render(hbs`<Messages::Page::CreateAndEditMessageForm @message={{this.message}} />`, {
owner: this.engine,
});
await fillIn(PAGE.input('title'), 'Awesome custom message title');
await fillIn(
PAGE.input('message'),
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Pulvinar mattis nunc sed blandit libero volutpat sed cras ornare.'
);
await fillIn(
PAGE.input('startTime'),
format(addDays(startOfDay(new Date('2023-12-12')), 1), datetimeLocalStringFormat)
);
await click('#specificDate');
await fillIn(
PAGE.input('endTime'),
format(addDays(startOfDay(new Date('2023-12-12')), 10), datetimeLocalStringFormat)
);
await fillIn('[data-test-kv-key="0"]', 'Learn more');
await fillIn('[data-test-kv-value="0"]', 'www.learn.com');
await click(PAGE.button('create-message'));
});
test('it should have form vaildations', async function (assert) {
await render(hbs`<Messages::Page::CreateAndEditMessageForm @message={{this.message}} />`, {
owner: this.engine,
});
await click(PAGE.button('create-message'));
assert.dom(PAGE.input('title')).hasClass('has-error-border', 'show error border for title field');
assert.dom(`${PAGE.fieldVaildation('title')} ${PAGE.inlineErrorMessage}`).hasText('Title is required.');
assert.dom(PAGE.input('message')).hasClass('has-error-border', 'show error border for message field');
assert
.dom(`${PAGE.fieldVaildation('message')} ${PAGE.inlineErrorMessage}`)
.hasText('Message is required.');
});
test('it should prepopulate form if form is in edit mode', async function (assert) {
this.store.pushPayload('config-ui/message', {
modelName: 'config-ui/message',
id: 'hhhhh-iiii-lllll-dddd',
type: 'modal',
authenticated: false,
title: 'Hello world',
message: 'Blah blah blah. Some super long message.',
start_time: '2023-12-12T08:00:00.000Z',
end_time: '2023-12-21T08:00:00.000Z',
link: { 'Learn more': 'www.learnmore.com' },
});
this.message = this.store.peekRecord('config-ui/message', 'hhhhh-iiii-lllll-dddd');
await render(hbs`<Messages::Page::CreateAndEditMessageForm @message={{this.message}} />`, {
owner: this.engine,
});
assert.dom('[data-test-page-title]').hasText('Edit message');
assert
.dom('[data-test-form-subtext]')
.hasText('Edit a custom message for all users when they access a Vault system via the UI.');
assert.dom(PAGE.radio('authenticated')).exists();
assert.dom(PAGE.radio('unauthenticated')).isChecked();
assert.dom(PAGE.radio('modal')).exists();
assert.dom(PAGE.radio('modal')).isChecked();
assert.dom(PAGE.input('title')).hasValue('Hello world');
assert.dom(PAGE.input('message')).hasValue('Blah blah blah. Some super long message.');
assert.dom('[data-test-kv-key="0"]').exists();
assert.dom('[data-test-kv-key="0"]').hasValue('Learn more');
assert.dom('[data-test-kv-value="0"]').exists();
assert.dom('[data-test-kv-value="0"]').hasValue('www.learnmore.com');
await click('#specificDate');
assert
.dom(PAGE.input('startTime'))
.hasValue(format(new Date(this.message.startTime), datetimeLocalStringFormat));
assert
.dom(PAGE.input('endTime'))
.hasValue(format(new Date(this.message.endTime), datetimeLocalStringFormat));
});
test('it should show a preview image modal when preview is clicked', async function (assert) {
await render(hbs`<Messages::Page::CreateAndEditMessageForm @message={{this.message}} />`, {
owner: this.engine,
});
await fillIn(PAGE.input('title'), 'Awesome custom message title');
await fillIn(
PAGE.input('message'),
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Pulvinar mattis nunc sed blandit libero volutpat sed cras ornare.'
);
await click(PAGE.button('preview'));
assert.dom(PAGE.modal('preview modal')).doesNotExist();
assert.dom(PAGE.modal('preview image')).exists();
assert.dom(PAGE.alertTitle('Awesome custom message title')).hasText('Awesome custom message title');
assert
.dom(PAGE.alertDescription('Awesome custom message title'))
.hasText(
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Pulvinar mattis nunc sed blandit libero volutpat sed cras ornare.'
);
assert.dom('img').hasAttribute('src', '/ui/images/custom-messages-dashboard.png');
await click(PAGE.modalButton('Close'));
await click('#unauthenticated');
await click(PAGE.button('preview'));
assert.dom('img').hasAttribute('src', '/ui/images/custom-messages-login.png');
});
test('it should show a preview modal when preview is clicked', async function (assert) {
await render(hbs`<Messages::Page::CreateAndEditMessageForm @message={{this.message}} />`, {
owner: this.engine,
});
await click(PAGE.radio('modal'));
await fillIn(PAGE.input('title'), 'Preview modal title');
await fillIn(PAGE.input('message'), 'Some preview modal message thats super long.');
await click(PAGE.button('preview'));
assert.dom(PAGE.modal('preview modal')).exists();
assert.dom(PAGE.modal('preview image')).doesNotExist();
assert.dom(PAGE.modalTitle('Preview modal title')).hasText('Preview modal title');
assert.dom(PAGE.modalBody('Preview modal title')).hasText('Some preview modal message thats super long.');
});
test('it should show multiple modal message', async function (assert) {
this.store.pushPayload('config-ui/message', {
modelName: 'config-ui/message',
id: '01234567-89ab-cdef-0123-456789abcdef',
active: true,
type: 'modal',
authenticated: true,
title: 'Message title 1',
message: 'Some long long long message',
link: { here: 'www.example.com' },
startTime: '2021-08-01T00:00:00Z',
endTime: '',
});
this.store.pushPayload('config-ui/message', {
modelName: 'config-ui/message',
id: '01234567-89ab-vvvv-0123-456789abcdef',
active: true,
type: 'modal',
authenticated: false,
title: 'Message title 2',
message: 'Some long long long message',
link: { here: 'www.example.com' },
startTime: '2021-08-01T00:00:00Z',
endTime: '2090-08-01T00:00:00Z',
});
this.messages = this.store.peekAll('config-ui/message');
await render(
hbs`<Messages::Page::CreateAndEditMessageForm @message={{this.message}} @messages={{this.messages}} @hasSomeActiveModals={{true}} />`,
{
owner: this.engine,
}
);
await fillIn(PAGE.input('title'), 'Awesome custom message title');
await fillIn(
PAGE.input('message'),
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Pulvinar mattis nunc sed blandit libero volutpat sed cras ornare.'
);
await click(PAGE.radio('modal'));
await click(PAGE.button('create-message'));
assert.dom(PAGE.modalTitle('Warning: more than one modal')).exists();
assert
.dom(PAGE.modalBody('Warning: more than one modal'))
.hasText(
'You have an active modal configured after the user logs in and are trying to create another one. It is recommended to avoid having more than one modal at once as it can be intrusive for users. Would you like to continue creating your message? Click “Confirm” to continue.'
);
await click(PAGE.modalButton('confirm'));
});
});

View File

@@ -0,0 +1,92 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'vault/tests/helpers';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { setupEngine } from 'ember-engines/test-support';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { dateFormat } from 'core/helpers/date-format';
const allFields = [
{ label: 'Active', key: 'active' },
{ label: 'Type', key: 'type' },
{ label: 'Authenticated', key: 'authenticated' },
{ label: 'Title', key: 'title' },
{ label: 'Message', key: 'message' },
{ label: 'Start time', key: 'startTime' },
{ label: 'End time', key: 'endTime' },
{ label: 'Link', key: 'link' },
];
module('Integration | Component | messages/page/details', function (hooks) {
setupRenderingTest(hooks);
setupEngine(hooks, 'config-ui');
setupMirage(hooks);
hooks.beforeEach(function () {
this.context = { owner: this.engine };
this.store = this.owner.lookup('service:store');
this.server.post('/sys/capabilities-self', () => ({
data: {
capabilities: ['root'],
},
}));
this.store.pushPayload('config-ui/message', {
modelName: 'config-ui/message',
id: '01234567-89ab-cdef-0123-456789abcdef',
active: true,
type: 'banner',
authenticated: true,
title: 'Message title 1',
message: 'Some long long long message',
link: { here: 'www.example.com' },
start_time: '2021-08-01T00:00:00Z',
end_time: '',
canDeleteCustomMessages: true,
canEditCustomMessages: true,
});
});
test('it should show the message details', async function (assert) {
this.message = await this.store.peekRecord('config-ui/message', '01234567-89ab-cdef-0123-456789abcdef');
await render(hbs`<Messages::Page::Details @message={{this.message}} />`, {
owner: this.engine,
});
assert.dom('[data-test-page-title]').hasText('Message title 1');
assert
.dom('[data-test-component="info-table-row"]')
.exists({ count: allFields.length }, 'Correct number of filtered fields render');
allFields.forEach((field) => {
assert
.dom(`[data-test-row-label="${field.label}"]`)
.hasText(field.label, `${field.label} label renders`);
if (field.key === 'startTime' || field.key === 'endTime') {
const formattedDate = dateFormat([this.message[field.key], 'MMM d, yyyy hh:mm aaa'], {
withTimeZone: true,
});
assert
.dom(`[data-test-row-value="${field.label}"]`)
.hasText(formattedDate || 'Never', `${field.label} value renders`);
} else if (field.key === 'authenticated' || field.key === 'active') {
assert
.dom(`[data-test-value-div="${field.label}"]`)
.hasText(this.message[field.key] ? 'Yes' : 'No', `${field.label} value renders`);
} else if (field.key === 'link') {
assert.dom('[data-test-value-div="Link"]').exists();
assert.dom('[data-test-value-div="Link"] [data-test-link="message link"]').hasText('here');
} else {
assert
.dom(`[data-test-row-value="${field.label}"]`)
.hasText(this.message[field.key], `${field.label} value renders`);
}
});
});
});

View File

@@ -0,0 +1,144 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'vault/tests/helpers';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { setupEngine } from 'ember-engines/test-support';
import { render, click } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { PAGE } from 'vault/tests/helpers/config-ui/message-selectors';
const META = {
currentPage: 1,
lastPage: 1,
nextPage: 1,
prevPage: 1,
total: 3,
pageSize: 15,
};
module('Integration | Component | messages/page/list', function (hooks) {
setupRenderingTest(hooks);
setupEngine(hooks, 'config-ui');
setupMirage(hooks);
hooks.beforeEach(function () {
this.context = { owner: this.engine };
this.store = this.owner.lookup('service:store');
this.store.pushPayload('config-ui/message', {
modelName: 'config-ui/message',
id: '0',
active: true,
type: 'banner',
authenticated: true,
title: 'Message title 1',
message: 'Some long long long message',
link: { title: 'here', href: 'www.example.com' },
start_time: '2021-08-01T00:00:00Z',
end_time: '',
});
this.store.pushPayload('config-ui/message', {
modelName: 'config-ui/message',
id: '1',
active: false,
type: 'modal',
authenticated: true,
title: 'Message title 2',
message: 'Some long long long message blah blah blah',
link: { title: 'here', href: 'www.example2.com' },
start_time: '2023-07-01T00:00:00Z',
end_time: '2023-08-01T00:00:00Z',
});
this.store.pushPayload('config-ui/message', {
modelName: 'config-ui/message',
id: '2',
active: false,
type: 'banner',
authenticated: false,
title: 'Message title 3',
message: 'Some long long long message',
link: { title: 'here', href: 'www.example.com' },
});
});
test('it should show the messages empty state', async function (assert) {
this.messages = [];
await render(hbs`<Messages::Page::List @messages={{this.messages}} />`, {
owner: this.engine,
});
assert.dom('[data-test-empty-state-title]').hasText('No messages yet');
assert
.dom('[data-test-empty-state-message]')
.hasText(
'Add a custom message for all users after they log into Vault. Create message to get started.'
);
assert.dom('[data-test-empty-state-actions] a').hasText('Create message');
});
test('it should show the list of custom messages', async function (assert) {
this.messages = this.store.peekAll('config-ui/message', {});
this.messages.meta = META;
await render(hbs`<Messages::Page::List @messages={{this.messages}} />`, {
owner: this.engine,
});
assert.dom('[data-test-icon="message-circle"]').exists();
for (const message of this.messages) {
assert.dom(`[data-test-list-item="${message.id}"]`).exists();
assert.dom(`[data-linked-block-title="${message.id}"]`).hasText(message.title);
}
});
test('it should show max message warning modal', async function (assert) {
for (let i = 0; i < 97; i++) {
this.store.pushPayload('config-ui/message', {
modelName: 'config-ui/message',
id: `${i}-a`,
active: true,
type: 'banner',
authenticated: false,
title: `Message title ${i}`,
message: 'Some long long long message',
link: { title: 'here', href: 'www.example.com' },
start_time: '2021-08-01T00:00:00Z',
});
}
this.messages = this.store.peekAll('config-ui/message', {});
this.messages.meta = {
currentPage: 1,
lastPage: 1,
nextPage: 1,
prevPage: 1,
total: this.messages.length,
pageSize: 100,
};
await render(hbs`<Messages::Page::List @messages={{this.messages}} />`, {
owner: this.engine,
});
await click(PAGE.button('create message'));
assert.dom(PAGE.modalTitle('maximum-message-modal')).hasText('Maximum number of messages reached');
assert
.dom(PAGE.modalBody('maximum-message-modal'))
.hasText(
'Vault can only store up to 100 messages. To create a message, delete one of your messages to clear up space.'
);
await click(PAGE.modalButton('maximum-message-modal'));
});
test('it should show the correct badge colors based on badge status', async function (assert) {
this.messages = this.store.peekAll('config-ui/message', {});
this.messages.meta = META;
await render(hbs`<Messages::Page::List @messages={{this.messages}} />`, {
owner: this.engine,
});
assert.dom(PAGE.badge('0')).hasClass('hds-badge--color-success');
assert.dom(PAGE.badge('1')).hasClass('hds-badge--color-neutral');
assert.dom(PAGE.badge('2')).hasClass('hds-badge--color-highlight');
});
});

View File

@@ -35,6 +35,7 @@ module('Integration | Component | mfa-login-enforcement-form', function (hooks)
label: { enabled: false },
// TODO: add labels to enforcement targets key/value style inputs
'select-name': { enabled: false },
'aria-prohibited-attr': { enabled: false },
},
});
});

View File

@@ -33,7 +33,7 @@ module('Integration | Component | sidebar-nav-cluster', function (hooks) {
});
test('it should render nav headings', async function (assert) {
const headings = ['Vault', 'Monitoring'];
const headings = ['Vault', 'Monitoring', 'Settings'];
stubFeaturesAndPermissions(this.owner, true, true);
await renderComponent();
@@ -70,6 +70,7 @@ module('Integration | Component | sidebar-nav-cluster', function (hooks) {
'Client Count',
'License',
'Seal Vault',
'Custom Messages',
];
stubFeaturesAndPermissions(this.owner, true, true);
await renderComponent();

View File

@@ -0,0 +1,72 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Serializer | config-ui/message', function (hooks) {
setupTest(hooks);
test('it should always encode message when creating/updating a message', function (assert) {
const store = this.owner.lookup('service:store');
const record = store.createRecord('config-ui/message', {
id: '01234567-89ab-cdef-0123-456789abcdef',
active: true,
type: 'banner',
authenticated: true,
title: 'Message title 1',
message: 'Some long long long message',
link: { title: '', href: '' },
startTime: '2024-01-03T20:54:29.802Z',
endTime: '',
});
const expectedResult = {
authenticated: true,
end_time: null,
link: {
href: '',
title: '',
},
message: 'U29tZSBsb25nIGxvbmcgbG9uZyBtZXNzYWdl',
start_time: '2024-01-03T20:54:29.802Z',
title: 'Message title 1',
type: 'banner',
};
const serializedRecord = record.serialize();
assert.deepEqual(serializedRecord, expectedResult, 'encode the message string');
});
test('it should always use ISO date format when creating/updating a message', function (assert) {
const store = this.owner.lookup('service:store');
const date = new Date();
const record = store.createRecord('config-ui/message', {
id: '01234567-89ab-cdef-0123-456789abcdef',
active: true,
type: 'banner',
authenticated: true,
title: 'Message title 1',
message: 'Some long long long message',
link: { title: '', href: '' },
startTime: date,
endTime: '',
});
const expectedResult = {
authenticated: true,
end_time: null,
link: {
href: '',
title: '',
},
message: 'U29tZSBsb25nIGxvbmcgbG9uZyBtZXNzYWdl',
start_time: date.toISOString(),
title: 'Message title 1',
type: 'banner',
};
const serializedRecord = record.serialize();
assert.deepEqual(serializedRecord, expectedResult, 'uses ISO date string');
});
});

View File

@@ -0,0 +1,30 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupTest } from 'vault/tests/helpers';
module('Unit | Transform | date time local', function (hooks) {
setupTest(hooks);
hooks.beforeEach(function () {
this.transform = this.owner.lookup('transform:date-time-local');
});
test('it serializes correctly for the API', function (assert) {
assert.ok(this.transform);
let serialized = this.transform.serialize('2024-01-31T00:00');
assert.strictEqual(
serialized,
new Date('2024-01-31T00:00').toISOString(),
'should serialize a string that is not in ISO format'
);
serialized = this.transform.serialize(new Date('2024-03-30T17:11:00Z'));
assert.strictEqual(serialized, '2024-03-30T17:11:00.000Z', 'should serialize a date object');
serialized = this.transform.serialize('2024-03-30T17:11:00.000Z');
assert.strictEqual(serialized, '2024-03-30T17:11:00.000Z', 'should always show an ISO string');
});
});