mirror of
https://github.com/hashicorp/vault.git
synced 2026-05-05 04:16:31 +02:00
UI: Make single method views consistent, add max width (#30660)
* update single method to match single tab view * add max-widht * update tests * convert page component to typescript * add azure to icons, update custom-login mirage scenario * update assertion count
This commit is contained in:
parent
b56f3c1135
commit
6964c093e7
@ -40,20 +40,11 @@
|
||||
<:authSelectOptions>
|
||||
<div class="has-bottom-margin-m">
|
||||
{{#if this.showCustomAuthOptions}}
|
||||
{{#if @directLinkData.isVisibleMount}}
|
||||
{{! 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 }}
|
||||
<Auth::MountsDisplay
|
||||
@mounts={{array @directLinkData}}
|
||||
@shouldRenderPath={{not-eq @selectedAuthMethod "token"}}
|
||||
/>
|
||||
{{else}}
|
||||
<Auth::Tabs
|
||||
@authTabData={{@visibleMountsByType}}
|
||||
@handleTabClick={{this.setAuthType}}
|
||||
@selectedAuthMethod={{this.selectedAuthMethod}}
|
||||
/>
|
||||
{{/if}}
|
||||
<Auth::Tabs
|
||||
@authTabData={{this.tabData}}
|
||||
@handleTabClick={{this.setAuthType}}
|
||||
@selectedAuthMethod={{this.selectedAuthMethod}}
|
||||
/>
|
||||
{{else}}
|
||||
{{! fallback view is the dropdown with all auth methods }}
|
||||
<Hds::Form::Select::Field
|
||||
|
||||
@ -64,6 +64,16 @@ export default class AuthFormTemplate extends Component<Args> {
|
||||
@tracked selectedAuthMethod = '';
|
||||
@tracked errorMessage = '';
|
||||
|
||||
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) : [];
|
||||
|
||||
@ -1,33 +0,0 @@
|
||||
{{!
|
||||
Copyright (c) HashiCorp, Inc.
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
}}
|
||||
|
||||
{{#if (gt @mounts.length 1)}}
|
||||
{{! render dropdown of mount paths }}
|
||||
<Hds::Form::Select::Field name="path" data-test-select="path" as |F|>
|
||||
<F.Label>Mount path</F.Label>
|
||||
<F.Options>
|
||||
{{#each @mounts as |mount|}}
|
||||
<option value={{mount.path}}>{{mount.path}}</option>
|
||||
{{/each}}
|
||||
</F.Options>
|
||||
</Hds::Form::Select::Field>
|
||||
{{else}}
|
||||
{{! render a single mount path }}
|
||||
{{#let (get @mounts "0") as |mount|}}
|
||||
{{#unless @hideType}}
|
||||
<Hds::Text::Body @tag="p" @weight="semibold" data-test-auth-method={{mount.type}}>
|
||||
{{auth-display-name mount.type}}
|
||||
</Hds::Text::Body>
|
||||
{{/unless}}
|
||||
{{#if mount.description}}
|
||||
<Hds::Text::Body @tag="p" @color="faint" data-test-description>{{mount.description}}</Hds::Text::Body>
|
||||
{{/if}}
|
||||
{{! the token auth method does't support custom paths so no need to render an input }}
|
||||
{{#if @shouldRenderPath}}
|
||||
{{! path is hidden so it is submitted with FormData but does not clutter the login form }}
|
||||
<input type="hidden" id="path" name="path" value={{mount.path}} data-test-input="path" />
|
||||
{{/if}}
|
||||
{{/let}}
|
||||
{{/if}}
|
||||
@ -8,6 +8,11 @@ import { service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
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 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
|
||||
@ -24,23 +29,36 @@ import { action } from '@ember/object';
|
||||
* @directLinkData={{this.model.directLinkData}}
|
||||
* />
|
||||
*
|
||||
* @param {string} directLinkData - type or mount data gleaned from query param
|
||||
* @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 {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
|
||||
* @param {function} onNamespaceUpdate - callback task that passes user input to the controller to update the login namespace in the url query params
|
||||
* @param {object} visibleAuthMounts - mount paths with listing_visibility="unauth", keys are the mount path and value is it's mount data such as "type" or "description," if it exists
|
||||
* @param {object} visibleAuthMounts - response from unauthenticated request to sys/internal/ui/mounts which returns mount paths tuned with `listing_visibility="unauth"`. keys are the mount path, values are mount data such as "type" or "description," if it exists
|
||||
* */
|
||||
|
||||
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.";
|
||||
|
||||
export default class AuthPage extends Component {
|
||||
@service('csp-event') csp;
|
||||
interface Args {
|
||||
visibleAuthMounts: UnauthMountsResponse;
|
||||
cluster: ClusterModel;
|
||||
onAuthSuccess: CallableFunction;
|
||||
}
|
||||
|
||||
interface MfaAuthData {
|
||||
mfa_requirement: object;
|
||||
selectedAuth: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export default class AuthPage extends Component<Args> {
|
||||
@service('csp-event') declare readonly csp: CspEventService;
|
||||
|
||||
@tracked canceledMfaAuth = '';
|
||||
@tracked mfaAuthData;
|
||||
@tracked mfaAuthData: MfaAuthData | null = null;
|
||||
@tracked mfaErrors = '';
|
||||
|
||||
get visibleMountsByType() {
|
||||
@ -52,7 +70,7 @@ export default class AuthPage extends Component {
|
||||
obj[type] ??= []; // if an array doesn't already exist for that type, create it
|
||||
obj[type].push({ path, ...mountData });
|
||||
return obj;
|
||||
}, {});
|
||||
}, {} as UnauthMountsByType);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@ -64,8 +82,8 @@ export default class AuthPage extends Component {
|
||||
}
|
||||
|
||||
@action
|
||||
onAuthResponse(authResponse, { selectedAuth, path }) {
|
||||
const { mfa_requirement } = authResponse;
|
||||
onAuthResponse(authResponse: AuthResponse | AuthResponseWithMfa, { selectedAuth = '', path = '' }) {
|
||||
const mfa_requirement = 'mfa_requirement' in authResponse ? authResponse.mfa_requirement : undefined;
|
||||
/*
|
||||
Checking for an mfa_requirement happens in two places.
|
||||
If doSubmit in <AuthForm> is called directly (by the <form> component) mfa is just handled here.
|
||||
@ -85,12 +103,12 @@ export default class AuthPage extends Component {
|
||||
@action
|
||||
onCancelMfa() {
|
||||
// before resetting mfaAuthData, preserve auth type
|
||||
this.canceledMfaAuth = this.mfaAuthData.selectedAuth;
|
||||
this.canceledMfaAuth = this.mfaAuthData?.selectedAuth ?? '';
|
||||
this.mfaAuthData = null;
|
||||
}
|
||||
|
||||
@action
|
||||
onMfaSuccess(authResponse) {
|
||||
onMfaSuccess(authResponse: AuthResponse) {
|
||||
// calls authSuccess in auth.js controller
|
||||
this.args.onAuthSuccess(authResponse);
|
||||
}
|
||||
@ -12,11 +12,29 @@
|
||||
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)}}
|
||||
<Auth::MountsDisplay
|
||||
@mounts={{mounts}}
|
||||
@shouldRenderPath={{not-eq @selectedAuthMethod "token"}}
|
||||
@hideType={{true}}
|
||||
/>
|
||||
{{#if (gt mounts.length 1)}}
|
||||
{{! DROPDOWN for mount paths }}
|
||||
<Hds::Form::Select::Field name="path" data-test-select="path" as |F|>
|
||||
<F.Label>Mount path</F.Label>
|
||||
<F.Options>
|
||||
{{#each mounts as |mount|}}
|
||||
<option value={{mount.path}}>{{mount.path}}</option>
|
||||
{{/each}}
|
||||
</F.Options>
|
||||
</Hds::Form::Select::Field>
|
||||
{{else}}
|
||||
{{! SINGLE mount path }}
|
||||
{{#let (get mounts "0") as |mount|}}
|
||||
{{#if mount.description}}
|
||||
<Hds::Text::Body @tag="p" @color="faint" data-test-description>{{mount.description}}</Hds::Text::Body>
|
||||
{{/if}}
|
||||
{{! the token auth method does't support custom paths so no need to render an input }}
|
||||
{{#if (not-eq @selectedAuthMethod "token")}}
|
||||
{{! path is hidden so it is submitted with FormData but does not clutter the login form }}
|
||||
<input type="hidden" id="path" name="path" value={{mount.path}} data-test-input="path" />
|
||||
{{/if}}
|
||||
{{/let}}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</div>
|
||||
</T.Panel>
|
||||
|
||||
@ -13,6 +13,7 @@ const DOMAIN_STRINGS = {
|
||||
'ping.com': 'Ping',
|
||||
'okta.com': 'Okta',
|
||||
'auth0.com': 'Auth0',
|
||||
'login.microsoftonline.com': 'Azure',
|
||||
};
|
||||
|
||||
const PROVIDER_WITH_LOGO = {
|
||||
@ -21,6 +22,7 @@ const PROVIDER_WITH_LOGO = {
|
||||
Google: 'google',
|
||||
Okta: 'okta',
|
||||
Auth0: 'auth0',
|
||||
Azure: 'azure',
|
||||
};
|
||||
|
||||
export { DOMAIN_STRINGS, PROVIDER_WITH_LOGO };
|
||||
|
||||
@ -199,5 +199,6 @@
|
||||
.column.is-4-desktop {
|
||||
flex: none;
|
||||
width: 33.33333%;
|
||||
max-width: 600px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@ export default function (server) {
|
||||
name: 'Root namespace default',
|
||||
namespace: '',
|
||||
default_auth_type: 'userpass',
|
||||
backup_auth_types: ['okta'],
|
||||
backup_auth_types: ['okta', 'token'],
|
||||
disable_inheritance: true,
|
||||
});
|
||||
server.create('login-rule', {
|
||||
@ -16,7 +16,7 @@ export default function (server) {
|
||||
default_auth_type: 'oidc',
|
||||
backup_auth_types: ['token'],
|
||||
});
|
||||
server.create('login-rule', { default_auth_type: 'jwt', backup_auth_types: [] });
|
||||
server.create('login-rule', { default_auth_type: '', backup_auth_types: ['oidc', 'jwt'] });
|
||||
server.create('login-rule', { default_auth_type: '', 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: '', backup_auth_types: ['oidc', 'jwt'] }); // namespace-3
|
||||
server.create('login-rule', { default_auth_type: '', backup_auth_types: ['token'] }); // namespace-4
|
||||
}
|
||||
|
||||
@ -103,15 +103,15 @@ module('Acceptance | auth login form', function (hooks) {
|
||||
|
||||
test('it renders preferred mount view if "with" query param is a mount path with listing_visibility="unauth"', async function (assert) {
|
||||
await visit('/vault/auth?with=my-oidc%2F');
|
||||
await waitFor(AUTH_FORM.preferredMethod('oidc'));
|
||||
assert.dom(AUTH_FORM.preferredMethod('oidc')).hasText('OIDC', 'it renders mount type');
|
||||
await waitFor(AUTH_FORM.tabBtn('oidc'));
|
||||
assert.dom(AUTH_FORM.authForm('oidc')).exists();
|
||||
assert.dom(AUTH_FORM.tabBtn('oidc')).exists();
|
||||
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(AUTH_FORM.tabBtn('oidc')).doesNotExist('tab does not render');
|
||||
assert.dom(GENERAL.selectByAttr('auth type')).doesNotExist();
|
||||
assert.dom(GENERAL.selectByAttr('auth type')).doesNotExist('dropdown does not render');
|
||||
assert.dom(AUTH_FORM.advancedSettings).doesNotExist();
|
||||
assert.dom(GENERAL.backButton).doesNotExist();
|
||||
});
|
||||
@ -122,7 +122,9 @@ module('Acceptance | auth login form', function (hooks) {
|
||||
assert
|
||||
.dom(AUTH_FORM.tabBtn('oidc'))
|
||||
.hasAttribute('aria-selected', 'true', 'it selects tab matching query param');
|
||||
assert.dom(AUTH_FORM.preferredMethod('oidc')).doesNotExist('it does not render single mount view');
|
||||
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.backButton).doesNotExist();
|
||||
});
|
||||
|
||||
|
||||
@ -7,7 +7,6 @@ export const AUTH_FORM = {
|
||||
selectMethod: '[data-test-select="auth type"]',
|
||||
form: '[data-test-auth-form]',
|
||||
login: '[data-test-auth-submit]',
|
||||
preferredMethod: (method: string) => `p[data-test-auth-method="${method}"]`,
|
||||
tabs: '[data-test-auth-tab]',
|
||||
tabBtn: (method: string) => `[data-test-auth-tab="${method}"] button`, // method is all lowercased
|
||||
description: '[data-test-description]',
|
||||
|
||||
@ -183,7 +183,7 @@ module('Integration | Component | auth | form template', function (hooks) {
|
||||
test('it renders the mount description', async function (assert) {
|
||||
await this.renderComponent();
|
||||
await click(AUTH_FORM.tabBtn('token'));
|
||||
assert.dom('section p').hasText('token based credentials');
|
||||
assert.dom(AUTH_FORM.description).hasText('token based credentials');
|
||||
});
|
||||
|
||||
test('it renders a dropdown if multiple mount paths are returned', async function (assert) {
|
||||
@ -261,14 +261,15 @@ module('Integration | Component | auth | form template', function (hooks) {
|
||||
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.preferredMethod('oidc')).hasText('OIDC', 'it renders mount type');
|
||||
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(AUTH_FORM.tabBtn('oidc')).doesNotExist('tab does not render');
|
||||
assert.dom(GENERAL.selectByAttr('auth type')).doesNotExist();
|
||||
assert.dom(GENERAL.selectByAttr('auth type')).doesNotExist('dropdown does not render');
|
||||
assert.dom(AUTH_FORM.advancedSettings).doesNotExist();
|
||||
assert.dom(GENERAL.backButton).doesNotExist();
|
||||
});
|
||||
@ -284,8 +285,6 @@ module('Integration | Component | auth | form template', function (hooks) {
|
||||
assert.dom(GENERAL.inputByAttr('password')).exists();
|
||||
await click(AUTH_FORM.advancedSettings);
|
||||
assert.dom(GENERAL.inputByAttr('path')).exists();
|
||||
|
||||
assert.dom(AUTH_FORM.preferredMethod('ldap')).doesNotExist('single mount view does not render');
|
||||
assert.dom(AUTH_FORM.tabBtn('ldap')).doesNotExist('tab does not render');
|
||||
assert
|
||||
.dom(GENERAL.backButton)
|
||||
|
||||
@ -132,8 +132,6 @@ module('Integration | Component | auth | page', function (hooks) {
|
||||
assert.dom(GENERAL.inputByAttr('password')).exists();
|
||||
await click(AUTH_FORM.advancedSettings);
|
||||
assert.dom(GENERAL.inputByAttr('path')).exists();
|
||||
|
||||
assert.dom(AUTH_FORM.preferredMethod('ldap')).doesNotExist('single mount view does not render');
|
||||
assert.dom(AUTH_FORM.tabBtn('ldap')).doesNotExist('tab does not render');
|
||||
assert
|
||||
.dom(GENERAL.backButton)
|
||||
@ -144,13 +142,11 @@ module('Integration | Component | auth | page', function (hooks) {
|
||||
test('it renders single mount view instead of tabs if @directLinkData data references a visible type', async function (assert) {
|
||||
this.directLinkData = { path: 'my-oidc/', type: 'oidc', isVisibleMount: true };
|
||||
await this.renderComponent();
|
||||
assert.dom(AUTH_FORM.preferredMethod('oidc')).hasText('OIDC', 'it renders mount type');
|
||||
assert.dom(AUTH_FORM.tabBtn('oidc')).hasText('OIDC', 'it renders tab for type');
|
||||
assert.dom(GENERAL.inputByAttr('role')).exists();
|
||||
assert.dom(GENERAL.inputByAttr('path')).hasAttribute('type', 'hidden');
|
||||
assert.dom(GENERAL.inputByAttr('path')).hasValue('my-oidc/');
|
||||
assert.dom(AUTH_FORM.otherMethodsBtn).exists('"Sign in with other methods" renders');
|
||||
|
||||
assert.dom(AUTH_FORM.tabBtn('oidc')).doesNotExist('tab does not render');
|
||||
assert.dom(GENERAL.selectByAttr('auth type')).doesNotExist();
|
||||
assert.dom(AUTH_FORM.advancedSettings).doesNotExist();
|
||||
assert.dom(GENERAL.backButton).doesNotExist();
|
||||
|
||||
@ -28,7 +28,7 @@ module('Unit | Model | role-jwt', function (hooks) {
|
||||
});
|
||||
|
||||
test('it provides a providerName for listed known providers', function (assert) {
|
||||
assert.expect(12);
|
||||
assert.expect(14);
|
||||
Object.keys(DOMAIN_STRINGS).forEach((domain) => {
|
||||
const model = this.owner.lookup('service:store').createRecord('role-jwt', {
|
||||
authUrl: `http://provider-${domain}`,
|
||||
|
||||
4
ui/types/vault/auth/form.d.ts
vendored
4
ui/types/vault/auth/form.d.ts
vendored
@ -7,6 +7,10 @@ export interface UnauthMountsByType {
|
||||
// key is the auth method type
|
||||
[key: string]: AuthTabMountData[];
|
||||
}
|
||||
export interface UnauthMountsResponse {
|
||||
// key is the mount path
|
||||
[key: string]: { type: string; description?: string; config?: object | null };
|
||||
}
|
||||
|
||||
export interface AuthTabMountData {
|
||||
path: string;
|
||||
|
||||
4
ui/types/vault/services/auth.d.ts
vendored
4
ui/types/vault/services/auth.d.ts
vendored
@ -16,7 +16,6 @@ export interface AuthData {
|
||||
renewable: boolean;
|
||||
entity_id: string;
|
||||
displayName?: string;
|
||||
mfa_requirement?: MfaRequirementApiResponse;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
@ -24,6 +23,9 @@ export interface AuthResponse {
|
||||
token: string; // the name of the token in local storage, not the actual token
|
||||
isRoot: boolean;
|
||||
}
|
||||
export interface AuthResponseWithMfa {
|
||||
mfa_requirement: MfaRequirementApiResponse;
|
||||
}
|
||||
|
||||
export default class AuthService extends Service {
|
||||
authData: AuthData;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user