diff --git a/ui/app/app.js b/ui/app/app.js index 2a4cdd68d1..128ab5a91a 100644 --- a/ui/app/app.js +++ b/ui/app/app.js @@ -20,10 +20,11 @@ export default class App extends Application { 'flash-messages', 'namespace', { 'app-router': 'router' }, - 'store', 'pagination', 'version', 'custom-messages', + 'api', + 'capabilities', ], }, }, diff --git a/ui/app/forms/custom-message.ts b/ui/app/forms/custom-message.ts new file mode 100644 index 0000000000..b702852432 --- /dev/null +++ b/ui/app/forms/custom-message.ts @@ -0,0 +1,125 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ +import Form from './form'; +import FormField from 'vault/utils/forms/field'; +import { isBefore, isAfter } from 'date-fns'; +import { encodeString } from 'core/utils/b64'; + +import type { CreateCustomMessageRequest } from '@hashicorp/vault-client-typescript'; +import { Validations } from 'vault/vault/app-types'; + +type CustomMessageFormData = Partial; + +export default class CustomMessageForm extends Form { + declare data: CustomMessageFormData; + + formFields = [ + new FormField('authenticated', undefined, { + label: 'Where should we display this message?', + editType: 'radio', + possibleValues: [ + { + label: 'After the user logs in', + subText: 'Display to users after they have successfully logged in to Vault.', + value: true, + id: 'authenticated', + }, + { + label: 'On the login page', + subText: 'Display to users on the login page before they have authenticated.', + value: false, + id: 'unauthenticated', + }, + ], + }), + + new FormField('type', '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', + }, + ], + }), + + new FormField('title', 'string'), + + new FormField('message', 'string', { editType: 'textarea' }), + + new FormField('link', 'string', { + editType: 'kv', + keyPlaceholder: 'Display text (e.g. Learn more)', + valuePlaceholder: 'Link URL (e.g. https://www.hashicorp.com/)', + label: 'Link (optional)', + isSingleRow: true, + allowWhiteSpace: true, + }), + + new FormField('startTime', 'dateTimeLocal', { + editType: 'dateTimeLocal', + label: 'Message starts', + subText: 'Defaults to 12:00 a.m. the following day (local timezone).', + }), + + new FormField('endTime', 'dateTimeLocal', { + editType: 'yield', + label: 'Message expires', + }), + ]; + + validations: Validations = { + title: [{ type: 'presence', message: 'Title is required.' }], + message: [{ type: 'presence', message: 'Message is required.' }], + link: [ + { + validator({ link }: CustomMessageFormData) { + if (!link) return true; + const [title] = Object.keys(link); + const [href] = Object.values(link); + return title || href ? !!(title && href) : true; + }, + message: 'Link title and url are required.', + }, + ], + startTime: [ + { + validator({ startTime, endTime }: CustomMessageFormData) { + if (!startTime || !endTime) return true; + return isBefore(new Date(startTime), new Date(endTime)); + }, + message: 'Start time is after end time.', + }, + ], + endTime: [ + { + validator({ startTime, endTime }: CustomMessageFormData) { + if (!startTime || !endTime) return true; + return isAfter(new Date(endTime), new Date(startTime)); + }, + message: 'End time is before start time.', + }, + ], + }; + + toJSON() { + // overriding to do some date serialization + // form sets dates as strings but client expects Date objects + const startTime = this.data.startTime ? new Date(this.data.startTime as unknown as string) : undefined; + const endTime = this.data.endTime ? new Date(this.data.endTime as unknown as string) : undefined; + // encode message to base64 + const message = this.data.message ? encodeString(this.data.message) : undefined; + return super.toJSON({ ...this.data, startTime, endTime, message }); + } +} diff --git a/ui/app/services/api.ts b/ui/app/services/api.ts index c15e153a14..c55351ab2e 100644 --- a/ui/app/services/api.ts +++ b/ui/app/services/api.ts @@ -12,8 +12,12 @@ import { IdentityApi, SecretsApi, SystemApi, + HTTPQuery, + HTTPRequestInit, + RequestOpts, } from '@hashicorp/vault-client-typescript'; import config from '../config/environment'; +import { waitForPromise } from '@ember/test-waiters'; import type AuthService from 'vault/services/auth'; import type NamespaceService from 'vault/services/namespace'; @@ -80,12 +84,15 @@ export default class ApiService extends Service { // -- Post Request Middleware -- showWarnings = async (context: ResponseContext) => { const response = context.response.clone(); - const json = await response?.json(); + // if the response is empty, don't try to parse it + if (response.headers.get('Content-Length')) { + const json = await response.json(); - if (json?.warnings) { - json.warnings.forEach((message: string) => { - this.flashMessages.info(message); - }); + if (json?.warnings) { + json.warnings.forEach((message: string) => { + this.flashMessages.info(message); + }); + } } }; @@ -129,6 +136,9 @@ export default class ApiService extends Service { { post: this.deleteControlGroupToken }, { post: this.formatErrorResponse }, ], + fetchApi: (...args: [Request]) => { + return waitForPromise(window.fetch(...args)); + }, }); auth = new AuthApi(this.configuration); @@ -154,4 +164,12 @@ export default class ApiService extends Service { return { headers }; } + + // convenience method for updating the query params object on the request context + // eg. this.api.sys.uiConfigListCustomMessages(true, ({ context: { query } }) => { query.authenticated = true }); + // -> this.api.sys.uiConfigListCustomMessages(true, (context) => this.api.addQueryParams(context, { authenticated: true })); + addQueryParams(requestContext: { init: HTTPRequestInit; context: RequestOpts }, params: HTTPQuery = {}) { + const { context } = requestContext; + context.query = { ...context.query, ...params }; + } } diff --git a/ui/app/utils/api-error-message.ts b/ui/app/utils/api-error-message.ts index d65c723d93..dd603a764a 100644 --- a/ui/app/utils/api-error-message.ts +++ b/ui/app/utils/api-error-message.ts @@ -10,11 +10,17 @@ */ import { ErrorContext, ApiError } from 'vault/api'; +import ENV from 'vault/config/environment'; // accepts an error and returns error.errors joined with a comma, error.message or a fallback message export default async function (error: unknown, fallbackMessage = 'An error occurred, please try again') { const messageOrFallback = (message?: string) => message || fallbackMessage; + // log out the error for ease of debugging in dev env + if (ENV.environment === 'development') { + console.error('API Error:', error); // eslint-disable-line no-console + } + if ((error as ErrorContext).response instanceof Response) { const apiError: ApiError = await (error as ErrorContext).response?.json(); diff --git a/ui/lib/config-ui/addon/components/messages/message-expiration-date-form.js b/ui/lib/config-ui/addon/components/messages/message-expiration-date-form.js index b85e7622a4..30304859da 100644 --- a/ui/lib/config-ui/addon/components/messages/message-expiration-date-form.js +++ b/ui/lib/config-ui/addon/components/messages/message-expiration-date-form.js @@ -15,7 +15,7 @@ import { datetimeLocalStringFormat } from 'core/utils/date-formatters'; * ```js * * ``` - * @param {array} messages - array message objects + * @param {array} message - message form data */ export default class MessageExpirationDateForm extends Component { diff --git a/ui/lib/config-ui/addon/components/messages/page/create-and-edit.js b/ui/lib/config-ui/addon/components/messages/page/create-and-edit.js index d0116a58e9..9b17392c85 100644 --- a/ui/lib/config-ui/addon/components/messages/page/create-and-edit.js +++ b/ui/lib/config-ui/addon/components/messages/page/create-and-edit.js @@ -6,7 +6,7 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { task, timeout } from 'ember-concurrency'; -import errorMessage from 'vault/utils/error-message'; +import apiErrorMessage from 'vault/utils/api-error-message'; import { service } from '@ember/service'; import { action } from '@ember/object'; import Ember from 'ember'; @@ -20,16 +20,18 @@ import timestamp from 'core/utils/timestamp'; * ```js * * ``` - * @param {model} message - message model to pass to form components + * @param message - message to pass to form component + * @param messages - array of all created messages + * @param breadcrumbs - breadcrumbs to pass to the TabPageHeader component */ export default class MessagesList extends Component { @service('app-router') router; - @service store; @service pagination; @service flashMessages; @service customMessages; @service namespace; + @service api; @tracked errorBanner = ''; @tracked modelValidations; @@ -38,17 +40,21 @@ export default class MessagesList extends Component { @tracked showMultipleModalsMessage = false; @tracked userConfirmation = ''; - willDestroy() { - const noTeardown = this.store && !this.store.isDestroying; - const { model } = this; - if (noTeardown && model && model.isDirty && !model.isDestroyed && !model.isDestroying) { - model.rollbackAttributes(); - } - super.willDestroy(); + get hasSomeActiveModals() { + const { messages } = this.args; + return messages?.some((message) => message.type === 'modal' && message.active); + } + + get hasExpiredModalMessages() { + const modalMessages = this.args.messages?.filter((message) => message.type === 'modal') || []; + return modalMessages.every((message) => { + if (!message.endTime) return false; + return isAfter(timestamp.now(), new Date(message.endTime)); + }); } validate() { - const { isValid, state, invalidFormMessage } = this.args.message.validate(); + const { isValid, state, invalidFormMessage } = this.args.message.toJSON(); this.modelValidations = isValid ? null : state; this.invalidFormAlert = invalidFormMessage; return isValid; @@ -60,29 +66,32 @@ export default class MessagesList extends Component { try { this.userConfirmation = ''; + const { message } = this.args; const isValid = this.validate(); - const modalMessages = this.args.messages?.filter((message) => message.type === 'modal') || []; - const hasExpiredModalMessages = modalMessages.every((message) => { - if (!message.endTime) return false; - return isAfter(timestamp.now(), new Date(message.endTime)); - }); - if (!hasExpiredModalMessages && this.args.hasSomeActiveModals && this.args.message.type === 'modal') { + if (!this.hasExpiredModalMessages && this.hasSomeActiveModals && 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.pagination.clearDataset('config-ui/message'); + const { data } = message.toJSON(); + let id = data.id; + + if (message.isNew) { + const response = yield this.api.sys.createCustomMessage(data); + id = response.data.id; + } else { + yield this.api.sys.uiConfigUpdateCustomMessage(id, data); + } + + this.flashMessages.success(`Successfully saved ${data.title} 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.errorBanner = yield apiErrorMessage(error); this.invalidFormAlert = 'There was an error submitting this form.'; } } diff --git a/ui/lib/config-ui/addon/components/messages/page/details.hbs b/ui/lib/config-ui/addon/components/messages/page/details.hbs index b2b6c26e29..84a6b9c814 100644 --- a/ui/lib/config-ui/addon/components/messages/page/details.hbs +++ b/ui/lib/config-ui/addon/components/messages/page/details.hbs @@ -11,7 +11,7 @@ - {{#if @message.canDeleteCustomMessages}} + {{#if @capabilities.canDelete}}
{{/if}} - {{#if @message.canEditCustomMessages}} + {{#if @capabilities.canUpdate}} Edit message @@ -32,18 +32,18 @@
-{{#each @message.allFields as |attr|}} - {{#if (or (eq attr.name "endTime") (eq attr.name "startTime"))}} +{{#each this.displayFields as |field|}} + {{#if (or (eq field "endTime") (eq field "startTime"))}} {{! if the attr is an endTime and is falsy, we want to show a 'Never' text value }} - {{else if (eq attr.name "link")}} + {{else if (eq field "link")}} {{#if (is-empty-value @message.link)}} {{else}} @@ -54,7 +54,7 @@ {{/each-in}} {{/if}} {{else}} - + {{/if}} {{/each}} \ No newline at end of file diff --git a/ui/lib/config-ui/addon/components/messages/page/details.js b/ui/lib/config-ui/addon/components/messages/page/details.js index b09ad182dc..6bc630914f 100644 --- a/ui/lib/config-ui/addon/components/messages/page/details.js +++ b/ui/lib/config-ui/addon/components/messages/page/details.js @@ -6,16 +6,17 @@ import Component from '@glimmer/component'; import { service } from '@ember/service'; import { action } from '@ember/object'; -import errorMessage from 'vault/utils/error-message'; +import apiErrorMessage from 'vault/utils/api-error-message'; /** * @module Page::MessageDetails * Page::MessageDetails components are used to display a message * @example * ```js - * + * * ``` - * @param {model} message - message model + * @param message + * @param capabilities - capabilities for the message */ export default class MessageDetails extends Component { @@ -24,18 +25,21 @@ export default class MessageDetails extends Component { @service customMessages; @service namespace; @service pagination; + @service api; + + displayFields = ['active', 'type', 'authenticated', 'title', 'message', 'startTime', 'endTime', 'link']; @action async deleteMessage() { try { - await this.args.message.destroyRecord(this.args.message.id); - this.pagination.clearDataset('config-ui/message'); + const { message } = this.args; + await this.api.sys.uiConfigDeleteCustomMessage(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}.`); + this.flashMessages.success(`Successfully deleted ${message.title}.`); } catch (e) { - const message = errorMessage(e); - this.flashMessages.danger(message); + const errorMessage = await apiErrorMessage(e); + this.flashMessages.danger(errorMessage); } } } diff --git a/ui/lib/config-ui/addon/components/messages/page/list.hbs b/ui/lib/config-ui/addon/components/messages/page/list.hbs index 87910f6c9f..07e5fcd180 100644 --- a/ui/lib/config-ui/addon/components/messages/page/list.hbs +++ b/ui/lib/config-ui/addon/components/messages/page/list.hbs @@ -104,7 +104,7 @@
- {{#if (or message.canEditCustomMessages message.canDeleteCustomMessages)}} + {{#if (has-capability @capabilities "update" "delete" id=message.id)}} - {{#if message.canEditCustomMessages}} + {{#if (has-capability @capabilities "update" id=message.id)}} Edit {{/if}} - {{#if message.canDeleteCustomMessages}} + {{#if (has-capability @capabilities "delete" id=message.id)}} { + return isAfter(message.startTime, timestamp.now()); + }; + get formattedMessages() { return this.args.messages.map((message) => { let badgeDisplayText = ''; @@ -47,7 +54,7 @@ export default class MessagesList extends Component { } badgeColor = 'success'; } else { - if (message.isStartTimeAfterToday) { + if (this.isStartTimeAfterToday(message)) { badgeDisplayText = `Scheduled: ${dateFormat([message.startTime, 'MMM d, yyyy hh:mm aaa'], { withTimeZone: true, })}`; @@ -90,14 +97,13 @@ export default class MessagesList extends Component { @task *deleteMessage(message) { try { - yield message.destroyRecord(message.id); - this.pagination.clearDataset('config-ui/message'); + yield this.api.sys.uiConfigDeleteCustomMessage(message.id); this.router.transitionTo('vault.cluster.config-ui.messages'); this.customMessages.fetchMessages(this.namespace.path); this.flashMessages.success(`Successfully deleted ${message.title}.`); } catch (e) { - const message = errorMessage(e); - this.flashMessages.danger(message); + const errorMessage = yield apiErrorMessage(e); + this.flashMessages.danger(errorMessage); } finally { this.messageToDelete = null; } diff --git a/ui/lib/config-ui/addon/engine.js b/ui/lib/config-ui/addon/engine.js index f8b1a0dbcc..6f2a9e9de9 100644 --- a/ui/lib/config-ui/addon/engine.js +++ b/ui/lib/config-ui/addon/engine.js @@ -18,13 +18,14 @@ export default class ConfigUiEngine extends Engine { dependencies = { services: [ 'auth', - 'store', 'pagination', 'flash-messages', 'namespace', 'app-router', 'version', 'custom-messages', + 'api', + 'capabilities', ], }; } diff --git a/ui/lib/config-ui/addon/routes/messages/create.js b/ui/lib/config-ui/addon/routes/messages/create.js index fe35b007b1..6c196bbac3 100644 --- a/ui/lib/config-ui/addon/routes/messages/create.js +++ b/ui/lib/config-ui/addon/routes/messages/create.js @@ -5,9 +5,12 @@ import Route from '@ember/routing/route'; import { service } from '@ember/service'; +import CustomMessage from 'vault/forms/custom-message'; +import { addDays, startOfDay } from 'date-fns'; +import timestamp from 'core/utils/timestamp'; export default class MessagesCreateRoute extends Route { - @service store; + @service api; queryParams = { authenticated: { @@ -17,9 +20,8 @@ export default class MessagesCreateRoute extends Route { async getMessages(authenticated) { try { - return await this.store.query('config-ui/message', { - authenticated, - }); + const { keyInfo } = await this.api.sys.uiConfigListCustomMessages(true, undefined, authenticated); + return Object.values(keyInfo); } catch { return []; } @@ -27,17 +29,21 @@ export default class MessagesCreateRoute extends Route { async model(params) { const { authenticated } = params; - const message = this.store.createRecord('config-ui/message', { - authenticated, - }); + const message = new CustomMessage( + { + authenticated, + type: 'banner', + startTime: addDays(startOfDay(timestamp.now()), 1).toISOString(), + }, + { isNew: true } + ); + const messages = await this.getMessages(authenticated); return { message, messages, authenticated, - hasSomeActiveModals: - messages.length && messages?.some((message) => message.type === 'modal' && message.active), }; } diff --git a/ui/lib/config-ui/addon/routes/messages/index.js b/ui/lib/config-ui/addon/routes/messages/index.js index de3a6e4be8..a1a79c08b6 100644 --- a/ui/lib/config-ui/addon/routes/messages/index.js +++ b/ui/lib/config-ui/addon/routes/messages/index.js @@ -5,10 +5,12 @@ import Route from '@ember/routing/route'; import { service } from '@ember/service'; -import { hash } from 'rsvp'; +import { paginate } from 'core/utils/paginate-list'; +import { PATH_MAP } from 'core/utils/capabilities'; export default class MessagesRoute extends Route { - @service pagination; + @service api; + @service capabilities; queryParams = { page: { @@ -28,36 +30,49 @@ export default class MessagesRoute extends Route { }, }; - model(params) { + async model(params) { const { authenticated, page, pageFilter, status, type } = params; - const filter = pageFilter - ? (dataset) => dataset.filter((item) => item?.title.toLowerCase().includes(pageFilter.toLowerCase())) - : null; - let active; + const active = { + active: true, + inactive: false, + }[status]; - if (status === 'active') active = true; - if (status === 'inactive') active = false; - - const messages = this.pagination - .lazyPaginatedQuery('config-ui/message', { - authenticated, - pageFilter: filter, + try { + const { keyInfo, keys } = await this.api.sys.uiConfigListCustomMessages( + true, active, - type, - responsePath: 'data.keys', - page: page || 1, - size: 10, - }) - .catch((e) => { - if (e.httpStatus === 404) { - return []; - } - throw e; + authenticated, + type + ); + // ids are in the keys array and can be mapped to the object in keyInfo + // map and set id property on keyInfo object + const data = keys.map((id) => { + const { startTime, endTime, ...message } = keyInfo[id]; + // dates returned from list endpoint are strings -- convert to date + return { + id, + ...message, + startTime: startTime ? new Date(startTime) : startTime, + endTime: endTime ? new Date(endTime) : endTime, + }; }); - return hash({ - params, - messages, - }); + const messages = paginate(data, { + page, + pageSize: 2, + filter: pageFilter, + filterKey: 'title', + }); + // fetch capabilities for each message path + const paths = messages.map((message) => `${PATH_MAP.customMessages}/${message.id}`); + const capabilities = await this.capabilities.fetch(paths); + + return { params, messages, capabilities }; + } catch (e) { + if (e.response?.status === 404) { + return { params, messages: [] }; + } + throw e; + } } setupController(controller, resolvedModel) { diff --git a/ui/lib/config-ui/addon/routes/messages/message/details.js b/ui/lib/config-ui/addon/routes/messages/message/details.js index 1889e5e2a6..66233853ee 100644 --- a/ui/lib/config-ui/addon/routes/messages/message/details.js +++ b/ui/lib/config-ui/addon/routes/messages/message/details.js @@ -5,22 +5,36 @@ import Route from '@ember/routing/route'; import { service } from '@ember/service'; +import { decodeString } from 'core/utils/b64'; +import { PATH_MAP } from 'core/utils/capabilities'; export default class MessagesMessageDetailsRoute extends Route { - @service store; + @service api; + @service capabilities; - model() { + async model() { const { id } = this.paramsFor('messages.message'); - return this.store.queryRecord('config-ui/message', id); + const requests = [ + this.api.sys.uiConfigReadCustomMessage(id), + this.capabilities.fetchPathCapabilities(`${PATH_MAP.customMessages}/${id}`), + ]; + const [customMessage, capabilities] = await Promise.all(requests); + customMessage.message = decodeString(customMessage.message); + + return { + message: customMessage, + capabilities, + }; } setupController(controller, resolvedModel) { super.setupController(controller, resolvedModel); + const { message } = resolvedModel; controller.breadcrumbs = [ - { label: 'Messages', route: 'messages', query: { authenticated: resolvedModel.authenticated } }, - { label: resolvedModel.title }, + { label: 'Messages', route: 'messages', query: { authenticated: message.authenticated } }, + { label: message.title }, ]; } } diff --git a/ui/lib/config-ui/addon/routes/messages/message/edit.js b/ui/lib/config-ui/addon/routes/messages/message/edit.js index 109c356123..c8e139050d 100644 --- a/ui/lib/config-ui/addon/routes/messages/message/edit.js +++ b/ui/lib/config-ui/addon/routes/messages/message/edit.js @@ -5,25 +5,24 @@ import Route from '@ember/routing/route'; import { service } from '@ember/service'; -import { hash } from 'rsvp'; +import CustomMessage from 'vault/forms/custom-message'; +import { decodeString } from 'core/utils/b64'; export default class MessagesMessageEditRoute extends Route { - @service store; - - getMessages(authenticated = true) { - return this.store.query('config-ui/message', { authenticated }).catch(() => []); - } + @service api; 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), - }); + const data = await this.api.sys.uiConfigReadCustomMessage(id); + const { keyInfo, keys } = await this.api.sys.uiConfigListCustomMessages( + true, + undefined, + data.authenticated + ); + return { + message: new CustomMessage({ ...data, message: decodeString(data.message) }), + messages: keys.map((id) => ({ ...keyInfo[id], id })), + }; } setupController(controller, resolvedModel) { diff --git a/ui/lib/config-ui/addon/templates/messages/create.hbs b/ui/lib/config-ui/addon/templates/messages/create.hbs index 0e274d7201..95c1366d1e 100644 --- a/ui/lib/config-ui/addon/templates/messages/create.hbs +++ b/ui/lib/config-ui/addon/templates/messages/create.hbs @@ -6,6 +6,5 @@ \ No newline at end of file diff --git a/ui/lib/config-ui/addon/templates/messages/message/details.hbs b/ui/lib/config-ui/addon/templates/messages/message/details.hbs index 2bd020d44d..13b184714e 100644 --- a/ui/lib/config-ui/addon/templates/messages/message/details.hbs +++ b/ui/lib/config-ui/addon/templates/messages/message/details.hbs @@ -3,4 +3,8 @@ SPDX-License-Identifier: BUSL-1.1 }} - \ No newline at end of file + \ No newline at end of file diff --git a/ui/lib/config-ui/addon/templates/messages/message/edit.hbs b/ui/lib/config-ui/addon/templates/messages/message/edit.hbs index 0e274d7201..95c1366d1e 100644 --- a/ui/lib/config-ui/addon/templates/messages/message/edit.hbs +++ b/ui/lib/config-ui/addon/templates/messages/message/edit.hbs @@ -6,6 +6,5 @@ \ No newline at end of file diff --git a/ui/lib/core/addon/components/form-field.hbs b/ui/lib/core/addon/components/form-field.hbs index c63a823455..6dd6d85d39 100644 --- a/ui/lib/core/addon/components/form-field.hbs +++ b/ui/lib/core/addon/components/form-field.hbs @@ -152,17 +152,18 @@