Skip to content

Commit

Permalink
UI: [VAULT-19096] Customizable banners (#23945)
Browse files Browse the repository at this point in the history
* 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!
  • Loading branch information
kiannaquach authored Jan 23, 2024
1 parent b49c673 commit b85365e
Show file tree
Hide file tree
Showing 64 changed files with 2,452 additions and 22 deletions.
3 changes: 3 additions & 0 deletions changelog/23945.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:feature
**Custom messages**: Introduces custom messages settings, allowing users to view, and operators to configure system-wide messages.
```
27 changes: 27 additions & 0 deletions ui/app/adapters/config-ui/message.js
Original file line number Diff line number Diff line change
@@ -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),
});
}
}
5 changes: 5 additions & 0 deletions ui/app/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
9 changes: 9 additions & 0 deletions ui/app/components/sidebar/nav/cluster.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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>
1 change: 1 addition & 0 deletions ui/app/controllers/vault/cluster.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export default Controller.extend({
permissions: service(),
namespaceService: service('namespace'),
flashMessages: service(),
customMessages: service(),

vaultVersion: service('version'),
console: service(),
Expand Down
4 changes: 4 additions & 0 deletions ui/app/controllers/vault/cluster/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -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(),

Expand All @@ -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(
Expand Down
125 changes: 125 additions & 0 deletions ui/app/models/config-ui/message.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
1 change: 1 addition & 0 deletions ui/app/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down
1 change: 1 addition & 0 deletions ui/app/routes/vault/cluster.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down
2 changes: 2 additions & 0 deletions ui/app/routes/vault/cluster/logout.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand All @@ -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) {
Expand Down
58 changes: 58 additions & 0 deletions ui/app/serializers/config-ui/message.js
Original file line number Diff line number Diff line change
@@ -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);
}
}
63 changes: 63 additions & 0 deletions ui/app/services/custom-messages.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
3 changes: 3 additions & 0 deletions ui/app/services/permissions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
5 changes: 5 additions & 0 deletions ui/app/styles/helper-classes/colors.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading

0 comments on commit b85365e

Please sign in to comment.