LCOV - code coverage report
Current view: top level - pkg/users - accounts-list.js Coverage Total Hit
Test: cockpit Lines: 95.6 % 363 347
Test Date: 2026-07-17 12:03:54

            Line data    Source code
       1              : /*
       2              :  * Copyright (C) 2020 Red Hat, Inc.
       3              :  * SPDX-License-Identifier: LGPL-2.1-or-later
       4              :  */
       5              : 
       6           19 : import cockpit from 'cockpit';
       7           19 : import React from 'react';
       8              : import { superuser } from "superuser";
       9              : 
      10              : import { admins } from './users.js';
      11              : import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
      12              : import { Badge } from "@patternfly/react-core/dist/esm/components/Badge/index.js";
      13              : import { Card, CardExpandableContent, CardHeader, CardTitle } from '@patternfly/react-core/dist/esm/components/Card/index.js';
      14              : import { Divider } from '@patternfly/react-core/dist/esm/components/Divider/index.js';
      15              : import { DropdownItem } from '@patternfly/react-core/dist/esm/components/Dropdown/index.js';
      16              : import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
      17              : import { HelperText, HelperTextItem } from "@patternfly/react-core/dist/esm/components/HelperText/index.js";
      18              : import { Label } from "@patternfly/react-core/dist/esm/components/Label/index.js";
      19              : import { Page, PageSection } from "@patternfly/react-core/dist/esm/components/Page/index.js";
      20              : import { SearchInput } from "@patternfly/react-core/dist/esm/components/SearchInput/index.js";
      21              : import { Stack } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js";
      22              : import { Content, ContentVariants } from "@patternfly/react-core/dist/esm/components/Content/index.js";
      23              : import { Toolbar, ToolbarContent, ToolbarItem } from "@patternfly/react-core/dist/esm/components/Toolbar/index.js";
      24              : import * as timeformat from "timeformat";
      25              : import { EmptyStatePanel } from 'cockpit-components-empty-state.jsx';
      26              : import { ListingTable } from 'cockpit-components-table.jsx';
      27              : import { SearchIcon } from '@patternfly/react-icons';
      28              : import { SortByDirection } from "@patternfly/react-table";
      29              : import { account_create_dialog } from "./account-create-dialog.js";
      30              : import { delete_account_dialog } from "./delete-account-dialog.js";
      31              : import { group_create_dialog } from "./group-create-dialog.js";
      32              : import { lockAccountDialog } from "./lock-account-dialog.js";
      33              : import { logoutAccountDialog } from "./logout-account-dialog.js";
      34              : import { GroupActions } from "./group-actions.jsx";
      35              : 
      36              : import { usePageLocation } from "hooks";
      37              : import { KebabDropdown } from "cockpit-components-dropdown";
      38              : 
      39           19 : const _ = cockpit.gettext;
      40              : 
      41           12 : const UserActions = ({ account, current }) => {
      42           12 :     const actions = [
      43           12 :         <DropdownItem key="edit-user"
      44            1 :                       onClick={ev => { ev.preventDefault(); cockpit.location.go(account.name) }}>
      45           12 :             {_("Edit user")}
      46           12 :         </DropdownItem>,
      47           12 :     ];
      48              : 
      49           11 :     superuser.allowed && actions.push(
      50           11 :         <Divider key="separator-0" />,
      51           11 :         <DropdownItem key="log-user-out"
      52           11 :                       isDisabled={account.uid === 0 || !account.loggedIn || current }
      53            1 :                       onClick={() => logoutAccountDialog(account) }>
      54           11 :             {_("Log user out")}
      55           11 :         </DropdownItem>,
      56           11 :         <Divider key="separator-1" />,
      57           11 :         <DropdownItem key="lock-account"
      58           11 :                       isDisabled={account.isLocked}
      59            1 :                       onClick={() => lockAccountDialog(account) }>
      60           11 :             {_("Lock account")}
      61           11 :         </DropdownItem>,
      62           11 :         <DropdownItem key="delete-account"
      63           11 :                       isDisabled={account.uid === 0 || current}
      64           11 :                       className={account.uid === 0 || current ? "" : "delete-resource-red"}
      65            1 :                       onClick={() => delete_account_dialog(account) }>
      66           11 :             {_("Delete account")}
      67           11 :         </DropdownItem>,
      68           11 :     );
      69              : 
      70           12 :     return <KebabDropdown toggleButtonId="accounts-actions" dropdownItems={actions} />;
      71           12 : };
      72              : 
      73           12 : const getGroupRow = (group, accounts) => {
      74           12 :     let groupColorClass;
      75           12 :     if (group.isAdmin)
      76           12 :         groupColorClass = "group-yellow";
      77           12 :     else if (group.members > 0)
      78           12 :         groupColorClass = "group-blue";
      79              :     else
      80           12 :         groupColorClass = "group-grey";
      81              : 
      82           12 :     const columns = [
      83           12 :         {
      84           12 :             sortKey: group.name,
      85           12 :             title: (
      86           12 :                 <Flex alignItems={{ default: 'alignItemsCenter' }}>
      87           12 :                     <div className={"dot " + groupColorClass} />
      88           12 :                     <FlexItem>{group.name}</FlexItem>
      89           12 :                 </Flex>
      90              :             ),
      91           12 :             props: { width: 20, },
      92           12 :         },
      93           12 :         {
      94           12 :             title: group.gid,
      95           12 :             props: { width: 10, },
      96           12 :         },
      97           12 :         {
      98           12 :             title: group.members,
      99           12 :             props: { width: 20, },
     100           12 :         },
     101           12 :         {
     102           12 :             title: (
     103           12 :                 <Content>
     104           12 :                     <Content component={ContentVariants.p}>
     105           12 :                         {(group.userlistPrimary.concat(group.userlist))
     106           12 :                                 .map(account => {
     107           12 :                                     if (accounts.map(account => account.name).includes(account))
     108           12 :                                         return <Content key={account} component={ContentVariants.a} href={"#" + account}>{account}</Content>;
     109              :                                     else
     110           12 :                                         return account;
     111           12 :                                 })
     112           12 :                                 .reduce((acc, curr) => [...acc, ", ", curr], [])
     113           12 :                                 .slice(1)}
     114           12 :                     </Content>
     115           12 :                 </Content>
     116              :             ),
     117           12 :             props: { width: 50, },
     118           12 :         },
     119           12 :     ];
     120              : 
     121           11 :     if (superuser.allowed) {
     122           11 :         columns.push(
     123           11 :             {
     124           11 :                 title: <GroupActions group={group} />,
     125           11 :                 props: { className: "pf-v6-c-table__action" }
     126           11 :             }
     127           11 :         );
     128           11 :     }
     129              : 
     130           12 :     return { columns, props: { "data-row-id": group.name, key: group.name } };
     131           12 : };
     132              : 
     133           12 : const getAccountRow = (account, current, groups) => {
     134           12 :     const userGroups = groups.filter(group => group.gid === account.gid || group.userlist.find(accountName => accountName === account.name));
     135           12 :     const userGroupLabels = userGroups.map(group => {
     136           12 :         const color = group.isAdmin ? "yellow" : "blue";
     137           12 :         return (
     138           12 :             <Label key={group.name} variant="filled" color={color}>
     139           12 :                 {!group.isAdmin ? group.name : ("admin" + " (" + group.name + ")") }
     140           12 :             </Label>
     141              :         );
     142           12 :     });
     143              : 
     144           12 :     let loginText = "";
     145           12 :     let loginSortKey = null;
     146           12 :     if (account.loggedIn) {
     147           12 :         loginText = _("Logged in");
     148           12 :         loginSortKey = "logged in";
     149           12 :     } else if (!account.lastLogin) {
     150           12 :         loginText = _("Never logged in");
     151           12 :         loginSortKey = "never";
     152           11 :     } else {
     153           11 :         loginSortKey = new Date(account.lastLogin);
     154           11 :         loginText = timeformat.dateTime(loginSortKey);
     155           11 :     }
     156              : 
     157           12 :     const columns = [
     158           12 :         {
     159           12 :             title: (
     160           12 :                 <span>
     161           12 :                     <a href={"#/" + account.name}>{account.name}</a>
     162           12 :                     {current && <Badge id="current-account-badge">{_("Your account")}</Badge>}
     163           12 :                 </span>
     164              :             ),
     165           12 :             sortKey: account.name,
     166           12 :             props: { width: 25, },
     167           12 :         },
     168           12 :         {
     169           12 :             title: account.gecos.split(',')[0],
     170           12 :             props: { width: 20, },
     171           12 :         },
     172           12 :         {
     173           12 :             title: account.uid,
     174           12 :             props: { width: 10, },
     175           12 :         },
     176           12 :         {
     177           12 :             title: loginText,
     178           12 :             sortKey: loginSortKey,
     179           12 :             props: { width: 25, },
     180           12 :         },
     181           12 :         {
     182           12 :             title: (
     183           12 :                 <Flex spaceItems={{ default: 'spaceItemsSm' }}>
     184           12 :                     {userGroupLabels}
     185           12 :                 </Flex>
     186              :             ),
     187           12 :             props: { width: 20 },
     188           12 :         },
     189           12 :         {
     190           12 :             title: <UserActions account={account} current={current} />,
     191           12 :             props: { className: "pf-v6-c-table__action" }
     192           12 :         },
     193           12 :     ];
     194              : 
     195           12 :     return { columns, props: { "data-row-id": account.name, key: account.name } };
     196           12 : };
     197              : 
     198           12 : const mapGroupsToAccount = (accounts, groups) => {
     199           12 :     return accounts.map(account => {
     200           12 :         const accountGroups = [];
     201           12 :         groups.forEach(group => {
     202           12 :             if (group.userlist.find(accountName => accountName === account.name))
     203           12 :                 accountGroups.push(group.name);
     204           12 :         });
     205           12 :         account.groups = accountGroups;
     206              : 
     207           12 :         return account;
     208           12 :     });
     209           12 : };
     210              : 
     211           12 : const GroupsList = ({ groups, accounts, isExpanded, setIsExpanded, min_gid, max_gid }) => {
     212           12 :     const { options } = usePageLocation();
     213              : 
     214            1 :     const currentTextFilter = typeof options.group == "string" ? options.group : '';
     215            0 :     const setCurrentTextFilter = val => {
     216            0 :         const newOptions = { ...cockpit.location.options };
     217            0 :         if (val)
     218            0 :             newOptions.group = val;
     219              :         else
     220            0 :             delete newOptions.group;
     221            0 :         cockpit.location.replace(cockpit.location.path, newOptions);
     222            0 :     };
     223              : 
     224           12 :     const filtered_groups = groups.filter(group => {
     225           12 :         if (currentTextFilter !== "" &&
     226            1 :             (group.name.toLowerCase().indexOf(currentTextFilter.toLowerCase()) === -1) &&
     227            1 :             (group.gid.toString().indexOf(currentTextFilter.toLowerCase()) === -1))
     228            1 :             return false;
     229              : 
     230           12 :         return true;
     231           12 :     });
     232              : 
     233           12 :     const columns = [
     234           12 :         { title: _("Group name"), sortable: true },
     235           12 :         { title: _("ID"), sortable: true },
     236           12 :         { title: _("# of users"), sortable: true },
     237           12 :         { title: _("Accounts") },
     238           12 :     ];
     239              : 
     240            2 :     const sortRows = (rows, direction, idx) => {
     241              :         // GID and members columns are numeric
     242            2 :         const isNumeric = idx == 1 || idx == 2;
     243            2 :         const sortedRows = rows.sort((a, b) => {
     244            2 :             const aitem = a.columns[idx].sortKey || a.columns[idx].title;
     245            2 :             const bitem = b.columns[idx].sortKey || b.columns[idx].title;
     246            2 :             const aname = a.columns[0].sortKey;
     247            2 :             const bname = b.columns[0].sortKey;
     248              : 
     249              :             // administrator groups are always first
     250            2 :             if (admins.includes(aname))
     251            0 :                 return direction === SortByDirection.asc ? -1 : 1;
     252            2 :             if (admins.includes(bname))
     253            0 :                 return direction === SortByDirection.asc ? 1 : -1;
     254              : 
     255            2 :             if (isNumeric)
     256            0 :                 return bitem - aitem;
     257              :             else
     258            0 :                 return aitem.localeCompare(bitem);
     259            2 :         });
     260            0 :         return direction === SortByDirection.asc ? sortedRows : sortedRows.reverse();
     261            2 :     };
     262              : 
     263           12 :     const tableToolbar = (
     264           12 :         <Toolbar>
     265           12 :             <ToolbarContent className="groups-toolbar-header">
     266            3 :                 {isExpanded && <ToolbarItem>
     267            3 :                     <SearchInput id="groups-filter"
     268            3 :                                  placeholder={_("Search for name or ID")}
     269            3 :                                  value={currentTextFilter}
     270            0 :                                  onChange={(_, val) => setCurrentTextFilter(val)}
     271            0 :                                  onClear={() => setCurrentTextFilter('')} />
     272            3 :                 </ToolbarItem>}
     273           12 :                 { superuser.allowed &&
     274           11 :                     <>
     275            3 :                         {isExpanded && <ToolbarItem variant="separator" />}
     276           11 :                         <ToolbarItem align={{ md: "alignEnd" }}>
     277            1 :                             <Button variant="secondary" id="groups-create" onClick={() => group_create_dialog(groups, setIsExpanded, min_gid, max_gid)}>
     278           11 :                                 {_("Create new group")}
     279           11 :                             </Button>
     280           11 :                         </ToolbarItem>
     281           11 :                     </>
     282              :                 }
     283           12 :             </ToolbarContent>
     284           12 :         </Toolbar>
     285              :     );
     286              : 
     287           12 :     return (
     288           12 :         <Card className="ct-card card-groups" isExpanded={isExpanded}>
     289           12 :             <CardHeader actions={{ actions: tableToolbar, hasNoOffset: true }}
     290           12 :                 className="ct-card-expandable-header"
     291            0 :                 onExpand={() => setIsExpanded(!isExpanded)}
     292           12 :                 toggleButtonProps={{
     293           12 :                     id: 'groups-view-toggle',
     294           12 :                     'aria-label': _("Groups"),
     295           12 :                     'aria-expanded': isExpanded
     296           12 :                 }}>
     297           12 :                 <CardTitle className="pf-v6-l-flex pf-m-align-items-center pf-m-space-items-md">
     298           12 :                     <Content component={ContentVariants.h2}>{_("Groups")}</Content>
     299            1 :                     {(!isExpanded && !groups.length) && <HelperText> <HelperTextItem variant="indeterminate">{_("Loading...")}</HelperTextItem></HelperText>}
     300           12 :                     {(!isExpanded && filtered_groups.length > 0) && <>
     301           12 :                         {filtered_groups.slice(0, 3)
     302           12 :                                 .map(group => {
     303           12 :                                     const color = group.isAdmin ? "yellow" : "blue";
     304           12 :                                     return (
     305           12 :                                         <Label key={group.name} variant="filled" color={color}>
     306           12 :                                             {group.name + ": " + (group.userlistPrimary.length + group.userlist.length)}
     307           12 :                                         </Label>
     308              :                                     );
     309           12 :                                 })}
     310            1 :                         {filtered_groups.length > 3 && <Button key="more" className="group-more-btn" isInline variant='link' onClick={() => setIsExpanded(!isExpanded)}>
     311           12 :                             {cockpit.format(_("$0 more..."), filtered_groups.length - 3)}
     312           12 :                         </Button>}
     313           12 :                     </>}
     314           12 :                 </CardTitle>
     315           12 :             </CardHeader>
     316           12 :             <CardExpandableContent>
     317           12 :                 <ListingTable columns={columns}
     318           12 :                     id="groups-list"
     319           12 :                     rows={ filtered_groups.map(a => getGroupRow(a, accounts)) }
     320            1 :                     loading={ groups.length && accounts.length ? '' : _("Loading...") }
     321           12 :                     sortMethod={sortRows}
     322           12 :                     emptyComponent={<EmptyStatePanel title={_("No matching results")} icon={SearchIcon} action={_("Clear filter")}
     323            0 :                                                      onAction={() => setCurrentTextFilter('')} actionVariant="link" />}
     324           12 :                     variant="compact" sortBy={{ index: 2, direction: SortByDirection.asc }} />
     325           12 :             </CardExpandableContent>
     326           12 :         </Card>
     327              :     );
     328           12 : };
     329              : 
     330           12 : const AccountsList = ({ accounts, current_user, groups, min_uid, max_uid, shells }) => {
     331           12 :     const { options } = usePageLocation();
     332              : 
     333            2 :     const currentTextFilter = typeof options.user == "string" ? options.user : '';
     334            1 :     const setCurrentTextFilter = val => {
     335            1 :         const newOptions = { ...cockpit.location.options };
     336            1 :         if (val)
     337            1 :             newOptions.user = val;
     338              :         else
     339            1 :             delete newOptions.user;
     340            1 :         cockpit.location.replace(cockpit.location.path, newOptions);
     341            1 :     };
     342              : 
     343           12 :     const filtered_accounts = accounts.filter(account => {
     344           12 :         if (currentTextFilter !== "" &&
     345            2 :             (account.name.toLowerCase().indexOf(currentTextFilter.toLowerCase()) === -1) &&
     346            2 :             (account.gecos.toLowerCase().indexOf(currentTextFilter.toLowerCase()) === -1) &&
     347            2 :             (account.uid.toString().indexOf(currentTextFilter.toLowerCase()) === -1) &&
     348            1 :             (!account.groups.find(group => group.toLowerCase().indexOf(currentTextFilter.toLowerCase()) !== -1)))
     349            2 :             return false;
     350              : 
     351           12 :         return true;
     352           12 :     });
     353              : 
     354           12 :     const columns = [
     355           12 :         { title: _("Username"), sortable: true },
     356           12 :         { title: _("Full name"), sortable: true },
     357           12 :         { title: _("ID"), sortable: true },
     358           12 :         { title: _("Last active"), sortable: true },
     359           12 :         { title: _("Group") },
     360           12 :         { title: "", sortable: false, props: { screenReaderText: _("Actions") } },
     361           12 :     ];
     362              : 
     363           12 :     const sortRows = (rows, direction, idx) => {
     364           12 :         const sortedRows = rows.sort((a, b) => {
     365           12 :             const aitem = a.columns[idx];
     366           12 :             const bitem = b.columns[idx];
     367           12 :             const aname = a.columns[0];
     368           12 :             const bname = b.columns[0];
     369              : 
     370              :             // current user is always first
     371           12 :             if (aname.sortKey === current_user)
     372            2 :                 return direction === SortByDirection.asc ? -1 : 1;
     373           12 :             if (bname.sortKey === current_user)
     374            2 :                 return direction === SortByDirection.asc ? 1 : -1;
     375              :             // sorting last login
     376            1 :             if (idx === 3) {
     377            1 :                 if (aitem.sortKey === "logged in")
     378            1 :                     return -1;
     379            1 :                 if (bitem.sortKey === "logged in")
     380            1 :                     return 1;
     381            1 :                 if (aitem.sortKey === "never")
     382            1 :                     return 1;
     383            1 :                 if (bitem.sortKey === "never")
     384            1 :                     return -1;
     385              : 
     386            1 :                 return bitem.sortKey - aitem.sortKey;
     387            1 :             }
     388              : 
     389           12 :             if (idx == 2)
     390            2 :                 return bitem.title - aitem.title;
     391            1 :             return ((typeof aitem == 'string' ? aitem : (aitem.sortKey || aitem.title)).localeCompare(typeof bitem == 'string' ? bitem : (bitem.sortKey || bitem.title)));
     392           12 :         });
     393            2 :         return direction === SortByDirection.asc ? sortedRows : sortedRows.reverse();
     394           12 :     };
     395              : 
     396           12 :     const tableToolbar = (
     397           12 :         <Toolbar>
     398           12 :             <ToolbarContent className="accounts-toolbar-header">
     399           12 :                 <ToolbarItem>
     400           12 :                     <SearchInput id="accounts-filter"
     401           12 :                                  placeholder={_("Search for name, group or ID")}
     402           12 :                                  value={currentTextFilter}
     403            1 :                                  onChange={(_, val) => setCurrentTextFilter(val)}
     404            1 :                                  onClear={() => setCurrentTextFilter('')} />
     405           12 :                 </ToolbarItem>
     406           12 :                 { superuser.allowed &&
     407           11 :                     <>
     408           11 :                         <ToolbarItem variant="separator" />
     409           11 :                         <ToolbarItem align={{ md: "alignEnd" }}>
     410            4 :                             <Button id="accounts-create" onClick={() => account_create_dialog(accounts, min_uid, max_uid, shells)}>
     411           11 :                                 {_("Create new account")}
     412           11 :                             </Button>
     413           11 :                         </ToolbarItem>
     414           11 :                     </>
     415              :                 }
     416           12 :             </ToolbarContent>
     417           12 :         </Toolbar>
     418              :     );
     419              : 
     420           12 :     return (
     421           12 :         <Card isPlain className="ct-card">
     422           12 :             <CardHeader actions={{ actions: tableToolbar }}>
     423           12 :                 <CardTitle component="h2">{_("Accounts")}</CardTitle>
     424           12 :             </CardHeader>
     425           12 :             <ListingTable columns={columns}
     426           12 :                           id="accounts-list"
     427            2 :                           isEmptyStateInTable={currentTextFilter !== "" && filtered_accounts.length !== accounts.length}
     428           12 :                           rows={ filtered_accounts.map(a => getAccountRow(a, current_user === a.name, groups)) }
     429            1 :                           loading={ accounts.length ? '' : _("Loading...") }
     430           12 :                           sortMethod={sortRows}
     431           12 :                           emptyComponent={<EmptyStatePanel title={_("No matching results")} icon={SearchIcon} action={_("Clear filter")}
     432            1 :                                                            onAction={() => setCurrentTextFilter('')} actionVariant="link" />}
     433           12 :                           variant="compact" sortBy={{ index: 0, direction: SortByDirection.asc }} />
     434           12 :         </Card>
     435              : 
     436              :     );
     437           12 : };
     438              : 
     439           12 : export const AccountsMain = ({ accountsInfo, current_user, groups, isGroupsExpanded, setIsGroupsExpanded, min_gid, max_gid, min_uid, max_uid, shells }) => {
     440           12 :     const accounts = mapGroupsToAccount(accountsInfo, groups).filter(account => {
     441           12 :         if ((account.uid < 1000 && account.uid !== 0) ||
     442           12 :                  account.shell.match(/^(\/usr)?\/sbin\/nologin/) ||
     443           12 :                  account.shell === '/bin/false')
     444           12 :             return false;
     445           12 :         return true;
     446           12 :     });
     447              : 
     448           12 :     return (
     449           12 :         <Page id="accounts" className="pf-m-no-sidebar">
     450           12 :             <PageSection hasBodyWrapper={false}>
     451           12 :                 <Stack hasGutter>
     452           12 :                     <GroupsList accounts={accounts} groups={groups} isExpanded={isGroupsExpanded} setIsExpanded={setIsGroupsExpanded} min_gid={min_gid} max_gid={max_gid} />
     453           12 :                     <AccountsList accounts={accounts} current_user={current_user} groups={groups} shells={shells} min_uid={min_uid} max_uid={max_uid} />
     454           12 :                 </Stack>
     455           12 :             </PageSection>
     456           12 :         </Page>
     457              :     );
     458           12 : };
        

Generated by: LCOV version 2.0-1