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 :
9 : import { Bullseye } from "@patternfly/react-core/dist/esm/layouts/Bullseye/index.js";
10 : import { Checkbox } from "@patternfly/react-core/dist/esm/components/Checkbox/index.js";
11 : import { Form, FormGroup } from "@patternfly/react-core/dist/esm/components/Form/index.js";
12 : import { FormSelect, FormSelectOption } from "@patternfly/react-core/dist/esm/components/FormSelect/index.js";
13 : import { TextInput } from "@patternfly/react-core/dist/esm/components/TextInput/index.js";
14 : import { Popover } from "@patternfly/react-core/dist/esm/components/Popover/index.js";
15 : import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
16 : import { Radio } from "@patternfly/react-core/dist/esm/components/Radio/index.js";
17 : import { Spinner } from "@patternfly/react-core/dist/esm/components/Spinner/index.js";
18 : import { has_errors, is_valid_char_name } from "./dialog-utils.js";
19 : import { passwd_change } from "./password-dialogs.js";
20 : import { FormHelper } from "cockpit-components-form-helper";
21 : import { password_quality, PasswordFormFields } from "cockpit-components-password.jsx";
22 : import { show_modal_dialog, apply_modal_dialog } from "cockpit-components-dialog.jsx";
23 : import { HelpIcon } from '@patternfly/react-icons';
24 :
25 15 : const _ = cockpit.gettext;
26 :
27 4 : function get_default_home_dir(base_home_dir, user_name) {
28 4 : return base_home_dir && user_name
29 4 : ? base_home_dir + '/' + user_name
30 0 : : "";
31 4 : }
32 :
33 4 : function AccountCreateBody({ state, errors, change, shells }) {
34 4 : const {
35 4 : real_name, user_name,
36 4 : locked, change_passw_force,
37 4 : shell,
38 4 : } = state;
39 :
40 : // We want to let user know that password and confirmation password do not match without them having to constantly click on "Create" to validate the form.
41 : // But we also don't want to show an error message while they are typing the confirmation password, only when they are finished typing it.
42 : // To solve the issue of telling that user is finished writing the confirmation password, let's do the following:
43 : // The dialog does not validate if passwords match until:
44 : // 1. they are at least the same length (which signals user has finished typing)
45 : // OR
46 : // 2. user submits the form (which also signals they are finished typing)
47 : // Once that happens, and passwords do not match, the confirm password is validated after each keystroke.
48 4 : let dynamic_password_confirm_error;
49 4 : if (state.password_confirm_dirty)
50 4 : dynamic_password_confirm_error = validate_password_confirm(state.password_confirm, state.password);
51 :
52 4 : return (
53 4 : <Form isHorizontal onSubmit={apply_modal_dialog}>
54 4 : <FormGroup label={_("Full name")}
55 4 : fieldId="accounts-create-real-name">
56 4 : <TextInput id="accounts-create-real-name"
57 0 : validated={(errors?.real_name) ? "error" : "default"}
58 4 : value={real_name} onChange={(_event, value) => change("real_name", value)} />
59 4 : <FormHelper fieldId="accounts-create-real-name" helperTextInvalid={errors?.real_name} />
60 4 : </FormGroup>
61 :
62 4 : <FormGroup label={_("User name")}
63 4 : fieldId="accounts-create-user-name">
64 4 : <TextInput id="accounts-create-user-name"
65 0 : validated={(errors?.user_name) ? "error" : "default"}
66 4 : value={user_name} onChange={(_event, value) => change("user_name", value)} />
67 4 : <FormHelper fieldId="accounts-create-user-name" helperTextInvalid={errors?.user_name} />
68 4 : </FormGroup>
69 :
70 4 : <FormGroup label={_("Home directory")}
71 4 : fieldId="accounts-create-user-home-dir">
72 4 : <TextInput id="accounts-create-user-home-dir"
73 1 : onChange={(_event, value) => change("home_dir", value)}
74 4 : placeholder={_("Path to directory")}
75 4 : value={state.home_dir} />
76 4 : <FormHelper fieldId="accounts-create-user-home-dir" helperTextInvalid={errors?.home_dir} />
77 4 : </FormGroup>
78 :
79 4 : <FormGroup label={_("Shell")}
80 4 : fieldId="accounts-create-user-shell">
81 4 : <FormSelect
82 4 : data-selected={shell}
83 4 : id="accounts-create-user-shell"
84 2 : onChange={(_, selection) => { change("shell", selection) }}
85 4 : value={shell}>
86 4 : { shells.map(shell_path => <FormSelectOption key={shell_path} value={shell_path} label={shell_path} />) }
87 4 : </FormSelect>
88 4 : </FormGroup>
89 :
90 4 : <FormGroup label={_("User ID")}
91 4 : fieldId="accounts-create-user-uid">
92 4 : <TextInput id="accounts-create-user-uid"
93 1 : onChange={(_event, value) => change("uid", value)}
94 4 : value={state.uid} />
95 4 : <FormHelper fieldId="accounts-create-user-uid" helperTextInvalid={errors?.uid} />
96 4 : </FormGroup>
97 :
98 4 : <FormGroup label={_("Authentication")} fieldId="accounts-create-locked" hasNoPaddingTop>
99 4 : <Radio id="account-use-password"
100 4 : label={_("Use password")}
101 0 : isChecked={!locked} onChange={(_, checked) => change("locked", !checked)}
102 4 : description={
103 4 : <Checkbox id="accounts-create-force-password-change"
104 4 : className="pf-v6-u-mb-xs"
105 4 : label={_("Require password change on first login")}
106 2 : isChecked={change_passw_force} onChange={(_event, checked) => change("change_passw_force", checked)} />
107 4 : } />
108 :
109 4 : <Flex spaceItems={{ default: 'spaceItemsSm' }} alignItems={{ default: 'alignItemsCenter' }}>
110 4 : <FlexItem spacer={{ default: 'spacerNone' }}>
111 4 : <Radio id="accounts-create-locked"
112 0 : isChecked={locked} onChange={(_, checked) => change("locked", checked)}
113 4 : label={_("Disallow password authentication")} />
114 4 : </FlexItem>
115 :
116 4 : <FlexItem spacer={{ default: 'spacerNone' }}>
117 4 : <Popover bodyContent={_("Other authentication methods are still available even when interactive password authentication is not allowed.")}
118 4 : showClose={false}>
119 4 : <HelpIcon />
120 4 : </Popover>
121 4 : </FlexItem>
122 4 : </Flex>
123 4 : </FormGroup>
124 :
125 4 : <PasswordFormFields password_label={_("Password")}
126 4 : password_confirm_label={_("Confirm password")}
127 4 : error_password={errors?.password}
128 4 : error_password_confirm={dynamic_password_confirm_error || errors?.password_confirm}
129 4 : idPrefix="accounts-create-password"
130 4 : change={change} />
131 4 : </Form>
132 : );
133 4 : }
134 :
135 4 : function validate_username(username, accounts) {
136 4 : if (!username)
137 0 : return _("No user name specified");
138 :
139 4 : for (let i = 0; i < username.length; i++) {
140 4 : if (!is_valid_char_name(username[i]))
141 0 : return _("The user name can only consist of letters from a-z, digits, dots, dashes and underscores.");
142 4 : }
143 :
144 4 : for (let k = 0; k < accounts.length; k++) {
145 4 : if (accounts[k].name == username)
146 0 : return _("This user name already exists");
147 4 : }
148 :
149 4 : return null;
150 4 : }
151 :
152 4 : function validate_real_name(real_name) {
153 4 : if (!real_name)
154 0 : return _("No real name specified");
155 :
156 4 : const real_name_chars = Array.from(real_name);
157 4 : if (real_name_chars.includes(':'))
158 0 : return _("The full name must not contain colons.");
159 4 : }
160 :
161 4 : function validate_uid(uid, accounts, min_uid, max_uid, change) {
162 4 : if (!uid)
163 1 : return undefined;
164 :
165 4 : const uid_number = Number(uid);
166 4 : if (!Number.isInteger(uid_number) || uid_number < 0)
167 1 : return _("User ID must be a positive integer");
168 :
169 4 : if (min_uid && uid_number < min_uid)
170 1 : return cockpit.format(_("User ID must not be lower than $0"), min_uid);
171 :
172 4 : if (max_uid && uid_number > max_uid)
173 1 : return cockpit.format(_("User ID must not be higher than $0"), max_uid);
174 :
175 4 : if (accounts.some(account => account.uid === uid_number)) {
176 1 : change("uid_exists", true);
177 1 : return _("User ID is already used by another user");
178 1 : }
179 4 : }
180 :
181 4 : function validate_home_dir(dir, directoryExpected) {
182 4 : return cockpit.spawn(["test", "!", directoryExpected ? "-d" : "-f", dir], { superuser: "require" });
183 4 : }
184 :
185 4 : function validate_password(password) {
186 4 : if (!password)
187 0 : return _("Empty password");
188 :
189 4 : return null;
190 4 : }
191 :
192 4 : function validate_password_confirm(password_confirm, password) {
193 4 : if (password_confirm !== password)
194 0 : return _("The passwords do not match");
195 :
196 4 : return null;
197 4 : }
198 :
199 0 : function suggest_username(realname) {
200 0 : function remove_diacritics(str) {
201 0 : const translate_table = {
202 0 : a: '[àáâãäå]',
203 0 : ae: 'æ',
204 0 : c: '[čç]',
205 0 : d: 'ď',
206 0 : e: '[èéêë]',
207 0 : i: '[íìïî]',
208 0 : l: '[ĺľ]',
209 0 : n: '[ňñ]',
210 0 : o: '[òóôõö]',
211 0 : oe: 'œ',
212 0 : r: '[ŕř]',
213 0 : s: 'š',
214 0 : t: 'ť',
215 0 : u: '[ùúůûűü]',
216 0 : y: '[ýÿ]',
217 0 : z: 'ž',
218 0 : };
219 0 : for (const i in translate_table)
220 0 : str = str.replace(new RegExp(translate_table[i], 'g'), i);
221 :
222 0 : for (let k = 0; k < str.length;) {
223 0 : if (!is_valid_char_name(str[k]))
224 0 : str = str.substring(0, k) + str.substring(k + 1);
225 : else
226 0 : k++;
227 0 : }
228 :
229 0 : return str;
230 0 : }
231 :
232 0 : let result = "";
233 0 : const name = realname.split(' ');
234 :
235 0 : if (name.length === 1)
236 0 : result = name[0].toLowerCase();
237 0 : else if (name.length > 1)
238 0 : result = name[0][0].toLowerCase() + name[name.length - 1].toLowerCase();
239 :
240 0 : return remove_diacritics(result);
241 0 : }
242 :
243 4 : export function account_create_dialog(accounts, min_uid, max_uid, shells) {
244 4 : let dlg = null;
245 :
246 4 : const used_ids = accounts.map(a => a.uid);
247 4 : const uid = Math.max(min_uid, Math.max(...used_ids.filter(id => id < max_uid)) + 1);
248 :
249 4 : const state = {
250 4 : dialogLoading: true,
251 4 : real_name: "",
252 4 : user_name: "",
253 4 : password: "",
254 4 : password_confirm: "",
255 4 : password_confirm_dirty: false,
256 4 : locked: false,
257 4 : confirm_weak: false,
258 4 : change_passw_force: false,
259 4 : base_home_dir: null,
260 4 : shell: "",
261 4 : uid,
262 4 : uid_exists: false,
263 4 : min_uid,
264 4 : max_uid,
265 4 : home_dir: "",
266 4 : home_dir_dirty: false,
267 4 : };
268 4 : let errors = { };
269 :
270 4 : let old_password = null;
271 4 : let user_name_dirty = false;
272 :
273 4 : function get_defaults() {
274 4 : return cockpit.spawn(["useradd", "-D"], { superuser: "require", err: "message" })
275 4 : .then(defaults => {
276 4 : let shell = "";
277 4 : let base_home_dir = null;
278 4 : defaults.split("\n").forEach(item => {
279 4 : if (item.indexOf("SHELL=") === 0) {
280 0 : shell = item.split("=")[1] || "/bin/bash";
281 4 : } else if (item.indexOf("HOME=") === 0) {
282 0 : base_home_dir = item.split("=")[1] || "";
283 4 : }
284 4 : });
285 4 : change("shell", shell);
286 4 : change("base_home_dir", base_home_dir);
287 4 : })
288 0 : .catch(e => console.warn("Could not get useradd defaults: ", e.message))
289 4 : .finally(() => change("dialogLoading", false));
290 4 : }
291 :
292 4 : function change(field, value) {
293 4 : state[field] = value;
294 4 : errors = { };
295 :
296 4 : if (field == "user_name") {
297 4 : user_name_dirty = true;
298 4 : if (!state.home_dir_dirty)
299 4 : state.home_dir = get_default_home_dir(state.base_home_dir, value);
300 4 : }
301 :
302 0 : if (!user_name_dirty && field == "real_name") {
303 0 : const suggested_username = suggest_username(state.real_name);
304 0 : state.user_name = suggested_username;
305 0 : if (!state.home_dir_dirty)
306 0 : state.home_dir = get_default_home_dir(state.base_home_dir, suggested_username);
307 0 : }
308 :
309 4 : if (state.password != old_password) {
310 4 : state.confirm_weak = false;
311 4 : old_password = state.password;
312 4 : }
313 :
314 4 : if (field == "change_passw_force")
315 2 : state.locked = false;
316 :
317 : // Once password and confirm password are the same length, validate them after each keystroke
318 4 : if (field == "password_confirm" && value.length >= state.password.length)
319 4 : state.password_confirm_dirty = true;
320 :
321 4 : if (field == "locked")
322 0 : state.change_passw_force = false;
323 :
324 4 : if (field == "uid")
325 1 : state.uid_exists = false;
326 :
327 1 : if (field == "home_dir") {
328 1 : state.home_dir_dirty = true;
329 1 : state.home_dir_exists = false;
330 1 : state.home_dir_is_file = false;
331 1 : }
332 :
333 4 : update();
334 4 : }
335 :
336 4 : function validate(force_weak, force_home, force_uid, real_name, user_name, password, password_confirm, uid, accounts, min_uid, max_uid, change) {
337 4 : const errs = { };
338 :
339 4 : errs.real_name = validate_real_name(real_name);
340 4 : errs.password = validate_password(password);
341 4 : errs.password_confirm = validate_password_confirm(password_confirm, password);
342 :
343 4 : if (password.length > 256)
344 0 : errs.password = _("Password is longer than 256 characters");
345 :
346 4 : errs.user_name = validate_username(user_name, accounts);
347 :
348 4 : const promises = [];
349 : // only evaluate password score if no other password error si present
350 4 : if (!errs.password) {
351 4 : promises.push(
352 4 : password_quality(password, force_weak)
353 0 : .catch(ex => {
354 0 : errs.password = (ex.message || ex.toString()).replaceAll("\n", " "); // not-covered: OS error
355 0 : })
356 4 : );
357 4 : }
358 4 : if (!force_uid)
359 4 : errs.uid = validate_uid(uid, accounts, min_uid, max_uid, change);
360 :
361 4 : promises.push(
362 4 : validate_home_dir(state.home_dir, false)
363 1 : .catch(() => {
364 1 : errs.home_dir = cockpit.format(_("$0 is an existing file"), state.home_dir);
365 1 : state.home_dir_is_file = true;
366 1 : })
367 4 : );
368 :
369 4 : if (!force_home) {
370 4 : promises.push(
371 4 : validate_home_dir(state.home_dir, true)
372 1 : .catch(() => {
373 1 : errs.home_dir = cockpit.format(_("The home directory $0 already exists. Its ownership will be changed to the new user."), state.home_dir);
374 1 : state.home_dir_exists = true;
375 1 : })
376 4 : );
377 4 : }
378 :
379 4 : return Promise.all(promises)
380 4 : .then(() => {
381 4 : errors = errs;
382 4 : return !has_errors(errs);
383 4 : });
384 4 : }
385 :
386 4 : function create(real_name, user_name, password, locked, uid, force_change, home_dir, force_home, force_uid) {
387 4 : const prog = ["useradd", "--create-home", "-s", state.shell];
388 4 : if (real_name) {
389 4 : prog.push('-c');
390 4 : prog.push(real_name);
391 4 : }
392 :
393 4 : if (uid) {
394 4 : prog.push('-u');
395 4 : prog.push(uid);
396 4 : }
397 :
398 4 : if (force_uid)
399 1 : prog.push('-o'); // Create user with non-unique user-specified UID, useful at certain use cases
400 :
401 4 : if (home_dir) {
402 4 : prog.push('-d');
403 4 : prog.push(home_dir);
404 4 : }
405 :
406 4 : prog.push(user_name);
407 4 : return cockpit.spawn(prog, { superuser: "require", err: "message" })
408 4 : .then(() => passwd_change(user_name, password))
409 4 : .then(() => {
410 4 : if (locked)
411 0 : return cockpit.spawn([
412 0 : "usermod",
413 0 : user_name,
414 0 : "--lock"
415 0 : ], { superuser: "require", err: "message" });
416 4 : if (force_change)
417 2 : return cockpit.spawn([
418 2 : "passwd",
419 2 : "-e",
420 2 : user_name
421 2 : ], { superuser: "require", err: "message" });
422 4 : })
423 4 : .then(() => {
424 1 : if (force_home) {
425 1 : return cockpit.spawn(["id", user_name, "--group"], { superuser: "require", err: "message" })
426 1 : .then(gid => cockpit.spawn(["chown", "-hR", `${user_name}:${gid.trim()}`, home_dir], { superuser: "require", err: "message" }));
427 1 : }
428 4 : });
429 4 : }
430 :
431 4 : 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) {
432 4 : return validate(force_weak, force_home, force_uid, real_name, user_name, password, password_confirm, uid, accounts, min_uid, max_uid, change).then(valid => {
433 4 : if (valid)
434 2 : return create(real_name, user_name, password, locked, uid, force_passwd_change, home_dir, force_home, force_uid);
435 2 : else {
436 0 : if (!errors.real_name && !errors.user_name && !errors.home_dir && !errors.uid && !errors.password_confirm && state.password.length <= 256)
437 0 : state.confirm_weak = true;
438 :
439 : // Once the form is submitted and passwords do not match, validate confirm password after each keystroke
440 2 : if (errors.password_confirm)
441 0 : state.password_confirm_dirty = true;
442 :
443 2 : update();
444 2 : return Promise.reject();
445 2 : }
446 4 : });
447 4 : }
448 :
449 4 : function update() {
450 4 : const props = {
451 4 : id: "accounts-create-dialog",
452 4 : title: _("Create new account"),
453 4 : };
454 4 : if (state.dialogLoading) {
455 4 : props.body = (
456 4 : <Bullseye>
457 4 : <Spinner />
458 4 : </Bullseye>
459 : );
460 4 : } else {
461 4 : props.body = <AccountCreateBody state={state} errors={errors} change={change} shells={shells} />;
462 4 : }
463 :
464 4 : const footer = {
465 4 : actions: [
466 4 : {
467 4 : caption: _("Create"),
468 4 : style: "primary",
469 4 : clicked: () => passwd_check(
470 4 : false, // force weak password was NOT clicked
471 4 : false, // force user with existing home directory was NOT clicked
472 4 : false, // force user with non-unique UID was NOT clicked
473 4 : state.real_name,
474 4 : state.user_name,
475 4 : state.password,
476 4 : state.password_confirm,
477 4 : state.locked,
478 4 : state.home_dir,
479 4 : state.change_passw_force,
480 4 : state.uid,
481 4 : accounts,
482 4 : state.min_uid,
483 4 : state.max_uid,
484 4 : change
485 4 : ),
486 4 : disabled: state.confirm_weak || state.uid_exists || state.home_dir_exists || state.home_dir_is_file
487 4 : }
488 4 : ]
489 4 : };
490 1 : if (state.home_dir_exists) {
491 1 : footer.actions.push(
492 1 : {
493 1 : caption: _("Create and change ownership of home directory"),
494 1 : style: "warning",
495 1 : clicked: () => passwd_check(
496 1 : false, // force weak password was NOT clicked
497 1 : true, // force user with existing home directory was WAS clicked
498 1 : false, // force user with non-unique UID was NOT clicked
499 1 : state.real_name,
500 1 : state.user_name,
501 1 : state.password,
502 1 : state.password_confirm,
503 1 : state.locked,
504 1 : state.home_dir,
505 1 : state.change_passw_force,
506 1 : state.uid,
507 1 : accounts,
508 1 : state.min_uid,
509 1 : state.max_uid,
510 1 : change
511 1 : ),
512 1 : }
513 1 : );
514 1 : }
515 0 : if (state.confirm_weak) {
516 0 : footer.actions.push(
517 0 : {
518 0 : caption: _("Create account with weak password"),
519 0 : style: "warning",
520 0 : clicked: () => passwd_check(
521 0 : true, // force weak password was WAS clicked
522 0 : false, // force user with existing home directory was NOT clicked
523 0 : false, // force user with non-unique UID was NOT clicked
524 0 : state.real_name,
525 0 : state.user_name,
526 0 : state.password,
527 0 : state.password_confirm,
528 0 : state.locked,
529 0 : state.home_dir,
530 0 : state.change_passw_force,
531 0 : state.uid,
532 0 : accounts,
533 0 : state.min_uid,
534 0 : state.max_uid,
535 0 : change
536 0 : ),
537 0 : }
538 0 : );
539 0 : }
540 1 : if (state.uid_exists) {
541 1 : footer.actions.push(
542 1 : {
543 1 : caption: _("Create account with non-unique UID"),
544 1 : style: "warning",
545 1 : clicked: () => passwd_check(
546 1 : false, // force weak password was NOT clicked
547 1 : false, // force user with existing home directory was NOT clicked
548 1 : true, // force user with non-unique UID was WAS clicked
549 1 : state.real_name,
550 1 : state.user_name,
551 1 : state.password,
552 1 : state.password_confirm,
553 1 : state.locked,
554 1 : state.home_dir,
555 1 : state.change_passw_force,
556 1 : state.uid,
557 1 : accounts,
558 1 : state.min_uid,
559 1 : state.max_uid,
560 1 : change
561 1 : ),
562 1 : }
563 1 : );
564 1 : }
565 :
566 4 : if (!dlg)
567 4 : dlg = show_modal_dialog(props, footer);
568 4 : else {
569 4 : dlg.setProps(props);
570 4 : dlg.setFooterProps(footer);
571 4 : }
572 4 : }
573 :
574 4 : update();
575 4 : get_defaults();
576 4 : }
|