mirror-cockpit-project-cockpit/pkg/users/account-details.js

475 lines
23 KiB
JavaScript
Raw Normal View History

/*
* 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";
2023-05-15 23:31:19 -08:00
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.
//
2026-08-05 02:09:06 -08:00
// 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 (
<EmptyState headingLevel="h1" icon={ExclamationCircleIcon} titleText={_("Account not available or cannot be edited.")} variant={EmptyStateVariant.sm} id="account-failure">
<EmptyStateFooter>
<EmptyStateActions>
<Breadcrumb>
<BreadcrumbItem to="#/">{_("Back to accounts")}</BreadcrumbItem>
</Breadcrumb>
</EmptyStateActions>
</EmptyStateFooter>
</EmptyState>
);
}
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 (
<EmptyState headingLevel="h1" titleText={_("Loading...")} variant={EmptyStateVariant.sm}>
<EmptyStateFooter><Spinner size="xl" /></EmptyStateFooter>
</EmptyState>
);
}
if (!expiration)
return null;
const self_mod_allowed = (user == current_user || !!superuser.allowed);
2021-09-21 02:42:01 -08:00
let title_name = account.gecos;
if (title_name)
title_name = title_name.split(',')[0];
else
title_name = account.name;
2021-09-21 02:42:01 -08:00
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 && (
<>
<Button variant="secondary" onClick={() => logout_account()} id="account-logout"
isDisabled={!account.loggedIn || account.uid == 0 || user === current_user}>
{_("Terminate session")}
</Button>
{ "\n" }
<Button isDisabled={account.uid == 0 || user === current_user} variant="danger" id="account-delete"
onClick={() => delete_account_dialog(account)}>
{_("Delete")}
</Button>
</>
);
return (
<Page id="account" className="pf-m-no-sidebar">
<PageBreadcrumb hasBodyWrapper={false} stickyOnBreakpoint={{ default: "top" }}>
<Breadcrumb>
<BreadcrumbItem to="#/">{_("Accounts")}</BreadcrumbItem>
<BreadcrumbItem isActive>{title_name}</BreadcrumbItem>
</Breadcrumb>
</PageBreadcrumb>
<PageSection hasBodyWrapper={false}>
<Gallery hasGutter>
<Card isPlain className="account-details" id="account-details">
<CardHeader actions={{ actions }}>
<CardTitle id="account-title" component="h2">{title_name}</CardTitle>
</CardHeader>
<CardBody>
<Form isHorizontal onSubmit={apply_modal_dialog}>
<FormGroup fieldId="account-real-name" hasNoPaddingTop={!superuser.allowed} label={_("Full name")}>
{ superuser.allowed
? <>
<TextInput id="account-real-name"
isDisabled={committingRealName || account.uid == 0}
value={editedRealName !== null ? editedRealName : account.gecos}
onKeyDown={event => {
if (event.key == "Enter") {
event.target.blur();
}
}}
onChange={(_event, value) => setEditedRealName(value)}
validated={realNameError !== "" ? "error" : "default"}
onBlur={() => changeRealName()} />
<FormHelper fieldId="account-real-name" helperTextInvalid={realNameError} />
</>
: <output id="account-real-name">{account.gecos}</output>}
</FormGroup>
<FormGroup fieldId="account-user-name" hasNoPaddingTop label={_("User name")}>
<output id="account-user-name">{account.name}</output>
</FormGroup>
<AccountGroupsSelect key={account.name} loggedIn={account.loggedIn} name={account.name} groups={groups} />
<FormGroup fieldId="account-last-login" hasNoPaddingTop label={_("Last login")}>
<output id="account-last-login">{last_login}</output>
</FormGroup>
<FormGroup fieldId="account-locked" label={_("Options")} hasNoPaddingTop>
<Flex spaceItems={{ default: 'spaceItemsSm' }} alignItems={{ default: 'alignItemsCenter' }}>
<FlexItem spacer={{ default: 'spacerNone' }}>
<Checkbox id="account-locked"
ouiaSafe={disableLockedEdit}
isDisabled={!superuser.allowed || disableLockedEdit || user == current_user || isLocked == null}
isChecked={isLocked}
onChange={(_event, checked) => change_locked(checked)}
label={_("Disallow interactive password")} />
</FlexItem>
<Popover bodyContent={_("Other authentication methods are still available even when interactive password authentication is not allowed.")}
showClose={false}>
<HelpIcon />
</Popover>
<span id="account-expiration-text">
{expiration.account_text}
</span>
<Button onClick={() => account_expiration_dialog(account, expiration.account_date)}
isDisabled={!superuser.allowed}
variant="link"
isInline
id="account-expiration-button">
{_("edit")}
</Button>
</Flex>
</FormGroup>
{ self_mod_allowed &&
<FormGroup fieldId="account-set-password" label={_("Password")}>
<div className="account-column-one">
{ self_mod_allowed &&
<Button variant="secondary" id="account-set-password"
onClick={() => set_password_dialog(account, current_user)}>
{_("Set password")}
</Button>
}
{ "\n" }
{ superuser.allowed &&
<Button variant="secondary" id="password-reset-button"
onClick={() => reset_password_dialog(account)}>
{_("Force change")}
</Button>
}
</div>
<Flex flex={{ default: 'inlineFlex' }}>
<span id="password-expiration-text">
{expiration.password_text}
</span>
<Button onClick={() => password_expiration_dialog(account, expiration.password_days)}
isDisabled={!superuser.allowed}
variant="link"
isInline
id="password-expiration-button">
{_("edit")}
</Button>
</Flex>
</FormGroup>
}
{ account.home && <FormGroup fieldId="account-home-dir" hasNoPaddingTop label={_("Home directory")}>
<output id="account-home-dir">{account.home}</output>
</FormGroup> }
{ account.shell && <FormGroup fieldId="account-shell" hasNoPaddingTop label={_("Shell")}>
<Flex flex={{ default: 'inlineFlex' }}>
<output id="account-shell">{account.shell}</output>
<Button onClick={() => account_shell_dialog(account, shells)}
isDisabled={!superuser.allowed}
variant="link"
isInline
id="change-shell-button">
{_("change")}
</Button>
</Flex>
</FormGroup> }
</Form>
</CardBody>
</Card>
<AuthorizedKeys name={account.name} home={account.home} allow_mods={self_mod_allowed} />
<AccountLogs name={account.name} />
</Gallery>
</PageSection>
</Page>
);
}
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 (
<LabelGroup numLabels={10}>
{(selected || []).map((currentLabel, index) => {
return (
<Label key={currentLabel}
color={groups.find(group => group.name === currentLabel).isAdmin ? "yellow" : "blue"}
>
{currentLabel}
</Label>
);
})}
</LabelGroup>
);
};
return (
<FormGroup
fieldId="account-groups"
id="account-groups-form-group"
label={_("Groups")}
validated={history.length > 0 ? "warning" : "default"}
>
{superuser.allowed
? <MultiTypeaheadSelect
isScrollable
isDisabled={loading || modifyingGroup}
onAdd={val => 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)
? <HelperText className="pf-v6-c-form__helper-text">
<Flex>
{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>}
{history.length > 0 && <Button variant="link" id="group-undo-btn" isInline icon={<UndoIcon />} onClick={undoGroupChanges}>{_("Undo")}</Button>}
</Flex>
</HelperText>
: ''
}
</FormGroup>
);
};