UI: Custom login settings views (#30592)

* UI: Adding routes for custom login settings  (#30404)

* adding route block

* adding to side nav

* jk its diff

* adding TODO, adding empty files

* UI: Adding List view component for custom login settings (#30459)

* first pass setting up list view

* style fix

* messing with routes

* fix

* undo

* using mock data

* renaming

* [UI] API Service Error Parsing (#30454)

* adds error parsing method to api service

* replaces apiErrorMessage util instances with api service parseError

* removes apiErrorMessage util and tests

* removes ApiError type

* fixes issue in isLocalStorageSupported error handling

* remove cli folder (#30458)

* [DOCS] Add explicit links to older release notes (#30461)

* Add explicit links to older release notes

* remove domain from URLs

* add link to important changes as well

* bump timeout for single flaky test (#30460)

* adds list response parsing to api service (#30455)

* update versions, and replace summary in important changes section (#30471)

* Update CHANGELOG.md (#30456)

* UI: Update Enterprise Client Count Datepicker (#30349)

* date picker changes (mostly) for ent client counts

* Move edit modal button + padding

* only show start time in dropdown and add changelog

* remove unused variable and update toggle width

* remove unnecessary period end dates

* tidy

* update tests

* Update changelog/30349.txt

Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com>

* improve date logic

* add export button back in, re-arrange header, update dropdown

* update when date is shown

* add default for retention months

* update tests and remove unnecessary tests

* account for retention months that are not whole periods

* update logic to show end date on export modal

* update exported file name

---------

Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com>

* Prevent early-exit of plugin reload (#30329)

* update to use util, update to this.cap

---------

Co-authored-by: Jordan Reimer <zofskeez@gmail.com>
Co-authored-by: Sarah Chavis <62406755+schavis@users.noreply.github.com>
Co-authored-by: Angel Garbarino <Monkeychip@users.noreply.github.com>
Co-authored-by: Ellie <ellie.sterner@hashicorp.com>
Co-authored-by: Tony Wittinger <anwittin@users.noreply.github.com>
Co-authored-by: lane-wetmore <lane.wetmore@hashicorp.com>
Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com>
Co-authored-by: kpcraig <3031348+kpcraig@users.noreply.github.com>

* UI: Create details component for custom login rules (#30530)

* setup

* adding to view

* fixing table keys

* add breadcrumbs

* fixes

* removing default vals

* pr comments

* adding delete button to toolbar

* adding delete functionality

* reorder and fix error handling

* updating api call, adding error template, fixing selectors

* remove param

* UI: Updating visibility attr on auth config to be a toggle with direct login link (#30548)

* updating visibility attr to be a toggle, adding link placeholder

* update test

* test fix pt2

* updating to build link + copy button

* updates

* use the right word

* using hds text

* updating helper text, path

* use encode directly

* updating capabilities check, creating test files, empty state

* UI: Update custom login to use api instead of mirage (#30640)

* updating to use api, removing store

* temp test fix

* fixes on types, remove test funcs

* fix assertion

* adding tests

* updating test

* adding to tests

* stub delete?

* removing stubs, updating tests

* fixes

* moving cmd placement, updating inheritance

* adding changelog

* fix changelog

* pr comments

* update check & update test

* remove empty state block

* remove comment

* Revert "remove empty state block"

This reverts commit ce34d8c76fea3b43bb96c6acd342a5ba0471f441.

* remove the right empty state

---------

Co-authored-by: Jordan Reimer <zofskeez@gmail.com>
Co-authored-by: Sarah Chavis <62406755+schavis@users.noreply.github.com>
Co-authored-by: Angel Garbarino <Monkeychip@users.noreply.github.com>
Co-authored-by: Ellie <ellie.sterner@hashicorp.com>
Co-authored-by: Tony Wittinger <anwittin@users.noreply.github.com>
Co-authored-by: lane-wetmore <lane.wetmore@hashicorp.com>
Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com>
Co-authored-by: kpcraig <3031348+kpcraig@users.noreply.github.com>
This commit is contained in:
Dan Rivera 2025-05-22 14:17:14 -04:00 committed by GitHub
parent 3eff32e851
commit fbb446f974
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 431 additions and 9 deletions

7
changelog/30592.txt Normal file
View File

@ -0,0 +1,7 @@
```release-note:feature
**Custom login settings**: Adding view to list and delete custom login rules
```
```release-note:change
ui: 'Custom messages' renamed to 'System Messages'
```

View File

@ -11,6 +11,12 @@
{{#each @model.tuneAttrs as |attr|}}
{{#if (not (includes attr.name @model.userLockoutConfig.modelAttrs))}}
<FormField data-test-field @attr={{attr}} @model={{@model}} />
{{#if (eq attr.name "config.listingVisibility")}}
<div class="has-top-margin-negative-s has-bottom-margin-l is-flex-center">
<Hds::Text::Body @tag="p" @color="faint">UI login link:</Hds::Text::Body>
<Hds::Copy::Snippet @textToCopy={{this.getLoginLink}} />
</div>
{{/if}}
{{/if}}
{{/each}}

View File

@ -65,4 +65,8 @@ export default class AuthConfigOptions extends AuthConfigComponent {
this.router.transitionTo('vault.cluster.access.methods').followRedirects();
this.flashMessages.success('The configuration was saved successfully.');
}
get getLoginLink() {
return `${window.origin}/ui/vault/auth?with=${encodeURIComponent(this.args.model.path)}`;
}
}

View File

@ -122,8 +122,16 @@
<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"
{{! formerly called 'Custom Messages' }}
@text="System Messages"
data-test-sidebar-nav-link="System Messages"
/>
{{#if (or this.isRootNamespace this.namespace.isHvdAdminNamespace)}}
<Nav.Link
@route="vault.cluster.config-ui.login-settings"
@text="UI Login Rules"
data-test-sidebar-nav-link="UI Login Rules"
/>
{{/if}}
{{/if}}
</Hds::SideNav::Portal>

View File

@ -33,8 +33,12 @@ export default class MountConfigModel extends Model {
auditNonHmacResponseKeys;
@attr('mountVisibility', {
editType: 'boolean',
label: 'List method when unauthenticated',
label: 'Use as preferred UI login method',
editType: 'toggleButton',
helperTextEnabled:
'This mount will be included in the unauthenticated UI login endpoint and display as a preferred login method.',
helperTextDisabled:
'Turn on the toggle to use this auth mount as a preferred login method during UI login.',
defaultValue: false,
})
listingVisibility;

View File

@ -16,6 +16,7 @@ export const SUDO_PATHS = [
export const SUDO_PATH_PREFIXES = ['sys/leases/revoke-prefix', 'sys/leases/revoke-force'];
export const PATH_MAP = {
customLogin: apiPath`sys/config/ui/login/default-auth/${'id'}`,
customMessages: apiPath`sys/config/ui/custom-messages/${'id'}`,
syncActivate: apiPath`sys/activation-flags/secrets-sync/activate`,
syncDestination: apiPath`sys/sync/destinations/${'type'}/${'name'}`,

View File

@ -0,0 +1,59 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
}}
<PageHeader as |p|>
<p.top>
<Page::Breadcrumbs @breadcrumbs={{@breadcrumbs}} />
</p.top>
<p.levelLeft>
<h1 class="title is-3">
{{@rule.name}}
</h1>
</p.levelLeft>
</PageHeader>
<nav class="tabs" aria-label="navigation for rule details">
<ul>
<li>
<LinkTo @route="login-settings.rule.details" @model={{@rule}} data-test-tab="rule">
Details
</LinkTo>
</li>
</ul>
</nav>
<Toolbar>
<ToolbarActions>
{{#if (has-capability this.capabilities "delete" pathKey="customLogin" params=@rule)}}
<Hds::Button
@text="Delete Rule"
@color="secondary"
class="toolbar-button"
{{on "click" (fn (mut this.showConfirmModal) true)}}
data-test-rule-delete
/>
{{/if}}
</ToolbarActions>
</Toolbar>
{{#each-in @rule as |key value|}}
{{#if (eq key "defaultAuthType")}}
<InfoTableRow @alwaysRender={{true}} @label="Default method" @value={{value}} />
{{else if (eq key "backupAuthTypes")}}
<InfoTableRow @alwaysRender={{true}} @label="Backup methods" @value={{value}} />
{{else if (eq key "disableInheritance")}}
<InfoTableRow @alwaysRender={{true}} @label="Inheritance" @value={{stringify (not value)}} />
{{else}}
<InfoTableRow @alwaysRender={{true}} @label={{capitalize key}} @value={{value}} />
{{/if}}
{{/each-in}}
{{#if this.showConfirmModal}}
<ConfirmModal
@color="critical"
@onClose={{fn (mut this.showConfirmModal) false}}
@confirmMessage="This will permanently delete this rule."
@onConfirm={{this.onDelete}}
/>
{{/if}}

View File

@ -0,0 +1,48 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { service } from '@ember/service';
import { action } from '@ember/object';
import errorMessage from 'vault/utils/error-message';
/**
* @module Page::LoginSettingsRuleDetails
* Page::LoginSettingsRuleDetails component is used to display rule information.
* Shows detailed data, (eg which namespace it applies to, auth type used etc.) on a selected login rule from the custom login settings list.
*
* @example
* ```js
* <Page::LoginSettingsRuleDetails @rule={{this.rule}} />
* ```
* @param {object} rule - holds login rule data, { backup_auth_types: string[] eg. ['token'], default_auth_type: string "oidc", disable_inheritance: boolean,
* name: string "Login rule 1", namespace: string eg "admin/"}
* */
export default class LoginSettingsRuleDetails extends Component {
@service capabilities;
@service flashMessages;
@service('app-router') router;
@service api;
@tracked showConfirmModal = false;
@action
async onDelete() {
const { rule } = this.args;
try {
await this.api.sys.uiLoginDefaultAuthDeleteConfiguration(rule.name);
this.flashMessages.success(`Successfully deleted rule ${rule.name}.`);
this.router.transitionTo('vault.cluster.config-ui.login-settings.index');
} catch (error) {
const message = errorMessage(error, 'Error deleting rule. Please try again.');
this.flashMessages.danger(message);
}
}
}

View File

@ -0,0 +1,66 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
}}
<PageHeader as |p|>
<p.levelLeft>
<h1 class="title is-3">
UI Login Rules
</h1>
</p.levelLeft>
</PageHeader>
<Toolbar />
{{#if @loginRules}}
{{#each @loginRules as |rule|}}
<div class="list-item-row linked-block-item is-no-underline">
<div>
<div class="is-grid align-items-center linked-block-title">
<Hds::Icon @name="user-check" @size="24" />
<div class="has-text-weight-semibold has-left-margin-xs" data-test-rule-name={{rule.Name}}>{{rule.Name}}</div>
</div>
<div class="has-top-margin-m" data-test-rule-path={{rule.Namespace}}>{{rule.Namespace}}</div>
</div>
<div class="linked-block-popup-menu">
<Hds::Dropdown @isInline={{true}} as |dd|>
<dd.ToggleIcon
@icon="more-horizontal"
@hasChevron={{false}}
@text="login rules menu"
data-test-popup-menu-trigger
/>
<dd.Interactive @route="login-settings.rule.details" @model={{rule.Name}} data-test-popup-menu="view-rule">
View
</dd.Interactive>
{{#if (has-capability this.capabilities "delete" pathKey="customLogin" params=rule)}}
<dd.Interactive
@color="critical"
data-test-popup-menu="delete-rule"
{{on "click" (fn (mut this.ruleToDelete) rule)}}
>Delete</dd.Interactive>
{{/if}}
</Hds::Dropdown>
</div>
</div>
{{/each}}
{{else}}
<EmptyState
data-test-empty-state="login-rules-list"
@title="No UI login rules yet"
@message="Login rules can be used to select default and back up login methods and customize which methods display in the web UI login form. Available to be created via the CLI or HTTP API."
>
{{! TODO: update href with tutorial link }}
{{! <Hds::Link::Standalone @icon="arrow-right" @iconPosition="trailing" @text="Learn more" @href="/" /> }}
</EmptyState>
{{/if}}
{{#if this.ruleToDelete}}
<ConfirmModal
@color="critical"
@onClose={{fn (mut this.ruleToDelete) null}}
@confirmMessage="This will permanently delete this rule."
@onConfirm={{this.onDelete}}
/>
{{/if}}

View File

@ -0,0 +1,41 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { service } from '@ember/service';
import { action } from '@ember/object';
import errorMessage from 'vault/utils/error-message';
/**
* @module Page::LoginSettingsList
* Page::LoginSettingsList components are used to display list of rules.
* @example
* ```js
* <Page::LoginSettingsList @loginRules={{this.rules}} />
* ```
* @param {array} loginRules - array of rule objects
*/
export default class LoginSettingsList extends Component {
@service capabilities;
@service flashMessages;
@service('app-router') router;
@tracked ruleToDelete = null; // set to the rule intended to delete
@service api;
@action
async onDelete() {
try {
await this.api.sys.uiLoginDefaultAuthDeleteConfiguration(this.ruleToDelete.id);
this.flashMessages.success(`Successfully deleted rule ${this.ruleToDelete.id}.`);
this.router.transitionTo('vault.cluster.config-ui.login-settings');
} catch (error) {
const message = errorMessage(error, 'Error deleting rule. Please try again.');
this.flashMessages.danger(message);
}
}
}

View File

@ -13,4 +13,10 @@ export default buildRoutes(function () {
this.route('edit');
});
});
this.route('login-settings', function () {
this.route('rule', { path: '/:name' }, function () {
this.route('details');
});
});
});

View File

@ -0,0 +1,18 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class LoginSettingsRoute extends Route {
@service api;
async model() {
const res = await this.api.sys.uiLoginDefaultAuthList(true);
const loginRules = this.api.keyInfoToArray({ keyInfo: res.keyInfo, keys: res.keys });
return { loginRules };
}
}

View File

@ -0,0 +1,36 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class LoginSettingsRuleDetailsRoute extends Route {
@service('app-router') router;
@service api;
beforeModel() {
const { name } = this.paramsFor('login-settings.rule');
if (!name) {
this.router.transitionTo('vault.cluster.config-ui.login-settings.index');
}
}
async model() {
const { name } = this.paramsFor('login-settings.rule');
const rule = await this.api.sys.uiLoginDefaultAuthReadConfiguration(name);
return { rule: { name, ...rule.data } };
}
setupController(controller, resolvedModel) {
super.setupController(controller, resolvedModel);
controller.breadcrumbs = [
{ label: 'UI login rules', route: 'login-settings' },
{ label: resolvedModel.rule.name },
];
}
}

View File

@ -0,0 +1,15 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class LoginSettingsRuleIndexRoute extends Route {
@service('app-router') router;
redirect() {
this.router.transitionTo('vault.cluster.config-ui.login-settings.rule.details');
}
}

View File

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

View File

@ -0,0 +1,6 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
}}
<LoginSettings::Page::List @loginRules={{this.model.loginRules}} />

View File

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

View File

@ -12,6 +12,8 @@ const assertFields = (assert, fields, customSelectors = {}) => {
fields.forEach((param) => {
if (Object.keys(customSelectors).includes(param)) {
assert.dom(customSelectors[param]).exists();
} else if (param === 'config.listingVisibility') {
assert.dom(GENERAL.toggleInput('toggle-config.listingVisibility')).exists();
} else {
assert.dom(GENERAL.inputByAttr(param)).exists();
}

View File

@ -0,0 +1,82 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { click, visit, currentRouteName } from '@ember/test-helpers';
import { login } from 'vault/tests/helpers/auth/auth-helpers';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { runCmd } from 'vault/tests/helpers/commands';
module('Acceptance | Enterprise | config-ui/login-settings', function (hooks) {
setupApplicationTest(hooks);
hooks.beforeEach(async function () {
await login();
// create login rules
await runCmd([
`write sys/config/ui/login/default-auth/testRule backup_auth_types=userpass default_auth_type=okta disable_inheritance=false namespace=ns1`,
'write sys/config/ui/login/default-auth/testRule2 backup_auth_types=oidc default_auth_type=ldap disable_inheritance=true namespace=ns2',
]);
});
hooks.afterEach(async function () {
await login();
// cleanup login rules
await runCmd([
'delete sys/config/ui/login/default-auth/testRule',
'delete sys/config/ui/login/default-auth/testRule2',
]);
});
test('fetched login rule list renders', async function (assert) {
// Visit the login settings list index page
await visit('vault/config-ui/login-settings');
// verify fetched rules are rendered in list
assert.dom('.linked-block-item').exists({ count: 2 });
// verify rule data namespaces render
assert.dom('[data-test-rule-path="ns1/"]').exists();
assert.dom('[data-test-rule-path="ns2/"]').exists();
});
test('delete rule from list view', async function (assert) {
// Visit the login settings list index page
await visit('vault/config-ui/login-settings');
await click(GENERAL.menuTrigger);
await click(GENERAL.menuItem('delete-rule'));
assert.dom(GENERAL.confirmationModal).exists();
await click(GENERAL.confirmButton);
// verify success message from deletion
assert.dom(GENERAL.latestFlashContent).includesText('Successfully deleted rule testRule.');
assert.dom('[data-test-rule-name="testRule"]').doesNotExist();
});
test('navigate to rule details page and renders rule data', async function (assert) {
// visit individual rule page
await visit('vault/config-ui/login-settings');
await click(GENERAL.menuTrigger);
await click(GENERAL.menuItem('view-rule'));
// verify that user is redirected to the rule details page
assert.strictEqual(
currentRouteName(),
'vault.cluster.config-ui.login-settings.rule.details',
'goes to rule details page'
);
// verify fetched rule data is rendered
assert.dom(GENERAL.infoRowValue('Name')).hasText('testRule');
assert.dom(GENERAL.infoRowValue('Namespace')).hasText('ns1/');
assert.dom(GENERAL.infoRowValue('Backup methods')).hasText('userpass');
assert.dom(GENERAL.infoRowValue('Inheritance')).hasText('true');
});
});

View File

@ -7,7 +7,7 @@ export const CUSTOM_MESSAGES = {
// General selectors that are common between custom messages
inlineErrorMessage: `[data-test-inline-error-message]`,
unauthCreateFormInfo: '[data-test-unauth-info]',
navLink: '[data-test-sidebar-nav-link="Custom Messages"]',
navLink: '[data-test-sidebar-nav-link="System Messages"]',
radio: (radioName: string) => `[data-test-radio="${radioName}"]`,
field: (fieldName: string) => `[data-test-field="${fieldName}"]`,
input: (input: string) => `[data-test-input="${input}"]`,

View File

@ -66,7 +66,7 @@ module('Integration | Component | auth-config-form options', function (hooks) {
assert.dom('[data-test-user-lockout-section]').hasText('User lockout configuration');
await click(GENERAL.inputByAttr('config.listingVisibility'));
await click(GENERAL.toggleInput('toggle-config.listingVisibility'));
await fillIn(GENERAL.inputByAttr('config.tokenType'), 'default-batch');
await click(GENERAL.ttl.toggle('Default Lease TTL'));
@ -124,7 +124,7 @@ module('Integration | Component | auth-config-form options', function (hooks) {
.dom('[data-test-user-lockout-section]')
.doesNotExist(`${type} method does not render user lockout section`);
await click(GENERAL.inputByAttr('config.listingVisibility'));
await click(GENERAL.toggleInput('toggle-config.listingVisibility'));
await fillIn(GENERAL.inputByAttr('config.tokenType'), 'default-batch');
await click(GENERAL.ttl.toggle('Default Lease TTL'));
@ -178,7 +178,7 @@ module('Integration | Component | auth-config-form options', function (hooks) {
.dom(GENERAL.inputByAttr('config.tokenType'))
.doesNotExist('does not render tokenType for token auth method');
await click(GENERAL.inputByAttr('config.listingVisibility'));
await click(GENERAL.toggleInput('toggle-config.listingVisibility'));
await click(GENERAL.ttl.toggle('Default Lease TTL'));
await fillIn(GENERAL.ttl.input('Default Lease TTL'), '30');

View File

@ -74,7 +74,8 @@ module('Integration | Component | sidebar-nav-cluster', function (hooks) {
'Vault Usage',
'License',
'Seal Vault',
'Custom Messages',
'System Messages',
'UI Login Rules',
];
stubFeaturesAndPermissions(this.owner, true, true);
await renderComponent();