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