diff --git a/awx/sso/views.py b/awx/sso/views.py index fa248f634f..ddbc2cbd59 100644 --- a/awx/sso/views.py +++ b/awx/sso/views.py @@ -25,7 +25,7 @@ class BaseRedirectView(RedirectView): def get_redirect_url(self, *args, **kwargs): last_path = self.request.COOKIES.get('lastPath', '') last_path = urllib.parse.quote(urllib.parse.unquote(last_path).strip('"')) - url = reverse('ui:index') + url = reverse('ui_next:index') if last_path: return '%s#%s' % (url, last_path) else: diff --git a/awx/ui_next/src/api/index.js b/awx/ui_next/src/api/index.js index 6bc5557f1c..f5f0c05330 100644 --- a/awx/ui_next/src/api/index.js +++ b/awx/ui_next/src/api/index.js @@ -1,5 +1,6 @@ import AdHocCommands from './models/AdHocCommands'; import Applications from './models/Applications'; +import Auth from './models/Auth'; import Config from './models/Config'; import CredentialInputSources from './models/CredentialInputSources'; import CredentialTypes from './models/CredentialTypes'; @@ -40,6 +41,7 @@ import WorkflowJobs from './models/WorkflowJobs'; const AdHocCommandsAPI = new AdHocCommands(); const ApplicationsAPI = new Applications(); +const AuthAPI = new Auth(); const ConfigAPI = new Config(); const CredentialInputSourcesAPI = new CredentialInputSources(); const CredentialTypesAPI = new CredentialTypes(); @@ -81,6 +83,7 @@ const WorkflowJobsAPI = new WorkflowJobs(); export { AdHocCommandsAPI, ApplicationsAPI, + AuthAPI, ConfigAPI, CredentialInputSourcesAPI, CredentialTypesAPI, diff --git a/awx/ui_next/src/api/models/Auth.js b/awx/ui_next/src/api/models/Auth.js new file mode 100644 index 0000000000..5743b4f3d5 --- /dev/null +++ b/awx/ui_next/src/api/models/Auth.js @@ -0,0 +1,10 @@ +import Base from '../Base'; + +class Auth extends Base { + constructor(http) { + super(http); + this.baseUrl = '/api/v2/auth/'; + } +} + +export default Auth; diff --git a/awx/ui_next/src/screens/Login/Login.jsx b/awx/ui_next/src/screens/Login/Login.jsx index ca358a0d74..cad87f1dfa 100644 --- a/awx/ui_next/src/screens/Login/Login.jsx +++ b/awx/ui_next/src/screens/Login/Login.jsx @@ -4,9 +4,20 @@ import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Formik } from 'formik'; import styled from 'styled-components'; -import { LoginForm, LoginPage as PFLoginPage } from '@patternfly/react-core'; +import { + LoginMainFooterLinksItem, + LoginForm, + LoginPage as PFLoginPage, + Tooltip, +} from '@patternfly/react-core'; +import { + AzureIcon, + GoogleIcon, + GithubIcon, + UserCircleIcon, +} from '@patternfly/react-icons'; import useRequest, { useDismissableError } from '../../util/useRequest'; -import { RootAPI } from '../../api'; +import { AuthAPI, RootAPI } from '../../api'; import AlertModal from '../../components/AlertModal'; import ErrorDetail from '../../components/ErrorDetail'; @@ -23,7 +34,7 @@ function AWXLogin({ alt, i18n, isAuthenticated }) { isLoading: isCustomLoginInfoLoading, error: customLoginInfoError, request: fetchCustomLoginInfo, - result: { brandName, logo, loginInfo }, + result: { brandName, logo, loginInfo, socialAuthOptions }, } = useRequest( useCallback(async () => { const [ @@ -33,7 +44,12 @@ function AWXLogin({ alt, i18n, isAuthenticated }) { { data: { BRAND_NAME }, }, - ] = await Promise.all([RootAPI.read(), RootAPI.readAssetVariables()]); + { data: authData }, + ] = await Promise.all([ + RootAPI.read(), + RootAPI.readAssetVariables(), + AuthAPI.read(), + ]); const logoSrc = custom_logo ? `data:image/jpeg;${custom_logo}` : loginLogoSrc; @@ -41,9 +57,15 @@ function AWXLogin({ alt, i18n, isAuthenticated }) { brandName: BRAND_NAME, logo: logoSrc, loginInfo: custom_login_info, + socialAuthOptions: authData, }; }, []), - { brandName: null, logo: loginLogoSrc, loginInfo: null } + { + brandName: null, + logo: loginLogoSrc, + loginInfo: null, + socialAuthOptions: {}, + } ); const { @@ -100,6 +122,79 @@ function AWXLogin({ alt, i18n, isAuthenticated }) { : '' } textContent={loginInfo} + socialMediaLoginContent={ + <> + {socialAuthOptions && + Object.keys(socialAuthOptions).map(authKey => { + const loginUrl = socialAuthOptions[authKey].login_url; + if (authKey === 'azuread-oauth2') { + return ( + + + + + + ); + } + if (authKey === 'github') { + return ( + + + + + + ); + } + if (authKey === 'github-org') { + return ( + + + + + + ); + } + if (authKey === 'github-team') { + return ( + + + + + + ); + } + if (authKey === 'google-oauth2') { + return ( + + + + + + ); + } + if (authKey.startsWith('saml')) { + const samlIDP = authKey.split(':')[1] || null; + return ( + + + + + + ); + } + + return null; + })} + + } > {formik => ( - <> - { - formik.setFieldValue('password', val); - dismissAuthError(); - }} - onChangeUsername={val => { - formik.setFieldValue('username', val); - dismissAuthError(); - }} - onLoginButtonClick={formik.handleSubmit} - passwordLabel={i18n._(t`Password`)} - passwordValue={formik.values.password} - showHelperText={authError} - usernameLabel={i18n._(t`Username`)} - usernameValue={formik.values.username} - /> - + { + formik.setFieldValue('password', val); + dismissAuthError(); + }} + onChangeUsername={val => { + formik.setFieldValue('username', val); + dismissAuthError(); + }} + onLoginButtonClick={formik.handleSubmit} + passwordLabel={i18n._(t`Password`)} + passwordValue={formik.values.password} + showHelperText={authError} + usernameLabel={i18n._(t`Username`)} + usernameValue={formik.values.username} + /> )} {loginInfoError && ( diff --git a/awx/ui_next/src/screens/Login/Login.test.jsx b/awx/ui_next/src/screens/Login/Login.test.jsx index 22c41ca6d4..5e9aa98909 100644 --- a/awx/ui_next/src/screens/Login/Login.test.jsx +++ b/awx/ui_next/src/screens/Login/Login.test.jsx @@ -1,6 +1,6 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; -import { RootAPI } from '../../api'; +import { AuthAPI, RootAPI } from '../../api'; import { mountWithContexts, waitForElement, @@ -16,6 +16,10 @@ RootAPI.readAssetVariables.mockResolvedValue({ }, }); +AuthAPI.read.mockResolvedValue({ + data: {}, +}); + describe('', () => { async function findChildren(wrapper) { const [ @@ -268,4 +272,111 @@ describe('', () => { await waitForElement(wrapper, 'Redirect', el => el.props().to === '/'); done(); }); + + test('GitHub auth buttons shown', async done => { + AuthAPI.read.mockResolvedValue({ + data: { + github: { + login_url: '/sso/login/github/', + complete_url: 'https://localhost:8043/sso/complete/github/', + }, + 'github-org': { + login_url: '/sso/login/github-org/', + complete_url: 'https://localhost:8043/sso/complete/github-org/', + }, + 'github-team': { + login_url: '/sso/login/github-team/', + complete_url: 'https://localhost:8043/sso/complete/github-team/', + }, + }, + }); + + let wrapper; + await act(async () => { + wrapper = mountWithContexts( false} />); + }); + wrapper.update(); + expect(wrapper.find('GithubIcon').length).toBe(3); + expect(wrapper.find('AzureIcon').length).toBe(0); + expect(wrapper.find('GoogleIcon').length).toBe(0); + expect(wrapper.find('UserCircleIcon').length).toBe(0); + done(); + }); + + test('Google auth button shown', async done => { + AuthAPI.read.mockResolvedValue({ + data: { + 'google-oauth2': { + login_url: '/sso/login/google-oauth2/', + complete_url: 'https://localhost:8043/sso/complete/google-oauth2/', + }, + }, + }); + + let wrapper; + await act(async () => { + wrapper = mountWithContexts( false} />); + }); + wrapper.update(); + expect(wrapper.find('GithubIcon').length).toBe(0); + expect(wrapper.find('AzureIcon').length).toBe(0); + expect(wrapper.find('GoogleIcon').length).toBe(1); + expect(wrapper.find('UserCircleIcon').length).toBe(0); + done(); + }); + + test('Azure AD auth button shown', async done => { + AuthAPI.read.mockResolvedValue({ + data: { + 'azuread-oauth2': { + login_url: '/sso/login/azuread-oauth2/', + complete_url: 'https://localhost:8043/sso/complete/azuread-oauth2/', + }, + }, + }); + + let wrapper; + await act(async () => { + wrapper = mountWithContexts( false} />); + }); + wrapper.update(); + expect(wrapper.find('GithubIcon').length).toBe(0); + expect(wrapper.find('AzureIcon').length).toBe(1); + expect(wrapper.find('GoogleIcon').length).toBe(0); + expect(wrapper.find('UserCircleIcon').length).toBe(0); + done(); + }); + + test('SAML auth buttons shown', async done => { + AuthAPI.read.mockResolvedValue({ + data: { + saml: { + login_url: '/sso/login/saml/', + complete_url: 'https://localhost:8043/sso/complete/saml/', + metadata_url: '/sso/metadata/saml/', + }, + 'saml:onelogin': { + login_url: '/sso/login/saml/?idp=onelogin', + complete_url: 'https://localhost:8043/sso/complete/saml/', + metadata_url: '/sso/metadata/saml/', + }, + 'saml:someotheridp': { + login_url: '/sso/login/saml/?idp=someotheridp', + complete_url: 'https://localhost:8043/sso/complete/saml/', + metadata_url: '/sso/metadata/saml/', + }, + }, + }); + + let wrapper; + await act(async () => { + wrapper = mountWithContexts( false} />); + }); + wrapper.update(); + expect(wrapper.find('GithubIcon').length).toBe(0); + expect(wrapper.find('AzureIcon').length).toBe(0); + expect(wrapper.find('GoogleIcon').length).toBe(0); + expect(wrapper.find('UserCircleIcon').length).toBe(3); + done(); + }); }); diff --git a/awx/ui_next/src/setupProxy.js b/awx/ui_next/src/setupProxy.js index 916f548afb..64027cc437 100644 --- a/awx/ui_next/src/setupProxy.js +++ b/awx/ui_next/src/setupProxy.js @@ -4,7 +4,7 @@ const TARGET = process.env.TARGET || 'https://localhost:8043'; module.exports = app => { app.use( - createProxyMiddleware(['/api', '/websocket'], { + createProxyMiddleware(['/api', '/websocket', '/sso'], { target: TARGET, secure: false, ws: true,