* Copyright (C) 2020 Red Hat, Inc.
* SPDX-License-Identifier: LGPL-2.1-or-later
*/
import cockpit from 'cockpit';
import React from 'react';
import { Bullseye } from "@patternfly/react-core/dist/esm/layouts/Bullseye/index.js";
import { Checkbox } from "@patternfly/react-core/dist/esm/components/Checkbox/index.js";
import { Form, FormGroup } from "@patternfly/react-core/dist/esm/components/Form/index.js";
import { FormSelect, FormSelectOption } from "@patternfly/react-core/dist/esm/components/FormSelect/index.js";
import { TextInput } from "@patternfly/react-core/dist/esm/components/TextInput/index.js";
import { Popover } from "@patternfly/react-core/dist/esm/components/Popover/index.js";
import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
import { Radio } from "@patternfly/react-core/dist/esm/components/Radio/index.js";
import { Spinner } from "@patternfly/react-core/dist/esm/components/Spinner/index.js";
import { has_errors, is_valid_char_name } from "./dialog-utils.js";
import { passwd_change } from "./password-dialogs.js";
import { FormHelper } from "cockpit-components-form-helper";
import { password_quality, PasswordFormFields } from "cockpit-components-password.jsx";
import { show_modal_dialog, apply_modal_dialog } from "cockpit-components-dialog.jsx";
import { HelpIcon } from '@patternfly/react-icons';
const _ = cockpit.gettext;
function get_default_home_dir(base_home_dir, user_name) {
return base_home_dir && user_name
? base_home_dir + '/' + user_name
: "";
}
function AccountCreateBody({ state, errors, change, shells }) {
const {
real_name, user_name,
locked, change_passw_force,
shell,
} = state;
let dynamic_password_confirm_error;
if (state.password_confirm_dirty)
dynamic_password_confirm_error = validate_password_confirm(state.password_confirm, state.password);
return (
<Form isHorizontal onSubmit={apply_modal_dialog}>
<FormGroup label={_("Full name")}
fieldId="accounts-create-real-name">
<TextInput id="accounts-create-real-name"
validated={(errors?.real_name) ? "error" : "default"}
value={real_name} onChange={(_event, value) => change("real_name", value)} />
<FormHelper fieldId="accounts-create-real-name" helperTextInvalid={errors?.real_name} />
</FormGroup>
<FormGroup label={_("User name")}
fieldId="accounts-create-user-name">
<TextInput id="accounts-create-user-name"
validated={(errors?.user_name) ? "error" : "default"}
value={user_name} onChange={(_event, value) => change("user_name", value)} />
<FormHelper fieldId="accounts-create-user-name" helperTextInvalid={errors?.user_name} />
</FormGroup>
<FormGroup label={_("Home directory")}
fieldId="accounts-create-user-home-dir">
<TextInput id="accounts-create-user-home-dir"
onChange={(_event, value) => change("home_dir", value)}
placeholder={_("Path to directory")}
value={state.home_dir} />
<FormHelper fieldId="accounts-create-user-home-dir" helperTextInvalid={errors?.home_dir} />
</FormGroup>
<FormGroup label={_("Shell")}
fieldId="accounts-create-user-shell">
<FormSelect
data-selected={shell}
id="accounts-create-user-shell"
onChange={(_, selection) => { change("shell", selection) }}
value={shell}>
{ shells.map(shell_path => <FormSelectOption key={shell_path} value={shell_path} label={shell_path} />) }
</FormSelect>
</FormGroup>
<FormGroup label={_("User ID")}
fieldId="accounts-create-user-uid">
<TextInput id="accounts-create-user-uid"
onChange={(_event, value) => change("uid", value)}
value={state.uid} />
<FormHelper fieldId="accounts-create-user-uid" helperTextInvalid={errors?.uid} />
</FormGroup>
<FormGroup label={_("Authentication")} fieldId="accounts-create-locked" hasNoPaddingTop>
<Radio id="account-use-password"
label={_("Use password")}
isChecked={!locked} onChange={(_, checked) => change("locked", !checked)}
description={
<Checkbox id="accounts-create-force-password-change"
className="pf-v6-u-mb-xs"
label={_("Require password change on first login")}
isChecked={change_passw_force} onChange={(_event, checked) => change("change_passw_force", checked)} />
} />
<Flex spaceItems={{ default: 'spaceItemsSm' }} alignItems={{ default: 'alignItemsCenter' }}>
<FlexItem spacer={{ default: 'spacerNone' }}>
<Radio id="accounts-create-locked"
isChecked={locked} onChange={(_, checked) => change("locked", checked)}
label={_("Disallow password authentication")} />
</FlexItem>
<FlexItem spacer={{ default: 'spacerNone' }}>
<Popover bodyContent={_("Other authentication methods are still available even when interactive password authentication is not allowed.")}
showClose={false}>
<HelpIcon />
</Popover>
</FlexItem>
</Flex>
</FormGroup>
<PasswordFormFields password_label={_("Password")}
password_confirm_label={_("Confirm password")}
error_password={errors?.password}
error_password_confirm={dynamic_password_confirm_error || errors?.password_confirm}
idPrefix="accounts-create-password"
change={change} />
</Form>
);
}
function validate_username(username, accounts) {
if (!username)
return _("No user name specified");
for (let i = 0; i < username.length; i++) {
if (!is_valid_char_name(username[i]))
return _("The user name can only consist of letters from a-z, digits, dots, dashes and underscores.");
}
for (let k = 0; k < accounts.length; k++) {
if (accounts[k].name === username)
return _("This user name already exists");
}
return null;
}
function validate_real_name(real_name) {
if (!real_name)
return _("No real name specified");
const real_name_chars = Array.from(real_name);
if (real_name_chars.includes(':'))
return _("The full name must not contain colons.");
}
function validate_uid(uid, accounts, min_uid, max_uid, change) {
if (!uid)
return undefined;
const uid_number = Number(uid);
if (!Number.isInteger(uid_number) || uid_number < 0)
return _("User ID must be a positive integer");
if (min_uid && uid_number < min_uid)
return cockpit.format(_("User ID must not be lower than $0"), min_uid);
if (max_uid && uid_number > max_uid)
return cockpit.format(_("User ID must not be higher than $0"), max_uid);
if (accounts.some(account => account.uid === uid_number)) {
change("uid_exists", true);
return _("User ID is already used by another user");
}
}
function validate_home_dir(dir, directoryExpected) {
return cockpit.spawn(["test", "!", directoryExpected ? "-d" : "-f", dir], { superuser: "require" });
}
function validate_password(password) {
if (!password)
return _("Empty password");
return null;
}
function validate_password_confirm(password_confirm, password) {
if (password_confirm !== password)
return _("The passwords do not match");
return null;
}
function suggest_username(realname) {
function remove_diacritics(str) {
const translate_table = {
a: '[àáâãäå]',
ae: 'æ',
c: '[čç]',
d: 'ď',
e: '[èéêë]',
i: '[íìïî]',
l: '[ĺľ]',
n: '[ňñ]',
o: '[òóôõö]',
oe: 'œ',
r: '[ŕř]',
s: 'š',
t: 'ť',
u: '[ùúůûűü]',
y: '[ýÿ]',
z: 'ž',
};
for (const i in translate_table)
str = str.replace(new RegExp(translate_table[i], 'g'), i);
for (let k = 0; k < str.length;) {
if (!is_valid_char_name(str[k]))
str = str.substring(0, k) + str.substring(k + 1);
else
k++;
}
return str;
}
let result = "";
const name = realname.split(' ');
if (name.length === 1)
result = name[0].toLowerCase();
else if (name.length > 1)
result = name[0][0].toLowerCase() + name[name.length - 1].toLowerCase();
return remove_diacritics(result);
}
export function account_create_dialog(accounts, min_uid, max_uid, shells) {
let dlg = null;
const used_ids = accounts.map(a => a.uid);
const uid = Math.max(min_uid, Math.max(...used_ids.filter(id => id < max_uid)) + 1);
const state = {
dialogLoading: true,
real_name: "",
user_name: "",
password: "",
password_confirm: "",
password_confirm_dirty: false,
locked: false,
confirm_weak: false,
change_passw_force: false,
base_home_dir: null,
shell: "",
uid,
uid_exists: false,
min_uid,
max_uid,
home_dir: "",
home_dir_dirty: false,
};
let errors = { };
let old_password = null;
let user_name_dirty = false;
function get_defaults() {
return cockpit.spawn(["useradd", "-D"], { superuser: "require", err: "message" })
.then(defaults => {
let shell = "";
let base_home_dir = null;
defaults.split("\n").forEach(item => {
if (item.indexOf("SHELL=") === 0) {
shell = item.split("=")[1] || "/bin/bash";
} else if (item.indexOf("HOME=") === 0) {
base_home_dir = item.split("=")[1] || "";
}
});
change("shell", shell);
change("base_home_dir", base_home_dir);
})
.catch(e => console.warn("Could not get useradd defaults: ", e.message))
.finally(() => change("dialogLoading", false));
}
function change(field, value) {
state[field] = value;
errors = { };
if (field === "user_name") {
user_name_dirty = true;
if (!state.home_dir_dirty)
state.home_dir = get_default_home_dir(state.base_home_dir, value);
}
if (!user_name_dirty && field === "real_name") {
const suggested_username = suggest_username(state.real_name);
state.user_name = suggested_username;
if (!state.home_dir_dirty)
state.home_dir = get_default_home_dir(state.base_home_dir, suggested_username);
}
if (state.password !== old_password) {
state.confirm_weak = false;
old_password = state.password;
}
if (field === "change_passw_force")
state.locked = false;
if (field === "password_confirm" && value.length >= state.password.length)
state.password_confirm_dirty = true;
if (field === "locked")
state.change_passw_force = false;
if (field === "uid")
state.uid_exists = false;
if (field === "home_dir") {
state.home_dir_dirty = true;
state.home_dir_exists = false;
state.home_dir_is_file = false;
}
update();
}
function validate(force_weak, force_home, force_uid, real_name, user_name, password, password_confirm, uid, accounts, min_uid, max_uid, change) {
const errs = { };
errs.real_name = validate_real_name(real_name);
errs.password = validate_password(password);
errs.password_confirm = validate_password_confirm(password_confirm, password);
if (password.length > 256)
errs.password = _("Password is longer than 256 characters");
errs.user_name = validate_username(user_name, accounts);
const promises = [];
if (!errs.password) {
promises.push(
password_quality(password, force_weak)
.catch(ex => {
errs.password = (ex.message || ex.toString()).replaceAll("\n", " ");
})
);
}
if (!force_uid)
errs.uid = validate_uid(uid, accounts, min_uid, max_uid, change);
promises.push(
validate_home_dir(state.home_dir, false)
.catch(() => {
errs.home_dir = cockpit.format(_("$0 is an existing file"), state.home_dir);
state.home_dir_is_file = true;
})
);
if (!force_home) {
promises.push(
validate_home_dir(state.home_dir, true)
.catch(() => {
errs.home_dir = cockpit.format(_("The home directory $0 already exists. Its ownership will be changed to the new user."), state.home_dir);
state.home_dir_exists = true;
})
);
}
return Promise.all(promises)
.then(() => {
errors = errs;
return !has_errors(errs);
});
}
function create(real_name, user_name, password, locked, uid, force_change, home_dir, force_home, force_uid) {
const prog = ["useradd", "--create-home", "-s", state.shell];
if (real_name) {
prog.push('-c');
prog.push(real_name);
}
if (uid) {
prog.push('-u');
prog.push(uid);
}
if (force_uid)
prog.push('-o');
if (home_dir) {
prog.push('-d');
prog.push(home_dir);
}
prog.push(user_name);
return cockpit.spawn(prog, { superuser: "require", err: "message" })
.then(() => passwd_change(user_name, password))
.then(() => {
if (locked)
return cockpit.spawn([
"usermod",
user_name,
"--lock"
], { superuser: "require", err: "message" });
if (force_change)
return cockpit.spawn([
"passwd",
"-e",
user_name
], { superuser: "require", err: "message" });
})
.then(() => {
if (force_home) {
return cockpit.spawn(["id", user_name, "--group"], { superuser: "require", err: "message" })
.then(gid => cockpit.spawn(["chown", "-hR", `${user_name}:${gid.trim()}`, home_dir], { superuser: "require", err: "message" }));
}
});
}
function passwd_check(force_weak, force_home, force_uid, real_name, user_name, password, password_confirm, locked, home_dir, force_passwd_change, uid, accounts, min_uid, max_uid, change) {
return validate(force_weak, force_home, force_uid, real_name, user_name, password, password_confirm, uid, accounts, min_uid, max_uid, change).then(valid => {
if (valid)
return create(real_name, user_name, password, locked, uid, force_passwd_change, home_dir, force_home, force_uid);
else {
if (!errors.real_name && !errors.user_name && !errors.home_dir && !errors.uid && !errors.password_confirm && state.password.length <= 256)
state.confirm_weak = true;
if (errors.password_confirm)
state.password_confirm_dirty = true;
update();
return Promise.reject();
}
});
}
function update() {
const props = {
id: "accounts-create-dialog",
title: _("Create new account"),
};
if (state.dialogLoading) {
props.body = (
<Bullseye>
<Spinner />
</Bullseye>
);
} else {
props.body = <AccountCreateBody state={state} errors={errors} change={change} shells={shells} />;
}
const footer = {
actions: [
{
caption: _("Create"),
style: "primary",
clicked: () => passwd_check(
false,
false,
false,
state.real_name,
state.user_name,
state.password,
state.password_confirm,
state.locked,
state.home_dir,
state.change_passw_force,
state.uid,
accounts,
state.min_uid,
state.max_uid,
change
),
disabled: state.confirm_weak || state.uid_exists || state.home_dir_exists || state.home_dir_is_file
}
]
};
if (state.home_dir_exists) {
footer.actions.push(
{
caption: _("Create and change ownership of home directory"),
style: "warning",
clicked: () => passwd_check(
false,
true,
false,
state.real_name,
state.user_name,
state.password,
state.password_confirm,
state.locked,
state.home_dir,
state.change_passw_force,
state.uid,
accounts,
state.min_uid,
state.max_uid,
change
),
}
);
}
if (state.confirm_weak) {
footer.actions.push(
{
caption: _("Create account with weak password"),
style: "warning",
clicked: () => passwd_check(
true,
false,
false,
state.real_name,
state.user_name,
state.password,
state.password_confirm,
state.locked,
state.home_dir,
state.change_passw_force,
state.uid,
accounts,
state.min_uid,
state.max_uid,
change
),
}
);
}
if (state.uid_exists) {
footer.actions.push(
{
caption: _("Create account with non-unique UID"),
style: "warning",
clicked: () => passwd_check(
false,
false,
true,
state.real_name,
state.user_name,
state.password,
state.password_confirm,
state.locked,
state.home_dir,
state.change_passw_force,
state.uid,
accounts,
state.min_uid,
state.max_uid,
change
),
}
);
}
if (!dlg)
dlg = show_modal_dialog(props, footer);
else {
dlg.setProps(props);
dlg.setFooterProps(footer);
}
}
update();
get_defaults();
}