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,