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 React, { useState, useEffect, useRef, useMemo } from 'react';
7 :
8 : import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
9 : import { Checkbox } from "@patternfly/react-core/dist/esm/components/Checkbox/index.js";
10 : import { Card, CardBody, CardHeader, CardTitle } from '@patternfly/react-core/dist/esm/components/Card/index.js';
11 : import { EmptyState, EmptyStateActions, EmptyStateFooter, EmptyStateVariant } from "@patternfly/react-core/dist/esm/components/EmptyState/index.js";
12 : import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
13 : import { HelperText, HelperTextItem } from "@patternfly/react-core/dist/esm/components/HelperText/index.js";
14 : import { Label, LabelGroup } from "@patternfly/react-core/dist/esm/components/Label/index.js";
15 : import { Page, PageBreadcrumb, PageSection } from "@patternfly/react-core/dist/esm/components/Page/index.js";
16 : import { Gallery } from "@patternfly/react-core/dist/esm/layouts/Gallery/index.js";
17 : import { Breadcrumb, BreadcrumbItem } from "@patternfly/react-core/dist/esm/components/Breadcrumb/index.js";
18 : import { Form, FormGroup } from "@patternfly/react-core/dist/esm/components/Form/index.js";
19 : import { TextInput } from "@patternfly/react-core/dist/esm/components/TextInput/index.js";
20 : import { Spinner } from "@patternfly/react-core/dist/esm/components/Spinner/index.js";
21 : import { Popover } from "@patternfly/react-core/dist/esm/components/Popover/index.js";
22 : import { ExclamationCircleIcon, HelpIcon, UndoIcon } from '@patternfly/react-icons';
23 :
24 19 : import cockpit from 'cockpit';
25 : import { superuser } from "superuser";
26 : import * as timeformat from "timeformat";
27 : import { apply_modal_dialog } from "cockpit-components-dialog.jsx";
28 : import { MultiTypeaheadSelect } from "cockpit-components-multi-typeahead-select";
29 :
30 : import { FormHelper } from "cockpit-components-form-helper";
31 : import { show_unexpected_error } from "./dialog-utils.js";
32 : import { delete_account_dialog } from "./delete-account-dialog.js";
33 : import { account_expiration_dialog, password_expiration_dialog } from "./expiration-dialogs.js";
34 : import { account_shell_dialog } from "./shell-dialog.js";
35 : import { set_password_dialog, reset_password_dialog } from "./password-dialogs.js";
36 : import { AccountLogs } from "./account-logs-panel.jsx";
37 : import { AuthorizedKeys } from "./authorized-keys-panel.js";
38 : import { get_locked } from "./utils.js";
39 :
40 19 : const _ = cockpit.gettext;
41 :
42 11 : function get_expire(name) {
43 11 : function parse_expire(data) {
44 11 : let account_expiration = '';
45 11 : let account_date = null;
46 :
47 11 : let password_expiration = '';
48 11 : let password_days = null;
49 :
50 11 : data.split('\n').forEach(line => {
51 11 : const fields = line.split(': ');
52 11 : if (fields[0] && fields[0].indexOf("Password expires") === 0) {
53 3 : if (fields[1].indexOf("never") === 0) {
54 3 : password_expiration = _("Never expire password");
55 2 : } else if (fields[1].indexOf("password must be changed") === 0) {
56 4 : password_expiration = _("Password must be changed");
57 3 : } else {
58 10 : password_expiration = cockpit.format(_("Require password change on $0"), timeformat.date(new Date(fields[1])));
59 10 : }
60 11 : } else if (fields[0] && fields[0].indexOf("Account expires") === 0) {
61 11 : if (fields[1].indexOf("never") === 0) {
62 11 : account_expiration = _("Never expire account");
63 2 : } else {
64 2 : account_date = new Date(fields[1] + " 12:00:00 UTC");
65 2 : account_expiration = cockpit.format(_("Expire account on $0"), timeformat.date(new Date(fields[1])));
66 2 : }
67 11 : } else if (fields[0] && fields[0].indexOf("Maximum number of days between password change") === 0) {
68 11 : password_days = fields[1];
69 11 : }
70 11 : });
71 :
72 : // 99999 traditionally meant "never" but the modern value for
73 : // that is -1.
74 : //
75 : // Modern versions of "chage" will not treat 99999 any special
76 : // and will output bogus expiration dates somewhere in 2300.
77 : // But let's recognize both, since old accounts will still use
78 : // 99999, and /etc/login.def also still uses 99999 as the
79 : // default for new accounts.
80 : //
81 : // Passord expiration is being removed completely from
82 : // shadow-utils, and we probably should continue to recognize
83 : // 99999 as "never" until we stop supporting password expiry
84 : // entirely.
85 :
86 3 : if (parseInt(password_days) >= 99999 || parseInt(password_days) < 0) {
87 10 : password_days = null;
88 10 : password_expiration = _("Never expire password");
89 10 : }
90 :
91 11 : return {
92 11 : account_text: account_expiration,
93 11 : account_date,
94 11 : password_text: password_expiration,
95 11 : password_days
96 11 : };
97 11 : }
98 :
99 11 : return cockpit.spawn(["chage", "-l", name],
100 11 : { environ: ["LC_ALL=C"], err: "message", superuser: "try" })
101 0 : .catch(() => "")
102 11 : .then(parse_expire);
103 11 : }
104 :
105 0 : export function AccountNotFound() {
106 0 : return (
107 0 : <EmptyState headingLevel="h1" icon={ExclamationCircleIcon} titleText={_("Account not available or cannot be edited.")} variant={EmptyStateVariant.sm} id="account-failure">
108 0 : <EmptyStateFooter>
109 0 : <EmptyStateActions>
110 0 : <Breadcrumb>
111 0 : <BreadcrumbItem to="#/">{_("Back to accounts")}</BreadcrumbItem>
112 0 : </Breadcrumb>
113 0 : </EmptyStateActions>
114 0 : </EmptyStateFooter>
115 0 : </EmptyState>
116 : );
117 0 : }
118 :
119 11 : export function AccountDetails({ account, groups, isLoading, current_user, shells }) {
120 11 : const [expiration, setExpiration] = useState(null);
121 :
122 11 : const user = useMemo(() => account.name, [account.name]);
123 11 : const [isLocked, setIsLocked] = useState(account.isLocked);
124 :
125 11 : const [editedRealName, setEditedRealName] = useState(null);
126 11 : const [realNameError, setRealNameError] = useState("");
127 11 : const [comittingRealName, setCommittingRealName] = useState(false);
128 11 : const [disableLockedEdit, setDisableLockedEdit] = useState(false);
129 :
130 11 : useEffect(() => {
131 11 : get_expire(account.name).then(setExpiration);
132 11 : }, [account]);
133 :
134 : // Only update isLocked field if account changes value
135 11 : useEffect(() => {
136 11 : setIsLocked(account.isLocked);
137 11 : }, [account.isLocked]);
138 :
139 0 : function changeRealName() {
140 0 : setRealNameError("");
141 :
142 0 : if (editedRealName === null || editedRealName === undefined)
143 0 : return;
144 :
145 0 : if (editedRealName.includes(':')) {
146 0 : setRealNameError(_("The full name must not contain colons."));
147 0 : return;
148 0 : }
149 :
150 0 : setCommittingRealName(true);
151 :
152 0 : cockpit.spawn(["/usr/sbin/usermod", user, "--comment", editedRealName],
153 0 : { superuser: "try", err: "message" })
154 0 : .then(() => {
155 0 : setEditedRealName(null);
156 0 : setCommittingRealName(false);
157 0 : })
158 0 : .catch(error => {
159 0 : setEditedRealName(null);
160 0 : setCommittingRealName(false);
161 0 : show_unexpected_error(error);
162 0 : });
163 0 : }
164 :
165 1 : function change_locked(value, dont_retry_if_stuck) {
166 1 : setIsLocked(value);
167 1 : setDisableLockedEdit(true);
168 :
169 0 : cockpit.spawn(["/usr/sbin/usermod", user, value ? "--lock" : "--unlock"],
170 1 : { superuser: "require", err: "message" })
171 1 : .then(() => {
172 1 : get_locked(user)
173 1 : .then(locked => {
174 : /* if we care about what the lock state should be and it doesn't match, try to change again
175 : this is a workaround for different ways of handling a locked account
176 : https://github.com/cockpit-project/cockpit/issues/1216
177 : https://bugzilla.redhat.com/show_bug.cgi?id=853153
178 : */
179 0 : if (locked != value && !dont_retry_if_stuck) {
180 0 : console.log("Account locked state doesn't match desired value, trying again.");
181 : // only retry once to avoid uncontrolled recursion
182 0 : change_locked(value, true);
183 0 : }
184 1 : });
185 1 : })
186 0 : .catch(error => {
187 0 : show_unexpected_error(error);
188 0 : setIsLocked(!value);
189 0 : })
190 1 : .finally(() => {
191 1 : setDisableLockedEdit(false);
192 1 : });
193 1 : }
194 :
195 0 : function logout_account() {
196 0 : cockpit.spawn(["loginctl", "terminate-user", user],
197 0 : { superuser: "try", err: "message" })
198 0 : .then(() => {
199 0 : get_expire(user).then(setExpiration);
200 0 : })
201 0 : .catch(show_unexpected_error);
202 0 : }
203 :
204 2 : if (isLoading) {
205 2 : return (
206 2 : <EmptyState headingLevel="h1" titleText={_("Loading...")} variant={EmptyStateVariant.sm}>
207 2 : <EmptyStateFooter><Spinner size="xl" /></EmptyStateFooter>
208 2 : </EmptyState>
209 : );
210 2 : }
211 :
212 11 : if (!expiration)
213 11 : return null;
214 :
215 7 : const self_mod_allowed = (user == current_user || !!superuser.allowed);
216 :
217 11 : let title_name = account.gecos;
218 11 : if (title_name)
219 2 : title_name = title_name.split(',')[0];
220 : else
221 4 : title_name = account.name;
222 :
223 11 : let last_login;
224 11 : if (account.loggedIn)
225 2 : last_login = _("Logged in");
226 6 : else if (!account.lastLogin)
227 2 : last_login = _("Never");
228 : else
229 3 : last_login = timeformat.dateTime(new Date(account.lastLogin));
230 :
231 11 : const actions = superuser.allowed && (
232 9 : <>
233 0 : <Button variant="secondary" onClick={() => logout_account()} id="account-logout"
234 5 : isDisabled={!account.loggedIn || account.uid == 0 || user === current_user}>
235 9 : {_("Terminate session")}
236 9 : </Button>
237 9 : { "\n" }
238 9 : <Button isDisabled={account.uid == 0 || user === current_user} variant="danger" id="account-delete"
239 0 : onClick={() => delete_account_dialog(account)}>
240 9 : {_("Delete")}
241 9 : </Button>
242 9 : </>
243 : );
244 :
245 11 : return (
246 11 : <Page id="account" className="pf-m-no-sidebar">
247 11 : <PageBreadcrumb hasBodyWrapper={false} stickyOnBreakpoint={{ default: "top" }}>
248 11 : <Breadcrumb>
249 11 : <BreadcrumbItem to="#/">{_("Accounts")}</BreadcrumbItem>
250 11 : <BreadcrumbItem isActive>{title_name}</BreadcrumbItem>
251 11 : </Breadcrumb>
252 11 : </PageBreadcrumb>
253 11 : <PageSection hasBodyWrapper={false}>
254 11 : <Gallery hasGutter>
255 11 : <Card isPlain className="account-details" id="account-details">
256 11 : <CardHeader actions={{ actions }}>
257 11 : <CardTitle id="account-title" component="h2">{title_name}</CardTitle>
258 11 : </CardHeader>
259 11 : <CardBody>
260 11 : <Form isHorizontal onSubmit={apply_modal_dialog}>
261 11 : <FormGroup fieldId="account-real-name" hasNoPaddingTop={!superuser.allowed} label={_("Full name")}>
262 11 : { superuser.allowed
263 9 : ? <>
264 9 : <TextInput id="account-real-name"
265 9 : isDisabled={comittingRealName || account.uid == 0}
266 2 : value={editedRealName !== null ? editedRealName : account.gecos}
267 0 : onKeyDown={event => {
268 0 : if (event.key == "Enter") {
269 0 : event.target.blur();
270 0 : }
271 0 : }}
272 0 : onChange={(_event, value) => setEditedRealName(value)}
273 2 : validated={realNameError !== "" ? "error" : "default"}
274 0 : onBlur={() => changeRealName()} />
275 9 : <FormHelper fieldId="account-real-name" helperTextInvalid={realNameError} />
276 9 : </>
277 4 : : <output id="account-real-name">{account.gecos}</output>}
278 11 : </FormGroup>
279 11 : <FormGroup fieldId="account-user-name" hasNoPaddingTop label={_("User name")}>
280 11 : <output id="account-user-name">{account.name}</output>
281 11 : </FormGroup>
282 11 : <AccountGroupsSelect key={account.name} loggedIn={account.loggedIn} name={account.name} groups={groups} />
283 11 : <FormGroup fieldId="account-last-login" hasNoPaddingTop label={_("Last login")}>
284 11 : <output id="account-last-login">{last_login}</output>
285 11 : </FormGroup>
286 11 : <FormGroup fieldId="account-locked" label={_("Options")} hasNoPaddingTop>
287 11 : <Flex spaceItems={{ default: 'spaceItemsSm' }} alignItems={{ default: 'alignItemsCenter' }}>
288 11 : <FlexItem spacer={{ default: 'spacerNone' }}>
289 11 : <Checkbox id="account-locked"
290 11 : ouiaSafe={disableLockedEdit}
291 7 : isDisabled={!superuser.allowed || disableLockedEdit || user == current_user || isLocked == null}
292 11 : isChecked={isLocked}
293 1 : onChange={(_event, checked) => change_locked(checked)}
294 11 : label={_("Disallow interactive password")} />
295 11 : </FlexItem>
296 :
297 11 : <Popover bodyContent={_("Other authentication methods are still available even when interactive password authentication is not allowed.")}
298 11 : showClose={false}>
299 11 : <HelpIcon />
300 11 : </Popover>
301 11 : <span id="account-expiration-text">
302 11 : {expiration.account_text}
303 11 : </span>
304 0 : <Button onClick={() => account_expiration_dialog(account, expiration.account_date)}
305 11 : isDisabled={!superuser.allowed}
306 11 : variant="link"
307 11 : isInline
308 11 : id="account-expiration-button">
309 11 : {_("edit")}
310 11 : </Button>
311 11 : </Flex>
312 11 : </FormGroup>
313 11 : { self_mod_allowed &&
314 11 : <FormGroup fieldId="account-set-password" label={_("Password")}>
315 11 : <div className="account-column-one">
316 11 : { self_mod_allowed &&
317 11 : <Button variant="secondary" id="account-set-password"
318 1 : onClick={() => set_password_dialog(account, current_user)}>
319 11 : {_("Set password")}
320 11 : </Button>
321 : }
322 11 : { "\n" }
323 11 : { superuser.allowed &&
324 9 : <Button variant="secondary" id="password-reset-button"
325 1 : onClick={() => reset_password_dialog(account)}>
326 9 : {_("Force change")}
327 9 : </Button>
328 : }
329 11 : </div>
330 11 : <Flex flex={{ default: 'inlineFlex' }}>
331 11 : <span id="password-expiration-text">
332 11 : {expiration.password_text}
333 11 : </span>
334 0 : <Button onClick={() => password_expiration_dialog(account, expiration.password_days)}
335 11 : isDisabled={!superuser.allowed}
336 11 : variant="link"
337 11 : isInline
338 11 : id="password-expiration-button">
339 11 : {_("edit")}
340 11 : </Button>
341 11 : </Flex>
342 11 : </FormGroup>
343 : }
344 11 : { account.home && <FormGroup fieldId="account-home-dir" hasNoPaddingTop label={_("Home directory")}>
345 11 : <output id="account-home-dir">{account.home}</output>
346 11 : </FormGroup> }
347 11 : { account.shell && <FormGroup fieldId="account-shell" hasNoPaddingTop label={_("Shell")}>
348 11 : <Flex flex={{ default: 'inlineFlex' }}>
349 11 : <output id="account-shell">{account.shell}</output>
350 1 : <Button onClick={() => account_shell_dialog(account, shells)}
351 11 : isDisabled={!superuser.allowed}
352 11 : variant="link"
353 11 : isInline
354 11 : id="change-shell-button">
355 11 : {_("change")}
356 11 : </Button>
357 11 : </Flex>
358 11 : </FormGroup> }
359 11 : </Form>
360 11 : </CardBody>
361 11 : </Card>
362 11 : <AuthorizedKeys name={account.name} home={account.home} allow_mods={self_mod_allowed} />
363 11 : <AccountLogs name={account.name} />
364 11 : </Gallery>
365 11 : </PageSection>
366 11 : </Page>
367 : );
368 11 : }
369 :
370 11 : export const AccountGroupsSelect = ({ name, loggedIn, groups }) => {
371 11 : const [selected, setSelected] = useState();
372 11 : const [primaryGroupName, setPrimaryGroupName] = useState();
373 11 : const [loading, setLoading] = useState(true);
374 11 : const [modifyingGroup, setModifyingGroup] = useState(false);
375 11 : const [history, setHistory] = useState([]);
376 11 : const previousValue = useRef(null);
377 :
378 11 : useEffect(() => {
379 11 : const usedGroups = groups.filter(group => group.userlist.includes(name));
380 11 : const primaryGroup = groups.find(group => group.userlistPrimary.includes(name));
381 11 : const _primaryGroupName = primaryGroup?.name;
382 5 : const _selected = usedGroups.map(group => group.name);
383 11 : if (primaryGroup)
384 11 : _selected.push(_primaryGroupName);
385 :
386 11 : previousValue.current = _selected;
387 11 : setSelected(_selected);
388 11 : setLoading(false);
389 11 : setPrimaryGroupName(_primaryGroupName);
390 11 : }, [groups, setSelected, name, previousValue]);
391 :
392 1 : const undoGroupChanges = () => {
393 1 : const undoItem = history[history.length - 1];
394 1 : if (undoItem.type === 'added') {
395 1 : removeGroup(undoItem.name, true).then(() => setHistory(history.slice(0, -1)));
396 1 : } else if (undoItem.type === 'removed') {
397 0 : addGroup(undoItem.name, true).then(() => setHistory(history.slice(0, -1)));
398 1 : }
399 1 : };
400 :
401 1 : const removeGroup = (group, isUndo) => {
402 1 : if (!isUndo)
403 1 : setHistory([...history, { type: 'removed', name: group }]);
404 :
405 1 : setModifyingGroup(true);
406 1 : return cockpit.spawn(["gpasswd", "-d", name, group], { superuser: "require", err: "message" })
407 1 : .then(() => {
408 1 : setModifyingGroup(false);
409 1 : }, show_unexpected_error);
410 1 : };
411 :
412 1 : const addGroup = (group, isUndo) => {
413 1 : if (!isUndo)
414 1 : setHistory([...history, { type: 'added', name: group }]);
415 :
416 1 : setModifyingGroup(true);
417 1 : return cockpit.spawn(["gpasswd", "-a", name, group], { superuser: "require", err: "message" })
418 1 : .then(() => {
419 1 : setModifyingGroup(false);
420 1 : }, show_unexpected_error);
421 1 : };
422 :
423 2 : const chipGroupComponent = () => {
424 2 : return (
425 2 : <LabelGroup numLabels={10}>
426 2 : {(selected || []).map((currentLabel, index) => {
427 2 : return (
428 2 : <Label key={currentLabel}
429 0 : color={groups.find(group => group.name === currentLabel).isAdmin ? "yellow" : "blue"}
430 : >
431 2 : {currentLabel}
432 2 : </Label>
433 : );
434 2 : })}
435 2 : </LabelGroup>
436 : );
437 2 : };
438 :
439 11 : return (
440 11 : <FormGroup
441 11 : fieldId="account-groups"
442 11 : id="account-groups-form-group"
443 11 : label={_("Groups")}
444 2 : validated={history.length > 0 ? "warning" : "default"}
445 : >
446 11 : {superuser.allowed
447 9 : ? <MultiTypeaheadSelect
448 9 : isScrollable
449 9 : isDisabled={loading || modifyingGroup}
450 1 : onAdd={val => addGroup(val)}
451 0 : onRemove={val => removeGroup(val)}
452 9 : options={groups.map((option, index) => {
453 9 : return {
454 9 : value: option.name,
455 9 : content: option.name,
456 9 : color: option.isAdmin ? "yellow" : "blue",
457 9 : isDisabled: option.name == primaryGroupName,
458 9 : };
459 9 : })}
460 9 : selected={selected || []}
461 9 : toggleProps={{ id: "account-groups" }} />
462 4 : : chipGroupComponent()}
463 11 : {(history.length > 0)
464 2 : ? <HelperText className="pf-v6-c-form__helper-text">
465 2 : <Flex>
466 2 : {loggedIn && <HelperTextItem id="account-groups-helper" variant="warning">{_("The user must log out and log back in for the new configuration to take effect.")}</HelperTextItem>}
467 2 : {history.length > 0 && <Button variant="link" id="group-undo-btn" isInline icon={<UndoIcon />} onClick={undoGroupChanges}>{_("Undo")}</Button>}
468 2 : </Flex>
469 2 : </HelperText>
470 11 : : ''
471 : }
472 11 : </FormGroup>
473 : );
474 11 : };
|