diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4ec1733220..ca2f305545 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -16,6 +16,7 @@ env: NODE_VERSION: "20" MANTAINERS: '["cdimonaco", "dottorblaster", "janvhs", "rtorrero", "nelsonkopliku", "arbulu89","jagabomb","emaksy","jamie-suse"]' RG_TEST_LABEL: regression + INTEGRATION_TEST_LABEL: integration jobs: elixir-deps: @@ -562,6 +563,108 @@ jobs: name: regression-${{ matrix.test }}-e2e-screenshots path: test/e2e/cypress/screenshots/ + check-integration-tests-label: + name: Check if the integration test criteria are met, store in the job output + runs-on: ubuntu-22.04 + outputs: + run_integration_test: ${{ steps.check.outputs.run_integration_test }} + steps: + - id: check + run: echo "run_integration_test=${{ contains(fromJson(env.MANTAINERS), github.event.sender.login) && contains(github.event.pull_request.labels.*.name, env.INTEGRATION_TEST_LABEL) }}" >> "$GITHUB_OUTPUT" + + integration-test-e2e: + name: Integration tests + needs: [check-integration-tests-label, elixir-deps, npm-deps, npm-e2e-deps] + runs-on: ubuntu-22.04 + if: needs.check-integration-tests-label.outputs.run_integration_test == 'true' + strategy: + matrix: + include: + - test: oidc + cypress_spec: | + cypress/e2e/oidc_integration.cy.js + config_file_content: | + import Config + + config :trento, :oidc, enabled: true + env: + MIX_ENV: dev + CYPRESS_OIDC_INTEGRATION_TESTS: true + env: ${{ matrix.env }} + steps: + - name: Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.12.1 + with: + access_token: ${{ github.token }} + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup + id: setup-elixir + uses: erlef/setup-beam@v1 + with: + version-file: .tool-versions + version-type: strict + env: + ImageOS: ubuntu20 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + - name: Retrieve Cached Dependencies + uses: actions/cache@v4 + id: mix-cache + with: + path: | + deps + _build/dev + priv/plts + key: ${{ runner.os }}-${{ steps.setup-elixir.outputs.otp-version }}-${{ steps.setup-elixir.outputs.elixir-version }}-${{ hashFiles('mix.lock') }} + - name: Retrieve NPM Cached Dependencies + uses: actions/cache@v4 + id: npm-cache + with: + path: | + assets/node_modules + key: ${{ runner.os }}-${{ env.NODE_VERSION }}-${{ hashFiles('assets/package-lock.json') }} + - name: Retrieve E2E NPM Cached Dependencies + uses: actions/cache@v4 + id: npm-e2e-cache + with: + path: | + test/e2e/node_modules + key: ${{ runner.os }}-${{ env.NODE_VERSION }}-${{ hashFiles('test/e2e/package-lock.json') }} + - name: "Docker compose dependencies" + uses: isbang/compose-action@v2.0.1 + with: + compose-file: "./docker-compose.yaml" + compose-flags: "--profile idp" + down-flags: "--volumes" + - name: Create dev.local.exs file + run: echo "${{ matrix.config_file_content }}" > config/dev.local.exs + - name: Mix setup + run: mix setup + - name: Run trento detached + run: mix phx.server & + - name: Cypress run + uses: cypress-io/github-action@v6 + env: + cypress_video: false + cypress_db_host: postgres + cypress_db_port: 5432 + with: + working-directory: test/e2e + spec: ${{ matrix.cypress_spec }} + wait-on-timeout: 30 + config: baseUrl=http://localhost:4000 + - name: Upload cypress test screenshots + uses: actions/upload-artifact@v4 + if: failure() + with: + name: integration-${{ matrix.test }}-e2e-screenshots + path: test/e2e/cypress/screenshots/ + target-branch-deps: name: Rebuild target branch dependencies runs-on: ubuntu-20.04 diff --git a/assets/jest.config.js b/assets/jest.config.js index 0442bfd103..9786828bef 100644 --- a/assets/jest.config.js +++ b/assets/jest.config.js @@ -67,6 +67,10 @@ module.exports = { config: { checksServiceBaseUrl: '', suseManagerEnabled: true, + adminUsername: 'admin', + oidcEnabled: false, + oidcLoginUrl: 'http://localhost:4000/auth/oidc_callback', + oidcCallbackUrl: '/auth/oidc_callback', aTestVariable: 123, }, }, diff --git a/assets/js/lib/auth/config.js b/assets/js/lib/auth/config.js new file mode 100644 index 0000000000..e1430616c2 --- /dev/null +++ b/assets/js/lib/auth/config.js @@ -0,0 +1,23 @@ +import { getFromConfig } from '@lib/config'; + +const OIDC_ENABLED = getFromConfig('oidcEnabled') || false; +const OIDC_LOGIN_URL = getFromConfig('oidcLoginUrl') || ''; +const OIDC_CALLBACK_URL = getFromConfig('oidcCallbackUrl') || ''; + +export const isSingleSignOnEnabled = () => OIDC_ENABLED; + +export const getSingleSignOnLoginUrl = () => { + if (OIDC_ENABLED) { + return OIDC_LOGIN_URL; + } + + return ''; +}; + +export const getSingleSignOnCallbackUrl = () => { + if (OIDC_ENABLED) { + return OIDC_CALLBACK_URL; + } + + return ''; +}; diff --git a/assets/js/lib/auth/config.test.js b/assets/js/lib/auth/config.test.js new file mode 100644 index 0000000000..e71ec53c12 --- /dev/null +++ b/assets/js/lib/auth/config.test.js @@ -0,0 +1,35 @@ +import { isSingleSignOnEnabled } from './config'; + +describe('auth config', () => { + beforeEach(() => { + jest.resetModules(); + }); + + it('should check if single sign on is enabled', () => { + expect(isSingleSignOnEnabled()).toBeFalsy(); + + global.config.oidcEnabled = true; + + return import('./config').then((config) => { + expect(config.isSingleSignOnEnabled()).toBeTruthy(); + }); + }); + + it('should get OIDC login url if OIDC is enabled', async () => { + global.config.oidcEnabled = true; + + return import('./config').then((config) => { + expect(config.getSingleSignOnLoginUrl()).toBe( + 'http://localhost:4000/auth/oidc_callback' + ); + }); + }); + + it('should get OIDC callback url if OIDC is enabled', async () => { + global.config.oidcEnabled = true; + + return import('./config').then((config) => { + expect(config.getSingleSignOnCallbackUrl()).toBe('/auth/oidc_callback'); + }); + }); +}); diff --git a/assets/js/lib/auth/index.js b/assets/js/lib/auth/index.js index 5fdd57b236..549df21672 100644 --- a/assets/js/lib/auth/index.js +++ b/assets/js/lib/auth/index.js @@ -13,6 +13,16 @@ export const login = (credentials) => return response; }); +export const oidcEnrollment = (credentials) => + authClient + .post('/api/session/oidc_local/callback', credentials) + .then((response) => { + if (response.status !== 200) { + throw Error('unauthorized', { cause: response.status }); + } + return response; + }); + export const refreshAccessToken = (refreshToken) => authClient .post('/api/session/refresh', { refresh_token: refreshToken }) diff --git a/assets/js/lib/model/users.test.js b/assets/js/lib/model/users.test.js index 5c530e42cf..6fd5328dc5 100644 --- a/assets/js/lib/model/users.test.js +++ b/assets/js/lib/model/users.test.js @@ -4,10 +4,10 @@ import { isAdmin } from './users'; describe('users', () => { it('should check if a user is admin', () => { - const admin = adminUser.build(); + const admin = adminUser.build({ username: 'admin' }); expect(isAdmin(admin)).toBe(true); - const user = userFactory.build({ id: 2 }); + const user = userFactory.build({ username: 'other' }); expect(isAdmin(user)).toBe(false); }); }); diff --git a/assets/js/pages/Login/Login.jsx b/assets/js/pages/Login/Login.jsx index 7704129328..ebcacf0b90 100644 --- a/assets/js/pages/Login/Login.jsx +++ b/assets/js/pages/Login/Login.jsx @@ -5,9 +5,13 @@ import { useDispatch, useSelector } from 'react-redux'; import { toast } from 'react-hot-toast'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { initiateLogin } from '@state/user'; -import classNames from 'classnames'; +import { + isSingleSignOnEnabled, + getSingleSignOnLoginUrl, +} from '@lib/auth/config'; -import Input from '@common/Input'; +import LoginForm from './LoginForm'; +import LoginSSO from './LoginSSO'; export default function Login() { const [username, setUsername] = useState(''); @@ -69,109 +73,23 @@ export default function Login() {
-
- {!totpCodeRequested ? ( - <> -
- -
- setUsername(e.target.value)} - name="username" - autoComplete="username" - required - error={isUnauthorized} - className={classNames( - 'appearance-none px-3 py-2 text-inherit border-gray-300 shadow-sm focus:ring-jungle-green-500 focus:border-jungle-green-500 sm:text-sm', - { 'disabled:opacity-50': authInProgress } - )} - /> -
-
- -
- -
- setPassword(e.target.value)} - name="password" - autoComplete="current-password" - required - error={isUnauthorized} - className={classNames( - 'appearance-none px-3 py-2 text-inherit border-gray-300 shadow-sm focus:ring-jungle-green-500 focus:border-jungle-green-500 sm:text-sm', - { 'disabled:opacity-50': authInProgress } - )} - /> -
-
- - ) : ( -
- -
- setTotpCode(e.target.value)} - autoComplete="off" - required - error={isUnauthorized} - className={classNames( - 'appearance-none px-3 py-2 text-inherit border-gray-300 shadow-sm focus:ring-jungle-green-500 focus:border-jungle-green-500 sm:text-sm', - { 'disabled:opacity-50': authInProgress } - )} - /> -
-
- )} - {authError && authError.code === 401 && ( -

- Invalid credentials -

- )} -
- -
-
+ {isSingleSignOnEnabled() ? ( + + ) : ( + + )}
diff --git a/assets/js/pages/Login/Login.test.jsx b/assets/js/pages/Login/Login.test.jsx index 9415e11eaf..e542ee2836 100644 --- a/assets/js/pages/Login/Login.test.jsx +++ b/assets/js/pages/Login/Login.test.jsx @@ -5,6 +5,7 @@ import '@testing-library/jest-dom'; import userEvent from '@testing-library/user-event'; import { Toaster } from 'react-hot-toast'; import { withState, renderWithRouter } from '@lib/test-utils'; +import * as authConfig from '@lib/auth/config'; import Login from './Login'; describe('Login component', () => { @@ -183,4 +184,27 @@ describe('Login component', () => { payload: { username: '', password: '', totpCode }, }); }); + + describe('Single sign on', () => { + it('should display the SSO login button', async () => { + jest.spyOn(authConfig, 'isSingleSignOnEnabled').mockReturnValue(true); + jest + .spyOn(authConfig, 'getSingleSignOnLoginUrl') + .mockReturnValue('http://idp-url'); + + const [StatefulLogin] = withState(, { + user: { + loggedIn: false, + authInProgress: false, + }, + }); + + renderWithRouter(StatefulLogin); + + const loginButton = screen.getByRole('button', { + name: 'Login with Single Sign-on', + }); + expect(loginButton).toBeVisible(); + }); + }); }); diff --git a/assets/js/pages/Login/LoginForm.jsx b/assets/js/pages/Login/LoginForm.jsx new file mode 100644 index 0000000000..46c1488669 --- /dev/null +++ b/assets/js/pages/Login/LoginForm.jsx @@ -0,0 +1,123 @@ +import React from 'react'; + +import classNames from 'classnames'; + +import Input from '@common/Input'; + +export default function LoginForm({ + authError, + authInProgress, + handleLoginSubmit, + isUnauthorized, + password, + setPassword, + setTotpCode, + setUsername, + totpCodeRequested, + totpCode, + username, +}) { + return ( +
+ {!totpCodeRequested ? ( + <> +
+ +
+ setUsername(e.target.value)} + name="username" + autoComplete="username" + required + error={isUnauthorized} + className={classNames( + 'appearance-none px-3 py-2 text-inherit border-gray-300 shadow-sm focus:ring-jungle-green-500 focus:border-jungle-green-500 sm:text-sm', + { 'disabled:opacity-50': authInProgress } + )} + /> +
+
+ +
+ +
+ setPassword(e.target.value)} + name="password" + autoComplete="current-password" + required + error={isUnauthorized} + className={classNames( + 'appearance-none px-3 py-2 text-inherit border-gray-300 shadow-sm focus:ring-jungle-green-500 focus:border-jungle-green-500 sm:text-sm', + { 'disabled:opacity-50': authInProgress } + )} + /> +
+
+ + ) : ( +
+ +
+ setTotpCode(e.target.value)} + autoComplete="off" + required + error={isUnauthorized} + className={classNames( + 'appearance-none px-3 py-2 text-inherit border-gray-300 shadow-sm focus:ring-jungle-green-500 focus:border-jungle-green-500 sm:text-sm', + { 'disabled:opacity-50': authInProgress } + )} + /> +
+
+ )} + {authError && authError.code === 401 && ( +

Invalid credentials

+ )} +
+ +
+
+ ); +} diff --git a/assets/js/pages/Login/LoginSSO.jsx b/assets/js/pages/Login/LoginSSO.jsx new file mode 100644 index 0000000000..e8676f0dda --- /dev/null +++ b/assets/js/pages/Login/LoginSSO.jsx @@ -0,0 +1,23 @@ +import React from 'react'; + +import Button from '@common/Button'; + +export default function LoginSSO({ singleSignOnUrl, error }) { + return ( + <> + {error && ( + + An error occurred while trying to Login. Please retry login again. + Should the error persist, contact the administrator. + + )} + + + ); +} diff --git a/assets/js/pages/OidcCallback/OidcCallback.jsx b/assets/js/pages/OidcCallback/OidcCallback.jsx new file mode 100644 index 0000000000..bc467137e7 --- /dev/null +++ b/assets/js/pages/OidcCallback/OidcCallback.jsx @@ -0,0 +1,71 @@ +import React, { useEffect, useState } from 'react'; +import TrentoLogo from '@static/trento-dark.svg'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { useDispatch, useSelector } from 'react-redux'; +import { performOidcEnrollment } from '@state/user'; +import { getSingleSignOnLoginUrl } from '@lib/auth/config'; +import LoginSSO from '@pages/Login/LoginSSO'; +import { getUserProfile } from '@state/selectors/user'; + +function OidCallback() { + const { search } = useLocation(); + const dispatch = useDispatch(); + const navigate = useNavigate(); + const [error, setError] = useState(null); + const { authError, loggedIn } = useSelector(getUserProfile); + + useEffect(() => { + const params = new URLSearchParams(search); + const code = params.get('code'); + const state = params.get('state'); + + setError(!code || !state); + + dispatch(performOidcEnrollment({ state, code })); + }, [search]); + + useEffect(() => { + if (loggedIn) { + navigate('/'); + } + }, [loggedIn]); + + if (authError || error) { + return ( +
+
+ Trento +

+ Login Failed +

+
+
+
+ +
+
+
+ ); + } + + return ( +
+
+ Trento +

+ Loading... +

+
+
+ ); +} + +export default OidCallback; diff --git a/assets/js/pages/OidcCallback/OidcCallback.test.jsx b/assets/js/pages/OidcCallback/OidcCallback.test.jsx new file mode 100644 index 0000000000..754dc85a90 --- /dev/null +++ b/assets/js/pages/OidcCallback/OidcCallback.test.jsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import 'intersection-observer'; +import '@testing-library/jest-dom'; +import userEvent from '@testing-library/user-event'; +import { withState, renderWithRouterMatch } from '@lib/test-utils'; +import OidcCallback from './OidcCallback'; + +describe('OidcCallback component', () => { + beforeEach(() => { + jest.resetModules(); + }); + + it('should display loading state when authentication is happening', async () => { + const [StatefulOidCallback, store] = withState(, { + user: {}, + }); + + renderWithRouterMatch(StatefulOidCallback, { + path: 'auth/oidc_callback', + route: `/auth/oidc_callback?code=code&state=state`, + }); + + expect(screen.getByText('Loading...')).toBeVisible(); + + expect(store.getActions()).toContainEqual({ + type: 'PERFORM_OIDC_ENROLLMENT', + payload: { code: 'code', state: 'state' }, + }); + }); + + it('should display an error message if some search param is missing', async () => { + const user = userEvent.setup(); + + const [StatefulOidCallback] = withState(, { + user: {}, + }); + + renderWithRouterMatch(StatefulOidCallback, { + path: 'auth/oidc_callback', + route: `/auth/oidc_callback?code=code`, + }); + + expect(screen.getByText('Login Failed')).toBeVisible(); + + expect( + screen.getByText('An error occurred while trying to Login', { + exact: false, + }) + ).toBeVisible(); + + const loginButton = screen.getByRole('button', { + name: 'Login with Single Sign-on', + }); + user.click(loginButton); + + expect(window.location.pathname).toBe('/auth/oidc_callback'); + }); + + it('should display an error message if authentication fails', async () => { + const [StatefulOidCallback] = withState(, { + user: { + authError: true, + }, + }); + + renderWithRouterMatch(StatefulOidCallback, { + path: 'auth/oidc_callback', + route: `/auth/oidc_callback?code=code&state=state`, + }); + + expect(screen.getByText('Login Failed')).toBeVisible(); + + expect( + screen.getByText('An error occurred while trying to Login', { + exact: false, + }) + ).toBeVisible(); + }); + + it('should navigate to home after user is logged in', () => { + const [StatefulOidCallback] = withState(, { + user: { + loggedIn: true, + }, + }); + + renderWithRouterMatch(StatefulOidCallback, { + path: '', + route: '/', + }); + + expect(window.location.pathname).toBe('/'); + }); +}); diff --git a/assets/js/pages/OidcCallback/index.js b/assets/js/pages/OidcCallback/index.js new file mode 100644 index 0000000000..f6d3545420 --- /dev/null +++ b/assets/js/pages/OidcCallback/index.js @@ -0,0 +1,3 @@ +import OidCallback from './OidcCallback'; + +export default OidCallback; diff --git a/assets/js/pages/Profile/ProfileForm.jsx b/assets/js/pages/Profile/ProfileForm.jsx index 0459f16787..de91eadc25 100644 --- a/assets/js/pages/Profile/ProfileForm.jsx +++ b/assets/js/pages/Profile/ProfileForm.jsx @@ -25,6 +25,7 @@ function ProfileForm({ disableForm, passwordModalOpen = false, totpBoxOpen = false, + singleSignOnEnabled = false, toggleTotpBox = noop, togglePasswordModal = noop, onSave = noop, @@ -94,6 +95,7 @@ function ProfileForm({ setFullName(value); setFullNameError(null); }} + disabled={singleSignOnEnabled} /> {fullNameErrorState && errorMessage(fullNameErrorState)} @@ -108,6 +110,7 @@ function ProfileForm({ setEmailAddress(value); setEmailAddressError(null); }} + disabled={singleSignOnEnabled} /> {emailAddressErrorState && errorMessage(emailAddressErrorState)} @@ -115,54 +118,58 @@ function ProfileForm({
- -
- -
- - -
-
- - {totpBoxOpen && ( - toggleTotpBox(false)} - aria-hidden="true" + {!singleSignOnEnabled && ( + <> + +
+
+ Change Password + +
- {totpBoxOpen && ( +
- +
+ + {totpBoxOpen && ( + toggleTotpBox(false)} + aria-hidden="true" + > + Cancel + + )} +
+ + {totpBoxOpen && ( +
+ +
+ )}
- )} -
+ + )}
@@ -174,15 +181,17 @@ function ProfileForm({ />
-
- -
+ {!singleSignOnEnabled && ( +
+ +
+ )} ( @@ -129,3 +133,10 @@ export const WithErrors = { ], }, }; + +export const SingleSignOnEnabled = { + args: { + ...Default.args, + singleSignOnEnabled: true, + }, +}; diff --git a/assets/js/pages/Profile/ProfileForm.test.jsx b/assets/js/pages/Profile/ProfileForm.test.jsx index 73b71d4fc5..23b088dcd8 100644 --- a/assets/js/pages/Profile/ProfileForm.test.jsx +++ b/assets/js/pages/Profile/ProfileForm.test.jsx @@ -305,4 +305,59 @@ describe('ProfileForm', () => { ); expect(screen.getByText('Error validating totp code')).toBeVisible(); }); + + describe('Single sign on', () => { + it('should disable fullname, email and username fields', () => { + const { username, fullname, email, abilities } = profileFactory.build(); + + render( + + ); + + expect(screen.getByLabelText('fullname')).toBeDisabled(); + expect(screen.getByLabelText('email')).toBeDisabled(); + expect(screen.getByLabelText('username')).toBeDisabled(); + expect(screen.getByLabelText('permissions')).toBeDisabled(); + }); + + it('should remove password and totp fields', () => { + const { username, fullname, email, abilities } = profileFactory.build(); + + render( + + ); + + expect(screen.queryByText('Password')).not.toBeInTheDocument(); + expect(screen.queryByText('Authenticator App')).not.toBeInTheDocument(); + expect(screen.getByText('Permissions')).toBeVisible(); + }); + + it('should remove save button', () => { + const { username, fullname, email, abilities } = profileFactory.build(); + + render( + + ); + + expect(screen.queryByText('Save')).not.toBeInTheDocument(); + }); + }); }); diff --git a/assets/js/pages/Profile/ProfilePage.jsx b/assets/js/pages/Profile/ProfilePage.jsx index 945e07d29d..3373b457e4 100644 --- a/assets/js/pages/Profile/ProfilePage.jsx +++ b/assets/js/pages/Profile/ProfilePage.jsx @@ -3,6 +3,7 @@ import { toast } from 'react-hot-toast'; import { useDispatch } from 'react-redux'; import PageHeader from '@common/PageHeader'; import { isAdmin } from '@lib/model/users'; +import { isSingleSignOnEnabled } from '@lib/auth/config'; import ProfileForm from '@pages/Profile/ProfileForm'; import { getUserProfile, @@ -162,6 +163,7 @@ function ProfilePage() { toggleTotpBox={setTotpBoxOpen} loading={loading || saving} disableForm={isDefaultAdmin} + singleSignOnEnabled={isSingleSignOnEnabled()} onSave={updateProfile} onEnableTotp={totpInitiateEnrolling} onVerifyTotp={verifyTotpEnrollment} diff --git a/assets/js/pages/Users/CreateUserPage.jsx b/assets/js/pages/Users/CreateUserPage.jsx index af38ac1884..06aad73533 100644 --- a/assets/js/pages/Users/CreateUserPage.jsx +++ b/assets/js/pages/Users/CreateUserPage.jsx @@ -4,7 +4,9 @@ import { toast } from 'react-hot-toast'; import BackButton from '@common/BackButton'; import PageHeader from '@common/PageHeader'; +import NotFound from '@pages/NotFound'; +import { isSingleSignOnEnabled } from '@lib/auth/config'; import { listAbilities } from '@lib/api/abilities'; import { createUser } from '@lib/api/users'; @@ -55,6 +57,10 @@ function CreateUserPage() { navigate('/users'); }; + if (isSingleSignOnEnabled()) { + return ; + } + useEffect(() => { fetchAbilities(setAbilities); }, []); diff --git a/assets/js/pages/Users/CreateUserPage.test.jsx b/assets/js/pages/Users/CreateUserPage.test.jsx index 3ac8ef373e..1dc2c5c2c3 100644 --- a/assets/js/pages/Users/CreateUserPage.test.jsx +++ b/assets/js/pages/Users/CreateUserPage.test.jsx @@ -13,7 +13,7 @@ import { faker } from '@faker-js/faker'; import * as router from 'react-router'; import { networkClient } from '@lib/network'; - +import * as authConfig from '@lib/auth/config'; import { abilityFactory, userFactory } from '@lib/test-utils/factories/users'; import CreateUserPage from './CreateUserPage'; @@ -144,4 +144,16 @@ describe('CreateUserPage', () => { await user.click(screen.getByRole('button', { name: 'Create' })); expect(toast.error).toHaveBeenCalledWith(toastMessage); }); + + describe('Single sign on', () => { + it('should redirect to not found page', async () => { + jest.spyOn(authConfig, 'isSingleSignOnEnabled').mockReturnValue(true); + + render(); + + expect( + screen.getByText('the page is in another castle', { exact: false }) + ).toBeVisible(); + }); + }); }); diff --git a/assets/js/pages/Users/EditUserPage.jsx b/assets/js/pages/Users/EditUserPage.jsx index 503665b7ed..f5bb732213 100644 --- a/assets/js/pages/Users/EditUserPage.jsx +++ b/assets/js/pages/Users/EditUserPage.jsx @@ -7,6 +7,8 @@ import Banner from '@common/Banners/Banner'; import PageHeader from '@common/PageHeader'; import { isAdmin } from '@lib/model/users'; +import { isSingleSignOnEnabled } from '@lib/auth/config'; + import { editUser, getUser } from '@lib/api/users'; import { fetchAbilities } from './CreateUserPage'; @@ -123,6 +125,7 @@ function EditUserPage() { onSave={onEditUser} onCancel={onCancel} editing + singleSignOnEnabled={isSingleSignOnEnabled()} /> ); diff --git a/assets/js/pages/Users/UserForm.jsx b/assets/js/pages/Users/UserForm.jsx index 4d34cd96d5..50355a9e8a 100644 --- a/assets/js/pages/Users/UserForm.jsx +++ b/assets/js/pages/Users/UserForm.jsx @@ -38,6 +38,7 @@ function UserForm({ saveEnabled = true, saveText = 'Create', editing = false, + singleSignOnEnabled = false, onSave = noop, onCancel = noop, }) { @@ -95,23 +96,32 @@ function UserForm({ return error; }; + const buildUserPayload = () => ({ + fullname: fullNameState, + email: emailAddressState, + enabled: statusState === USER_ENABLED, + ...(!editing && { username: usernameState }), + ...(passwordState && { password: passwordState }), + ...(confirmPasswordState && { + password_confirmation: confirmPasswordState, + }), + abilities: abilities.filter(({ id }) => selectedAbilities.includes(id)), + ...(totpEnabledAt && !totpState && { totp_disabled: true }), + }); + + const buildSSOUserPayload = () => ({ + enabled: statusState === USER_ENABLED, + abilities: abilities.filter(({ id }) => selectedAbilities.includes(id)), + }); + const onSaveClicked = () => { if (validateRequired()) { return; } - const user = { - fullname: fullNameState, - email: emailAddressState, - enabled: statusState === USER_ENABLED, - ...(!editing && { username: usernameState }), - ...(passwordState && { password: passwordState }), - ...(confirmPasswordState && { - password_confirmation: confirmPasswordState, - }), - abilities: abilities.filter(({ id }) => selectedAbilities.includes(id)), - ...(totpEnabledAt && !totpState && { totp_disabled: true }), - }; + const user = singleSignOnEnabled + ? buildSSOUserPayload() + : buildUserPayload(); onSave(user); }; @@ -139,6 +149,7 @@ function UserForm({ setFullName(value); setFullNameError(null); }} + disabled={singleSignOnEnabled} /> {fullNameErrorState && errorMessage(fullNameErrorState)} @@ -155,6 +166,7 @@ function UserForm({ setEmailAddress(value); setEmailAddressError(null); }} + disabled={singleSignOnEnabled} /> {emailAddressErrorState && errorMessage(emailAddressErrorState)} @@ -171,56 +183,64 @@ function UserForm({ setUsername(value); setUsernameError(null); }} - disabled={editing} + disabled={editing || singleSignOnEnabled} /> {usernameErrorState && errorMessage(usernameErrorState)} - -
- { - setPassword(value); - setPasswordError(null); - }} - /> - {passwordErrorState && errorMessage(passwordErrorState)} -
- -
- { - setConfirmPassword(value); - setConfirmPasswordError(null); - }} - /> - {confirmPasswordErrorState && - errorMessage(confirmPasswordErrorState)} -
-
- -
+ {!singleSignOnEnabled && ( + <> + +
+ { + setPassword(value); + setPasswordError(null); + }} + /> + {passwordErrorState && errorMessage(passwordErrorState)} +
+ +
+ { + setConfirmPassword(value); + setConfirmPasswordError(null); + }} + /> + {confirmPasswordErrorState && + errorMessage(confirmPasswordErrorState)} +
+
+ +
+ + )}
- {editing && ( + {editing && !singleSignOnEnabled && ( <>