/* * Copyright (C) 2020 Red Hat, Inc. * SPDX-License-Identifier: LGPL-2.1-or-later */ import React, { useState, useEffect, useRef, useMemo } from 'react'; import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js"; import { Checkbox } from "@patternfly/react-core/dist/esm/components/Checkbox/index.js"; import { Card, CardBody, CardHeader, CardTitle } from '@patternfly/react-core/dist/esm/components/Card/index.js'; import { EmptyState, EmptyStateActions, EmptyStateFooter, EmptyStateVariant } from "@patternfly/react-core/dist/esm/components/EmptyState/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, LabelGroup } from "@patternfly/react-core/dist/esm/components/Label/index.js"; import { Page, PageBreadcrumb, PageSection } from "@patternfly/react-core/dist/esm/components/Page/index.js"; import { Gallery } from "@patternfly/react-core/dist/esm/layouts/Gallery/index.js"; import { Breadcrumb, BreadcrumbItem } from "@patternfly/react-core/dist/esm/components/Breadcrumb/index.js"; import { Form, FormGroup } from "@patternfly/react-core/dist/esm/components/Form/index.js"; import { TextInput } from "@patternfly/react-core/dist/esm/components/TextInput/index.js"; import { Spinner } from "@patternfly/react-core/dist/esm/components/Spinner/index.js"; import { Popover } from "@patternfly/react-core/dist/esm/components/Popover/index.js"; import { ExclamationCircleIcon, HelpIcon, UndoIcon } from '@patternfly/react-icons'; import cockpit from 'cockpit'; import { superuser } from "superuser"; import * as timeformat from "timeformat"; import { apply_modal_dialog } from "cockpit-components-dialog.jsx"; import { MultiTypeaheadSelect } from "cockpit-components-multi-typeahead-select"; import { FormHelper } from "cockpit-components-form-helper"; import { show_unexpected_error } from "./dialog-utils.js"; import { delete_account_dialog } from "./delete-account-dialog.js"; import { account_expiration_dialog, password_expiration_dialog } from "./expiration-dialogs.js"; import { account_shell_dialog } from "./shell-dialog.js"; import { set_password_dialog, reset_password_dialog } from "./password-dialogs.js"; import { AccountLogs } from "./account-logs-panel.jsx"; import { AuthorizedKeys } from "./authorized-keys-panel.js"; import { get_locked } from "./utils.js"; const _ = cockpit.gettext; function get_expire(name) { function parse_expire(data) { let account_expiration = ''; let account_date = null; let password_expiration = ''; let password_days = null; data.split('\n').forEach(line => { const fields = line.split(': '); if (fields[0] && fields[0].indexOf("Password expires") === 0) { if (fields[1].indexOf("never") === 0) { password_expiration = _("Never expire password"); } else if (fields[1].indexOf("password must be changed") === 0) { password_expiration = _("Password must be changed"); } else { password_expiration = cockpit.format(_("Require password change on $0"), timeformat.date(new Date(fields[1]))); } } else if (fields[0] && fields[0].indexOf("Account expires") === 0) { if (fields[1].indexOf("never") === 0) { account_expiration = _("Never expire account"); } else { account_date = new Date(fields[1] + " 12:00:00 UTC"); account_expiration = cockpit.format(_("Expire account on $0"), timeformat.date(new Date(fields[1]))); } } else if (fields[0] && fields[0].indexOf("Maximum number of days between password change") === 0) { password_days = fields[1]; } }); // 99999 traditionally meant "never" but the modern value for // that is -1. // // Modern versions of "chage" will not treat 99999 any special // and will output bogus expiration dates somewhere in 2300. // But let's recognize both, since old accounts will still use // 99999, and /etc/login.def also still uses 99999 as the // default for new accounts. // // Password expiration is being removed completely from // shadow-utils, and we probably should continue to recognize // 99999 as "never" until we stop supporting password expiry // entirely. if (parseInt(password_days) >= 99999 || parseInt(password_days) < 0) { password_days = null; password_expiration = _("Never expire password"); } return { account_text: account_expiration, account_date, password_text: password_expiration, password_days }; } return cockpit.spawn(["chage", "-l", name], { environ: ["LC_ALL=C"], err: "message", superuser: "try" }) .catch(() => "") .then(parse_expire); } export function AccountNotFound() { return ( {_("Back to accounts")} ); } export function AccountDetails({ account, groups, isLoading, current_user, shells }) { const [expiration, setExpiration] = useState(null); const user = useMemo(() => account.name, [account.name]); const [isLocked, setIsLocked] = useState(account.isLocked); const [editedRealName, setEditedRealName] = useState(null); const [realNameError, setRealNameError] = useState(""); const [committingRealName, setCommittingRealName] = useState(false); const [disableLockedEdit, setDisableLockedEdit] = useState(false); useEffect(() => { get_expire(account.name).then(setExpiration); }, [account]); // Only update isLocked field if account changes value useEffect(() => { setIsLocked(account.isLocked); }, [account.isLocked]); function changeRealName() { setRealNameError(""); if (editedRealName === null || editedRealName === undefined) return; if (editedRealName.includes(':')) { setRealNameError(_("The full name must not contain colons.")); return; } setCommittingRealName(true); cockpit.spawn(["/usr/sbin/usermod", user, "--comment", editedRealName], { superuser: "try", err: "message" }) .then(() => { setEditedRealName(null); setCommittingRealName(false); }) .catch(error => { setEditedRealName(null); setCommittingRealName(false); show_unexpected_error(error); }); } function change_locked(value, dont_retry_if_stuck) { setIsLocked(value); setDisableLockedEdit(true); cockpit.spawn(["/usr/sbin/usermod", user, value ? "--lock" : "--unlock"], { superuser: "require", err: "message" }) .then(() => { get_locked(user) .then(locked => { /* if we care about what the lock state should be and it doesn't match, try to change again this is a workaround for different ways of handling a locked account https://github.com/cockpit-project/cockpit/issues/1216 https://bugzilla.redhat.com/show_bug.cgi?id=853153 */ if (locked != value && !dont_retry_if_stuck) { console.log("Account locked state doesn't match desired value, trying again."); // only retry once to avoid uncontrolled recursion change_locked(value, true); } }); }) .catch(error => { show_unexpected_error(error); setIsLocked(!value); }) .finally(() => { setDisableLockedEdit(false); }); } function logout_account() { cockpit.spawn(["loginctl", "terminate-user", user], { superuser: "try", err: "message" }) .then(() => { get_expire(user).then(setExpiration); }) .catch(show_unexpected_error); } if (isLoading) { return ( ); } if (!expiration) return null; const self_mod_allowed = (user == current_user || !!superuser.allowed); let title_name = account.gecos; if (title_name) title_name = title_name.split(',')[0]; else title_name = account.name; let last_login; if (account.loggedIn) last_login = _("Logged in"); else if (!account.lastLogin) last_login = _("Never"); else last_login = timeformat.dateTime(new Date(account.lastLogin)); const actions = superuser.allowed && ( <> { "\n" } ); return ( {_("Accounts")} {title_name} {title_name}
{ superuser.allowed ? <> { if (event.key == "Enter") { event.target.blur(); } }} onChange={(_event, value) => setEditedRealName(value)} validated={realNameError !== "" ? "error" : "default"} onBlur={() => changeRealName()} /> : {account.gecos}} {account.name} {last_login} change_locked(checked)} label={_("Disallow interactive password")} /> {expiration.account_text} { self_mod_allowed &&
{ self_mod_allowed && } { "\n" } { superuser.allowed && }
{expiration.password_text}
} { account.home && {account.home} } { account.shell && {account.shell} }
); } export const AccountGroupsSelect = ({ name, loggedIn, groups }) => { const [selected, setSelected] = useState(); const [primaryGroupName, setPrimaryGroupName] = useState(); const [loading, setLoading] = useState(true); const [modifyingGroup, setModifyingGroup] = useState(false); const [history, setHistory] = useState([]); const previousValue = useRef(null); useEffect(() => { const usedGroups = groups.filter(group => group.userlist.includes(name)); const primaryGroup = groups.find(group => group.userlistPrimary.includes(name)); const _primaryGroupName = primaryGroup?.name; const _selected = usedGroups.map(group => group.name); if (primaryGroup) _selected.push(_primaryGroupName); previousValue.current = _selected; setSelected(_selected); setLoading(false); setPrimaryGroupName(_primaryGroupName); }, [groups, setSelected, name, previousValue]); const undoGroupChanges = () => { const undoItem = history[history.length - 1]; if (undoItem.type === 'added') { removeGroup(undoItem.name, true).then(() => setHistory(history.slice(0, -1))); } else if (undoItem.type === 'removed') { addGroup(undoItem.name, true).then(() => setHistory(history.slice(0, -1))); } }; const removeGroup = (group, isUndo) => { if (!isUndo) setHistory([...history, { type: 'removed', name: group }]); setModifyingGroup(true); return cockpit.spawn(["gpasswd", "-d", name, group], { superuser: "require", err: "message" }) .then(() => { setModifyingGroup(false); }, show_unexpected_error); }; const addGroup = (group, isUndo) => { if (!isUndo) setHistory([...history, { type: 'added', name: group }]); setModifyingGroup(true); return cockpit.spawn(["gpasswd", "-a", name, group], { superuser: "require", err: "message" }) .then(() => { setModifyingGroup(false); }, show_unexpected_error); }; const chipGroupComponent = () => { return ( {(selected || []).map((currentLabel, index) => { return ( ); })} ); }; return ( 0 ? "warning" : "default"} > {superuser.allowed ? addGroup(val)} onRemove={val => removeGroup(val)} options={groups.map((option, index) => { return { value: option.name, content: option.name, color: option.isAdmin ? "yellow" : "blue", isDisabled: option.name == primaryGroupName, }; })} selected={selected || []} toggleProps={{ id: "account-groups" }} /> : chipGroupComponent()} {(history.length > 0) ? {loggedIn && {_("The user must log out and log back in for the new configuration to take effect.")}} {history.length > 0 && } : '' } ); };