diff --git a/assets/jest.config.js b/assets/jest.config.js
index 0442bfd103..636d692185 100644
--- a/assets/jest.config.js
+++ b/assets/jest.config.js
@@ -67,6 +67,8 @@ module.exports = {
config: {
checksServiceBaseUrl: '',
suseManagerEnabled: true,
+ adminUsername: 'admin',
+ oidcEnabled: false,
aTestVariable: 123,
},
},
diff --git a/assets/js/lib/auth/config.js b/assets/js/lib/auth/config.js
new file mode 100644
index 0000000000..58efbe0838
--- /dev/null
+++ b/assets/js/lib/auth/config.js
@@ -0,0 +1,5 @@
+import { getFromConfig } from '@lib/config';
+
+const OIDC_ENABLED = getFromConfig('oidcEnabled') || false;
+
+export const isSingleSignOnEnabled = () => OIDC_ENABLED;
diff --git a/assets/js/lib/auth/config.test.js b/assets/js/lib/auth/config.test.js
new file mode 100644
index 0000000000..612fb31e4c
--- /dev/null
+++ b/assets/js/lib/auth/config.test.js
@@ -0,0 +1,7 @@
+import { isSingleSignOnEnabled } from './config';
+
+describe('auth config', () => {
+ it('should check if single sign on is enabled', () => {
+ expect(isSingleSignOnEnabled()).toBe(false);
+ });
+});
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/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 && (
<>
- >
- )}
- {editing && (
- <>
{format(parseISO(createdAt), 'PPpp')}
diff --git a/assets/js/pages/Users/UserForm.stories.jsx b/assets/js/pages/Users/UserForm.stories.jsx
index 817b9fb425..61236f8107 100644
--- a/assets/js/pages/Users/UserForm.stories.jsx
+++ b/assets/js/pages/Users/UserForm.stories.jsx
@@ -117,6 +117,10 @@ export default {
errors: {
description: 'OpenAPI errors coming from backend validation',
},
+ singleSignOnEnabled: {
+ description: 'Single sign on login is enabled',
+ control: { type: 'boolean' },
+ },
onSave: {
action: 'Save user',
description: 'Save user action',
@@ -205,3 +209,10 @@ export const WithErrors = {
],
},
};
+
+export const SingleSignOnEnabled = {
+ args: {
+ ...Editing.args,
+ singleSignOnEnabled: true,
+ },
+};
diff --git a/assets/js/pages/Users/UserForm.test.jsx b/assets/js/pages/Users/UserForm.test.jsx
index 1e35e1e999..abdca6a9e2 100644
--- a/assets/js/pages/Users/UserForm.test.jsx
+++ b/assets/js/pages/Users/UserForm.test.jsx
@@ -360,4 +360,72 @@ describe('UserForm', () => {
abilities: abilities.slice(0, 2),
});
});
+
+ describe('Single sign on', () => {
+ it('should disable fullname, email and username fields', () => {
+ render();
+
+ expect(screen.getByLabelText('fullname')).toBeDisabled();
+ expect(screen.getByLabelText('email')).toBeDisabled();
+ expect(screen.getByLabelText('username')).toBeDisabled();
+ });
+
+ it('should remove password, totp and timestamp fields', () => {
+ render();
+
+ expect(screen.queryByText('Created')).not.toBeInTheDocument();
+ expect(screen.queryByText('Updated')).not.toBeInTheDocument();
+ expect(screen.queryByText('TOTP')).not.toBeInTheDocument();
+ expect(screen.getByText('Permissions')).toBeVisible();
+ expect(screen.getByText('Enabled')).toBeVisible();
+ });
+
+ it('should save permissions and status', async () => {
+ const user = userEvent.setup();
+
+ const { fullname, email, username } = userFactory.build();
+
+ const abilities = abilityFactory.buildList(3);
+ const userAbilities = [abilities[0]];
+
+ const mockOnSave = jest.fn();
+
+ render(
+
+ );
+
+ expect(
+ screen.getByText(
+ `${userAbilities[0].name}:${userAbilities[0].resource}`
+ )
+ ).toBeVisible();
+
+ await user.click(screen.getByLabelText('permissions'));
+
+ abilities.forEach(({ name, resource }) =>
+ expect(screen.getByText(`${name}:${resource}`)).toBeVisible()
+ );
+
+ await user.click(
+ screen.getByText(`${abilities[1].name}:${abilities[1].resource}`)
+ );
+
+ await user.click(screen.getByRole('button', { name: 'Save' }));
+
+ expect(mockOnSave).toHaveBeenNthCalledWith(1, {
+ enabled: true,
+ abilities: abilities.slice(0, 2),
+ });
+ });
+ });
});
diff --git a/assets/js/pages/Users/Users.jsx b/assets/js/pages/Users/Users.jsx
index 56e5d1db26..ead04dbf0c 100644
--- a/assets/js/pages/Users/Users.jsx
+++ b/assets/js/pages/Users/Users.jsx
@@ -19,6 +19,7 @@ function Users({
navigate = noop,
users = defaultUsers,
loading = false,
+ singleSignOnEnabled = false,
}) {
const [modalOpen, setModalOpen] = useState(false);
const [user, setUser] = useState(null);
@@ -92,18 +93,20 @@ function Users({
-
-
-
+ {!singleSignOnEnabled && (
+
+
+
+
-
+ )}
{
await userEvent.click(cancelButton);
expect(modalTitel).not.toBeInTheDocument();
});
+
+ describe('Single sign on', () => {
+ it('should disable user creation', () => {
+ renderWithRouter();
+ expect(screen.queryByText('Create User')).not.toBeInTheDocument();
+ });
+ });
});
diff --git a/assets/js/pages/Users/UsersPage.jsx b/assets/js/pages/Users/UsersPage.jsx
index 1e8e17d253..9207e77756 100644
--- a/assets/js/pages/Users/UsersPage.jsx
+++ b/assets/js/pages/Users/UsersPage.jsx
@@ -1,9 +1,10 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
-import { listUsers, deleteUser } from '@lib/api/users';
-
import { toast } from 'react-hot-toast';
+import { listUsers, deleteUser } from '@lib/api/users';
+import { isSingleSignOnEnabled } from '@lib/auth/config';
+
import Users from './Users';
const SUCCESS_DELETE_MESSAGE = 'User deleted successfully';
@@ -62,6 +63,7 @@ function UsersPage() {
navigate={navigate}
users={users}
loading={loading}
+ singleSignOnEnabled={isSingleSignOnEnabled()}
/>
);
}
diff --git a/assets/js/state/sagas/user.js b/assets/js/state/sagas/user.js
index 99a4c00798..39ed3267b8 100644
--- a/assets/js/state/sagas/user.js
+++ b/assets/js/state/sagas/user.js
@@ -22,6 +22,7 @@ import {
clearCredentialsFromStore,
} from '@lib/auth';
import { networkClient } from '@lib/network';
+import { isSingleSignOnEnabled } from '@lib/auth/config';
export function* performOIDCEnrollment({ payload: { code, state } }) {
yield put(setAuthInProgress());
@@ -113,6 +114,10 @@ export function* userUpdated() {
}
export function* checkUserPasswordChangeRequested() {
+ if (isSingleSignOnEnabled()) {
+ return;
+ }
+
const { password_change_requested } = yield select(getUserProfile);
if (!password_change_requested) {
diff --git a/assets/js/state/sagas/user.test.js b/assets/js/state/sagas/user.test.js
index dafef3ec12..0f9351083c 100644
--- a/assets/js/state/sagas/user.test.js
+++ b/assets/js/state/sagas/user.test.js
@@ -16,6 +16,7 @@ import {
import { customNotify } from '@state/notifications';
import { networkClient } from '@lib/network';
import { profileFactory } from '@lib/test-utils/factories/users';
+import * as authConfig from '@lib/auth/config';
import {
performLogin,
clearUserAndLogout,
@@ -168,7 +169,7 @@ describe('user login saga', () => {
expect(dispatched).toEqual([expectedAction]);
});
- it('should not dispatch notification is password change is not requested', async () => {
+ it('should not dispatch notification if password change is not requested', async () => {
const dispatched = await recordSaga(
checkUserPasswordChangeRequested,
{},
@@ -181,4 +182,22 @@ describe('user login saga', () => {
expect(dispatched).toEqual([]);
});
+
+ describe('Single sign on', () => {
+ it('should not dispatch notification if single sign on is enabled', async () => {
+ jest.spyOn(authConfig, 'isSingleSignOnEnabled').mockReturnValue(true);
+
+ const dispatched = await recordSaga(
+ checkUserPasswordChangeRequested,
+ {},
+ {
+ user: {
+ password_change_requested: true,
+ },
+ }
+ );
+
+ expect(dispatched).toEqual([]);
+ });
+ });
});
diff --git a/config/dev.exs b/config/dev.exs
index 79212cd7b6..0eed8a49a3 100644
--- a/config/dev.exs
+++ b/config/dev.exs
@@ -136,7 +136,7 @@ config :unplug, :init_mode, :runtime
config :open_api_spex, :cache_adapter, OpenApiSpex.Plug.NoneCache
config :trento, :oidc,
- enabled: true,
+ enabled: false,
callback_url: "http://localhost:4000/auth/oidc_callback"
config :trento, :pow_assent,
diff --git a/lib/trento_web/controllers/page_controller.ex b/lib/trento_web/controllers/page_controller.ex
index f790467445..08eefdd778 100644
--- a/lib/trento_web/controllers/page_controller.ex
+++ b/lib/trento_web/controllers/page_controller.ex
@@ -15,6 +15,7 @@ defmodule TrentoWeb.PageController do
deregistration_debounce: deregistration_debounce,
suse_manager_enabled: suse_manager_enabled,
admin_username: admin_username,
+ oidc_enabled: oidc_enabled,
oidc_login_url: oidc_login_url(conn, oidc_enabled)
)
end
diff --git a/lib/trento_web/templates/page/index.html.heex b/lib/trento_web/templates/page/index.html.heex
index 7c11deb6ec..4ca28e95a4 100644
--- a/lib/trento_web/templates/page/index.html.heex
+++ b/lib/trento_web/templates/page/index.html.heex
@@ -7,6 +7,7 @@
chartsEnabled: <%= @charts_enabled %>,
suseManagerEnabled: <%= @suse_manager_enabled %>,
adminUsername: '<%= @admin_username %>',
+ oidcEnabled: <%= @oidc_enabled %>,
oidcLoginUrl: '<%= raw @oidc_login_url %>',
};
diff --git a/mix.exs b/mix.exs
index 7f54da486d..b98370d973 100644
--- a/mix.exs
+++ b/mix.exs
@@ -118,7 +118,7 @@ defmodule Trento.MixProject do
{:nimble_totp, "~> 1.0"},
{:phoenix_view, "~> 2.0"},
{:phoenix_html_helpers, "~> 1.0"},
- {:pow_assent, "~> 0.4.18"},
+ {:pow_assent, "~> 0.4.18"}
]
end