]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-18689 Add Filters for all, local and managed users on user list
authorguillaume-peoch-sonarsource <guillaume.peoch@sonarsource.com>
Thu, 9 Mar 2023 09:10:18 +0000 (10:10 +0100)
committersonartech <sonartech@sonarsource.com>
Wed, 22 Mar 2023 20:04:07 +0000 (20:04 +0000)
22 files changed:
server/sonar-web/src/main/js/api/mocks/UsersServiceMock.ts
server/sonar-web/src/main/js/api/users.ts
server/sonar-web/src/main/js/apps/users/Search.tsx [deleted file]
server/sonar-web/src/main/js/apps/users/UsersApp.tsx
server/sonar-web/src/main/js/apps/users/UsersList.tsx
server/sonar-web/src/main/js/apps/users/__tests__/UsersApp-it.tsx
server/sonar-web/src/main/js/apps/users/__tests__/UsersApp-test.tsx [deleted file]
server/sonar-web/src/main/js/apps/users/__tests__/UsersList-test.tsx
server/sonar-web/src/main/js/apps/users/__tests__/__snapshots__/UsersApp-test.tsx.snap [deleted file]
server/sonar-web/src/main/js/apps/users/__tests__/__snapshots__/UsersList-test.tsx.snap
server/sonar-web/src/main/js/apps/users/components/UserListItem.tsx
server/sonar-web/src/main/js/apps/users/components/UserListItemIdentity.tsx
server/sonar-web/src/main/js/apps/users/components/__tests__/UserActions-test.tsx
server/sonar-web/src/main/js/apps/users/components/__tests__/UserGroups-test.tsx
server/sonar-web/src/main/js/apps/users/components/__tests__/UserListItem-test.tsx
server/sonar-web/src/main/js/apps/users/components/__tests__/UserListItemIdentity-test.tsx
server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UserListItem-test.tsx.snap
server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UserListItemIdentity-test.tsx.snap
server/sonar-web/src/main/js/apps/users/utils.ts
server/sonar-web/src/main/js/helpers/testMocks.ts
server/sonar-web/src/main/js/types/users.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index f575559c69a253d3d6f6d08bed88de9006f37986..0c8c719732ce2443910b9ce08195795cc171149b 100644 (file)
@@ -19,7 +19,7 @@
  */
 
 import { cloneDeep } from 'lodash';
-import { mockClusterSysInfo, mockIdentityProvider } from '../../helpers/testMocks';
+import { mockClusterSysInfo, mockIdentityProvider, mockUser } from '../../helpers/testMocks';
 import { IdentityProvider, Paging, SysInfoCluster } from '../../types/types';
 import { User } from '../../types/users';
 import { getSystemInfo } from '../system';
@@ -27,26 +27,44 @@ import { getIdentityProviders, searchUsers } from '../users';
 
 export default class UsersServiceMock {
   isManaged = true;
+  users = [
+    mockUser({
+      managed: true,
+      login: 'bob.marley',
+      name: 'Bob Marley',
+    }),
+    mockUser({
+      managed: false,
+      login: 'alice.merveille',
+      name: 'Alice Merveille',
+    }),
+  ];
 
   constructor() {
     jest.mocked(getSystemInfo).mockImplementation(this.handleGetSystemInfo);
     jest.mocked(getIdentityProviders).mockImplementation(this.handleGetIdentityProviders);
-    jest.mocked(searchUsers).mockImplementation(this.handleSearchUsers);
+    jest.mocked(searchUsers).mockImplementation((p) => this.handleSearchUsers(p));
   }
 
   setIsManaged(managed: boolean) {
     this.isManaged = managed;
   }
 
-  handleSearchUsers = (): Promise<{ paging: Paging; users: User[] }> => {
-    return this.reply({
-      paging: {
-        pageIndex: 1,
-        pageSize: 100,
-        total: 0,
-      },
-      users: [],
-    });
+  handleSearchUsers = (data: any): Promise<{ paging: Paging; users: User[] }> => {
+    const paging = {
+      pageIndex: 1,
+      pageSize: 100,
+      total: 0,
+    };
+
+    if (this.isManaged) {
+      if (data.managed === undefined) {
+        return this.reply({ paging, users: this.users });
+      }
+      const users = this.users.filter((user) => user.managed === data.managed);
+      return this.reply({ paging, users });
+    }
+    return this.reply({ paging, users: this.users });
   };
 
   handleGetIdentityProviders = (): Promise<{ identityProviders: IdentityProvider[] }> => {
index ca921bd026c3a70b60cea3e458f695420eab72ba..d3fbb2f7e8bdea41d6dd181e8ad8ce8d1066e866 100644 (file)
@@ -71,6 +71,7 @@ export function searchUsers(data: {
   p?: number;
   ps?: number;
   q?: string;
+  managed?: boolean;
 }): Promise<{ paging: Paging; users: User[] }> {
   data.q = data.q || undefined;
   return getJSON('/api/users/search', data).catch(throwGlobalError);
diff --git a/server/sonar-web/src/main/js/apps/users/Search.tsx b/server/sonar-web/src/main/js/apps/users/Search.tsx
deleted file mode 100644 (file)
index f7f0b5e..0000000
+++ /dev/null
@@ -1,49 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-import * as React from 'react';
-import SearchBox from '../../components/controls/SearchBox';
-import { translate } from '../../helpers/l10n';
-import { Query } from './utils';
-
-interface Props {
-  query: Query;
-  updateQuery: (newQuery: Partial<Query>) => void;
-}
-
-export default class Search extends React.PureComponent<Props> {
-  handleSearch = (search: string) => {
-    this.props.updateQuery({ search });
-  };
-
-  render() {
-    const { query } = this.props;
-
-    return (
-      <div className="panel panel-vertical bordered-bottom spacer-bottom" id="users-search">
-        <SearchBox
-          minLength={2}
-          onChange={this.handleSearch}
-          placeholder={translate('search.search_by_login_or_name')}
-          value={query.search}
-        />
-      </div>
-    );
-  }
-}
index 768b2c8f7e6386547caf2722496f753c72c1c928..3eed4f2f5e433d78a8d9d81921b2c1351eef634f 100644 (file)
@@ -22,14 +22,15 @@ import { Helmet } from 'react-helmet-async';
 import { getSystemInfo } from '../../api/system';
 import { getIdentityProviders, searchUsers } from '../../api/users';
 import withCurrentUserContext from '../../app/components/current-user/withCurrentUserContext';
+import ButtonToggle from '../../components/controls/ButtonToggle';
 import ListFooter from '../../components/controls/ListFooter';
+import SearchBox from '../../components/controls/SearchBox';
 import Suggestions from '../../components/embed-docs-modal/Suggestions';
 import { Location, Router, withRouter } from '../../components/hoc/withRouter';
 import { translate } from '../../helpers/l10n';
 import { IdentityProvider, Paging, SysInfoCluster } from '../../types/types';
 import { CurrentUser, User } from '../../types/users';
 import Header from './Header';
-import Search from './Search';
 import UsersList from './UsersList';
 import { parseQuery, Query, serializeQuery } from './utils';
 
@@ -59,7 +60,10 @@ export class UsersApp extends React.PureComponent<Props, State> {
   }
 
   componentDidUpdate(prevProps: Props) {
-    if (prevProps.location.query.search !== this.props.location.query.search) {
+    if (
+      prevProps.location.query.search !== this.props.location.query.search ||
+      prevProps.location.query.managed !== this.props.location.query.managed
+    ) {
       this.fetchUsers();
     }
   }
@@ -91,9 +95,12 @@ export class UsersApp extends React.PureComponent<Props, State> {
     });
 
   fetchUsers = () => {
-    const { location } = this.props;
+    const { search, managed } = parseQuery(this.props.location.query);
     this.setState({ loading: true });
-    searchUsers({ q: parseQuery(location.query).search }).then(({ paging, users }) => {
+    searchUsers({
+      q: search,
+      managed,
+    }).then(({ paging, users }) => {
       if (this.mounted) {
         this.setState({ loading: false, paging, users });
       }
@@ -103,10 +110,12 @@ export class UsersApp extends React.PureComponent<Props, State> {
   fetchMoreUsers = () => {
     const { paging } = this.state;
     if (paging) {
+      const { search, managed } = parseQuery(this.props.location.query);
       this.setState({ loading: true });
       searchUsers({
         p: paging.pageIndex + 1,
-        q: parseQuery(this.props.location.query).search,
+        q: search,
+        managed,
       }).then(({ paging, users }) => {
         if (this.mounted) {
           this.setState((state) => ({ loading: false, users: [...state.users, ...users], paging }));
@@ -127,20 +136,48 @@ export class UsersApp extends React.PureComponent<Props, State> {
   };
 
   render() {
-    const query = parseQuery(this.props.location.query);
+    const { search, managed } = parseQuery(this.props.location.query);
     const { loading, paging, users, manageProvider } = this.state;
+    // What if we have ONLY managed users? Should we not display the filter toggle?
     return (
       <main className="page page-limited" id="users-page">
         <Suggestions suggestions="users" />
         <Helmet defer={false} title={translate('users.page')} />
         <Header loading={loading} onUpdateUsers={this.fetchUsers} manageProvider={manageProvider} />
-        <Search query={query} updateQuery={this.updateQuery} />
+        <div className="display-flex-justify-start big-spacer-bottom big-spacer-top">
+          {manageProvider !== undefined && (
+            <div className="big-spacer-right">
+              <ButtonToggle
+                value={managed === undefined ? 'all' : managed}
+                options={[
+                  { label: translate('all'), value: 'all' },
+                  { label: translate('users.managed'), value: true },
+                  { label: translate('users.local'), value: false },
+                ]}
+                onCheck={(filterOption) => {
+                  if (filterOption === 'all') {
+                    this.updateQuery({ managed: undefined });
+                  } else {
+                    this.updateQuery({ managed: filterOption as boolean });
+                  }
+                }}
+              />
+            </div>
+          )}
+          <SearchBox
+            id="users-search"
+            onChange={(search: string) => this.updateQuery({ search })}
+            placeholder={translate('search.search_by_login_or_name')}
+            value={search}
+          />
+        </div>
         <UsersList
           currentUser={this.props.currentUser}
           identityProviders={this.state.identityProviders}
           onUpdateUsers={this.fetchUsers}
           updateTokensCount={this.updateTokensCount}
           users={users}
+          manageProvider={manageProvider}
         />
         {paging !== undefined && (
           <ListFooter
index affc9381c9becc9def3a52f31a01c9da86f8a843..7dbb05134aad2cb3a2701f12d148b2f1b9ffe47e 100644 (file)
@@ -29,6 +29,7 @@ interface Props {
   onUpdateUsers: () => void;
   updateTokensCount: (login: string, tokensCount: number) => void;
   users: User[];
+  manageProvider: string | undefined;
 }
 
 export default function UsersList({
@@ -37,6 +38,7 @@ export default function UsersList({
   onUpdateUsers,
   updateTokensCount,
   users,
+  manageProvider,
 }: Props) {
   return (
     <div className="boxed-group boxed-group-inner">
@@ -63,6 +65,7 @@ export default function UsersList({
               onUpdateUsers={onUpdateUsers}
               updateTokensCount={updateTokensCount}
               user={user}
+              manageProvider={manageProvider}
             />
           ))}
         </tbody>
index 3917a11296e8bf502928a1e26142769855620b43..1532ad653c039caab83116ffd052d47794acdb53 100644 (file)
@@ -18,6 +18,7 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 
+import userEvent from '@testing-library/user-event';
 import * as React from 'react';
 import { byRole, byText } from 'testing-library-selector';
 import UsersServiceMock from '../../../api/mocks/UsersServiceMock';
@@ -33,24 +34,82 @@ const ui = {
   createUserButton: byRole('button', { name: 'users.create_user' }),
   infoManageMode: byText(/users\.page\.managed_description/),
   description: byText('users.page.description'),
+  allFilter: byRole('button', { name: 'all' }),
+  managedFilter: byRole('button', { name: 'users.managed' }),
+  localFilter: byRole('button', { name: 'users.local' }),
+  aliceRow: byRole('row', { name: 'AM Alice Merveille alice.merveille never' }),
+  aliceRowWithLocalBadge: byRole('row', {
+    name: 'AM Alice Merveille alice.merveille users.local never',
+  }),
+  bobRow: byRole('row', { name: 'BM Bob Marley bob.marley never' }),
 };
 
-it('should render list of user in non manage mode', async () => {
-  handler.setIsManaged(false);
-  renderUsersApp();
+describe('in non managed mode', () => {
+  beforeEach(() => {
+    handler.setIsManaged(false);
+  });
 
-  expect(await ui.description.find()).toBeInTheDocument();
-  expect(ui.createUserButton.get()).toBeEnabled();
+  it('should allow the creation of user', async () => {
+    renderUsersApp();
+
+    expect(await ui.description.find()).toBeInTheDocument();
+    expect(ui.createUserButton.get()).toBeEnabled();
+  });
+
+  it('should render all users', async () => {
+    renderUsersApp();
+
+    expect(ui.aliceRowWithLocalBadge.query()).not.toBeInTheDocument();
+    expect(await ui.aliceRow.find()).toBeInTheDocument();
+    expect(await ui.bobRow.find()).toBeInTheDocument();
+  });
 });
 
-it('should render list of user in manage mode', async () => {
-  handler.setIsManaged(true);
-  renderUsersApp();
+describe('in manage mode', () => {
+  beforeEach(() => {
+    handler.setIsManaged(true);
+  });
+
+  it('should not be able to create a user"', async () => {
+    renderUsersApp();
+    expect(await ui.createUserButton.get()).toBeDisabled();
+    expect(await ui.infoManageMode.find()).toBeInTheDocument();
+  });
+
+  it('should render list of all users', async () => {
+    renderUsersApp();
+
+    expect(await ui.allFilter.find()).toBeInTheDocument();
+
+    expect(ui.aliceRowWithLocalBadge.get()).toBeInTheDocument();
+    expect(ui.bobRow.get()).toBeInTheDocument();
+  });
+
+  it('should render list of managed users', async () => {
+    const user = userEvent.setup();
+    renderUsersApp();
+
+    // The click downs't work without this line
+    expect(await ui.managedFilter.find()).toBeInTheDocument();
+    await user.click(await ui.managedFilter.get());
+
+    expect(ui.aliceRowWithLocalBadge.query()).not.toBeInTheDocument();
+    expect(ui.bobRow.get()).toBeInTheDocument();
+  });
+
+  it('should render list of local users', async () => {
+    const user = userEvent.setup();
+    renderUsersApp();
+
+    // The click downs't work without this line
+    expect(await ui.localFilter.find()).toBeInTheDocument();
+    await user.click(await ui.localFilter.get());
 
-  expect(await ui.infoManageMode.find()).toBeInTheDocument();
-  expect(ui.createUserButton.get()).toBeDisabled();
+    expect(ui.aliceRowWithLocalBadge.get()).toBeInTheDocument();
+    expect(ui.bobRow.query()).not.toBeInTheDocument();
+  });
 });
 
 function renderUsersApp() {
-  renderApp('admin/users', <UsersApp />);
+  return renderApp('admin/users', <UsersApp />);
 }
diff --git a/server/sonar-web/src/main/js/apps/users/__tests__/UsersApp-test.tsx b/server/sonar-web/src/main/js/apps/users/__tests__/UsersApp-test.tsx
deleted file mode 100644 (file)
index e9cab0d..0000000
+++ /dev/null
@@ -1,84 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-import { shallow } from 'enzyme';
-import * as React from 'react';
-import { Location } from '../../../components/hoc/withRouter';
-import { mockRouter } from '../../../helpers/testMocks';
-import { waitAndUpdate } from '../../../helpers/testUtils';
-import { UsersApp } from '../UsersApp';
-
-jest.mock('../../../api/users', () => ({
-  getIdentityProviders: jest.fn(() =>
-    Promise.resolve({
-      identityProviders: [
-        {
-          backgroundColor: 'blue',
-          iconPath: 'icon/path',
-          key: 'foo',
-          name: 'Foo Provider',
-        },
-      ],
-    })
-  ),
-  searchUsers: jest.fn(() =>
-    Promise.resolve({
-      paging: {
-        pageIndex: 1,
-        pageSize: 1,
-        total: 2,
-      },
-      users: [
-        {
-          login: 'luke',
-          name: 'Luke',
-          active: true,
-          scmAccounts: [],
-          local: false,
-        },
-      ],
-    })
-  ),
-}));
-
-const getIdentityProviders = require('../../../api/users').getIdentityProviders as jest.Mock<any>;
-const searchUsers = require('../../../api/users').searchUsers as jest.Mock<any>;
-
-const currentUser = { isLoggedIn: true, login: 'luke', dismissedNotices: {} };
-const location = { pathname: '', query: {} } as Location;
-
-beforeEach(() => {
-  getIdentityProviders.mockClear();
-  searchUsers.mockClear();
-});
-
-it('should render correctly', async () => {
-  const wrapper = getWrapper();
-  expect(wrapper).toMatchSnapshot();
-  expect(getIdentityProviders).toHaveBeenCalled();
-  expect(searchUsers).toHaveBeenCalled();
-  await waitAndUpdate(wrapper);
-  expect(wrapper).toMatchSnapshot();
-});
-
-function getWrapper(props: Partial<UsersApp['props']> = {}) {
-  return shallow(
-    <UsersApp currentUser={currentUser} location={location} router={mockRouter()} {...props} />
-  );
-}
index 8d67e0b78948407cbbe88b14a090a1eaa0e5e7ab..51b3d06e94576bd904fd60fc0a592e54cfb0505b 100644 (file)
@@ -28,6 +28,7 @@ const users = [
     active: true,
     scmAccounts: [],
     local: false,
+    managed: false,
   },
   {
     login: 'obi',
@@ -35,6 +36,7 @@ const users = [
     active: true,
     scmAccounts: [],
     local: false,
+    managed: false,
   },
 ];
 
@@ -57,6 +59,7 @@ function getWrapper(props = {}) {
       onUpdateUsers={jest.fn()}
       updateTokensCount={jest.fn()}
       users={users}
+      manageProvider={undefined}
       {...props}
     />
   );
diff --git a/server/sonar-web/src/main/js/apps/users/__tests__/__snapshots__/UsersApp-test.tsx.snap b/server/sonar-web/src/main/js/apps/users/__tests__/__snapshots__/UsersApp-test.tsx.snap
deleted file mode 100644 (file)
index 41245b6..0000000
+++ /dev/null
@@ -1,110 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`should render correctly 1`] = `
-<main
-  className="page page-limited"
-  id="users-page"
->
-  <Suggestions
-    suggestions="users"
-  />
-  <Helmet
-    defer={false}
-    encodeSpecialCharacters={true}
-    prioritizeSeoTags={false}
-    title="users.page"
-  />
-  <Header
-    loading={true}
-    onUpdateUsers={[Function]}
-  />
-  <Search
-    query={
-      {
-        "search": "",
-      }
-    }
-    updateQuery={[Function]}
-  />
-  <UsersList
-    currentUser={
-      {
-        "dismissedNotices": {},
-        "isLoggedIn": true,
-        "login": "luke",
-      }
-    }
-    identityProviders={[]}
-    onUpdateUsers={[Function]}
-    updateTokensCount={[Function]}
-    users={[]}
-  />
-</main>
-`;
-
-exports[`should render correctly 2`] = `
-<main
-  className="page page-limited"
-  id="users-page"
->
-  <Suggestions
-    suggestions="users"
-  />
-  <Helmet
-    defer={false}
-    encodeSpecialCharacters={true}
-    prioritizeSeoTags={false}
-    title="users.page"
-  />
-  <Header
-    loading={false}
-    onUpdateUsers={[Function]}
-  />
-  <Search
-    query={
-      {
-        "search": "",
-      }
-    }
-    updateQuery={[Function]}
-  />
-  <UsersList
-    currentUser={
-      {
-        "dismissedNotices": {},
-        "isLoggedIn": true,
-        "login": "luke",
-      }
-    }
-    identityProviders={
-      [
-        {
-          "backgroundColor": "blue",
-          "iconPath": "icon/path",
-          "key": "foo",
-          "name": "Foo Provider",
-        },
-      ]
-    }
-    onUpdateUsers={[Function]}
-    updateTokensCount={[Function]}
-    users={
-      [
-        {
-          "active": true,
-          "local": false,
-          "login": "luke",
-          "name": "Luke",
-          "scmAccounts": [],
-        },
-      ]
-    }
-  />
-  <ListFooter
-    count={1}
-    loadMore={[Function]}
-    ready={true}
-    total={2}
-  />
-</main>
-`;
index 175936df424dd813641926bc9675cbb0fbb1c245..64566ee533b081423f9e37d24ccef05cc9a05fc2 100644 (file)
@@ -52,6 +52,7 @@ exports[`should render correctly 1`] = `
             "active": true,
             "local": false,
             "login": "luke",
+            "managed": false,
             "name": "Luke",
             "scmAccounts": [],
           }
@@ -67,6 +68,7 @@ exports[`should render correctly 1`] = `
             "active": true,
             "local": false,
             "login": "obi",
+            "managed": false,
             "name": "One",
             "scmAccounts": [],
           }
index 04a03dee2878f06a460de5ee65689bffd1e73216..4ec202012c5fb7ae6da4ec7cf95269b8acd38aa5 100644 (file)
@@ -31,67 +31,66 @@ import UserGroups from './UserGroups';
 import UserListItemIdentity from './UserListItemIdentity';
 import UserScmAccounts from './UserScmAccounts';
 
-interface Props {
+export interface UserListItemProps {
   identityProvider?: IdentityProvider;
   isCurrentUser: boolean;
   onUpdateUsers: () => void;
   updateTokensCount: (login: string, tokensCount: number) => void;
   user: User;
+  manageProvider: string | undefined;
 }
 
-interface State {
-  openTokenForm: boolean;
-}
-
-export default class UserListItem extends React.PureComponent<Props, State> {
-  state: State = { openTokenForm: false };
-
-  handleOpenTokensForm = () => this.setState({ openTokenForm: true });
-  handleCloseTokensForm = () => this.setState({ openTokenForm: false });
+export default function UserListItem(props: UserListItemProps) {
+  const [openTokenForm, setOpenTokenForm] = React.useState(false);
 
-  render() {
-    const { identityProvider, onUpdateUsers, user } = this.props;
+  const {
+    identityProvider,
+    onUpdateUsers,
+    user,
+    manageProvider,
+    isCurrentUser,
+    updateTokensCount,
+  } = props;
 
-    return (
-      <tr>
-        <td className="thin nowrap text-middle">
-          <Avatar hash={user.avatar} name={user.name} size={36} />
-        </td>
-        <UserListItemIdentity identityProvider={identityProvider} user={user} />
-        <td className="thin nowrap text-middle">
-          <UserScmAccounts scmAccounts={user.scmAccounts || []} />
-        </td>
-        <td className="thin nowrap text-middle">
-          <DateFromNow date={user.lastConnectionDate} hourPrecision={true} />
-        </td>
-        <td className="thin nowrap text-middle">
-          <UserGroups groups={user.groups || []} onUpdateUsers={onUpdateUsers} user={user} />
-        </td>
-        <td className="thin nowrap text-middle">
-          {user.tokensCount}
-          <ButtonIcon
-            className="js-user-tokens spacer-left button-small"
-            onClick={this.handleOpenTokensForm}
-            tooltip={translate('users.update_tokens')}
-          >
-            <BulletListIcon />
-          </ButtonIcon>
-        </td>
-        <td className="thin nowrap text-right text-middle">
-          <UserActions
-            isCurrentUser={this.props.isCurrentUser}
-            onUpdateUsers={onUpdateUsers}
-            user={user}
-          />
-        </td>
-        {this.state.openTokenForm && (
-          <TokensFormModal
-            onClose={this.handleCloseTokensForm}
-            updateTokensCount={this.props.updateTokensCount}
-            user={user}
-          />
-        )}
-      </tr>
-    );
-  }
+  return (
+    <tr>
+      <td className="thin nowrap text-middle">
+        <Avatar hash={user.avatar} name={user.name} size={36} />
+      </td>
+      <UserListItemIdentity
+        identityProvider={identityProvider}
+        user={user}
+        manageProvider={manageProvider}
+      />
+      <td className="thin nowrap text-middle">
+        <UserScmAccounts scmAccounts={user.scmAccounts || []} />
+      </td>
+      <td className="thin nowrap text-middle">
+        <DateFromNow date={user.lastConnectionDate} hourPrecision={true} />
+      </td>
+      <td className="thin nowrap text-middle">
+        <UserGroups groups={user.groups || []} onUpdateUsers={onUpdateUsers} user={user} />
+      </td>
+      <td className="thin nowrap text-middle">
+        {user.tokensCount}
+        <ButtonIcon
+          className="js-user-tokens spacer-left button-small"
+          onClick={() => setOpenTokenForm(true)}
+          tooltip={translate('users.update_tokens')}
+        >
+          <BulletListIcon />
+        </ButtonIcon>
+      </td>
+      <td className="thin nowrap text-right text-middle">
+        <UserActions isCurrentUser={isCurrentUser} onUpdateUsers={onUpdateUsers} user={user} />
+      </td>
+      {openTokenForm && (
+        <TokensFormModal
+          onClose={() => setOpenTokenForm(false)}
+          updateTokensCount={updateTokensCount}
+          user={user}
+        />
+      )}
+    </tr>
+  );
 }
index 6d4d6872ce8b1db3499347284775cc7156de6d79..f73eb688689fd0596d0cf420390082cd26341fd0 100644 (file)
@@ -21,6 +21,7 @@
 import { getTextColor } from 'design-system';
 import * as React from 'react';
 import { colors } from '../../../app/theme';
+import { translate } from '../../../helpers/l10n';
 import { getBaseUrl } from '../../../helpers/system';
 import { IdentityProvider } from '../../../types/types';
 import { User } from '../../../types/users';
@@ -28,9 +29,10 @@ import { User } from '../../../types/users';
 export interface Props {
   identityProvider?: IdentityProvider;
   user: User;
+  manageProvider?: string;
 }
 
-export default function UserListItemIdentity({ identityProvider, user }: Props) {
+export default function UserListItemIdentity({ identityProvider, user, manageProvider }: Props) {
   return (
     <td className="text-middle">
       <div>
@@ -41,11 +43,14 @@ export default function UserListItemIdentity({ identityProvider, user }: Props)
       {!user.local && user.externalProvider !== 'sonarqube' && (
         <ExternalProvider identityProvider={identityProvider} user={user} />
       )}
+      {user.managed === false && manageProvider !== undefined && (
+        <span className="badge">{translate('users.local')}</span>
+      )}
     </td>
   );
 }
 
-export function ExternalProvider({ identityProvider, user }: Props) {
+export function ExternalProvider({ identityProvider, user }: Omit<Props, 'manageProvider'>) {
   if (!identityProvider) {
     return (
       <div className="js-user-identity-provider little-spacer-top">
index ab9a64daa91522eb9dcc39c4d8440ff56e375fa9..e4925c08c9a949872e38d2b1415fb0bb340fb04c 100644 (file)
@@ -28,6 +28,7 @@ const user = {
   active: true,
   scmAccounts: [],
   local: false,
+  managed: false,
 };
 
 it('should render correctly', () => {
index 5683e8ef5c2a211c1cdfae390f84ce66e8383bea..47ea651dbe466e60da52a5fa1db40f64363eac40 100644 (file)
@@ -28,6 +28,7 @@ const user = {
   active: true,
   scmAccounts: [],
   local: false,
+  managed: false,
 };
 
 const groups = ['foo', 'bar', 'baz', 'plop'];
index a3c0c1eb34ddc31b8814ac7586fa2ed3fc734586..cfd69e9e579d0f3b56bddf00269d993fba008b27 100644 (file)
@@ -21,7 +21,7 @@ import { shallow } from 'enzyme';
 import * as React from 'react';
 import { click } from '../../../../helpers/testUtils';
 import { User } from '../../../../types/users';
-import UserListItem from '../UserListItem';
+import UserListItem, { UserListItemProps } from '../UserListItem';
 
 jest.mock('../../../../components/intl/DateFromNow');
 jest.mock('../../../../components/intl/DateTimeFormatter');
@@ -33,6 +33,7 @@ const user: User = {
   login: 'obi',
   name: 'One',
   scmAccounts: [],
+  managed: false,
 };
 
 it('should render correctly', () => {
@@ -49,13 +50,14 @@ it('should open the correct forms', () => {
   expect(wrapper.find('TokensFormModal').exists()).toBe(true);
 });
 
-function shallowRender(props: Partial<UserListItem['props']> = {}) {
+function shallowRender(props: Partial<UserListItemProps> = {}) {
   return shallow(
     <UserListItem
       isCurrentUser={false}
       onUpdateUsers={jest.fn()}
       updateTokensCount={jest.fn()}
       user={user}
+      manageProvider={undefined}
       {...props}
     />
   );
index 661feb1fc1ec9458948fe88f7e331d0c60b1fa8d..a362fd780d4883d191f242ea870644c333b39555 100644 (file)
@@ -44,6 +44,7 @@ describe('#UserListItemIdentity', () => {
           login: 'obi',
           name: 'One',
           scmAccounts: [],
+          managed: false,
         }}
         {...props}
       />
@@ -78,6 +79,7 @@ describe('#ExternalProvider', () => {
           login: 'obi',
           name: 'One',
           scmAccounts: [],
+          managed: false,
         }}
         {...props}
       />
index a4e8b65bfc3fc1133ba3514d693a3e179c0a2a57..5970d8c387170035f3c7746c90a9c2c57e224f54 100644 (file)
@@ -17,6 +17,7 @@ exports[`should render correctly 1`] = `
         "lastConnectionDate": "2019-01-18T15:06:33+0100",
         "local": false,
         "login": "obi",
+        "managed": false,
         "name": "One",
         "scmAccounts": [],
       }
@@ -49,6 +50,7 @@ exports[`should render correctly 1`] = `
           "lastConnectionDate": "2019-01-18T15:06:33+0100",
           "local": false,
           "login": "obi",
+          "managed": false,
           "name": "One",
           "scmAccounts": [],
         }
@@ -78,6 +80,7 @@ exports[`should render correctly 1`] = `
           "lastConnectionDate": "2019-01-18T15:06:33+0100",
           "local": false,
           "login": "obi",
+          "managed": false,
           "name": "One",
           "scmAccounts": [],
         }
@@ -104,6 +107,7 @@ exports[`should render correctly without last connection date 1`] = `
         "lastConnectionDate": "2019-01-18T15:06:33+0100",
         "local": false,
         "login": "obi",
+        "managed": false,
         "name": "One",
         "scmAccounts": [],
       }
@@ -136,6 +140,7 @@ exports[`should render correctly without last connection date 1`] = `
           "lastConnectionDate": "2019-01-18T15:06:33+0100",
           "local": false,
           "login": "obi",
+          "managed": false,
           "name": "One",
           "scmAccounts": [],
         }
@@ -165,6 +170,7 @@ exports[`should render correctly without last connection date 1`] = `
           "lastConnectionDate": "2019-01-18T15:06:33+0100",
           "local": false,
           "login": "obi",
+          "managed": false,
           "name": "One",
           "scmAccounts": [],
         }
index eccdb8b81474d70cd23ed4825b5db46f7e1e1528..9fd139b23b9e4f4efdd30ac8c38701e2202497ad 100644 (file)
@@ -73,6 +73,7 @@ exports[`#UserListItemIdentity should render correctly 1`] = `
         "lastConnectionDate": "2019-01-18T15:06:33+0100",
         "local": false,
         "login": "obi",
+        "managed": false,
         "name": "One",
         "scmAccounts": [],
       }
index 9af11a617b84b49aeef4b4cdb7326df87991d1ff..c3ceb0cc5b47f31f702370231897ccb177eb3070 100644 (file)
@@ -23,11 +23,13 @@ import { RawQuery } from '../../types/types';
 
 export interface Query {
   search: string;
+  managed?: boolean;
 }
 
 export const parseQuery = memoize(
   (urlQuery: RawQuery): Query => ({
     search: parseAsString(urlQuery['search']),
+    managed: urlQuery['managed'] !== undefined ? urlQuery['managed'] === 'true' : undefined,
   })
 );
 
@@ -35,5 +37,6 @@ export const serializeQuery = memoize(
   (query: Query): RawQuery =>
     cleanQuery({
       search: query.search ? serializeString(query.search) : undefined,
+      managed: query.managed,
     })
 );
index 9b2b7c653325e9d78a4b5843b90c41b869d5b3a8..3cc53cbf2e64395c9641f4129b78d1f153c1273b 100644 (file)
@@ -651,6 +651,7 @@ export function mockUser(overrides: Partial<User> = {}): User {
     local: true,
     login: 'john.doe',
     name: 'John Doe',
+    managed: false,
     ...overrides,
   };
 }
index eb94140e2b42c377e44cb5362e481832973fcd78..b5a5f55ba4c02e95e2a82f14bb9314880e5c317b 100644 (file)
@@ -77,6 +77,7 @@ export interface User extends UserBase {
   groups?: string[];
   lastConnectionDate?: string;
   local: boolean;
+  managed: boolean;
   scmAccounts?: string[];
   tokensCount?: number;
 }
index 5ea15bc32c38442add08b0e9ce896af123a314a7..43a618575dbbb9245791308fe7311e94435ed907 100644 (file)
@@ -4384,6 +4384,8 @@ users.change_admin_password.form.confirm=Confirm password for user 'admin'
 users.change_admin_password.form.cannot_use_default_password=You must choose a password that is different from the default password.
 users.change_admin_password.form.success=The admin user's password was successfully changed.
 users.change_admin_password.form.continue_to_app=Continue to SonarQube
+users.local=Local
+users.managed=Managed
 
 #------------------------------------------------------------------------------
 #