* Copyright (C) 2020 Red Hat, Inc.
* SPDX-License-Identifier: LGPL-2.1-or-later
*/
import cockpit from 'cockpit';
import React from 'react';
import { superuser } from "superuser";
import { admins } from './users.js';
import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
import { Badge } from "@patternfly/react-core/dist/esm/components/Badge/index.js";
import { Card, CardExpandableContent, CardHeader, CardTitle } from '@patternfly/react-core/dist/esm/components/Card/index.js';
import { Divider } from '@patternfly/react-core/dist/esm/components/Divider/index.js';
import { DropdownItem } from '@patternfly/react-core/dist/esm/components/Dropdown/index.js';
import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
import { HelperText, HelperTextItem } from "@patternfly/react-core/dist/esm/components/HelperText/index.js";
import { Label } from "@patternfly/react-core/dist/esm/components/Label/index.js";
import { Page, PageSection } from "@patternfly/react-core/dist/esm/components/Page/index.js";
import { SearchInput } from "@patternfly/react-core/dist/esm/components/SearchInput/index.js";
import { Stack } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js";
import { Content, ContentVariants } from "@patternfly/react-core/dist/esm/components/Content/index.js";
import { Toolbar, ToolbarContent, ToolbarItem } from "@patternfly/react-core/dist/esm/components/Toolbar/index.js";
import * as timeformat from "timeformat";
import { EmptyStatePanel } from 'cockpit-components-empty-state.jsx';
import { ListingTable } from 'cockpit-components-table.jsx';
import { SearchIcon } from '@patternfly/react-icons';
import { SortByDirection } from "@patternfly/react-table";
import { account_create_dialog } from "./account-create-dialog.js";
import { delete_account_dialog } from "./delete-account-dialog.js";
import { group_create_dialog } from "./group-create-dialog.js";
import { lockAccountDialog } from "./lock-account-dialog.js";
import { logoutAccountDialog } from "./logout-account-dialog.js";
import { GroupActions } from "./group-actions.jsx";
import { usePageLocation } from "hooks";
import { KebabDropdown } from "cockpit-components-dropdown";
const _ = cockpit.gettext;
const UserActions = ({ account, current }) => {
const actions = [
<DropdownItem key="edit-user"
onClick={ev => { ev.preventDefault(); cockpit.location.go(account.name) }}>
{_("Edit user")}
</DropdownItem>,
];
superuser.allowed && actions.push(
<Divider key="separator-0" />,
<DropdownItem key="log-user-out"
isDisabled={account.uid === 0 || !account.loggedIn || current }
onClick={() => logoutAccountDialog(account) }>
{_("Log user out")}
</DropdownItem>,
<Divider key="separator-1" />,
<DropdownItem key="lock-account"
isDisabled={account.isLocked}
onClick={() => lockAccountDialog(account) }>
{_("Lock account")}
</DropdownItem>,
<DropdownItem key="delete-account"
isDisabled={account.uid === 0 || current}
className={account.uid === 0 || current ? "" : "delete-resource-red"}
onClick={() => delete_account_dialog(account) }>
{_("Delete account")}
</DropdownItem>,
);
return <KebabDropdown toggleButtonId="accounts-actions" dropdownItems={actions} />;
};
const getGroupRow = (group, accounts) => {
let groupColorClass;
if (group.isAdmin)
groupColorClass = "group-yellow";
else if (group.members > 0)
groupColorClass = "group-blue";
else
groupColorClass = "group-grey";
const columns = [
{
sortKey: group.name,
title: (
<Flex alignItems={{ default: 'alignItemsCenter' }}>
<div className={"dot " + groupColorClass} />
<FlexItem>{group.name}</FlexItem>
</Flex>
),
props: { width: 20, },
},
{
title: group.gid,
props: { width: 10, },
},
{
title: group.members,
props: { width: 20, },
},
{
title: (
<Content>
<Content component={ContentVariants.p}>
{(group.userlistPrimary.concat(group.userlist))
.map(account => {
if (accounts.map(account => account.name).includes(account))
return <Content key={account} component={ContentVariants.a} href={"#" + account}>{account}</Content>;
else
return account;
})
.reduce((acc, curr) => [...acc, ", ", curr], [])
.slice(1)}
</Content>
</Content>
),
props: { width: 50, },
},
];
if (superuser.allowed) {
columns.push(
{
title: <GroupActions group={group} />,
props: { className: "pf-v6-c-table__action" }
}
);
}
return { columns, props: { "data-row-id": group.name, key: group.name } };
};
const getAccountRow = (account, current, groups) => {
const userGroups = groups.filter(group => group.gid === account.gid || group.userlist.find(accountName => accountName === account.name));
const userGroupLabels = userGroups.map(group => {
const color = group.isAdmin ? "yellow" : "blue";
return (
<Label key={group.name} variant="filled" color={color}>
{!group.isAdmin ? group.name : ("admin" + " (" + group.name + ")") }
</Label>
);
});
let loginText = "";
let loginSortKey = null;
if (account.loggedIn) {
loginText = _("Logged in");
loginSortKey = "logged in";
} else if (!account.lastLogin) {
loginText = _("Never logged in");
loginSortKey = "never";
} else {
loginSortKey = new Date(account.lastLogin);
loginText = timeformat.dateTime(loginSortKey);
}
const columns = [
{
title: (
<span>
<a href={"#/" + account.name}>{account.name}</a>
{current && <Badge id="current-account-badge">{_("Your account")}</Badge>}
</span>
),
sortKey: account.name,
props: { width: 25, },
},
{
title: account.gecos.split(',')[0],
props: { width: 20, },
},
{
title: account.uid,
props: { width: 10, },
},
{
title: loginText,
sortKey: loginSortKey,
props: { width: 25, },
},
{
title: (
<Flex spaceItems={{ default: 'spaceItemsSm' }}>
{userGroupLabels}
</Flex>
),
props: { width: 20 },
},
{
title: <UserActions account={account} current={current} />,
props: { className: "pf-v6-c-table__action" }
},
];
return { columns, props: { "data-row-id": account.name, key: account.name } };
};
const mapGroupsToAccount = (accounts, groups) => {
return accounts.map(account => {
const accountGroups = [];
groups.forEach(group => {
if (group.userlist.find(accountName => accountName === account.name))
accountGroups.push(group.name);
});
account.groups = accountGroups;
return account;
});
};
const GroupsList = ({ groups, accounts, isExpanded, setIsExpanded, min_gid, max_gid }) => {
const { options } = usePageLocation();
const currentTextFilter = typeof options.group == "string" ? options.group : '';
const setCurrentTextFilter = val => {
const newOptions = { ...cockpit.location.options };
if (val)
newOptions.group = val;
else
delete newOptions.group;
cockpit.location.replace(cockpit.location.path, newOptions);
};
const filtered_groups = groups.filter(group => {
if (currentTextFilter !== "" &&
(group.name.toLowerCase().indexOf(currentTextFilter.toLowerCase()) === -1) &&
(group.gid.toString().indexOf(currentTextFilter.toLowerCase()) === -1))
return false;
return true;
});
const columns = [
{ title: _("Group name"), sortable: true },
{ title: _("ID"), sortable: true },
{ title: _("# of users"), sortable: true },
{ title: _("Accounts") },
];
const sortRows = (rows, direction, idx) => {
const isNumeric = idx === 1 || idx === 2;
const sortedRows = rows.sort((a, b) => {
const aitem = a.columns[idx].sortKey || a.columns[idx].title;
const bitem = b.columns[idx].sortKey || b.columns[idx].title;
const aname = a.columns[0].sortKey;
const bname = b.columns[0].sortKey;
if (admins.includes(aname))
return direction === SortByDirection.asc ? -1 : 1;
if (admins.includes(bname))
return direction === SortByDirection.asc ? 1 : -1;
if (isNumeric)
return bitem - aitem;
else
return aitem.localeCompare(bitem);
});
return direction === SortByDirection.asc ? sortedRows : sortedRows.reverse();
};
const tableToolbar = (
<Toolbar>
<ToolbarContent className="groups-toolbar-header">
{isExpanded && <ToolbarItem>
<SearchInput id="groups-filter"
placeholder={_("Search for name or ID")}
value={currentTextFilter}
onChange={(_, val) => setCurrentTextFilter(val)}
onClear={() => setCurrentTextFilter('')} />
</ToolbarItem>}
{ superuser.allowed &&
<>
{isExpanded && <ToolbarItem variant="separator" />}
<ToolbarItem align={{ md: "alignEnd" }}>
<Button variant="secondary" id="groups-create" onClick={() => group_create_dialog(groups, setIsExpanded, min_gid, max_gid)}>
{_("Create new group")}
</Button>
</ToolbarItem>
</>
}
</ToolbarContent>
</Toolbar>
);
return (
<Card className="ct-card card-groups" isExpanded={isExpanded}>
<CardHeader actions={{ actions: tableToolbar, hasNoOffset: true }}
className="ct-card-expandable-header"
onExpand={() => setIsExpanded(!isExpanded)}
toggleButtonProps={{
id: 'groups-view-toggle',
'aria-label': _("Groups"),
'aria-expanded': isExpanded
}}>
<CardTitle className="pf-v6-l-flex pf-m-align-items-center pf-m-space-items-md">
{_("Groups")}
{(!isExpanded && !groups.length) && <HelperText> <HelperTextItem variant="indeterminate">{_("Loading...")}</HelperTextItem></HelperText>}
{(!isExpanded && filtered_groups.length > 0) && <>
{filtered_groups.slice(0, 3)
.map(group => {
const color = group.isAdmin ? "yellow" : "blue";
return (
<Label key={group.name} variant="filled" color={color}>
{group.name + ": " + (group.userlistPrimary.length + group.userlist.length)}
</Label>
);
})}
{filtered_groups.length > 3 && <Button key="more" className="group-more-btn" isInline variant='link' onClick={() => setIsExpanded(!isExpanded)}>
{cockpit.format(_("$0 more..."), filtered_groups.length - 3)}
</Button>}
</>}
</CardTitle>
</CardHeader>
<CardExpandableContent>
<ListingTable columns={columns}
id="groups-list"
rows={ filtered_groups.map(a => getGroupRow(a, accounts)) }
loading={ groups.length && accounts.length ? '' : _("Loading...") }
sortMethod={sortRows}
emptyComponent={<EmptyStatePanel title={_("No matching results")} icon={SearchIcon} action={_("Clear filter")}
onAction={() => setCurrentTextFilter('')} actionVariant="link" />}
variant="compact" sortBy={{ index: 2, direction: SortByDirection.asc }} />
</CardExpandableContent>
</Card>
);
};
const AccountsList = ({ accounts, current_user, groups, min_uid, max_uid, shells }) => {
const { options } = usePageLocation();
const currentTextFilter = typeof options.user == "string" ? options.user : '';
const setCurrentTextFilter = val => {
const newOptions = { ...cockpit.location.options };
if (val)
newOptions.user = val;
else
delete newOptions.user;
cockpit.location.replace(cockpit.location.path, newOptions);
};
const filtered_accounts = accounts.filter(account => {
if (currentTextFilter !== "" &&
(account.name.toLowerCase().indexOf(currentTextFilter.toLowerCase()) === -1) &&
(account.gecos.toLowerCase().indexOf(currentTextFilter.toLowerCase()) === -1) &&
(account.uid.toString().indexOf(currentTextFilter.toLowerCase()) === -1) &&
(!account.groups.find(group => group.toLowerCase().indexOf(currentTextFilter.toLowerCase()) !== -1)))
return false;
return true;
});
const columns = [
{ title: _("Username"), sortable: true },
{ title: _("Full name"), sortable: true },
{ title: _("ID"), sortable: true },
{ title: _("Last active"), sortable: true },
{ title: _("Group") },
{ title: "", sortable: false, props: { screenReaderText: _("Actions") } },
];
const sortRows = (rows, direction, idx) => {
const sortedRows = rows.sort((a, b) => {
const aitem = a.columns[idx];
const bitem = b.columns[idx];
const aname = a.columns[0];
const bname = b.columns[0];
if (aname.sortKey === current_user)
return direction === SortByDirection.asc ? -1 : 1;
if (bname.sortKey === current_user)
return direction === SortByDirection.asc ? 1 : -1;
if (idx === 3) {
if (aitem.sortKey === "logged in")
return -1;
if (bitem.sortKey === "logged in")
return 1;
if (aitem.sortKey === "never")
return 1;
if (bitem.sortKey === "never")
return -1;
return bitem.sortKey - aitem.sortKey;
}
if (idx === 2)
return bitem.title - aitem.title;
return ((typeof aitem == 'string' ? aitem : (aitem.sortKey || aitem.title)).localeCompare(typeof bitem == 'string' ? bitem : (bitem.sortKey || bitem.title)));
});
return direction === SortByDirection.asc ? sortedRows : sortedRows.reverse();
};
const tableToolbar = (
<Toolbar>
<ToolbarContent className="accounts-toolbar-header">
<ToolbarItem>
<SearchInput id="accounts-filter"
placeholder={_("Search for name, group or ID")}
value={currentTextFilter}
onChange={(_, val) => setCurrentTextFilter(val)}
onClear={() => setCurrentTextFilter('')} />
</ToolbarItem>
{ superuser.allowed &&
<>
<ToolbarItem variant="separator" />
<ToolbarItem align={{ md: "alignEnd" }}>
<Button id="accounts-create" onClick={() => account_create_dialog(accounts, min_uid, max_uid, shells)}>
{_("Create new account")}
</Button>
</ToolbarItem>
</>
}
</ToolbarContent>
</Toolbar>
);
return (
<Card isPlain className="ct-card">
<CardHeader actions={{ actions: tableToolbar }}>
<CardTitle component="h2">{_("Accounts")}</CardTitle>
</CardHeader>
<ListingTable columns={columns}
id="accounts-list"
isEmptyStateInTable={currentTextFilter !== "" && filtered_accounts.length !== accounts.length}
rows={ filtered_accounts.map(a => getAccountRow(a, current_user === a.name, groups)) }
loading={ accounts.length ? '' : _("Loading...") }
sortMethod={sortRows}
emptyComponent={<EmptyStatePanel title={_("No matching results")} icon={SearchIcon} action={_("Clear filter")}
onAction={() => setCurrentTextFilter('')} actionVariant="link" />}
variant="compact" sortBy={{ index: 0, direction: SortByDirection.asc }} />
</Card>
);
};
export const AccountsMain = ({ accountsInfo, current_user, groups, isGroupsExpanded, setIsGroupsExpanded, min_gid, max_gid, min_uid, max_uid, shells }) => {
const accounts = mapGroupsToAccount(accountsInfo, groups).filter(account => {
if ((account.uid < 1000 && account.uid !== 0) ||
account.shell.match(/^(\/usr)?\/sbin\/nologin/) ||
account.shell === '/bin/false')
return false;
return true;
});
return (
<Page id="accounts" className="pf-m-no-sidebar">
<PageSection hasBodyWrapper={false}>
<Stack hasGutter>
<GroupsList accounts={accounts} groups={groups} isExpanded={isGroupsExpanded} setIsExpanded={setIsGroupsExpanded} min_gid={min_gid} max_gid={max_gid} />
<AccountsList accounts={accounts} current_user={current_user} groups={groups} shells={shells} min_uid={min_uid} max_uid={max_uid} />
</Stack>
</PageSection>
</Page>
);
};