From 45fee5c225c53f9b7273df5358a85e6d9e30bcbb Mon Sep 17 00:00:00 2001 From: claire bontempo <68122737+hellobontempo@users.noreply.github.com> Date: Thu, 22 May 2025 15:13:14 -0700 Subject: [PATCH] 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 --- changelog/30700.txt | 3 + ui/app/components/auth/form-template.hbs | 192 ++++--- ui/app/components/auth/form-template.ts | 120 ++--- ui/app/components/auth/page.hbs | 7 +- ui/app/components/auth/page.ts | 177 ++++++- ui/app/components/auth/tabs.hbs | 9 +- ui/app/routes/vault/cluster/auth.js | 53 +- ui/app/styles/helper-classes/general.scss | 4 + ui/app/templates/vault/cluster/auth.hbs | 1 + .../login-settings/page/details.hbs | 13 +- .../components/login-settings/page/details.js | 13 + .../components/login-settings/page/list.hbs | 71 ++- .../components/login-settings/page/list.js | 4 +- .../addon/routes/login-settings/index.js | 17 +- ui/mirage/handlers/custom-login.js | 2 +- ui/mirage/scenarios/custom-login.js | 4 +- ui/tests/acceptance/auth/auth-test.js | 41 +- .../acceptance/auth/login-settings-test.js | 91 ++++ .../config-ui/login-settings-test.js | 151 ++++-- ui/tests/helpers/auth/auth-helpers.ts | 9 +- .../components/auth/form-template-test.js | 141 ++--- .../integration/components/auth/page-test.js | 492 ++++++++++++++++-- .../integration/components/auth/tabs-test.js | 110 ++++ ui/types/vault/auth/form.d.ts | 5 +- 24 files changed, 1284 insertions(+), 446 deletions(-) create mode 100644 changelog/30700.txt create mode 100644 ui/tests/acceptance/auth/login-settings-test.js create mode 100644 ui/tests/integration/components/auth/tabs-test.js diff --git a/changelog/30700.txt b/changelog/30700.txt new file mode 100644 index 0000000000..91f3e925ee --- /dev/null +++ b/changelog/30700.txt @@ -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. +``` \ No newline at end of file diff --git a/ui/app/components/auth/form-template.hbs b/ui/app/components/auth/form-template.hbs index b4a24c1c47..c5873a1655 100644 --- a/ui/app/components/auth/form-template.hbs +++ b/ui/app/components/auth/form-template.hbs @@ -3,105 +3,103 @@ SPDX-License-Identifier: BUSL-1.1 }} -
- {{#if this.formComponent}} - {{#let (component this.formComponent) as |AuthFormComponent|}} - {{! renders Auth::Form::Base or Auth::Form::}} - - <:namespace> - {{#if (has-feature "Namespaces")}} - }} + + <:namespace> + {{#if (has-feature "Namespaces")}} + + {{/if}} + + + <:back> + {{#if this.showAlternateView}} + + {{/if}} + + + {{! DIRECT LINK, TABS OR DROPDOWN }} + <:authSelectOptions> +
+ {{#if this.tabData}} + + {{else}} + {{! Fallback is dropdown with all auth methods }} + + Method + + {{#each this.supportedAuthTypes as |type|}} + + {{/each}} + + {{/if}} - +
+ - <:back> - {{#if this.showOtherMethods}} - - {{/if}} - + <:error> + {{#if this.errorMessage}} + + {{/if}} + - {{! DIRECT LINK, TABS OR DROPDOWN }} - <:authSelectOptions> -
- {{#if this.showCustomAuthOptions}} - - {{else}} - {{! fallback view is the dropdown with all auth methods }} - - Auth method - - {{#each this.availableMethodTypes as |type|}} - - {{/each}} - - - {{/if}} -
- + <:advancedSettings> + {{! custom auth options render their own mount path inputs and token does not support custom paths }} + {{#unless this.hideAdvancedSettings}} + + + Mount path + + If this authentication method was mounted using a non-default path, input it below. Otherwise Vault will + assume the default path + {{this.selectedAuthMethod}} + . + + + {{/unless}} + - <:error> - {{#if this.errorMessage}} - - {{/if}} - - - <: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"))}} - - - Mount path - - If this authentication method was mounted using a non-default path, input it below. Otherwise Vault will - assume the default path - {{this.selectedAuthMethod}} - . - - - {{/if}} - - - <:footer> - {{#if this.showCustomAuthOptions}} - - {{/if}} - -
- {{/let}} - {{/if}} -
\ No newline at end of file + <:footer> + {{#if (and @alternateView (not this.showAlternateView))}} + + {{/if}} + + + {{/let}} +{{/if}} \ No newline at end of file diff --git a/ui/app/components/auth/form-template.ts b/ui/app/components/auth/form-template.ts index e5d22fb579..a0b400e5f3 100644 --- a/ui/app/components/auth/form-template.ts +++ b/ui/app/components/auth/form-template.ts @@ -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 { - @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 { 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 { 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 { @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 diff --git a/ui/app/components/auth/page.hbs b/ui/app/components/auth/page.hbs index a0fd536c65..174478ac19 100644 --- a/ui/app/components/auth/page.hbs +++ b/ui/app/components/auth/page.hbs @@ -50,14 +50,15 @@ /> {{else}} {{/if}} diff --git a/ui/app/components/auth/page.ts b/ui/app/components/auth/page.ts index b0c1c9a3c0..11bbbffeeb 100644 --- a/ui/app/components/auth/page.ts +++ b/ui/app/components/auth/page.ts @@ -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 * * * @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 { + @service declare readonly auth: AuthService; @service('csp-event') declare readonly csp: CspEventService; @tracked canceledMfaAuth = ''; @@ -75,12 +121,117 @@ export default class AuthPage extends Component { 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 { 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 }; + } } diff --git a/ui/app/components/auth/tabs.hbs b/ui/app/components/auth/tabs.hbs index 1188522b0f..cfb49cc3f9 100644 --- a/ui/app/components/auth/tabs.hbs +++ b/ui/app/components/auth/tabs.hbs @@ -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 }} @@ -26,7 +26,12 @@ {{! SINGLE mount path }} {{#let (get mounts "0") as |mount|}} {{#if mount.description}} - {{mount.description}} + {{mount.description}} {{/if}} {{! the token auth method does't support custom paths so no need to render an input }} {{#if (not-eq @selectedAuthMethod "token")}} diff --git a/ui/app/routes/vault/cluster/auth.js b/ui/app/routes/vault/cluster/auth.js index cb97fbbd25..596acf870c 100644 --- a/ui/app/routes/vault/cluster/auth.js +++ b/ui/app/routes/vault/cluster/auth.js @@ -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; } } diff --git a/ui/app/styles/helper-classes/general.scss b/ui/app/styles/helper-classes/general.scss index 0cf5ddfa94..1da0c93d52 100644 --- a/ui/app/styles/helper-classes/general.scss +++ b/ui/app/styles/helper-classes/general.scss @@ -64,6 +64,10 @@ } } +.white-space-pre-line { + white-space: pre-line; +} + // large grouped css blocks .is-hint { color: color_variables.$grey; diff --git a/ui/app/templates/vault/cluster/auth.hbs b/ui/app/templates/vault/cluster/auth.hbs index 80d9183310..4cec9869d3 100644 --- a/ui/app/templates/vault/cluster/auth.hbs +++ b/ui/app/templates/vault/cluster/auth.hbs @@ -16,6 +16,7 @@ -{{#each-in @rule as |key value|}} - {{#if (eq key "defaultAuthType")}} - - {{else if (eq key "backupAuthTypes")}} - - {{else if (eq key "disableInheritance")}} - - {{else}} - - {{/if}} + +{{#each-in this.displayFields as |key label|}} + {{/each-in}} {{#if this.showConfirmModal}} diff --git a/ui/lib/config-ui/addon/components/login-settings/page/details.js b/ui/lib/config-ui/addon/components/login-settings/page/details.js index d91d5d55e6..3cba1e18ca 100644 --- a/ui/lib/config-ui/addon/components/login-settings/page/details.js +++ b/ui/lib/config-ui/addon/components/login-settings/page/details.js @@ -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; diff --git a/ui/lib/config-ui/addon/components/login-settings/page/list.hbs b/ui/lib/config-ui/addon/components/login-settings/page/list.hbs index 949e7dcd83..d59075c75d 100644 --- a/ui/lib/config-ui/addon/components/login-settings/page/list.hbs +++ b/ui/lib/config-ui/addon/components/login-settings/page/list.hbs @@ -15,35 +15,52 @@ {{#if @loginRules}} {{#each @loginRules as |rule|}} -
-
-
- -
{{rule.Name}}
+ +
+
+
+ + + {{rule.name}} + +
+ {{rule.namespace}} + +
+
+
+
+
+ + + + View + + {{#if (has-capability this.capabilities "delete" pathKey="customLogin" params=rule)}} + Delete + {{/if}} + +
-
{{rule.Namespace}}
-
- - - - View - - {{#if (has-capability this.capabilities "delete" pathKey="customLogin" params=rule)}} - Delete - {{/if}} - -
-
+ {{/each}} {{else}} - * ``` + * * @param {array} loginRules - array of rule objects */ diff --git a/ui/lib/config-ui/addon/routes/login-settings/index.js b/ui/lib/config-ui/addon/routes/login-settings/index.js index 73556e90c1..1c7d41e926 100644 --- a/ui/lib/config-ui/addon/routes/login-settings/index.js +++ b/ui/lib/config-ui/addon/routes/login-settings/index.js @@ -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; + } } } diff --git a/ui/mirage/handlers/custom-login.js b/ui/mirage/handlers/custom-login.js index cb82141076..6f7b7602f2 100644 --- a/ui/mirage/handlers/custom-login.js +++ b/ui/mirage/handlers/custom-login.js @@ -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; diff --git a/ui/mirage/scenarios/custom-login.js b/ui/mirage/scenarios/custom-login.js index 5fe4bf312e..cfeb372f99 100644 --- a/ui/mirage/scenarios/custom-login.js +++ b/ui/mirage/scenarios/custom-login.js @@ -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 } diff --git a/ui/tests/acceptance/auth/auth-test.js b/ui/tests/acceptance/auth/auth-test.js index 4414888577..ef4ae7fff8 100644 --- a/ui/tests/acceptance/auth/auth-test.js +++ b/ui/tests/acceptance/auth/auth-test.js @@ -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'); diff --git a/ui/tests/acceptance/auth/login-settings-test.js b/ui/tests/acceptance/auth/login-settings-test.js new file mode 100644 index 0000000000..5d1db9eaa2 --- /dev/null +++ b/ui/tests/acceptance/auth/login-settings-test.js @@ -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'); + }); +}); diff --git a/ui/tests/acceptance/config-ui/login-settings-test.js b/ui/tests/acceptance/config-ui/login-settings-test.js index 802a5d4e33..402634029d 100644 --- a/ui/tests/acceptance/config-ui/login-settings-test.js +++ b/ui/tests/acceptance/config-ui/login-settings-test.js @@ -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'); + }); }); }); diff --git a/ui/tests/helpers/auth/auth-helpers.ts b/ui/tests/helpers/auth/auth-helpers.ts index d61c68fd10..f9a4614715 100644 --- a/ui/tests/helpers/auth/auth-helpers.ts +++ b/ui/tests/helpers/auth/auth-helpers.ts @@ -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', }, }; diff --git a/ui/tests/integration/components/auth/form-template-test.js b/ui/tests/integration/components/auth/form-template-test.js index ab1db62d7c..8e16bcc6e4 100644 --- a/ui/tests/integration/components/auth/form-template-test.js +++ b/ui/tests/integration/components/auth/form-template-test.js @@ -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` `); }; }); @@ -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) { diff --git a/ui/tests/integration/components/auth/page-test.js b/ui/tests/integration/components/auth/page-test.js index d855041ab5..106f902902 100644 --- a/ui/tests/integration/components/auth/page-test.js +++ b/ui/tests/integration/components/auth/page-test.js @@ -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` 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); + }); + }); + }); + }); }); diff --git a/ui/tests/integration/components/auth/tabs-test.js b/ui/tests/integration/components/auth/tabs-test.js new file mode 100644 index 0000000000..40fc14c0a1 --- /dev/null +++ b/ui/tests/integration/components/auth/tabs-test.js @@ -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` + `); + }; + }); + + 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'); + }); +}); diff --git a/ui/types/vault/auth/form.d.ts b/ui/types/vault/auth/form.d.ts index cf32073d7f..ec3dd441ed 100644 --- a/ui/types/vault/auth/form.d.ts +++ b/ui/types/vault/auth/form.d.ts @@ -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;