Skip to content

Commit 2d56c5e

Browse files
hellobontempoE_Esmaeili
authored andcommitted
UI: (Enterprise) Login form customization feature (hashicorp#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
1 parent a300c7a commit 2d56c5e

File tree

24 files changed

+1285
-447
lines changed

24 files changed

+1285
-447
lines changed

changelog/30700.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
```release-note:feature
2+
**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.
3+
```

ui/app/components/auth/form-template.hbs

Lines changed: 94 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -3,105 +3,103 @@
33
SPDX-License-Identifier: BUSL-1.1
44
}}
55

6-
<div {{did-insert this.initializeState}}>
7-
{{#if this.formComponent}}
8-
{{#let (component this.formComponent) as |AuthFormComponent|}}
9-
{{! renders Auth::Form::Base or Auth::Form::<Type>}}
10-
<AuthFormComponent
11-
@authType={{this.selectedAuthMethod}}
12-
@cluster={{@cluster}}
13-
@onError={{this.handleError}}
14-
@onSuccess={{@onSuccess}}
15-
>
16-
<:namespace>
17-
{{#if (has-feature "Namespaces")}}
18-
<Auth::NamespaceInput
19-
@disabled={{@oidcProviderQueryParam}}
20-
@hvdManagedNamespace={{this.flags.hvdManagedNamespaceRoot}}
21-
@namespaceValue={{this.namespaceInput}}
22-
@updateNamespace={{this.handleNamespaceUpdate}}
23-
/>
24-
{{/if}}
25-
</:namespace>
6+
{{#if this.formComponent}}
7+
{{#let (component this.formComponent) as |AuthFormComponent|}}
8+
{{! renders Auth::Form::Base or Auth::Form::<Type>}}
9+
<AuthFormComponent
10+
@authType={{this.selectedAuthMethod}}
11+
@cluster={{@cluster}}
12+
@onError={{this.handleError}}
13+
@onSuccess={{@onSuccess}}
14+
>
15+
<:namespace>
16+
{{#if (has-feature "Namespaces")}}
17+
<Auth::NamespaceInput
18+
@disabled={{@oidcProviderQueryParam}}
19+
@hvdManagedNamespace={{this.flags.hvdManagedNamespaceRoot}}
20+
@namespaceValue={{this.namespaceInput}}
21+
@updateNamespace={{this.handleNamespaceUpdate}}
22+
/>
23+
{{/if}}
24+
</:namespace>
2625

27-
<:back>
28-
{{#if this.showOtherMethods}}
29-
<Hds::Button
30-
@text="Back"
31-
{{on "click" this.toggleView}}
32-
@color="tertiary"
33-
@icon="arrow-left"
34-
data-test-back-button
26+
<:back>
27+
{{#if this.showAlternateView}}
28+
<Hds::Button
29+
@text="Back"
30+
{{on "click" this.toggleView}}
31+
@color="tertiary"
32+
@icon="arrow-left"
33+
data-test-back-button
34+
/>
35+
{{/if}}
36+
</:back>
37+
38+
{{! DIRECT LINK, TABS OR DROPDOWN }}
39+
<:authSelectOptions>
40+
<div class="has-bottom-margin-m">
41+
{{#if this.tabData}}
42+
<Auth::Tabs
43+
@authTabData={{this.tabData}}
44+
@handleTabClick={{this.setAuthType}}
45+
@selectedAuthMethod={{this.selectedAuthMethod}}
3546
/>
47+
{{else}}
48+
{{! Fallback is dropdown with all auth methods }}
49+
<Hds::Form::Select::Field
50+
name="selectedAuthMethod"
51+
{{on "input" this.setTypeFromDropdown}}
52+
data-test-select="auth type"
53+
as |F|
54+
>
55+
<F.Label class="has-top-margin-m">Method</F.Label>
56+
<F.Options>
57+
{{#each this.supportedAuthTypes as |type|}}
58+
<option selected={{eq this.selectedAuthMethod type}} value={{type}}>
59+
{{auth-display-name type}}
60+
</option>
61+
{{/each}}
62+
</F.Options>
63+
</Hds::Form::Select::Field>
3664
{{/if}}
37-
</:back>
38-
39-
{{! DIRECT LINK, TABS OR DROPDOWN }}
40-
<:authSelectOptions>
41-
<div class="has-bottom-margin-m">
42-
{{#if this.showCustomAuthOptions}}
43-
<Auth::Tabs
44-
@authTabData={{this.tabData}}
45-
@handleTabClick={{this.setAuthType}}
46-
@selectedAuthMethod={{this.selectedAuthMethod}}
47-
/>
48-
{{else}}
49-
{{! fallback view is the dropdown with all auth methods }}
50-
<Hds::Form::Select::Field
51-
name="selectedAuthMethod"
52-
{{on "input" this.setTypeFromDropdown}}
53-
data-test-select="auth type"
54-
as |F|
55-
>
56-
<F.Label class="has-top-margin-m">Auth method</F.Label>
57-
<F.Options>
58-
{{#each this.availableMethodTypes as |type|}}
59-
<option selected={{eq this.selectedAuthMethod type}} value={{type}}>
60-
{{auth-display-name type}}
61-
</option>
62-
{{/each}}
63-
</F.Options>
64-
</Hds::Form::Select::Field>
65-
{{/if}}
66-
</div>
67-
</:authSelectOptions>
65+
</div>
66+
</:authSelectOptions>
6867

69-
<:error>
70-
{{#if this.errorMessage}}
71-
<MessageError @errorMessage={{this.errorMessage}} />
72-
{{/if}}
73-
</:error>
68+
<:error>
69+
{{#if this.errorMessage}}
70+
<MessageError @errorMessage={{this.errorMessage}} />
71+
{{/if}}
72+
</:error>
7473

75-
<:advancedSettings>
76-
{{! custom auth options render their own mount path inputs and token does not support custom paths }}
77-
{{#if (and (not this.showCustomAuthOptions) (not-eq this.selectedAuthMethod "token"))}}
78-
<Hds::Reveal @text="Advanced settings" data-test-auth-form-options-toggle class="is-fullwidth">
79-
<Hds::Form::TextInput::Field name="path" data-test-input="path" as |F|>
80-
<F.Label class="has-top-margin-m">Mount path</F.Label>
81-
<F.HelperText>
82-
If this authentication method was mounted using a non-default path, input it below. Otherwise Vault will
83-
assume the default path
84-
<Hds::Text::Code class="code-in-text">{{this.selectedAuthMethod}}</Hds::Text::Code>
85-
.</F.HelperText>
86-
</Hds::Form::TextInput::Field>
87-
</Hds::Reveal>
88-
{{/if}}
89-
</:advancedSettings>
74+
<:advancedSettings>
75+
{{! custom auth options render their own mount path inputs and token does not support custom paths }}
76+
{{#unless this.hideAdvancedSettings}}
77+
<Hds::Reveal @text="Advanced settings" data-test-auth-form-options-toggle class="is-fullwidth">
78+
<Hds::Form::TextInput::Field name="path" data-test-input="path" as |F|>
79+
<F.Label class="has-top-margin-m">Mount path</F.Label>
80+
<F.HelperText>
81+
If this authentication method was mounted using a non-default path, input it below. Otherwise Vault will
82+
assume the default path
83+
<Hds::Text::Code class="code-in-text">{{this.selectedAuthMethod}}</Hds::Text::Code>
84+
.</F.HelperText>
85+
</Hds::Form::TextInput::Field>
86+
</Hds::Reveal>
87+
{{/unless}}
88+
</:advancedSettings>
9089

91-
<:footer>
92-
{{#if this.showCustomAuthOptions}}
93-
<Hds::Button
94-
{{on "click" this.toggleView}}
95-
@color="tertiary"
96-
@icon="arrow-right"
97-
@iconPosition="trailing"
98-
@isInline={{true}}
99-
@text="Sign in with other methods"
100-
data-test-other-methods-button
101-
/>
102-
{{/if}}
103-
</:footer>
104-
</AuthFormComponent>
105-
{{/let}}
106-
{{/if}}
107-
</div>
90+
<:footer>
91+
{{#if (and @alternateView (not this.showAlternateView))}}
92+
<Hds::Button
93+
{{on "click" this.toggleView}}
94+
@color="tertiary"
95+
@icon="arrow-right"
96+
@iconPosition="trailing"
97+
@isInline={{true}}
98+
@text="Sign in with other methods"
99+
data-test-other-methods-button
100+
/>
101+
{{/if}}
102+
</:footer>
103+
</AuthFormComponent>
104+
{{/let}}
105+
{{/if}}

ui/app/components/auth/form-template.ts

Lines changed: 44 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,10 @@ import { action } from '@ember/object';
1010
import { supportedTypes } from 'vault/utils/supported-login-methods';
1111
import { getRelativePath } from 'core/utils/sanitize-path';
1212

13-
import type AuthService from 'vault/vault/services/auth';
1413
import type FlagsService from 'vault/services/flags';
15-
import type Store from '@ember-data/store';
1614
import type VersionService from 'vault/services/version';
1715
import type ClusterModel from 'vault/models/cluster';
18-
import type { UnauthMountsByType, AuthTabMountData } from 'vault/vault/auth/form';
16+
import type { UnauthMountsByType } from 'vault/vault/auth/form';
1917
import type { HTMLElementEvent } from 'vault/forms';
2018

2119
/**
@@ -29,71 +27,82 @@ import type { HTMLElementEvent } from 'vault/forms';
2927
* dynamically renders the corresponding form.
3028
*
3129
*
30+
* @param {object | null} alternateView - if an alternate view exists, this is the `FormView` (see interface below) data to render that view.
3231
* @param {string} canceledMfaAuth - saved auth type from a cancelled mfa verification
3332
* @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
34-
* @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.
33+
* @param {object} defaultView - The `FormView` (see the interface below) data to render the initial view.
3534
* @param {function} handleNamespaceUpdate - callback task that passes user input to the controller and updates the namespace query param in the url
35+
* @param {object} initialFormState - sets selectedAuthMethod and showAlternateView based on the login form configuration computed in parent component
3636
* @param {string} namespaceQueryParam - namespace query param from the url
3737
* @param {string} oidcProviderQueryParam - oidc provider query param, set in url as "?o=someprovider". if present, disables the namespace input
3838
* @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
39-
* @param {object} visibleMountsByType - auth methods to render as tabs, contains mount data for any mounts with listing_visibility="unauth"
39+
* @param {array} visibleMountTypes - array of auth method types that have mounts with listing_visibility="unauth"
4040
*
4141
* */
4242

4343
interface Args {
44-
canceledMfaAuth: string;
44+
alternateView: FormView | null;
4545
cluster: ClusterModel;
46-
directLinkData: (AuthTabMountData & { isVisibleMount: boolean }) | null;
46+
defaultView: FormView;
4747
handleNamespaceUpdate: CallableFunction;
48+
initialFormState: { initialAuthType: string; showAlternate: boolean };
4849
namespaceQueryParam: string;
4950
oidcProviderQueryParam: string;
5051
onSuccess: CallableFunction;
51-
visibleMountsByType: UnauthMountsByType;
52+
visibleMountTypes: string[];
53+
}
54+
55+
interface FormView {
56+
view: string; // "dropdown" or "tabs"
57+
tabData: UnauthMountsByType | null; // tabs to render if view = "tabs"
5258
}
5359

5460
export default class AuthFormTemplate extends Component<Args> {
55-
@service declare readonly auth: AuthService;
5661
@service declare readonly flags: FlagsService;
57-
@service declare readonly store: Store;
5862
@service declare readonly version: VersionService;
5963

60-
// true → "Back" button renders, false → "Sign in with other methods→" renders if customizations exist
61-
@tracked showOtherMethods = false;
64+
supportedAuthTypes: string[];
6265

63-
// auth login variables
64-
@tracked selectedAuthMethod = '';
6566
@tracked errorMessage = '';
66-
67-
get tabData() {
68-
const { directLinkData } = this.args;
69-
// URL contains a "with" query param that references a mount with listing_visibility="unauth"
70-
// Treat it as a "preferred" mount and hide all other tabs
71-
if (directLinkData?.isVisibleMount && directLinkData?.type) {
72-
return { [directLinkData.type]: [this.args.directLinkData] };
73-
}
74-
return this.args.visibleMountsByType;
75-
}
76-
77-
get authTabTypes() {
78-
const visibleMounts = this.args.visibleMountsByType;
79-
return visibleMounts ? Object.keys(visibleMounts) : [];
67+
@tracked selectedAuthMethod = '';
68+
// true → "Back" button renders, false → "Sign in with other methods→" renders if an alternate view exists
69+
@tracked showAlternateView = false;
70+
71+
constructor(owner: unknown, args: Args) {
72+
super(owner, args);
73+
const { initialAuthType, showAlternate } = this.args.initialFormState;
74+
this.selectedAuthMethod = initialAuthType;
75+
this.showAlternateView = showAlternate;
76+
this.supportedAuthTypes = supportedTypes(this.version.isEnterprise);
8077
}
8178

82-
get availableMethodTypes() {
83-
return supportedTypes(this.version.isEnterprise);
79+
get tabData() {
80+
if (this.showAlternateView) return this.args?.alternateView?.tabData;
81+
return this.args?.defaultView?.tabData;
8482
}
8583

8684
get formComponent() {
8785
const { selectedAuthMethod } = this;
8886
// isSupported means there is a component file defined for that auth type
89-
const isSupported = this.availableMethodTypes.includes(selectedAuthMethod);
87+
const isSupported = this.supportedAuthTypes.includes(selectedAuthMethod);
9088
const formFile = () => (['oidc', 'jwt'].includes(selectedAuthMethod) ? 'oidc-jwt' : selectedAuthMethod);
9189
const component = isSupported ? formFile() : 'base';
9290

9391
// an Auth::Form::<Type> component exists for each method in supported-login-methods
9492
return `auth/form/${component}`;
9593
}
9694

95+
get hideAdvancedSettings() {
96+
// Token does not support custom paths
97+
if (this.selectedAuthMethod === 'token') return true;
98+
99+
// Always show for dropdown mode
100+
if (!this.tabData) return false;
101+
102+
// For remaining scenarios, hide "Advanced settings" if the selected method has visible mount(s)
103+
return this.args.visibleMountTypes?.includes(this.selectedAuthMethod);
104+
}
105+
97106
get namespaceInput() {
98107
const namespaceQueryParam = this.args.namespaceQueryParam;
99108
if (this.flags.hvdManagedNamespaceRoot) {
@@ -105,42 +114,6 @@ export default class AuthFormTemplate extends Component<Args> {
105114
return namespaceQueryParam;
106115
}
107116

108-
get preselectedType() {
109-
// Prioritize canceledMfaAuth since it's triggered by user interaction.
110-
// Next, check type from directLinkData as it's specified by the URL.
111-
// Finally, fall back to the most recently used auth method in localStorage.
112-
return this.args.canceledMfaAuth || this.args.directLinkData?.type || this.auth.getAuthType();
113-
}
114-
115-
// The "standard" selection is a dropdown listing all auth methods.
116-
// This getter determines whether to render an alternative view (e.g., tabs or a preferred mount).
117-
// If `true`, the "Sign in with other methods →" link is shown.
118-
get showCustomAuthOptions() {
119-
const hasLoginCustomization = this.args?.directLinkData?.isVisibleMount || this.args.visibleMountsByType;
120-
// Show if customization exists and the user has NOT clicked "Sign in with other methods →"
121-
return hasLoginCustomization && !this.showOtherMethods;
122-
}
123-
124-
@action
125-
initializeState() {
126-
// SET AUTH TYPE
127-
if (this.preselectedType) {
128-
this.setAuthType(this.preselectedType);
129-
} else {
130-
// if nothing has been preselected, select first tab or set to 'token'
131-
const authType = this.args.visibleMountsByType ? (this.authTabTypes[0] as string) : 'token';
132-
this.setAuthType(authType);
133-
}
134-
135-
// DETERMINES INITIAL RENDER: custom selection (direct link or tabs) vs dropdown
136-
if (this.args.visibleMountsByType) {
137-
// render tabs if selectedAuthMethod is one, otherwise render dropdown (i.e. showOtherMethods = false)
138-
this.showOtherMethods = this.authTabTypes.includes(this.selectedAuthMethod) ? false : true;
139-
} else {
140-
this.showOtherMethods = false;
141-
}
142-
}
143-
144117
@action
145118
setAuthType(authType: string) {
146119
this.selectedAuthMethod = authType;
@@ -153,15 +126,10 @@ export default class AuthFormTemplate extends Component<Args> {
153126

154127
@action
155128
toggleView() {
156-
this.showOtherMethods = !this.showOtherMethods;
157-
158-
if (this.showCustomAuthOptions) {
159-
const firstTab = this.authTabTypes[0] as string;
160-
this.setAuthType(firstTab);
161-
} else {
162-
// all methods render, reset dropdown
163-
this.selectedAuthMethod = this.preselectedType || 'token';
164-
}
129+
this.showAlternateView = !this.showAlternateView;
130+
const firstAuthTab = Object.keys(this.tabData || {})[0];
131+
const type = firstAuthTab || this.args.initialFormState.initialAuthType;
132+
this.setAuthType(type);
165133
}
166134

167135
@action

0 commit comments

Comments
 (0)