UI: (Enterprise) Login form customization feature (#30700)

* add request for custom login settings to auth route

* add tests to page integration before updating logic

* make tab component tests

* move form state logic to parent page component

* test updates for sanitizing query param in auth route

* add custom login feature

* add test for fetching login settings on ent only

* add changelog

* reword changelog

* rename variable from showOtherMethods to showAlternateView

* cleanup store

* cleanup comments per PR feedback

* abc

* VAULT-34672 render line breaks in description

* update endpoints after testing with live api

* add test coverage

* word

* remove backup types from test-ns for testing

* change to manually log in

* add error handling for no login settings

* add inheritance badge and make list item linkable
This commit is contained in:
claire bontempo 2025-05-22 15:13:14 -07:00 committed by GitHub
parent ea3a8203e4
commit 45fee5c225
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1284 additions and 446 deletions

3
changelog/30700.txt Normal file
View file

@ -0,0 +1,3 @@
```release-note:feature
**Login form customization** (enterprise): Adds support to choose a default and/or backup auth methods for the web UI login form to streamline the web UI login experience.
```

View file

@ -3,105 +3,103 @@
SPDX-License-Identifier: BUSL-1.1
}}
<div {{did-insert this.initializeState}}>
{{#if this.formComponent}}
{{#let (component this.formComponent) as |AuthFormComponent|}}
{{! renders Auth::Form::Base or Auth::Form::<Type>}}
<AuthFormComponent
@authType={{this.selectedAuthMethod}}
@cluster={{@cluster}}
@onError={{this.handleError}}
@onSuccess={{@onSuccess}}
>
<:namespace>
{{#if (has-feature "Namespaces")}}
<Auth::NamespaceInput
@disabled={{@oidcProviderQueryParam}}
@hvdManagedNamespace={{this.flags.hvdManagedNamespaceRoot}}
@namespaceValue={{this.namespaceInput}}
@updateNamespace={{this.handleNamespaceUpdate}}
{{#if this.formComponent}}
{{#let (component this.formComponent) as |AuthFormComponent|}}
{{! renders Auth::Form::Base or Auth::Form::<Type>}}
<AuthFormComponent
@authType={{this.selectedAuthMethod}}
@cluster={{@cluster}}
@onError={{this.handleError}}
@onSuccess={{@onSuccess}}
>
<:namespace>
{{#if (has-feature "Namespaces")}}
<Auth::NamespaceInput
@disabled={{@oidcProviderQueryParam}}
@hvdManagedNamespace={{this.flags.hvdManagedNamespaceRoot}}
@namespaceValue={{this.namespaceInput}}
@updateNamespace={{this.handleNamespaceUpdate}}
/>
{{/if}}
</:namespace>
<:back>
{{#if this.showAlternateView}}
<Hds::Button
@text="Back"
{{on "click" this.toggleView}}
@color="tertiary"
@icon="arrow-left"
data-test-back-button
/>
{{/if}}
</:back>
{{! DIRECT LINK, TABS OR DROPDOWN }}
<:authSelectOptions>
<div class="has-bottom-margin-m">
{{#if this.tabData}}
<Auth::Tabs
@authTabData={{this.tabData}}
@handleTabClick={{this.setAuthType}}
@selectedAuthMethod={{this.selectedAuthMethod}}
/>
{{else}}
{{! Fallback is dropdown with all auth methods }}
<Hds::Form::Select::Field
name="selectedAuthMethod"
{{on "input" this.setTypeFromDropdown}}
data-test-select="auth type"
as |F|
>
<F.Label class="has-top-margin-m">Method</F.Label>
<F.Options>
{{#each this.supportedAuthTypes as |type|}}
<option selected={{eq this.selectedAuthMethod type}} value={{type}}>
{{auth-display-name type}}
</option>
{{/each}}
</F.Options>
</Hds::Form::Select::Field>
{{/if}}
</:namespace>
</div>
</:authSelectOptions>
<:back>
{{#if this.showOtherMethods}}
<Hds::Button
@text="Back"
{{on "click" this.toggleView}}
@color="tertiary"
@icon="arrow-left"
data-test-back-button
/>
{{/if}}
</:back>
<:error>
{{#if this.errorMessage}}
<MessageError @errorMessage={{this.errorMessage}} />
{{/if}}
</:error>
{{! DIRECT LINK, TABS OR DROPDOWN }}
<:authSelectOptions>
<div class="has-bottom-margin-m">
{{#if this.showCustomAuthOptions}}
<Auth::Tabs
@authTabData={{this.tabData}}
@handleTabClick={{this.setAuthType}}
@selectedAuthMethod={{this.selectedAuthMethod}}
/>
{{else}}
{{! fallback view is the dropdown with all auth methods }}
<Hds::Form::Select::Field
name="selectedAuthMethod"
{{on "input" this.setTypeFromDropdown}}
data-test-select="auth type"
as |F|
>
<F.Label class="has-top-margin-m">Auth method</F.Label>
<F.Options>
{{#each this.availableMethodTypes as |type|}}
<option selected={{eq this.selectedAuthMethod type}} value={{type}}>
{{auth-display-name type}}
</option>
{{/each}}
</F.Options>
</Hds::Form::Select::Field>
{{/if}}
</div>
</:authSelectOptions>
<:advancedSettings>
{{! custom auth options render their own mount path inputs and token does not support custom paths }}
{{#unless this.hideAdvancedSettings}}
<Hds::Reveal @text="Advanced settings" data-test-auth-form-options-toggle class="is-fullwidth">
<Hds::Form::TextInput::Field name="path" data-test-input="path" as |F|>
<F.Label class="has-top-margin-m">Mount path</F.Label>
<F.HelperText>
If this authentication method was mounted using a non-default path, input it below. Otherwise Vault will
assume the default path
<Hds::Text::Code class="code-in-text">{{this.selectedAuthMethod}}</Hds::Text::Code>
.</F.HelperText>
</Hds::Form::TextInput::Field>
</Hds::Reveal>
{{/unless}}
</:advancedSettings>
<:error>
{{#if this.errorMessage}}
<MessageError @errorMessage={{this.errorMessage}} />
{{/if}}
</:error>
<:advancedSettings>
{{! custom auth options render their own mount path inputs and token does not support custom paths }}
{{#if (and (not this.showCustomAuthOptions) (not-eq this.selectedAuthMethod "token"))}}
<Hds::Reveal @text="Advanced settings" data-test-auth-form-options-toggle class="is-fullwidth">
<Hds::Form::TextInput::Field name="path" data-test-input="path" as |F|>
<F.Label class="has-top-margin-m">Mount path</F.Label>
<F.HelperText>
If this authentication method was mounted using a non-default path, input it below. Otherwise Vault will
assume the default path
<Hds::Text::Code class="code-in-text">{{this.selectedAuthMethod}}</Hds::Text::Code>
.</F.HelperText>
</Hds::Form::TextInput::Field>
</Hds::Reveal>
{{/if}}
</:advancedSettings>
<:footer>
{{#if this.showCustomAuthOptions}}
<Hds::Button
{{on "click" this.toggleView}}
@color="tertiary"
@icon="arrow-right"
@iconPosition="trailing"
@isInline={{true}}
@text="Sign in with other methods"
data-test-other-methods-button
/>
{{/if}}
</:footer>
</AuthFormComponent>
{{/let}}
{{/if}}
</div>
<:footer>
{{#if (and @alternateView (not this.showAlternateView))}}
<Hds::Button
{{on "click" this.toggleView}}
@color="tertiary"
@icon="arrow-right"
@iconPosition="trailing"
@isInline={{true}}
@text="Sign in with other methods"
data-test-other-methods-button
/>
{{/if}}
</:footer>
</AuthFormComponent>
{{/let}}
{{/if}}

View file

@ -10,12 +10,10 @@ import { action } from '@ember/object';
import { supportedTypes } from 'vault/utils/supported-login-methods';
import { getRelativePath } from 'core/utils/sanitize-path';
import type AuthService from 'vault/vault/services/auth';
import type FlagsService from 'vault/services/flags';
import type Store from '@ember-data/store';
import type VersionService from 'vault/services/version';
import type ClusterModel from 'vault/models/cluster';
import type { UnauthMountsByType, AuthTabMountData } from 'vault/vault/auth/form';
import type { UnauthMountsByType } from 'vault/vault/auth/form';
import type { HTMLElementEvent } from 'vault/forms';
/**
@ -29,64 +27,64 @@ import type { HTMLElementEvent } from 'vault/forms';
* dynamically renders the corresponding form.
*
*
* @param {object | null} alternateView - if an alternate view exists, this is the `FormView` (see interface below) data to render that view.
* @param {string} canceledMfaAuth - saved auth type from a cancelled mfa verification
* @param {object} cluster - The route model which is the ember data cluster model. contains information such as cluster id, name and boolean for if the cluster is in standby
* @param {object} directLinkData - mount data built from the "with" query param. If param is a mount path and maps to a visible mount, the login form defaults to this mount. Otherwise the form preselects the passed auth type.
* @param {object} defaultView - The `FormView` (see the interface below) data to render the initial view.
* @param {function} handleNamespaceUpdate - callback task that passes user input to the controller and updates the namespace query param in the url
* @param {object} initialFormState - sets selectedAuthMethod and showAlternateView based on the login form configuration computed in parent component
* @param {string} namespaceQueryParam - namespace query param from the url
* @param {string} oidcProviderQueryParam - oidc provider query param, set in url as "?o=someprovider". if present, disables the namespace input
* @param {function} onSuccess - callback after the initial authentication request, if an mfa_requirement exists the parent renders the mfa form otherwise it fires the authSuccess action in the auth controller and handles transitioning to the app
* @param {object} visibleMountsByType - auth methods to render as tabs, contains mount data for any mounts with listing_visibility="unauth"
* @param {array} visibleMountTypes - array of auth method types that have mounts with listing_visibility="unauth"
*
* */
interface Args {
canceledMfaAuth: string;
alternateView: FormView | null;
cluster: ClusterModel;
directLinkData: (AuthTabMountData & { isVisibleMount: boolean }) | null;
defaultView: FormView;
handleNamespaceUpdate: CallableFunction;
initialFormState: { initialAuthType: string; showAlternate: boolean };
namespaceQueryParam: string;
oidcProviderQueryParam: string;
onSuccess: CallableFunction;
visibleMountsByType: UnauthMountsByType;
visibleMountTypes: string[];
}
interface FormView {
view: string; // "dropdown" or "tabs"
tabData: UnauthMountsByType | null; // tabs to render if view = "tabs"
}
export default class AuthFormTemplate extends Component<Args> {
@service declare readonly auth: AuthService;
@service declare readonly flags: FlagsService;
@service declare readonly store: Store;
@service declare readonly version: VersionService;
// true → "Back" button renders, false → "Sign in with other methods→" renders if customizations exist
@tracked showOtherMethods = false;
supportedAuthTypes: string[];
// auth login variables
@tracked selectedAuthMethod = '';
@tracked errorMessage = '';
@tracked selectedAuthMethod = '';
// true → "Back" button renders, false → "Sign in with other methods→" renders if an alternate view exists
@tracked showAlternateView = false;
constructor(owner: unknown, args: Args) {
super(owner, args);
const { initialAuthType, showAlternate } = this.args.initialFormState;
this.selectedAuthMethod = initialAuthType;
this.showAlternateView = showAlternate;
this.supportedAuthTypes = supportedTypes(this.version.isEnterprise);
}
get tabData() {
const { directLinkData } = this.args;
// URL contains a "with" query param that references a mount with listing_visibility="unauth"
// Treat it as a "preferred" mount and hide all other tabs
if (directLinkData?.isVisibleMount && directLinkData?.type) {
return { [directLinkData.type]: [this.args.directLinkData] };
}
return this.args.visibleMountsByType;
}
get authTabTypes() {
const visibleMounts = this.args.visibleMountsByType;
return visibleMounts ? Object.keys(visibleMounts) : [];
}
get availableMethodTypes() {
return supportedTypes(this.version.isEnterprise);
if (this.showAlternateView) return this.args?.alternateView?.tabData;
return this.args?.defaultView?.tabData;
}
get formComponent() {
const { selectedAuthMethod } = this;
// isSupported means there is a component file defined for that auth type
const isSupported = this.availableMethodTypes.includes(selectedAuthMethod);
const isSupported = this.supportedAuthTypes.includes(selectedAuthMethod);
const formFile = () => (['oidc', 'jwt'].includes(selectedAuthMethod) ? 'oidc-jwt' : selectedAuthMethod);
const component = isSupported ? formFile() : 'base';
@ -94,6 +92,17 @@ export default class AuthFormTemplate extends Component<Args> {
return `auth/form/${component}`;
}
get hideAdvancedSettings() {
// Token does not support custom paths
if (this.selectedAuthMethod === 'token') return true;
// Always show for dropdown mode
if (!this.tabData) return false;
// For remaining scenarios, hide "Advanced settings" if the selected method has visible mount(s)
return this.args.visibleMountTypes?.includes(this.selectedAuthMethod);
}
get namespaceInput() {
const namespaceQueryParam = this.args.namespaceQueryParam;
if (this.flags.hvdManagedNamespaceRoot) {
@ -105,42 +114,6 @@ export default class AuthFormTemplate extends Component<Args> {
return namespaceQueryParam;
}
get preselectedType() {
// Prioritize canceledMfaAuth since it's triggered by user interaction.
// Next, check type from directLinkData as it's specified by the URL.
// Finally, fall back to the most recently used auth method in localStorage.
return this.args.canceledMfaAuth || this.args.directLinkData?.type || this.auth.getAuthType();
}
// The "standard" selection is a dropdown listing all auth methods.
// This getter determines whether to render an alternative view (e.g., tabs or a preferred mount).
// If `true`, the "Sign in with other methods →" link is shown.
get showCustomAuthOptions() {
const hasLoginCustomization = this.args?.directLinkData?.isVisibleMount || this.args.visibleMountsByType;
// Show if customization exists and the user has NOT clicked "Sign in with other methods →"
return hasLoginCustomization && !this.showOtherMethods;
}
@action
initializeState() {
// SET AUTH TYPE
if (this.preselectedType) {
this.setAuthType(this.preselectedType);
} else {
// if nothing has been preselected, select first tab or set to 'token'
const authType = this.args.visibleMountsByType ? (this.authTabTypes[0] as string) : 'token';
this.setAuthType(authType);
}
// DETERMINES INITIAL RENDER: custom selection (direct link or tabs) vs dropdown
if (this.args.visibleMountsByType) {
// render tabs if selectedAuthMethod is one, otherwise render dropdown (i.e. showOtherMethods = false)
this.showOtherMethods = this.authTabTypes.includes(this.selectedAuthMethod) ? false : true;
} else {
this.showOtherMethods = false;
}
}
@action
setAuthType(authType: string) {
this.selectedAuthMethod = authType;
@ -153,15 +126,10 @@ export default class AuthFormTemplate extends Component<Args> {
@action
toggleView() {
this.showOtherMethods = !this.showOtherMethods;
if (this.showCustomAuthOptions) {
const firstTab = this.authTabTypes[0] as string;
this.setAuthType(firstTab);
} else {
// all methods render, reset dropdown
this.selectedAuthMethod = this.preselectedType || 'token';
}
this.showAlternateView = !this.showAlternateView;
const firstAuthTab = Object.keys(this.tabData || {})[0];
const type = firstAuthTab || this.args.initialFormState.initialAuthType;
this.setAuthType(type);
}
@action

View file

@ -50,14 +50,15 @@
/>
{{else}}
<Auth::FormTemplate
@canceledMfaAuth={{this.canceledMfaAuth}}
@alternateView={{this.formViews.alternateView}}
@cluster={{@cluster}}
@directLinkData={{@directLinkData}}
@defaultView={{this.formViews.defaultView}}
@handleNamespaceUpdate={{@onNamespaceUpdate}}
@initialFormState={{this.initialFormState}}
@namespaceQueryParam={{@namespaceQueryParam}}
@oidcProviderQueryParam={{@oidcProviderQueryParam}}
@onSuccess={{this.onAuthResponse}}
@visibleMountsByType={{this.visibleMountsByType}}
@visibleMountTypes={{this.visibleMountTypes}}
/>
{{/if}}
</:content>

View file

@ -10,28 +10,66 @@ import { action } from '@ember/object';
import type { AuthResponse, AuthResponseWithMfa } from 'vault/vault/services/auth';
import type { UnauthMountsByType, UnauthMountsResponse } from 'vault/vault/auth/form';
import type AuthService from 'vault/vault/services/auth';
import type ClusterModel from 'vault/models/cluster';
import type CspEventService from 'vault/services/csp-event';
/**
* @module AuthPage
* The Auth::Page is the route template for the login splash view. It renders the Auth::FormTemplate or MFA component if an
* mfa validation is returned from the auth request. It also formats mount data to manage what tabs are rendered in Auth::FormTemplate.
* Auth::Page renders the Auth::FormTemplate or MFA component if an mfa validation is returned from the auth request.
* It receives configuration settings from the route's model hook and determines the possible form states passed to Auth::FormTemplate.
* The model hook refreshes when the namespace input updates and re-requests `sys/internal/ui/mounts` and the login settings endpoint (enterprise only).
*
* CONFIGURATION OVERVIEW:
* The login form either renders a `dropdown` or `tabs` depending on specific configuration combinations.
* In some scenarios, the component supports toggling between a default view and an alternate view.
*
* 📋 Dropdown (default view)
* All supported auth methods show in a dropdown.
* No alternate view.
*
* 🗂 Visible mount tabs
* Groups visible mounts (`listing_visibility="unauth"`) by type and displays as tabs.
* Alternate view: full dropdown of all methods.
*
* 🔗 Direct link (via `?with=` query param)
* If the param references a visible mount, that method renders by default and the mount path is assumed.
* Alternate view: full dropdown.
* If the param references a method type (legacy behavior), the method is preselected in the dropdown or its tab is selected.
* Alternate view: if other methods have visible mounts, the form can toggle between tabs and dropdown. The initial view depends on whether the chosen type is a tab.
*
* 🏢 Login settings * enterprise only *
* A namespace can define a default method and/or preferred methods (i.e. "backups") and enable child namespaces to inherit these preferences.
* Both set:
* Default method shown initially.
* Alternate view: preferred methods in tab layout.
* Only one set:
* No alternate view.
*
* 🛠 Advanced settings toggle reveals the custom path input:
* 🚫 No visible mounts:
* UI defaults to method type as path.
* "Advanced settings" shows a path input.
* 1 One visible mount:
* Path is assumed and hidden.
* 🔀 Multiple visible mounts:
* Path dropdown is shown.
*
* @example
* <Auth::Page
* @cluster={{this.model.clusterModel}}
* @directLinkData={{this.model.directLinkData}}
* @loginSettings={{this.model.loginSettings}}
* @namespaceQueryParam={{this.namespaceQueryParam}}
* @oidcProviderQueryParam={{this.oidcProvider}}
* @onAuthSuccess={{action "authSuccess"}}
* @onNamespaceUpdate={{perform this.updateNamespace}}
* @visibleAuthMounts={{this.model.visibleAuthMounts}}
* @directLinkData={{this.model.directLinkData}}
* />
*
* @param {object} cluster - the ember data cluster model. contains information such as cluster id, name and boolean for if the cluster is in standby
* @param {object} directLinkData - mount data built from the "with" query param. If param is a mount path and maps to a visible mount, the login form defaults to this mount. Otherwise the form preselects the passed auth type.
* @param {object} loginSettings - * enterprise only * login settings configured for the namespace
* @param {object} loginSettings - * enterprise only * login settings configured for the namespace. If set, specifies a default auth method type and/or backup method types
* @param {string} namespaceQueryParam - namespace to login with, updated by typing in to the namespace input
* @param {string} oidcProviderQueryParam - oidc provider query param, set in url as "?o=someprovider"
* @param {function} onAuthSuccess - callback task in controller that receives the auth response (after MFA, if enabled) when login is successful
@ -43,18 +81,26 @@ export const CSP_ERROR =
"This is a standby Vault node but can't communicate with the active node via request forwarding. Sign in at the active node to use the Vault UI.";
interface Args {
visibleAuthMounts: UnauthMountsResponse;
cluster: ClusterModel;
directLinkData: { type: string; path?: string } | null; // if "path" key is present then mount data is visible
loginSettings: { defaultType: string; backupTypes: string[] | null }; // enterprise only
onAuthSuccess: CallableFunction;
visibleAuthMounts: UnauthMountsResponse;
}
interface MfaAuthData {
mfa_requirement: object;
selectedAuth: string;
path: string;
selectedAuth: string;
}
enum FormView {
DROPDOWN = 'dropdown',
TABS = 'tabs',
}
export default class AuthPage extends Component<Args> {
@service declare readonly auth: AuthService;
@service('csp-event') declare readonly csp: CspEventService;
@tracked canceledMfaAuth = '';
@ -75,12 +121,117 @@ export default class AuthPage extends Component<Args> {
return null;
}
get visibleMountTypes(): string[] {
return Object.keys(this.visibleMountsByType || {});
}
get cspError() {
const isStandby = this.args.cluster.standby;
const hasConnectionViolations = this.csp.connectionViolations.length;
return isStandby && hasConnectionViolations ? CSP_ERROR : '';
}
// FORM STATE GETTERS
get formViews() {
const { directLinkData, loginSettings } = this.args;
if (directLinkData) {
return this.directLinkViews;
}
if (loginSettings) {
return this.loginSettingsViews;
}
if (this.visibleMountsByType) {
return this.visibleMountViews;
}
// If none of the above, the UI renders the standard dropdown with no alternate views
return this.standardDropdownView;
}
get initialAuthType(): string {
// First, prioritize canceledMfaAuth since it's set by user interaction.
// Next, "type" from direct link since the URL query param overrides any login settings.
// Then, first tab which is either the first backup method or visible mount tab.
// Finally, fallback to the most recently used auth method in localStorage.
// Token is the default otherwise.
const directLinkType = this.args.directLinkData?.type;
const firstTab = Object.keys(this.formViews.defaultView?.tabData || {})[0];
return this.canceledMfaAuth || directLinkType || firstTab || this.auth.getAuthType() || 'token';
}
get directLinkViews() {
const { directLinkData } = this.args;
// If "path" key exists we know the "with" query param references a mount with listing_visibility="unauth"
// Treat it as a preferred method and hide all other tabs.
if (directLinkData?.path) {
const tabData = this.filterVisibleMountsByType([directLinkData.type]);
const defaultView = this.constructViews(FormView.TABS, tabData);
const alternateView = this.constructViews(FormView.DROPDOWN, null);
return { defaultView, alternateView };
}
// Otherwise, directLinkData just has a "type" key.
// Render either visibleMountViews or dropdown with that type preselected
return this.visibleMountsByType ? this.visibleMountViews : this.standardDropdownView;
}
get standardDropdownView() {
return {
defaultView: this.constructViews(FormView.DROPDOWN, null),
alternateView: null,
};
}
get loginSettingsViews() {
const { loginSettings } = this.args;
const defaultType = loginSettings?.defaultType;
const backupTypes = loginSettings?.backupTypes;
// If a default is not set, render backup methods as the initial view
const preferredTypes = defaultType ? [defaultType] : backupTypes;
let defaultView;
if (preferredTypes) {
const tabData = this.filterVisibleMountsByType(preferredTypes);
defaultView = this.constructViews(FormView.TABS, tabData);
}
// Both default and backups must be set for an alternate view to exist
let alternateView = null;
if (defaultType && backupTypes) {
const tabData = this.filterVisibleMountsByType(backupTypes);
alternateView = this.constructViews(FormView.TABS, tabData);
}
return { defaultView, alternateView };
}
get visibleMountViews() {
const defaultView = this.constructViews(FormView.TABS, this.visibleMountsByType);
const alternateView = this.constructViews(FormView.DROPDOWN, null);
return { defaultView, alternateView };
}
get initialFormState() {
const { defaultView, alternateView } = this.formViews;
const hasTab = (tabData: object) => Object.keys(tabData).includes(this.initialAuthType);
const authIsNotDefaultTab = !hasTab(defaultView?.tabData || {});
const hasAlternateView = !!alternateView;
const authIsAlternateTab = hasTab(alternateView?.tabData || {});
// In rare cases, pre-toggle the form to the fallback dropdown if the selected method is not in the alternate view.
// This could happen if tabs render for visible mounts and the "with" query param references a type that isn't a tab.
// Or auth type is preset from canceled MFA or local storage and is not in the default view.
const showAlternate = (authIsNotDefaultTab && hasAlternateView) || authIsAlternateTab;
return { initialAuthType: this.initialAuthType, showAlternate };
}
// ACTIONS
@action
onAuthResponse(authResponse: AuthResponse | AuthResponseWithMfa, { selectedAuth = '', path = '' }) {
const mfa_requirement = 'mfa_requirement' in authResponse ? authResponse.mfa_requirement : undefined;
@ -118,4 +269,18 @@ export default class AuthPage extends Component<Args> {
this.mfaAuthData = null;
this.mfaErrors = '';
}
// HELPERS
private filterVisibleMountsByType(authTypes: string[]) {
const tabs: UnauthMountsByType = {};
for (const type of authTypes) {
// adds visible mounts for each type, if they exist
tabs[type] = this.visibleMountsByType?.[type] || null;
}
return tabs;
}
private constructViews(view: FormView, tabData: UnauthMountsByType | null) {
return { view, tabData };
}
}

View file

@ -11,7 +11,7 @@
{{! Elements "behind" tabs always render on the DOM and are just superficially hidden/shown.
However, for accessibility, we only want to render form inputs relevant to the selected method.
By wrapping the elements in this conditional, it only renders them when the tab is selected. }}
{{#if (eq @selectedAuthMethod methodType)}}
{{#if (and mounts (eq @selectedAuthMethod methodType))}}
{{#if (gt mounts.length 1)}}
{{! DROPDOWN for mount paths }}
<Hds::Form::Select::Field name="path" data-test-select="path" as |F|>
@ -26,7 +26,12 @@
{{! SINGLE mount path }}
{{#let (get mounts "0") as |mount|}}
{{#if mount.description}}
<Hds::Text::Body @tag="p" @color="faint" data-test-description>{{mount.description}}</Hds::Text::Body>
<Hds::Text::Body
@tag="p"
@color="faint"
class="white-space-pre-line"
data-test-description
>{{mount.description}}</Hds::Text::Body>
{{/if}}
{{! the token auth method does't support custom paths so no need to render an input }}
{{#if (not-eq @selectedAuthMethod "token")}}

View file

@ -19,6 +19,8 @@ export default class AuthRoute extends ClusterRouteBase {
@service api;
@service auth;
@service flashMessages;
@service namespace;
@service store;
@service version;
beforeModel() {
@ -36,13 +38,15 @@ export default class AuthRoute extends ClusterRouteBase {
return { clusterModel, unwrapResponse: authResponse };
}
const loginSettings = this.version.isEnterprise ? await this.fetchLoginSettings() : null;
const visibleAuthMounts = await this.fetchMounts();
const authMount = params?.authMount;
return {
clusterModel,
visibleAuthMounts,
directLinkData: authMount ? this.getMountOrTypeData(authMount, visibleAuthMounts) : null,
directLinkData: this.getDirectLinkData(authMount, visibleAuthMounts),
loginSettings,
};
}
@ -84,6 +88,30 @@ export default class AuthRoute extends ClusterRouteBase {
}
}
async fetchLoginSettings() {
const adapter = this.store.adapterFor('application');
try {
// TODO update with api service when api-client is updated
const response = await adapter.ajax(
'/v1/sys/internal/ui/default-auth-methods',
'GET',
this.api.buildHeaders({ token: '' })
);
if (response?.data) {
const { default_auth_type, backup_auth_types } = response.data;
return {
defaultType: default_auth_type,
// TODO WIP backend PR consistently return empty array when no backup_auth_types
backupTypes: backup_auth_types?.length ? backup_auth_types : null,
};
}
} catch {
// swallow if there's an error and fallback to default login form configuration
return null;
}
}
async fetchMounts() {
try {
const resp = await this.api.sys.internalUiListEnabledVisibleMounts(
@ -101,19 +129,26 @@ export default class AuthRoute extends ClusterRouteBase {
In older versions of Vault, the "with" query param could refer to either the auth mount path or the type
(which may be the same, since the default mount path *is* the type).
For backward compatibility, we handle both scenarios.
If `authMount` matches a visible auth mount, return its mount data (which includes the type).
If it matches a supported auth type instead, return just the type to preselect it in the dropdown.
If `authMount` matches a visible auth mount the method will assume that mount path to login and render as the default in the login form.
If `authMount` matches a supported auth type (and the mount does not have `listing_visibility="unauth"`), that type is preselected in the login form.
*/
getMountOrTypeData(authMount, visibleAuthMounts) {
if (visibleAuthMounts?.[authMount]) {
return { path: authMount, ...visibleAuthMounts[authMount], isVisibleMount: true };
getDirectLinkData(authMount, visibleAuthMounts) {
if (!authMount) return null;
const sanitizedParam = sanitizePath(authMount); // strip leading/trailing slashes
// mount paths in visibleAuthMounts always end in a slash, so format for consistency
const formattedPath = `${sanitizedParam}/`;
const mountData = visibleAuthMounts?.[formattedPath];
if (mountData) {
return { path: formattedPath, type: mountData.type };
}
const types = supportedTypes(this.version.isEnterprise);
if (types.includes(sanitizePath(authMount))) {
return { type: authMount, isVisibleMount: false };
if (types.includes(sanitizedParam)) {
return { type: sanitizedParam };
}
// `type` is necessary because it determines which login fields to render.
// If we can't safely glean it from the query param, ignore it and return null
// If we can't safely glean it from the query param, ignore it and return null.
return null;
}
}

View file

@ -64,6 +64,10 @@
}
}
.white-space-pre-line {
white-space: pre-line;
}
// large grouped css blocks
.is-hint {
color: color_variables.$grey;

View file

@ -16,6 +16,7 @@
<Auth::Page
@cluster={{this.model.clusterModel}}
@directLinkData={{this.model.directLinkData}}
@loginSettings={{this.model.loginSettings}}
@namespaceQueryParam={{this.namespaceQueryParam}}
@oidcProviderQueryParam={{this.oidcProvider}}
@onAuthSuccess={{action "authSuccess"}}

View file

@ -37,16 +37,9 @@
{{/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 this.displayFields as |key label|}}
<InfoTableRow @alwaysRender={{true}} @label={{label}} @value={{this.displayValue key}} />
{{/each-in}}
{{#if this.showConfirmModal}}

View file

@ -30,6 +30,19 @@ export default class LoginSettingsRuleDetails extends Component {
@tracked showConfirmModal = false;
displayFields = {
defaultAuthType: 'Default method',
backupAuthTypes: 'Backup methods',
disableInheritance: 'Inheritance enabled',
namespace: 'Namespace',
};
displayValue = (key) => {
const value = this.args.rule[key];
// flip boolean for disable inheritance so template reads "Inheritance enabled: Yes/No"
return key === 'disableInheritance' ? !value : value;
};
@action
async onDelete() {
const { rule } = this.args;

View file

@ -15,35 +15,52 @@
{{#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>
<LinkedBlock
class="list-item-row"
@params={{array "login-settings.rule.details" rule.name}}
@linkPrefix="vault.cluster.config-ui"
data-test-rule={{rule.name}}
>
<div class="level is-mobile">
<div class="level-left">
<div>
<Hds::Text::Display @tag="h3">
<Hds::Icon @name="user-check" @size="24" @isInline={{true}} />
{{rule.name}}
</Hds::Text::Display>
<div class="has-top-margin-m">
{{rule.namespace}}
<Hds::Badge
@text="Inheritance {{if rule.disableInheritance 'disabled' 'enabled'}}"
class="has-left-margin-xxs"
/>
</div>
</div>
</div>
<div class="level-right is-flex is-paddingless is-marginless">
<div class="level-item">
<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>
<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>
</LinkedBlock>
{{/each}}
{{else}}
<EmptyState

View file

@ -12,10 +12,10 @@ 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
*/

View file

@ -10,9 +10,18 @@ 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 };
try {
const res = await this.api.sys.uiLoginDefaultAuthList(true);
const loginRules = this.api.keyInfoToArray({ keyInfo: res.keyInfo, keys: res.keys });
return { loginRules };
} catch (e) {
const error = await this.api.parseError(e);
if (error.status === 404) {
// If no login settings exist, return an empty array to render the empty state
return { loginRules: [] };
}
// Otherwise fallback to the standard error template
throw error;
}
}
}

View file

@ -45,7 +45,7 @@ export default function (server) {
});
// UNAUTHENTICATED READ ONLY for login form display logic
server.get('sys/internal/ui/default-login-methods', (schema, req) => {
server.get('sys/internal/ui/default-auth-methods', (schema, req) => {
const nsHeader = req.requestHeaders['X-Vault-Namespace'];
// if no namespace is passed, assume root
const namespace = !nsHeader ? '' : nsHeader;

View file

@ -12,11 +12,11 @@ export default function (server) {
disable_inheritance: true,
});
server.create('login-rule', {
namespace: 'admin/',
namespace: 'admin',
default_auth_type: 'oidc',
backup_auth_types: ['token'],
});
server.create('login-rule', { default_auth_type: 'jwt', backup_auth_types: null }); // namespace-2
server.create('login-rule', { default_auth_type: 'jwt', backup_auth_types: [] }); // namespace-2
server.create('login-rule', { default_auth_type: '', backup_auth_types: ['oidc', 'jwt'] }); // namespace-3
server.create('login-rule', { default_auth_type: '', backup_auth_types: ['token'] }); // namespace-4
}

View file

@ -16,7 +16,13 @@ import {
mountEngineCmd,
runCmd,
} from 'vault/tests/helpers/commands';
import { login, loginMethod, loginNs, logout, VISIBLE_MOUNTS } from 'vault/tests/helpers/auth/auth-helpers';
import {
login,
loginMethod,
loginNs,
logout,
SYS_INTERNAL_UI_MOUNTS,
} from 'vault/tests/helpers/auth/auth-helpers';
import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors';
import { v4 as uuidv4 } from 'uuid';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
@ -29,6 +35,17 @@ module('Acceptance | auth login form', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
test('it does not request login settings for community versions', async function (assert) {
assert.expect(1); // should only be one assertion because the stubbed mirage request should NOT be hit
this.owner.lookup('service:version').type = 'community';
this.server.get('/sys/internal/ui/default-auth-methods', () => {
// cannot throw error here because request errors are swallowed
assert.false(true, 'request made for login settings and it should not have been');
});
await visit('/vault/auth');
assert.strictEqual(currentURL(), '/vault/auth');
});
test('it selects auth method if "with" query param is a supported auth method', async function (assert) {
const backends = supportedAuthBackends();
assert.expect(backends.length);
@ -38,6 +55,16 @@ module('Acceptance | auth login form', function (hooks) {
}
});
test('it selects auth method if "with" query param ends in an unencoded a slash', async function (assert) {
await visit('/vault/auth?with=userpass/');
assert.dom(AUTH_FORM.selectMethod).hasValue('userpass');
});
test('it selects auth method if "with" query param ends in an encoded slash and matches an auth type', async function (assert) {
await visit('/vault/auth?with=userpass%2F');
assert.dom(AUTH_FORM.selectMethod).hasValue('userpass');
});
test('it redirects if "with" query param is not a supported auth method', async function (assert) {
await visit('/vault/auth?with=fake');
assert.strictEqual(currentURL(), '/vault/auth', 'invalid query param is cleared');
@ -74,7 +101,7 @@ module('Acceptance | auth login form', function (hooks) {
module('listing visibility', function (hooks) {
hooks.beforeEach(async function () {
this.server.get('/sys/internal/ui/mounts', () => {
return { data: { auth: VISIBLE_MOUNTS } };
return { data: { auth: SYS_INTERNAL_UI_MOUNTS } };
});
await logout(); // clear local storage
});
@ -84,7 +111,7 @@ module('Acceptance | auth login form', function (hooks) {
const expectedTabs = [
{ type: 'userpass', display: 'Userpass' },
{ type: 'oidc', display: 'OIDC' },
{ type: 'token', display: 'Token' },
{ type: 'ldap', display: 'LDAP' },
];
await visit('/vault/auth');
await waitFor(AUTH_FORM.tabs);
@ -129,9 +156,9 @@ module('Acceptance | auth login form', function (hooks) {
});
test('it selects type from dropdown if query param is NOT a visible mount, but is a supported method', async function (assert) {
await visit('/vault/auth?with=ldap');
await visit('/vault/auth?with=token');
await waitFor(GENERAL.selectByAttr('auth type'));
assert.dom(GENERAL.selectByAttr('auth type')).hasValue('ldap');
assert.dom(GENERAL.selectByAttr('auth type')).hasValue('token');
assert.dom(GENERAL.backButton).exists('it renders "Back" button because tabs do exist');
assert
.dom(AUTH_FORM.otherMethodsBtn)
@ -357,9 +384,7 @@ module('Acceptance | auth login form', function (hooks) {
await visit('/vault/auth');
this.server.get('/sys/internal/ui/mounts', (_, req) => {
// sometimes the namespace header is "X-Vault-Namespace" and other times "x-vault-namespace"...haven't figured out why
const key = Object.keys(req.requestHeaders).find((k) => k.toLowerCase().includes('namespace'));
assert.strictEqual(req.requestHeaders[key], 'admin', `${key}: header contains namespace`);
assert.strictEqual(req.requestHeaders['x-vault-namespace'], 'admin', 'header contains namespace');
req.passthrough();
});
await typeIn(GENERAL.inputByAttr('namespace'), 'admin');

View file

@ -0,0 +1,91 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { click, fillIn, typeIn, visit, waitFor } from '@ember/test-helpers';
import { runCmd } from 'vault/tests/helpers/commands';
import { login, logout, rootToken } from 'vault/tests/helpers/auth/auth-helpers';
import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
// Auth form login settings
// This feature has thorough integration test coverage so only testing a few scenarios and direct link functionality
// Tests for read/list views are in ui/tests/acceptance/config-ui/login-settings-test.js
module('Acceptance | Enterprise | auth form custom login settings', function (hooks) {
setupApplicationTest(hooks);
hooks.beforeEach(async function () {
await login();
await runCmd([
`write sys/namespaces/test-ns -force`,
`write test-ns/sys/namespaces/child -force`,
`write sys/config/ui/login/default-auth/root-rule backup_auth_types=token default_auth_type=okta disable_inheritance=false namespace=""`,
`write sys/config/ui/login/default-auth/ns-rule default_auth_type=ldap disable_inheritance=true namespace=test-ns`,
`write sys/auth/my-oidc type=oidc`,
`write sys/auth/my-oidc/tune listing_visibility="unauth"`,
]);
return await logout();
});
hooks.afterEach(async function () {
// cleanup login rules
await visit('/vault/auth?with=token');
await fillIn(GENERAL.inputByAttr('token'), rootToken);
await click(AUTH_FORM.login);
await runCmd([
'delete sys/config/ui/login/default-auth/root-rule',
'delete sys/config/ui/login/default-auth/ns-rule',
'delete sys/auth/my-oidc',
'delete test-ns/sys/namespaces/child',
'delete sys/namespaces/test-ns',
]);
});
test('it renders login settings for root namespace', async function (assert) {
await visit('/vault/auth');
await waitFor(AUTH_FORM.tabBtn('okta'));
assert.dom(AUTH_FORM.tabBtn('okta')).hasAttribute('aria-selected', 'true');
assert.dom(AUTH_FORM.authForm('okta')).exists('it renders default method');
assert.dom(AUTH_FORM.advancedSettings).exists();
await click(AUTH_FORM.otherMethodsBtn);
assert.dom(AUTH_FORM.authForm('token')).exists('it renders backup method');
});
test('it renders login settings for namespaces', async function (assert) {
await visit('/vault/auth');
await fillIn(GENERAL.inputByAttr('namespace'), 'test-ns');
await waitFor(AUTH_FORM.authForm('ldap'));
assert.dom(AUTH_FORM.authForm('ldap')).exists('it renders default method');
assert.dom(AUTH_FORM.advancedSettings).exists();
assert.dom(AUTH_FORM.otherMethodsBtn).doesNotExist('it does not render alternate view');
// type in so that the namespace is "test-ns/child"
await typeIn(GENERAL.inputByAttr('namespace'), '/child');
await waitFor(AUTH_FORM.authForm('okta'));
assert
.dom(AUTH_FORM.authForm('okta'))
.exists('it inherits view from root namespace because "test-ns" settings are not inheritable');
});
test('it ignores login settings if query param references a visible mount path', async function (assert) {
await visit('/vault/auth?with=my-oidc%2F');
await waitFor(AUTH_FORM.tabBtn('oidc'));
assert
.dom(AUTH_FORM.tabBtn('oidc'))
.hasAttribute('aria-selected', 'true', 'it selects tab matching query param');
assert.dom(AUTH_FORM.authForm('oidc')).exists();
assert.dom(AUTH_FORM.advancedSettings).doesNotExist();
await click(AUTH_FORM.otherMethodsBtn);
assert.dom(GENERAL.selectByAttr('auth type')).exists('dropdown renders as fallback view');
});
test('it ignores login settings if query param references a valid type', async function (assert) {
await visit('/vault/auth?with=userpass');
assert.dom(GENERAL.selectByAttr('auth type')).hasValue('userpass', 'dropdown selects userpass');
await click(GENERAL.backButton);
assert.dom(AUTH_FORM.tabBtn('oidc')).exists('it renders tabs on "Back" because visible mounts exist');
});
});

View file

@ -9,74 +9,121 @@ 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';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { overrideResponse } from 'vault/tests/helpers/stubs';
const SELECTORS = {
rule: (name) => (name ? `[data-test-rule="${name}"]` : '[data-test-rule]'),
popupMenu: (name) => `[data-test-rule="${name}"] ${GENERAL.menuTrigger}`,
};
// read view for custom login settings
module('Acceptance | Enterprise | config-ui/login-settings', function (hooks) {
setupApplicationTest(hooks);
setupMirage(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',
]);
return await login();
});
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
test('it renders empty state if no login settings exist', async function (assert) {
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();
assert.dom(GENERAL.emptyStateTitle).hasText('No UI login rules yet');
assert
.dom(GENERAL.emptyStateMessage)
.hasText(
'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.'
);
});
test('delete rule from list view', async function (assert) {
// Visit the login settings list index page
test('it falls back error template if no permission', async function (assert) {
this.server.get('/sys/config/ui/login/default-auth', () => overrideResponse(403));
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();
assert.dom(GENERAL.pageError.error).hasText('Error permission denied');
});
test('navigate to rule details page and renders rule data', async function (assert) {
// visit individual rule page
await visit('vault/config-ui/login-settings');
module('list, read and delete', function (hooks) {
hooks.beforeEach(async function () {
await login();
await click(GENERAL.menuTrigger);
await click(GENERAL.menuItem('view-rule'));
// 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',
]);
});
// 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'
);
hooks.afterEach(async function () {
await login();
// 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');
// 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(SELECTORS.rule()).exists({ count: 2 });
assert.dom(SELECTORS.rule('testRule')).hasText('testRule ns1/ Inheritance enabled');
assert.dom(SELECTORS.rule('testRule2')).hasText('testRule2 ns2/ Inheritance disabled');
});
test('delete rule from list view', async function (assert) {
// Visit the login settings list index page
await visit('vault/config-ui/login-settings');
assert.dom(SELECTORS.rule()).exists({ count: 2 });
await click(SELECTORS.popupMenu('testRule'));
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(SELECTORS.rule('testRule')).doesNotExist();
assert.dom(SELECTORS.rule()).exists({ count: 1 });
});
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(SELECTORS.popupMenu('testRule'));
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('Default method')).hasText('okta');
assert.dom(GENERAL.infoRowValue('Namespace')).hasText('ns1/');
assert.dom(GENERAL.infoRowValue('Backup methods')).hasText('userpass');
assert.dom(GENERAL.infoRowValue('Inheritance enabled')).hasText('Yes');
});
test('it navigates to rule details from linked block', async function (assert) {
await visit('vault/config-ui/login-settings');
await click(SELECTORS.rule('testRule2'));
assert.strictEqual(
currentRouteName(),
'vault.cluster.config-ui.login-settings.rule.details',
'goes to rule details page'
);
assert.dom(GENERAL.infoRowValue('Default method')).hasText('ldap');
assert.dom(GENERAL.infoRowValue('Namespace')).hasText('ns2/');
assert.dom(GENERAL.infoRowValue('Backup methods')).hasText('oidc');
assert.dom(GENERAL.infoRowValue('Inheritance enabled')).hasText('No');
});
});
});

View file

@ -128,7 +128,8 @@ export const AUTH_METHOD_MAP = [
{ authType: 'saml', options: LOGIN_DATA.saml },
];
export const VISIBLE_MOUNTS = {
// Mock response for `sys/internal/ui/mounts`
export const SYS_INTERNAL_UI_MOUNTS = {
'userpass/': {
description: '',
options: {},
@ -144,9 +145,9 @@ export const VISIBLE_MOUNTS = {
options: {},
type: 'oidc',
},
'token/': {
description: 'token based credentials',
'ldap/': {
description: '',
options: null,
type: 'token',
type: 'ldap',
},
};

View file

@ -27,26 +27,29 @@ module('Integration | Component | auth | form template', function (hooks) {
hooks.beforeEach(function () {
window.localStorage.clear();
this.version = this.owner.lookup('service:version');
this.visibleMountsByType = null;
this.cluster = { id: '1' };
this.directLinkData = null;
this.alternateView = null;
this.defaultView = { view: 'dropdown', tabData: null };
this.handleNamespaceUpdate = sinon.spy();
this.initialFormState = { initialAuthType: 'token', showAlternate: false };
this.namespaceQueryParam = '';
this.oidcProviderQueryParam = '';
this.onSuccess = sinon.spy();
this.canceledMfaAuth = '';
this.visibleMountTypes = null;
this.renderComponent = () => {
return render(hbs`
<Auth::FormTemplate
@visibleMountsByType={{this.visibleMountsByType}}
@alternateView={{this.alternateView}}
@cluster={{this.cluster}}
@directLinkData={{this.directLinkData}}
@defaultView={{this.defaultView}}
@handleNamespaceUpdate={{this.handleNamespaceUpdate}}
@initialFormState={{this.initialFormState}}
@namespaceQueryParam={{this.namespaceQueryParam}}
@oidcProviderQueryParam={{this.oidcProviderQueryParam}}
@onSuccess={{this.onSuccess}}
@canceledMfaAuth={{this.canceledMfaAuth}}
@visibleMountTypes={{this.visibleMountTypes}}
/>`);
};
});
@ -57,31 +60,18 @@ module('Integration | Component | auth | form template', function (hooks) {
assert.dom(GENERAL.selectByAttr('auth type')).hasValue('token');
});
test('it selects @canceledMfaAuth by default', async function (assert) {
this.canceledMfaAuth = 'ldap';
await this.renderComponent();
assert.dom(GENERAL.selectByAttr('auth type')).hasValue('ldap');
assert.dom(GENERAL.inputByAttr('username')).exists();
assert.dom(GENERAL.inputByAttr('password')).exists();
});
test('it selects type in the dropdown if @directLinkData data just contains type', async function (assert) {
this.directLinkData = { type: 'oidc', isVisibleMount: false };
await this.renderComponent();
assert.dom(GENERAL.selectByAttr('auth type')).hasValue('oidc');
assert.dom(GENERAL.inputByAttr('role')).exists();
await click(AUTH_FORM.advancedSettings);
assert.dom(GENERAL.inputByAttr('path')).exists();
assert.dom(GENERAL.backButton).doesNotExist();
assert.dom(AUTH_FORM.otherMethodsBtn).doesNotExist('"Sign in with other methods" does not render');
});
test('it does not show toggle buttons when listing visibility is not set', async function (assert) {
test('it does not show toggle buttons if @alternateView does not exist', async function (assert) {
await this.renderComponent();
assert.dom(GENERAL.backButton).doesNotExist('"Back" button does not render');
assert.dom(AUTH_FORM.otherMethodsBtn).doesNotExist('"Sign in with other methods" does not render');
});
test('it initializes with preset auth type', async function (assert) {
this.initialFormState = { initialAuthType: 'userpass' };
await this.renderComponent();
assert.dom(GENERAL.selectByAttr('auth type')).hasValue('userpass');
});
test('it displays errors', async function (assert) {
const authenticateStub = sinon.stub(this.owner.lookup('service:auth'), 'authenticate');
authenticateStub.throws('permission denied');
@ -95,7 +85,7 @@ module('Integration | Component | auth | form template', function (hooks) {
module('listing visibility', function (hooks) {
hooks.beforeEach(function () {
this.visibleMountsByType = {
const defaultTabs = {
userpass: [
{
path: 'userpass/',
@ -127,27 +117,12 @@ module('Integration | Component | auth | form template', function (hooks) {
},
],
};
});
test('it renders mounts configured with listing_visibility="unuath"', async function (assert) {
const expectedTabs = [
{ type: 'userpass', display: 'Userpass' },
{ type: 'oidc', display: 'OIDC' },
{ type: 'token', display: 'Token' },
];
await this.renderComponent();
assert.dom(GENERAL.selectByAttr('auth type')).doesNotExist('dropdown does not render');
// there are 4 mount paths returned in the stubbed sys/internal/ui/mounts response above,
// but two are of the same type so only expect 3 tabs
assert.dom(AUTH_FORM.tabs).exists({ count: 3 }, 'it groups mount paths by type and renders 3 tabs');
expectedTabs.forEach((m) => {
assert.dom(AUTH_FORM.tabBtn(m.type)).exists(`${m.type} renders as a tab`);
assert.dom(AUTH_FORM.tabBtn(m.type)).hasText(m.display, `${m.type} renders expected display name`);
});
assert
.dom(AUTH_FORM.tabBtn('userpass'))
.hasAttribute('aria-selected', 'true', 'it selects the first type by default');
// all computed by the parent, in this case the initial tabs are the same as visible mount types
// but that isn't always the case
this.visibleMountTypes = Object.keys(defaultTabs);
this.defaultView = { type: 'tabs', tabData: defaultTabs };
this.alternateView = { type: 'dropdown', tabData: null };
this.initialFormState = { initialAuthType: 'userpass', showAlternate: false };
});
test('it selects each auth tab and renders form for that type', async function (assert) {
@ -180,37 +155,14 @@ module('Integration | Component | auth | form template', function (hooks) {
assert.dom(AUTH_FORM.advancedSettings).doesNotExist();
});
test('it renders the mount description', async function (assert) {
await this.renderComponent();
await click(AUTH_FORM.tabBtn('token'));
assert.dom(AUTH_FORM.description).hasText('token based credentials');
});
test('it renders a dropdown if multiple mount paths are returned', async function (assert) {
await this.renderComponent();
await click(AUTH_FORM.tabBtn('userpass'));
const dropdownOptions = findAll(`${GENERAL.selectByAttr('path')} option`).map((o) => o.value);
const expectedPaths = ['userpass/', 'userpass2/'];
expectedPaths.forEach((p) => {
assert.true(dropdownOptions.includes(p), `dropdown includes path: ${p}`);
});
});
test('it renders hidden input if only one mount path is returned', async function (assert) {
await this.renderComponent();
await click(AUTH_FORM.tabBtn('oidc'));
assert.dom(GENERAL.inputByAttr('path')).hasAttribute('type', 'hidden');
assert.dom(GENERAL.inputByAttr('path')).hasValue('my-oidc/');
});
test('it clicks "Sign in with other methods"', async function (assert) {
test('it clicks "Sign in with other methods" and toggles to other view', async function (assert) {
await this.renderComponent();
assert.dom(AUTH_FORM.tabs).exists({ count: 3 }, 'tabs render by default');
assert.dom(GENERAL.backButton).doesNotExist();
await click(AUTH_FORM.otherMethodsBtn);
assert
.dom(AUTH_FORM.otherMethodsBtn)
.doesNotExist('"Sign in with other methods" does not renderafter it is clicked');
.doesNotExist('"Sign in with other methods" does not render after it is clicked');
assert
.dom(GENERAL.selectByAttr('auth type'))
.exists('clicking "Sign in with other methods" renders dropdown instead of tabs');
@ -239,15 +191,15 @@ module('Integration | Component | auth | form template', function (hooks) {
assert.dom(AUTH_FORM.tabBtn('token')).hasAttribute('aria-selected', 'false');
});
test('it preselects tab if @canceledMfaAuth is a tab', async function (assert) {
this.canceledMfaAuth = 'oidc';
test('it preselects tab from initialFormState', async function (assert) {
this.initialFormState = { initialAuthType: 'oidc', showAlternate: false };
await this.renderComponent();
assert.dom(AUTH_FORM.authForm('oidc')).exists('oidc form renders');
assert.dom(AUTH_FORM.tabBtn('oidc')).hasAttribute('aria-selected', 'true');
});
test('if @canceledMfaAuth is NOT a tab, dropdown renders with type selected instead of tabs', async function (assert) {
this.canceledMfaAuth = 'ldap';
test('it renders dropdown and preselects type if initialFormState is not a tab', async function (assert) {
this.initialFormState = { initialAuthType: 'ldap', showAlternate: true };
await this.renderComponent();
assert.dom(GENERAL.selectByAttr('auth type')).hasValue('ldap');
assert.dom(GENERAL.inputByAttr('username')).exists();
@ -256,41 +208,6 @@ module('Integration | Component | auth | form template', function (hooks) {
assert.dom(GENERAL.backButton).exists('"Back" button renders');
assert.dom(AUTH_FORM.otherMethodsBtn).doesNotExist('"Sign in with other methods" does not render');
});
// if mount data exists, the mount has listing_visibility="unauth"
test('it renders single mount view instead of tabs if @directLinkData data exists and includes mount data', async function (assert) {
this.directLinkData = { path: 'my-oidc/', type: 'oidc', isVisibleMount: true };
await this.renderComponent();
assert.dom(AUTH_FORM.authForm('oidc')).exists;
assert.dom(AUTH_FORM.tabBtn('oidc')).hasText('OIDC', 'it renders auth type tab');
assert.dom(AUTH_FORM.tabs).exists({ count: 1 }, 'only one tab renders');
assert.dom(GENERAL.inputByAttr('role')).exists();
assert.dom(GENERAL.inputByAttr('path')).hasAttribute('type', 'hidden');
assert.dom(GENERAL.inputByAttr('path')).hasValue('my-oidc/');
assert.dom(AUTH_FORM.otherMethodsBtn).exists('"Sign in with other methods" renders');
assert.dom(GENERAL.selectByAttr('auth type')).doesNotExist('dropdown does not render');
assert.dom(AUTH_FORM.advancedSettings).doesNotExist();
assert.dom(GENERAL.backButton).doesNotExist();
});
test('it does not render tabs if @directLinkData data exists and just includes type', async function (assert) {
// set a type that is NOT in a visible mount because mount data exists otherwise
this.directLinkData = { type: 'ldap', isVisibleMount: false };
await this.renderComponent();
assert.dom(GENERAL.selectByAttr('auth type')).hasValue('ldap', 'dropdown has type selected');
assert.dom(AUTH_FORM.authForm('ldap')).exists();
assert.dom(GENERAL.inputByAttr('username')).exists();
assert.dom(GENERAL.inputByAttr('password')).exists();
await click(AUTH_FORM.advancedSettings);
assert.dom(GENERAL.inputByAttr('path')).exists();
assert.dom(AUTH_FORM.tabBtn('ldap')).doesNotExist('tab does not render');
assert
.dom(GENERAL.backButton)
.exists('back button renders because listing_visibility="unauth" for other mounts');
assert.dom(AUTH_FORM.otherMethodsBtn).doesNotExist('"Sign in with other methods" does not render');
});
});
module('community', function (hooks) {

View file

@ -10,9 +10,10 @@ import hbs from 'htmlbars-inline-precompile';
import sinon from 'sinon';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors';
import { fillInLoginFields, VISIBLE_MOUNTS } from 'vault/tests/helpers/auth/auth-helpers';
import { fillInLoginFields, SYS_INTERNAL_UI_MOUNTS } from 'vault/tests/helpers/auth/auth-helpers';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { CSP_ERROR } from 'vault/components/auth/page';
import { setupTotpMfaResponse } from 'vault/tests/helpers/mfa/mfa-helpers';
module('Integration | Component | auth | page', function (hooks) {
setupRenderingTest(hooks);
@ -21,16 +22,18 @@ module('Integration | Component | auth | page', function (hooks) {
hooks.beforeEach(function () {
this.version = this.owner.lookup('service:version');
this.cluster = { id: '1' };
this.directLinkData = null;
this.loginSettings = null;
this.onAuthSuccess = sinon.spy();
this.onNamespaceUpdate = sinon.spy();
this.visibleAuthMounts = false;
this.directLinkData = null;
this.renderComponent = () => {
return render(hbs`
<Auth::Page
@cluster={{this.cluster}}
@directLinkData={{this.directLinkData}}
@loginSettings={{this.loginSettings}}
@namespaceQueryParam={{this.nsQp}}
@oidcProviderQueryParam={{this.providerQp}}
@onAuthSuccess={{this.onAuthSuccess}}
@ -96,18 +99,32 @@ module('Integration | Component | auth | page', function (hooks) {
assert.strictEqual(actual, 'mynamespace', `onNamespaceUpdate called with: ${actual}`);
});
// DIRECT LINK tests (without any listing visibility)
test('it selects type in the dropdown if direct link is just type', async function (assert) {
this.directLinkData = { type: 'oidc' };
await this.renderComponent();
assert.dom(AUTH_FORM.tabBtn('oidc')).doesNotExist('tab does not render');
assert.dom(GENERAL.selectByAttr('auth type')).hasValue('oidc', 'dropdown has type selected');
assert.dom(AUTH_FORM.authForm('oidc')).exists();
assert.dom(GENERAL.inputByAttr('role')).exists();
await click(AUTH_FORM.advancedSettings);
assert.dom(GENERAL.inputByAttr('path')).exists({ count: 1 });
assert.dom(GENERAL.backButton).doesNotExist();
assert.dom(AUTH_FORM.otherMethodsBtn).doesNotExist('"Sign in with other methods" does not render');
});
module('listing visibility', function (hooks) {
hooks.beforeEach(function () {
this.visibleAuthMounts = VISIBLE_MOUNTS;
this.visibleAuthMounts = SYS_INTERNAL_UI_MOUNTS;
window.localStorage.clear();
});
test('it formats tab data if visible auth mounts exist', async function (assert) {
test('it formats and renders tabs if visible auth mounts exist', async function (assert) {
await this.renderComponent();
const expectedTabs = [
{ type: 'userpass', display: 'Userpass' },
{ type: 'oidc', display: 'OIDC' },
{ type: 'token', display: 'Token' },
{ type: 'ldap', display: 'LDAP' },
];
assert.dom(GENERAL.selectByAttr('auth type')).doesNotExist('dropdown does not render');
@ -123,33 +140,101 @@ module('Integration | Component | auth | page', function (hooks) {
.hasAttribute('aria-selected', 'true', 'it selects the first type by default');
});
test('it selects type in the dropdown if @directLinkData references NON visible type', async function (assert) {
this.directLinkData = { type: 'ldap', isVisibleMount: false };
test('it renders dropdown as alternate view', async function (assert) {
await this.renderComponent();
assert.dom(GENERAL.selectByAttr('auth type')).hasValue('ldap', 'dropdown has type selected');
assert.dom(AUTH_FORM.authForm('ldap')).exists();
assert.dom(GENERAL.inputByAttr('username')).exists();
assert.dom(GENERAL.inputByAttr('password')).exists();
assert.dom(AUTH_FORM.tabs).exists({ count: 3 }, 'tabs render by default');
assert.dom(GENERAL.backButton).doesNotExist();
await click(AUTH_FORM.otherMethodsBtn);
assert.dom(AUTH_FORM.otherMethodsBtn).doesNotExist('button disappears after it is clicked');
assert.dom(GENERAL.selectByAttr('auth type')).hasValue('userpass', 'dropdown has userpass selected');
assert.dom(AUTH_FORM.advancedSettings).exists('toggle renders even though userpass has visible mounts');
await click(AUTH_FORM.advancedSettings);
assert.dom(GENERAL.inputByAttr('path')).exists();
assert.dom(AUTH_FORM.tabBtn('ldap')).doesNotExist('tab does not render');
assert
.dom(GENERAL.backButton)
.exists('back button renders because listing_visibility="unauth" for other mounts');
assert.dom(AUTH_FORM.otherMethodsBtn).doesNotExist('"Sign in with other methods" does not render');
assert.dom(GENERAL.inputByAttr('path')).exists({ count: 1 });
assert.dom(GENERAL.inputByAttr('path')).hasValue('', 'it renders empty custom path input');
await fillIn(GENERAL.selectByAttr('auth type'), 'oidc');
assert.dom(AUTH_FORM.advancedSettings).exists('toggle renders even though oidc has a visible mount');
await click(AUTH_FORM.advancedSettings);
assert.dom(GENERAL.inputByAttr('path')).exists({ count: 1 });
assert.dom(GENERAL.inputByAttr('path')).hasValue('', 'it renders empty custom path input');
await click(GENERAL.backButton);
assert.dom(GENERAL.backButton).doesNotExist('"Back" button does not render after it is clicked');
assert.dom(AUTH_FORM.tabs).exists({ count: 3 }, 'clicking "Back" renders tabs again');
assert.dom(AUTH_FORM.otherMethodsBtn).exists('"Sign in with other methods" renders again');
});
test('it renders single mount view instead of tabs if @directLinkData data references a visible type', async function (assert) {
this.directLinkData = { path: 'my-oidc/', type: 'oidc', isVisibleMount: true };
await this.renderComponent();
assert.dom(AUTH_FORM.tabBtn('oidc')).hasText('OIDC', 'it renders tab for type');
assert.dom(GENERAL.inputByAttr('role')).exists();
assert.dom(GENERAL.inputByAttr('path')).hasAttribute('type', 'hidden');
assert.dom(GENERAL.inputByAttr('path')).hasValue('my-oidc/');
assert.dom(AUTH_FORM.otherMethodsBtn).exists('"Sign in with other methods" renders');
assert.dom(GENERAL.selectByAttr('auth type')).doesNotExist();
assert.dom(AUTH_FORM.advancedSettings).doesNotExist();
assert.dom(GENERAL.backButton).doesNotExist();
module('with a direct link', function (hooks) {
hooks.beforeEach(function () {
// if path exists, the mount has listing_visibility="unauth"
this.directLinkIsVisibleMount = { path: 'my-oidc/', type: 'oidc' };
this.directLinkIsJustType = { type: 'okta' };
});
test('it selects type in the dropdown if direct link is just type', async function (assert) {
this.directLinkData = this.directLinkIsJustType;
await this.renderComponent();
assert.dom(AUTH_FORM.tabBtn('okta')).doesNotExist('tab does not render');
assert.dom(GENERAL.selectByAttr('auth type')).hasValue('okta', 'dropdown has type selected');
assert.dom(AUTH_FORM.authForm('okta')).exists();
assert.dom(GENERAL.inputByAttr('username')).exists();
assert.dom(GENERAL.inputByAttr('password')).exists();
await click(AUTH_FORM.advancedSettings);
assert.dom(GENERAL.inputByAttr('path')).exists({ count: 1 });
assert.dom(AUTH_FORM.otherMethodsBtn).doesNotExist('"Sign in with other methods" does not render');
assert.dom(GENERAL.backButton).exists('back button renders because tabs exist for other methods');
await click(GENERAL.backButton);
assert
.dom(AUTH_FORM.tabBtn('userpass'))
.hasAttribute('aria-selected', 'true', 'first tab is selected on back');
});
test('it renders single method view instead of tabs if direct link includes path', async function (assert) {
this.directLinkData = this.directLinkIsVisibleMount;
await this.renderComponent();
assert.dom(AUTH_FORM.authForm('oidc')).exists;
assert.dom(AUTH_FORM.tabBtn('oidc')).hasText('OIDC', 'it renders tab for type');
assert.dom(AUTH_FORM.tabs).exists({ count: 1 }, 'only one tab renders');
assert.dom(GENERAL.inputByAttr('role')).exists();
assert.dom(GENERAL.inputByAttr('path')).hasAttribute('type', 'hidden');
assert.dom(GENERAL.inputByAttr('path')).hasValue('my-oidc/');
assert.dom(AUTH_FORM.otherMethodsBtn).exists('"Sign in with other methods" renders');
assert.dom(GENERAL.selectByAttr('auth type')).doesNotExist();
assert.dom(AUTH_FORM.advancedSettings).doesNotExist();
assert.dom(GENERAL.backButton).doesNotExist();
});
test('it prioritizes auth type from canceled mfa instead of direct link for path', async function (assert) {
assert.expect(1);
this.directLinkData = this.directLinkIsVisibleMount;
const authType = 'okta';
const { loginData, url } = REQUEST_DATA.username;
const requestUrl = url({ path: authType, username: loginData?.username });
this.server.post(requestUrl, () => setupTotpMfaResponse(authType));
await this.renderComponent();
await click(AUTH_FORM.otherMethodsBtn);
await fillIn(AUTH_FORM.selectMethod, authType);
await fillInLoginFields(loginData);
await click(AUTH_FORM.login);
await waitFor('[data-test-mfa-description]'); // wait until MFA validation renders
await click(GENERAL.backButton);
assert.dom(AUTH_FORM.selectMethod).hasValue(authType, 'Okta is selected in dropdown');
});
test('it prioritizes auth type from canceled mfa instead of direct link with type', async function (assert) {
assert.expect(1);
this.directLinkData = this.directLinkIsJustType;
const authType = 'userpass';
const { loginData, url } = REQUEST_DATA.username;
const requestUrl = url({ path: authType, username: loginData?.username });
this.server.post(requestUrl, () => setupTotpMfaResponse(authType));
await this.renderComponent();
await fillIn(AUTH_FORM.selectMethod, authType);
await fillInLoginFields(loginData);
await click(AUTH_FORM.login);
await click(GENERAL.backButton);
assert.dom(AUTH_FORM.tabBtn('userpass')).hasAttribute('aria-selected', 'true');
});
});
});
@ -182,7 +267,6 @@ module('Integration | Component | auth | page', function (hooks) {
await this.renderComponent();
await fillIn(AUTH_FORM.selectMethod, authType);
// await fillIn(AUTH_FORM.selectMethod, authType);
await fillInLoginFields(loginData);
await click(AUTH_FORM.login);
const [actual] = this.onAuthSuccess.lastCall.args;
@ -218,6 +302,20 @@ module('Integration | Component | auth | page', function (hooks) {
};
assert.propEqual(actual, expected, `onAuthSuccess called with: ${JSON.stringify(actual)}`);
});
test('it preselects auth type from canceled mfa', async function (assert) {
assert.expect(1);
const { loginData, url } = options;
const requestUrl = url({ path: authType, username: loginData?.username });
this.server.post(requestUrl, () => setupTotpMfaResponse(authType));
await this.renderComponent();
await fillIn(AUTH_FORM.selectMethod, authType);
await fillInLoginFields(loginData);
await click(AUTH_FORM.login);
await click(GENERAL.backButton);
assert.dom(AUTH_FORM.selectMethod).hasValue(authType, `${authType} is selected in dropdown`);
});
}
// token makes a GET request so test separately
@ -240,4 +338,340 @@ module('Integration | Component | auth | page', function (hooks) {
};
assert.propEqual(actual, expected, `onAuthSuccess called with: ${JSON.stringify(actual)}`);
});
/*
Login settings are an enterprise only feature but the component is version agnostic (and subsequently so are these tests)
because fetching login settings happens in the route only for enterprise versions.
Each combination must be tested with and without visible mounts (i.e. tuned with listing_visibility="unauth")
1. default+backups: default type set, backup types set
2. default only: no backup types
3. backup only: backup types set without a default
*/
module('ent login settings', function (hooks) {
hooks.beforeEach(function () {
this.loginSettings = {
defaultType: 'oidc',
backupTypes: ['userpass', 'ldap'],
};
this.assertPathInput = async (assert, { isHidden = false, value = '' } = {}) => {
// the path input can render behind the "Advanced settings" toggle or as a hidden input.
// Assert it only renders once and is the expected input
if (!isHidden) {
await click(AUTH_FORM.advancedSettings);
assert.dom(GENERAL.inputByAttr('path')).exists('it renders mount path input');
}
if (isHidden) {
assert.dom(AUTH_FORM.advancedSettings).doesNotExist();
assert.dom(GENERAL.inputByAttr('path')).hasAttribute('type', 'hidden');
assert.dom(GENERAL.inputByAttr('path')).hasValue(value);
}
assert.dom(GENERAL.inputByAttr('path')).exists({ count: 1 });
};
});
test('(default+backups): it initially renders default type and toggles to view backup methods', async function (assert) {
await this.renderComponent();
assert.dom(AUTH_FORM.tabBtn('oidc')).hasText('OIDC', 'it renders default method');
assert.dom(AUTH_FORM.tabs).exists({ count: 1 }, 'only one tab renders');
assert.dom(AUTH_FORM.authForm('oidc')).exists();
assert.dom(GENERAL.backButton).doesNotExist();
await this.assertPathInput(assert);
await click(AUTH_FORM.otherMethodsBtn);
assert.dom(GENERAL.backButton).exists();
assert.dom(AUTH_FORM.tabs).exists({ count: 2 }, 'it renders 2 backup type tabs');
assert
.dom(AUTH_FORM.tabBtn('userpass'))
.hasAttribute('aria-selected', 'true', 'it selects the first backup type');
await this.assertPathInput(assert);
await click(AUTH_FORM.tabBtn('ldap'));
assert.dom(AUTH_FORM.tabBtn('ldap')).hasAttribute('aria-selected', 'true', 'it selects ldap tab');
await this.assertPathInput(assert);
});
test('(default only): it renders default type without backup methods', async function (assert) {
this.loginSettings.backupTypes = null;
await this.renderComponent();
assert.dom(AUTH_FORM.tabBtn('oidc')).hasText('OIDC', 'it renders default method');
assert.dom(AUTH_FORM.tabs).exists({ count: 1 }, 'only one tab renders');
assert.dom(GENERAL.backButton).doesNotExist();
assert.dom(AUTH_FORM.otherMethodsBtn).doesNotExist();
});
test('(backups only): it initially renders backup types if no default is set', async function (assert) {
this.loginSettings.defaultType = '';
await this.renderComponent();
assert.dom(AUTH_FORM.tabBtn('oidc')).doesNotExist();
assert.dom(AUTH_FORM.tabs).exists({ count: 2 }, 'it renders 2 backup type tabs');
assert
.dom(AUTH_FORM.tabBtn('userpass'))
.hasAttribute('aria-selected', 'true', 'it selects the first backup type');
await this.assertPathInput(assert);
assert.dom(GENERAL.backButton).doesNotExist();
assert.dom(AUTH_FORM.otherMethodsBtn).doesNotExist();
});
module('all methods have visible mounts', function (hooks) {
hooks.beforeEach(function () {
this.loginSettings = {
defaultType: 'oidc',
backupTypes: ['userpass', 'ldap'],
};
this.visibleAuthMounts = SYS_INTERNAL_UI_MOUNTS;
});
test('(default+backups): it hides advanced settings for both views', async function (assert) {
await this.renderComponent();
assert.dom(AUTH_FORM.tabBtn('oidc')).hasText('OIDC', 'it renders default method');
assert.dom(AUTH_FORM.tabs).exists({ count: 1 }, 'only one tab renders');
this.assertPathInput(assert, { isHidden: true, value: 'my-oidc/' });
await click(AUTH_FORM.otherMethodsBtn);
assert.dom(AUTH_FORM.tabs).exists({ count: 2 }, 'it renders 2 backup type tabs');
assert
.dom(AUTH_FORM.tabBtn('userpass'))
.hasAttribute('aria-selected', 'true', 'it selects the first backup type');
assert.dom(AUTH_FORM.advancedSettings).doesNotExist();
assert.dom(GENERAL.inputByAttr('path')).doesNotExist();
assert.dom(GENERAL.selectByAttr('path')).exists(); // dropdown renders because userpass has 2 mount paths
await click(AUTH_FORM.tabBtn('ldap'));
this.assertPathInput(assert, { isHidden: true, value: 'ldap/' });
});
test('(default only): it hides advanced settings and renders hidden input', async function (assert) {
this.loginSettings.backupTypes = null;
await this.renderComponent();
assert.dom(AUTH_FORM.tabBtn('oidc')).hasText('OIDC', 'it renders default method');
assert.dom(AUTH_FORM.tabs).exists({ count: 1 }, 'only one tab renders');
assert.dom(AUTH_FORM.authForm('oidc')).exists();
this.assertPathInput(assert, { isHidden: true, value: 'my-oidc/' });
assert.dom(GENERAL.backButton).doesNotExist();
assert.dom(AUTH_FORM.otherMethodsBtn).doesNotExist();
});
test('(backups only): it hides advanced settings and renders hidden input', async function (assert) {
this.loginSettings.defaultType = '';
await this.renderComponent();
assert.dom(AUTH_FORM.tabs).exists({ count: 2 }, 'it renders 2 backup type tabs');
assert
.dom(AUTH_FORM.tabBtn('userpass'))
.hasAttribute('aria-selected', 'true', 'it selects the first backup type');
assert.dom(AUTH_FORM.advancedSettings).doesNotExist();
assert.dom(GENERAL.backButton).doesNotExist();
assert.dom(AUTH_FORM.otherMethodsBtn).doesNotExist();
});
});
module('only some methods have visible mounts', function (hooks) {
hooks.beforeEach(function () {
this.loginSettings = {
defaultType: 'oidc',
backupTypes: ['userpass', 'ldap'],
};
this.mountData = (path) => ({ [path]: SYS_INTERNAL_UI_MOUNTS[path] });
});
test('(default+backups): it hides advanced settings for default with visible mount but it renders for backups', async function (assert) {
this.visibleAuthMounts = { ...this.mountData('my-oidc/') };
await this.renderComponent();
this.assertPathInput(assert, { isHidden: true, value: 'my-oidc/' });
await click(AUTH_FORM.otherMethodsBtn);
assert.dom(AUTH_FORM.tabBtn('userpass')).hasAttribute('aria-selected', 'true');
await this.assertPathInput(assert);
await click(AUTH_FORM.tabBtn('ldap'));
await this.assertPathInput(assert);
});
test('(default+backups): it only renders advanced settings for method without mounts', async function (assert) {
// default and only one backup method have visible mounts
this.visibleAuthMounts = {
...this.mountData('my-oidc/'),
...this.mountData('userpass/'),
...this.mountData('userpass2/'),
};
await this.renderComponent();
assert.dom(AUTH_FORM.advancedSettings).doesNotExist();
await click(AUTH_FORM.otherMethodsBtn);
assert.dom(AUTH_FORM.tabBtn('userpass')).hasAttribute('aria-selected', 'true');
assert.dom(GENERAL.selectByAttr('path')).exists();
assert.dom(AUTH_FORM.advancedSettings).doesNotExist();
await click(AUTH_FORM.tabBtn('ldap'));
assert.dom(AUTH_FORM.advancedSettings).exists();
});
test('(default+backups): it hides advanced settings for single backup method with mounts', async function (assert) {
this.visibleAuthMounts = { ...this.mountData('ldap/') };
await this.renderComponent();
assert.dom(AUTH_FORM.authForm('oidc')).exists();
assert.dom(AUTH_FORM.advancedSettings).exists();
await click(AUTH_FORM.otherMethodsBtn);
assert.dom(AUTH_FORM.tabBtn('userpass')).hasAttribute('aria-selected', 'true');
assert.dom(AUTH_FORM.advancedSettings).exists();
await click(AUTH_FORM.tabBtn('ldap'));
this.assertPathInput(assert, { isHidden: true, value: 'ldap/' });
});
test('(backups only): it hides advanced settings for single method with mounts', async function (assert) {
this.loginSettings.defaultType = '';
this.visibleAuthMounts = { ...this.mountData('ldap/') };
await this.renderComponent();
assert.dom(AUTH_FORM.tabBtn('userpass')).hasAttribute('aria-selected', 'true');
assert.dom(AUTH_FORM.advancedSettings).exists();
await click(AUTH_FORM.tabBtn('ldap'));
this.assertPathInput(assert, { isHidden: true, value: 'ldap/' });
});
});
module('@directLinkData overrides login settings', function (hooks) {
hooks.beforeEach(function () {
this.mountData = SYS_INTERNAL_UI_MOUNTS;
});
module('when there are no visible mounts at all', function (hooks) {
hooks.beforeEach(function () {
this.visibleAuthMounts = null;
this.directLinkData = { type: 'okta' };
});
const testHelper = (assert) => {
assert.dom(AUTH_FORM.selectMethod).hasValue('okta');
assert.dom(AUTH_FORM.authForm('okta')).exists();
assert.dom(AUTH_FORM.otherMethodsBtn).doesNotExist();
assert.dom(GENERAL.backButton).doesNotExist();
};
test('(default+backups): it renders standard view and selects @directLinkData type from dropdown', async function (assert) {
await this.renderComponent();
testHelper(assert);
});
test('(default only): it renders standard view and selects @directLinkData type from dropdown', async function (assert) {
this.loginSettings.backupTypes = null;
await this.renderComponent();
testHelper(assert);
});
test('(backups only): it renders standard view and selects @directLinkData type from dropdown', async function (assert) {
this.loginSettings.defaultType = '';
await this.renderComponent();
testHelper(assert);
});
});
module('when param matches a visible mount path and other visible mounts exist', function (hooks) {
hooks.beforeEach(function () {
this.visibleAuthMounts = {
...this.mountData,
'my-okta/': {
description: '',
options: null,
type: 'okta',
},
};
this.directLinkData = { path: 'my-okta/', type: 'okta' };
});
const testHelper = async (assert) => {
assert.dom(AUTH_FORM.tabBtn('okta')).hasText('Okta', 'it renders preferred method');
assert.dom(AUTH_FORM.tabs).exists({ count: 1 }, 'only one tab renders');
assert.dom(AUTH_FORM.authForm('okta'));
assert.dom(AUTH_FORM.advancedSettings).doesNotExist();
assert.dom(GENERAL.inputByAttr('path')).hasAttribute('type', 'hidden');
assert.dom(GENERAL.inputByAttr('path')).hasValue('my-okta/');
assert.dom(GENERAL.inputByAttr('path')).exists({ count: 1 });
await click(AUTH_FORM.otherMethodsBtn);
assert
.dom(GENERAL.selectByAttr('auth type'))
.exists('it renders dropdown after clicking "Sign in with other"');
};
test('(default+backups): it renders single mount view for @directLinkData', async function (assert) {
await this.renderComponent();
await testHelper(assert);
});
test('(default only): it renders single mount view for @directLinkData', async function (assert) {
this.loginSettings.backupTypes = null;
await this.renderComponent();
await testHelper(assert);
});
test('(backups only): it renders single mount view for @directLinkData', async function (assert) {
this.loginSettings.defaultType = '';
await this.renderComponent();
await testHelper(assert);
});
});
module('when param matches a type and other visible mounts exist', function (hooks) {
hooks.beforeEach(function () {
// only type is present in directLinkData because the query param does not match a path with listing_visibility="unauth"
this.directLinkData = { type: 'okta' };
this.visibleAuthMounts = this.mountData;
});
const testHelper = async (assert) => {
assert.dom(GENERAL.backButton).exists('back button renders because other methods have tabs');
assert.dom(AUTH_FORM.selectMethod).hasValue('okta');
assert.dom(AUTH_FORM.authForm('okta')).exists();
assert.dom(AUTH_FORM.otherMethodsBtn).doesNotExist();
await click(GENERAL.backButton);
assert.dom(AUTH_FORM.tabBtn('userpass')).hasAttribute('aria-selected', 'true');
await click(AUTH_FORM.otherMethodsBtn);
assert.dom(AUTH_FORM.selectMethod).exists('it toggles back to dropdown');
};
test('(default+backups): it selects @directLinkData type from dropdown and toggles to tab view', async function (assert) {
await this.renderComponent();
await testHelper(assert);
});
test('(default only): it selects @directLinkData type from dropdown and toggles to tab view', async function (assert) {
this.loginSettings.backupTypes = null;
await this.renderComponent();
await testHelper(assert);
});
test('(backups only): it selects @directLinkData type from dropdown and toggles to tab view', async function (assert) {
this.loginSettings.defaultType = '';
await this.renderComponent();
await testHelper(assert);
});
});
module('when param matches a type that matches other visible mounts', function (hooks) {
hooks.beforeEach(function () {
// only type exists because the query param does not match a path with listing_visibility="unauth"
this.directLinkData = { type: 'oidc' };
this.visibleAuthMounts = this.mountData;
});
const testHelper = async (assert) => {
assert.dom(AUTH_FORM.tabBtn('oidc')).hasAttribute('aria-selected', 'true');
assert.dom(AUTH_FORM.authForm('oidc')).exists();
assert.dom(GENERAL.backButton).doesNotExist();
await click(AUTH_FORM.otherMethodsBtn);
assert.dom(AUTH_FORM.selectMethod).exists('it toggles to view dropdown');
await click(GENERAL.backButton);
assert.dom(AUTH_FORM.tabs).exists('it toggles back to tabs');
};
test('(default+backups): it selects @directLinkData type tab and toggles to dropdown view', async function (assert) {
await this.renderComponent();
await testHelper(assert);
});
test('(default only): it selects @directLinkData type tab and toggles to dropdown view', async function (assert) {
this.loginSettings.backupTypes = null;
await this.renderComponent();
await testHelper(assert);
});
test('(backups only): it selects @directLinkData type tab and toggles to dropdown view', async function (assert) {
this.loginSettings.defaultType = '';
await this.renderComponent();
await testHelper(assert);
});
});
});
});
});

View file

@ -0,0 +1,110 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { click, findAll, render } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
import sinon from 'sinon';
import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
module('Integration | Component | auth | tabs', function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
this.tabData = {
userpass: [
{
path: 'userpass/',
description: '',
options: {},
type: 'userpass',
},
{
path: 'userpass2/',
description: '',
options: {},
type: 'userpass',
},
],
oidc: [
{
path: 'my-oidc/',
description: '',
options: {},
type: 'oidc',
},
],
token: [
{
path: 'token/',
description: 'token based credentials',
options: null,
type: 'token',
},
],
};
this.selectedAuthMethod = '';
this.handleTabClick = sinon.spy();
this.renderComponent = () => {
return render(hbs`
<Auth::Tabs
@authTabData={{this.tabData}}
@handleTabClick={{this.handleTabClick}}
@selectedAuthMethod={{this.selectedAuthMethod}}
/>`);
};
});
test('it renders tabs', async function (assert) {
const expectedTabs = [
{ type: 'userpass', display: 'Userpass' },
{ type: 'oidc', display: 'OIDC' },
{ type: 'token', display: 'Token' },
];
await this.renderComponent();
expectedTabs.forEach((m) => {
assert.dom(AUTH_FORM.tabBtn(m.type)).exists(`${m.type} renders as a tab`);
assert.dom(AUTH_FORM.tabBtn(m.type)).hasText(m.display, `${m.type} renders expected display name`);
});
});
test('it selects first tab if no @selectedAuthMethod exists', async function (assert) {
await this.renderComponent();
assert.dom(AUTH_FORM.tabBtn('userpass')).hasAttribute('aria-selected', 'true');
});
test('it renders the mount description', async function (assert) {
this.selectedAuthMethod = 'token';
await this.renderComponent();
assert.dom(AUTH_FORM.description).hasText('token based credentials');
});
test('it renders a dropdown if multiple mount paths are returned', async function (assert) {
this.selectedAuthMethod = 'userpass';
await this.renderComponent();
const dropdownOptions = findAll(`${GENERAL.selectByAttr('path')} option`).map((o) => o.value);
const expectedPaths = ['userpass/', 'userpass2/'];
expectedPaths.forEach((p) => {
assert.true(dropdownOptions.includes(p), `dropdown includes path: ${p}`);
});
});
test('it renders hidden input if only one mount path is returned', async function (assert) {
this.selectedAuthMethod = 'oidc';
await this.renderComponent();
assert.dom(GENERAL.inputByAttr('path')).hasAttribute('type', 'hidden');
assert.dom(GENERAL.inputByAttr('path')).hasValue('my-oidc/');
});
test('it calls handleTabClick with tab method type', async function (assert) {
await this.renderComponent();
await click(AUTH_FORM.tabBtn('oidc'));
const [actual] = this.handleTabClick.lastCall.args;
assert.strictEqual(actual, 'oidc');
});
});

View file

@ -5,14 +5,15 @@
export interface UnauthMountsByType {
// key is the auth method type
[key: string]: AuthTabMountData[];
// if the value is "null" there is no mount data for that type
[key: string]: AuthTabMountData[] | null;
}
export interface UnauthMountsResponse {
// key is the mount path
[key: string]: { type: string; description?: string; config?: object | null };
}
export interface AuthTabMountData {
interface AuthTabMountData {
path: string;
type: string;
description?: string;