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 : };
|